stereo-charuco-pipeline 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1838 @@
1
+ """
2
+ Simplified Pipeline UI: Auto-Calibrate -> Record -> Auto-Reconstruct
3
+
4
+ One-click workflow that replaces caliscope's manual 5-tab process.
5
+ Uses the same dark theme and threading patterns as calibration_ui.py.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import queue
16
+ import time
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import tkinter as tk
22
+ from tkinter import ttk, messagebox
23
+
24
+ try:
25
+ import cv2
26
+ import numpy as np
27
+ CV2_AVAILABLE = True
28
+ except ImportError:
29
+ CV2_AVAILABLE = False
30
+
31
+ try:
32
+ import matplotlib
33
+ matplotlib.use("TkAgg")
34
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
35
+ from matplotlib.figure import Figure
36
+ from mpl_toolkits.mplot3d import Axes3D # noqa: F401
37
+ MPL_AVAILABLE = True
38
+ except ImportError:
39
+ MPL_AVAILABLE = False
40
+
41
+ from .config import load_yaml_config
42
+ from .calibration_ui import (
43
+ CalibConfig,
44
+ CameraPreview,
45
+ FFmpegRecorder,
46
+ PostProcessor,
47
+ )
48
+ from .viz_3d import Viz3DData, load_xyz_csv, load_wireframe_for_tracker, render_frame
49
+ from .smart_recorder import SmartRecorder, SmartRecorderConfig
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ # ============================================================================
54
+ # Constants
55
+ # ============================================================================
56
+
57
+ BG = "#2b2b2b"
58
+ BG_PANEL = "#333333"
59
+ FG = "#e0e0e0"
60
+ FG_DIM = "#888888"
61
+ ACCENT = "#4CAF50"
62
+ ACCENT_BLUE = "#2196F3"
63
+ ACCENT_ORANGE = "#FF9800"
64
+ ACCENT_RED = "#f44336"
65
+
66
+
67
+ # ============================================================================
68
+ # Pipeline UI
69
+ # ============================================================================
70
+
71
+ class PipelineUI(tk.Tk):
72
+ """Simplified pipeline: calibrate -> record -> reconstruct."""
73
+
74
+ def __init__(self, config_path: Optional[Path] = None,
75
+ project_dir: Optional[Path] = None):
76
+ super().__init__()
77
+
78
+ self.geometry("1050x960")
79
+ self.configure(bg=BG)
80
+
81
+ # Message queue for thread -> UI communication
82
+ self._msg_queue: queue.Queue = queue.Queue()
83
+
84
+ # Load configuration
85
+ self._load_config(config_path, project_dir_override=project_dir)
86
+
87
+ # State
88
+ self._state = "idle" # idle | calibrating | calibrated | recording | processing | reconstructing
89
+ self._camera: Optional[CameraPreview] = None
90
+ self._recorder: Optional[FFmpegRecorder] = None
91
+ self._session_dir: Optional[Path] = None
92
+ self._recording_name: Optional[str] = None
93
+
94
+ # Auto-monitoring state
95
+ self._smart_recorder: Optional[SmartRecorder] = None
96
+ self._auto_event_queue: queue.Queue = queue.Queue()
97
+ self._auto_pending_visits: list = [] # visits awaiting post-processing
98
+ self._auto_timer_id: Optional[str] = None
99
+
100
+ # Dining zone state
101
+ self._zone_data: Optional[dict] = None # {x1, y1, x2, y2} normalized
102
+ self._zone_drawing = False
103
+ self._zone_start: Optional[tuple] = None
104
+ self._preview_scale = 1.0
105
+ self._preview_x_offset = 0
106
+ self._preview_y_offset = 0
107
+ self._preview_left_w = 1600 # left camera width in source image pixels
108
+ self._preview_left_h = 1200 # left camera height in source image pixels
109
+
110
+ # Visualization state
111
+ self._viz_data: Optional[Viz3DData] = None
112
+ self._viz_playing = False
113
+ self._viz_frame_idx = 0
114
+ self._viz_speed = 1.0
115
+ self._viz_timer_id: Optional[str] = None
116
+
117
+ # Build UI
118
+ self._build_ui()
119
+
120
+ # Start update loop
121
+ self.after(50, self._update_loop)
122
+
123
+ # Check if already calibrated
124
+ self._check_existing_calibration()
125
+
126
+ def _load_config(self, config_path: Optional[Path],
127
+ project_dir_override: Optional[Path] = None):
128
+ """Load configuration from YAML."""
129
+ if config_path is None:
130
+ from .paths import resolve_default_config
131
+ config_path = resolve_default_config()
132
+
133
+ self._yaml_data = load_yaml_config(config_path)
134
+ self.config = CalibConfig.from_yaml(config_path)
135
+
136
+ # Use explicit project_dir if provided
137
+ if project_dir_override:
138
+ self._project_dir = Path(project_dir_override)
139
+ self.title(f"Stereo Pipeline - {self._project_dir.name}")
140
+ logger.info(f"Project directory (override): {self._project_dir}")
141
+ return
142
+
143
+ self.title("Stereo Pipeline - Calibrate & Record")
144
+
145
+ # Resolve project directory from YAML
146
+ project_cfg = self._yaml_data.get("project", {})
147
+ project_dir = project_cfg.get("project_dir", "")
148
+ if not project_dir:
149
+ # Derive from output.base_dir
150
+ output_base = self._yaml_data.get("output", {}).get("base_dir", "")
151
+ if output_base:
152
+ base = Path(output_base)
153
+ if not base.is_absolute():
154
+ base = Path.cwd() / base
155
+ self._project_dir = base.parent # recordings -> parent = caliscope_project
156
+ else:
157
+ self._project_dir = Path(".")
158
+ else:
159
+ p = Path(project_dir)
160
+ if not p.is_absolute():
161
+ p = Path.cwd() / p
162
+ self._project_dir = p
163
+
164
+ logger.info(f"Project directory: {self._project_dir}")
165
+
166
+ def _check_existing_calibration(self):
167
+ """Check if calibration data already exists."""
168
+ camera_array_path = self._project_dir / "camera_array.toml"
169
+ charuco_dir = self._project_dir / "calibration" / "extrinsic" / "CHARUCO"
170
+ bundle_exists = (charuco_dir / "camera_array.toml").exists()
171
+
172
+ # Load existing dining zone if available
173
+ self._zone_data = self._load_dining_zone()
174
+
175
+ if camera_array_path.exists() and bundle_exists:
176
+ self._state = "calibrated"
177
+ self._update_state_display()
178
+ if self._zone_data:
179
+ self._log("Existing calibration and dining zone found. Ready to record.")
180
+ else:
181
+ self._log("Existing calibration found. Mark dining zone before recording.")
182
+
183
+ # ========================================================================
184
+ # UI Building
185
+ # ========================================================================
186
+
187
+ def _build_ui(self):
188
+ """Build the main UI layout."""
189
+ # Scrollable container
190
+ outer = tk.Frame(self, bg=BG)
191
+ outer.pack(fill=tk.BOTH, expand=True)
192
+
193
+ canvas_scroll = tk.Canvas(outer, bg=BG, highlightthickness=0)
194
+ scrollbar = ttk.Scrollbar(outer, orient="vertical", command=canvas_scroll.yview)
195
+ main_frame = tk.Frame(canvas_scroll, bg=BG)
196
+
197
+ main_frame.bind(
198
+ "<Configure>",
199
+ lambda e: canvas_scroll.configure(scrollregion=canvas_scroll.bbox("all")),
200
+ )
201
+ canvas_scroll.create_window((0, 0), window=main_frame, anchor="nw", tags="inner")
202
+ canvas_scroll.configure(yscrollcommand=scrollbar.set)
203
+
204
+ scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
205
+ canvas_scroll.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
206
+
207
+ # Bind mousewheel scrolling
208
+ def _on_mousewheel(event):
209
+ canvas_scroll.yview_scroll(int(-1 * (event.delta / 120)), "units")
210
+ canvas_scroll.bind_all("<MouseWheel>", _on_mousewheel)
211
+
212
+ # Make inner frame fill the canvas width
213
+ def _on_canvas_resize(event):
214
+ canvas_scroll.itemconfig("inner", width=event.width)
215
+ canvas_scroll.bind("<Configure>", _on_canvas_resize)
216
+
217
+ self._main_canvas = canvas_scroll
218
+ self._main_inner = main_frame
219
+
220
+ # Add padding inside scrollable area
221
+ main_frame.config(padx=10, pady=10)
222
+
223
+ # Title
224
+ tk.Label(
225
+ main_frame, text="Stereo Pipeline",
226
+ font=("Segoe UI", 18, "bold"), fg=FG, bg=BG,
227
+ ).pack(pady=(0, 5))
228
+
229
+ tk.Label(
230
+ main_frame,
231
+ text=f"Project: {self._project_dir.name}",
232
+ font=("Segoe UI", 10), fg=FG_DIM, bg=BG,
233
+ ).pack(pady=(0, 10))
234
+
235
+ # ── Panel 1: Calibration ──────────────────────────────────────
236
+ calib_frame = tk.LabelFrame(
237
+ main_frame, text=" 1. Auto Calibration ",
238
+ font=("Segoe UI", 11, "bold"), fg=ACCENT, bg=BG_PANEL,
239
+ bd=1, relief="groove",
240
+ )
241
+ calib_frame.pack(fill=tk.X, pady=(0, 8))
242
+
243
+ calib_inner = tk.Frame(calib_frame, bg=BG_PANEL)
244
+ calib_inner.pack(fill=tk.X, padx=10, pady=8)
245
+
246
+ # Status indicator
247
+ status_frame = tk.Frame(calib_inner, bg=BG_PANEL)
248
+ status_frame.pack(fill=tk.X)
249
+
250
+ self._calib_status_dot = tk.Label(
251
+ status_frame, text="\u25CF", font=("Segoe UI", 14),
252
+ fg=FG_DIM, bg=BG_PANEL,
253
+ )
254
+ self._calib_status_dot.pack(side=tk.LEFT, padx=(0, 5))
255
+
256
+ self._calib_status_label = tk.Label(
257
+ status_frame, text="Not calibrated",
258
+ font=("Segoe UI", 10), fg=FG_DIM, bg=BG_PANEL,
259
+ )
260
+ self._calib_status_label.pack(side=tk.LEFT)
261
+
262
+ # Calibrate button
263
+ self.btn_calibrate = tk.Button(
264
+ status_frame, text="Auto Calibrate",
265
+ font=("Segoe UI", 10, "bold"),
266
+ bg=ACCENT, fg="white", activebackground="#388E3C",
267
+ relief="flat", padx=15, pady=4,
268
+ command=self._on_calibrate,
269
+ )
270
+ self.btn_calibrate.pack(side=tk.RIGHT)
271
+
272
+ # Progress bar
273
+ self._calib_progress = ttk.Progressbar(
274
+ calib_inner, mode="determinate", maximum=100,
275
+ )
276
+ self._calib_progress.pack(fill=tk.X, pady=(8, 0))
277
+
278
+ self._calib_step_label = tk.Label(
279
+ calib_inner, text="", font=("Segoe UI", 9), fg=FG_DIM, bg=BG_PANEL,
280
+ anchor="w",
281
+ )
282
+ self._calib_step_label.pack(fill=tk.X)
283
+
284
+ # ── Panel 2: Recording ────────────────────────────────────────
285
+ rec_frame = tk.LabelFrame(
286
+ main_frame, text=" 2. Record Session ",
287
+ font=("Segoe UI", 11, "bold"), fg=ACCENT_BLUE, bg=BG_PANEL,
288
+ bd=1, relief="groove",
289
+ )
290
+ rec_frame.pack(fill=tk.X, pady=(0, 8))
291
+
292
+ rec_inner = tk.Frame(rec_frame, bg=BG_PANEL)
293
+ rec_inner.pack(fill=tk.X, padx=10, pady=8)
294
+
295
+ # ── Dining Zone section ──
296
+ zone_row = tk.Frame(rec_inner, bg=BG_PANEL)
297
+ zone_row.pack(fill=tk.X, pady=(0, 6))
298
+
299
+ self._zone_status_dot = tk.Label(
300
+ zone_row, text="\u25CF", font=("Segoe UI", 11),
301
+ fg=FG_DIM, bg=BG_PANEL,
302
+ )
303
+ self._zone_status_dot.pack(side=tk.LEFT, padx=(0, 4))
304
+
305
+ self._zone_status_label = tk.Label(
306
+ zone_row, text="Dining Zone: not defined",
307
+ font=("Segoe UI", 10), fg=FG_DIM, bg=BG_PANEL,
308
+ )
309
+ self._zone_status_label.pack(side=tk.LEFT)
310
+
311
+ self.btn_clear_zone = tk.Button(
312
+ zone_row, text="Clear",
313
+ font=("Segoe UI", 9), bg="#555", fg=FG,
314
+ relief="flat", padx=8, pady=2,
315
+ command=self._on_clear_zone, state=tk.DISABLED,
316
+ )
317
+ self.btn_clear_zone.pack(side=tk.RIGHT, padx=2)
318
+
319
+ self.btn_mark_zone = tk.Button(
320
+ zone_row, text="Mark Zone",
321
+ font=("Segoe UI", 9, "bold"), bg=ACCENT, fg="white",
322
+ relief="flat", padx=10, pady=2,
323
+ command=self._on_mark_zone, state=tk.DISABLED,
324
+ )
325
+ self.btn_mark_zone.pack(side=tk.RIGHT, padx=2)
326
+
327
+ # Camera controls row
328
+ cam_row = tk.Frame(rec_inner, bg=BG_PANEL)
329
+ cam_row.pack(fill=tk.X)
330
+
331
+ tk.Label(
332
+ cam_row, text="Camera Index:",
333
+ font=("Segoe UI", 10), fg=FG, bg=BG_PANEL,
334
+ ).pack(side=tk.LEFT)
335
+
336
+ self.var_cam_index = tk.IntVar(value=0)
337
+ self._cam_spin = tk.Spinbox(
338
+ cam_row, from_=0, to=10, width=4,
339
+ textvariable=self.var_cam_index,
340
+ font=("Segoe UI", 10),
341
+ )
342
+ self._cam_spin.pack(side=tk.LEFT, padx=5)
343
+
344
+ self.btn_preview = tk.Button(
345
+ cam_row, text="Preview",
346
+ font=("Segoe UI", 9), bg="#555", fg=FG,
347
+ relief="flat", padx=10, pady=2,
348
+ command=self._on_toggle_preview,
349
+ )
350
+ self.btn_preview.pack(side=tk.LEFT, padx=5)
351
+
352
+ # Mode toggle row (Manual / Auto Monitor)
353
+ mode_row = tk.Frame(rec_inner, bg=BG_PANEL)
354
+ mode_row.pack(fill=tk.X, pady=(6, 0))
355
+
356
+ tk.Label(
357
+ mode_row, text="Mode:",
358
+ font=("Segoe UI", 10), fg=FG, bg=BG_PANEL,
359
+ ).pack(side=tk.LEFT)
360
+
361
+ self.var_rec_mode = tk.StringVar(value="manual")
362
+ tk.Radiobutton(
363
+ mode_row, text="Manual", variable=self.var_rec_mode,
364
+ value="manual", command=self._on_rec_mode_change,
365
+ font=("Segoe UI", 9), fg=FG, bg=BG_PANEL,
366
+ selectcolor=BG, activebackground=BG_PANEL, activeforeground=FG,
367
+ ).pack(side=tk.LEFT, padx=(8, 4))
368
+ tk.Radiobutton(
369
+ mode_row, text="Auto Monitor", variable=self.var_rec_mode,
370
+ value="auto", command=self._on_rec_mode_change,
371
+ font=("Segoe UI", 9), fg=FG, bg=BG_PANEL,
372
+ selectcolor=BG, activebackground=BG_PANEL, activeforeground=FG,
373
+ ).pack(side=tk.LEFT, padx=4)
374
+
375
+ # ── Manual recording controls ──
376
+ self._manual_frame = tk.Frame(rec_inner, bg=BG_PANEL)
377
+ self._manual_frame.pack(fill=tk.X, pady=(4, 0))
378
+
379
+ manual_btn_row = tk.Frame(self._manual_frame, bg=BG_PANEL)
380
+ manual_btn_row.pack(fill=tk.X)
381
+
382
+ self.btn_record = tk.Button(
383
+ manual_btn_row, text="Start Recording",
384
+ font=("Segoe UI", 10, "bold"),
385
+ bg=ACCENT_RED, fg="white", activebackground="#C62828",
386
+ relief="flat", padx=15, pady=4,
387
+ state=tk.DISABLED,
388
+ command=self._on_record,
389
+ )
390
+ self.btn_record.pack(side=tk.RIGHT)
391
+
392
+ self.btn_stop = tk.Button(
393
+ manual_btn_row, text="Stop",
394
+ font=("Segoe UI", 10),
395
+ bg="#666", fg=FG,
396
+ relief="flat", padx=10, pady=4,
397
+ state=tk.DISABLED,
398
+ command=self._on_stop,
399
+ )
400
+ self.btn_stop.pack(side=tk.RIGHT, padx=5)
401
+
402
+ # ── Auto monitor controls ──
403
+ self._auto_frame = tk.Frame(rec_inner, bg=BG_PANEL)
404
+ # Hidden by default (manual mode)
405
+
406
+ auto_btn_row = tk.Frame(self._auto_frame, bg=BG_PANEL)
407
+ auto_btn_row.pack(fill=tk.X)
408
+
409
+ self.btn_start_monitor = tk.Button(
410
+ auto_btn_row, text="Start Monitoring",
411
+ font=("Segoe UI", 10, "bold"),
412
+ bg=ACCENT_BLUE, fg="white", activebackground="#1976D2",
413
+ relief="flat", padx=15, pady=4,
414
+ state=tk.DISABLED,
415
+ command=self._on_start_monitor,
416
+ )
417
+ self.btn_start_monitor.pack(side=tk.LEFT)
418
+
419
+ self.btn_stop_monitor = tk.Button(
420
+ auto_btn_row, text="Stop Monitoring",
421
+ font=("Segoe UI", 10),
422
+ bg="#666", fg=FG,
423
+ relief="flat", padx=10, pady=4,
424
+ state=tk.DISABLED,
425
+ command=self._on_stop_monitor,
426
+ )
427
+ self.btn_stop_monitor.pack(side=tk.LEFT, padx=5)
428
+
429
+ # Auto-monitor status display
430
+ self._auto_status_frame = tk.Frame(self._auto_frame, bg=BG_PANEL)
431
+ self._auto_status_frame.pack(fill=tk.X, pady=(4, 0))
432
+
433
+ self._auto_state_dot = tk.Label(
434
+ self._auto_status_frame, text="\u25CF", font=("Segoe UI", 12),
435
+ fg=FG_DIM, bg=BG_PANEL,
436
+ )
437
+ self._auto_state_dot.pack(side=tk.LEFT, padx=(0, 4))
438
+
439
+ self._auto_state_label = tk.Label(
440
+ self._auto_status_frame, text="Idle",
441
+ font=("Segoe UI", 10), fg=FG_DIM, bg=BG_PANEL,
442
+ )
443
+ self._auto_state_label.pack(side=tk.LEFT)
444
+
445
+ self._auto_visit_label = tk.Label(
446
+ self._auto_status_frame, text="Visits: 0",
447
+ font=("Segoe UI", 9), fg=FG_DIM, bg=BG_PANEL,
448
+ )
449
+ self._auto_visit_label.pack(side=tk.RIGHT)
450
+
451
+ self._auto_timer_label = tk.Label(
452
+ self._auto_status_frame, text="",
453
+ font=("Segoe UI", 9), fg=FG_DIM, bg=BG_PANEL,
454
+ )
455
+ self._auto_timer_label.pack(side=tk.RIGHT, padx=(0, 10))
456
+
457
+ # Detection settings sub-frame
458
+ det_frame = tk.LabelFrame(
459
+ self._auto_frame, text=" Detection Settings ",
460
+ font=("Segoe UI", 9), fg=FG_DIM, bg=BG_PANEL,
461
+ bd=1, relief="groove",
462
+ )
463
+ det_frame.pack(fill=tk.X, pady=(4, 0))
464
+
465
+ det_grid = tk.Frame(det_frame, bg=BG_PANEL)
466
+ det_grid.pack(fill=tk.X, padx=6, pady=4)
467
+
468
+ # Row 1: detection_interval | absence_threshold
469
+ tk.Label(det_grid, text="Detect every:", font=("Segoe UI", 9),
470
+ fg=FG_DIM, bg=BG_PANEL).grid(row=0, column=0, sticky="w")
471
+ self.var_det_interval = tk.IntVar(value=5)
472
+ tk.Spinbox(det_grid, from_=1, to=30, width=4,
473
+ textvariable=self.var_det_interval,
474
+ font=("Segoe UI", 9),
475
+ command=lambda: self._on_detection_param_change(
476
+ "detection_interval", self.var_det_interval.get()),
477
+ ).grid(row=0, column=1, padx=4)
478
+ tk.Label(det_grid, text="frames", font=("Segoe UI", 9),
479
+ fg=FG_DIM, bg=BG_PANEL).grid(row=0, column=2, sticky="w")
480
+
481
+ tk.Label(det_grid, text="Miss threshold:", font=("Segoe UI", 9),
482
+ fg=FG_DIM, bg=BG_PANEL).grid(row=0, column=3, sticky="w", padx=(12, 0))
483
+ self.var_absence = tk.IntVar(value=5)
484
+ tk.Spinbox(det_grid, from_=1, to=30, width=4,
485
+ textvariable=self.var_absence,
486
+ font=("Segoe UI", 9),
487
+ command=lambda: self._on_detection_param_change(
488
+ "absence_threshold", self.var_absence.get()),
489
+ ).grid(row=0, column=4, padx=4)
490
+
491
+ # Row 2: cooldown_seconds | hog_hit_threshold
492
+ tk.Label(det_grid, text="Cooldown:", font=("Segoe UI", 9),
493
+ fg=FG_DIM, bg=BG_PANEL).grid(row=1, column=0, sticky="w", pady=(4, 0))
494
+ self.var_cooldown = tk.IntVar(value=30)
495
+ tk.Spinbox(det_grid, from_=5, to=120, width=4,
496
+ textvariable=self.var_cooldown,
497
+ font=("Segoe UI", 9),
498
+ command=lambda: self._on_detection_param_change(
499
+ "cooldown_seconds", float(self.var_cooldown.get())),
500
+ ).grid(row=1, column=1, padx=4, pady=(4, 0))
501
+ tk.Label(det_grid, text="sec", font=("Segoe UI", 9),
502
+ fg=FG_DIM, bg=BG_PANEL).grid(row=1, column=2, sticky="w", pady=(4, 0))
503
+
504
+ tk.Label(det_grid, text="HOG threshold:", font=("Segoe UI", 9),
505
+ fg=FG_DIM, bg=BG_PANEL).grid(row=1, column=3, sticky="w", padx=(12, 0), pady=(4, 0))
506
+ self.var_hog_thresh = tk.DoubleVar(value=0.0)
507
+ self._hog_scale = tk.Scale(
508
+ det_grid, from_=-1.0, to=2.0, resolution=0.1,
509
+ orient=tk.HORIZONTAL, variable=self.var_hog_thresh,
510
+ bg=BG_PANEL, fg=FG, highlightthickness=0, troughcolor="#555",
511
+ length=100, showvalue=True,
512
+ command=lambda v: self._on_detection_param_change(
513
+ "hog_hit_threshold", float(v)),
514
+ )
515
+ self._hog_scale.grid(row=1, column=4, padx=4, pady=(4, 0))
516
+
517
+ # Camera preview canvas
518
+ self.preview_canvas = tk.Canvas(
519
+ rec_inner, bg="#1a1a1a", height=250,
520
+ highlightthickness=0,
521
+ )
522
+ self.preview_canvas.pack(fill=tk.X, pady=(8, 0))
523
+
524
+ # Recording timer
525
+ self._rec_timer_label = tk.Label(
526
+ rec_inner, text="", font=("Segoe UI", 10), fg=FG_DIM, bg=BG_PANEL,
527
+ )
528
+ self._rec_timer_label.pack(fill=tk.X)
529
+
530
+ # ── Panel 3: Reconstruction ───────────────────────────────────
531
+ recon_frame = tk.LabelFrame(
532
+ main_frame, text=" 3. 3D Reconstruction ",
533
+ font=("Segoe UI", 11, "bold"), fg=ACCENT_ORANGE, bg=BG_PANEL,
534
+ bd=1, relief="groove",
535
+ )
536
+ recon_frame.pack(fill=tk.X, pady=(0, 8))
537
+
538
+ recon_inner = tk.Frame(recon_frame, bg=BG_PANEL)
539
+ recon_inner.pack(fill=tk.X, padx=10, pady=8)
540
+
541
+ recon_row = tk.Frame(recon_inner, bg=BG_PANEL)
542
+ recon_row.pack(fill=tk.X)
543
+
544
+ tk.Label(
545
+ recon_row, text="Recording:",
546
+ font=("Segoe UI", 10), fg=FG, bg=BG_PANEL,
547
+ ).pack(side=tk.LEFT)
548
+
549
+ self.var_recording = tk.StringVar()
550
+ self._rec_combo = ttk.Combobox(
551
+ recon_row, textvariable=self.var_recording,
552
+ state="readonly", width=30,
553
+ )
554
+ self._rec_combo.pack(side=tk.LEFT, padx=5)
555
+
556
+ self.btn_refresh_rec = tk.Button(
557
+ recon_row, text="Refresh",
558
+ font=("Segoe UI", 9), bg="#555", fg=FG,
559
+ relief="flat", padx=8, pady=2,
560
+ command=self._refresh_recordings,
561
+ )
562
+ self.btn_refresh_rec.pack(side=tk.LEFT, padx=2)
563
+
564
+ # Tracker type selection row
565
+ tracker_row = tk.Frame(recon_inner, bg=BG_PANEL)
566
+ tracker_row.pack(fill=tk.X, pady=(6, 0))
567
+
568
+ tk.Label(
569
+ tracker_row, text="Tracker:",
570
+ font=("Segoe UI", 10), fg=FG, bg=BG_PANEL,
571
+ ).pack(side=tk.LEFT)
572
+
573
+ self.var_tracker = tk.StringVar(value="HOLISTIC")
574
+ self._tracker_combo = ttk.Combobox(
575
+ tracker_row, textvariable=self.var_tracker,
576
+ state="readonly", width=20,
577
+ )
578
+ self._tracker_combo.pack(side=tk.LEFT, padx=5)
579
+ self._load_tracker_options()
580
+
581
+ self.btn_reconstruct = tk.Button(
582
+ tracker_row, text="Reconstruct 3D",
583
+ font=("Segoe UI", 10, "bold"),
584
+ bg=ACCENT_ORANGE, fg="white", activebackground="#E65100",
585
+ relief="flat", padx=15, pady=4,
586
+ state=tk.DISABLED,
587
+ command=self._on_reconstruct,
588
+ )
589
+ self.btn_reconstruct.pack(side=tk.RIGHT)
590
+
591
+ self._recon_progress = ttk.Progressbar(
592
+ recon_inner, mode="determinate", maximum=100,
593
+ )
594
+ self._recon_progress.pack(fill=tk.X, pady=(8, 0))
595
+
596
+ self._recon_label = tk.Label(
597
+ recon_inner, text="", font=("Segoe UI", 9), fg=FG_DIM, bg=BG_PANEL,
598
+ anchor="w",
599
+ )
600
+ self._recon_label.pack(fill=tk.X)
601
+
602
+ # ── Panel 4: 3D Visualization ──────────────────────────────────
603
+ self._build_viz_panel(main_frame)
604
+
605
+ # ── Log Panel ─────────────────────────────────────────────────
606
+ log_frame = tk.LabelFrame(
607
+ main_frame, text=" Log ",
608
+ font=("Segoe UI", 10), fg=FG_DIM, bg=BG_PANEL,
609
+ bd=1, relief="groove",
610
+ )
611
+ log_frame.pack(fill=tk.X)
612
+
613
+ self._log_text = tk.Text(
614
+ log_frame, bg="#1a1a1a", fg="#aaaaaa",
615
+ font=("Consolas", 9), height=8,
616
+ state=tk.DISABLED, wrap=tk.WORD,
617
+ highlightthickness=0, bd=0,
618
+ )
619
+ self._log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
620
+
621
+ # Initial recordings refresh
622
+ self._refresh_recordings()
623
+
624
+ # ========================================================================
625
+ # State Display
626
+ # ========================================================================
627
+
628
+ def _update_state_display(self):
629
+ """Update UI elements based on current state."""
630
+ if self._state == "idle":
631
+ self._calib_status_dot.config(fg=FG_DIM)
632
+ self._calib_status_label.config(text="Not calibrated", fg=FG_DIM)
633
+ self.btn_calibrate.config(state=tk.NORMAL, text="Auto Calibrate")
634
+ self.btn_record.config(state=tk.DISABLED)
635
+ self.btn_reconstruct.config(state=tk.DISABLED)
636
+
637
+ elif self._state == "calibrating":
638
+ self._calib_status_dot.config(fg=ACCENT_ORANGE)
639
+ self._calib_status_label.config(text="Calibrating...", fg=ACCENT_ORANGE)
640
+ self.btn_calibrate.config(state=tk.DISABLED, text="Calibrating...")
641
+ self.btn_record.config(state=tk.DISABLED)
642
+
643
+ elif self._state == "calibrated":
644
+ self._calib_status_dot.config(fg=ACCENT)
645
+ self._calib_status_label.config(text="Calibrated", fg=ACCENT)
646
+ self.btn_calibrate.config(state=tk.NORMAL, text="Re-calibrate")
647
+ # Enable zone marking
648
+ self.btn_mark_zone.config(state=tk.NORMAL)
649
+ # Gate recording behind zone check
650
+ if self._zone_data:
651
+ self.btn_record.config(state=tk.NORMAL)
652
+ self.btn_start_monitor.config(state=tk.NORMAL)
653
+ self._zone_status_dot.config(fg=ACCENT)
654
+ self._zone_status_label.config(text="Dining Zone: defined", fg=ACCENT)
655
+ self.btn_clear_zone.config(state=tk.NORMAL)
656
+ else:
657
+ self.btn_record.config(state=tk.DISABLED)
658
+ self.btn_start_monitor.config(state=tk.DISABLED)
659
+ self._zone_status_dot.config(fg=FG_DIM)
660
+ self._zone_status_label.config(
661
+ text="Dining Zone: not defined (mark zone first)", fg=ACCENT_ORANGE)
662
+ self._calib_progress["value"] = 100
663
+ self._refresh_recordings()
664
+ # Enable reconstruct if recordings available
665
+ if self._rec_combo["values"]:
666
+ self.btn_reconstruct.config(state=tk.NORMAL)
667
+
668
+ elif self._state == "recording":
669
+ self.btn_record.config(state=tk.DISABLED)
670
+ self.btn_stop.config(state=tk.NORMAL)
671
+ self.btn_calibrate.config(state=tk.DISABLED)
672
+
673
+ elif self._state == "processing":
674
+ self.btn_stop.config(state=tk.DISABLED)
675
+ self.btn_record.config(state=tk.DISABLED)
676
+ self._rec_timer_label.config(text="Post-processing video...")
677
+
678
+ elif self._state == "monitoring":
679
+ self.btn_calibrate.config(state=tk.DISABLED)
680
+ self.btn_record.config(state=tk.DISABLED)
681
+ self.btn_start_monitor.config(state=tk.DISABLED)
682
+ self.btn_stop_monitor.config(state=tk.NORMAL)
683
+ self.btn_reconstruct.config(state=tk.DISABLED)
684
+
685
+ elif self._state == "reconstructing":
686
+ self.btn_reconstruct.config(state=tk.DISABLED)
687
+ self.btn_record.config(state=tk.DISABLED)
688
+
689
+ # ========================================================================
690
+ # Calibration
691
+ # ========================================================================
692
+
693
+ def _on_calibrate(self):
694
+ """Start auto-calibration in background thread."""
695
+ # Verify calibration videos exist
696
+ intrinsic_dir = self._project_dir / "calibration" / "intrinsic"
697
+ extrinsic_dir = self._project_dir / "calibration" / "extrinsic"
698
+
699
+ for port in [1, 2]:
700
+ if not (intrinsic_dir / f"port_{port}.mp4").exists():
701
+ messagebox.showerror("Error", f"Missing intrinsic video: port_{port}.mp4\n\nUse the recording tool first.")
702
+ return
703
+ if not (extrinsic_dir / f"port_{port}.mp4").exists():
704
+ messagebox.showerror("Error", f"Missing extrinsic video: port_{port}.mp4\n\nUse the recording tool first.")
705
+ return
706
+
707
+ self._state = "calibrating"
708
+ self._update_state_display()
709
+ self._calib_progress["value"] = 0
710
+
711
+ thread = threading.Thread(target=self._worker_calibrate, daemon=True)
712
+ thread.start()
713
+
714
+ def _worker_calibrate(self):
715
+ """Background thread: run auto-calibration."""
716
+ try:
717
+ from .auto_calibrate import AutoCalibConfig, run_auto_calibration
718
+
719
+ config = AutoCalibConfig.from_yaml(self._yaml_data, self._project_dir)
720
+
721
+ def on_progress(stage, msg, pct):
722
+ self._msg_queue.put(("calib_progress", (stage, msg, pct)))
723
+
724
+ result = run_auto_calibration(config, on_progress=on_progress)
725
+
726
+ if result.success:
727
+ self._msg_queue.put(("calib_done", result))
728
+ else:
729
+ self._msg_queue.put(("calib_error", result.error_message))
730
+
731
+ except Exception as e:
732
+ self._msg_queue.put(("calib_error", str(e)))
733
+
734
+ # ========================================================================
735
+ # Recording
736
+ # ========================================================================
737
+
738
+ def _on_toggle_preview(self):
739
+ """Toggle camera preview on/off."""
740
+ if self._camera and self._camera._running:
741
+ self._camera.stop()
742
+ self._camera = None
743
+ self.btn_preview.config(text="Preview")
744
+ self.preview_canvas.delete("all")
745
+ return
746
+
747
+ if not CV2_AVAILABLE:
748
+ messagebox.showerror("Error", "OpenCV not available for camera preview.")
749
+ return
750
+
751
+ self._camera = CameraPreview(
752
+ self.config.device_name,
753
+ self.config.video_size,
754
+ self.config.fps,
755
+ device_index=self.var_cam_index.get(),
756
+ )
757
+
758
+ if self._camera.start():
759
+ self.btn_preview.config(text="Stop Preview")
760
+ else:
761
+ messagebox.showerror("Error", "Failed to open camera.")
762
+ self._camera = None
763
+
764
+ def _on_record(self):
765
+ """Start recording a new session."""
766
+ # Stop preview first (camera needed by FFmpeg)
767
+ if self._camera:
768
+ self._camera.stop()
769
+ self._camera = None
770
+ self.btn_preview.config(text="Preview")
771
+ time.sleep(0.5)
772
+
773
+ # Create session directory
774
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
775
+ self._recording_name = f"session_{ts}"
776
+
777
+ raw_output_dir = self._project_dir / "raw_output"
778
+ self._session_dir = raw_output_dir / self._recording_name
779
+ self._session_dir.mkdir(parents=True, exist_ok=True)
780
+
781
+ raw_avi = self._session_dir / "raw.avi"
782
+
783
+ # Start FFmpeg recording
784
+ self._recorder = FFmpegRecorder(self.config, raw_avi)
785
+ if not self._recorder.start():
786
+ messagebox.showerror("Error", "Failed to start recording.")
787
+ return
788
+
789
+ self._state = "recording"
790
+ self._recording_start_time = time.time()
791
+ self._update_state_display()
792
+ self._log(f"Recording started: {self._recording_name}")
793
+
794
+ def _on_stop(self):
795
+ """Stop recording and start post-processing."""
796
+ if self._recorder:
797
+ self._recorder.stop()
798
+ self._recorder = None
799
+
800
+ self._state = "processing"
801
+ self._update_state_display()
802
+ self._log("Recording stopped. Post-processing...")
803
+
804
+ thread = threading.Thread(target=self._worker_post_process, daemon=True)
805
+ thread.start()
806
+
807
+ def _worker_post_process(self):
808
+ """Background: split video and copy to recordings directory."""
809
+ try:
810
+ raw_avi = self._session_dir / "raw.avi"
811
+ recordings_dir = self._project_dir / "recordings" / self._recording_name
812
+ recordings_dir.mkdir(parents=True, exist_ok=True)
813
+
814
+ processor = PostProcessor(
815
+ self.config, self._session_dir,
816
+ on_log=lambda msg: self._msg_queue.put(("log", msg)),
817
+ )
818
+
819
+ success = processor.run(raw_avi, [recordings_dir])
820
+
821
+ if success:
822
+ # Generate frame_timestamps.csv for the recording
823
+ self._generate_recording_timestamps(recordings_dir)
824
+ self._msg_queue.put(("record_done", self._recording_name))
825
+ else:
826
+ self._msg_queue.put(("record_error", "Post-processing failed"))
827
+
828
+ except Exception as e:
829
+ self._msg_queue.put(("record_error", str(e)))
830
+
831
+ def _generate_recording_timestamps(self, recording_dir: Path):
832
+ """Generate frame_timestamps.csv for a recording session."""
833
+ try:
834
+ from .auto_calibrate import _generate_frame_timestamps
835
+ _generate_frame_timestamps(recording_dir, [1, 2])
836
+ except Exception as e:
837
+ logger.warning(f"Could not generate frame_timestamps.csv: {e}")
838
+
839
+ # ========================================================================
840
+ # Auto-Monitor Mode
841
+ # ========================================================================
842
+
843
+ def _on_rec_mode_change(self):
844
+ """Switch between manual and auto recording modes."""
845
+ mode = self.var_rec_mode.get()
846
+ if mode == "manual":
847
+ self._auto_frame.pack_forget()
848
+ self._manual_frame.pack(fill=tk.X, pady=(4, 0))
849
+ else:
850
+ self._manual_frame.pack_forget()
851
+ self._auto_frame.pack(fill=tk.X, pady=(4, 0))
852
+
853
+ def _on_start_monitor(self):
854
+ """Start SmartRecorder auto-monitoring."""
855
+ # Stop any existing preview
856
+ if self._camera:
857
+ self._camera.stop()
858
+ self._camera = None
859
+ self.btn_preview.config(text="Preview")
860
+ time.sleep(0.3)
861
+
862
+ # Create SmartRecorder config from CalibConfig
863
+ sr_config = SmartRecorderConfig.from_calib_config(
864
+ self.config,
865
+ device_index=self.var_cam_index.get(),
866
+ )
867
+
868
+ # Output base: project raw_output directory
869
+ output_base = self._project_dir / "raw_output"
870
+ output_base.mkdir(parents=True, exist_ok=True)
871
+
872
+ # Event handler (called from SmartRecorder's background thread)
873
+ def on_event(event):
874
+ self._auto_event_queue.put(event)
875
+
876
+ self._smart_recorder = SmartRecorder(sr_config, output_base, on_event=on_event)
877
+
878
+ if not self._smart_recorder.start():
879
+ messagebox.showerror("Error", "Failed to start auto-monitoring.\nCheck camera connection.")
880
+ self._smart_recorder = None
881
+ return
882
+
883
+ self._state = "monitoring"
884
+ self._auto_pending_visits = []
885
+ self._update_state_display()
886
+ self._log("Auto-monitoring started. Waiting for person...")
887
+
888
+ # Start periodic auto-monitor UI update
889
+ self._auto_monitor_tick()
890
+
891
+ def _on_stop_monitor(self):
892
+ """Stop SmartRecorder auto-monitoring."""
893
+ if self._smart_recorder:
894
+ self._smart_recorder.stop()
895
+ self._smart_recorder = None
896
+
897
+ if self._auto_timer_id:
898
+ self.after_cancel(self._auto_timer_id)
899
+ self._auto_timer_id = None
900
+
901
+ # Process any remaining pending visits
902
+ self._process_pending_visits()
903
+
904
+ self._state = "calibrated"
905
+ self._update_state_display()
906
+ self._auto_state_dot.config(fg=FG_DIM)
907
+ self._auto_state_label.config(text="Idle", fg=FG_DIM)
908
+ self._auto_timer_label.config(text="")
909
+ self._log("Auto-monitoring stopped.")
910
+
911
+ def _auto_monitor_tick(self):
912
+ """Periodic UI update during auto-monitoring (50ms interval)."""
913
+ if not self._smart_recorder or not self._smart_recorder.is_running:
914
+ return
915
+
916
+ # Process events from SmartRecorder
917
+ while True:
918
+ try:
919
+ event = self._auto_event_queue.get_nowait()
920
+ self._handle_auto_event(event)
921
+ except queue.Empty:
922
+ break
923
+
924
+ # Update auto-monitor status display
925
+ sr = self._smart_recorder
926
+ state = sr.state
927
+
928
+ if state == "idle":
929
+ self._auto_state_dot.config(fg=ACCENT)
930
+ self._auto_state_label.config(text="Monitoring (idle)", fg=ACCENT)
931
+ self._auto_timer_label.config(text="")
932
+ elif state == "recording":
933
+ self._auto_state_dot.config(fg=ACCENT_RED)
934
+ elapsed = int(sr.recording_elapsed)
935
+ self._auto_state_label.config(text="RECORDING", fg=ACCENT_RED)
936
+ self._auto_timer_label.config(text=f"{elapsed}s", fg=ACCENT_RED)
937
+ elif state == "cooldown":
938
+ remaining = int(sr.cooldown_remaining)
939
+ self._auto_state_dot.config(fg=ACCENT_ORANGE)
940
+ self._auto_state_label.config(text="Cooldown", fg=ACCENT_ORANGE)
941
+ self._auto_timer_label.config(text=f"{remaining}s remaining", fg=ACCENT_ORANGE)
942
+
943
+ self._auto_visit_label.config(text=f"Visits: {sr.visit_count}")
944
+
945
+ # Update preview from SmartRecorder
946
+ frame = sr.get_frame()
947
+ if frame is not None:
948
+ self._display_preview_frame(frame, show_detection=(state != "idle"))
949
+
950
+ # Schedule next tick
951
+ self._auto_timer_id = self.after(50, self._auto_monitor_tick)
952
+
953
+ def _handle_auto_event(self, event: dict):
954
+ """Handle events from SmartRecorder."""
955
+ etype = event.get("type", "")
956
+
957
+ if etype == "state_change":
958
+ new_state = event.get("state", "")
959
+ self._log(f"[AUTO] State: {new_state}")
960
+
961
+ elif etype == "recording_start":
962
+ visit = event.get("visit", "")
963
+ self._log(f"[AUTO] Recording started: {visit}")
964
+
965
+ elif etype == "recording_stop":
966
+ visit = event.get("visit", "")
967
+ session_dir = event.get("session_dir", "")
968
+ frame_count = event.get("frame_count", 0)
969
+ self._log(f"[AUTO] Recording stopped: {visit} ({frame_count} frames)")
970
+ if session_dir:
971
+ self._auto_pending_visits.append({
972
+ "visit": visit,
973
+ "session_dir": session_dir,
974
+ })
975
+ # Start post-processing in background
976
+ self._process_pending_visits()
977
+
978
+ elif etype == "error":
979
+ msg = event.get("message", "")
980
+ self._log(f"[AUTO] Error: {msg}")
981
+
982
+ def _process_pending_visits(self):
983
+ """Post-process any pending auto-recorded visits."""
984
+ while self._auto_pending_visits:
985
+ visit_info = self._auto_pending_visits.pop(0)
986
+ visit_name = visit_info["visit"]
987
+ session_dir = Path(visit_info["session_dir"])
988
+
989
+ self._log(f"[AUTO] Post-processing: {visit_name}")
990
+
991
+ thread = threading.Thread(
992
+ target=self._worker_auto_post_process,
993
+ args=(visit_name, session_dir),
994
+ daemon=True,
995
+ )
996
+ thread.start()
997
+
998
+ def _worker_auto_post_process(self, visit_name: str, session_dir: Path):
999
+ """Background: post-process an auto-recorded visit."""
1000
+ try:
1001
+ raw_avi = session_dir / "raw.avi"
1002
+ if not raw_avi.exists():
1003
+ self._msg_queue.put(("log", f"[AUTO] No raw.avi found for {visit_name}"))
1004
+ return
1005
+
1006
+ recordings_dir = self._project_dir / "recordings" / visit_name
1007
+ recordings_dir.mkdir(parents=True, exist_ok=True)
1008
+
1009
+ processor = PostProcessor(
1010
+ self.config, session_dir,
1011
+ on_log=lambda msg: self._msg_queue.put(("log", msg)),
1012
+ )
1013
+
1014
+ success = processor.run(raw_avi, [recordings_dir])
1015
+
1016
+ if success:
1017
+ # Copy metadata.json if it exists
1018
+ metadata_src = session_dir / "metadata.json"
1019
+ if metadata_src.exists():
1020
+ import shutil
1021
+ shutil.copy2(metadata_src, recordings_dir / "metadata.json")
1022
+
1023
+ # Generate frame timestamps
1024
+ self._generate_recording_timestamps(recordings_dir)
1025
+ self._msg_queue.put(("auto_visit_done", visit_name))
1026
+ else:
1027
+ self._msg_queue.put(("log", f"[AUTO] Post-processing failed: {visit_name}"))
1028
+
1029
+ except Exception as e:
1030
+ self._msg_queue.put(("log", f"[AUTO] Error processing {visit_name}: {e}"))
1031
+
1032
+ def _display_preview_frame(self, frame: np.ndarray, show_detection: bool = False):
1033
+ """Display a preview frame on the preview canvas."""
1034
+ canvas_w = self.preview_canvas.winfo_width()
1035
+ canvas_h = self.preview_canvas.winfo_height()
1036
+ if canvas_w < 10 or canvas_h < 10:
1037
+ return
1038
+
1039
+ frame_h, frame_w = frame.shape[:2]
1040
+ scale = min(canvas_w / frame_w, canvas_h / frame_h)
1041
+ new_w = int(frame_w * scale)
1042
+ new_h = int(frame_h * scale)
1043
+
1044
+ frame_resized = cv2.resize(frame, (new_w, new_h))
1045
+
1046
+ # Add recording indicator overlay
1047
+ if show_detection:
1048
+ cv2.circle(frame_resized, (new_w - 20, 20), 8, (0, 0, 255), -1)
1049
+
1050
+ frame_rgb = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB)
1051
+ from PIL import Image, ImageTk
1052
+ img = Image.fromarray(frame_rgb)
1053
+ photo = ImageTk.PhotoImage(image=img)
1054
+
1055
+ self.preview_canvas.delete("all")
1056
+ x_offset = (canvas_w - new_w) // 2
1057
+ y_offset = (canvas_h - new_h) // 2
1058
+ self.preview_canvas.create_image(x_offset, y_offset, anchor=tk.NW, image=photo)
1059
+ self.preview_canvas._photo = photo
1060
+
1061
+ # Store preview transform (auto-monitor: frame IS left camera)
1062
+ self._preview_scale = scale
1063
+ self._preview_x_offset = x_offset
1064
+ self._preview_y_offset = y_offset
1065
+ self._preview_left_w = frame_w
1066
+ self._preview_left_h = frame_h
1067
+
1068
+ # Draw zone overlay
1069
+ self._draw_zone_overlay()
1070
+
1071
+ # ========================================================================
1072
+ # Dining Zone
1073
+ # ========================================================================
1074
+
1075
+ def _on_mark_zone(self):
1076
+ """Enter zone-drawing mode. Start preview if needed."""
1077
+ # Start camera preview if not running (and not in auto-monitor mode)
1078
+ if not (self._camera and self._camera._running) and not self._smart_recorder:
1079
+ if not CV2_AVAILABLE:
1080
+ messagebox.showerror("Error", "OpenCV not available for camera preview.")
1081
+ return
1082
+ self._camera = CameraPreview(
1083
+ self.config.device_name,
1084
+ self.config.video_size,
1085
+ self.config.fps,
1086
+ device_index=self.var_cam_index.get(),
1087
+ )
1088
+ if self._camera.start():
1089
+ self.btn_preview.config(text="Stop Preview")
1090
+ else:
1091
+ messagebox.showerror("Error", "Failed to open camera for zone marking.")
1092
+ self._camera = None
1093
+ return
1094
+
1095
+ self._zone_drawing = True
1096
+ self.preview_canvas.bind("<Button-1>", self._zone_mouse_press)
1097
+ self.preview_canvas.bind("<B1-Motion>", self._zone_mouse_drag)
1098
+ self.preview_canvas.bind("<ButtonRelease-1>", self._zone_mouse_release)
1099
+ self._zone_status_label.config(text="Dining Zone: draw on preview...", fg=ACCENT_ORANGE)
1100
+ self._zone_status_dot.config(fg=ACCENT_ORANGE)
1101
+ self._log("Draw dining zone on camera preview (click and drag)")
1102
+
1103
+ def _on_clear_zone(self):
1104
+ """Clear the dining zone."""
1105
+ self._zone_data = None
1106
+ self._zone_drawing = False
1107
+ self.preview_canvas.delete("zone_rect")
1108
+ self.preview_canvas.delete("zone_overlay")
1109
+ # Unbind mouse events
1110
+ self.preview_canvas.unbind("<Button-1>")
1111
+ self.preview_canvas.unbind("<B1-Motion>")
1112
+ self.preview_canvas.unbind("<ButtonRelease-1>")
1113
+ # Delete dining_zone.toml
1114
+ zone_path = self._project_dir / "config" / "dining_zone.toml"
1115
+ if zone_path.exists():
1116
+ zone_path.unlink()
1117
+ # Update UI
1118
+ self._zone_status_dot.config(fg=FG_DIM)
1119
+ self._zone_status_label.config(text="Dining Zone: not defined", fg=FG_DIM)
1120
+ self.btn_clear_zone.config(state=tk.DISABLED)
1121
+ self._update_state_display()
1122
+ self._log("Dining zone cleared")
1123
+
1124
+ def _zone_mouse_press(self, event):
1125
+ """Handle mouse press for zone drawing."""
1126
+ self._zone_start = (event.x, event.y)
1127
+
1128
+ def _zone_mouse_drag(self, event):
1129
+ """Handle mouse drag for zone drawing — show dashed rectangle."""
1130
+ if self._zone_start is None:
1131
+ return
1132
+ self.preview_canvas.delete("zone_rect")
1133
+ self.preview_canvas.create_rectangle(
1134
+ self._zone_start[0], self._zone_start[1], event.x, event.y,
1135
+ outline="#4CAF50", width=2, dash=(5, 3), tags="zone_rect",
1136
+ )
1137
+
1138
+ def _zone_mouse_release(self, event):
1139
+ """Handle mouse release — finalize zone rectangle."""
1140
+ if self._zone_start is None:
1141
+ return
1142
+ x1_c, y1_c = self._zone_start
1143
+ x2_c, y2_c = event.x, event.y
1144
+ self._zone_start = None
1145
+ self._zone_drawing = False
1146
+
1147
+ # Ensure minimum size on canvas
1148
+ if abs(x2_c - x1_c) < 10 or abs(y2_c - y1_c) < 10:
1149
+ self.preview_canvas.delete("zone_rect")
1150
+ self._zone_status_label.config(text="Dining Zone: too small, try again", fg=ACCENT_RED)
1151
+ return
1152
+
1153
+ # Normalize order
1154
+ if x1_c > x2_c:
1155
+ x1_c, x2_c = x2_c, x1_c
1156
+ if y1_c > y2_c:
1157
+ y1_c, y2_c = y2_c, y1_c
1158
+
1159
+ # Convert canvas coords to source image coords
1160
+ img_x1 = (x1_c - self._preview_x_offset) / self._preview_scale
1161
+ img_y1 = (y1_c - self._preview_y_offset) / self._preview_scale
1162
+ img_x2 = (x2_c - self._preview_x_offset) / self._preview_scale
1163
+ img_y2 = (y2_c - self._preview_y_offset) / self._preview_scale
1164
+
1165
+ # Normalize relative to left camera dimensions
1166
+ left_w = self._preview_left_w
1167
+ left_h = self._preview_left_h
1168
+ x1_norm = max(0.0, min(1.0, img_x1 / left_w))
1169
+ y1_norm = max(0.0, min(1.0, img_y1 / left_h))
1170
+ x2_norm = max(0.0, min(1.0, img_x2 / left_w))
1171
+ y2_norm = max(0.0, min(1.0, img_y2 / left_h))
1172
+
1173
+ # Validate normalized size
1174
+ if x2_norm - x1_norm < 0.02 or y2_norm - y1_norm < 0.02:
1175
+ self.preview_canvas.delete("zone_rect")
1176
+ self._zone_status_label.config(text="Dining Zone: too small, try again", fg=ACCENT_RED)
1177
+ return
1178
+
1179
+ # Store and save
1180
+ self._zone_data = {
1181
+ "x1": x1_norm, "y1": y1_norm,
1182
+ "x2": x2_norm, "y2": y2_norm,
1183
+ }
1184
+ self._save_dining_zone()
1185
+
1186
+ # Unbind mouse events
1187
+ self.preview_canvas.unbind("<Button-1>")
1188
+ self.preview_canvas.unbind("<B1-Motion>")
1189
+ self.preview_canvas.unbind("<ButtonRelease-1>")
1190
+
1191
+ # Update UI
1192
+ self.preview_canvas.delete("zone_rect")
1193
+ self._draw_zone_overlay()
1194
+ self._zone_status_dot.config(fg=ACCENT)
1195
+ self._zone_status_label.config(text="Dining Zone: defined", fg=ACCENT)
1196
+ self.btn_clear_zone.config(state=tk.NORMAL)
1197
+ self._update_state_display()
1198
+ self._log(f"Dining zone saved: ({x1_norm:.2f},{y1_norm:.2f})-({x2_norm:.2f},{y2_norm:.2f})")
1199
+
1200
+ def _save_dining_zone(self):
1201
+ """Save dining zone to project config/dining_zone.toml."""
1202
+ if not self._zone_data:
1203
+ return
1204
+ config_dir = self._project_dir / "config"
1205
+ config_dir.mkdir(parents=True, exist_ok=True)
1206
+ zone_path = config_dir / "dining_zone.toml"
1207
+
1208
+ z = self._zone_data
1209
+ xsplit = self.config.xsplit
1210
+ full_h = self.config.full_h
1211
+ content = (
1212
+ "[zone]\n"
1213
+ f'name = "dining_area"\n'
1214
+ f"x1 = {z['x1']:.4f}\n"
1215
+ f"y1 = {z['y1']:.4f}\n"
1216
+ f"x2 = {z['x2']:.4f}\n"
1217
+ f"y2 = {z['y2']:.4f}\n"
1218
+ f"px_x1 = {int(z['x1'] * xsplit)}\n"
1219
+ f"px_y1 = {int(z['y1'] * full_h)}\n"
1220
+ f"px_x2 = {int(z['x2'] * xsplit)}\n"
1221
+ f"px_y2 = {int(z['y2'] * full_h)}\n"
1222
+ )
1223
+ zone_path.write_text(content)
1224
+
1225
+ def _load_dining_zone(self) -> Optional[dict]:
1226
+ """Load dining zone from project config/dining_zone.toml."""
1227
+ zone_path = self._project_dir / "config" / "dining_zone.toml"
1228
+ if not zone_path.exists():
1229
+ return None
1230
+ try:
1231
+ data = {}
1232
+ for line in zone_path.read_text().splitlines():
1233
+ line = line.strip()
1234
+ if "=" in line and not line.startswith("[") and not line.startswith("#"):
1235
+ key, val = line.split("=", 1)
1236
+ key = key.strip()
1237
+ val = val.strip().strip('"')
1238
+ if key in ("x1", "y1", "x2", "y2"):
1239
+ data[key] = float(val)
1240
+ if all(k in data for k in ("x1", "y1", "x2", "y2")):
1241
+ return data
1242
+ except Exception as e:
1243
+ logger.warning(f"Failed to load dining zone: {e}")
1244
+ return None
1245
+
1246
+ def _draw_zone_overlay(self):
1247
+ """Draw the dining zone rectangle overlay on preview canvas."""
1248
+ self.preview_canvas.delete("zone_overlay")
1249
+ if not self._zone_data:
1250
+ return
1251
+ z = self._zone_data
1252
+ left_w = self._preview_left_w
1253
+ left_h = self._preview_left_h
1254
+ scale = self._preview_scale
1255
+ x_off = self._preview_x_offset
1256
+ y_off = self._preview_y_offset
1257
+
1258
+ # Convert normalized zone coords to canvas coords
1259
+ cx1 = z["x1"] * left_w * scale + x_off
1260
+ cy1 = z["y1"] * left_h * scale + y_off
1261
+ cx2 = z["x2"] * left_w * scale + x_off
1262
+ cy2 = z["y2"] * left_h * scale + y_off
1263
+
1264
+ self.preview_canvas.create_rectangle(
1265
+ cx1, cy1, cx2, cy2,
1266
+ outline="#4CAF50", width=2, tags="zone_overlay",
1267
+ )
1268
+ self.preview_canvas.create_text(
1269
+ cx1 + 4, cy1 + 2, text="Dining Zone", anchor=tk.NW,
1270
+ fill="#4CAF50", font=("Segoe UI", 8), tags="zone_overlay",
1271
+ )
1272
+
1273
+ def _on_detection_param_change(self, param_name: str, value):
1274
+ """Update SmartRecorder config parameter in real-time."""
1275
+ if self._smart_recorder:
1276
+ try:
1277
+ setattr(self._smart_recorder.config, param_name, value)
1278
+ logger.debug(f"Detection param updated: {param_name}={value}")
1279
+ except Exception as e:
1280
+ logger.warning(f"Failed to update detection param: {e}")
1281
+
1282
+ # ========================================================================
1283
+ # Reconstruction
1284
+ # ========================================================================
1285
+
1286
+ def _load_tracker_options(self):
1287
+ """Load available tracker types from caliscope's TrackerEnum."""
1288
+ try:
1289
+ from caliscope.trackers.tracker_enum import TrackerEnum
1290
+ names = [t.name for t in TrackerEnum]
1291
+ except ImportError:
1292
+ names = ["HOLISTIC", "POSE", "HAND", "SIMPLE_HOLISTIC", "CHARUCO", "ARUCO"]
1293
+ self._tracker_combo["values"] = names
1294
+ if "HOLISTIC" in names:
1295
+ self.var_tracker.set("HOLISTIC")
1296
+
1297
+ def _refresh_recordings(self):
1298
+ """Refresh the recordings dropdown."""
1299
+ rec_dir = self._project_dir / "recordings"
1300
+ recordings = []
1301
+ if rec_dir.exists():
1302
+ for d in sorted(rec_dir.iterdir()):
1303
+ if d.is_dir():
1304
+ # Check if it has port_N.mp4 files
1305
+ has_videos = (d / "port_1.mp4").exists() or (d / "port_2.mp4").exists()
1306
+ if has_videos:
1307
+ recordings.append(d.name)
1308
+
1309
+ self._rec_combo["values"] = recordings
1310
+ if recordings:
1311
+ self._rec_combo.current(len(recordings) - 1) # Select latest
1312
+ if self._state == "calibrated":
1313
+ self.btn_reconstruct.config(state=tk.NORMAL)
1314
+
1315
+ # Also refresh Panel 4 recording list
1316
+ if hasattr(self, "_viz_rec_combo"):
1317
+ self._refresh_viz_recordings()
1318
+
1319
+ def _on_reconstruct(self):
1320
+ """Start 3D reconstruction."""
1321
+ recording_name = self.var_recording.get()
1322
+ if not recording_name:
1323
+ messagebox.showerror("Error", "No recording selected.")
1324
+ return
1325
+
1326
+ self._state = "reconstructing"
1327
+ self._update_state_display()
1328
+ self._recon_progress["value"] = 0
1329
+
1330
+ thread = threading.Thread(
1331
+ target=self._worker_reconstruct,
1332
+ args=(recording_name,),
1333
+ daemon=True,
1334
+ )
1335
+ thread.start()
1336
+
1337
+ def _worker_reconstruct(self, recording_name: str):
1338
+ """Background: run 3D reconstruction."""
1339
+ try:
1340
+ from .auto_calibrate import run_auto_reconstruction
1341
+
1342
+ def on_progress(stage, msg, pct):
1343
+ self._msg_queue.put(("recon_progress", (stage, msg, pct)))
1344
+
1345
+ output_path = run_auto_reconstruction(
1346
+ self._project_dir,
1347
+ recording_name,
1348
+ tracker_name=self.var_tracker.get(),
1349
+ on_progress=on_progress,
1350
+ )
1351
+
1352
+ self._msg_queue.put(("recon_done", str(output_path)))
1353
+
1354
+ except Exception as e:
1355
+ self._msg_queue.put(("recon_error", str(e)))
1356
+
1357
+ # ========================================================================
1358
+ # 3D Visualization
1359
+ # ========================================================================
1360
+
1361
+ def _build_viz_panel(self, parent):
1362
+ """Build Panel 4: 3D Visualization with playback controls."""
1363
+ viz_frame = tk.LabelFrame(
1364
+ parent, text=" 4. 3D Visualization ",
1365
+ font=("Segoe UI", 11, "bold"), fg="#9C27B0", bg=BG_PANEL,
1366
+ bd=1, relief="groove",
1367
+ )
1368
+ viz_frame.pack(fill=tk.X, pady=(0, 8))
1369
+
1370
+ viz_inner = tk.Frame(viz_frame, bg=BG_PANEL)
1371
+ viz_inner.pack(fill=tk.X, padx=10, pady=8)
1372
+
1373
+ # ── Top row: recording + tracker selectors + Load ──
1374
+ sel_row = tk.Frame(viz_inner, bg=BG_PANEL)
1375
+ sel_row.pack(fill=tk.X)
1376
+
1377
+ tk.Label(
1378
+ sel_row, text="Recording:",
1379
+ font=("Segoe UI", 10), fg=FG, bg=BG_PANEL,
1380
+ ).pack(side=tk.LEFT)
1381
+
1382
+ self.var_viz_recording = tk.StringVar()
1383
+ self._viz_rec_combo = ttk.Combobox(
1384
+ sel_row, textvariable=self.var_viz_recording,
1385
+ state="readonly", width=22,
1386
+ )
1387
+ self._viz_rec_combo.pack(side=tk.LEFT, padx=5)
1388
+
1389
+ tk.Label(
1390
+ sel_row, text="Tracker:",
1391
+ font=("Segoe UI", 10), fg=FG, bg=BG_PANEL,
1392
+ ).pack(side=tk.LEFT, padx=(10, 0))
1393
+
1394
+ self.var_viz_tracker = tk.StringVar(value="HOLISTIC")
1395
+ self._viz_tracker_combo = ttk.Combobox(
1396
+ sel_row, textvariable=self.var_viz_tracker,
1397
+ state="readonly", width=16,
1398
+ )
1399
+ self._viz_tracker_combo.pack(side=tk.LEFT, padx=5)
1400
+ # Share tracker options with Panel 3 combo
1401
+ self._viz_tracker_combo["values"] = self._tracker_combo["values"]
1402
+
1403
+ self.btn_viz_load = tk.Button(
1404
+ sel_row, text="Load",
1405
+ font=("Segoe UI", 9, "bold"), bg="#9C27B0", fg="white",
1406
+ relief="flat", padx=12, pady=2,
1407
+ command=self._on_load_viz,
1408
+ )
1409
+ self.btn_viz_load.pack(side=tk.LEFT, padx=5)
1410
+
1411
+ # ── Matplotlib 3D canvas ──
1412
+ if MPL_AVAILABLE:
1413
+ self._viz_fig = Figure(figsize=(8, 4), dpi=80, facecolor="#1a1a1a")
1414
+ self._viz_ax = self._viz_fig.add_subplot(111, projection="3d")
1415
+ self._viz_ax.set_facecolor("#1a1a1a")
1416
+ self._viz_fig.subplots_adjust(left=0.02, right=0.98, top=0.98, bottom=0.02)
1417
+
1418
+ self._viz_canvas = FigureCanvasTkAgg(self._viz_fig, master=viz_inner)
1419
+ self._viz_canvas.get_tk_widget().configure(height=350, bg="#1a1a1a")
1420
+ self._viz_canvas.get_tk_widget().pack(fill=tk.X, pady=(8, 0))
1421
+ self._viz_canvas.draw()
1422
+ else:
1423
+ tk.Label(
1424
+ viz_inner,
1425
+ text="matplotlib not available — install it to enable 3D visualization",
1426
+ font=("Segoe UI", 10), fg=ACCENT_RED, bg=BG_PANEL,
1427
+ ).pack(fill=tk.X, pady=20)
1428
+ self._viz_fig = None
1429
+ self._viz_ax = None
1430
+ self._viz_canvas = None
1431
+
1432
+ # ── Playback controls row ──
1433
+ ctrl_row = tk.Frame(viz_inner, bg=BG_PANEL)
1434
+ ctrl_row.pack(fill=tk.X, pady=(6, 0))
1435
+
1436
+ self.btn_viz_prev = tk.Button(
1437
+ ctrl_row, text="\u25c0", font=("Segoe UI", 10),
1438
+ bg="#555", fg=FG, relief="flat", width=3,
1439
+ command=self._on_viz_prev, state=tk.DISABLED,
1440
+ )
1441
+ self.btn_viz_prev.pack(side=tk.LEFT)
1442
+
1443
+ self.btn_viz_play = tk.Button(
1444
+ ctrl_row, text="\u25b6 Play", font=("Segoe UI", 10, "bold"),
1445
+ bg=ACCENT, fg="white", relief="flat", padx=10,
1446
+ command=self._on_viz_play_pause, state=tk.DISABLED,
1447
+ )
1448
+ self.btn_viz_play.pack(side=tk.LEFT, padx=4)
1449
+
1450
+ self.btn_viz_next = tk.Button(
1451
+ ctrl_row, text="\u25b6", font=("Segoe UI", 10),
1452
+ bg="#555", fg=FG, relief="flat", width=3,
1453
+ command=self._on_viz_next, state=tk.DISABLED,
1454
+ )
1455
+ self.btn_viz_next.pack(side=tk.LEFT)
1456
+
1457
+ # Frame slider
1458
+ self._viz_slider = tk.Scale(
1459
+ ctrl_row, from_=0, to=0, orient=tk.HORIZONTAL,
1460
+ bg=BG_PANEL, fg=FG, highlightthickness=0, troughcolor="#555",
1461
+ showvalue=False, command=self._on_viz_slider,
1462
+ )
1463
+ self._viz_slider.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=8)
1464
+ self._viz_slider.config(state=tk.DISABLED)
1465
+
1466
+ self._viz_frame_label = tk.Label(
1467
+ ctrl_row, text="Frame: 0/0",
1468
+ font=("Segoe UI", 9), fg=FG_DIM, bg=BG_PANEL, width=14,
1469
+ )
1470
+ self._viz_frame_label.pack(side=tk.LEFT)
1471
+
1472
+ # Speed row
1473
+ speed_row = tk.Frame(viz_inner, bg=BG_PANEL)
1474
+ speed_row.pack(fill=tk.X, pady=(2, 0))
1475
+
1476
+ tk.Label(
1477
+ speed_row, text="Speed:",
1478
+ font=("Segoe UI", 9), fg=FG_DIM, bg=BG_PANEL,
1479
+ ).pack(side=tk.LEFT)
1480
+
1481
+ self.var_viz_speed = tk.StringVar(value="1.0x")
1482
+ speed_combo = ttk.Combobox(
1483
+ speed_row, textvariable=self.var_viz_speed,
1484
+ state="readonly", width=6,
1485
+ values=["0.25x", "0.5x", "1.0x", "2.0x", "4.0x"],
1486
+ )
1487
+ speed_combo.pack(side=tk.LEFT, padx=5)
1488
+ speed_combo.bind("<<ComboboxSelected>>", self._on_viz_speed_change)
1489
+
1490
+ self._viz_status_label = tk.Label(
1491
+ speed_row, text="",
1492
+ font=("Segoe UI", 9), fg=FG_DIM, bg=BG_PANEL, anchor="e",
1493
+ )
1494
+ self._viz_status_label.pack(side=tk.RIGHT)
1495
+
1496
+ def _refresh_viz_recordings(self):
1497
+ """Refresh Panel 4 recording dropdown (same list as Panel 3)."""
1498
+ rec_dir = self._project_dir / "recordings"
1499
+ recordings = []
1500
+ if rec_dir.exists():
1501
+ for d in sorted(rec_dir.iterdir()):
1502
+ if d.is_dir():
1503
+ recordings.append(d.name)
1504
+ self._viz_rec_combo["values"] = recordings
1505
+ if recordings:
1506
+ self._viz_rec_combo.current(len(recordings) - 1)
1507
+
1508
+ def _on_load_viz(self):
1509
+ """Load xyz CSV for the selected recording + tracker."""
1510
+ rec_name = self.var_viz_recording.get()
1511
+ tracker = self.var_viz_tracker.get()
1512
+
1513
+ if not rec_name or not tracker:
1514
+ messagebox.showerror("Error", "Select a recording and tracker first.")
1515
+ return
1516
+
1517
+ csv_path = (
1518
+ self._project_dir / "recordings" / rec_name / tracker / f"xyz_{tracker}.csv"
1519
+ )
1520
+
1521
+ if not csv_path.exists():
1522
+ messagebox.showerror(
1523
+ "Error",
1524
+ f"No reconstruction found:\n{csv_path}\n\n"
1525
+ "Run 3D Reconstruction first (Panel 3).",
1526
+ )
1527
+ return
1528
+
1529
+ self._log(f"Loading 3D data: {csv_path.name}")
1530
+ self.btn_viz_load.config(state=tk.DISABLED, text="Loading...")
1531
+
1532
+ thread = threading.Thread(
1533
+ target=self._worker_load_viz,
1534
+ args=(csv_path, tracker),
1535
+ daemon=True,
1536
+ )
1537
+ thread.start()
1538
+
1539
+ def _worker_load_viz(self, csv_path: Path, tracker_name: str):
1540
+ """Background: load CSV + wireframe data."""
1541
+ try:
1542
+ viz_data = load_xyz_csv(csv_path)
1543
+ segments = load_wireframe_for_tracker(tracker_name)
1544
+ viz_data.segments = segments
1545
+ self._msg_queue.put(("viz_loaded", viz_data))
1546
+ except Exception as e:
1547
+ self._msg_queue.put(("viz_error", str(e)))
1548
+
1549
+ def _viz_set_data(self, viz_data: Viz3DData):
1550
+ """Set loaded data and enable playback controls."""
1551
+ self._viz_data = viz_data
1552
+ self._viz_frame_idx = 0
1553
+ self._viz_playing = False
1554
+
1555
+ # Enable controls
1556
+ self.btn_viz_load.config(state=tk.NORMAL, text="Load")
1557
+ self.btn_viz_prev.config(state=tk.NORMAL)
1558
+ self.btn_viz_play.config(state=tk.NORMAL, text="\u25b6 Play")
1559
+ self.btn_viz_next.config(state=tk.NORMAL)
1560
+
1561
+ self._viz_slider.config(state=tk.NORMAL, from_=0, to=max(0, viz_data.num_frames - 1))
1562
+ self._viz_slider.set(0)
1563
+
1564
+ self._viz_frame_label.config(text=f"Frame: 1/{viz_data.num_frames}")
1565
+ self._viz_status_label.config(
1566
+ text=f"{viz_data.num_frames} frames, {len(viz_data.segments)} segments",
1567
+ )
1568
+
1569
+ self._render_current_frame()
1570
+
1571
+ def _render_current_frame(self):
1572
+ """Render the current frame on the 3D canvas."""
1573
+ if not self._viz_data or not self._viz_canvas:
1574
+ return
1575
+ render_frame(self._viz_ax, self._viz_data, self._viz_frame_idx)
1576
+ self._viz_canvas.draw_idle()
1577
+ self._viz_frame_label.config(
1578
+ text=f"Frame: {self._viz_frame_idx + 1}/{self._viz_data.num_frames}",
1579
+ )
1580
+
1581
+ def _on_viz_prev(self):
1582
+ """Go to previous frame."""
1583
+ if not self._viz_data:
1584
+ return
1585
+ self._viz_frame_idx = max(0, self._viz_frame_idx - 1)
1586
+ self._viz_slider.set(self._viz_frame_idx)
1587
+ self._render_current_frame()
1588
+
1589
+ def _on_viz_next(self):
1590
+ """Go to next frame."""
1591
+ if not self._viz_data:
1592
+ return
1593
+ self._viz_frame_idx = min(self._viz_data.num_frames - 1, self._viz_frame_idx + 1)
1594
+ self._viz_slider.set(self._viz_frame_idx)
1595
+ self._render_current_frame()
1596
+
1597
+ def _on_viz_play_pause(self):
1598
+ """Toggle play/pause for animation."""
1599
+ if not self._viz_data:
1600
+ return
1601
+
1602
+ self._viz_playing = not self._viz_playing
1603
+
1604
+ if self._viz_playing:
1605
+ self.btn_viz_play.config(text="\u23f8 Pause", bg=ACCENT_ORANGE)
1606
+ # Reset to start if at the end
1607
+ if self._viz_frame_idx >= self._viz_data.num_frames - 1:
1608
+ self._viz_frame_idx = 0
1609
+ self._animation_tick()
1610
+ else:
1611
+ self.btn_viz_play.config(text="\u25b6 Play", bg=ACCENT)
1612
+ if self._viz_timer_id:
1613
+ self.after_cancel(self._viz_timer_id)
1614
+ self._viz_timer_id = None
1615
+
1616
+ def _animation_tick(self):
1617
+ """Advance one frame during playback."""
1618
+ if not self._viz_playing or not self._viz_data:
1619
+ return
1620
+
1621
+ self._viz_frame_idx += 1
1622
+ if self._viz_frame_idx >= self._viz_data.num_frames:
1623
+ # Reached the end — stop playback
1624
+ self._viz_frame_idx = self._viz_data.num_frames - 1
1625
+ self._viz_playing = False
1626
+ self.btn_viz_play.config(text="\u25b6 Play", bg=ACCENT)
1627
+ self._render_current_frame()
1628
+ self._viz_slider.set(self._viz_frame_idx)
1629
+ return
1630
+
1631
+ self._render_current_frame()
1632
+ self._viz_slider.set(self._viz_frame_idx)
1633
+
1634
+ # Schedule next tick based on speed
1635
+ interval_ms = max(1, int(33 / self._viz_speed)) # ~30fps at 1x
1636
+ self._viz_timer_id = self.after(interval_ms, self._animation_tick)
1637
+
1638
+ def _on_viz_slider(self, value):
1639
+ """Handle slider drag for manual frame seek."""
1640
+ if not self._viz_data:
1641
+ return
1642
+ idx = int(float(value))
1643
+ if idx != self._viz_frame_idx:
1644
+ self._viz_frame_idx = idx
1645
+ self._render_current_frame()
1646
+
1647
+ def _on_viz_speed_change(self, event=None):
1648
+ """Handle speed dropdown change."""
1649
+ speed_str = self.var_viz_speed.get().rstrip("x")
1650
+ try:
1651
+ self._viz_speed = float(speed_str)
1652
+ except ValueError:
1653
+ self._viz_speed = 1.0
1654
+
1655
+ # ========================================================================
1656
+ # Update Loop
1657
+ # ========================================================================
1658
+
1659
+ def _update_loop(self):
1660
+ """Main update loop (called every 50ms)."""
1661
+ # Process messages from background threads
1662
+ while True:
1663
+ try:
1664
+ msg_type, msg = self._msg_queue.get_nowait()
1665
+ self._handle_message(msg_type, msg)
1666
+ except queue.Empty:
1667
+ break
1668
+
1669
+ # Update camera preview
1670
+ if self._camera and self._camera._running:
1671
+ self._update_preview()
1672
+
1673
+ # Update recording timer
1674
+ if self._state == "recording" and hasattr(self, "_recording_start_time"):
1675
+ elapsed = time.time() - self._recording_start_time
1676
+ self._rec_timer_label.config(
1677
+ text=f"Recording: {int(elapsed)}s",
1678
+ fg=ACCENT_RED,
1679
+ )
1680
+
1681
+ self.after(50, self._update_loop)
1682
+
1683
+ def _handle_message(self, msg_type: str, msg):
1684
+ """Handle messages from background threads."""
1685
+ if msg_type == "log":
1686
+ self._log(msg)
1687
+
1688
+ elif msg_type == "calib_progress":
1689
+ stage, text, pct = msg
1690
+ if pct >= 0:
1691
+ self._calib_progress["value"] = pct
1692
+ self._calib_step_label.config(text=text)
1693
+ self._log(f"[{stage}] {text}")
1694
+
1695
+ elif msg_type == "calib_done":
1696
+ result = msg
1697
+ self._state = "calibrated"
1698
+ self._update_state_display()
1699
+ rmse_info = ", ".join(f"cam{p}={r:.3f}px" for p, r in result.intrinsic_rmse.items())
1700
+ self._log(f"Calibration complete! Intrinsic RMSE: {rmse_info}")
1701
+ self._log(f"Extrinsic cost: {result.extrinsic_cost:.4f}")
1702
+ self._log(f"Origin sync_index: {result.origin_sync_index}")
1703
+ self._calib_step_label.config(text="Calibration complete!")
1704
+ messagebox.showinfo("Success", "Auto-calibration complete!\nYou can now record sessions.")
1705
+
1706
+ elif msg_type == "calib_error":
1707
+ self._state = "idle"
1708
+ self._update_state_display()
1709
+ self._log(f"[ERROR] Calibration failed: {msg}")
1710
+ self._calib_step_label.config(text=f"Error: {msg}")
1711
+ messagebox.showerror("Calibration Failed", str(msg))
1712
+
1713
+ elif msg_type == "record_done":
1714
+ recording_name = msg
1715
+ self._state = "calibrated"
1716
+ self._update_state_display()
1717
+ self._rec_timer_label.config(text="Recording complete!", fg=ACCENT)
1718
+ self._log(f"Recording saved: recordings/{recording_name}/")
1719
+ self._refresh_recordings()
1720
+
1721
+ # Auto-start reconstruction
1722
+ if messagebox.askyesno("Reconstruct?", f"Recording '{recording_name}' saved.\n\nStart 3D reconstruction now?"):
1723
+ self.var_recording.set(recording_name)
1724
+ self._on_reconstruct()
1725
+
1726
+ elif msg_type == "record_error":
1727
+ self._state = "calibrated"
1728
+ self._update_state_display()
1729
+ self._log(f"[ERROR] Recording failed: {msg}")
1730
+ messagebox.showerror("Error", str(msg))
1731
+
1732
+ elif msg_type == "recon_progress":
1733
+ stage, text, pct = msg
1734
+ if pct >= 0:
1735
+ self._recon_progress["value"] = pct
1736
+ self._recon_label.config(text=text)
1737
+ self._log(f"[{stage}] {text}")
1738
+
1739
+ elif msg_type == "recon_done":
1740
+ self._state = "calibrated"
1741
+ self._update_state_display()
1742
+ self._recon_label.config(text="Reconstruction complete!")
1743
+ self._log(f"3D output: {msg}")
1744
+ # Auto-load result into Panel 4
1745
+ rec_name = self.var_recording.get()
1746
+ tracker = self.var_tracker.get()
1747
+ if rec_name and tracker:
1748
+ self.var_viz_recording.set(rec_name)
1749
+ self.var_viz_tracker.set(tracker)
1750
+ self._refresh_viz_recordings()
1751
+ self._on_load_viz()
1752
+
1753
+ elif msg_type == "recon_error":
1754
+ self._state = "calibrated"
1755
+ self._update_state_display()
1756
+ self._log(f"[ERROR] Reconstruction failed: {msg}")
1757
+ self._recon_label.config(text=f"Error: {msg}")
1758
+ messagebox.showerror("Error", str(msg))
1759
+
1760
+ elif msg_type == "auto_visit_done":
1761
+ visit_name = msg
1762
+ self._log(f"[AUTO] Visit processed: recordings/{visit_name}/")
1763
+ self._refresh_recordings()
1764
+
1765
+ elif msg_type == "viz_loaded":
1766
+ viz_data = msg
1767
+ self._viz_set_data(viz_data)
1768
+ self._log(f"3D visualization loaded: {viz_data.num_frames} frames")
1769
+
1770
+ elif msg_type == "viz_error":
1771
+ self.btn_viz_load.config(state=tk.NORMAL, text="Load")
1772
+ self._log(f"[ERROR] Viz load failed: {msg}")
1773
+ messagebox.showerror("Error", f"Failed to load 3D data:\n{msg}")
1774
+
1775
+ def _update_preview(self):
1776
+ """Update camera preview on canvas."""
1777
+ if not self._camera:
1778
+ return
1779
+
1780
+ frame = self._camera.get_frame()
1781
+ if frame is None:
1782
+ return
1783
+
1784
+ canvas_w = self.preview_canvas.winfo_width()
1785
+ canvas_h = self.preview_canvas.winfo_height()
1786
+ if canvas_w < 10 or canvas_h < 10:
1787
+ return
1788
+
1789
+ frame_h, frame_w = frame.shape[:2]
1790
+ scale = min(canvas_w / frame_w, canvas_h / frame_h)
1791
+ new_w = int(frame_w * scale)
1792
+ new_h = int(frame_h * scale)
1793
+
1794
+ frame_resized = cv2.resize(frame, (new_w, new_h))
1795
+
1796
+ # Draw center line (stereo split indicator)
1797
+ center_x = new_w // 2
1798
+ cv2.line(frame_resized, (center_x, 0), (center_x, new_h), (0, 255, 0), 2)
1799
+
1800
+ frame_rgb = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB)
1801
+ from PIL import Image, ImageTk
1802
+ img = Image.fromarray(frame_rgb)
1803
+ photo = ImageTk.PhotoImage(image=img)
1804
+
1805
+ self.preview_canvas.delete("all")
1806
+ x_offset = (canvas_w - new_w) // 2
1807
+ y_offset = (canvas_h - new_h) // 2
1808
+ self.preview_canvas.create_image(x_offset, y_offset, anchor=tk.NW, image=photo)
1809
+ self.preview_canvas._photo = photo # Keep reference
1810
+
1811
+ # Store preview transform (manual preview: full stereo frame)
1812
+ self._preview_scale = scale
1813
+ self._preview_x_offset = x_offset
1814
+ self._preview_y_offset = y_offset
1815
+ self._preview_left_w = self.config.xsplit # left camera width in source pixels
1816
+ self._preview_left_h = frame_h
1817
+
1818
+ # Draw zone overlay
1819
+ self._draw_zone_overlay()
1820
+
1821
+ def _log(self, msg: str):
1822
+ """Append message to the log panel."""
1823
+ ts = datetime.now().strftime("%H:%M:%S")
1824
+ self._log_text.config(state=tk.NORMAL)
1825
+ self._log_text.insert(tk.END, f"[{ts}] {msg}\n")
1826
+ self._log_text.see(tk.END)
1827
+ self._log_text.config(state=tk.DISABLED)
1828
+
1829
+ def destroy(self):
1830
+ """Cleanup on window close."""
1831
+ if self._smart_recorder:
1832
+ self._smart_recorder.stop()
1833
+ self._smart_recorder = None
1834
+ if self._camera:
1835
+ self._camera.stop()
1836
+ if self._recorder and self._recorder.is_running:
1837
+ self._recorder.stop()
1838
+ super().destroy()