AI_Photographer/Core/direct_camera_service.py
2026-04-12 18:52:37 +04:00

1171 lines
43 KiB
Python

#!/usr/bin/env python3
"""
Direct camera service.
Purpose:
- Main camera backend for AI_Photographer.
- Does not use teleimager streaming.
- Opens the camera directly through RealSense SDK or OpenCV.
- Saves captured images into the configured output folder.
- Provides the web UI/API used for preview, capture, download, and delete.
Run:
python3 Core/direct_camera_service.py
Optional:
python3 Core/direct_camera_service.py --camera auto --port 8091
python3 Core/direct_camera_service.py --camera realsense --port 8091
python3 Core/direct_camera_service.py --camera /dev/video0 --port 8091
CAMERA_DEVICE=/dev/video2 python3 Core/direct_camera_service.py
"""
from __future__ import annotations
import argparse
from datetime import datetime
import io
import json
import mimetypes
import os
import re
import socket
import threading
import time
import urllib.parse
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Optional, Union
import cv2
import numpy as np
try:
import pyrealsense2 as rs # type: ignore
_HAS_RS = True
except Exception:
rs = None # type: ignore
_HAS_RS = False
BASE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_SAMPLES_DIR = BASE_DIR / "photos" / "samples"
WEB_DIR = BASE_DIR / "Web"
DIRECT_CAMERA_HTML = WEB_DIR / "direct_camera.html"
DIRECT_CAMERA_CSS = WEB_DIR / "direct_camera.css"
DIRECT_CAMERA_JS = WEB_DIR / "direct_camera.js"
CONFIG_JSON = BASE_DIR / "Data" / "Settings" / "config.json"
LEGACY_CONFIG_JSONS = (
BASE_DIR / "Data" / "config.json",
BASE_DIR / "Scripts" / "config.json",
)
DEFAULT_PREFERRED_REALSENSE_SERIAL = "243622071722"
def safe_name(name: str) -> str:
return name.strip().replace("\\", "/").split("/")[-1]
def build_ordered_datetime_name(out_dir: Path, prefix: str, ext: str) -> Path:
safe_prefix = re.sub(r"[^A-Za-z0-9_-]+", "_", (prefix or "").strip()).strip("_") or "photo"
safe_ext = (ext or "jpg").strip().lstrip(".") or "jpg"
order = sum(
1
for p in out_dir.iterdir()
if p.is_file() and p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
) + 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return out_dir / f"{order:04d}_{timestamp}_{safe_prefix}.{safe_ext}"
def list_photos(folder: Path) -> list[dict]:
items = []
folder.mkdir(parents=True, exist_ok=True)
for p in sorted(folder.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if not p.is_file() or p.suffix.lower() not in (".jpg", ".jpeg", ".png", ".webp"):
continue
st = p.stat()
items.append({"name": p.name, "size": st.st_size, "mtime": st.st_mtime})
return items
def get_local_ip() -> str:
try:
host_info = socket.gethostbyname_ex(socket.gethostname())
for ip in host_info[2]:
if ip and not ip.startswith("127."):
return ip
except Exception:
pass
for target in ("10.255.255.255", "192.168.1.1", "8.8.8.8"):
s = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.2)
s.connect((target, 80))
ip = s.getsockname()[0]
if ip:
return ip
except Exception:
pass
finally:
try:
if s is not None:
s.close()
except Exception:
pass
try:
return socket.gethostbyname("localhost")
except Exception:
return "127.0.0.1"
def list_video_devices() -> list[str]:
return [str(p) for p in sorted(Path("/dev").glob("video*"))]
def list_video_device_info() -> list[dict]:
items = []
for devnode in sorted(Path("/dev").glob("video*")):
sysdir = Path("/sys/class/video4linux") / devnode.name
items.append(
{
"value": str(devnode),
"label": str(devnode),
"name": _read_text(sysdir / "name"),
}
)
return items
def _read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="ignore").strip()
except Exception:
return ""
def list_realsense_video_devices() -> list[str]:
matches = []
for devnode in sorted(Path("/dev").glob("video*")):
sysdir = Path("/sys/class/video4linux") / devnode.name
name = _read_text(sysdir / "name").lower()
usb_dir = (sysdir / "device").resolve()
vendor = ""
for probe in [usb_dir, usb_dir.parent, usb_dir.parent.parent]:
if probe == probe.parent:
break
vendor = _read_text(probe / "idVendor")
if vendor:
break
if "realsense" in name and vendor.lower() in ("8086", "32902"):
matches.append(str(devnode))
return matches
def list_realsense_serials() -> list[str]:
if not _HAS_RS:
return []
try:
ctx = rs.context()
devices = ctx.query_devices()
serials = []
for dev in devices:
try:
serials.append(str(dev.get_info(rs.camera_info.serial_number)))
except Exception:
continue
return serials
except Exception:
return []
def list_realsense_devices() -> list[dict]:
if not _HAS_RS:
return []
items = []
try:
ctx = rs.context()
for dev in ctx.query_devices():
try:
name = str(dev.get_info(rs.camera_info.name))
except Exception:
name = "Intel RealSense"
try:
serial = str(dev.get_info(rs.camera_info.serial_number))
except Exception:
serial = ""
try:
usb_type = str(dev.get_info(rs.camera_info.usb_type_descriptor))
except Exception:
usb_type = ""
try:
physical_port = str(dev.get_info(rs.camera_info.physical_port))
except Exception:
physical_port = ""
items.append(
{
"name": name,
"serial": serial,
"usb_type": usb_type,
"physical_port": physical_port,
"value": f"realsense:{serial}" if serial else "realsense",
"label": f"{name} [{serial}]" if serial else name,
}
)
except Exception:
return []
return items
def parse_realsense_source(source: Union[str, int, None]) -> Optional[str]:
if not isinstance(source, str):
return None
s = source.strip()
if s.lower().startswith("realsense:"):
serial = s.split(":", 1)[1].strip()
return serial or None
return None
def read_teleimager_realsense_serial() -> str:
candidates = []
env_cfg = os.environ.get("TELEIMAGER_CONFIG", "").strip()
if env_cfg:
candidates.append(Path(env_cfg).expanduser())
home = Path.home()
script_dir = Path(__file__).resolve().parent
project_root = script_dir.parent
candidates.append(home / "teleimager" / "cam_config_server.yaml")
candidates.append(project_root.parent / "teleimager" / "cam_config_server.yaml")
candidates.append(script_dir / "cam_config_server.yaml")
candidates.append(home / "Robot-Unitree Main Folders" / "teleimager" / "cam_config_server.yaml")
for path in candidates:
try:
if not path.exists():
continue
text = path.read_text(encoding="utf-8", errors="ignore")
m = re.search(r"serial_number:\s*\"?([0-9A-Za-z_-]+)\"?", text)
if m:
return m.group(1).strip()
except Exception:
continue
return ""
def read_config_preferred_realsense_serial() -> str:
for path in (CONFIG_JSON, *LEGACY_CONFIG_JSONS):
try:
if not path.exists():
continue
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
continue
camera_cfg = raw.get("camera")
if not isinstance(camera_cfg, dict):
continue
serial = str(camera_cfg.get("preferred_realsense_serial", "")).strip()
if serial:
return serial
except Exception:
continue
return ""
def resolve_preferred_realsense_serial_snapshot() -> tuple[str, str, str]:
forced = os.environ.get("REALSENSE_SERIAL", "").strip()
env_preferred = os.environ.get("PREFERRED_REALSENSE_SERIAL", "").strip()
config_preferred = read_config_preferred_realsense_serial()
configured = read_teleimager_realsense_serial()
if forced:
preferred = forced
elif env_preferred:
preferred = env_preferred
elif config_preferred:
preferred = config_preferred
else:
preferred = DEFAULT_PREFERRED_REALSENSE_SERIAL
return preferred, config_preferred, configured
def write_config_preferred_realsense_serial(serial: str) -> str:
raw = {}
for cfg_path in (CONFIG_JSON, *LEGACY_CONFIG_JSONS):
try:
if cfg_path.exists():
loaded = json.loads(cfg_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
raw = loaded
break
except Exception:
pass
camera_cfg = raw.get("camera")
if not isinstance(camera_cfg, dict):
camera_cfg = {}
camera_cfg["preferred_realsense_serial"] = str(serial or "").strip()
raw["camera"] = camera_cfg
CONFIG_JSON.parent.mkdir(parents=True, exist_ok=True)
CONFIG_JSON.write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8")
return str(camera_cfg["preferred_realsense_serial"])
class DirectCamera:
def __init__(
self,
source: Optional[Union[str, int]] = None,
width: int = 960,
height: int = 540,
fps: int = 15,
preview_quality: int = 60,
):
self.source = self._resolve_source(source)
self.width = int(width)
self.height = int(height)
self.fps = int(fps)
self.preview_quality = int(preview_quality)
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
self._cap = None
self._rs_pipeline = None
self._rs_align = None
self._lock = threading.Lock()
self._latest_frame = None
self._latest_jpeg: Optional[bytes] = None
self._last_error = ""
self._frame_ts = 0.0
self._camera_source_in_use: Optional[Union[str, int]] = None
self._backend = "opencv"
self._active_profile: Optional[str] = None
self._preferred_realsense_serial = ""
self._config_realsense_serial = ""
self._configured_realsense_serial = ""
self._realsense_serial = self._preferred_realsense_serial
self._reopen_after = 0.0
self._reopen_delay_sec = 2.0
self._consecutive_read_failures = 0
self._max_read_failures_before_reopen = int(os.environ.get("CAMERA_MAX_READ_FAILS", "20"))
self._refresh_realsense_preferences()
def _refresh_realsense_preferences(self):
preferred, config_preferred, configured = resolve_preferred_realsense_serial_snapshot()
self._preferred_realsense_serial = preferred
self._config_realsense_serial = config_preferred
self._configured_realsense_serial = configured
if not self._realsense_serial:
self._realsense_serial = preferred
def _resolve_source(self, source: Optional[Union[str, int]]) -> Union[str, int]:
if source is None:
env_device = os.environ.get("CAMERA_DEVICE", "").strip()
if env_device:
source = env_device
else:
source = os.environ.get("CAMERA_INDEX", "auto").strip() or "auto"
if isinstance(source, str):
s = source.strip()
if s.lower() == "auto":
return "auto"
if s.lower() in ("realsense", "rs"):
return "realsense"
if s.lower().startswith("realsense:"):
serial = s.split(":", 1)[1].strip()
return f"realsense:{serial}" if serial else "realsense"
try:
return int(s)
except ValueError:
return s
return int(source)
def _candidate_sources(self) -> list[Union[str, int]]:
src = self.source
out: list[Union[str, int]] = [src]
if src == "realsense" or (isinstance(src, str) and src.startswith("realsense:")):
for sp in list_realsense_video_devices():
if sp not in out:
out.append(sp)
return out
if isinstance(src, str) and src.startswith("/dev/video"):
try:
idx = int(src.replace("/dev/video", ""))
out.append(idx)
except Exception:
pass
scan_env = os.environ.get("CAMERA_FALLBACK_SCAN", "").strip().lower()
if scan_env:
do_scan = scan_env not in ("0", "false", "no", "off")
else:
do_scan = src == "auto"
if src == "auto" or do_scan:
if _HAS_RS and "realsense" not in out:
out.append("realsense")
for p in sorted(Path("/dev").glob("video*")):
sp = str(p)
if sp not in out:
out.append(sp)
try:
idx = int(sp.replace("/dev/video", ""))
if idx not in out:
out.append(idx)
except Exception:
pass
for idx in range(6):
if idx not in out:
out.append(idx)
return out
def _open_capture(self, source: Union[str, int]):
if isinstance(source, str) and source.startswith("/dev/video"):
return cv2.VideoCapture(source, cv2.CAP_V4L2)
return cv2.VideoCapture(source)
def _looks_rgb(self, frame) -> bool:
return frame is not None and getattr(frame, "ndim", 0) == 3 and frame.shape[2] == 3
def _open_realsense(self):
if not _HAS_RS:
raise RuntimeError("pyrealsense2 not installed")
self._refresh_realsense_preferences()
profiles = [
(self.width, self.height, self.fps),
(640, 480, 30),
(640, 480, 15),
(1280, 720, 30),
(1280, 720, 15),
(848, 480, 30),
(848, 480, 15),
]
available_serials = list_realsense_serials()
requested_serial = parse_realsense_source(self.source)
serial = ""
if requested_serial and requested_serial in available_serials:
serial = requested_serial
elif self._preferred_realsense_serial and self._preferred_realsense_serial in available_serials:
serial = self._preferred_realsense_serial
elif self._configured_realsense_serial and self._configured_realsense_serial in available_serials:
serial = self._configured_realsense_serial
elif self._realsense_serial and self._realsense_serial in available_serials:
serial = self._realsense_serial
elif available_serials:
serial = available_serials[0]
if serial:
self._realsense_serial = serial
tried = []
seen = set()
for w, h, fps in profiles:
key = (int(w), int(h), int(fps))
if key in seen:
continue
seen.add(key)
tried.append(f"{w}x{h}@{fps}")
pipe = rs.pipeline()
try:
cfg = rs.config()
if serial:
cfg.enable_device(serial)
cfg.enable_stream(rs.stream.color, int(w), int(h), rs.format.bgr8, int(fps))
pipe.start(cfg)
self._rs_align = rs.align(rs.stream.color)
self._realsense_serial = serial or self._realsense_serial
self._active_profile = f"{w}x{h}@{fps}"
return pipe
except Exception:
try:
pipe.stop()
except Exception:
pass
serial_note = serial or "(auto)"
raise RuntimeError(f"RealSense color stream open failed. serial={serial_note} tried: {', '.join(tried)}")
def _configure_capture(self, cap):
try:
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
except Exception:
pass
try:
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
except Exception:
pass
cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
cap.set(cv2.CAP_PROP_FPS, self.fps)
def _close_locked(self):
if self._rs_pipeline is not None:
try:
self._rs_pipeline.stop()
except Exception:
pass
self._rs_pipeline = None
self._rs_align = None
if self._cap is not None:
try:
self._cap.release()
except Exception:
pass
self._cap = None
self._camera_source_in_use = None
self._backend = "opencv"
self._active_profile = None
def _read_frame_locked(self):
if self._rs_pipeline is not None:
frames = self._rs_pipeline.wait_for_frames(timeout_ms=1000)
if self._rs_align is not None:
frames = self._rs_align.process(frames)
color_frame = frames.get_color_frame()
if not color_frame:
return False, None
frame = np.asanyarray(color_frame.get_data())
if frame is None or frame.size == 0:
return False, None
return True, frame
if self._cap is None:
return False, None
return self._cap.read()
def _ensure_open_locked(self) -> bool:
now = time.time()
if self._rs_pipeline is not None:
return True
if self._cap is not None and self._cap.isOpened():
return True
if now < self._reopen_after:
return False
self._last_error = "Opening camera..."
self._close_locked()
tried = []
last_open_error = ""
for source in self._candidate_sources():
tried.append(str(source))
if source == "realsense":
pipe = None
try:
pipe = self._open_realsense()
frames = pipe.wait_for_frames(timeout_ms=1500)
color_frame = frames.get_color_frame()
if not color_frame:
pipe.stop()
continue
frame = np.asanyarray(color_frame.get_data())
if frame is None or frame.size == 0:
pipe.stop()
continue
self._rs_pipeline = pipe
self._backend = "realsense"
self._camera_source_in_use = source
self._last_error = ""
self._consecutive_read_failures = 0
self._latest_frame = frame.copy()
return True
except Exception as e:
last_open_error = str(e)
self._last_error = str(e)
try:
if pipe is not None:
pipe.stop()
except Exception:
pass
continue
else:
cap = None
try:
cap = self._open_capture(source)
except Exception:
cap = None
if cap is None or not cap.isOpened():
try:
if cap is not None:
cap.release()
except Exception:
pass
continue
self._configure_capture(cap)
ok, frame = cap.read()
if not ok or frame is None:
cap.release()
continue
if self.source == "realsense" and not self._looks_rgb(frame):
cap.release()
continue
self._cap = cap
self._backend = "opencv"
self._camera_source_in_use = source
self._last_error = ""
self._consecutive_read_failures = 0
return True
self._last_error = last_open_error or f"Could not open camera. Tried: {', '.join(tried)}"
self._reopen_after = now + self._reopen_delay_sec
return False
def start(self):
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
if self._thread:
self._thread.join(timeout=1.0)
with self._lock:
self._close_locked()
def _run(self):
while not self._stop.is_set():
frame = None
frame_ts = 0.0
with self._lock:
if not self._ensure_open_locked():
time.sleep(0.2)
continue
try:
ok, frame = self._read_frame_locked()
if not ok or frame is None:
self._consecutive_read_failures += 1
self._last_error = f"Camera read failed ({self._consecutive_read_failures}/{self._max_read_failures_before_reopen})"
if self._consecutive_read_failures < self._max_read_failures_before_reopen:
time.sleep(0.05)
continue
self._close_locked()
self._reopen_after = time.time() + self._reopen_delay_sec
self._consecutive_read_failures = 0
time.sleep(0.2)
continue
self._consecutive_read_failures = 0
self._latest_frame = frame.copy()
frame_ts = time.time()
except Exception as e:
self._last_error = str(e)
self._close_locked()
self._reopen_after = time.time() + self._reopen_delay_sec
self._consecutive_read_failures = 0
frame = None
if frame is not None:
try:
ok, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), self.preview_quality])
if ok and jpg is not None:
with self._lock:
self._latest_jpeg = jpg.tobytes()
self._frame_ts = frame_ts
except Exception:
pass
time.sleep(max(0.02, 1.0 / max(self.fps, 1)))
def latest_jpeg(self) -> Optional[bytes]:
return self._latest_jpeg
def capture(self, out_dir: Path, prefix: str = "sample", ext: str = "jpg") -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
if not self._lock.acquire(timeout=0.5):
raise RuntimeError("camera busy")
try:
if not self._ensure_open_locked():
raise RuntimeError(self._last_error or "camera unavailable")
frame = self._latest_frame
if frame is None:
ok, frame = self._read_frame_locked()
if not ok or frame is None:
raise RuntimeError("camera read failed during capture")
ok, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
if not ok or jpg is None:
raise RuntimeError("cv2.imencode failed")
out_path = build_ordered_datetime_name(out_dir, prefix, ext)
out_path.write_bytes(jpg.tobytes())
return out_path
finally:
self._lock.release()
def reconfigure(self, width: int, height: int, fps: int) -> dict:
width = int(width)
height = int(height)
fps = int(fps)
if width < 1 or height < 1 or fps < 1:
raise RuntimeError("width, height, and fps must be positive integers")
if not self._lock.acquire(timeout=1.0):
raise RuntimeError("camera busy")
try:
self.width = width
self.height = height
self.fps = fps
self._last_error = f"Reconfiguring camera to {width}x{height}@{fps}..."
self._latest_frame = None
self._latest_jpeg = None
self._frame_ts = 0.0
self._close_locked()
self._reopen_after = 0.0
self._consecutive_read_failures = 0
finally:
self._lock.release()
deadline = time.time() + 6.0
while time.time() < deadline and not self._stop.is_set():
status = self.status()
if status["ok"]:
return status
time.sleep(0.05)
return self.status()
def set_source(self, source: Union[str, int]) -> dict:
resolved = self._resolve_source(source)
if not self._lock.acquire(timeout=1.0):
raise RuntimeError("camera busy")
try:
self.source = resolved
self._last_error = f"Switching camera source to {resolved}..."
self._latest_frame = None
self._latest_jpeg = None
self._frame_ts = 0.0
self._close_locked()
self._reopen_after = 0.0
self._consecutive_read_failures = 0
finally:
self._lock.release()
deadline = time.time() + 6.0
while time.time() < deadline and not self._stop.is_set():
status = self.status()
if status["ok"]:
return status
time.sleep(0.05)
return self.status()
def set_preferred_realsense_serial(self, serial: str) -> dict:
value = str(serial or "").strip()
if not self._lock.acquire(timeout=1.0):
raise RuntimeError("camera busy")
try:
self._preferred_realsense_serial = value
self._config_realsense_serial = value
if self.source == "realsense":
self._last_error = f"Switching preferred RealSense to {value or '(auto)'}..."
self._latest_frame = None
self._latest_jpeg = None
self._frame_ts = 0.0
self._close_locked()
self._reopen_after = 0.0
self._consecutive_read_failures = 0
finally:
self._lock.release()
deadline = time.time() + 6.0
while time.time() < deadline and not self._stop.is_set():
status = self.status()
if status["ok"] or self.source != "realsense":
return status
time.sleep(0.05)
return self.status()
def status(self) -> dict:
self._refresh_realsense_preferences()
return {
"ok": bool(self._rs_pipeline is not None or (self._cap is not None and self._cap.isOpened())),
"requested_source": str(self.source),
"source": str(self._camera_source_in_use if self._camera_source_in_use is not None else self.source),
"backend": self._backend,
"profile": self._active_profile,
"requested_profile": f"{self.width}x{self.height}@{self.fps}",
"requested_width": self.width,
"requested_height": self.height,
"requested_fps": self.fps,
"preferred_realsense_serial": self._preferred_realsense_serial,
"config_realsense_serial": self._config_realsense_serial,
"configured_realsense_serial": self._configured_realsense_serial,
"realsense_serial": self._realsense_serial,
"last_error": self._last_error,
"frame_time": self._frame_ts,
}
def static_file_response(path: Path) -> tuple[bytes, str]:
raw = path.read_bytes()
content_type, _ = mimetypes.guess_type(str(path))
return raw, content_type or "application/octet-stream"
class SamplesHandler(BaseHTTPRequestHandler):
server_version = "DirectCameraSamples/1.0"
def _json(self, payload: dict, status: int = 200):
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(raw)
def _text(self, text: str, status: int = 200, content_type: str = "text/plain; charset=utf-8"):
raw = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(raw)
def _binary(self, raw: bytes, status: int = 200, content_type: str = "application/octet-stream", cache_control: str = "no-store"):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", cache_control)
self.end_headers()
self.wfile.write(raw)
def log_message(self, fmt: str, *args):
path = getattr(self, "path", "")
if path.startswith("/api/health") or path.startswith("/api/photos"):
return
print(f"HTTP {self.command} {path} -> {args[1]}")
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
q = urllib.parse.parse_qs(parsed.query)
if path == "/":
raw, content_type = static_file_response(DIRECT_CAMERA_HTML)
return self._binary(raw, content_type=f"{content_type}; charset=utf-8")
if path == "/static/direct_camera.css":
raw, content_type = static_file_response(DIRECT_CAMERA_CSS)
return self._binary(raw, content_type=content_type)
if path == "/static/direct_camera.js":
raw, content_type = static_file_response(DIRECT_CAMERA_JS)
return self._binary(raw, content_type=f"{content_type}; charset=utf-8")
if path == "/api/health":
return self._json(
{
"ok": True,
"camera": self.server.camera.status(),
"samples_dir": str(self.server.samples_dir),
}
)
if path == "/api/cameras":
camera_status = self.server.camera.status()
realsense_devices = list_realsense_devices()
video_devices = list_video_device_info()
preferred_serial = str(camera_status.get("preferred_realsense_serial") or "").strip()
active_serial = str(camera_status.get("realsense_serial") or "").strip()
preferred_device = None
active_device = None
rs_items = []
for item in realsense_devices:
serial = str(item.get("serial") or "").strip()
is_preferred = bool(serial and serial == preferred_serial)
is_active = bool(serial and serial == active_serial)
if is_preferred:
preferred_device = item
if is_active:
active_device = item
label = str(item.get("label") or item.get("value") or "").strip()
tags = []
if is_preferred:
tags.append("default")
if is_active:
tags.append("live")
if tags:
label = f"{label} ({', '.join(tags)})"
rs_items.append({**item, "kind": "realsense", "is_preferred": is_preferred, "is_active": is_active, "label": label})
generic_label = "Preferred RealSense"
if preferred_serial:
generic_label += f" [{preferred_serial}]"
generic_label += " (default)"
options = [{"value": "realsense", "label": generic_label, "kind": "realsense-default"}]
options.extend(rs_items)
options.extend({**item, "kind": "video"} for item in video_devices)
options.append({"value": "auto", "label": "Auto fallback", "kind": "auto"})
return self._json(
{
"ok": True,
"selected_source": str(self.server.camera.source),
"active_source": str(camera_status.get("source") or self.server.camera.source),
"preferred_realsense_serial": preferred_serial,
"configured_realsense_serial": str(camera_status.get("configured_realsense_serial") or ""),
"config_realsense_serial": str(camera_status.get("config_realsense_serial") or ""),
"realsense_devices": rs_items,
"video_devices": video_devices,
"preferred_device": preferred_device,
"active_device": active_device,
"options": options,
}
)
if path == "/api/photos":
return self._json({"items": list_photos(self.server.samples_dir)})
if path == "/api/capture":
try:
prefix = ((q.get("prefix") or [self.server.capture_prefix])[0] or self.server.capture_prefix).strip() or self.server.capture_prefix
ext = ((q.get("ext") or [self.server.capture_ext])[0] or self.server.capture_ext).strip().lstrip(".") or self.server.capture_ext
saved = self.server.camera.capture(self.server.samples_dir, prefix=prefix, ext=ext)
return self._json({"ok": True, "name": saved.name, "path": str(saved)})
except Exception as e:
return self._json({"ok": False, "error": str(e)}, status=500)
if path == "/api/frame.jpg":
jpg = self.server.camera.latest_jpeg()
if jpg is None:
return self._text("camera not ready", status=503)
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(jpg)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(jpg)
return
if path == "/api/set_resolution":
try:
width = int((q.get("width") or [""])[0])
height = int((q.get("height") or [""])[0])
fps = int((q.get("fps") or [""])[0])
except Exception:
return self._json({"ok": False, "error": "width, height, and fps are required integers"}, status=400)
try:
status = self.server.camera.reconfigure(width, height, fps)
return self._json({"ok": True, "camera": status})
except Exception as e:
return self._json({"ok": False, "error": str(e)}, status=500)
if path == "/api/set_camera":
source = (q.get("source") or [""])[0].strip()
if not source:
return self._json({"ok": False, "error": "missing source"}, status=400)
try:
status = self.server.camera.set_source(source)
return self._json({"ok": True, "camera": status})
except Exception as e:
return self._json({"ok": False, "error": str(e)}, status=500)
if path == "/api/set_preferred_camera":
serial = (q.get("serial") or [""])[0].strip()
try:
saved = write_config_preferred_realsense_serial(serial)
status = self.server.camera.set_preferred_realsense_serial(saved)
return self._json({"ok": True, "serial": saved, "camera": status})
except Exception as e:
return self._json({"ok": False, "error": str(e)}, status=500)
if path == "/api/delete":
name = safe_name((q.get("name") or [""])[0])
if not name:
return self._json({"ok": False, "error": "missing name"}, status=400)
p = (self.server.samples_dir / name).resolve()
if p.parent != self.server.samples_dir.resolve() or not p.exists():
return self._json({"ok": False, "error": "not found"}, status=404)
try:
p.unlink()
return self._json({"ok": True, "name": name})
except Exception as e:
return self._json({"ok": False, "error": str(e)}, status=500)
if path == "/api/download":
name = safe_name((q.get("name") or [""])[0])
if not name:
return self._text("missing name", status=400)
p = (self.server.samples_dir / name).resolve()
if p.parent != self.server.samples_dir.resolve() or not p.exists():
return self._text("not found", status=404)
data = p.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(data)))
self.send_header("Content-Disposition", f'attachment; filename="{p.name}"')
self.end_headers()
self.wfile.write(data)
return
if path == "/api/download_all.zip":
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for item in list_photos(self.server.samples_dir):
p = self.server.samples_dir / item["name"]
zf.write(p, arcname=p.name)
raw = buf.getvalue()
self.send_response(200)
self.send_header("Content-Type", "application/zip")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Content-Disposition", 'attachment; filename="samples.zip"')
self.end_headers()
self.wfile.write(raw)
return
if path.startswith("/samples/"):
name = safe_name(path[len("/samples/"):])
p = (self.server.samples_dir / name).resolve()
if p.parent != self.server.samples_dir.resolve() or not p.exists():
return self._text("not found", status=404)
data = p.read_bytes()
ext = p.suffix.lower()
ct = "image/jpeg" if ext in (".jpg", ".jpeg") else "image/png"
self.send_response(200)
self.send_header("Content-Type", ct)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
return
if path == "/preview.mjpg":
self.send_response(200)
self.send_header("Age", "0")
self.send_header("Cache-Control", "no-cache, private")
self.send_header("Pragma", "no-cache")
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.end_headers()
try:
while True:
jpg = self.server.camera.latest_jpeg()
if jpg is None:
time.sleep(0.05)
continue
self.wfile.write(b"--frame\r\n")
self.wfile.write(b"Content-Type: image/jpeg\r\n")
self.wfile.write(f"Content-Length: {len(jpg)}\r\n\r\n".encode("ascii"))
self.wfile.write(jpg)
self.wfile.write(b"\r\n")
time.sleep(0.12)
except BrokenPipeError:
return
except ConnectionResetError:
return
except Exception:
return
return self._text("not found", status=404)
class SamplesServer(ThreadingHTTPServer):
def __init__(
self,
addr,
handler_cls,
camera: DirectCamera,
samples_dir: Path,
capture_prefix: str = "sample",
capture_ext: str = "jpg",
):
super().__init__(addr, handler_cls)
self.camera = camera
self.samples_dir = samples_dir
self.capture_prefix = capture_prefix
self.capture_ext = capture_ext
def parse_args():
parser = argparse.ArgumentParser(description="Standalone direct-camera samples server")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8091")))
parser.add_argument("--camera", default=os.environ.get("CAMERA_DEVICE", os.environ.get("CAMERA_INDEX", "auto")))
parser.add_argument("--width", type=int, default=int(os.environ.get("FRAME_WIDTH", "960")))
parser.add_argument("--height", type=int, default=int(os.environ.get("FRAME_HEIGHT", "540")))
parser.add_argument("--fps", type=int, default=int(os.environ.get("FPS", "15")))
parser.add_argument("--samples-dir", default=os.environ.get("SAMPLES_DIR", str(DEFAULT_SAMPLES_DIR)))
parser.add_argument("--capture-prefix", default=os.environ.get("CAPTURE_PREFIX", "sample"))
parser.add_argument("--capture-ext", default=os.environ.get("CAPTURE_EXT", "jpg"))
return parser.parse_args()
def main():
args = parse_args()
samples_dir = Path(args.samples_dir).expanduser().resolve()
samples_dir.mkdir(parents=True, exist_ok=True)
if str(args.camera).strip().lower() in ("realsense", "rs") and not _HAS_RS:
raise SystemExit(
"RealSense backend requested but pyrealsense2 is not installed in this environment. "
"Run this script from the teleimager conda env or install pyrealsense2 here."
)
camera = DirectCamera(source=args.camera, width=args.width, height=args.height, fps=args.fps)
camera.start()
server = SamplesServer(
(args.host, args.port),
SamplesHandler,
camera=camera,
samples_dir=samples_dir,
capture_prefix=str(args.capture_prefix).strip() or "sample",
capture_ext=str(args.capture_ext).strip().lstrip(".") or "jpg",
)
ip = get_local_ip()
print(f"Video devices: {', '.join(list_video_devices()) or '(none found)'}")
if _HAS_RS:
print(f"RealSense serials: {', '.join(list_realsense_serials()) or '(none found)'}")
print(f"Preferred RealSense serial: {camera._preferred_realsense_serial}")
print(f"Samples dir: {samples_dir}")
print(f"Camera source: {args.camera}")
print(f"Capture naming: prefix={server.capture_prefix} ext={server.capture_ext}")
print(f"Open: http://{ip}:{args.port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.shutdown()
camera.stop()
if __name__ == "__main__":
main()