webcam copy
This commit is contained in:
168
h8536/camera_snapshots.py
Normal file
168
h8536/camera_snapshots.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import heapq
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+")
|
||||
|
||||
|
||||
class CameraSnapshots:
|
||||
"""Small optional OpenCV webcam wrapper for bench-test snapshots."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
camera_index: int,
|
||||
output_dir: Path,
|
||||
warmup_seconds: float = 0.5,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> None:
|
||||
self.camera_index = camera_index
|
||||
self.output_dir = output_dir
|
||||
self.warmup_seconds = max(0.0, warmup_seconds)
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.cv2: Any | None = None
|
||||
self.device: Any | None = None
|
||||
self._condition = threading.Condition()
|
||||
self._tasks: list[tuple[float, int, dict[str, Any]]] = []
|
||||
self._records: list[dict[str, Any]] = []
|
||||
self._thread: threading.Thread | None = None
|
||||
self._seq = 0
|
||||
self._closing = False
|
||||
|
||||
def open(self) -> None:
|
||||
try:
|
||||
import cv2
|
||||
except ImportError as exc: # pragma: no cover - depends on local bench environment.
|
||||
raise SystemExit(
|
||||
"OpenCV is required for webcam snapshots. Install it with: "
|
||||
".\\.venv\\Scripts\\python.exe -m pip install opencv-python"
|
||||
) from exc
|
||||
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cv2 = cv2
|
||||
self.device = cv2.VideoCapture(self.camera_index)
|
||||
if not self.device.isOpened():
|
||||
raise SystemExit(f"could not open webcam index {self.camera_index}")
|
||||
if self.width:
|
||||
self.device.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
|
||||
if self.height:
|
||||
self.device.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
|
||||
self._warm_up()
|
||||
self._thread = threading.Thread(target=self._run, name="camera-snapshots", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._condition:
|
||||
self._closing = True
|
||||
self._condition.notify_all()
|
||||
if self._thread is not None:
|
||||
self._thread.join()
|
||||
self._thread = None
|
||||
if self.device is not None:
|
||||
self.device.release()
|
||||
self.device = None
|
||||
|
||||
def schedule(
|
||||
self,
|
||||
*,
|
||||
label: str,
|
||||
frame_text: str,
|
||||
phase: str,
|
||||
delay_seconds: float = 0.0,
|
||||
step_index: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if self.cv2 is None or self.device is None:
|
||||
raise RuntimeError("camera snapshotter is not open")
|
||||
|
||||
due_monotonic = time.monotonic() + max(0.0, delay_seconds)
|
||||
due_timestamp = datetime.now() + timedelta(seconds=max(0.0, delay_seconds))
|
||||
timestamp = due_timestamp.strftime("%Y%m%d-%H%M%S-%f")[:-3]
|
||||
step_part = f"step{step_index:03d}_" if step_index is not None else ""
|
||||
frame_part = frame_text.replace(" ", "")
|
||||
filename = _safe_name(f"{timestamp}_{step_part}{phase}_{label}_{frame_part}.jpg")
|
||||
path = self.output_dir / filename
|
||||
record: dict[str, Any] = {
|
||||
"path": str(path),
|
||||
"scheduled_timestamp": timestamp,
|
||||
"camera_index": self.camera_index,
|
||||
"step_index": step_index,
|
||||
"phase": phase,
|
||||
"delay_seconds": delay_seconds,
|
||||
"label": label,
|
||||
"frame": frame_text,
|
||||
"status": "scheduled",
|
||||
}
|
||||
with self._condition:
|
||||
self._seq += 1
|
||||
heapq.heappush(self._tasks, (due_monotonic, self._seq, record))
|
||||
self._records.append(record)
|
||||
self._condition.notify_all()
|
||||
return record
|
||||
|
||||
def records(self) -> list[dict[str, Any]]:
|
||||
with self._condition:
|
||||
return [dict(record) for record in self._records]
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
with self._condition:
|
||||
while not self._tasks and not self._closing:
|
||||
self._condition.wait()
|
||||
if not self._tasks and self._closing:
|
||||
return
|
||||
due_monotonic, _seq, record = self._tasks[0]
|
||||
wait_seconds = due_monotonic - time.monotonic()
|
||||
if wait_seconds > 0:
|
||||
self._condition.wait(wait_seconds)
|
||||
continue
|
||||
heapq.heappop(self._tasks)
|
||||
self._write_snapshot(record)
|
||||
|
||||
def _write_snapshot(self, record: dict[str, Any]) -> None:
|
||||
if self.cv2 is None or self.device is None:
|
||||
record["status"] = "error"
|
||||
record["error"] = "camera closed before capture"
|
||||
return
|
||||
|
||||
image = None
|
||||
ok = False
|
||||
for _attempt in range(3):
|
||||
ok, image = self.device.read()
|
||||
if ok:
|
||||
break
|
||||
time.sleep(0.020)
|
||||
if not ok or image is None:
|
||||
record["status"] = "error"
|
||||
record["error"] = f"webcam index {self.camera_index} did not return an image"
|
||||
return
|
||||
|
||||
path = Path(str(record["path"]))
|
||||
if not self.cv2.imwrite(str(path), image):
|
||||
record["status"] = "error"
|
||||
record["error"] = f"failed to write webcam snapshot {path}"
|
||||
return
|
||||
record["status"] = "written"
|
||||
record["captured_timestamp"] = datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
|
||||
|
||||
def _warm_up(self) -> None:
|
||||
if self.device is None:
|
||||
return
|
||||
deadline = time.monotonic() + self.warmup_seconds
|
||||
while time.monotonic() < deadline:
|
||||
self.device.read()
|
||||
time.sleep(0.020)
|
||||
self.device.read()
|
||||
|
||||
|
||||
def _safe_name(text: str) -> str:
|
||||
cleaned = _SAFE_NAME_RE.sub("_", text.strip()).strip("._")
|
||||
return cleaned or "snapshot.jpg"
|
||||
Reference in New Issue
Block a user