start of config editor
This commit is contained in:
@@ -34,7 +34,7 @@ from pose_worker import run_pose_analysis
|
|||||||
from batch_processing import BatchProcessorDialog
|
from batch_processing import BatchProcessorDialog
|
||||||
|
|
||||||
import PySide6
|
import PySide6
|
||||||
from PySide6.QtWidgets import (QApplication, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QProgressDialog, QTabBar, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene,
|
from PySide6.QtWidgets import (QApplication, QDoubleSpinBox, QGraphicsItem, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QProgressDialog, QStyleOptionGraphicsItem, QTabBar, QWidget, QVBoxLayout, QGraphicsView, QGraphicsScene,
|
||||||
QHBoxLayout, QSplitter, QLabel, QPushButton, QComboBox, QInputDialog,
|
QHBoxLayout, QSplitter, QLabel, QPushButton, QComboBox, QInputDialog,
|
||||||
QFileDialog, QScrollArea, QMessageBox, QSlider, QTextEdit, QGroupBox, QGridLayout, QCheckBox, QTabWidget, QProgressBar)
|
QFileDialog, QScrollArea, QMessageBox, QSlider, QTextEdit, QGroupBox, QGridLayout, QCheckBox, QTabWidget, QProgressBar)
|
||||||
from PySide6.QtCore import QEvent, Qt, QThread, Signal, QUrl, QRectF, QPointF, QRect, QSizeF, QTimer
|
from PySide6.QtCore import QEvent, Qt, QThread, Signal, QUrl, QRectF, QPointF, QRect, QSizeF, QTimer
|
||||||
@@ -220,6 +220,40 @@ class OpenFileWindow(QWidget):
|
|||||||
pkl_layout.addWidget(self.lbl_pkl_path, 1, 1)
|
pkl_layout.addWidget(self.lbl_pkl_path, 1, 1)
|
||||||
main_layout.addWidget(self.pkl_group)
|
main_layout.addWidget(self.pkl_group)
|
||||||
|
|
||||||
|
# section 3.5
|
||||||
|
# --- Velocities and Deviations Section ---
|
||||||
|
self.calc_group = QGroupBox("Calculated Events")
|
||||||
|
self.calc_group.setCheckable(True)
|
||||||
|
self.calc_group.setChecked(False)
|
||||||
|
calc_layout = QGridLayout(self.calc_group)
|
||||||
|
|
||||||
|
# --- Velocity Row ---
|
||||||
|
self.cb_velocity = QCheckBox("Enable Velocities")
|
||||||
|
self.cb_velocity.setChecked(True)
|
||||||
|
self.spin_vel_threshold = QDoubleSpinBox()
|
||||||
|
self.spin_vel_threshold.setRange(0.0, 999.99)
|
||||||
|
self.spin_vel_threshold.setValue(15)
|
||||||
|
self.spin_vel_threshold.setSuffix(" px/s") # Optional: add units for clarity
|
||||||
|
|
||||||
|
# --- Deviation Row ---
|
||||||
|
self.cb_deviation = QCheckBox("Enable Deviations")
|
||||||
|
self.cb_deviation.setChecked(True)
|
||||||
|
self.spin_dev_threshold = QDoubleSpinBox()
|
||||||
|
self.spin_dev_threshold.setRange(0.0, 999.99)
|
||||||
|
self.spin_dev_threshold.setValue(80)
|
||||||
|
self.spin_dev_threshold.setSuffix(" px") # Optional: add units for clarity
|
||||||
|
|
||||||
|
# Add to Grid: (widget, row, column)
|
||||||
|
calc_layout.addWidget(self.cb_velocity, 0, 0)
|
||||||
|
calc_layout.addWidget(QLabel("Vel. Threshold:"), 0, 1)
|
||||||
|
calc_layout.addWidget(self.spin_vel_threshold, 0, 2)
|
||||||
|
|
||||||
|
calc_layout.addWidget(self.cb_deviation, 1, 0)
|
||||||
|
calc_layout.addWidget(QLabel("Dev. Threshold:"), 1, 1)
|
||||||
|
calc_layout.addWidget(self.spin_dev_threshold, 1, 2)
|
||||||
|
|
||||||
|
main_layout.addWidget(self.calc_group)
|
||||||
|
|
||||||
# --- Section 4: Inference ---
|
# --- Section 4: Inference ---
|
||||||
self.cfg_group = QGroupBox("Inference Settings")
|
self.cfg_group = QGroupBox("Inference Settings")
|
||||||
c_grid = QGridLayout(self.cfg_group)
|
c_grid = QGridLayout(self.cfg_group)
|
||||||
@@ -445,6 +479,12 @@ class OpenFileWindow(QWidget):
|
|||||||
# ML Model Data
|
# ML Model Data
|
||||||
"use_pkl": self.pkl_group.isChecked(),
|
"use_pkl": self.pkl_group.isChecked(),
|
||||||
"pkl_path": self.pkl_path if self.pkl_group.isChecked() else None,
|
"pkl_path": self.pkl_path if self.pkl_group.isChecked() else None,
|
||||||
|
|
||||||
|
"use_calculations": self.calc_group.isChecked(),
|
||||||
|
"velocity_enabled": self.cb_velocity.isChecked(),
|
||||||
|
"velocity_threshold": self.spin_vel_threshold.value(),
|
||||||
|
"deviation_enabled": self.cb_deviation.isChecked(),
|
||||||
|
"deviation_threshold": self.spin_dev_threshold.value(),
|
||||||
|
|
||||||
# Inference Settings
|
# Inference Settings
|
||||||
"use_pose": not self.check_bypass_inference.isChecked(),
|
"use_pose": not self.check_bypass_inference.isChecked(),
|
||||||
@@ -458,6 +498,7 @@ from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
|
|||||||
QLabel, QFileDialog, QFrame, QComboBox)
|
QLabel, QFileDialog, QFrame, QComboBox)
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
class TrainModelWindow(QDialog):
|
class TrainModelWindow(QDialog):
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -638,6 +679,168 @@ class TrainModelWindow(QDialog):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import json
|
||||||
|
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QListWidget, QListWidgetItem,
|
||||||
|
QFileDialog, QCheckBox, QMessageBox)
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class ExportTimelineJsonWindow(QDialog):
|
||||||
|
def __init__(self, timeline_data, fps=30.0, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Export Timeline Data")
|
||||||
|
self.setFixedSize(500, 550)
|
||||||
|
|
||||||
|
self.timeline_data = timeline_data
|
||||||
|
self.fps = fps
|
||||||
|
self.output_path = None
|
||||||
|
|
||||||
|
self.setup_ui()
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
# --- Section 1: Output Location ---
|
||||||
|
self.path_display = QLabel("No output file selected...")
|
||||||
|
self.path_display.setStyleSheet("background: #1e1e1e; padding: 8px; border-radius: 3px;")
|
||||||
|
btn_browse = QPushButton("Select Output Location")
|
||||||
|
btn_browse.clicked.connect(self.browse_file)
|
||||||
|
|
||||||
|
layout.addWidget(QLabel("Export Destination:"))
|
||||||
|
layout.addWidget(self.path_display)
|
||||||
|
layout.addWidget(btn_browse)
|
||||||
|
|
||||||
|
# --- Section 2: Track Selection ---
|
||||||
|
layout.addWidget(QLabel("Select Tracks to Include:"))
|
||||||
|
self.track_list = QListWidget()
|
||||||
|
self.populate_track_list()
|
||||||
|
layout.addWidget(self.track_list)
|
||||||
|
|
||||||
|
# --- Section 3: 'Fancy' Calculations Filter ---
|
||||||
|
self.cb_fancy = QCheckBox("Apply Fancy Filtering")
|
||||||
|
self.cb_fancy.setToolTip("Drops any Dev_ or Vel_ track events that overlap with an active BORIS event.")
|
||||||
|
layout.addWidget(self.cb_fancy)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# --- Final Actions ---
|
||||||
|
button_box = QHBoxLayout()
|
||||||
|
self.btn_export = QPushButton("Export JSON")
|
||||||
|
self.btn_export.setEnabled(False)
|
||||||
|
self.btn_export.setStyleSheet("background-color: #2e7d32; font-weight: bold; padding: 8px;")
|
||||||
|
self.btn_export.clicked.connect(self.perform_export)
|
||||||
|
|
||||||
|
btn_cancel = QPushButton("Cancel")
|
||||||
|
btn_cancel.clicked.connect(self.reject)
|
||||||
|
|
||||||
|
button_box.addWidget(btn_cancel)
|
||||||
|
button_box.addWidget(self.btn_export)
|
||||||
|
layout.addLayout(button_box)
|
||||||
|
|
||||||
|
def populate_track_list(self):
|
||||||
|
"""Populates the list widget with all available tracks, defaulting to checked."""
|
||||||
|
for track_name in sorted(self.timeline_data.keys()):
|
||||||
|
item = QListWidgetItem(track_name)
|
||||||
|
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(Qt.Checked)
|
||||||
|
self.track_list.addItem(item)
|
||||||
|
|
||||||
|
def browse_file(self):
|
||||||
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self, "Save Timeline JSON", "", "JSON Files (*.json)"
|
||||||
|
)
|
||||||
|
if file_path:
|
||||||
|
# Ensure extension
|
||||||
|
if not file_path.endswith('.json'):
|
||||||
|
file_path += '.json'
|
||||||
|
|
||||||
|
self.output_path = file_path
|
||||||
|
self.path_display.setText(file_path)
|
||||||
|
self.btn_export.setEnabled(True)
|
||||||
|
|
||||||
|
def perform_export(self):
|
||||||
|
if not self.output_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Get explicitly selected tracks
|
||||||
|
selected_tracks = []
|
||||||
|
for i in range(self.track_list.count()):
|
||||||
|
item = self.track_list.item(i)
|
||||||
|
if item.checkState() == Qt.Checked:
|
||||||
|
selected_tracks.append(item.text())
|
||||||
|
|
||||||
|
# 2. Gather BORIS intervals for the "Fancy" overlap check
|
||||||
|
do_fancy = self.cb_fancy.isChecked()
|
||||||
|
boris_intervals = []
|
||||||
|
|
||||||
|
if do_fancy:
|
||||||
|
for track_name in selected_tracks:
|
||||||
|
# Assuming BORIS events don't start with Dev_ or Vel_
|
||||||
|
if not track_name.startswith(("Dev_", "Vel_")):
|
||||||
|
for ev in self.timeline_data.get(track_name, []):
|
||||||
|
boris_intervals.append((ev[0], ev[1]))
|
||||||
|
|
||||||
|
# 3. Process events into a flat list
|
||||||
|
flat_events = []
|
||||||
|
|
||||||
|
for track_name in selected_tracks:
|
||||||
|
is_calc_track = track_name.startswith(("Dev_", "Vel_"))
|
||||||
|
events = self.timeline_data.get(track_name, [])
|
||||||
|
|
||||||
|
for ev in events:
|
||||||
|
start_f = ev[0]
|
||||||
|
end_f = ev[1]
|
||||||
|
|
||||||
|
# 'Fancy' Logic: Skip this event if it's a calc track and overlaps with BORIS
|
||||||
|
if do_fancy and is_calc_track:
|
||||||
|
overlap_found = False
|
||||||
|
for (b_start, b_end) in boris_intervals:
|
||||||
|
# Standard math for checking if two intervals overlap
|
||||||
|
if max(start_f, b_start) <= min(end_f, b_end):
|
||||||
|
overlap_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if overlap_found:
|
||||||
|
continue # Drop completely
|
||||||
|
|
||||||
|
# Append valid events
|
||||||
|
flat_events.append({
|
||||||
|
"track_name": track_name,
|
||||||
|
"start_frame": int(start_f),
|
||||||
|
"start_sec": round(start_f / self.fps, 3),
|
||||||
|
"end_frame": int(end_f),
|
||||||
|
"end_sec": round(end_f / self.fps, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. Order events chronologically by start frame
|
||||||
|
flat_events.sort(key=lambda x: x["start_frame"])
|
||||||
|
|
||||||
|
# 5. Build final JSON structure
|
||||||
|
all_possible_tracks = list(self.timeline_data.keys())
|
||||||
|
|
||||||
|
export_payload = {
|
||||||
|
"metadata": {
|
||||||
|
"fps": self.fps,
|
||||||
|
"total_events_exported": len(flat_events),
|
||||||
|
"fancy_filtering_applied": do_fancy,
|
||||||
|
"all_possible_tracks": all_possible_tracks
|
||||||
|
},
|
||||||
|
"events": flat_events
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6. Save to disk
|
||||||
|
try:
|
||||||
|
with open(self.output_path, 'w') as f:
|
||||||
|
json.dump(export_payload, f, indent=4)
|
||||||
|
self.accept() # Close the dialog successfully
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Export Error", f"Failed to write JSON:\n{str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class AboutWindow(QWidget):
|
class AboutWindow(QWidget):
|
||||||
"""
|
"""
|
||||||
Simple About window displaying basic application information.
|
Simple About window displaying basic application information.
|
||||||
@@ -1134,8 +1337,8 @@ class TimelineWidget(QWidget):
|
|||||||
self.sync_fps = 30.0
|
self.sync_fps = 30.0
|
||||||
self.is_scrubbing = False
|
self.is_scrubbing = False
|
||||||
|
|
||||||
self.total_content_height = (15 * self.track_height) + self.ruler_height
|
# self.total_content_height = (15 * self.track_height) + self.ruler_height
|
||||||
self.setMinimumHeight(self.total_content_height + self.scrollbar_buffer)
|
# self.setMinimumHeight(self.total_content_height + self.scrollbar_buffer)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1148,6 +1351,9 @@ class TimelineWidget(QWidget):
|
|||||||
"fps": fps
|
"fps": fps
|
||||||
}
|
}
|
||||||
self.sync_fps = fps
|
self.sync_fps = fps
|
||||||
|
|
||||||
|
self.total_content_height = (len(self.track_names) * self.track_height) + self.ruler_height
|
||||||
|
self.setMinimumHeight(self.total_content_height + self.scrollbar_buffer)
|
||||||
|
|
||||||
# Generate colors dynamically since we don't know the tracks ahead of time
|
# Generate colors dynamically since we don't know the tracks ahead of time
|
||||||
self.track_colors = [QColor.fromHsl((i * 360 // max(1, len(self.track_names))), 160, 140)
|
self.track_colors = [QColor.fromHsl((i * 360 // max(1, len(self.track_names))), 160, 140)
|
||||||
@@ -1510,7 +1716,6 @@ class SkeletonOverlay(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
def set_frame(self, frame_idx):
|
def set_frame(self, frame_idx):
|
||||||
debug_print()
|
|
||||||
self.current_frame = frame_idx
|
self.current_frame = frame_idx
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -1528,7 +1733,6 @@ class SkeletonOverlay(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
debug_print()
|
|
||||||
if not self.data or 'raw_kps' not in self.data:
|
if not self.data or 'raw_kps' not in self.data:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1573,7 +1777,15 @@ class SkeletonOverlay(QWidget):
|
|||||||
p_left = kp_live[idx_l_hip][:2]
|
p_left = kp_live[idx_l_hip][:2]
|
||||||
p_right = kp_live[idx_r_hip][:2]
|
p_right = kp_live[idx_r_hip][:2]
|
||||||
pelvis_live = (p_left + p_right) / 2
|
pelvis_live = (p_left + p_right) / 2
|
||||||
kp_baseline = self.data['baseline_kp_mean'] + pelvis_live
|
base_raw = self.data['baseline_kp_mean']
|
||||||
|
|
||||||
|
# CRITICAL: Center the baseline template around its own pelvis first
|
||||||
|
# This prevents the "Double Dipping" jump
|
||||||
|
b_l_hip, b_r_hip = base_raw[idx_l_hip], base_raw[idx_r_hip]
|
||||||
|
pelvis_base = (b_l_hip + b_r_hip) / 2
|
||||||
|
|
||||||
|
# New calculation: (Template - its center) + live anchor
|
||||||
|
kp_baseline = (base_raw - pelvis_base) + pelvis_live
|
||||||
|
|
||||||
painter.setPen(QPen(QColor(200, 200, 200, 200), 2, Qt.DashLine))
|
painter.setPen(QPen(QColor(200, 200, 200, 200), 2, Qt.DashLine))
|
||||||
for s_name, e_name in self.CONNECTIONS:
|
for s_name, e_name in self.CONNECTIONS:
|
||||||
@@ -1763,6 +1975,7 @@ class PremiereWindow(QMainWindow):
|
|||||||
# Window instances
|
# Window instances
|
||||||
self.load_window = None
|
self.load_window = None
|
||||||
self.train_window = None
|
self.train_window = None
|
||||||
|
self.export_window = None
|
||||||
self.about = None
|
self.about = None
|
||||||
self.help = None
|
self.help = None
|
||||||
|
|
||||||
@@ -1794,10 +2007,15 @@ class PremiereWindow(QMainWindow):
|
|||||||
|
|
||||||
train_act = QAction("Train Model", self)
|
train_act = QAction("Train Model", self)
|
||||||
train_act.triggered.connect(self.open_train_model_dialog)
|
train_act.triggered.connect(self.open_train_model_dialog)
|
||||||
|
|
||||||
|
config_act = QAction("Configuration Editor", self)
|
||||||
|
config_act.triggered.connect(self.open_model_configuration_tab)
|
||||||
|
|
||||||
|
|
||||||
menu.addAction(load_act)
|
menu.addAction(load_act)
|
||||||
menu.addAction(train_act)
|
menu.addAction(train_act)
|
||||||
|
menu.addAction(config_act)
|
||||||
|
|
||||||
# Show the menu right under the mouse cursor
|
# Show the menu right under the mouse cursor
|
||||||
menu.exec(QCursor.pos())
|
menu.exec(QCursor.pos())
|
||||||
|
|
||||||
@@ -1871,7 +2089,17 @@ class PremiereWindow(QMainWindow):
|
|||||||
# Connect the initialization button from OpenFileWindow to our tab creator
|
# Connect the initialization button from OpenFileWindow to our tab creator
|
||||||
self.train_window.btn_train.clicked.connect(self.handle_start_training)
|
self.train_window.btn_train.clicked.connect(self.handle_start_training)
|
||||||
self.train_window.show()
|
self.train_window.show()
|
||||||
|
|
||||||
|
def open_model_configuration_tab(self):
|
||||||
|
self.handle_model_configuration_session()
|
||||||
|
|
||||||
|
def open_export_data_dialog(self):
|
||||||
|
if self.export_window is None or not self.export_window.isVisible():
|
||||||
|
self.export_window = ExportTimelineJsonWindow(self)
|
||||||
|
# Connect the initialization button from OpenFileWindow to our tab creator
|
||||||
|
# self.export_window.btn_train.clicked.connect(self.handle_start_training)
|
||||||
|
self.export_window.show()
|
||||||
|
|
||||||
|
|
||||||
def handle_start_training(self):
|
def handle_start_training(self):
|
||||||
params = self.train_window.get_selection()
|
params = self.train_window.get_selection()
|
||||||
@@ -1940,6 +2168,19 @@ class PremiereWindow(QMainWindow):
|
|||||||
# 5. Switch to it
|
# 5. Switch to it
|
||||||
self.tabs.setCurrentIndex(new_idx)
|
self.tabs.setCurrentIndex(new_idx)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_model_configuration_session(self):
|
||||||
|
new_tab = ModelParameterConfigurationTab()
|
||||||
|
|
||||||
|
# 4. Handle Tab Placement (Keep '+' at the end)
|
||||||
|
tab_name = "Configuration Editor"
|
||||||
|
plus_idx = self.tabs.count() - 1
|
||||||
|
new_idx = self.tabs.insertTab(plus_idx, new_tab, tab_name)
|
||||||
|
|
||||||
|
# 5. Switch to it
|
||||||
|
self.tabs.setCurrentIndex(new_idx)
|
||||||
|
|
||||||
|
|
||||||
def close_tab(self, index):
|
def close_tab(self, index):
|
||||||
# Prevent closing the Welcome tab if it's the only one left
|
# Prevent closing the Welcome tab if it's the only one left
|
||||||
if index == 0 and self.tabs.count() == 1:
|
if index == 0 and self.tabs.count() == 1:
|
||||||
@@ -2163,12 +2404,17 @@ class VideoAnalysisTab(QWidget):
|
|||||||
self.inspector_scroll.setWidget(self.info_label)
|
self.inspector_scroll.setWidget(self.info_label)
|
||||||
|
|
||||||
# NEW: Export Button for Metrics
|
# NEW: Export Button for Metrics
|
||||||
self.btn_export_metrics = QPushButton("Export Metrics to JSON")
|
self.btn_export_metrics = QPushButton("Export Data for Machine Learning...")
|
||||||
self.btn_export_metrics.clicked.connect(self.export_behavior_metrics)
|
self.btn_export_metrics.clicked.connect(self.export_behavior_metrics)
|
||||||
self.btn_export_metrics.setEnabled(False) # Enable only after load
|
self.btn_export_metrics.setEnabled(False) # Enable only after load
|
||||||
|
|
||||||
|
self.btn_export_flares = QPushButton("Export Timeline Events for FLARES...")
|
||||||
|
self.btn_export_flares.clicked.connect(self.export_timeline_flares)
|
||||||
|
self.btn_export_flares.setEnabled(False) # Enable only after load
|
||||||
|
|
||||||
info_layout.addWidget(self.inspector_scroll)
|
info_layout.addWidget(self.inspector_scroll)
|
||||||
info_layout.addWidget(self.btn_export_metrics)
|
info_layout.addWidget(self.btn_export_metrics)
|
||||||
|
info_layout.addWidget(self.btn_export_flares)
|
||||||
|
|
||||||
top_splitter.addWidget(video_container)
|
top_splitter.addWidget(video_container)
|
||||||
top_splitter.addWidget(info_container)
|
top_splitter.addWidget(info_container)
|
||||||
@@ -2275,6 +2521,23 @@ class VideoAnalysisTab(QWidget):
|
|||||||
|
|
||||||
baseline_mean = np.mean(valid_data, axis=0) if valid_data else np.zeros((17, 2))
|
baseline_mean = np.mean(valid_data, axis=0) if valid_data else np.zeros((17, 2))
|
||||||
|
|
||||||
|
if self.config.get('use_calculations'):
|
||||||
|
dists, velocities = self.generate_automated_tracks(baseline_mean)
|
||||||
|
|
||||||
|
if self.config.get('velocity_enabled'):
|
||||||
|
for joint_name, idx in self.skeleton_overlay.KP_MAP.items():
|
||||||
|
vel_events = self.create_event_blocks(velocities[:, idx], threshold=float(self.config.get('velocity_threshold', 15)))
|
||||||
|
if vel_events:
|
||||||
|
self.processed_data[f"Vel_{joint_name}"] = vel_events
|
||||||
|
|
||||||
|
if self.config.get('deviation_enabled'):
|
||||||
|
# 2. Convert to timeline events (using a threshold of e.g. 50 pixels)
|
||||||
|
for joint_name, idx in self.skeleton_overlay.KP_MAP.items():
|
||||||
|
dev_events = self.create_event_blocks(dists[:, idx], threshold=float(self.config.get('deviation_threshold', 50)))
|
||||||
|
if dev_events:
|
||||||
|
self.processed_data[f"Dev_{joint_name}"] = dev_events
|
||||||
|
|
||||||
|
|
||||||
overlay_payload = {
|
overlay_payload = {
|
||||||
"raw_kps": self.raw_kpts,
|
"raw_kps": self.raw_kpts,
|
||||||
"width": v_w,
|
"width": v_w,
|
||||||
@@ -2308,6 +2571,54 @@ class VideoAnalysisTab(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def generate_automated_tracks(self, baseline_mean):
|
||||||
|
# Convert list of frames to a numpy stack: (frames, joints, xy)
|
||||||
|
raw_stack = np.stack([f[:, :2] for f in self.raw_kpts])
|
||||||
|
|
||||||
|
# 1. Get Live Pelvis (Anchor)
|
||||||
|
l_hip_idx, r_hip_idx = 11, 12 # Adjust based on your KP_MAP
|
||||||
|
pelvis_live = (raw_stack[:, l_hip_idx] + raw_stack[:, r_hip_idx]) / 2
|
||||||
|
|
||||||
|
# 2. Center the Baseline Template
|
||||||
|
pelvis_base = (baseline_mean[l_hip_idx] + baseline_mean[r_hip_idx]) / 2
|
||||||
|
base_template = baseline_mean - pelvis_base
|
||||||
|
|
||||||
|
# 3. Broadcast template across all frames
|
||||||
|
# (frames, 1, 2) + (17, 2) -> (frames, 17, 2)
|
||||||
|
target_pos = pelvis_live[:, np.newaxis, :] + base_template
|
||||||
|
|
||||||
|
# 4. Euclidean Distance
|
||||||
|
dists = np.linalg.norm(raw_stack - target_pos, axis=2)
|
||||||
|
|
||||||
|
# 5. Velocity (Difference between frames)
|
||||||
|
velocities = np.zeros_like(dists)
|
||||||
|
velocities[1:] = np.linalg.norm(np.diff(raw_stack, axis=0), axis=2)
|
||||||
|
|
||||||
|
return dists, velocities
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def create_event_blocks(self, data_array, threshold):
|
||||||
|
events = []
|
||||||
|
active = False
|
||||||
|
start_f = 0
|
||||||
|
|
||||||
|
mask = data_array > threshold
|
||||||
|
for f, is_high in enumerate(mask):
|
||||||
|
if is_high and not active:
|
||||||
|
active = True
|
||||||
|
start_f = f
|
||||||
|
elif not is_high and active:
|
||||||
|
active = False
|
||||||
|
if (f - start_f) > 5: # Filter out noise shorter than 5 frames
|
||||||
|
events.append([start_f, f, "Moderate", "N/A"])
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def on_ai_inference_complete(self, ai_events):
|
def on_ai_inference_complete(self, ai_events):
|
||||||
"""Runs when the thread finishes. Merges AI into the existing UI."""
|
"""Runs when the thread finishes. Merges AI into the existing UI."""
|
||||||
# 1. Merge AI tracks into the dictionary that already has BORIS
|
# 1. Merge AI tracks into the dictionary that already has BORIS
|
||||||
@@ -2398,6 +2709,7 @@ class VideoAnalysisTab(QWidget):
|
|||||||
# 2. Update Stats & Export button
|
# 2. Update Stats & Export button
|
||||||
self.update_inspector_stats(self.processed_data, fps)
|
self.update_inspector_stats(self.processed_data, fps)
|
||||||
self.btn_export_metrics.setEnabled(True)
|
self.btn_export_metrics.setEnabled(True)
|
||||||
|
self.btn_export_flares.setEnabled(True)
|
||||||
|
|
||||||
print(f"DEBUG: UI Synced with {len(self.processed_data)} total tracks.")
|
print(f"DEBUG: UI Synced with {len(self.processed_data)} total tracks.")
|
||||||
|
|
||||||
@@ -2422,6 +2734,15 @@ class VideoAnalysisTab(QWidget):
|
|||||||
self.info_label.setHtml(stats_text)
|
self.info_label.setHtml(stats_text)
|
||||||
|
|
||||||
|
|
||||||
|
def export_timeline_flares(self):
|
||||||
|
export_dialog = ExportTimelineJsonWindow(
|
||||||
|
timeline_data=self.processed_data,
|
||||||
|
fps=self.fps,
|
||||||
|
parent=self
|
||||||
|
)
|
||||||
|
export_dialog.exec()
|
||||||
|
|
||||||
|
|
||||||
def export_behavior_metrics(self):
|
def export_behavior_metrics(self):
|
||||||
"""Exports the processed behavior metrics to a new JSON file."""
|
"""Exports the processed behavior metrics to a new JSON file."""
|
||||||
if not self.processed_data:
|
if not self.processed_data:
|
||||||
@@ -2431,6 +2752,7 @@ class VideoAnalysisTab(QWidget):
|
|||||||
"metadata": {
|
"metadata": {
|
||||||
"source_video": os.path.basename(self.config.get('video_path', 'unknown')),
|
"source_video": os.path.basename(self.config.get('video_path', 'unknown')),
|
||||||
"session": self.config.get('session_key', 'unknown'),
|
"session": self.config.get('session_key', 'unknown'),
|
||||||
|
"pose_model": self.config.get('pose_model', 'unknown'),
|
||||||
"export_timestamp": datetime.now().isoformat(),
|
"export_timestamp": datetime.now().isoformat(),
|
||||||
"fps": self.config.get('fps', 30.0)
|
"fps": self.config.get('fps', 30.0)
|
||||||
},
|
},
|
||||||
@@ -2811,6 +3133,356 @@ class VideoAnalysisTab(QWidget):
|
|||||||
if hasattr(self, 'worker') and self.worker.isRunning():
|
if hasattr(self, 'worker') and self.worker.isRunning():
|
||||||
self.worker.terminate()
|
self.worker.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
------------------------------ It all breaks here ------------------------------
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QSplitter, QListWidget,
|
||||||
|
QGraphicsView, QGraphicsScene, QListWidgetItem)
|
||||||
|
from PySide6.QtCore import Qt, QMimeData
|
||||||
|
from PySide6.QtGui import QDrag
|
||||||
|
|
||||||
|
from PySide6.QtGui import QPainterPath, QColor, QPen, QBrush
|
||||||
|
from PySide6.QtWidgets import QGraphicsPathItem, QGraphicsSimpleTextItem
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QGraphicsItem, QGraphicsSimpleTextItem, QInputDialog
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleBlock(QGraphicsPathItem):
|
||||||
|
def __init__(self, b_type, label, parent_item=None):
|
||||||
|
super().__init__(parent_item)
|
||||||
|
self.b_type = b_type # "begin", "middle", "end"
|
||||||
|
self.label_text = label
|
||||||
|
self.width = 160
|
||||||
|
self.height = 50
|
||||||
|
self.tab_size = 15
|
||||||
|
|
||||||
|
self.setFlags(
|
||||||
|
QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
|
||||||
|
QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
|
||||||
|
QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
||||||
|
)
|
||||||
|
|
||||||
|
self.update_path()
|
||||||
|
|
||||||
|
# Color Coding
|
||||||
|
colors = {"begin": "#2e7d32", "middle": "#1565c0", "end": "#c62828"}
|
||||||
|
self.setBrush(QBrush(QColor(colors.get(b_type, "#555"))))
|
||||||
|
self.setPen(QPen(QColor("#ffffff"), 1))
|
||||||
|
|
||||||
|
self.label_item = QGraphicsSimpleTextItem(self.label_text, self)
|
||||||
|
self.label_item.setBrush(QColor("white"))
|
||||||
|
self.label_item.setPos(25, 15)
|
||||||
|
|
||||||
|
def update_path(self):
|
||||||
|
self.prepareGeometryChange()
|
||||||
|
path = QPainterPath()
|
||||||
|
path.moveTo(0, 0)
|
||||||
|
|
||||||
|
# 1. TOP EDGE
|
||||||
|
# Middle and End blocks now have a "Nub" (Male) on top
|
||||||
|
if self.b_type in ["middle", "end"]:
|
||||||
|
path.lineTo(30, 0)
|
||||||
|
# Nub (Male) - Sweep is negative to curve OUTWARD
|
||||||
|
path.arcTo(30, -self.tab_size/2, self.tab_size, self.tab_size, 180, -180)
|
||||||
|
path.lineTo(self.width, 0)
|
||||||
|
|
||||||
|
# 2. RIGHT EDGE
|
||||||
|
path.lineTo(self.width, self.height)
|
||||||
|
|
||||||
|
# 3. BOTTOM EDGE
|
||||||
|
# Begin and Middle blocks now have a "Hole" (Female) on bottom
|
||||||
|
if self.b_type in ["begin", "middle"]:
|
||||||
|
path.lineTo(30 + self.tab_size, self.height)
|
||||||
|
# Socket (Female) - Sweep is positive to curve INWARD
|
||||||
|
path.arcTo(30, self.height - self.tab_size/2, self.tab_size, self.tab_size, 0, 180)
|
||||||
|
path.lineTo(0, self.height)
|
||||||
|
|
||||||
|
# 4. LEFT EDGE
|
||||||
|
path.lineTo(0, 0)
|
||||||
|
|
||||||
|
path.closeSubpath()
|
||||||
|
self.setPath(path)
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event):
|
||||||
|
if self.b_type == "begin":
|
||||||
|
new_name, ok = QInputDialog.getText(None, "Rename Step", "Enter name:", text=self.label_text)
|
||||||
|
if ok and new_name:
|
||||||
|
self.label_text = new_name
|
||||||
|
self.label_item.setText(new_name)
|
||||||
|
super().mouseDoubleClickEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class BlockLibrary(QListWidget):
|
||||||
|
def __init__(self, parent_tab):
|
||||||
|
super().__init__()
|
||||||
|
self.setDragEnabled(True)
|
||||||
|
self.add_item("Begin Event", "begin")
|
||||||
|
self.add_item("Process Step", "middle")
|
||||||
|
self.add_item("Finish/Export", "end")
|
||||||
|
self.parent_tab = parent_tab # Reference to main tab to access canvas
|
||||||
|
|
||||||
|
def add_item(self, label, block_type):
|
||||||
|
item = QListWidgetItem(label)
|
||||||
|
item.setData(Qt.UserRole, block_type)
|
||||||
|
self.addItem(item)
|
||||||
|
|
||||||
|
def startDrag(self, supportedActions):
|
||||||
|
item = self.currentItem()
|
||||||
|
mime_data = QMimeData()
|
||||||
|
# Pass the type and label as string
|
||||||
|
mime_data.setText(f"{item.data(Qt.UserRole)}|{item.text()}")
|
||||||
|
|
||||||
|
drag = QDrag(self)
|
||||||
|
drag.setMimeData(mime_data)
|
||||||
|
result = drag.exec(supportedActions)
|
||||||
|
|
||||||
|
print(f"DEBUG: Drag finished with result: {result}")
|
||||||
|
|
||||||
|
if result != Qt.IgnoreAction:
|
||||||
|
self.finalize_drop_snapping()
|
||||||
|
|
||||||
|
def finalize_drop_snapping(self):
|
||||||
|
# Grab all blocks and sort by their index in the scene (newest is usually last)
|
||||||
|
all_items = self.parent_tab.canvas.scene.items()
|
||||||
|
puzzle_blocks = [i for i in all_items if isinstance(i, PuzzleBlock)]
|
||||||
|
|
||||||
|
if puzzle_blocks:
|
||||||
|
# We want the block that was just dropped
|
||||||
|
# Usually the first in scene.items() is the top-most/newest
|
||||||
|
newest_block = puzzle_blocks[0]
|
||||||
|
|
||||||
|
# Call the method that was missing
|
||||||
|
self.parent_tab.canvas.perform_snap(newest_block, newest_block.scenePos())
|
||||||
|
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsRectItem, QGraphicsSimpleTextItem
|
||||||
|
from PySide6.QtGui import QPen, QColor, QBrush
|
||||||
|
|
||||||
|
class TopologyCanvas(QGraphicsView):
|
||||||
|
def __init__(self):
|
||||||
|
self.scene = QGraphicsScene()
|
||||||
|
super().__init__(self.scene)
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
self.scene.setSceneRect(0, 0, 2000, 2000)
|
||||||
|
|
||||||
|
self.chain = [] # Track items if needed
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
# 1. Native release
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
# 2. Sidebar Trash Logic
|
||||||
|
global_pos = event.globalPosition().toPoint()
|
||||||
|
widget_under_mouse = QApplication.widgetAt(global_pos)
|
||||||
|
|
||||||
|
print("huh")
|
||||||
|
|
||||||
|
is_over_library = False
|
||||||
|
current_widget = widget_under_mouse
|
||||||
|
while current_widget:
|
||||||
|
if isinstance(current_widget, BlockLibrary):
|
||||||
|
is_over_library = True
|
||||||
|
break
|
||||||
|
current_widget = current_widget.parentWidget()
|
||||||
|
|
||||||
|
if is_over_library:
|
||||||
|
print("Action: Trashing selected items")
|
||||||
|
for item in self.scene.selectedItems():
|
||||||
|
if isinstance(item, PuzzleBlock):
|
||||||
|
self.scene.removeItem(item)
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = [i for i in self.scene.selectedItems() if isinstance(i, PuzzleBlock)]
|
||||||
|
if selected:
|
||||||
|
dragged_item = selected[0]
|
||||||
|
# Use scenePos() for the internal coordinate math
|
||||||
|
self.perform_snap(dragged_item, dragged_item.scenePos())
|
||||||
|
|
||||||
|
self.validate_topology()
|
||||||
|
|
||||||
|
|
||||||
|
def perform_snap(self, dragged_item, pos):
|
||||||
|
""" This is the method the library is looking for. """
|
||||||
|
overlap = 0
|
||||||
|
# Create a search area around the block's current position
|
||||||
|
snap_rect = QRectF(pos.x() - 25, pos.y() - 25, 200, 100)
|
||||||
|
|
||||||
|
items_nearby = self.scene.items(snap_rect)
|
||||||
|
for target in items_nearby:
|
||||||
|
if isinstance(target, PuzzleBlock) and target != dragged_item:
|
||||||
|
|
||||||
|
# SNAP BELOW: Dragged Item is BELOW the target
|
||||||
|
if dragged_item.b_type in ["middle", "end"] and target.b_type in ["begin", "middle"]:
|
||||||
|
dragged_item.setPos(target.scenePos() + QPointF(0, target.height - overlap))
|
||||||
|
print(f"DEBUG: {dragged_item.label_text} snapped to BOTTOM of {target.label_text}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# SNAP ABOVE: Dragged Item is ABOVE the target
|
||||||
|
elif dragged_item.b_type in ["begin", "middle"] and target.b_type in ["middle", "end"]:
|
||||||
|
dragged_item.setPos(target.scenePos() - QPointF(0, dragged_item.height - overlap))
|
||||||
|
print(f"DEBUG: {dragged_item.label_text} snapped to TOP of {target.label_text}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def dropEvent(self, event):
|
||||||
|
raw_data = event.mimeData().text()
|
||||||
|
if "|" not in raw_data: return
|
||||||
|
|
||||||
|
# Only process drops from the Library (New blocks)
|
||||||
|
if isinstance(event.source(), BlockLibrary):
|
||||||
|
block_type, label = raw_data.split("|")
|
||||||
|
pos = self.mapToScene(event.position().toPoint())
|
||||||
|
|
||||||
|
new_block = PuzzleBlock(block_type, label)
|
||||||
|
new_block.setPos(pos)
|
||||||
|
self.scene.addItem(new_block)
|
||||||
|
self.validate_topology()
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event):
|
||||||
|
if event.mimeData().hasText():
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dragMoveEvent(self, event):
|
||||||
|
if event.mimeData().hasText():
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def validate_topology(self):
|
||||||
|
all_items = [i for i in self.scene.items() if isinstance(i, PuzzleBlock)]
|
||||||
|
has_begin = any(i.b_type == "begin" for i in all_items)
|
||||||
|
has_end = any(i.b_type == "end" for i in all_items)
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
if not has_begin:
|
||||||
|
warnings.append("⚠️ Missing 'Begin' block (No data source defined).")
|
||||||
|
if not has_end:
|
||||||
|
warnings.append("⚠️ Missing 'Finish' block (Data will not be saved).")
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
status_text = "\n".join(warnings)
|
||||||
|
#self.parent().info_label.setText(f"<font color='red'>{status_text}</font>")
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
#self.parent().info_label.setText("<font color='#00ff00'>✅ Configuration Valid</font>")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelParameterConfigurationTab(QWidget):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QMainWindow, QWidget#centralWidget {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
}
|
||||||
|
QLabel, QStatusBar, QMenuBar {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
/* Target the Timeline specifically */
|
||||||
|
TimelineWidget {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border: 1px solid #333333;
|
||||||
|
}
|
||||||
|
/* Button styling with Grey borders */
|
||||||
|
QDialog, QMessageBox, QFileDialog {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
}
|
||||||
|
QDialog QLabel, QMessageBox QLabel {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #555555; /* Subtle Grey border */
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #3d3d3d;
|
||||||
|
border-color: #888888; /* Brightens border on hover */
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: #111111;
|
||||||
|
}
|
||||||
|
QPushButton:disabled {
|
||||||
|
border-color: #333333;
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
/* Splitter/Divider styling */
|
||||||
|
QSplitter::handle {
|
||||||
|
background-color: #333333; /* Dark grey dividers */
|
||||||
|
}
|
||||||
|
QSplitter::handle:horizontal {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
QSplitter::handle:vertical {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
/* ScrollArea styling to keep it dark */
|
||||||
|
QScrollArea, QScrollArea > QWidget > QWidget {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
self.setup_ui()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
# Main horizontal split: [ Canvas | Library/Inspector ]
|
||||||
|
self.horizontal_splitter = QSplitter(Qt.Horizontal)
|
||||||
|
|
||||||
|
# --- LEFT: Topology Canvas ---
|
||||||
|
self.canvas = TopologyCanvas()
|
||||||
|
|
||||||
|
# --- RIGHT: Sidebar (Library + Inspector) ---
|
||||||
|
sidebar_container = QWidget()
|
||||||
|
sidebar_layout = QVBoxLayout(sidebar_container)
|
||||||
|
|
||||||
|
self.block_library = BlockLibrary(parent_tab=self)
|
||||||
|
|
||||||
|
# Reuse your existing Inspector logic
|
||||||
|
self.inspector_scroll = QScrollArea()
|
||||||
|
self.inspector_scroll.setWidgetResizable(True)
|
||||||
|
self.info_label = QTextEdit()
|
||||||
|
self.info_label.setReadOnly(True)
|
||||||
|
self.info_label.setStyleSheet("background-color: #2b2b2b; color: #ffffff;")
|
||||||
|
self.inspector_scroll.setWidget(self.info_label)
|
||||||
|
|
||||||
|
sidebar_layout.addWidget(QLabel("Block Library"))
|
||||||
|
sidebar_layout.addWidget(self.block_library, 2) # Library gets more space
|
||||||
|
sidebar_layout.addWidget(QLabel("Properties"))
|
||||||
|
sidebar_layout.addWidget(self.inspector_scroll, 1)
|
||||||
|
|
||||||
|
# Add to main splitter
|
||||||
|
self.horizontal_splitter.addWidget(self.canvas)
|
||||||
|
self.horizontal_splitter.addWidget(sidebar_container)
|
||||||
|
self.horizontal_splitter.setSizes([800, 300])
|
||||||
|
|
||||||
|
main_layout.addWidget(self.horizontal_splitter)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def resource_path(relative_path):
|
def resource_path(relative_path):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user