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
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Calibration Recording UI Tool
|
|
3
|
+
|
|
4
|
+
A guided UI for stereo camera calibration video recording.
|
|
5
|
+
Guides users through a standardized 2-minute calibration workflow.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import queue
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable, Optional, List
|
|
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
|
+
from .config import load_yaml_config
|
|
32
|
+
from .ffmpeg import FFmpegRunner, python_executable
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ============================================================================
|
|
36
|
+
# Configuration
|
|
37
|
+
# ============================================================================
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CalibrationPosition:
|
|
41
|
+
"""A single calibration position in the workflow."""
|
|
42
|
+
name: str
|
|
43
|
+
instruction: str
|
|
44
|
+
duration_seconds: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Default calibration positions (total ~120 seconds)
|
|
48
|
+
DEFAULT_POSITIONS: List[CalibrationPosition] = [
|
|
49
|
+
CalibrationPosition("Center", "Hold the board at CENTER of frame", 15),
|
|
50
|
+
CalibrationPosition("Top-Left", "Move board to TOP-LEFT corner", 15),
|
|
51
|
+
CalibrationPosition("Top-Right", "Move board to TOP-RIGHT corner", 15),
|
|
52
|
+
CalibrationPosition("Bottom-Left", "Move board to BOTTOM-LEFT corner", 15),
|
|
53
|
+
CalibrationPosition("Bottom-Right", "Move board to BOTTOM-RIGHT corner", 15),
|
|
54
|
+
CalibrationPosition("Near", "Move board CLOSER to camera", 15),
|
|
55
|
+
CalibrationPosition("Far", "Move board FURTHER from camera", 15),
|
|
56
|
+
CalibrationPosition("Tilt-Left", "TILT board to the LEFT", 10),
|
|
57
|
+
CalibrationPosition("Tilt-Right", "TILT board to the RIGHT", 10),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
MAX_CALIBRATION_TIME = 120 # 2 minutes max
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CalibConfig:
|
|
65
|
+
"""Configuration for calibration recording."""
|
|
66
|
+
device_name: str = "3D USB Camera"
|
|
67
|
+
video_size: str = "3200x1200"
|
|
68
|
+
fps: int = 60
|
|
69
|
+
|
|
70
|
+
# Split parameters
|
|
71
|
+
full_w: int = 3200
|
|
72
|
+
full_h: int = 1200
|
|
73
|
+
xsplit: int = 1600
|
|
74
|
+
|
|
75
|
+
# Encoding
|
|
76
|
+
preset: str = "veryfast"
|
|
77
|
+
crf: int = 18
|
|
78
|
+
|
|
79
|
+
# Output
|
|
80
|
+
output_base: str = ""
|
|
81
|
+
keep_avi: bool = False
|
|
82
|
+
|
|
83
|
+
# FFmpeg
|
|
84
|
+
ffmpeg_exe: str = "ffmpeg"
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_yaml(cls, yaml_path: Path) -> "CalibConfig":
|
|
88
|
+
"""Load configuration from YAML file."""
|
|
89
|
+
data = load_yaml_config(yaml_path)
|
|
90
|
+
|
|
91
|
+
cfg = cls()
|
|
92
|
+
|
|
93
|
+
# Camera settings
|
|
94
|
+
camera = data.get("camera", {})
|
|
95
|
+
cfg.device_name = camera.get("device_name", cfg.device_name)
|
|
96
|
+
cfg.video_size = camera.get("video_size", cfg.video_size)
|
|
97
|
+
cfg.fps = int(camera.get("fps", cfg.fps))
|
|
98
|
+
|
|
99
|
+
# Split settings
|
|
100
|
+
split = data.get("split", {})
|
|
101
|
+
cfg.full_w = int(split.get("full_w", cfg.full_w))
|
|
102
|
+
cfg.full_h = int(split.get("full_h", cfg.full_h))
|
|
103
|
+
cfg.xsplit = int(split.get("xsplit", cfg.xsplit))
|
|
104
|
+
|
|
105
|
+
# MP4 encoding
|
|
106
|
+
mp4 = data.get("mp4", {})
|
|
107
|
+
cfg.preset = mp4.get("preset", cfg.preset)
|
|
108
|
+
cfg.crf = int(mp4.get("crf", cfg.crf))
|
|
109
|
+
|
|
110
|
+
# Output
|
|
111
|
+
output = data.get("output", {})
|
|
112
|
+
cfg.output_base = output.get("base_dir", cfg.output_base)
|
|
113
|
+
cfg.keep_avi = bool(output.get("keep_avi", cfg.keep_avi))
|
|
114
|
+
|
|
115
|
+
# FFmpeg
|
|
116
|
+
ffmpeg = data.get("ffmpeg", {})
|
|
117
|
+
cfg.ffmpeg_exe = ffmpeg.get("executable", cfg.ffmpeg_exe)
|
|
118
|
+
|
|
119
|
+
return cfg
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ============================================================================
|
|
123
|
+
# Camera Preview Thread
|
|
124
|
+
# ============================================================================
|
|
125
|
+
|
|
126
|
+
class CameraPreview:
|
|
127
|
+
"""Handles camera preview using OpenCV."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, device_name: str, video_size: str, fps: int,
|
|
130
|
+
device_index: int = 0):
|
|
131
|
+
self.device_name = device_name
|
|
132
|
+
self.video_size = video_size
|
|
133
|
+
self.fps = fps
|
|
134
|
+
self.device_index = device_index
|
|
135
|
+
|
|
136
|
+
self._cap: Optional[cv2.VideoCapture] = None
|
|
137
|
+
self._running = False
|
|
138
|
+
self._thread: Optional[threading.Thread] = None
|
|
139
|
+
self._frame_queue: queue.Queue = queue.Queue(maxsize=2)
|
|
140
|
+
self._lock = threading.Lock()
|
|
141
|
+
|
|
142
|
+
# Parse video size
|
|
143
|
+
parts = video_size.split("x")
|
|
144
|
+
self.width = int(parts[0])
|
|
145
|
+
self.height = int(parts[1])
|
|
146
|
+
|
|
147
|
+
def start(self) -> bool:
|
|
148
|
+
"""Start camera capture. Returns True if successful."""
|
|
149
|
+
if not CV2_AVAILABLE:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# Open camera by device index
|
|
153
|
+
if sys.platform == "win32":
|
|
154
|
+
self._cap = cv2.VideoCapture(self.device_index, cv2.CAP_DSHOW)
|
|
155
|
+
else:
|
|
156
|
+
self._cap = cv2.VideoCapture(self.device_index)
|
|
157
|
+
|
|
158
|
+
if not self._cap.isOpened():
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
# Set camera properties
|
|
162
|
+
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
|
|
163
|
+
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
|
|
164
|
+
self._cap.set(cv2.CAP_PROP_FPS, self.fps)
|
|
165
|
+
self._cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
|
|
166
|
+
|
|
167
|
+
self._running = True
|
|
168
|
+
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
|
169
|
+
self._thread.start()
|
|
170
|
+
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
def stop(self):
|
|
174
|
+
"""Stop camera capture."""
|
|
175
|
+
self._running = False
|
|
176
|
+
if self._thread:
|
|
177
|
+
self._thread.join(timeout=1.0)
|
|
178
|
+
if self._cap:
|
|
179
|
+
self._cap.release()
|
|
180
|
+
self._cap = None
|
|
181
|
+
|
|
182
|
+
def get_frame(self) -> Optional[np.ndarray]:
|
|
183
|
+
"""Get the latest frame (non-blocking)."""
|
|
184
|
+
try:
|
|
185
|
+
return self._frame_queue.get_nowait()
|
|
186
|
+
except queue.Empty:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def _capture_loop(self):
|
|
190
|
+
"""Background thread for capturing frames."""
|
|
191
|
+
while self._running and self._cap and self._cap.isOpened():
|
|
192
|
+
ret, frame = self._cap.read()
|
|
193
|
+
if ret and frame is not None:
|
|
194
|
+
# Clear old frames and put new one
|
|
195
|
+
try:
|
|
196
|
+
self._frame_queue.get_nowait()
|
|
197
|
+
except queue.Empty:
|
|
198
|
+
pass
|
|
199
|
+
try:
|
|
200
|
+
self._frame_queue.put_nowait(frame)
|
|
201
|
+
except queue.Full:
|
|
202
|
+
pass
|
|
203
|
+
time.sleep(1.0 / 30) # Limit to ~30fps for preview
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ============================================================================
|
|
207
|
+
# FFmpeg Recording
|
|
208
|
+
# ============================================================================
|
|
209
|
+
|
|
210
|
+
class FFmpegRecorder:
|
|
211
|
+
"""Handles FFmpeg-based video recording."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, config: CalibConfig, output_path: Path):
|
|
214
|
+
self.config = config
|
|
215
|
+
self.output_path = output_path
|
|
216
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
217
|
+
self._running = False
|
|
218
|
+
|
|
219
|
+
def _resolve_ffmpeg(self) -> str:
|
|
220
|
+
"""Resolve ffmpeg executable path."""
|
|
221
|
+
exe = self.config.ffmpeg_exe
|
|
222
|
+
|
|
223
|
+
if not exe:
|
|
224
|
+
return "ffmpeg"
|
|
225
|
+
|
|
226
|
+
if os.path.isabs(exe):
|
|
227
|
+
return exe
|
|
228
|
+
|
|
229
|
+
# Resolve relative to tool root
|
|
230
|
+
from .paths import tool_root as _tool_root; tool_root = _tool_root()
|
|
231
|
+
candidate = tool_root / exe
|
|
232
|
+
if candidate.exists():
|
|
233
|
+
return str(candidate)
|
|
234
|
+
|
|
235
|
+
# Try system PATH
|
|
236
|
+
which = shutil.which("ffmpeg")
|
|
237
|
+
if which:
|
|
238
|
+
return which
|
|
239
|
+
|
|
240
|
+
return exe
|
|
241
|
+
|
|
242
|
+
def start(self) -> bool:
|
|
243
|
+
"""Start recording. Returns True if successful."""
|
|
244
|
+
ffmpeg = self._resolve_ffmpeg()
|
|
245
|
+
|
|
246
|
+
cmd = [
|
|
247
|
+
ffmpeg, "-hide_banner", "-y",
|
|
248
|
+
"-f", "dshow",
|
|
249
|
+
"-video_size", self.config.video_size,
|
|
250
|
+
"-framerate", str(self.config.fps),
|
|
251
|
+
"-vcodec", "mjpeg",
|
|
252
|
+
"-i", f"video={self.config.device_name}",
|
|
253
|
+
"-c:v", "copy",
|
|
254
|
+
str(self.output_path)
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
self._proc = subprocess.Popen(
|
|
259
|
+
cmd,
|
|
260
|
+
stdin=subprocess.PIPE,
|
|
261
|
+
stdout=subprocess.PIPE,
|
|
262
|
+
stderr=subprocess.PIPE,
|
|
263
|
+
)
|
|
264
|
+
self._running = True
|
|
265
|
+
return True
|
|
266
|
+
except Exception as e:
|
|
267
|
+
print(f"[ERROR] Failed to start recording: {e}")
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
def stop(self) -> bool:
|
|
271
|
+
"""Stop recording gracefully."""
|
|
272
|
+
if not self._proc:
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
self._running = False
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
# Send 'q' to FFmpeg to stop gracefully
|
|
279
|
+
if self._proc.stdin:
|
|
280
|
+
self._proc.stdin.write(b'q')
|
|
281
|
+
self._proc.stdin.flush()
|
|
282
|
+
|
|
283
|
+
# Wait for process to finish
|
|
284
|
+
self._proc.wait(timeout=5)
|
|
285
|
+
return self._proc.returncode == 0
|
|
286
|
+
except subprocess.TimeoutExpired:
|
|
287
|
+
self._proc.terminate()
|
|
288
|
+
try:
|
|
289
|
+
self._proc.wait(timeout=2)
|
|
290
|
+
except subprocess.TimeoutExpired:
|
|
291
|
+
self._proc.kill()
|
|
292
|
+
return False
|
|
293
|
+
except Exception as e:
|
|
294
|
+
print(f"[ERROR] Failed to stop recording: {e}")
|
|
295
|
+
return False
|
|
296
|
+
finally:
|
|
297
|
+
self._proc = None
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def is_running(self) -> bool:
|
|
301
|
+
return self._running and self._proc is not None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ============================================================================
|
|
305
|
+
# Post-Processing Pipeline
|
|
306
|
+
# ============================================================================
|
|
307
|
+
|
|
308
|
+
class PostProcessor:
|
|
309
|
+
"""Handles post-processing of recorded video."""
|
|
310
|
+
|
|
311
|
+
def __init__(self, config: CalibConfig, session_dir: Path,
|
|
312
|
+
on_log: Optional[Callable[[str], None]] = None):
|
|
313
|
+
self.config = config
|
|
314
|
+
self.session_dir = session_dir
|
|
315
|
+
self.on_log = on_log
|
|
316
|
+
|
|
317
|
+
def _log(self, msg: str):
|
|
318
|
+
if self.on_log:
|
|
319
|
+
self.on_log(msg)
|
|
320
|
+
|
|
321
|
+
def _resolve_ffmpeg(self) -> str:
|
|
322
|
+
"""Resolve ffmpeg executable path."""
|
|
323
|
+
exe = self.config.ffmpeg_exe
|
|
324
|
+
|
|
325
|
+
if not exe:
|
|
326
|
+
return "ffmpeg"
|
|
327
|
+
|
|
328
|
+
if os.path.isabs(exe):
|
|
329
|
+
return exe
|
|
330
|
+
|
|
331
|
+
from .paths import tool_root as _tool_root; tool_root = _tool_root()
|
|
332
|
+
candidate = tool_root / exe
|
|
333
|
+
if candidate.exists():
|
|
334
|
+
return str(candidate)
|
|
335
|
+
|
|
336
|
+
which = shutil.which("ffmpeg")
|
|
337
|
+
if which:
|
|
338
|
+
return which
|
|
339
|
+
|
|
340
|
+
return exe
|
|
341
|
+
|
|
342
|
+
def run(self, raw_avi: Path, output_dirs) -> bool:
|
|
343
|
+
"""
|
|
344
|
+
Run the full post-processing pipeline.
|
|
345
|
+
|
|
346
|
+
1. Convert AVI to MP4
|
|
347
|
+
2. Split into left/right
|
|
348
|
+
3. Copy port_1.mp4 / port_2.mp4 to each output directory
|
|
349
|
+
|
|
350
|
+
Parameters
|
|
351
|
+
----------
|
|
352
|
+
raw_avi : Path
|
|
353
|
+
Input raw AVI file.
|
|
354
|
+
output_dirs : Path or list[Path]
|
|
355
|
+
One or more directories to receive port_1.mp4 / port_2.mp4.
|
|
356
|
+
"""
|
|
357
|
+
# Normalise to list
|
|
358
|
+
if isinstance(output_dirs, (str, Path)):
|
|
359
|
+
output_dirs = [Path(output_dirs)]
|
|
360
|
+
else:
|
|
361
|
+
output_dirs = [Path(d) for d in output_dirs]
|
|
362
|
+
|
|
363
|
+
ffmpeg = self._resolve_ffmpeg()
|
|
364
|
+
|
|
365
|
+
raw_mp4 = self.session_dir / "raw.mp4"
|
|
366
|
+
left_mp4 = self.session_dir / "left.mp4"
|
|
367
|
+
right_mp4 = self.session_dir / "right.mp4"
|
|
368
|
+
|
|
369
|
+
preset = self.config.preset
|
|
370
|
+
crf = str(self.config.crf)
|
|
371
|
+
|
|
372
|
+
# Step 1: Convert AVI to MP4
|
|
373
|
+
self._log("[STEP 1] Converting AVI to MP4...")
|
|
374
|
+
cmd_convert = [
|
|
375
|
+
ffmpeg, "-hide_banner", "-y",
|
|
376
|
+
"-i", str(raw_avi),
|
|
377
|
+
"-c:v", "libx264", "-preset", preset, "-crf", crf,
|
|
378
|
+
"-pix_fmt", "yuv420p",
|
|
379
|
+
str(raw_mp4)
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
result = subprocess.run(cmd_convert, capture_output=True, text=True)
|
|
383
|
+
if result.returncode != 0:
|
|
384
|
+
self._log(f"[ERROR] Convert failed: {result.stderr}")
|
|
385
|
+
return False
|
|
386
|
+
self._log("[STEP 1] Conversion complete.")
|
|
387
|
+
|
|
388
|
+
# Step 2: Split into left/right
|
|
389
|
+
self._log("[STEP 2] Splitting into left/right videos...")
|
|
390
|
+
|
|
391
|
+
xsplit = self.config.xsplit
|
|
392
|
+
full_w = self.config.full_w
|
|
393
|
+
full_h = self.config.full_h
|
|
394
|
+
|
|
395
|
+
left_crop = f"crop={xsplit}:{full_h}:0:0"
|
|
396
|
+
right_crop = f"crop={full_w - xsplit}:{full_h}:{xsplit}:0"
|
|
397
|
+
|
|
398
|
+
# Left video
|
|
399
|
+
cmd_left = [
|
|
400
|
+
ffmpeg, "-hide_banner", "-y",
|
|
401
|
+
"-i", str(raw_mp4),
|
|
402
|
+
"-vf", left_crop,
|
|
403
|
+
"-c:v", "libx264", "-preset", preset, "-crf", crf,
|
|
404
|
+
"-pix_fmt", "yuv420p",
|
|
405
|
+
str(left_mp4)
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
result = subprocess.run(cmd_left, capture_output=True, text=True)
|
|
409
|
+
if result.returncode != 0:
|
|
410
|
+
self._log(f"[ERROR] Left split failed: {result.stderr}")
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
# Right video
|
|
414
|
+
cmd_right = [
|
|
415
|
+
ffmpeg, "-hide_banner", "-y",
|
|
416
|
+
"-i", str(raw_mp4),
|
|
417
|
+
"-vf", right_crop,
|
|
418
|
+
"-c:v", "libx264", "-preset", preset, "-crf", crf,
|
|
419
|
+
"-pix_fmt", "yuv420p",
|
|
420
|
+
str(right_mp4)
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
result = subprocess.run(cmd_right, capture_output=True, text=True)
|
|
424
|
+
if result.returncode != 0:
|
|
425
|
+
self._log(f"[ERROR] Right split failed: {result.stderr}")
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
self._log("[STEP 2] Split complete.")
|
|
429
|
+
|
|
430
|
+
# Step 3: Copy port_1.mp4 / port_2.mp4 to ALL output directories
|
|
431
|
+
self._log("[STEP 3] Copying to output directories...")
|
|
432
|
+
|
|
433
|
+
for out_dir in output_dirs:
|
|
434
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
shutil.copy2(left_mp4, out_dir / "port_1.mp4")
|
|
436
|
+
shutil.copy2(right_mp4, out_dir / "port_2.mp4")
|
|
437
|
+
self._log(f" → {out_dir / 'port_1.mp4'}")
|
|
438
|
+
self._log(f" → {out_dir / 'port_2.mp4'}")
|
|
439
|
+
|
|
440
|
+
self._log("[DONE] All copies complete.")
|
|
441
|
+
|
|
442
|
+
# Cleanup intermediate files if not keeping AVI
|
|
443
|
+
if not self.config.keep_avi:
|
|
444
|
+
try:
|
|
445
|
+
raw_avi.unlink()
|
|
446
|
+
self._log(f"[CLEANUP] Deleted {raw_avi}")
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
return True
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ============================================================================
|
|
454
|
+
# Main UI
|
|
455
|
+
# ============================================================================
|
|
456
|
+
|
|
457
|
+
class CalibrationUI(tk.Tk):
|
|
458
|
+
"""Main calibration recording UI."""
|
|
459
|
+
|
|
460
|
+
def __init__(self, config_path: Optional[Path] = None,
|
|
461
|
+
project_dir: Optional[Path] = None,
|
|
462
|
+
on_complete: Optional[Callable[[], None]] = None):
|
|
463
|
+
super().__init__()
|
|
464
|
+
|
|
465
|
+
self.geometry("1200x800")
|
|
466
|
+
self.configure(bg="#2b2b2b")
|
|
467
|
+
|
|
468
|
+
# Load configuration
|
|
469
|
+
self._load_config(config_path)
|
|
470
|
+
|
|
471
|
+
# Dynamic project directory (overrides config-derived path)
|
|
472
|
+
self._project_dir: Optional[Path] = Path(project_dir) if project_dir else None
|
|
473
|
+
self._on_complete = on_complete
|
|
474
|
+
|
|
475
|
+
if self._project_dir:
|
|
476
|
+
self.title(f"Stereo Calibration - {self._project_dir.name}")
|
|
477
|
+
else:
|
|
478
|
+
self.title("Stereo Calibration Recording Tool")
|
|
479
|
+
|
|
480
|
+
# State
|
|
481
|
+
self._state = "idle" # idle, preview, recording, processing
|
|
482
|
+
self._camera: Optional[CameraPreview] = None
|
|
483
|
+
self._recorder: Optional[FFmpegRecorder] = None
|
|
484
|
+
self._positions = DEFAULT_POSITIONS.copy()
|
|
485
|
+
self._current_position_idx = 0
|
|
486
|
+
self._recording_start_time: Optional[float] = None
|
|
487
|
+
self._position_start_time: Optional[float] = None
|
|
488
|
+
self._session_dir: Optional[Path] = None
|
|
489
|
+
self._raw_avi_path: Optional[Path] = None
|
|
490
|
+
|
|
491
|
+
# Message queue for thread-safe UI updates
|
|
492
|
+
self._msg_queue: queue.Queue = queue.Queue()
|
|
493
|
+
|
|
494
|
+
# Build UI
|
|
495
|
+
self._build_ui()
|
|
496
|
+
|
|
497
|
+
# Start periodic updates
|
|
498
|
+
self.after(50, self._update_loop)
|
|
499
|
+
|
|
500
|
+
def _load_config(self, config_path: Optional[Path]):
|
|
501
|
+
"""Load configuration from YAML or use defaults."""
|
|
502
|
+
if config_path and config_path.exists():
|
|
503
|
+
self.config = CalibConfig.from_yaml(config_path)
|
|
504
|
+
else:
|
|
505
|
+
# Try default config path
|
|
506
|
+
from .paths import tool_root as _tool_root; tool_root = _tool_root()
|
|
507
|
+
default_config = tool_root / "configs" / "default.yaml"
|
|
508
|
+
if default_config.exists():
|
|
509
|
+
self.config = CalibConfig.from_yaml(default_config)
|
|
510
|
+
else:
|
|
511
|
+
self.config = CalibConfig()
|
|
512
|
+
|
|
513
|
+
# Resolve output base
|
|
514
|
+
if self.config.output_base and not os.path.isabs(self.config.output_base):
|
|
515
|
+
from .paths import tool_root as _tool_root; tool_root = _tool_root()
|
|
516
|
+
self.config.output_base = str(tool_root / self.config.output_base)
|
|
517
|
+
|
|
518
|
+
def _build_ui(self):
|
|
519
|
+
"""Build the main UI components."""
|
|
520
|
+
# Main container
|
|
521
|
+
main_frame = ttk.Frame(self, padding=10)
|
|
522
|
+
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
523
|
+
|
|
524
|
+
# Style configuration
|
|
525
|
+
style = ttk.Style()
|
|
526
|
+
style.configure("Title.TLabel", font=("Segoe UI", 16, "bold"))
|
|
527
|
+
style.configure("Status.TLabel", font=("Segoe UI", 12))
|
|
528
|
+
style.configure("Instruction.TLabel", font=("Segoe UI", 14, "bold"))
|
|
529
|
+
style.configure("Big.TButton", font=("Segoe UI", 12), padding=10)
|
|
530
|
+
|
|
531
|
+
# Top: Status and controls
|
|
532
|
+
top_frame = ttk.Frame(main_frame)
|
|
533
|
+
top_frame.pack(fill=tk.X, pady=(0, 10))
|
|
534
|
+
|
|
535
|
+
# Title
|
|
536
|
+
ttk.Label(top_frame, text="Stereo Camera Calibration",
|
|
537
|
+
style="Title.TLabel").pack(side=tk.LEFT)
|
|
538
|
+
|
|
539
|
+
# Status indicator
|
|
540
|
+
self.status_var = tk.StringVar(value="Camera Ready")
|
|
541
|
+
self.status_label = ttk.Label(top_frame, textvariable=self.status_var,
|
|
542
|
+
style="Status.TLabel")
|
|
543
|
+
self.status_label.pack(side=tk.RIGHT, padx=10)
|
|
544
|
+
|
|
545
|
+
# Status dot
|
|
546
|
+
self.status_canvas = tk.Canvas(top_frame, width=20, height=20,
|
|
547
|
+
highlightthickness=0)
|
|
548
|
+
self.status_canvas.pack(side=tk.RIGHT)
|
|
549
|
+
self._status_dot = self.status_canvas.create_oval(4, 4, 16, 16,
|
|
550
|
+
fill="#4CAF50", outline="")
|
|
551
|
+
|
|
552
|
+
# Middle: Preview and Progress
|
|
553
|
+
middle_frame = ttk.Frame(main_frame)
|
|
554
|
+
middle_frame.pack(fill=tk.BOTH, expand=True)
|
|
555
|
+
|
|
556
|
+
# Left: Camera preview
|
|
557
|
+
preview_frame = ttk.LabelFrame(middle_frame, text="Camera Preview", padding=5)
|
|
558
|
+
preview_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))
|
|
559
|
+
|
|
560
|
+
# Canvas for video preview (scaled down for display)
|
|
561
|
+
self.preview_canvas = tk.Canvas(preview_frame, bg="#1a1a1a",
|
|
562
|
+
width=800, height=300)
|
|
563
|
+
self.preview_canvas.pack(fill=tk.BOTH, expand=True)
|
|
564
|
+
|
|
565
|
+
# Right: Calibration guide
|
|
566
|
+
guide_frame = ttk.LabelFrame(middle_frame, text="Calibration Guide", padding=10)
|
|
567
|
+
guide_frame.pack(side=tk.RIGHT, fill=tk.Y, ipadx=20)
|
|
568
|
+
|
|
569
|
+
# Current instruction
|
|
570
|
+
ttk.Label(guide_frame, text="Current Position:").pack(anchor=tk.W)
|
|
571
|
+
self.position_var = tk.StringVar(value="--")
|
|
572
|
+
ttk.Label(guide_frame, textvariable=self.position_var,
|
|
573
|
+
style="Instruction.TLabel", foreground="#2196F3").pack(anchor=tk.W, pady=(5, 15))
|
|
574
|
+
|
|
575
|
+
# Instruction text
|
|
576
|
+
ttk.Label(guide_frame, text="Instruction:").pack(anchor=tk.W)
|
|
577
|
+
self.instruction_var = tk.StringVar(value="Click 'Start Camera' to begin")
|
|
578
|
+
self.instruction_label = ttk.Label(guide_frame, textvariable=self.instruction_var,
|
|
579
|
+
wraplength=250)
|
|
580
|
+
self.instruction_label.pack(anchor=tk.W, pady=(5, 15))
|
|
581
|
+
|
|
582
|
+
# Position countdown
|
|
583
|
+
ttk.Label(guide_frame, text="Position Time:").pack(anchor=tk.W)
|
|
584
|
+
self.position_time_var = tk.StringVar(value="--")
|
|
585
|
+
ttk.Label(guide_frame, textvariable=self.position_time_var,
|
|
586
|
+
font=("Segoe UI", 24, "bold")).pack(anchor=tk.W, pady=(5, 15))
|
|
587
|
+
|
|
588
|
+
# Total time
|
|
589
|
+
ttk.Label(guide_frame, text="Total Time:").pack(anchor=tk.W)
|
|
590
|
+
self.total_time_var = tk.StringVar(value="0:00 / 2:00")
|
|
591
|
+
ttk.Label(guide_frame, textvariable=self.total_time_var,
|
|
592
|
+
font=("Segoe UI", 14)).pack(anchor=tk.W, pady=(5, 15))
|
|
593
|
+
|
|
594
|
+
# Progress section
|
|
595
|
+
progress_frame = ttk.LabelFrame(main_frame, text="Calibration Progress", padding=10)
|
|
596
|
+
progress_frame.pack(fill=tk.X, pady=10)
|
|
597
|
+
|
|
598
|
+
# Progress bar
|
|
599
|
+
self.progress_var = tk.DoubleVar(value=0)
|
|
600
|
+
self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var,
|
|
601
|
+
maximum=100, length=600)
|
|
602
|
+
self.progress_bar.pack(fill=tk.X, pady=(0, 10))
|
|
603
|
+
|
|
604
|
+
# Position indicators
|
|
605
|
+
self.position_frame = ttk.Frame(progress_frame)
|
|
606
|
+
self.position_frame.pack(fill=tk.X)
|
|
607
|
+
|
|
608
|
+
self.position_labels = []
|
|
609
|
+
for i, pos in enumerate(self._positions):
|
|
610
|
+
lbl = ttk.Label(self.position_frame, text=pos.name,
|
|
611
|
+
font=("Segoe UI", 9), foreground="#888888")
|
|
612
|
+
lbl.pack(side=tk.LEFT, expand=True)
|
|
613
|
+
self.position_labels.append(lbl)
|
|
614
|
+
|
|
615
|
+
# Bottom: Control buttons
|
|
616
|
+
button_frame = ttk.Frame(main_frame)
|
|
617
|
+
button_frame.pack(fill=tk.X, pady=10)
|
|
618
|
+
|
|
619
|
+
# Camera index selector
|
|
620
|
+
cam_select_frame = ttk.Frame(button_frame)
|
|
621
|
+
cam_select_frame.pack(side=tk.LEFT, padx=(5, 15))
|
|
622
|
+
|
|
623
|
+
ttk.Label(cam_select_frame, text="Camera:").pack(side=tk.LEFT, padx=(0, 4))
|
|
624
|
+
self.var_cam_index = tk.IntVar(value=0)
|
|
625
|
+
self.spin_cam = ttk.Spinbox(
|
|
626
|
+
cam_select_frame, from_=0, to=9, width=3,
|
|
627
|
+
textvariable=self.var_cam_index, state="readonly"
|
|
628
|
+
)
|
|
629
|
+
self.spin_cam.pack(side=tk.LEFT)
|
|
630
|
+
|
|
631
|
+
self.btn_switch_cam = ttk.Button(
|
|
632
|
+
cam_select_frame, text="Switch",
|
|
633
|
+
command=self._on_switch_camera
|
|
634
|
+
)
|
|
635
|
+
self.btn_switch_cam.pack(side=tk.LEFT, padx=(4, 0))
|
|
636
|
+
|
|
637
|
+
self.btn_camera = ttk.Button(button_frame, text="Start Camera",
|
|
638
|
+
style="Big.TButton", command=self._on_camera_toggle)
|
|
639
|
+
self.btn_camera.pack(side=tk.LEFT, padx=5)
|
|
640
|
+
|
|
641
|
+
self.btn_record = ttk.Button(button_frame, text="Start Calibration Recording",
|
|
642
|
+
style="Big.TButton", command=self._on_record_start,
|
|
643
|
+
state=tk.DISABLED)
|
|
644
|
+
self.btn_record.pack(side=tk.LEFT, padx=5)
|
|
645
|
+
|
|
646
|
+
self.btn_stop = ttk.Button(button_frame, text="Stop",
|
|
647
|
+
style="Big.TButton", command=self._on_stop,
|
|
648
|
+
state=tk.DISABLED)
|
|
649
|
+
self.btn_stop.pack(side=tk.LEFT, padx=5)
|
|
650
|
+
|
|
651
|
+
# Log area
|
|
652
|
+
log_frame = ttk.LabelFrame(main_frame, text="Log", padding=5)
|
|
653
|
+
log_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
|
654
|
+
|
|
655
|
+
self.log_text = tk.Text(log_frame, height=6, bg="#1a1a1a", fg="#ffffff",
|
|
656
|
+
font=("Consolas", 9))
|
|
657
|
+
self.log_text.pack(fill=tk.BOTH, expand=True)
|
|
658
|
+
|
|
659
|
+
scrollbar = ttk.Scrollbar(self.log_text, command=self.log_text.yview)
|
|
660
|
+
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
661
|
+
self.log_text.config(yscrollcommand=scrollbar.set)
|
|
662
|
+
|
|
663
|
+
self._log("Calibration UI initialized.")
|
|
664
|
+
self._log(f"Device: {self.config.device_name}")
|
|
665
|
+
self._log(f"Resolution: {self.config.video_size} @ {self.config.fps}fps")
|
|
666
|
+
|
|
667
|
+
def _log(self, msg: str):
|
|
668
|
+
"""Add message to log."""
|
|
669
|
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
670
|
+
self.log_text.insert(tk.END, f"[{timestamp}] {msg}\n")
|
|
671
|
+
self.log_text.see(tk.END)
|
|
672
|
+
|
|
673
|
+
def _set_status(self, text: str, color: str = "#4CAF50"):
|
|
674
|
+
"""Update status indicator."""
|
|
675
|
+
self.status_var.set(text)
|
|
676
|
+
self.status_canvas.itemconfig(self._status_dot, fill=color)
|
|
677
|
+
|
|
678
|
+
def _open_camera(self, device_index: int) -> bool:
|
|
679
|
+
"""Open camera with the given device index. Returns True if successful."""
|
|
680
|
+
if not CV2_AVAILABLE:
|
|
681
|
+
messagebox.showerror("Error", "OpenCV (cv2) is not installed.")
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
self._camera = CameraPreview(
|
|
685
|
+
self.config.device_name,
|
|
686
|
+
self.config.video_size,
|
|
687
|
+
self.config.fps,
|
|
688
|
+
device_index=device_index,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
if self._camera.start():
|
|
692
|
+
self._state = "preview"
|
|
693
|
+
self._set_status(f"Camera {device_index} Active", "#4CAF50")
|
|
694
|
+
self.btn_camera.config(text="Stop Camera")
|
|
695
|
+
self.btn_record.config(state=tk.NORMAL)
|
|
696
|
+
self.instruction_var.set("Camera ready. Click 'Start Calibration Recording' to begin.")
|
|
697
|
+
self._log(f"Camera {device_index} started successfully.")
|
|
698
|
+
return True
|
|
699
|
+
else:
|
|
700
|
+
self._camera = None
|
|
701
|
+
messagebox.showerror("Error", f"Failed to open camera {device_index}.")
|
|
702
|
+
self._log(f"Failed to open camera {device_index}.")
|
|
703
|
+
return False
|
|
704
|
+
|
|
705
|
+
def _close_camera(self):
|
|
706
|
+
"""Close the current camera."""
|
|
707
|
+
if self._camera:
|
|
708
|
+
self._camera.stop()
|
|
709
|
+
self._camera = None
|
|
710
|
+
self._state = "idle"
|
|
711
|
+
self._set_status("Camera Ready", "#888888")
|
|
712
|
+
self.btn_camera.config(text="Start Camera")
|
|
713
|
+
self.btn_record.config(state=tk.DISABLED)
|
|
714
|
+
self.instruction_var.set("Click 'Start Camera' to begin")
|
|
715
|
+
|
|
716
|
+
def _on_camera_toggle(self):
|
|
717
|
+
"""Toggle camera preview on/off."""
|
|
718
|
+
if self._camera is None:
|
|
719
|
+
self._open_camera(self.var_cam_index.get())
|
|
720
|
+
else:
|
|
721
|
+
self._close_camera()
|
|
722
|
+
self._log("Camera stopped.")
|
|
723
|
+
|
|
724
|
+
def _on_switch_camera(self):
|
|
725
|
+
"""Switch to a different camera device index."""
|
|
726
|
+
new_index = self.var_cam_index.get()
|
|
727
|
+
|
|
728
|
+
if self._state == "recording":
|
|
729
|
+
messagebox.showwarning("Recording", "Cannot switch camera while recording.")
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
# If camera is running, restart with new index
|
|
733
|
+
was_open = self._camera is not None
|
|
734
|
+
if was_open:
|
|
735
|
+
self._close_camera()
|
|
736
|
+
self._log(f"Switching to camera {new_index}...")
|
|
737
|
+
time.sleep(0.3)
|
|
738
|
+
|
|
739
|
+
self._open_camera(new_index)
|
|
740
|
+
|
|
741
|
+
def _on_record_start(self):
|
|
742
|
+
"""Start calibration recording."""
|
|
743
|
+
if self._state != "preview":
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
# Stop camera preview first (to release camera for FFmpeg)
|
|
747
|
+
if self._camera:
|
|
748
|
+
self._camera.stop()
|
|
749
|
+
self._camera = None
|
|
750
|
+
self._log("Camera preview stopped for recording.")
|
|
751
|
+
# Small delay to ensure camera is fully released
|
|
752
|
+
time.sleep(0.5)
|
|
753
|
+
|
|
754
|
+
# Create session directory under raw_output/
|
|
755
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
756
|
+
|
|
757
|
+
# Resolve project root
|
|
758
|
+
if self._project_dir:
|
|
759
|
+
project_root = self._project_dir
|
|
760
|
+
else:
|
|
761
|
+
from .paths import tool_root as _tool_root; tool_root = _tool_root()
|
|
762
|
+
if self.config.output_base:
|
|
763
|
+
output_base = Path(self.config.output_base)
|
|
764
|
+
if not output_base.is_absolute():
|
|
765
|
+
output_base = tool_root / output_base
|
|
766
|
+
project_root = output_base.parent
|
|
767
|
+
else:
|
|
768
|
+
project_root = tool_root
|
|
769
|
+
|
|
770
|
+
raw_output_dir = project_root / "raw_output"
|
|
771
|
+
self._session_dir = raw_output_dir / f"calib_session_{ts}"
|
|
772
|
+
self._session_dir.mkdir(parents=True, exist_ok=True)
|
|
773
|
+
|
|
774
|
+
self._raw_avi_path = self._session_dir / "raw.avi"
|
|
775
|
+
|
|
776
|
+
# Start FFmpeg recording
|
|
777
|
+
self._recorder = FFmpegRecorder(self.config, self._raw_avi_path)
|
|
778
|
+
|
|
779
|
+
if not self._recorder.start():
|
|
780
|
+
messagebox.showerror("Error", "Failed to start recording.")
|
|
781
|
+
self._log("Failed to start FFmpeg recording.")
|
|
782
|
+
# Restart camera preview
|
|
783
|
+
self._restart_camera_preview()
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
# Initialize recording state
|
|
787
|
+
self._state = "recording"
|
|
788
|
+
self._current_position_idx = 0
|
|
789
|
+
self._recording_start_time = time.time()
|
|
790
|
+
self._position_start_time = time.time()
|
|
791
|
+
|
|
792
|
+
# Update UI
|
|
793
|
+
self._set_status("RECORDING", "#f44336")
|
|
794
|
+
self.btn_camera.config(state=tk.DISABLED)
|
|
795
|
+
self.btn_record.config(state=tk.DISABLED)
|
|
796
|
+
self.btn_stop.config(state=tk.NORMAL)
|
|
797
|
+
|
|
798
|
+
self._update_position_display()
|
|
799
|
+
self._log(f"Recording started. Session: {self._session_dir}")
|
|
800
|
+
|
|
801
|
+
def _restart_camera_preview(self):
|
|
802
|
+
"""Restart camera preview after recording."""
|
|
803
|
+
if not CV2_AVAILABLE:
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
self._open_camera(self.var_cam_index.get())
|
|
807
|
+
|
|
808
|
+
def _on_stop(self):
|
|
809
|
+
"""Stop recording manually."""
|
|
810
|
+
if self._state == "recording":
|
|
811
|
+
self._finish_recording()
|
|
812
|
+
|
|
813
|
+
def _finish_recording(self):
|
|
814
|
+
"""Finish recording and start post-processing."""
|
|
815
|
+
if self._recorder:
|
|
816
|
+
self._recorder.stop()
|
|
817
|
+
self._recorder = None
|
|
818
|
+
|
|
819
|
+
self._state = "processing"
|
|
820
|
+
self._set_status("Processing...", "#FF9800")
|
|
821
|
+
self.btn_stop.config(state=tk.DISABLED)
|
|
822
|
+
|
|
823
|
+
self.instruction_var.set("Processing video...")
|
|
824
|
+
self.position_var.set("--")
|
|
825
|
+
self.position_time_var.set("--")
|
|
826
|
+
|
|
827
|
+
self._log("Recording stopped. Starting post-processing...")
|
|
828
|
+
|
|
829
|
+
# Run post-processing in background thread
|
|
830
|
+
thread = threading.Thread(target=self._run_post_processing, daemon=True)
|
|
831
|
+
thread.start()
|
|
832
|
+
|
|
833
|
+
def _run_post_processing(self):
|
|
834
|
+
"""Run post-processing pipeline in background."""
|
|
835
|
+
try:
|
|
836
|
+
# Determine project root
|
|
837
|
+
if self._project_dir:
|
|
838
|
+
project_root = self._project_dir
|
|
839
|
+
else:
|
|
840
|
+
from .paths import tool_root as _tool_root; tool_root = _tool_root()
|
|
841
|
+
if self.config.output_base:
|
|
842
|
+
output_base = Path(self.config.output_base)
|
|
843
|
+
if not output_base.is_absolute():
|
|
844
|
+
output_base = tool_root / output_base
|
|
845
|
+
project_root = output_base.parent
|
|
846
|
+
else:
|
|
847
|
+
project_root = tool_root
|
|
848
|
+
|
|
849
|
+
# Output to both intrinsic and extrinsic
|
|
850
|
+
intrinsic_dir = project_root / "calibration" / "intrinsic"
|
|
851
|
+
extrinsic_dir = project_root / "calibration" / "extrinsic"
|
|
852
|
+
|
|
853
|
+
processor = PostProcessor(
|
|
854
|
+
self.config,
|
|
855
|
+
self._session_dir,
|
|
856
|
+
on_log=lambda msg: self._msg_queue.put(("log", msg))
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
success = processor.run(self._raw_avi_path, [intrinsic_dir, extrinsic_dir])
|
|
860
|
+
|
|
861
|
+
if success:
|
|
862
|
+
self._msg_queue.put(("done", "Calibration recording complete!"))
|
|
863
|
+
else:
|
|
864
|
+
self._msg_queue.put(("error", "Post-processing failed."))
|
|
865
|
+
|
|
866
|
+
except Exception as e:
|
|
867
|
+
self._msg_queue.put(("error", f"Error: {e}"))
|
|
868
|
+
|
|
869
|
+
def _update_position_display(self):
|
|
870
|
+
"""Update the current position display."""
|
|
871
|
+
if self._current_position_idx >= len(self._positions):
|
|
872
|
+
return
|
|
873
|
+
|
|
874
|
+
pos = self._positions[self._current_position_idx]
|
|
875
|
+
self.position_var.set(pos.name)
|
|
876
|
+
self.instruction_var.set(pos.instruction)
|
|
877
|
+
|
|
878
|
+
# Update position label colors
|
|
879
|
+
for i, lbl in enumerate(self.position_labels):
|
|
880
|
+
if i < self._current_position_idx:
|
|
881
|
+
lbl.config(foreground="#4CAF50") # Completed - green
|
|
882
|
+
elif i == self._current_position_idx:
|
|
883
|
+
lbl.config(foreground="#2196F3") # Current - blue
|
|
884
|
+
else:
|
|
885
|
+
lbl.config(foreground="#888888") # Pending - gray
|
|
886
|
+
|
|
887
|
+
def _update_loop(self):
|
|
888
|
+
"""Main update loop (called every 50ms)."""
|
|
889
|
+
# Process messages from background threads
|
|
890
|
+
while True:
|
|
891
|
+
try:
|
|
892
|
+
msg_type, msg = self._msg_queue.get_nowait()
|
|
893
|
+
if msg_type == "log":
|
|
894
|
+
self._log(msg)
|
|
895
|
+
elif msg_type == "done":
|
|
896
|
+
self._on_processing_complete(msg)
|
|
897
|
+
elif msg_type == "error":
|
|
898
|
+
self._on_processing_error(msg)
|
|
899
|
+
except queue.Empty:
|
|
900
|
+
break
|
|
901
|
+
|
|
902
|
+
# Update preview
|
|
903
|
+
if self._state == "preview" or self._state == "recording":
|
|
904
|
+
self._update_preview()
|
|
905
|
+
|
|
906
|
+
# Update recording state
|
|
907
|
+
if self._state == "recording":
|
|
908
|
+
self._update_recording_state()
|
|
909
|
+
|
|
910
|
+
# Schedule next update
|
|
911
|
+
self.after(50, self._update_loop)
|
|
912
|
+
|
|
913
|
+
def _update_preview(self):
|
|
914
|
+
"""Update camera preview."""
|
|
915
|
+
canvas_w = self.preview_canvas.winfo_width()
|
|
916
|
+
canvas_h = self.preview_canvas.winfo_height()
|
|
917
|
+
|
|
918
|
+
if canvas_w < 10 or canvas_h < 10:
|
|
919
|
+
return
|
|
920
|
+
|
|
921
|
+
# During recording, show a placeholder since camera is used by FFmpeg
|
|
922
|
+
if self._state == "recording" and self._camera is None:
|
|
923
|
+
self._draw_recording_placeholder(canvas_w, canvas_h)
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
if self._camera is None:
|
|
927
|
+
return
|
|
928
|
+
|
|
929
|
+
frame = self._camera.get_frame()
|
|
930
|
+
if frame is None:
|
|
931
|
+
return
|
|
932
|
+
|
|
933
|
+
# Maintain aspect ratio
|
|
934
|
+
frame_h, frame_w = frame.shape[:2]
|
|
935
|
+
scale = min(canvas_w / frame_w, canvas_h / frame_h)
|
|
936
|
+
new_w = int(frame_w * scale)
|
|
937
|
+
new_h = int(frame_h * scale)
|
|
938
|
+
|
|
939
|
+
frame_resized = cv2.resize(frame, (new_w, new_h))
|
|
940
|
+
|
|
941
|
+
# Draw center line (split indicator)
|
|
942
|
+
center_x = new_w // 2
|
|
943
|
+
cv2.line(frame_resized, (center_x, 0), (center_x, new_h), (0, 255, 0), 2)
|
|
944
|
+
|
|
945
|
+
# Convert to PhotoImage
|
|
946
|
+
frame_rgb = cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB)
|
|
947
|
+
from PIL import Image, ImageTk
|
|
948
|
+
img = Image.fromarray(frame_rgb)
|
|
949
|
+
photo = ImageTk.PhotoImage(image=img)
|
|
950
|
+
|
|
951
|
+
# Update canvas
|
|
952
|
+
self.preview_canvas.delete("all")
|
|
953
|
+
x_offset = (canvas_w - new_w) // 2
|
|
954
|
+
y_offset = (canvas_h - new_h) // 2
|
|
955
|
+
self.preview_canvas.create_image(x_offset, y_offset, anchor=tk.NW, image=photo)
|
|
956
|
+
self.preview_canvas._photo = photo # Keep reference
|
|
957
|
+
|
|
958
|
+
def _draw_recording_placeholder(self, canvas_w: int, canvas_h: int):
|
|
959
|
+
"""Draw a placeholder during recording (when camera is used by FFmpeg)."""
|
|
960
|
+
self.preview_canvas.delete("all")
|
|
961
|
+
|
|
962
|
+
# Draw background
|
|
963
|
+
self.preview_canvas.create_rectangle(0, 0, canvas_w, canvas_h, fill="#1a1a1a")
|
|
964
|
+
|
|
965
|
+
# Draw recording indicator
|
|
966
|
+
center_x = canvas_w // 2
|
|
967
|
+
center_y = canvas_h // 2
|
|
968
|
+
|
|
969
|
+
# Flashing red circle
|
|
970
|
+
if int(time.time() * 2) % 2 == 0:
|
|
971
|
+
self.preview_canvas.create_oval(
|
|
972
|
+
center_x - 30, center_y - 80,
|
|
973
|
+
center_x + 30, center_y - 20,
|
|
974
|
+
fill="#f44336", outline=""
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
# "RECORDING" text
|
|
978
|
+
self.preview_canvas.create_text(
|
|
979
|
+
center_x, center_y,
|
|
980
|
+
text="RECORDING IN PROGRESS",
|
|
981
|
+
fill="#ffffff",
|
|
982
|
+
font=("Segoe UI", 18, "bold")
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
# Instruction
|
|
986
|
+
self.preview_canvas.create_text(
|
|
987
|
+
center_x, center_y + 40,
|
|
988
|
+
text="Follow the calibration guide on the right",
|
|
989
|
+
fill="#888888",
|
|
990
|
+
font=("Segoe UI", 12)
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
def _update_recording_state(self):
|
|
994
|
+
"""Update recording state (timing, position transitions)."""
|
|
995
|
+
if not self._recording_start_time or not self._position_start_time:
|
|
996
|
+
return
|
|
997
|
+
|
|
998
|
+
current_time = time.time()
|
|
999
|
+
total_elapsed = current_time - self._recording_start_time
|
|
1000
|
+
position_elapsed = current_time - self._position_start_time
|
|
1001
|
+
|
|
1002
|
+
# Check for timeout
|
|
1003
|
+
if total_elapsed >= MAX_CALIBRATION_TIME:
|
|
1004
|
+
self._set_status("Time Exceeded!", "#f44336")
|
|
1005
|
+
self.instruction_var.set("Calibration time exceeded. Click Stop to finish.")
|
|
1006
|
+
return
|
|
1007
|
+
|
|
1008
|
+
# Update total time display
|
|
1009
|
+
total_mins = int(total_elapsed) // 60
|
|
1010
|
+
total_secs = int(total_elapsed) % 60
|
|
1011
|
+
self.total_time_var.set(f"{total_mins}:{total_secs:02d} / 2:00")
|
|
1012
|
+
|
|
1013
|
+
# Check if current position is complete
|
|
1014
|
+
if self._current_position_idx < len(self._positions):
|
|
1015
|
+
pos = self._positions[self._current_position_idx]
|
|
1016
|
+
remaining = max(0, pos.duration_seconds - position_elapsed)
|
|
1017
|
+
|
|
1018
|
+
self.position_time_var.set(f"{int(remaining)}s")
|
|
1019
|
+
|
|
1020
|
+
# Calculate progress
|
|
1021
|
+
completed_time = sum(p.duration_seconds for p in self._positions[:self._current_position_idx])
|
|
1022
|
+
current_progress = min(position_elapsed, pos.duration_seconds)
|
|
1023
|
+
total_progress = completed_time + current_progress
|
|
1024
|
+
total_duration = sum(p.duration_seconds for p in self._positions)
|
|
1025
|
+
self.progress_var.set((total_progress / total_duration) * 100)
|
|
1026
|
+
|
|
1027
|
+
# Move to next position
|
|
1028
|
+
if position_elapsed >= pos.duration_seconds:
|
|
1029
|
+
self._current_position_idx += 1
|
|
1030
|
+
self._position_start_time = current_time
|
|
1031
|
+
|
|
1032
|
+
if self._current_position_idx >= len(self._positions):
|
|
1033
|
+
# All positions complete
|
|
1034
|
+
self._log("All calibration positions completed!")
|
|
1035
|
+
self._finish_recording()
|
|
1036
|
+
else:
|
|
1037
|
+
self._update_position_display()
|
|
1038
|
+
self._log(f"Moving to: {self._positions[self._current_position_idx].name}")
|
|
1039
|
+
|
|
1040
|
+
def _on_processing_complete(self, msg: str):
|
|
1041
|
+
"""Handle processing completion."""
|
|
1042
|
+
self._state = "idle"
|
|
1043
|
+
self._set_status("Complete", "#4CAF50")
|
|
1044
|
+
self.instruction_var.set(msg)
|
|
1045
|
+
self.btn_camera.config(state=tk.NORMAL)
|
|
1046
|
+
self.btn_record.config(state=tk.DISABLED)
|
|
1047
|
+
self.progress_var.set(100)
|
|
1048
|
+
|
|
1049
|
+
# Reset position labels
|
|
1050
|
+
for lbl in self.position_labels:
|
|
1051
|
+
lbl.config(foreground="#4CAF50")
|
|
1052
|
+
|
|
1053
|
+
self._log(msg)
|
|
1054
|
+
|
|
1055
|
+
if self._on_complete:
|
|
1056
|
+
result = messagebox.askquestion(
|
|
1057
|
+
"Recording Complete",
|
|
1058
|
+
f"{msg}\n\nContinue to Pipeline for auto-calibration?",
|
|
1059
|
+
)
|
|
1060
|
+
if result == "yes":
|
|
1061
|
+
self._on_complete()
|
|
1062
|
+
self.destroy()
|
|
1063
|
+
else:
|
|
1064
|
+
messagebox.showinfo("Complete", msg)
|
|
1065
|
+
|
|
1066
|
+
def _on_processing_error(self, msg: str):
|
|
1067
|
+
"""Handle processing error."""
|
|
1068
|
+
self._state = "idle"
|
|
1069
|
+
self._set_status("Error", "#f44336")
|
|
1070
|
+
self.instruction_var.set(msg)
|
|
1071
|
+
self.btn_camera.config(state=tk.NORMAL)
|
|
1072
|
+
self.btn_record.config(state=tk.DISABLED)
|
|
1073
|
+
|
|
1074
|
+
self._log(msg)
|
|
1075
|
+
messagebox.showerror("Error", msg)
|
|
1076
|
+
|
|
1077
|
+
def destroy(self):
|
|
1078
|
+
"""Clean up before closing."""
|
|
1079
|
+
if self._camera:
|
|
1080
|
+
self._camera.stop()
|
|
1081
|
+
if self._recorder:
|
|
1082
|
+
self._recorder.stop()
|
|
1083
|
+
super().destroy()
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
# ============================================================================
|
|
1087
|
+
# Entry Point
|
|
1088
|
+
# ============================================================================
|
|
1089
|
+
|
|
1090
|
+
def main():
|
|
1091
|
+
"""Main entry point."""
|
|
1092
|
+
import argparse
|
|
1093
|
+
|
|
1094
|
+
parser = argparse.ArgumentParser(description="Stereo Calibration Recording UI")
|
|
1095
|
+
parser.add_argument("--config", type=str, default=None,
|
|
1096
|
+
help="Path to YAML configuration file")
|
|
1097
|
+
args = parser.parse_args()
|
|
1098
|
+
|
|
1099
|
+
config_path = Path(args.config) if args.config else None
|
|
1100
|
+
|
|
1101
|
+
app = CalibrationUI(config_path)
|
|
1102
|
+
app.mainloop()
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
if __name__ == "__main__":
|
|
1106
|
+
main()
|