idek anymore just change the whole thing
This commit is contained in:
@@ -174,3 +174,5 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
*.boris
|
||||||
|
*.json
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
import cv2
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import numpy as np
|
||||||
|
from ultralytics import YOLO
|
||||||
|
from multiprocessing import current_process
|
||||||
|
|
||||||
|
JOINT_NAMES = [
|
||||||
|
"nose", "l_eye", "r_eye", "l_ear", "r_ear", "l_shld", "r_shld",
|
||||||
|
"l_elbw", "r_elbw", "l_wri", "r_wri", "l_hip", "r_hip",
|
||||||
|
"l_knee", "r_knee", "l_ankl", "r_ankl"
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_best_infant_match(results, w, h, prev_track_id):
|
||||||
|
"""
|
||||||
|
Identifies the most likely infant based on visibility,
|
||||||
|
centrality, and tracking ID consistency.
|
||||||
|
"""
|
||||||
|
if not results[0].boxes or results[0].boxes.id is None:
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
ids = results[0].boxes.id.int().cpu().tolist()
|
||||||
|
kpts = results[0].keypoints.xy.cpu().numpy()
|
||||||
|
confs = results[0].keypoints.conf.cpu().numpy()
|
||||||
|
|
||||||
|
best_idx, best_score = -1, -1
|
||||||
|
|
||||||
|
for i, k in enumerate(kpts):
|
||||||
|
# visibility score
|
||||||
|
vis = np.sum(confs[i] > 0.5)
|
||||||
|
valid = k[confs[i] > 0.5]
|
||||||
|
|
||||||
|
# distance from center score
|
||||||
|
dist = np.linalg.norm(np.mean(valid, axis=0) - [w/2, h/2]) if len(valid) > 0 else 1000
|
||||||
|
|
||||||
|
# calculate total score
|
||||||
|
score = (vis * 10) - (dist * 0.1) + (50 if ids[i] == prev_track_id else 0)
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score, best_idx = score, i
|
||||||
|
|
||||||
|
if best_idx == -1:
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
return ids[best_idx], kpts[best_idx], confs[best_idx], best_idx
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def run_pose_analysis(video_path, progress_queue, result_queue, config):
|
||||||
|
"""Worker task executed in a separate Process."""
|
||||||
|
p_name = current_process().name
|
||||||
|
pose_cache = video_path.rsplit('.', 1)[0] + "_pose_raw.csv"
|
||||||
|
print(f"[{p_name}] Starting analysis on: {video_path}")
|
||||||
|
csv_storage_data = []
|
||||||
|
inference_performed = False
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
|
||||||
|
if config.get("use_cache") and os.path.exists(pose_cache):
|
||||||
|
print(f"[{p_name}] Cache checkmark active. Loading: {pose_cache}")
|
||||||
|
try:
|
||||||
|
with open(pose_cache, 'r') as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
next(reader) # Skip header
|
||||||
|
for row in reader:
|
||||||
|
# Flattened (51,) back to (17, 3)
|
||||||
|
full_frame = np.array([float(x) for x in row]).reshape(17, 3)
|
||||||
|
csv_storage_data.append(full_frame)
|
||||||
|
|
||||||
|
progress_queue.put(100)
|
||||||
|
result_queue.put({
|
||||||
|
"raw_kpts": np.array(csv_storage_data),
|
||||||
|
"fps": fps,
|
||||||
|
"total_frames": len(csv_storage_data),
|
||||||
|
"dims": (width, height),
|
||||||
|
"status": "loaded_from_cache"
|
||||||
|
})
|
||||||
|
return # Exit early, no inference or saving needed
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{p_name}] Cache read failed, falling back to inference: {e}")
|
||||||
|
csv_storage_data = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
inference_performed = True
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||||
|
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
|
||||||
|
model_map = {
|
||||||
|
"YOLO8n-Pose": "yolov8n-pose.pt",
|
||||||
|
"YOLO8m-Pose": "yolov8m-pose.pt",
|
||||||
|
"Mediapipe BlazePose": "mediapipe"
|
||||||
|
}
|
||||||
|
model_file = model_map.get(config.get("pose_model"), "yolov8n-pose.pt")
|
||||||
|
|
||||||
|
print(f"[{p_name}] Running inference with model: {model_file}")
|
||||||
|
model = YOLO(model_file)
|
||||||
|
|
||||||
|
new_csv_storage_data = []
|
||||||
|
prev_track_id = None
|
||||||
|
|
||||||
|
for i in range(total_frames):
|
||||||
|
ret, frame = cap.read()
|
||||||
|
if not ret:
|
||||||
|
break
|
||||||
|
|
||||||
|
results = model.track(frame, persist=True, verbose=False)
|
||||||
|
|
||||||
|
track_id, kp, confs, _ = get_best_infant_match(results, width, height, prev_track_id)
|
||||||
|
|
||||||
|
if kp is not None:
|
||||||
|
prev_track_id = track_id
|
||||||
|
# Store as (17, 3) including confidence
|
||||||
|
new_csv_storage_data.append(np.column_stack((kp, confs)))
|
||||||
|
else:
|
||||||
|
new_csv_storage_data.append(np.zeros((17, 3)))
|
||||||
|
|
||||||
|
if i % 10 == 0:
|
||||||
|
progress_queue.put(int((i / total_frames) * 100))
|
||||||
|
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
if inference_performed:
|
||||||
|
print(f"[{p_name}] Saving new pose cache to {pose_cache}")
|
||||||
|
try:
|
||||||
|
with open(pose_cache, 'w', newline='') as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
header = []
|
||||||
|
for joint in JOINT_NAMES:
|
||||||
|
header.extend([f"{joint}_x", f"{joint}_y", f"{joint}_conf"])
|
||||||
|
writer.writerow(header)
|
||||||
|
for frame_array in new_csv_storage_data:
|
||||||
|
writer.writerow(frame_array.flatten())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{p_name}] Error saving cache: {e}")
|
||||||
|
|
||||||
|
# Return results through the queue
|
||||||
|
result_queue.put({
|
||||||
|
"raw_kpts": np.array(new_csv_storage_data),
|
||||||
|
"fps": fps,
|
||||||
|
"total_frames": len(new_csv_storage_data),
|
||||||
|
"dims": (width, height),
|
||||||
|
"status": "inference_complete"
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"[{p_name}] Analysis complete.")
|
||||||
+188
-248
@@ -1,16 +1,9 @@
|
|||||||
"""
|
|
||||||
Filename: predictor.py
|
|
||||||
Description: BLAZES machine learning
|
|
||||||
|
|
||||||
Author: Tyler de Zeeuw
|
|
||||||
License: GPL-3.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Built-in imports
|
|
||||||
import inspect
|
import inspect
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# External library imports
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import joblib
|
import joblib
|
||||||
import seaborn as sns
|
import seaborn as sns
|
||||||
@@ -21,249 +14,202 @@ from sklearn.model_selection import train_test_split
|
|||||||
from sklearn.metrics import classification_report, f1_score, precision_score, recall_score, confusion_matrix
|
from sklearn.metrics import classification_report, f1_score, precision_score, recall_score, confusion_matrix
|
||||||
from sklearn.preprocessing import StandardScaler
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
|
||||||
# To be used once multiple models are supported and functioning:
|
|
||||||
# import torch
|
|
||||||
# import torch.nn as nn
|
|
||||||
# import torch.optim as optim
|
|
||||||
# import xgboost as xgb
|
|
||||||
# from sklearn.svm import SVC
|
|
||||||
# import os
|
|
||||||
|
|
||||||
VERBOSITY = 1
|
VERBOSITY = 1
|
||||||
|
|
||||||
GEOMETRY_LIBRARY = {
|
def load_analysis_config(path="analysis_config.json"):
|
||||||
# --- Distances (Point A, Point B) ---
|
with open(path, 'r') as f:
|
||||||
"dist_l_wrist_nose": ("dist", [9, 0], True),
|
config = json.load(f)
|
||||||
"dist_r_wrist_nose": ("dist", [10, 0], True),
|
return config['geometry_library'], config['activity_map']
|
||||||
"dist_l_ear_r_shld": ("dist", [3, 6], True),
|
|
||||||
"dist_r_ear_l_shld": ("dist", [4, 5], True),
|
|
||||||
|
|
||||||
"dist_l_wrist_pelvis": ("dist", [9, [11, 12]], True),
|
try:
|
||||||
"dist_r_wrist_pelvis": ("dist", [10, [11, 12]], True),
|
GEOMETRY_LIBRARY, ACTIVITY_MAP = load_analysis_config()
|
||||||
"dist_l_ankl_pelvis": ("dist", [15, [11, 12]], True),
|
except FileNotFoundError:
|
||||||
"dist_r_ankl_pelvis": ("dist", [16, [11, 12]], True),
|
GEOMETRY_LIBRARY, ACTIVITY_MAP = {}, {}
|
||||||
"dist_nose_pelvis": ("dist", [0, [11, 12]], True),
|
print("Warning: analysis_config.json not found. ML functions will fail.")
|
||||||
"dist_ankl_ankl": ("dist", [15, 16], True),
|
|
||||||
|
|
||||||
# NEW: Cross-Body and Pure Extension Distances
|
|
||||||
"dist_l_wri_r_shld": ("dist", [9, 6], True), # Reach across body
|
|
||||||
"dist_r_wri_l_shld": ("dist", [10, 5], True), # Reach across body
|
|
||||||
"dist_l_wri_l_shld": ("dist", [9, 5], True), # Pure arm extension
|
|
||||||
"dist_r_wri_r_shld": ("dist", [10, 6], True), # Pure arm extension
|
|
||||||
|
|
||||||
# --- Angles (Point A, Center B, Point C) ---
|
|
||||||
"angle_l_elbow": ("angle", [5, 7, 9]),
|
|
||||||
"angle_r_elbow": ("angle", [6, 8, 10]),
|
|
||||||
"angle_l_shoulder": ("angle", [11, 5, 7]),
|
|
||||||
"angle_r_shoulder": ("angle", [12, 6, 8]),
|
|
||||||
"angle_l_knee": ("angle", [11, 13, 15]),
|
|
||||||
"angle_r_knee": ("angle", [12, 14, 16]),
|
|
||||||
"angle_l_hip": ("angle", [5, 11, 13]),
|
|
||||||
"angle_r_hip": ("angle", [6, 12, 14]),
|
|
||||||
|
|
||||||
# --- Custom/Derived ---
|
|
||||||
"asym_wrist": ("z_diff", [9, 10]),
|
|
||||||
"asym_ankl": ("z_diff", [15, 16]),
|
|
||||||
"offset_head": ("head_offset", [0, 5, 6]),
|
|
||||||
"diff_ear_shld": ("subtraction", ["dist_l_ear_r_shld", "dist_r_ear_l_shld"]),
|
|
||||||
"abs_diff_ear_shld": ("abs_subtraction", ["dist_l_ear_r_shld", "dist_r_ear_l_shld"]),
|
|
||||||
|
|
||||||
# NEW: Verticality and Contralateral Contrast
|
|
||||||
"height_l_ankl": ("y_diff", [15, 11]), # Foot height relative to hip
|
|
||||||
"height_r_ankl": ("y_diff", [16, 12]), # Foot height relative to hip
|
|
||||||
"diff_knee_angle": ("subtraction", ["angle_l_knee", "angle_r_knee"]),
|
|
||||||
"asym_wri_shld": ("subtraction", ["dist_l_wri_l_shld", "dist_r_wri_r_shld"])
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# The Target Activity Map
|
|
||||||
ACTIVITY_MAP = {
|
|
||||||
"Mouthing": [
|
|
||||||
"dist_l_wrist_nose", "dist_r_wrist_nose", "angle_l_elbow",
|
|
||||||
"angle_r_elbow", "angle_l_shoulder", "angle_r_shoulder",
|
|
||||||
"asym_wrist", "offset_head"
|
|
||||||
],
|
|
||||||
"Head Movement": [
|
|
||||||
"dist_l_wrist_nose", "dist_r_wrist_nose", "angle_l_elbow",
|
|
||||||
"angle_r_elbow", "angle_l_shoulder", "angle_r_shoulder",
|
|
||||||
"asym_wrist", "offset_head", "dist_l_ear_r_shld",
|
|
||||||
"dist_r_ear_l_shld", "diff_ear_shld", "abs_diff_ear_shld"
|
|
||||||
],
|
|
||||||
"Reach (Left)": [
|
|
||||||
"dist_l_wrist_pelvis", "dist_l_wrist_nose", "dist_l_wri_l_shld",
|
|
||||||
"dist_l_wri_r_shld", "angle_l_elbow", "angle_l_shoulder",
|
|
||||||
"asym_wri_shld"
|
|
||||||
],
|
|
||||||
"Reach (Right)": [
|
|
||||||
"dist_r_wrist_pelvis", "dist_r_wrist_nose", "dist_r_wri_r_shld",
|
|
||||||
"dist_r_wri_l_shld", "angle_r_elbow", "angle_r_shoulder",
|
|
||||||
"asym_wri_shld"
|
|
||||||
],
|
|
||||||
"Kick (Left)": [
|
|
||||||
"dist_l_ankl_pelvis", "angle_l_knee", "angle_l_hip",
|
|
||||||
"height_l_ankl", "dist_ankl_ankl", "asym_ankl",
|
|
||||||
"diff_knee_angle", "dist_nose_pelvis"
|
|
||||||
],
|
|
||||||
"Kick (Right)": [
|
|
||||||
"dist_r_ankl_pelvis", "angle_r_knee", "angle_r_hip",
|
|
||||||
"height_r_ankl", "dist_ankl_ankl", "asym_ankl",
|
|
||||||
"diff_knee_angle", "dist_nose_pelvis"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def debug_print():
|
def debug_print():
|
||||||
if VERBOSITY:
|
if VERBOSITY:
|
||||||
frame = inspect.currentframe().f_back
|
frame = inspect.currentframe().f_back
|
||||||
qualname = frame.f_code.co_filename
|
qualname = frame.f_code.co_filename
|
||||||
print(qualname)
|
print(f"DEBUG_PRINT: {qualname}")
|
||||||
|
|
||||||
|
|
||||||
class GeneralPredictor:
|
class GeneralPredictor:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
debug_print()
|
debug_print()
|
||||||
self.base_paths = {
|
self.base_paths = {
|
||||||
"Random Forest": "rf.pkl",
|
"Random Forest": "rf.pkl"
|
||||||
"XGBoost": "xgb.json",
|
|
||||||
"SVM": "svm.pkl",
|
|
||||||
"LSTM": "lstm.pth",
|
|
||||||
"1D-CNN": "cnn.pth"
|
|
||||||
}
|
}
|
||||||
self.raw_participant_buffer = []
|
|
||||||
self.current_target = ""
|
self.current_target = ""
|
||||||
self.scaler_cache = {}
|
self.active_feature_keys = []
|
||||||
|
|
||||||
|
def calculate_and_train(self, training_params):
|
||||||
def add_to_raw_buffer(self, raw_payload, y_labels):
|
|
||||||
"""
|
"""
|
||||||
Adds a participant's raw kinematic components to the pool.
|
Takes the dict from get_selection() in TrainModelWindow.
|
||||||
raw_payload should contain: 'z_kps', 'directions', 'raw_kps'
|
Loads CSV/JSON pairs, extracts combined features, and trains Random Forest.
|
||||||
"""
|
"""
|
||||||
debug_print()
|
debug_print()
|
||||||
entry = {
|
|
||||||
"raw_data": raw_payload,
|
folder = training_params.get("folder")
|
||||||
"labels": y_labels
|
pairs = training_params.get("pairs", [])
|
||||||
|
selected_behaviors = training_params.get("selected_behaviors", [])
|
||||||
|
self.current_target = training_params.get("target_name", "combined_model")
|
||||||
|
model_type = training_params.get("model_type", "Random Forest")
|
||||||
|
|
||||||
|
if not pairs or not selected_behaviors:
|
||||||
|
return "Error: Missing data pairs or target behaviors."
|
||||||
|
|
||||||
|
# 1. Determine the union of ALL needed geometric features across selected behaviors
|
||||||
|
needed_features = set()
|
||||||
|
for b_name in selected_behaviors:
|
||||||
|
req_feats = ACTIVITY_MAP.get(b_name, [])
|
||||||
|
needed_features.update(req_feats)
|
||||||
|
|
||||||
|
self.active_feature_keys = sorted(list(needed_features))
|
||||||
|
print(self.active_feature_keys)
|
||||||
|
|
||||||
|
model_metadata = {
|
||||||
|
"target_behavior": self.current_target,
|
||||||
|
"feature_keys": self.active_feature_keys,
|
||||||
|
"model_type": model_type,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
}
|
}
|
||||||
self.raw_participant_buffer.append(entry)
|
|
||||||
return f"Added participant to pool. Total participants: {len(self.raw_participant_buffer)}"
|
|
||||||
|
|
||||||
|
if not self.active_feature_keys:
|
||||||
|
return "Error: No geometric features mapped to the selected behavior(s) in analysis_config.json."
|
||||||
|
|
||||||
def clear_buffer(self):
|
|
||||||
"""Clears the raw pool."""
|
|
||||||
debug_print()
|
|
||||||
self.raw_participant_buffer = []
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_and_train(self, model_type, target_name):
|
|
||||||
"""
|
|
||||||
The 'On-the-Fly' engine. Loops through the raw buffer,
|
|
||||||
calculates features for the SELECTED target, and trains.
|
|
||||||
"""
|
|
||||||
debug_print()
|
|
||||||
self.current_target = target_name
|
|
||||||
all_X = []
|
all_X = []
|
||||||
all_y = []
|
all_y = []
|
||||||
|
|
||||||
# 1. Process every participant in the pool
|
# 2. Process each Pair (JSON labels + CSV raw pose)
|
||||||
for participant in self.raw_participant_buffer:
|
for json_path, csv_path in pairs:
|
||||||
raw = participant["raw_data"]
|
# --- Load JSON Labels ---
|
||||||
all_tracks = participant["labels"]
|
try:
|
||||||
|
with open(json_path, 'r') as f:
|
||||||
# Pull the specific track that was requested
|
label_data = json.load(f)
|
||||||
track_key = f"OBS: {target_name}"
|
except Exception as e:
|
||||||
if track_key not in all_tracks:
|
print(f"Error loading {json_path}: {e}")
|
||||||
print(f"Warning: Track {track_key} not found for a participant. Skipping.")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
y = all_tracks[track_key]
|
behaviors = label_data.get("behaviors", {})
|
||||||
|
|
||||||
# Extract lists from the payload
|
# --- Load CSV Pose Data ---
|
||||||
z_scores = raw["z_kps"]
|
try:
|
||||||
dirs = raw["directions"]
|
raw_kpts = []
|
||||||
kpts = raw["raw_kps"]
|
with open(csv_path, 'r') as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
next(reader) # skip header
|
||||||
|
for row in reader:
|
||||||
|
raw_kpts.append(np.array([float(x) for x in row]).reshape(17, 3))
|
||||||
|
raw_kpts = np.array(raw_kpts)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading {csv_path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_frames = len(raw_kpts)
|
||||||
|
if total_frames == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create binary target array (0 = Rest, 1 = Active)
|
||||||
|
y_vector = np.zeros(total_frames, dtype=int)
|
||||||
|
|
||||||
|
# If the frame falls inside ANY of the selected behaviors, mark it 1
|
||||||
|
for b_name in selected_behaviors:
|
||||||
|
instances = behaviors.get(b_name, [])
|
||||||
|
for inst in instances:
|
||||||
|
start = inst.get("start_frame", 0)
|
||||||
|
duration = inst.get("duration_frames", 0)
|
||||||
|
end = min(start + duration, total_frames)
|
||||||
|
y_vector[start:end] = 1
|
||||||
|
|
||||||
|
# --- Calculate Features per Frame ---
|
||||||
|
# To match the new flow, we just need raw_kpts.
|
||||||
|
# (Z-scores were previously passed, but those were derived from raw anyway.
|
||||||
|
# If you require normalized z-scores for RF, you must recalculate them here
|
||||||
|
# using the same baseline logic from the main window. For now, we extract raw geom.)
|
||||||
|
|
||||||
# Calculate geometric features for every frame
|
|
||||||
participant_features = []
|
participant_features = []
|
||||||
for i in range(len(y)):
|
for i in range(total_frames):
|
||||||
feat = self.format_features(z_scores[i], dirs[i], kpts[i])
|
kpts = raw_kpts[i] # Shape (17, 3)
|
||||||
|
feat = self.format_features(kpts)
|
||||||
participant_features.append(feat)
|
participant_features.append(feat)
|
||||||
|
|
||||||
all_X.append(np.array(participant_features))
|
all_X.append(np.array(participant_features))
|
||||||
all_y.append(y)
|
all_y.append(y_vector)
|
||||||
|
|
||||||
|
# 3. Prepare for Training
|
||||||
|
if not all_X:
|
||||||
|
return "Error: No valid data extracted from files."
|
||||||
|
|
||||||
# 2. Prepare for Training
|
|
||||||
X_combined = np.vstack(all_X)
|
X_combined = np.vstack(all_X)
|
||||||
y_combined = np.concatenate(all_y)
|
y_combined = np.concatenate(all_y)
|
||||||
|
|
||||||
# 3. Scale the data specifically for this target/model combo
|
# Check for class imbalance edge case (e.g. 0 instances of behavior found)
|
||||||
|
if len(np.unique(y_combined)) < 2:
|
||||||
|
return "Error: Training data only contains one class (usually 0/Rest). Model cannot train."
|
||||||
|
|
||||||
|
metadata_path = self.get_path(model_type).replace(".pkl", "_metadata.json")
|
||||||
|
with open(metadata_path, 'w') as f:
|
||||||
|
json.dump(model_metadata, f, indent=4)
|
||||||
|
|
||||||
|
print(f"[INFO] Metadata saved to: {metadata_path}")
|
||||||
|
|
||||||
|
# 4. Scale Data
|
||||||
scaler = StandardScaler()
|
scaler = StandardScaler()
|
||||||
X_scaled = scaler.fit_transform(X_combined)
|
X_scaled = scaler.fit_transform(X_combined)
|
||||||
scaler_path = self.get_path(model_type, is_scaler=True)
|
scaler_path = self.get_path(model_type, is_scaler=True)
|
||||||
joblib.dump(scaler, scaler_path)
|
joblib.dump(scaler, scaler_path)
|
||||||
|
|
||||||
# 4. Train/Test Split
|
# 5. Train/Test Split
|
||||||
X_train, X_test, y_train, y_test = train_test_split(
|
X_train, X_test, y_train, y_test = train_test_split(
|
||||||
X_scaled, y_combined, test_size=0.2, stratify=y_combined, random_state=42
|
X_scaled, y_combined, test_size=0.2, stratify=y_combined, random_state=42
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. Process with corresponding Model
|
# 6. Train Random Forest (Placeholders exist for others)
|
||||||
if model_type == "Random Forest":
|
if model_type == "Random Forest":
|
||||||
model = RandomForestClassifier(max_depth=15, n_estimators=100, class_weight="balanced")
|
model = RandomForestClassifier(max_depth=15, n_estimators=100, class_weight="balanced")
|
||||||
model.fit(X_train, y_train)
|
model.fit(X_train, y_train)
|
||||||
|
|
||||||
# Save the model
|
|
||||||
save_path = self.get_path(model_type)
|
save_path = self.get_path(model_type)
|
||||||
joblib.dump(model, save_path)
|
joblib.dump(model, save_path)
|
||||||
|
|
||||||
y_pred = model.predict(X_test)
|
y_pred = model.predict(X_test)
|
||||||
|
|
||||||
# Feature Importance for the UI
|
# Feature Importance
|
||||||
labels_names = self.get_feature_labels()
|
|
||||||
importances = model.feature_importances_
|
importances = model.feature_importances_
|
||||||
feature_data = sorted(zip(labels_names, importances), key=lambda x: x[1], reverse=True)
|
feature_data = sorted(zip(self.active_feature_keys, importances), key=lambda x: x[1], reverse=True)
|
||||||
ui_extras = "<b>Top Predictors:</b><br>" + "<br>".join([f"{n}: {v:.3f}" for n, v in feature_data])
|
|
||||||
|
ui_extras = "<b>Top Predictors:</b><br>" + "<br>".join([f"{n}: {v:.3f}" for n, v in feature_data[:10]])
|
||||||
file_extras = "Top Predictors:\n" + "\n".join([f"- {n}: {v:.3f}" for n, v in feature_data])
|
file_extras = "Top Predictors:\n" + "\n".join([f"- {n}: {v:.3f}" for n, v in feature_data])
|
||||||
|
|
||||||
return self._evaluate_and_report(model_type, y_test, y_pred, ui_extras=ui_extras, file_extras=file_extras, target_name=target_name)
|
return self._evaluate_and_report(model_type, y_test, y_pred, ui_extras=ui_extras, file_extras=file_extras)
|
||||||
|
|
||||||
# TODO: More than random forest
|
elif model_type == "1D-CNN":
|
||||||
|
return "1D-CNN training placeholder reached. Not yet implemented."
|
||||||
|
elif model_type == "LSTM":
|
||||||
|
return "LSTM training placeholder reached. Not yet implemented."
|
||||||
|
elif model_type == "XGBoost":
|
||||||
|
return "XGBoost training placeholder reached. Not yet implemented."
|
||||||
else:
|
else:
|
||||||
return "Model type not yet implemented in calculate_and_train."
|
return f"Model type {model_type} not supported."
|
||||||
|
|
||||||
|
|
||||||
def get_path(self, model_type, is_scaler=False):
|
def get_path(self, model_type, is_scaler=False):
|
||||||
"""Returns the specific file path for the target/model or its scaler."""
|
|
||||||
debug_print()
|
debug_print()
|
||||||
suffix = self.base_paths[model_type]
|
suffix = self.base_paths.get(model_type, "model.pkl")
|
||||||
|
|
||||||
if is_scaler:
|
if is_scaler:
|
||||||
suffix = suffix.split('.')[0] + "_scaler.pkl"
|
suffix = suffix.split('.')[0] + "_scaler.pkl"
|
||||||
|
|
||||||
return f"ml_{self.current_target}_{suffix}"
|
return f"ml_{self.current_target}_{suffix}"
|
||||||
|
|
||||||
|
def format_features(self, kpts):
|
||||||
def get_feature_labels(self):
|
"""
|
||||||
"""Returns labels only for features active in the current target."""
|
Calculates only the geometric features required by self.active_feature_keys.
|
||||||
debug_print()
|
"""
|
||||||
active_keys = ACTIVITY_MAP.get(self.current_target, [])
|
|
||||||
return active_keys
|
|
||||||
|
|
||||||
|
|
||||||
def format_features(self, z_scores, directions, kpts):
|
|
||||||
"""The 'Universal Parser' for geometric features."""
|
|
||||||
# debug_print()
|
|
||||||
# Internal Math Helpers
|
|
||||||
if self.current_target == "ALL_FEATURES":
|
|
||||||
active_list = list(GEOMETRY_LIBRARY.keys())
|
|
||||||
else:
|
|
||||||
active_list = ACTIVITY_MAP.get(self.current_target, ACTIVITY_MAP["Mouthing"])
|
|
||||||
|
|
||||||
def resolve_pt(idx):
|
def resolve_pt(idx):
|
||||||
if isinstance(idx, list):
|
if isinstance(idx, list):
|
||||||
# Calculate midpoint of all indices in the list
|
pts = [kpts[i][:2] for i in idx] # Ensure X/Y only
|
||||||
pts = [kpts[i] for i in idx]
|
|
||||||
return np.mean(pts, axis=0)
|
return np.mean(pts, axis=0)
|
||||||
return kpts[idx]
|
return kpts[idx][:2]
|
||||||
|
|
||||||
def get_dist(p1, p2): return np.linalg.norm(p1 - p2)
|
def get_dist(p1, p2): return np.linalg.norm(p1 - p2)
|
||||||
def get_angle(a, b, c):
|
def get_angle(a, b, c):
|
||||||
@@ -278,128 +224,122 @@ class GeneralPredictor:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if kpts is None or len(kpts) < 13: raise ValueError()
|
if kpts is None or len(kpts) < 13: raise ValueError()
|
||||||
# Reference scale (Shoulders)
|
scale = get_dist(kpts[5][:2], kpts[6][:2]) + 1e-6
|
||||||
scale = get_dist(kpts[5], kpts[6]) + 1e-6
|
|
||||||
|
# First Pass: Direct Geometries (Only calculate what is needed or what is a dependency)
|
||||||
|
for name, config_data in GEOMETRY_LIBRARY.items():
|
||||||
|
f_type = config_data[0]
|
||||||
|
indices = config_data[1]
|
||||||
|
|
||||||
# First Pass: Direct Geometries
|
|
||||||
for name, (f_type, indices, *meta) in GEOMETRY_LIBRARY.items():
|
|
||||||
if f_type == "dist":
|
if f_type == "dist":
|
||||||
# Use resolve_pt for both indices
|
|
||||||
p1 = resolve_pt(indices[0])
|
p1 = resolve_pt(indices[0])
|
||||||
p2 = resolve_pt(indices[1])
|
p2 = resolve_pt(indices[1])
|
||||||
calculated_pool[name] = get_dist(p1, p2) / scale
|
calculated_pool[name] = get_dist(p1, p2) / scale
|
||||||
|
|
||||||
elif f_type == "angle":
|
elif f_type == "angle":
|
||||||
# Use resolve_pt for all three indices
|
|
||||||
p1 = resolve_pt(indices[0])
|
p1 = resolve_pt(indices[0])
|
||||||
p2 = resolve_pt(indices[1])
|
p2 = resolve_pt(indices[1])
|
||||||
p3 = resolve_pt(indices[2])
|
p3 = resolve_pt(indices[2])
|
||||||
calculated_pool[name] = get_angle(p1, p2, p3)
|
calculated_pool[name] = get_angle(p1, p2, p3)
|
||||||
|
|
||||||
elif f_type == "z_diff":
|
|
||||||
# Z-scores are usually single indices, but we handle lists just in case
|
|
||||||
z1 = np.mean([z_scores[i] for i in indices[0]]) if isinstance(indices[0], list) else z_scores[indices[0]]
|
|
||||||
z2 = np.mean([z_scores[i] for i in indices[1]]) if isinstance(indices[1], list) else z_scores[indices[1]]
|
|
||||||
calculated_pool[name] = abs(z1 - z2)
|
|
||||||
|
|
||||||
elif f_type == "head_offset":
|
elif f_type == "head_offset":
|
||||||
p_target = resolve_pt(indices[0])
|
p_target = resolve_pt(indices[0])
|
||||||
p_mid = resolve_pt([indices[1], indices[2]]) # Midpoint of shoulders
|
p_mid = resolve_pt([indices[1], indices[2]])
|
||||||
calculated_pool[name] = abs(p_target[0] - p_mid[0]) / scale
|
calculated_pool[name] = abs(p_target[0] - p_mid[0]) / scale
|
||||||
|
|
||||||
# Second Pass: Composite Geometries (Subtractions/Symmetry)
|
elif f_type == "y_diff": # NEW from JSON
|
||||||
# We do this after so 'dist_l_ear_r_shld' is already calculated
|
p1 = resolve_pt(indices[0])
|
||||||
for name, (f_type, indices, *meta) in GEOMETRY_LIBRARY.items():
|
p2 = resolve_pt(indices[1])
|
||||||
|
calculated_pool[name] = abs(p1[1] - p2[1]) / scale
|
||||||
|
|
||||||
|
# Second Pass: Subtractions (Requires first pass to be complete)
|
||||||
|
for name, config_data in GEOMETRY_LIBRARY.items():
|
||||||
|
f_type = config_data[0]
|
||||||
|
indices = config_data[1]
|
||||||
|
|
||||||
if f_type == "subtraction":
|
if f_type == "subtraction":
|
||||||
calculated_pool[name] = calculated_pool[indices[0]] - calculated_pool[indices[1]]
|
val1 = calculated_pool.get(indices[0], 0)
|
||||||
|
val2 = calculated_pool.get(indices[1], 0)
|
||||||
|
calculated_pool[name] = val1 - val2
|
||||||
elif f_type == "abs_subtraction":
|
elif f_type == "abs_subtraction":
|
||||||
calculated_pool[name] = abs(calculated_pool[indices[0]] - calculated_pool[indices[1]])
|
val1 = calculated_pool.get(indices[0], 0)
|
||||||
|
val2 = calculated_pool.get(indices[1], 0)
|
||||||
|
calculated_pool[name] = abs(val1 - val2)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# If a frame fails, fill the pool with zeros to prevent crashes
|
|
||||||
calculated_pool = {name: 0.0 for name in GEOMETRY_LIBRARY.keys()}
|
calculated_pool = {name: 0.0 for name in GEOMETRY_LIBRARY.keys()}
|
||||||
|
|
||||||
# Final Extraction based on current_target
|
# Final Extraction based on the set of needed features
|
||||||
|
feature_vector = [calculated_pool.get(feat, 0.0) for feat in self.active_feature_keys]
|
||||||
active_list = ACTIVITY_MAP.get(self.current_target, ACTIVITY_MAP["Mouthing"])
|
|
||||||
feature_vector = [calculated_pool[feat] for feat in active_list]
|
|
||||||
|
|
||||||
return np.array(feature_vector, dtype=np.float32)
|
return np.array(feature_vector, dtype=np.float32)
|
||||||
|
|
||||||
def _prepare_pool_data(self):
|
def _evaluate_and_report(self, model_name, y_test, y_pred, ui_extras="", file_extras=""):
|
||||||
"""Merges buffer and fits scaler."""
|
|
||||||
debug_print()
|
|
||||||
if not self.X_buffer:
|
|
||||||
return None, None, None
|
|
||||||
|
|
||||||
X_total = np.vstack(self.X_buffer)
|
|
||||||
y_total = np.concatenate(self.y_buffer)
|
|
||||||
|
|
||||||
# We always fit a fresh scaler on the current pool
|
|
||||||
scaler_file = f"{self.current_target}_scaler.pkl"
|
|
||||||
scaler = StandardScaler()
|
|
||||||
X_scaled = scaler.fit_transform(X_total)
|
|
||||||
joblib.dump(scaler, scaler_file)
|
|
||||||
|
|
||||||
return X_scaled, y_total, scaler
|
|
||||||
|
|
||||||
|
|
||||||
def _evaluate_and_report(self, model_name, y_test, y_pred, extra_text="", ui_extras="", file_extras="", target_name=""):
|
|
||||||
"""Generates unified metrics, confusion matrix, and reports for ANY model"""
|
|
||||||
debug_print()
|
debug_print()
|
||||||
prec = precision_score(y_test, y_pred, zero_division=0)
|
prec = precision_score(y_test, y_pred, zero_division=0)
|
||||||
rec = recall_score(y_test, y_pred, zero_division=0)
|
rec = recall_score(y_test, y_pred, zero_division=0)
|
||||||
f1 = f1_score(y_test, y_pred, zero_division=0)
|
f1 = f1_score(y_test, y_pred, zero_division=0)
|
||||||
|
|
||||||
target = getattr(self, 'current_target', 'Activity')
|
display_labels = ['Rest', self.current_target]
|
||||||
display_labels = ['Rest', target]
|
|
||||||
# Plot Confusion Matrix
|
|
||||||
cm = confusion_matrix(y_test, y_pred)
|
cm = confusion_matrix(y_test, y_pred)
|
||||||
|
|
||||||
plt.figure(figsize=(8, 6))
|
plt.figure(figsize=(8, 6))
|
||||||
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
|
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
|
||||||
xticklabels=display_labels,
|
xticklabels=display_labels, yticklabels=display_labels)
|
||||||
yticklabels=display_labels)
|
plt.title(f'{model_name} Detection: {self.current_target}')
|
||||||
plt.title(f'{model_name} Detection: Predicted vs Actual')
|
|
||||||
plt.ylabel('Actual State')
|
plt.ylabel('Actual State')
|
||||||
plt.xlabel('Predicted State')
|
plt.xlabel('Predicted State')
|
||||||
|
|
||||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
plt.savefig(f"ml_{target_name}_confusion_matrix_rf_{timestamp}.png")
|
plt.savefig(f"ml_{self.current_target}_cm_{timestamp}.png")
|
||||||
plt.close()
|
plt.close()
|
||||||
|
|
||||||
# Classification Report String
|
report_str = classification_report(y_test, y_pred, target_names=display_labels, zero_division=0)
|
||||||
report_str = classification_report(y_test, y_pred,
|
|
||||||
target_names=display_labels,
|
|
||||||
zero_division=0)
|
|
||||||
|
|
||||||
# Build TXT File Content
|
|
||||||
report_text = f"MODEL PERFORMANCE REPORT: {model_name}\nGenerated: {timestamp}\n"
|
report_text = f"MODEL PERFORMANCE REPORT: {model_name}\nGenerated: {timestamp}\n"
|
||||||
report_text += "="*40 + "\n"
|
report_text += "="*40 + "\n"
|
||||||
report_text += report_str + "\n"
|
report_text += report_str + "\n"
|
||||||
report_text += f"Precision: {prec:.4f}\nRecall: {rec:.4f}\nF1-Score: {f1:.4f}\n"
|
report_text += f"Precision: {prec:.4f}\nRecall: {rec:.4f}\nF1-Score: {f1:.4f}\n"
|
||||||
report_text += "="*40 + "\n" + extra_text
|
|
||||||
report_text += "="*40 + "\n" + file_extras
|
report_text += "="*40 + "\n" + file_extras
|
||||||
|
|
||||||
with open(f"ml_{target_name}_performance_rf_{timestamp}.txt", "w") as f:
|
with open(f"ml_{self.current_target}_performance_{timestamp}.txt", "w") as f:
|
||||||
f.write(report_text)
|
f.write(report_text)
|
||||||
|
|
||||||
# Build UI String
|
|
||||||
ui_report = f"""
|
ui_report = f"""
|
||||||
<b>{model_name} Performance:</b><br>
|
<b>{model_name} Model for '{self.current_target}'</b><br>
|
||||||
Precision: {prec:.2f} | Recall: {rec:.2f} | <b>F1: {f1:.2f}</b><br>
|
Precision: {prec:.2f} | Recall: {rec:.2f} | <b>F1: {f1:.2f}</b><br>
|
||||||
<hr>
|
<hr>
|
||||||
{ui_extras}
|
{ui_extras}
|
||||||
"""
|
"""
|
||||||
return ui_report
|
return ui_report
|
||||||
|
|
||||||
def calculate_directions(self, analysis_kps):
|
|
||||||
debug_print()
|
|
||||||
all_dirs = np.zeros((len(analysis_kps), 17))
|
|
||||||
|
|
||||||
for f in range(1, len(analysis_kps)):
|
# Inside predictor.py -> GeneralPredictor class
|
||||||
deltas = analysis_kps[f] - analysis_kps[f-1] # Shape (17, 2)
|
def convert_to_events(self, predictions, track_name="🤖 AI: Predicted"):
|
||||||
|
"""
|
||||||
|
Converts a 1D array of class labels into a dictionary of timeline blocks.
|
||||||
|
predictions: np.array of 0s and 1s
|
||||||
|
track_name: The name for the resulting timeline row
|
||||||
|
"""
|
||||||
|
events = {track_name: []}
|
||||||
|
current_class = None
|
||||||
|
start_frame = 0
|
||||||
|
|
||||||
angles = np.arctan2(-deltas[:, 1], deltas[:, 0])
|
for i, pred in enumerate(predictions):
|
||||||
all_dirs[f] = angles
|
# We only care about the transition into or out of class 1
|
||||||
|
if pred != current_class:
|
||||||
|
# If we were in an active block (1), close it
|
||||||
|
if current_class == 1:
|
||||||
|
events[track_name].append([start_frame, i, "Normal", "ML Prediction"])
|
||||||
|
|
||||||
return all_dirs
|
# If we are starting a new active block (1), mark the start
|
||||||
|
if pred == 1:
|
||||||
|
start_frame = i
|
||||||
|
|
||||||
|
current_class = pred
|
||||||
|
|
||||||
|
# Close the final block if the video ends while the behavior is active
|
||||||
|
if current_class == 1:
|
||||||
|
events[track_name].append([start_frame, len(predictions), "Normal", "ML Prediction"])
|
||||||
|
|
||||||
|
return events
|
||||||
Reference in New Issue
Block a user