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.
- recorder/__init__.py +90 -0
- recorder/auto_calibrate.py +493 -0
- recorder/calibration_ui.py +1106 -0
- recorder/calibration_ui_advanced.py +1013 -0
- recorder/camera.py +51 -0
- recorder/cli.py +122 -0
- recorder/config.py +75 -0
- recorder/configs/default.yaml +38 -0
- recorder/ffmpeg.py +137 -0
- recorder/paths.py +87 -0
- recorder/pipeline_ui.py +1838 -0
- recorder/project_manager.py +329 -0
- recorder/smart_recorder.py +478 -0
- recorder/ui.py +136 -0
- recorder/viz_3d.py +220 -0
- stereo_charuco_pipeline-0.1.0.dist-info/METADATA +10 -0
- stereo_charuco_pipeline-0.1.0.dist-info/RECORD +19 -0
- stereo_charuco_pipeline-0.1.0.dist-info/WHEEL +4 -0
- stereo_charuco_pipeline-0.1.0.dist-info/entry_points.txt +4 -0
recorder/pipeline_ui.py
ADDED
|
@@ -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()
|