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,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()