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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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()