release: consolidate web simulation and real CV prototype

Add cleaned RealSense/OpenCV CV under cv/ with secret-free example
config, MQTT disabled by default, and canonical README coverage for
web + CV relationship without claiming production integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-08-02 22:23:05 +02:00
parent 3777fecacf
commit 21e83be26e
26 changed files with 5050 additions and 178 deletions

373
cv/camera.py Normal file
View File

@@ -0,0 +1,373 @@
"""Захват Depth (+опционально Color) с Intel RealSense D415 через V4L2/ffmpeg.
Классификация по ТЗ опирается на depth (габариты + круг в сечении).
RGB у D415 через сырой V4L2 часто пустой без librealsense —
тогда для превью используется colorize(depth).
"""
from __future__ import annotations
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass
from typing import Optional, Union
import cv2
import numpy as np
@dataclass
class FramePair:
color_bgr: np.ndarray
depth_mm: np.ndarray # uint16, миллиметры
timestamp_ms: float
color_is_depth_preview: bool = False
def _device_path(device: Union[str, int]) -> str:
if isinstance(device, int) or str(device).isdigit():
return f"/dev/video{int(device)}"
return str(device)
def _v4l2_index(device: Union[str, int]) -> int:
if isinstance(device, int):
return device
s = str(device).strip()
if s.isdigit():
return int(s)
if "video" in s:
return int(s.rsplit("video", 1)[-1])
raise ValueError(f"Некорректный V4L2 device: {device}")
def find_realsense_color_device(preferred: Union[str, int, None] = None) -> Optional[str]:
"""Найти RGB-ноду D415 (YUYV). Номера /dev/videoN плавают после переподключения."""
import glob
import os
def _formats(path: str) -> str:
try:
return subprocess.check_output(
["v4l2-ctl", "-d", path, "--list-formats-ext"],
stderr=subprocess.DEVNULL,
text=True,
timeout=2,
)
except (OSError, subprocess.SubprocessError):
return ""
preferred_path = _device_path(preferred) if preferred is not None else ""
scored: list[tuple[int, str]] = []
for path in sorted(glob.glob("/dev/video*")):
if not os.path.exists(path):
continue
fmt = _formats(path)
if "Z16" in fmt or "'GREY'" in fmt or "Greyscale" in fmt:
continue
score = 2 if ("YUYV" in fmt or "MJPG" in fmt or "Motion-JPEG" in fmt) else 0
if path == preferred_path:
score += 5
scored.append((score, path))
scored.sort(key=lambda x: (-x[0], x[1]))
for _, path in scored:
try:
idx = _v4l2_index(path)
except ValueError:
continue
cap = cv2.VideoCapture(idx, cv2.CAP_V4L2)
if not cap.isOpened():
continue
ok_frame = None
for _ in range(12):
ok, frame = cap.read()
if not ok or frame is None:
continue
if frame.ndim == 2:
break
if frame.ndim == 3 and frame.shape[2] == 2:
frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_YUY2)
if frame.ndim == 3 and float(np.mean(frame)) > 8.0 and float(np.std(frame)) > 5.0:
ok_frame = frame
break
cap.release()
if ok_frame is not None:
return path
return None
def fill_depth_holes(depth_mm: np.ndarray, ksize: int = 5) -> np.ndarray:
"""Простое заполнение дыр в depth."""
d = depth_mm.copy()
mask = (d > 0).astype(np.uint8) * 255
if mask.mean() < 1:
return d
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ksize, ksize))
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
holes = ((closed > 0) & (d == 0)).astype(np.uint8) * 255
if holes.any():
scale = max(float(d.max()), 1.0)
img8 = np.clip(d.astype(np.float32) / scale * 255.0, 0, 255).astype(np.uint8)
filled8 = cv2.inpaint(img8, holes, 3, cv2.INPAINT_TELEA)
filled = (filled8.astype(np.float32) / 255.0 * scale).astype(np.uint16)
d[holes > 0] = filled[holes > 0]
med = cv2.medianBlur(d, 3)
valid = d > 0
d[valid] = med[valid]
return d
def depth_colormap(depth_mm: np.ndarray, max_mm: Optional[int] = None) -> np.ndarray:
valid = depth_mm[(depth_mm > 0) & (depth_mm < 10000)]
if max_mm is None:
max_mm = int(np.percentile(valid, 95)) if valid.size else 2000
max_mm = max(max_mm, 500)
clipped = np.clip(depth_mm.astype(np.float32), 0, max_mm)
norm = np.zeros_like(clipped, dtype=np.uint8)
mask = depth_mm > 0
norm[mask] = (clipped[mask] / max_mm * 255.0).astype(np.uint8)
return cv2.applyColorMap(norm, cv2.COLORMAP_JET)
class RealSenseV4L2:
"""D415: depth=/dev/video0 (Z16 gray16le). Color опционален."""
def __init__(
self,
depth_device: Union[str, int] = "/dev/video0",
color_device: Union[str, int] = "/dev/video4",
width: int = 640,
height: int = 480,
fps: int = 30,
depth_scale_mm: float = 1.0,
use_color: bool = True,
) -> None:
if shutil.which("ffmpeg") is None:
raise RuntimeError("Нужен ffmpeg для чтения depth Z16 с RealSense")
self.depth_scale_mm = float(depth_scale_mm)
self.width = int(width)
self.height = int(height)
self.fps = int(fps)
self.use_color = bool(use_color)
self._frame_bytes = self.width * self.height * 2
self.color_cap = None
self._depth_path = _device_path(depth_device)
self._lock = threading.Lock()
self._latest: Optional[np.ndarray] = None
self._stop = threading.Event()
self._ff: Optional[subprocess.Popen] = None
self._thread: Optional[threading.Thread] = None
self._start_depth_worker()
if self.use_color:
found = find_realsense_color_device(color_device)
if found is None:
print(f"[camera] RGB не найден (искали {color_device}) — в вебе будет colorize(depth)")
self.color_cap = None
else:
if _device_path(found) != _device_path(color_device):
print(f"[camera] RGB: {found} (в конфиге было {color_device})")
else:
print(f"[camera] RGB: {found}")
color_idx = _v4l2_index(found)
self.color_cap = cv2.VideoCapture(color_idx, cv2.CAP_V4L2)
if self.color_cap.isOpened():
self.color_cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
self.color_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
self.color_cap.set(cv2.CAP_PROP_FPS, self.fps)
self.color_cap.set(cv2.CAP_PROP_CONVERT_RGB, 1)
# прогрев автоэкспозиции — иначе первые кадры чёрные/зелёные
for _ in range(20):
self.color_cap.read()
else:
print("[camera] RGB VideoCapture не открылся")
self.color_cap = None
# Ждём первый кадр
deadline = time.time() + 5.0
while time.time() < deadline:
with self._lock:
if self._latest is not None:
break
time.sleep(0.05)
else:
self.release()
raise RuntimeError(
f"Не удалось читать depth с {self._depth_path}. "
"Проверьте USB3, что камера не занята другим процессом."
)
valid_pct = float(((self._latest > 0) & (self._latest < 5000)).mean() * 100)
print(f"[camera] depth OK, valid≈{valid_pct:.1f}% (лучше >30%; высота камеры 0.5–1.5 м)")
def _start_depth_worker(self) -> None:
self._ff = subprocess.Popen(
[
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-f",
"v4l2",
"-video_size",
f"{self.width}x{self.height}",
"-framerate",
str(self.fps),
"-pixel_format",
"gray16le",
"-i",
self._depth_path,
"-f",
"rawvideo",
"-pix_fmt",
"gray16le",
"-",
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=self._frame_bytes * 8,
)
self._thread = threading.Thread(target=self._depth_loop, name="rs-depth", daemon=True)
self._thread.start()
def _depth_loop(self) -> None:
assert self._ff is not None and self._ff.stdout is not None
while not self._stop.is_set():
raw = self._ff.stdout.read(self._frame_bytes)
if not raw or len(raw) != self._frame_bytes:
if self._ff.poll() is not None:
break
continue
depth = np.frombuffer(raw, dtype="<u2").reshape(self.height, self.width).copy()
depth[depth == 65535] = 0
if self.depth_scale_mm != 1.0:
depth = np.clip(depth.astype(np.float32) * self.depth_scale_mm, 0, 65535).astype(np.uint16)
depth = fill_depth_holes(depth)
with self._lock:
self._latest = depth
def _read_color(self, depth_mm: np.ndarray) -> tuple[np.ndarray, bool]:
if self.color_cap is not None:
ok, color = self.color_cap.read()
if ok and color is not None:
if color.shape[:2] != (self.height, self.width):
color = cv2.resize(color, (self.width, self.height), interpolation=cv2.INTER_LINEAR)
if color.ndim == 3 and color.shape[2] == 2:
color = cv2.cvtColor(color, cv2.COLOR_YUV2BGR_YUY2)
elif color.ndim == 2:
color = cv2.cvtColor(color, cv2.COLOR_GRAY2BGR)
# пустой YUYV-кадр после конвертации — ровный зелёный (mean>5,
# но вариации нет) → проверяем и разброс пикселей
if float(np.mean(color)) > 5.0 and float(np.std(color)) > 4.0:
return color, False
return depth_colormap(depth_mm), True
def read(self) -> Optional[FramePair]:
with self._lock:
depth = None if self._latest is None else self._latest.copy()
if depth is None:
return None
color, is_preview = self._read_color(depth)
return FramePair(
color_bgr=color,
depth_mm=depth,
timestamp_ms=time.time() * 1000.0,
color_is_depth_preview=is_preview,
)
def capture_background(self, samples: int = 15) -> np.ndarray:
"""Медианная карта глубины пустой сцены (лента + платформы/борта).
Позволяет сегментировать товар на неровном фоне и не сливать его
с накопителем: объект = то, что ближе фона на min_object_height_mm.
"""
frames = []
deadline = time.time() + 12.0
while len(frames) < samples and time.time() < deadline:
pair = self.read()
if pair is not None:
frames.append(pair.depth_mm.astype(np.float32))
time.sleep(0.04)
if len(frames) < max(3, samples // 3):
raise RuntimeError("Не удалось накопить кадры для фоновой карты")
stack = np.stack(frames)
stack[stack <= 0] = np.nan
bg = np.nanmedian(stack, axis=0)
return np.nan_to_num(bg, nan=0.0).astype(np.uint16)
def capture_background_rgb(self, samples: int = 10) -> Optional[np.ndarray]:
"""Усреднённый RGB-кадр пустой сцены — для детекции плоских товаров
(телефон и т.п.), которые не видны в depth."""
frames = []
deadline = time.time() + 8.0
while len(frames) < samples and time.time() < deadline:
pair = self.read()
if pair is not None and not pair.color_is_depth_preview:
frames.append(pair.color_bgr.astype(np.float32))
time.sleep(0.04)
if len(frames) < 3:
return None
return np.clip(np.mean(np.stack(frames), axis=0), 0, 255).astype(np.uint8)
def estimate_belt_distance_mm(self, samples: int = 30) -> float:
vals = []
h, w = self.height, self.width
rois = [
(h // 2 - 40, h // 2 + 40, w // 2 - 60, w // 2 + 60),
(h // 3 - 30, h // 3 + 30, w // 3 - 40, w // 3 + 40),
(2 * h // 3 - 30, 2 * h // 3 + 30, 2 * w // 3 - 40, 2 * w // 3 + 40),
(h // 4, 3 * h // 4, w // 4, 3 * w // 4),
]
for _ in range(samples):
pair = self.read()
if pair is None:
time.sleep(0.03)
continue
for y0, y1, x0, x1 in rois:
roi = pair.depth_mm[y0:y1, x0:x1]
valid = roi[(roi > 200) & (roi < 4000)]
if valid.size >= 50:
vals.append(float(np.median(valid)))
break
else:
valid = pair.depth_mm[(pair.depth_mm > 200) & (pair.depth_mm < 4000)]
if valid.size >= 50:
vals.append(float(np.median(valid)))
time.sleep(0.03)
if not vals:
raise RuntimeError(
"Не удалось оценить belt_distance_mm. "
"Поставьте камеру на 0.5–1.5 м над лентой (USB3), задайте belt_distance_mm в config.yaml вручную."
)
return float(np.median(vals))
def release(self) -> None:
self._stop.set()
if self.color_cap is not None:
self.color_cap.release()
self.color_cap = None
if self._ff is not None and self._ff.poll() is None:
self._ff.terminate()
try:
self._ff.wait(timeout=2)
except subprocess.TimeoutExpired:
self._ff.kill()
self._ff = None
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
def __enter__(self) -> "RealSenseV4L2":
return self
def __exit__(self, *args) -> None:
self.release()