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,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SmartRecorder: Person-detection triggered auto-recording.
|
|
3
|
+
|
|
4
|
+
Monitors a stereo camera via OpenCV, runs person detection (OpenCV HOG),
|
|
5
|
+
and automatically records video segments when a person is present.
|
|
6
|
+
|
|
7
|
+
State machine: IDLE -> RECORDING -> COOLDOWN -> IDLE
|
|
8
|
+
|
|
9
|
+
Uses OpenCV VideoWriter (MJPG) for recording so that detection can continue
|
|
10
|
+
during recording (avoids Windows DirectShow exclusive access issue with FFmpeg).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import queue
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable, Optional
|
|
24
|
+
|
|
25
|
+
import cv2
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ============================================================================
|
|
32
|
+
# Configuration
|
|
33
|
+
# ============================================================================
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SmartRecorderConfig:
|
|
37
|
+
"""Configuration for SmartRecorder."""
|
|
38
|
+
|
|
39
|
+
# Detection parameters
|
|
40
|
+
detection_interval: int = 5 # Run detection every N frames
|
|
41
|
+
detection_scale: float = 0.4 # Downscale factor for detection
|
|
42
|
+
hog_hit_threshold: float = 0.0 # HOG SVM threshold (lower = more sensitive)
|
|
43
|
+
hog_win_stride: int = 8 # HOG window stride
|
|
44
|
+
hog_scale: float = 1.05 # HOG multi-scale step
|
|
45
|
+
|
|
46
|
+
# State machine parameters
|
|
47
|
+
absence_threshold: int = 5 # Consecutive misses before COOLDOWN
|
|
48
|
+
cooldown_seconds: float = 30.0 # Seconds in COOLDOWN before stopping
|
|
49
|
+
|
|
50
|
+
# Camera parameters (from CalibConfig)
|
|
51
|
+
device_name: str = "3D USB Camera"
|
|
52
|
+
video_size: str = "3200x1200"
|
|
53
|
+
fps: int = 60
|
|
54
|
+
device_index: int = 0
|
|
55
|
+
|
|
56
|
+
# Split parameters
|
|
57
|
+
full_w: int = 3200
|
|
58
|
+
full_h: int = 1200
|
|
59
|
+
xsplit: int = 1600
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_calib_config(cls, calib_config, device_index: int = 0) -> "SmartRecorderConfig":
|
|
63
|
+
"""Create SmartRecorderConfig from an existing CalibConfig."""
|
|
64
|
+
return cls(
|
|
65
|
+
device_name=calib_config.device_name,
|
|
66
|
+
video_size=calib_config.video_size,
|
|
67
|
+
fps=calib_config.fps,
|
|
68
|
+
device_index=device_index,
|
|
69
|
+
full_w=calib_config.full_w,
|
|
70
|
+
full_h=calib_config.full_h,
|
|
71
|
+
xsplit=calib_config.xsplit,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ============================================================================
|
|
76
|
+
# SmartRecorder
|
|
77
|
+
# ============================================================================
|
|
78
|
+
|
|
79
|
+
class SmartRecorder:
|
|
80
|
+
"""Person-detection triggered auto-recording with state machine.
|
|
81
|
+
|
|
82
|
+
Monitors a stereo camera via OpenCV, runs person detection on every Nth frame
|
|
83
|
+
using OpenCV's HOG person detector, and records video when a person is present.
|
|
84
|
+
Recording uses OpenCV VideoWriter (MJPG fourcc), producing raw AVI files
|
|
85
|
+
compatible with PostProcessor.
|
|
86
|
+
|
|
87
|
+
Events emitted via on_event callback:
|
|
88
|
+
{"type": "state_change", "state": "idle"|"recording"|"cooldown"}
|
|
89
|
+
{"type": "recording_start", "visit": "visit_YYYYMMDD_HHMMSS"}
|
|
90
|
+
{"type": "recording_stop", "visit": "visit_YYYYMMDD_HHMMSS", "session_dir": str}
|
|
91
|
+
{"type": "error", "message": str}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, config: SmartRecorderConfig,
|
|
95
|
+
output_base: Path,
|
|
96
|
+
on_event: Optional[Callable[[dict], None]] = None):
|
|
97
|
+
"""
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
config : SmartRecorderConfig
|
|
101
|
+
Camera and detection settings.
|
|
102
|
+
output_base : Path
|
|
103
|
+
Base directory for raw output (e.g. project/raw_output).
|
|
104
|
+
on_event : callable, optional
|
|
105
|
+
Callback for state/event notifications.
|
|
106
|
+
"""
|
|
107
|
+
self.config = config
|
|
108
|
+
self.output_base = Path(output_base)
|
|
109
|
+
self.on_event = on_event
|
|
110
|
+
|
|
111
|
+
# Parse video dimensions
|
|
112
|
+
parts = config.video_size.split("x")
|
|
113
|
+
self._full_w = int(parts[0])
|
|
114
|
+
self._full_h = int(parts[1])
|
|
115
|
+
|
|
116
|
+
# State
|
|
117
|
+
self._state = "idle" # idle | recording | cooldown
|
|
118
|
+
self._running = False
|
|
119
|
+
self._thread: Optional[threading.Thread] = None
|
|
120
|
+
|
|
121
|
+
# Camera
|
|
122
|
+
self._cap: Optional[cv2.VideoCapture] = None
|
|
123
|
+
|
|
124
|
+
# Recording
|
|
125
|
+
self._writer: Optional[cv2.VideoWriter] = None
|
|
126
|
+
self._visit_name: Optional[str] = None
|
|
127
|
+
self._session_dir: Optional[Path] = None
|
|
128
|
+
self._recording_start_time: Optional[float] = None
|
|
129
|
+
self._frame_count_recorded: int = 0
|
|
130
|
+
self._visit_count: int = 0
|
|
131
|
+
|
|
132
|
+
# Detection state
|
|
133
|
+
self._consecutive_misses: int = 0
|
|
134
|
+
self._cooldown_start: Optional[float] = None
|
|
135
|
+
self._frame_count: int = 0
|
|
136
|
+
self._last_detection_result: bool = False
|
|
137
|
+
|
|
138
|
+
# Preview frame queue (thread-safe)
|
|
139
|
+
self._frame_queue: queue.Queue = queue.Queue(maxsize=2)
|
|
140
|
+
|
|
141
|
+
# HOG person detector (lazy init)
|
|
142
|
+
self._hog: Optional[cv2.HOGDescriptor] = None
|
|
143
|
+
|
|
144
|
+
# ────────────────────────────────────────────────────────────────
|
|
145
|
+
# Public API
|
|
146
|
+
# ────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def start(self) -> bool:
|
|
149
|
+
"""Start monitoring. Opens camera and begins detection loop.
|
|
150
|
+
|
|
151
|
+
Returns True if camera opened successfully.
|
|
152
|
+
"""
|
|
153
|
+
if self._running:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
# Open camera
|
|
157
|
+
if sys.platform == "win32":
|
|
158
|
+
self._cap = cv2.VideoCapture(self.config.device_index, cv2.CAP_DSHOW)
|
|
159
|
+
else:
|
|
160
|
+
self._cap = cv2.VideoCapture(self.config.device_index)
|
|
161
|
+
|
|
162
|
+
if not self._cap.isOpened():
|
|
163
|
+
self._emit({"type": "error", "message": "Failed to open camera"})
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
# Set camera properties
|
|
167
|
+
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self._full_w)
|
|
168
|
+
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self._full_h)
|
|
169
|
+
self._cap.set(cv2.CAP_PROP_FPS, self.config.fps)
|
|
170
|
+
self._cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
|
|
171
|
+
|
|
172
|
+
# Initialize HOG person detector
|
|
173
|
+
self._hog = cv2.HOGDescriptor()
|
|
174
|
+
self._hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
|
|
175
|
+
|
|
176
|
+
# Reset state
|
|
177
|
+
self._state = "idle"
|
|
178
|
+
self._running = True
|
|
179
|
+
self._frame_count = 0
|
|
180
|
+
self._consecutive_misses = 0
|
|
181
|
+
self._visit_count = 0
|
|
182
|
+
|
|
183
|
+
self._emit({"type": "state_change", "state": "idle"})
|
|
184
|
+
|
|
185
|
+
# Start monitor thread
|
|
186
|
+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
187
|
+
self._thread.start()
|
|
188
|
+
|
|
189
|
+
logger.info("SmartRecorder started monitoring")
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
def stop(self):
|
|
193
|
+
"""Stop monitoring. Finalizes any active recording."""
|
|
194
|
+
self._running = False
|
|
195
|
+
|
|
196
|
+
if self._thread:
|
|
197
|
+
self._thread.join(timeout=3.0)
|
|
198
|
+
self._thread = None
|
|
199
|
+
|
|
200
|
+
# Finalize recording if active
|
|
201
|
+
if self._state in ("recording", "cooldown"):
|
|
202
|
+
self._stop_recording()
|
|
203
|
+
|
|
204
|
+
# Release camera
|
|
205
|
+
if self._cap:
|
|
206
|
+
self._cap.release()
|
|
207
|
+
self._cap = None
|
|
208
|
+
|
|
209
|
+
# Release HOG detector
|
|
210
|
+
self._hog = None
|
|
211
|
+
|
|
212
|
+
self._state = "idle"
|
|
213
|
+
logger.info("SmartRecorder stopped")
|
|
214
|
+
|
|
215
|
+
def get_frame(self) -> Optional[np.ndarray]:
|
|
216
|
+
"""Get the latest preview frame (non-blocking, thread-safe).
|
|
217
|
+
|
|
218
|
+
Returns a downscaled left-half frame suitable for UI preview,
|
|
219
|
+
or None if no frame available.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
return self._frame_queue.get_nowait()
|
|
223
|
+
except queue.Empty:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def state(self) -> str:
|
|
228
|
+
"""Current state: 'idle', 'recording', or 'cooldown'."""
|
|
229
|
+
return self._state
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def is_running(self) -> bool:
|
|
233
|
+
return self._running
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def visit_count(self) -> int:
|
|
237
|
+
"""Number of completed visits in this monitoring session."""
|
|
238
|
+
return self._visit_count
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def recording_elapsed(self) -> float:
|
|
242
|
+
"""Seconds elapsed in current recording, or 0 if not recording."""
|
|
243
|
+
if self._recording_start_time and self._state in ("recording", "cooldown"):
|
|
244
|
+
return time.time() - self._recording_start_time
|
|
245
|
+
return 0.0
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def cooldown_remaining(self) -> float:
|
|
249
|
+
"""Seconds remaining in cooldown, or 0 if not in cooldown."""
|
|
250
|
+
if self._state == "cooldown" and self._cooldown_start:
|
|
251
|
+
elapsed = time.time() - self._cooldown_start
|
|
252
|
+
remaining = self.config.cooldown_seconds - elapsed
|
|
253
|
+
return max(0.0, remaining)
|
|
254
|
+
return 0.0
|
|
255
|
+
|
|
256
|
+
# ────────────────────────────────────────────────────────────────
|
|
257
|
+
# Monitor loop (runs in background thread)
|
|
258
|
+
# ────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def _monitor_loop(self):
|
|
261
|
+
"""Main monitoring thread. Reads frames, runs detection, manages state."""
|
|
262
|
+
while self._running and self._cap and self._cap.isOpened():
|
|
263
|
+
ret, frame = self._cap.read()
|
|
264
|
+
if not ret or frame is None:
|
|
265
|
+
# Camera read failure — retry after short delay
|
|
266
|
+
time.sleep(0.01)
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
self._frame_count += 1
|
|
270
|
+
|
|
271
|
+
# ── Person detection (every N frames) ──
|
|
272
|
+
person_detected = self._last_detection_result
|
|
273
|
+
if self._frame_count % self.config.detection_interval == 0:
|
|
274
|
+
person_detected = self._detect_person(frame)
|
|
275
|
+
self._last_detection_result = person_detected
|
|
276
|
+
|
|
277
|
+
# ── Write frame if recording ──
|
|
278
|
+
if self._state in ("recording", "cooldown") and self._writer:
|
|
279
|
+
self._writer.write(frame)
|
|
280
|
+
self._frame_count_recorded += 1
|
|
281
|
+
|
|
282
|
+
# ── State machine transitions ──
|
|
283
|
+
self._update_state_machine(person_detected)
|
|
284
|
+
|
|
285
|
+
# ── Update preview frame ──
|
|
286
|
+
self._update_preview(frame)
|
|
287
|
+
|
|
288
|
+
# Thread ending — cleanup
|
|
289
|
+
if self._state in ("recording", "cooldown"):
|
|
290
|
+
self._stop_recording()
|
|
291
|
+
|
|
292
|
+
def _detect_person(self, frame: np.ndarray) -> bool:
|
|
293
|
+
"""Run OpenCV HOG person detector on downscaled left-half frame.
|
|
294
|
+
|
|
295
|
+
Returns True if at least one person is detected.
|
|
296
|
+
"""
|
|
297
|
+
if self._hog is None:
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
# Crop left half only (detection on one view)
|
|
302
|
+
xsplit = self.config.xsplit
|
|
303
|
+
left = frame[:, :xsplit]
|
|
304
|
+
|
|
305
|
+
# Downscale for faster detection
|
|
306
|
+
scale = self.config.detection_scale
|
|
307
|
+
small_w = int(xsplit * scale)
|
|
308
|
+
small_h = int(self._full_h * scale)
|
|
309
|
+
small = cv2.resize(left, (small_w, small_h))
|
|
310
|
+
|
|
311
|
+
# Run HOG person detector
|
|
312
|
+
stride = self.config.hog_win_stride
|
|
313
|
+
boxes, weights = self._hog.detectMultiScale(
|
|
314
|
+
small,
|
|
315
|
+
hitThreshold=self.config.hog_hit_threshold,
|
|
316
|
+
winStride=(stride, stride),
|
|
317
|
+
padding=(4, 4),
|
|
318
|
+
scale=self.config.hog_scale,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
return len(boxes) > 0
|
|
322
|
+
except Exception as e:
|
|
323
|
+
logger.warning(f"Detection error: {e}")
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
def _update_state_machine(self, person_detected: bool):
|
|
327
|
+
"""Handle state transitions based on detection result."""
|
|
328
|
+
prev_state = self._state
|
|
329
|
+
|
|
330
|
+
if self._state == "idle":
|
|
331
|
+
if person_detected:
|
|
332
|
+
self._start_recording()
|
|
333
|
+
self._state = "recording"
|
|
334
|
+
self._consecutive_misses = 0
|
|
335
|
+
|
|
336
|
+
elif self._state == "recording":
|
|
337
|
+
if person_detected:
|
|
338
|
+
self._consecutive_misses = 0
|
|
339
|
+
else:
|
|
340
|
+
self._consecutive_misses += 1
|
|
341
|
+
if self._consecutive_misses >= self.config.absence_threshold:
|
|
342
|
+
self._state = "cooldown"
|
|
343
|
+
self._cooldown_start = time.time()
|
|
344
|
+
|
|
345
|
+
elif self._state == "cooldown":
|
|
346
|
+
if person_detected:
|
|
347
|
+
# Person returned — resume recording
|
|
348
|
+
self._state = "recording"
|
|
349
|
+
self._consecutive_misses = 0
|
|
350
|
+
self._cooldown_start = None
|
|
351
|
+
elif self._cooldown_start is not None:
|
|
352
|
+
elapsed = time.time() - self._cooldown_start
|
|
353
|
+
if elapsed >= self.config.cooldown_seconds:
|
|
354
|
+
# Cooldown expired — stop recording
|
|
355
|
+
self._stop_recording()
|
|
356
|
+
self._state = "idle"
|
|
357
|
+
self._consecutive_misses = 0
|
|
358
|
+
self._cooldown_start = None
|
|
359
|
+
|
|
360
|
+
if self._state != prev_state:
|
|
361
|
+
self._emit({"type": "state_change", "state": self._state})
|
|
362
|
+
|
|
363
|
+
def _update_preview(self, frame: np.ndarray):
|
|
364
|
+
"""Put a downscaled left-half frame into the preview queue."""
|
|
365
|
+
try:
|
|
366
|
+
# Crop left half and downscale for preview
|
|
367
|
+
xsplit = self.config.xsplit
|
|
368
|
+
left = frame[:, :xsplit]
|
|
369
|
+
preview = cv2.resize(left, (640, 480))
|
|
370
|
+
|
|
371
|
+
# Clear old frame and put new one
|
|
372
|
+
try:
|
|
373
|
+
self._frame_queue.get_nowait()
|
|
374
|
+
except queue.Empty:
|
|
375
|
+
pass
|
|
376
|
+
try:
|
|
377
|
+
self._frame_queue.put_nowait(preview)
|
|
378
|
+
except queue.Full:
|
|
379
|
+
pass
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
# ────────────────────────────────────────────────────────────────
|
|
384
|
+
# Recording management
|
|
385
|
+
# ────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
def _start_recording(self):
|
|
388
|
+
"""Begin a new recording segment (visit)."""
|
|
389
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
390
|
+
self._visit_name = f"visit_{ts}"
|
|
391
|
+
self._session_dir = self.output_base / self._visit_name
|
|
392
|
+
self._session_dir.mkdir(parents=True, exist_ok=True)
|
|
393
|
+
|
|
394
|
+
raw_avi = self._session_dir / "raw.avi"
|
|
395
|
+
|
|
396
|
+
fourcc = cv2.VideoWriter_fourcc(*'MJPG')
|
|
397
|
+
self._writer = cv2.VideoWriter(
|
|
398
|
+
str(raw_avi), fourcc, self.config.fps,
|
|
399
|
+
(self._full_w, self._full_h),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if not self._writer.isOpened():
|
|
403
|
+
logger.error(f"Failed to open VideoWriter: {raw_avi}")
|
|
404
|
+
self._emit({"type": "error", "message": f"Failed to open VideoWriter: {raw_avi}"})
|
|
405
|
+
self._writer = None
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
self._recording_start_time = time.time()
|
|
409
|
+
self._frame_count_recorded = 0
|
|
410
|
+
|
|
411
|
+
logger.info(f"Recording started: {self._visit_name}")
|
|
412
|
+
self._emit({
|
|
413
|
+
"type": "recording_start",
|
|
414
|
+
"visit": self._visit_name,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
def _stop_recording(self):
|
|
418
|
+
"""Finalize current recording segment."""
|
|
419
|
+
if self._writer:
|
|
420
|
+
self._writer.release()
|
|
421
|
+
self._writer = None
|
|
422
|
+
|
|
423
|
+
visit_name = self._visit_name
|
|
424
|
+
session_dir = self._session_dir
|
|
425
|
+
start_time = self._recording_start_time
|
|
426
|
+
frame_count = self._frame_count_recorded
|
|
427
|
+
|
|
428
|
+
# Write metadata
|
|
429
|
+
if session_dir and session_dir.exists():
|
|
430
|
+
end_time = time.time()
|
|
431
|
+
duration = end_time - start_time if start_time else 0
|
|
432
|
+
|
|
433
|
+
metadata = {
|
|
434
|
+
"visit_id": visit_name,
|
|
435
|
+
"start_time": datetime.fromtimestamp(start_time).isoformat() if start_time else None,
|
|
436
|
+
"end_time": datetime.now().isoformat(),
|
|
437
|
+
"duration_seconds": round(duration, 1),
|
|
438
|
+
"frame_count": frame_count,
|
|
439
|
+
"fps": self.config.fps,
|
|
440
|
+
"resolution": self.config.video_size,
|
|
441
|
+
"detection_interval": self.config.detection_interval,
|
|
442
|
+
"cooldown_seconds": self.config.cooldown_seconds,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
metadata_path = session_dir / "metadata.json"
|
|
446
|
+
try:
|
|
447
|
+
with open(metadata_path, "w") as f:
|
|
448
|
+
json.dump(metadata, f, indent=2)
|
|
449
|
+
except Exception as e:
|
|
450
|
+
logger.warning(f"Failed to write metadata: {e}")
|
|
451
|
+
|
|
452
|
+
self._visit_count += 1
|
|
453
|
+
|
|
454
|
+
logger.info(f"Recording stopped: {visit_name} ({frame_count} frames)")
|
|
455
|
+
self._emit({
|
|
456
|
+
"type": "recording_stop",
|
|
457
|
+
"visit": visit_name,
|
|
458
|
+
"session_dir": str(session_dir) if session_dir else None,
|
|
459
|
+
"frame_count": frame_count,
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
# Reset recording state
|
|
463
|
+
self._visit_name = None
|
|
464
|
+
self._session_dir = None
|
|
465
|
+
self._recording_start_time = None
|
|
466
|
+
self._frame_count_recorded = 0
|
|
467
|
+
|
|
468
|
+
# ────────────────────────────────────────────────────────────────
|
|
469
|
+
# Helpers
|
|
470
|
+
# ────────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
def _emit(self, event: dict):
|
|
473
|
+
"""Emit an event via callback."""
|
|
474
|
+
if self.on_event:
|
|
475
|
+
try:
|
|
476
|
+
self.on_event(event)
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logger.warning(f"Event callback error: {e}")
|
recorder/ui.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import queue
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tkinter as tk
|
|
8
|
+
from tkinter import ttk, scrolledtext, messagebox
|
|
9
|
+
|
|
10
|
+
from .config import RecorderConfig
|
|
11
|
+
from . import run_pipeline
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RecorderUI(tk.Tk):
|
|
15
|
+
def __init__(self):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.title("Calib Record Tool - Recorder UI (Minimal)")
|
|
18
|
+
self.geometry("900x600")
|
|
19
|
+
|
|
20
|
+
self.log_q: "queue.Queue[str]" = queue.Queue()
|
|
21
|
+
self._worker: threading.Thread | None = None
|
|
22
|
+
|
|
23
|
+
self._build()
|
|
24
|
+
|
|
25
|
+
# periodic log pump
|
|
26
|
+
self.after(50, self._pump_logs)
|
|
27
|
+
|
|
28
|
+
def _build(self):
|
|
29
|
+
frm = ttk.Frame(self, padding=10)
|
|
30
|
+
frm.pack(fill=tk.BOTH, expand=True)
|
|
31
|
+
|
|
32
|
+
# Inputs
|
|
33
|
+
grid = ttk.Frame(frm)
|
|
34
|
+
grid.pack(fill=tk.X)
|
|
35
|
+
|
|
36
|
+
self.var_device = tk.StringVar(value="3D USB Camera")
|
|
37
|
+
self.var_size = tk.StringVar(value="3200x1200")
|
|
38
|
+
self.var_fps = tk.StringVar(value="60")
|
|
39
|
+
self.var_seconds = tk.StringVar(value="120")
|
|
40
|
+
self.var_outdir = tk.StringVar(value=".")
|
|
41
|
+
self.var_outroot = tk.StringVar(value="")
|
|
42
|
+
|
|
43
|
+
def row(r, label, var):
|
|
44
|
+
ttk.Label(grid, text=label, width=12).grid(row=r, column=0, sticky="w", padx=4, pady=4)
|
|
45
|
+
ttk.Entry(grid, textvariable=var).grid(row=r, column=1, sticky="we", padx=4, pady=4)
|
|
46
|
+
|
|
47
|
+
grid.columnconfigure(1, weight=1)
|
|
48
|
+
|
|
49
|
+
row(0, "device", self.var_device)
|
|
50
|
+
row(1, "size", self.var_size)
|
|
51
|
+
row(2, "fps", self.var_fps)
|
|
52
|
+
row(3, "seconds", self.var_seconds)
|
|
53
|
+
row(4, "outdir", self.var_outdir)
|
|
54
|
+
row(5, "outroot", self.var_outroot)
|
|
55
|
+
|
|
56
|
+
# Buttons
|
|
57
|
+
btns = ttk.Frame(frm)
|
|
58
|
+
btns.pack(fill=tk.X, pady=(10, 0))
|
|
59
|
+
|
|
60
|
+
self.btn_start = ttk.Button(btns, text="Start", command=self._on_start)
|
|
61
|
+
self.btn_start.pack(side=tk.LEFT)
|
|
62
|
+
|
|
63
|
+
self.btn_clear = ttk.Button(btns, text="Clear Log", command=self._clear_log)
|
|
64
|
+
self.btn_clear.pack(side=tk.LEFT, padx=8)
|
|
65
|
+
|
|
66
|
+
# Log area
|
|
67
|
+
self.log = scrolledtext.ScrolledText(frm, height=25)
|
|
68
|
+
self.log.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
|
69
|
+
|
|
70
|
+
self._append("UI ready.\n")
|
|
71
|
+
|
|
72
|
+
def _append(self, text: str):
|
|
73
|
+
self.log.insert(tk.END, text)
|
|
74
|
+
self.log.see(tk.END)
|
|
75
|
+
|
|
76
|
+
def _clear_log(self):
|
|
77
|
+
self.log.delete("1.0", tk.END)
|
|
78
|
+
|
|
79
|
+
def _pump_logs(self):
|
|
80
|
+
while True:
|
|
81
|
+
try:
|
|
82
|
+
msg = self.log_q.get_nowait()
|
|
83
|
+
except queue.Empty:
|
|
84
|
+
break
|
|
85
|
+
self._append(msg + "\n")
|
|
86
|
+
self.after(50, self._pump_logs)
|
|
87
|
+
|
|
88
|
+
def _on_start(self):
|
|
89
|
+
if self._worker and self._worker.is_alive():
|
|
90
|
+
messagebox.showwarning("Running", "Pipeline is already running.")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Parse config
|
|
94
|
+
try:
|
|
95
|
+
cfg = RecorderConfig(
|
|
96
|
+
device=self.var_device.get().strip(),
|
|
97
|
+
size=self.var_size.get().strip(),
|
|
98
|
+
fps=int(self.var_fps.get().strip()),
|
|
99
|
+
seconds=int(self.var_seconds.get().strip()),
|
|
100
|
+
outdir=self.var_outdir.get().strip(),
|
|
101
|
+
outroot=self.var_outroot.get().strip(),
|
|
102
|
+
)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
messagebox.showerror("Invalid input", str(e))
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
self.btn_start.config(state=tk.DISABLED)
|
|
108
|
+
self.log_q.put(f"[CONFIG] {asdict(cfg)}")
|
|
109
|
+
|
|
110
|
+
def on_event(ev: dict):
|
|
111
|
+
stage = ev.get("stage", "")
|
|
112
|
+
msg = ev.get("message", "")
|
|
113
|
+
self.log_q.put(f"[{stage}] {msg}")
|
|
114
|
+
|
|
115
|
+
def worker():
|
|
116
|
+
try:
|
|
117
|
+
res = run_pipeline(cfg, on_event=on_event)
|
|
118
|
+
self.log_q.put(f"[RESULT] returncode={res.returncode}")
|
|
119
|
+
self.log_q.put(f"[RESULT] session_hint={res.session_hint}")
|
|
120
|
+
except Exception as e:
|
|
121
|
+
self.log_q.put(f"[EXCEPTION] {e}")
|
|
122
|
+
finally:
|
|
123
|
+
# Re-enable start
|
|
124
|
+
self.btn_start.config(state=tk.NORMAL)
|
|
125
|
+
|
|
126
|
+
self._worker = threading.Thread(target=worker, daemon=True)
|
|
127
|
+
self._worker.start()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main():
|
|
131
|
+
app = RecorderUI()
|
|
132
|
+
app.mainloop()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|