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

7
.gitignore vendored
View File

@@ -14,4 +14,9 @@ playwright-report/
blob-report/
.demo-preview.pid
.demo-preview.log
__pycache__
__pycache__/
*.pyc
cv/.venv/
cv/debug_frames/
cv/logs/
cv/config.yaml

341
README.md
View File

@@ -1,111 +1,171 @@
# OZON Sorter Digital Twin
Web digital twin of an Ozon Tech Track 3 conveyor sorting line: continuous product playback, camera/classification stage, B/C/D routing, and author-CAD diverter motion.
Complete Track 3 Ozon Tech solution in one repository: a web digital twin of the sorting line, plus a real RealSense/OpenCV CV prototype.
**Production domain:** https://arhipovdan.ru
**Production:** https://arhipovdan.ru
## Current solution
## 1. Overview
Final web simulation lives on **`main`** (same commit as `dan_branch`):
This repository delivers a continuous web simulation of an Ozon conveyor sorter: CAD conveyor, product models, camera/measurement stage, B/C/D classification, diverters, and Rapier physics. The public demo runs on https://arhipovdan.ru with routes `/` (simulation) and `/documentation` (engineering status).
- continuous 3D conveyor digital twin (`/`);
- engineering documentation (`/documentation`);
- author CAD (`3d_models/conveer.FCStd`) + runtime GLB (`public/models/sorter/conveyor-clean.glb`);
- product STL assets (`public/models/*.stl`);
- camera / classification stage (digital twin rules, not live RealSense);
- B / C / D routing with CAD left/right diverters (45° / +45°);
- Rapier-backed product physics (contact routing **not** fully validated).
Separately, `cv/` contains a **working hardware prototype** that reads Intel RealSense D415 depth, measures parcels with OpenCV, classifies B/C/D, and can publish results over MQTT. It is **not** wired into the live website.
Accepted release head: **`bb76963`** (content tree identical to stable baseline `13ce16b`). Later experimental junction/discharge commits were reverted by owner request and are **not** part of the submitted product.
Both paths share the same Track 3 classification domain (exclusive 10×10×10 … 450×320×320 mm, roundness K > 0.8). The web twin uses a digital sensor simulation; the CV folder uses real depth frames.
### Main functions
`main` is the canonical complete solution. Developers do not need other branches to run the web app or inspect/run the CV prototype.
1. Continuous SKU playback on a three-module CAD conveyor (clean → camera → sorter).
2. Measurement / classification stage with Track 3 B/C/D rules.
3. Diverter state machine and route visualization.
4. Desktop WebGL twin; mobile uses a lightweight presentation policy when WebGL budget requires it.
5. Canonical engineering status on `/documentation`.
Large submission artifacts (presentation, video, optional CAD/model mirrors) belong in team cloud storage; runtime assets required by deploy stay in Git.
## Technology stack
## 2. Submission components
| Layer | Packages |
| Component | Location | Notes |
|---|---|---|
| Web digital twin | `src/`, `public/` | Production-integrated |
| Real CV prototype | `cv/` | WORKING_PROTOTYPE, not live-integrated |
| Author CAD | `3d_models/conveer.FCStd` | FreeCAD source |
| Official materials | `input_info/`, `official_sources/` | PDFs/ZIPs cited by docs/code |
| Production domain | https://arhipovdan.ru | Docker + nginx |
| Presentation / video | cloud (owner) | Links TBD — see §17 |
## 3. Production demo
- **URL:** https://arhipovdan.ru
- **`/`** — continuous digital-twin simulation
- **`/documentation`** — canonical engineering status
- Unknown routes redirect to `/`
Device behavior on current baseline:
- **Desktop:** interactive WebGL 3D
- **Mobile:** lightweight **2D** fallback (not full WebGL 3D)
Build identity: `/version.json`.
## 4. Web capabilities
- Three-module CAD conveyor (clean → camera → sorter)
- Camera / measurement simulation and Track 3 classifier
- B / C / D routing with CAD diverters (45° / +45°)
- Rapier product physics (contact sorting **not** fully validated)
- Continuous playback HUD + documentation page
## 5. Real CV prototype
| Field | Value |
|---|---|
| UI | React, react-router-dom |
| 3D | three, @react-three/fiber, @react-three/drei, @react-three/postprocessing |
| Physics | @react-three/rapier, @dimforge/rapier3d-compat |
| Build | Vite, TypeScript, @vitejs/plugin-react |
| Tests | Vitest, Playwright |
| Path | `cv/` |
| Origin | `drho1y-mvp_1` / `vision_classifier/` |
| Technology | RealSense D415 + OpenCV (depth segmentation + metrics) |
| Status | WORKING_PROTOTYPE |
| Integration | **Not** connected to production web runtime |
Exact versions: `package.json` / `package-lock.json`.
See **[cv/README.md](cv/README.md)** for install, demo, live camera, and MQTT.
## Dependencies / Node
- Node.js **20+**
- npm (locked via `package-lock.json`)
### Exact commands
```bash
npm ci # install
npm run dev # development → http://127.0.0.1:3100
npm test -- --run # unit tests
npm run build # production build → dist/
npm run preview # serve build → http://127.0.0.1:3100
npm run test:e2e # Playwright (set PLAYWRIGHT_BASE_URL if needed)
```
## Active routes
| Route | Purpose |
|---|---|
| `/` | Continuous digital-twin simulation |
| `/documentation` | Canonical product / engineering status |
| `*` | Redirects to `/` |
Removed routes (`/details`, `/device-test`) are not part of the product.
## Project structure
## 6. Architecture
```
src/ React app, domain logic, 3D twin, pages
public/ Runtime static assets (models, draco)
models/ Product STL + sorter/conveyor-clean.glb
3d_models/ Author CAD (conveer.FCStd)
docs/ Engineering notes (ENGINEERING.md)
e2e/ Playwright smoke / routes
input_info/ Official Ozon input packs (PDFs/ZIPs)
official_sources/ Classifier bounds PDF cited by code
Dockerfile Multi-stage Vite build + nginx
Real device path:
RealSense D415 → depth preprocess → segmentation → measurement
→ B/C/D → optional MQTT / hardware
Web path:
Digital product → simulated sensor → classifier
→ physical digital twin → B/C/D receiver visualization
```
Shared: B/C/D semantics and official dimension/roundness rules.
Not shared today: live camera frames into the website.
## 7. Repository structure
```
.github/ CI (build, unit, e2e)
3d_models/ Author CAD (conveer.FCStd)
cv/ Real CV prototype (Python)
docs/ Engineering notes
e2e/ Playwright smoke/routes
input_info/ Official Ozon input packs
official_sources/ Classifier bounds PDF
public/ Runtime static assets (GLB/STL/draco)
src/ React/Three web twin
Dockerfile Web production image
docker-compose.server.yml
nginx.conf
.github/workflows/ci.yml
package.json / lock
vite / vitest / playwright / tsconfig
README.md
```
## Environment variables
No other top-level product directories are required to run or understand the solution.
No runtime secrets are required for local demo or production static hosting.
## 8. Requirements
Optional **build-time** identity (Docker / CI only; never commit secrets):
**Web:** Node.js 20+, npm (`package-lock.json`).
**CV:** Python 3.10+, ffmpeg, V4L2; RealSense D415 for live mode (`cv/requirements.txt`).
**Hardware (CV live / MQTT):** D415 USB3; optional MQTT broker + servo/motor controllers on site network.
| Variable | Purpose |
## 9. Web quick start
```bash
npm ci
npm run dev # http://127.0.0.1:3100
npm test -- --run
npm run build
npm run preview # http://127.0.0.1:3100
```
## 10. CV quick start
```bash
cd cv
./demo.sh # venv + deps; HUD on :8080 (needs D415 for live view)
# without camera:
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python test_classify.py
.venv/bin/python test_geometry.py
# live pipeline (hardware):
./run.sh --preview --no-mqtt --no-motor
```
`npm install` does **not** install CV dependencies.
## 11. Configuration
**Web (build-time, optional):** `VITE_BUILD_COMMIT`, `VITE_BUILD_BRANCH`, `VITE_BUILD_RELEASE``/version.json`. No runtime secrets.
**CV:** copy `cv/config.example.yaml``cv/config.yaml` (gitignored). MQTT/motor/routing **disabled by default**. Never commit real passwords or broker credentials.
## 12. Classification rules
Verified in web (`src/domain/classifier.ts`) and CV (`cv/classify.py`):
- dimensions strictly **> 10×10×10 mm** and **< 450×320×320 mm**
- circular when **K > 0.8** (web) / `circle_ratio ≥ 0.8` (CV)
- order: dimensions fail → **C**; else circular → **D**; else **B**
Official citation: `official_sources/doc-1783095831.pdf` (present; not re-parsed on every doc pass). Missing extracted brief PDF is not claimed.
## 13. Physics (accepted `main`)
From current source (not superseded experimental branches):
- belt speed **1.0 m/s** (`CONVEYOR_SPEED_MPS`)
- fixed timestep **1/60 s** (`PHYSICS_TIMESTEP_SEC`)
- CCD for light/thin product bodies
- diverters LEFT **45°**, RIGHT **+45°**
- full contact-only junction sorting through CAD: **not fully validated**
## 14. CAD and assets
| Asset | Path |
|---|---|
| `VITE_BUILD_COMMIT` | Short git commit in `/version.json` |
| `VITE_BUILD_BRANCH` | Branch name in `/version.json` |
| `VITE_BUILD_RELEASE` | Release label in `/version.json` |
| Author CAD | `3d_models/conveer.FCStd` |
| Runtime GLB | `public/models/sorter/conveyor-clean.glb` |
| Products | `public/models/*.stl` |
Do not commit `.env` files. Local `npm run build` derives identity from git when available.
Keep runtime assets in Git for deploy. Mirror large CAD/models/presentation/video to cloud for submission.
## Active assets
| Path | Role |
|---|---|
| `3d_models/conveer.FCStd` | Author CAD (canonical source) |
| `public/models/sorter/conveyor-clean.glb` | Active runtime conveyor |
| `public/models/*.stl` | Product models |
| `input_info/*` | Official Ozon packs (PDFs/ZIPs) |
| `official_sources/doc-1783095831.pdf` | Official classifier bounds source cited by code |
### Frozen checksums (SHA-256)
SHA-256 (frozen):
```
3d_models/conveer.FCStd
@@ -115,112 +175,39 @@ public/models/sorter/conveyor-clean.glb
1dc7a8d7891bfe756e277ad5368df74cb73410156b2fe0f92845afb8a56f285a
```
## Classifier vs real CV
**Web twin (`main`):** rule-based classification in the digital twin (`src/domain/classifier.ts`) using Track 3 bounds and roundness. Camera overlay / measurement in the twin is a **simulation**, not live RealSense inference. This is **not** a neural network in production.
**Real CV (parallel prototype, not in the web app):** preserved on branch **`drho1y-mvp_1`** under `vision_classifier/` — OpenCV + Intel RealSense D415 depth → L×W×H + circle_ratio → B/C/D → MQTT. No PyTorch/YOLO weight files are stored in git.
## 15. Testing
```bash
git fetch origin
git switch drho1y-mvp_1
cd vision_classifier
# see vision_classifier/README.md and START.md
./demo.sh
.venv/bin/python test_classify.py
./run.sh --preview # hardware path (Orange PI + RealSense)
```
Integration status: **WORKING_PROTOTYPE on `drho1y-mvp_1`**, **not integrated** into https://arhipovdan.ru.
## Sorting logic
**CURRENT_IMPLEMENTATION_VERIFIED_IN_CODE** (`src/domain/classifier.ts`, `src/domain/pusherMotion.ts`, unit tests).
| Category | Physical route | Active CAD diverter |
|---|---|---|
| B | STRAIGHT | none |
| C | PHYSICAL_LEFT | LEFT, **45°** |
| D | PHYSICAL_RIGHT | RIGHT, **+45°** |
Frozen diverter timing / planes (verified in code + tests):
- rotation duration: **0.50 s**
- opening safety margin: **0.15 s**
- contact plane S: **1.0538**
- clear plane S: **1.6000**
Classifier bounds (code + `official_sources/doc-1783095831.pdf` reference):
- dimensions strictly **> 10×10×10 mm** and **< 450×320×320 mm**
- circular when **K > 0.8**
- check order: dimensions → C, else circular → D, else B
## Physical simulation
- Belt target speed **1.0 m/s** (`CONVEYOR_SPEED_MPS`)
- Fixed physics timestep **1/60 s** (`PHYSICS_TIMESTEP_SEC`)
- CCD enabled for small/fast or thin product rigid bodies
- LEFT/RIGHT CAD diverters 45° / +45°
- Rapier world with gravity **[0, 9.81, 0]**
- Full contact-only junction sorting through CAD diverters is **not fully validated**
Details: `docs/ENGINEERING.md`, `/documentation`.
## Validation
```bash
npm ci
npm test -- --run # currently 196 unit tests
npm test -- --run
# current release result: 196/196
npm run build
npx vite preview --host 127.0.0.1 --port 3101 &
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3101 npm run test:e2e -- e2e/routes.spec.ts e2e/smoke.spec.ts
cd cv && .venv/bin/python test_classify.py && .venv/bin/python test_geometry.py
```
## Production deploy (this host)
## 16. Deployment
Nginx terminates TLS for `arhipovdan.ru` and proxies to Docker `owl-web-1` on `127.0.0.1:3100` (`docker-compose.server.yml` + `Dockerfile`).
Nginx terminates TLS for `arhipovdan.ru` and proxies to Docker `owl-web-1` (`docker-compose.server.yml` + `Dockerfile`) on `127.0.0.1:3100`. Deploy from `main` with build-args for `/version.json`. CV is **not** part of the web container.
```bash
cd /opt/arhipovdan/app
COMMIT=$(git rev-parse --short HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
RELEASE=$(date -u +%Y%m%d-%H%M%S)
docker compose -p owl -f docker-compose.server.yml build \
--build-arg "BUILD_COMMIT=${COMMIT}" \
--build-arg "BUILD_BRANCH=${BRANCH}" \
--build-arg "BUILD_RELEASE=${RELEASE}"
docker compose -p owl -f docker-compose.server.yml up -d --force-recreate
curl -s https://arhipovdan.ru/version.json
```
## 17. Submission materials
## Repository vs cloud materials
| Material | Status |
|---|---|
| Presentation URL | REQUIRED_FROM_OWNER |
| Video demo URL | REQUIRED_FROM_OWNER |
| Cloud folder URL | REQUIRED_FROM_OWNER |
| Material | In git | Cloud expected |
|---|---|---|
| Source, README, docs, configs, tests | yes | — |
| Runtime GLB/STL used by deploy | yes (required) | recommended mirror |
| Author CAD `conveer.FCStd` (~5 MB) | yes | recommended upload |
| Official PDFs/ZIPs (`input_info/`, `official_sources/`) | yes | optional mirror |
| Presentation / video demonstration | **not in repo** | **required for platform field** |
| ML weights | none (CV is classical OpenCV depth) | N/A |
Do not invent links. Runtime site assets remain in Git even when mirrored to cloud.
Confirmed public presentation / video / cloud-folder URLs are **not stored in this repository**. Owner must supply them in the platform submission field.
## 18. Known limitations
## Current limitations
- Mobile uses 2D lite fallback on current baseline
- CV is a prototype and is not live-integrated into arhipovdan.ru
- Simulation physics is engineering-derived; hardware calibration still required
- Contact routing through CAD diverters not fully validated
- Large presentation/video must be uploaded to cloud by owner
- Web classifier is a digital twin simulation, not live RealSense inference.
- Full contact-only sorting through CAD diverters is not fully validated.
- Belt surface-velocity physics (true tangential drive) is planned, not complete.
- Per-SKU mass / COM / friction profiles still need calibration.
- Author CAD horn / transmission incomplete in active GLB (`AUTHOR_CAD_INCOMPLETE`).
- Official compliance limited by missing extracted task PDF / PDF re-parse policy.
- Generated screenshots, videos, and stage folders are not canonical.
## 19. Branch history policy
## Repository policy
- Keep author CAD, active runtime assets, official sources, build configs, and tests.
- Real CV remains on `drho1y-mvp_1`; do not force-merge into `main` without validated web integration.
- Rollback / accepted product baseline content: `13ce16b` (current `main` tree via `bb76963`).
See also: `/documentation` in the running app, and `docs/ENGINEERING.md`.
**`main` is the canonical complete solution** (web + cleaned CV under `cv/`). Historical branches (`dan_branch`, `drho1y-mvp_1`, …) may remain for audit but are not required to run the product.

11
cv/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.pyc
debug_frames/
logs/
*.jpg
*.png
.pytest_cache/
config.yaml
.env
.env.*

23
cv/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Vision classifier for Intel RealSense D415 on Orange PI (aarch64)
FROM python:3.11-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgl1 \
libv4l-0 \
v4l-utils \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Камера пробрасывается через docker-compose (--device)
CMD ["python", "main.py", "-c", "config.yaml"]

176
cv/README.md Normal file
View File

@@ -0,0 +1,176 @@
# Real CV prototype — RealSense D415 + OpenCV
**Status:** WORKING_PROTOTYPE
**Production integrated:** NO (https://arhipovdan.ru does **not** consume this pipeline)
**Source:** consolidated from branch `drho1y-mvp_1` (`vision_classifier/`) into `cv/`
Same Track 3 B/C/D rules as the web twin; different input path (real depth camera vs simulated sensor).
## Purpose
Measure parcels on a conveyor with an Intel RealSense D415 (depth + color), estimate L×W×H and circularity, classify into zones **B / C / D**, optionally publish results over MQTT for hardware routing.
## Data flow
```
RealSense D415 (V4L2 depth + color)
→ OpenCV segmentation on depth (optional RGB flat detect)
→ measure L×W×H + circle_ratio
→ stabilize (median / vote → LOCK)
→ classify B/C/D
→ optional MQTT (category, dimensions, servo/motor topics)
```
## Entrypoints (start here)
| Command | Role |
|---|---|
| `./demo.sh` | Browser HUD demo on `:8080` (needs camera for live view) |
| `./run.sh --preview` | Live pipeline with JPEG preview frames |
| `./run.sh --once --no-mqtt --no-motor` | Single-shot / dry hardware |
| `.venv/bin/python test_classify.py` | Classifier unit checks **without camera** |
| `.venv/bin/python test_geometry.py` | Geometry helpers **without camera** |
Primary modules: `main.py` (live), `demo.py` (HUD), `classify.py` (rules), `measure.py` (depth metrics), `camera.py` (V4L2 RealSense).
## Classification rules (Track 3)
1. Dimensions must be strictly **> 10×10×10 mm** and **< 450×320×320 mm** → else **C**
2. Else if `circle_ratio ≥ 0.8`**D**
3. Else → **B**
Stabilization: median window + vote → **LOCK**. Uncertain cases fall back to zone **C** after N frames.
## File structure
```
cv/
main.py # live pipeline entry
demo.py / demo.sh # browser demo
run.sh # venv bootstrap + main.py
camera.py # RealSense via V4L2 + ffmpeg depth
measure.py # segmentation + dimensions
classify.py # B/C/D rules
stabilize.py # temporal LOCK
mqtt_bridge.py # optional MQTT (disabled by default)
calibrate.py # fx/fy + belt height calibration
align_color.py # RGB↔depth alignment helper
tracker.py # multi-object tracking assist
journal.py # decisions JSONL writer
demo_hud.py # HUD rendering
collect_log.py # log helper
test_classify.py # no-camera tests
test_geometry.py # no-camera tests
config.example.yaml # safe defaults (commit)
config.yaml # local only (gitignored)
requirements.txt
Dockerfile / docker-compose.yml
```
## Dependencies
**Software**
- Python **3.10+** (3.11 recommended; Docker image uses 3.11)
- `opencv-python-headless`, `numpy`, `PyYAML`, `pillow`, `paho-mqtt` — see `requirements.txt`
- System: **ffmpeg**, V4L2 (`v4l-utils` useful)
**Hardware (live mode)**
- Intel **RealSense D415** on USB3
- Linux host with `/dev/video*` depth+color nodes (Orange PI / x86)
`npm` / Node packages are **not** used here.
## Installation
```bash
cd cv
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install -r requirements.txt
cp config.example.yaml config.yaml # optional; scripts auto-copy
```
Or simply:
```bash
cd cv
./demo.sh # creates .venv and config.yaml on first run
```
## Demo / tests without claiming live camera
Classifier and geometry (no RealSense required):
```bash
cd cv
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python test_classify.py
.venv/bin/python test_geometry.py
python3 -m compileall .
```
Live HUD (requires D415):
```bash
./demo.sh
# open http://127.0.0.1:8080/
```
Live pipeline:
```bash
./run.sh --preview --no-mqtt --no-motor
# or full hardware once MQTT/routing configured in local config.yaml:
./run.sh --preview
```
## Configuration
| File | Role |
|---|---|
| `config.example.yaml` | Committed safe defaults; **MQTT/motor/routing disabled** |
| `config.yaml` | Local overrides — **gitignored**; never commit credentials |
Optional MQTT (enable only locally):
```yaml
mqtt:
enabled: true
broker: "127.0.0.1"
port: 1883
user: "<your-user>"
password: "<your-password>"
```
CLI overrides: `--no-mqtt`, `--no-motor`, `--dry-route`, `--once`, `--preview`.
## Output schema (LOCK)
- Zone: `B` | `C` | `D`
- Dimensions mm: L×W×H
- `circle_ratio`
- Optional MQTT topics (when enabled): `vision/feedback/category`, `…/dimensions`, `…/circle_ratio`
- Optional JSONL: `logs/decisions.jsonl` (local, gitignored)
## Limitations
- Not connected to the web digital twin runtime.
- Requires calibrated intrinsics / belt height for accurate mm.
- Live demo needs a physical D415; CI hosts usually lack it.
- MQTT/servo/motor path is optional and site-specific.
## Troubleshooting
| Symptom | Check |
|---|---|
| No `/dev/video*` | USB3, `lsusb`, `v4l2-ctl --list-devices` |
| Depth empty | ffmpeg installed; correct `depth_device` |
| Wrong sizes | run `calibrate.py --length … --width …` |
| MQTT offline | expected when `mqtt.enabled: false` |
## Relation to web twin
Web (`src/domain/classifier.ts`) and CV (`classify.py`) implement the **same official bounds**. The public site uses a **digital sensor simulation**; this folder is the **hardware prototype** for future integration.

139
cv/align_color.py Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Автоподбор совмещения RGB↔depth для демо (сенсоры D415 разнесены).
Положите на ленту коробку с чёткими краями и запустите:
.venv/bin/python align_color.py
Скрипт ищет сдвиг (dx, dy) и масштаб цветного кадра, при которых края
на RGB совпадают с краями на карте глубины, и пишет результат в config.yaml.
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from pathlib import Path
import cv2
import numpy as np
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from camera import RealSenseV4L2
from demo_hud import align_color
def _edges_depth(depth_mm: np.ndarray) -> np.ndarray:
d = depth_mm.astype(np.float32)
d = cv2.medianBlur(d.astype(np.uint16), 5).astype(np.float32)
valid = d > 0
if valid.sum() < 1000:
return np.zeros(depth_mm.shape, np.uint8)
lo, hi = np.percentile(d[valid], [2, 98])
norm = np.clip((d - lo) / max(hi - lo, 1.0) * 255.0, 0, 255).astype(np.uint8)
return cv2.Canny(norm, 30, 90)
def _edges_color(color_bgr: np.ndarray) -> np.ndarray:
gray = cv2.cvtColor(color_bgr, cv2.COLOR_BGR2GRAY)
# тёмные сцены: выравниваем контраст перед Canny
gray = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)).apply(gray)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
return cv2.Canny(gray, 40, 120)
def _score(color_edges: np.ndarray, depth_band: np.ndarray, dx: float, dy: float, s: float) -> float:
warped = align_color(color_edges[..., None].repeat(3, axis=2), dx, dy, s)[..., 0]
return float(np.count_nonzero((warped > 0) & (depth_band > 0)))
def main() -> int:
parser = argparse.ArgumentParser(description="Совмещение RGB и depth")
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
args = parser.parse_args()
cfg_path = Path(args.config)
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
cam_cfg = cfg["camera"]
print("[align] открываю камеру… на ленте должна лежать коробка с чёткими краями")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
use_color=True,
)
try:
# прогрев RGB: первые кадры бывают пустыми, плюс автоэкспозиция
for _ in range(30):
cam.read()
time.sleep(0.05)
depth_acc, color_acc = [], []
for _ in range(15):
pair = cam.read()
if pair is not None and not pair.color_is_depth_preview:
depth_acc.append(pair.depth_mm.astype(np.float32))
color_acc.append(pair.color_bgr.astype(np.float32))
time.sleep(0.06)
if len(color_acc) < 3:
print("[align] RGB не читается — проверьте use_color/USB")
return 1
depth = np.median(np.stack(depth_acc), axis=0).astype(np.uint16)
color = np.clip(np.mean(np.stack(color_acc), axis=0), 0, 255).astype(np.uint8)
de = _edges_depth(depth)
if np.count_nonzero(de) < 500:
print("[align] мало краёв на depth — положите коробку в центр кадра")
return 1
band = cv2.dilate(de, cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)))
ce = _edges_color(color)
# грубый перебор → уточнение
best = (0.0, 0.0, 1.0)
best_s = -1.0
for s in np.arange(0.90, 1.16, 0.05):
for dx in range(-80, 81, 8):
for dy in range(-60, 61, 8):
sc = _score(ce, band, dx, dy, float(s))
if sc > best_s:
best_s, best = sc, (float(dx), float(dy), float(s))
bdx, bdy, bs = best
for s in np.arange(bs - 0.04, bs + 0.045, 0.01):
for dx in np.arange(bdx - 8, bdx + 9, 2):
for dy in np.arange(bdy - 8, bdy + 9, 2):
sc = _score(ce, band, float(dx), float(dy), float(s))
if sc > best_s:
best_s, best = sc, (float(dx), float(dy), float(s))
base = _score(ce, band, 0, 0, 1.0)
dx, dy, s = best
print(f"[align] лучшее совмещение: dx={dx:.0f} dy={dy:.0f} scale={s:.2f} "
f"(совпадение краёв {best_s:.0f} против {base:.0f} без коррекции)")
text = cfg_path.read_text(encoding="utf-8")
text = re.sub(r"(?m)^(\s*color_dx:\s*)-?[\d.]+", rf"\g<1>{dx:.1f}", text)
text = re.sub(r"(?m)^(\s*color_dy:\s*)-?[\d.]+", rf"\g<1>{dy:.1f}", text)
text = re.sub(r"(?m)^(\s*color_scale:\s*)-?[\d.]+", rf"\g<1>{s:.3f}", text)
cfg_path.write_text(text, encoding="utf-8")
print(f"[align] записано в {cfg_path}")
# контрольная картинка
out = Path("debug_frames"); out.mkdir(exist_ok=True)
from camera import depth_colormap
aligned = align_color(color, dx, dy, s)
vis = cv2.addWeighted(aligned, 0.6, depth_colormap(depth), 0.4, 0)
cv2.imwrite(str(out / "align_check.jpg"), vis)
print(f"[align] проверка: debug_frames/align_check.jpg")
finally:
cam.release()
return 0
if __name__ == "__main__":
raise SystemExit(main())

188
cv/calibrate.py Normal file
View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Калибровка камеры для точных габаритов (правила ТЗ: >10×10×10, <450×320×320 мм).
Два шага:
1) пустая лента → высота belt_distance_mm;
2) коробка известного размера в центре → фокусное fx=fy.
Запуск (пример для коробки 300×200 мм, высотой ≥ 30 мм):
.venv/bin/python calibrate.py --length 300 --width 200
Результат пишется прямо в config.yaml (fx, fy, belt_distance_mm).
"""
from __future__ import annotations
import argparse
import re
import sys
import time
from pathlib import Path
import cv2
import numpy as np
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from camera import RealSenseV4L2
from measure import segment_object
def _wait_key(prompt: str) -> None:
"""Ждёт одиночное нажатие: 1 — продолжить, q — выйти (Enter не нужен)."""
print(prompt + " [1 — продолжить, q — выйти]", flush=True)
if not sys.stdin.isatty():
# stdin не терминал (пайп/IDE) — читаем строку
line = sys.stdin.readline().strip().lower()
if line.startswith("q"):
raise KeyboardInterrupt
return
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch in ("1", "\r", "\n"): # Enter тоже принимаем на всякий случай
return
if ch in ("q", "Q", "\x03"): # q или Ctrl+C
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _collect_belt(cam: RealSenseV4L2, samples: int = 25) -> float:
vals = []
for _ in range(samples):
pair = cam.read()
if pair is None:
time.sleep(0.05)
continue
d = pair.depth_mm
h, w = d.shape
roi = d[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4]
valid = roi[(roi > 200) & (roi < 4000)]
if valid.size > 100:
vals.append(float(np.median(valid)))
time.sleep(0.04)
if not vals:
raise RuntimeError("Не вижу ленту: проверьте, что камера на 0.51.5 м над поверхностью")
return float(np.median(vals))
def _collect_focal(
cam: RealSenseV4L2,
belt_mm: float,
known_length_mm: float,
known_width_mm: float,
samples: int = 40,
) -> float:
"""fx=fy по площади minAreaRect в пикселях: f = z * sqrt(S_px / S_mm)."""
focals = []
for _ in range(samples):
pair = cam.read()
if pair is None:
time.sleep(0.05)
continue
seg = segment_object(pair.depth_mm, belt_distance_mm=belt_mm, min_area_px=400)
if seg is None:
time.sleep(0.04)
continue
mask, contour = seg
ys, xs = np.where(mask > 0)
z = pair.depth_mm[ys, xs].astype(np.float32)
z = z[z > 0]
if z.size < 100:
continue
z_med = float(np.median(z))
rect = cv2.minAreaRect(contour)
pw, ph = rect[1]
if pw < 10 or ph < 10:
continue
f = z_med * float(np.sqrt((pw * ph) / (known_length_mm * known_width_mm)))
focals.append(f)
time.sleep(0.04)
if len(focals) < 10:
raise RuntimeError(
f"Стабильно вижу коробку только в {len(focals)} кадрах из {samples}. "
"Коробка должна быть высотой ≥ 30 мм и лежать в центре кадра."
)
return float(np.median(focals))
def _patch_config(path: Path, fx: float, belt_mm: float) -> None:
text = path.read_text(encoding="utf-8")
text = re.sub(r"(?m)^(\s*fx:\s*)[\d.]+", rf"\g<1>{fx:.1f}", text)
text = re.sub(r"(?m)^(\s*fy:\s*)[\d.]+", rf"\g<1>{fx:.1f}", text)
text = re.sub(r"(?m)^(belt_distance_mm:\s*)[\d.]+", rf"\g<1>{belt_mm:.0f}", text)
path.write_text(text, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Калибровка fx/fy и высоты ленты")
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
parser.add_argument("--length", type=float, required=True, help="Длина коробки, мм (рулеткой)")
parser.add_argument("--width", type=float, required=True, help="Ширина коробки, мм (рулеткой)")
parser.add_argument("--yes", action="store_true", help="Не ждать Enter (сцена уже готова на каждом шаге)")
args = parser.parse_args()
cfg_path = Path(args.config)
if not cfg_path.exists():
example = cfg_path.with_name("config.example.yaml")
if not example.exists():
raise SystemExit(f"Config not found: {cfg_path}")
cfg_path = example
print(f"[calib] using {cfg_path.name} (copy to config.yaml before saving results)")
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
cam_cfg = cfg["camera"]
print("[calib] открываю RealSense D415…")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=False,
)
try:
if not args.yes:
_wait_key("[calib] Шаг 1/2: УБЕРИТЕ всё с ленты")
belt_mm = _collect_belt(cam)
print(f"[calib] высота до ленты: {belt_mm:.0f} мм")
if not args.yes:
_wait_key(
f"[calib] Шаг 2/2: положите коробку {args.length:.0f}×{args.width:.0f} мм "
"в центр кадра"
)
time.sleep(1.0)
fx = _collect_focal(cam, belt_mm, args.length, args.width)
old_fx = float(cam_cfg.get("fx", 0))
print(f"[calib] фокусное fx=fy: {fx:.1f} (было {old_fx:.1f})")
if old_fx > 0:
k = fx / old_fx
print(f"[calib] габариты со старым fx были завышены/занижены в {k:.2f} раза")
_patch_config(cfg_path, fx, belt_mm)
print(f"[calib] записано в {cfg_path}: fx=fy={fx:.1f}, belt_distance_mm={belt_mm:.0f}")
print("[calib] проверьте: .venv/bin/python demo.py — размеры LWH должны совпадать с рулеткой")
except KeyboardInterrupt:
print("\n[calib] отменено, config.yaml не изменён")
return 1
finally:
cam.release()
return 0
if __name__ == "__main__":
raise SystemExit(main())

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.51.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.51.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()

147
cv/classify.py Normal file
View File

@@ -0,0 +1,147 @@
"""Классификация строго по правилам ТЗ трека 3."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Sequence, Tuple
from measure import ObjectMeasurement
class Category(str, Enum):
SUITABLE = "suitable" # Подходит для сортировки → B
OVERSIZE = "oversize" # Не подходит по габаритам → C
NEED_PACK = "need_pack" # Не подходит без доупаковки → D
@property
def zone(self) -> str:
return {
Category.SUITABLE: "B",
Category.OVERSIZE: "C",
Category.NEED_PACK: "D",
}[self]
@property
def ru_label(self) -> str:
return {
Category.SUITABLE: "Подходит для сортировки",
Category.OVERSIZE: "Не подходит для сортировки по габаритам",
Category.NEED_PACK: "Не подходит для сортировки без доупаковки",
}[self]
@property
def short_label(self) -> str:
"""Короткая метка для HUD и веб-статуса."""
return {
Category.SUITABLE: "ГОТОВ К СОРТИРОВКЕ",
Category.OVERSIZE: "НЕГАБАРИТ",
Category.NEED_PACK: "ТРЕБУЕТ ДОУПАКОВКИ",
}[self]
@dataclass
class ClassificationResult:
category: Category
dims_sorted_mm: Tuple[float, float, float]
circle_ratio: float
passes_size: bool
is_circular: bool
reason: str
def _sorted_dims(length: float, width: float, height: float) -> Tuple[float, float, float]:
a, b, c = sorted([float(length), float(width), float(height)], reverse=True)
return a, b, c
def check_size(
dims_sorted: Sequence[float],
min_mm: Sequence[float],
max_mm: Sequence[float],
) -> bool:
"""
ТЗ: габариты строго больше минимума и строго меньше максимума
по сопоставленным сторонам после сортировки.
"""
min_s = sorted([float(x) for x in min_mm], reverse=True)
max_s = sorted([float(x) for x in max_mm], reverse=True)
d = [float(x) for x in dims_sorted]
return all(d[i] > min_s[i] for i in range(3)) and all(d[i] < max_s[i] for i in range(3))
def classify(
measurement: ObjectMeasurement,
min_mm: Sequence[float] = (10, 10, 10),
max_mm: Sequence[float] = (450, 320, 320),
circle_ratio_threshold: float = 0.8,
) -> ClassificationResult:
"""
Порядок ТЗ:
1) габариты → иначе C (приоритет над кругом)
2) если r_in/r_out >= 0.8 в любом сечении → D
3) иначе → B
"""
dims = _sorted_dims(measurement.length_mm, measurement.width_mm, measurement.height_mm)
passes = check_size(dims, min_mm, max_mm)
ratio = float(measurement.circle_ratio)
circular = ratio >= float(circle_ratio_threshold)
clipped = bool(getattr(measurement, "clipped_by_frame", False))
if not passes or clipped:
reason = (
"объект обрезан краем кадра → габарит неполный, считаем негабаритом"
if clipped and passes
else "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм"
)
if clipped and not passes:
reason = "габариты вне допуска (в т.ч. обрезан кадром): нужно >10×10×10 и <450×320×320 мм"
return ClassificationResult(
category=Category.OVERSIZE,
dims_sorted_mm=dims,
circle_ratio=ratio,
passes_size=False,
is_circular=circular,
reason=reason,
)
if circular:
return ClassificationResult(
category=Category.NEED_PACK,
dims_sorted_mm=dims,
circle_ratio=ratio,
passes_size=True,
is_circular=True,
reason=f"круг в сечении: r_in/r_out={ratio:.3f} >= {circle_ratio_threshold}",
)
return ClassificationResult(
category=Category.SUITABLE,
dims_sorted_mm=dims,
circle_ratio=ratio,
passes_size=True,
is_circular=False,
reason=f"габариты OK, круга нет: r_in/r_out={ratio:.3f} < {circle_ratio_threshold}",
)
def classify_from_dims(
length_mm: float,
width_mm: float,
height_mm: float,
circle_ratio: float,
min_mm: Sequence[float] = (10, 10, 10),
max_mm: Sequence[float] = (450, 320, 320),
circle_ratio_threshold: float = 0.8,
) -> ClassificationResult:
fake = ObjectMeasurement(
length_mm=length_mm,
width_mm=width_mm,
height_mm=height_mm,
circle_ratio=circle_ratio,
area_px=0,
centroid_px=(0, 0),
contour=None, # type: ignore
mask=None, # type: ignore
)
return classify(fake, min_mm, max_mm, circle_ratio_threshold)

151
cv/collect_log.py Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""
Сбор логов классификации с камеры.
Кладите предметы по очереди — пишет CSV + печатает сводку.
.venv/bin/python collect_log.py
# Ctrl+C — стоп
"""
from __future__ import annotations
import csv
import sys
import time
from datetime import datetime
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from camera import RealSenseV4L2
from measure import measure_object, segment_object
from stabilize import DecisionStabilizer
def main() -> int:
cfg = yaml.safe_load(Path("config.yaml").read_text(encoding="utf-8"))
cam_cfg = cfg["camera"]
cls = cfg["classification"]
thr = float(cls.get("circle_ratio_threshold", 0.8))
out_dir = Path("debug_frames")
out_dir.mkdir(exist_ok=True)
stamp = datetime.now().strftime("%H%M%S")
csv_path = out_dir / f"log_{stamp}.csv"
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=False,
)
belt = float(cfg.get("belt_distance_mm") or 600)
print(f"[log] belt={belt:.0f} mm thr={thr}{csv_path}")
print("[log] Кладите КРУГ / ПРЯМОУГОЛЬНИК. Ctrl+C — стоп.\n")
stab = DecisionStabilizer(window=12, confirm_frames=8, lost_frames=12, enter_circle=thr)
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
f = csv_path.open("w", newline="", encoding="utf-8")
w = csv.writer(f)
w.writerow(
[
"t",
"present",
"L",
"W",
"H",
"top",
"sec",
"circle",
"raw_zone",
"lock",
"lock_zone",
"conf",
]
)
last_print = 0.0
n = 0
try:
while True:
pair = cam.read()
if pair is None:
time.sleep(0.02)
continue
n += 1
seg = segment_object(
pair.depth_mm,
belt_distance_mm=belt,
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
min_object_height_mm=float(cfg.get("min_object_height_mm", 5)),
min_area_px=int(cfg.get("min_object_area_px", 400)),
)
m = None
if seg is not None:
mask, contour = seg
m = measure_object(
pair.depth_mm, mask, contour, belt, fx, fy, cx, cy
)
d = stab.update(
m,
min_mm=cls.get("min_mm", [10, 10, 10]),
max_mm=cls.get("max_mm", [450, 320, 320]),
)
if m is None:
raw_zone = "-"
row = [time.time(), 0, "", "", "", "", "", "", raw_zone, int(d.locked), "", d.confidence_pct]
else:
dims = sorted([m.length_mm, m.width_mm, m.height_mm], reverse=True)
raw = "C"
if all(dims[i] > 10 and dims[i] < [450, 320, 320][i] for i in range(3)):
raw = "D" if m.circle_ratio >= thr else "B"
lz = d.result.category.zone if (d.locked and d.result) else ""
row = [
time.time(),
1,
round(dims[0], 1),
round(dims[1], 1),
round(dims[2], 1),
round(m.top_ratio, 3),
round(m.section_ratio, 3),
round(m.circle_ratio, 3),
raw,
int(d.locked),
lz,
d.confidence_pct,
]
w.writerow(row)
if n % 5 == 0:
f.flush()
now = time.time()
if now - last_print > 0.45:
last_print = now
if m is None:
print(f"[{n:05d}] пусто")
else:
lz = d.result.category.zone if (d.locked and d.result) else ""
print(
f"[{n:05d}] raw={row[8]} lock={lz or '':1s} conf={d.confidence_pct:3d}% | "
f"LWH={row[2]:.0f}×{row[3]:.0f}×{row[4]:.0f} | "
f"top={m.top_ratio:.3f} sec={m.section_ratio:.3f} circ={m.circle_ratio:.3f}"
)
except KeyboardInterrupt:
print(f"\n[log] сохранено {csv_path}")
finally:
f.close()
cam.release()
return 0
if __name__ == "__main__":
raise SystemExit(main())

90
cv/config.example.yaml Normal file
View File

@@ -0,0 +1,90 @@
# Vision classifier config EXAMPLE — copy to config.yaml and edit locally.
# Do not commit config.yaml (it may contain site-specific credentials).
camera:
depth_device: /dev/video0
color_device: /dev/video4
width: 640
height: 480
fps: 30
depth_scale_mm: 1.0
# D415 @ 640x480 approximate intrinsics — calibrate on your setup
fx: 564.0
fy: 564.0
cx: 320.0
cy: 240.0
color_dx: -40.0
color_dy: -8.0
color_scale: 1.030
belt_distance_mm: 594
belt_tolerance_mm: 25
min_object_height_mm: 20
min_object_area_px: 800
roi_margin:
top: 0.12
bottom: 0.02
left: 0.05
right: 0.12
max_objects_in_frame: 3
use_color: true
use_background_map: false
detect_flat_rgb: false
rgb_diff_threshold: 35
classification:
min_mm: [10, 10, 10]
max_mm: [450, 320, 320]
circle_ratio_threshold: 0.8
uncertain_after_frames: 45
uncertain_fallback_zone: C
# MQTT is OPTIONAL and disabled by default for safe local runs.
mqtt:
broker: "127.0.0.1"
port: 1883
user: ""
password: ""
client_id: "vision_classifier"
topic_result: "vision/feedback/category"
topic_dims: "vision/feedback/dimensions"
topic_circle: "vision/feedback/circle_ratio"
topic_debug: "vision/feedback/debug"
enabled: false
motor:
enabled: false
rpm: -200
current_percent: 50
microsteps: 16
stealthchop: true
disable_on_stop: false
routing:
enabled: false
zones:
B:
servo: 0
idle_angle: 0
divert_angle: 0
hold_ms: 500
C:
servo: 1
idle_angle: 0
divert_angle: 90
hold_ms: 800
D:
servo: 2
idle_angle: 0
divert_angle: 90
hold_ms: 800
cooldown_ms: 1500
runtime:
show_preview: false
save_debug_frames: false
debug_dir: "debug_frames"
decisions_log: "logs/decisions.jsonl"
preview_every_n: 3
process_every_n: 1
confirm_frames: 8

873
cv/demo.py Executable file
View File

@@ -0,0 +1,873 @@
#!/usr/bin/env python3
"""
Демо-режим хакатона с ползунками в браузере:
• высота до ленты (belt_distance_mm)
• порог уверенности (сколько кадров подряд одно и то же решение)
• мин. высота объекта, порог круга
Без MQTT / мотора / серво.
"""
from __future__ import annotations
import argparse
import base64
import json
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlparse
import cv2
import numpy as np
import yaml
from camera import RealSenseV4L2
from classify import Category, ClassificationResult
from demo_hud import ContourSmoother, align_color, build_demo_frame
from journal import append_decision
from measure import (
is_plausible_measurement,
measure_flat_object,
measure_object,
merge_overlapping_measurements,
segment_objects,
segment_rgb_objects,
)
from stabilize import DecisionStabilizer
from tracker import MultiObjectTracker, slot_key
ZONE_TO_CATEGORY = {
"B": Category.SUITABLE,
"C": Category.OVERSIZE,
"D": Category.NEED_PACK,
}
HTML = r"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Трек 3 — демо</title>
<style>
:root {
--bg:#111114; --card:#1c1c22; --line:#33333c; --txt:#f2f2f4;
--muted:#a0a0ab; --acc:#2dd4bf; --b:#22c55e; --c:#ef4444; --d:#f59e0b;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--txt); font-family:system-ui,-apple-system,sans-serif; }
.top {
position:sticky; top:0; z-index:20;
background:var(--card); border-bottom:2px solid var(--acc);
padding:12px 16px 14px; box-shadow:0 8px 24px rgba(0,0,0,.45);
}
.top h1 { margin:0 0 4px; font-size:17px; }
.top .sub { margin:0 0 12px; color:var(--muted); font-size:12px; }
.sliders {
display:grid;
grid-template-columns:repeat(auto-fit, minmax(220px, 1fr));
gap:12px 18px;
}
.sliders label {
display:flex; justify-content:space-between; align-items:baseline;
font-size:12px; margin-bottom:4px; color:var(--muted);
}
.sliders label b { color:var(--acc); font-size:14px; font-variant-numeric:tabular-nums; }
input[type=range] { width:100%; height:28px; accent-color:var(--acc); cursor:pointer; }
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; align-items:center; }
button {
border:0; border-radius:8px; padding:10px 14px; font-weight:700; cursor:pointer;
background:var(--acc); color:#042f2e;
}
button.sec { background:#2a2a32; color:var(--txt); }
#st {
flex:1; min-width:200px; padding:10px 12px; border-radius:8px;
background:#121218; border:1px solid var(--line); font-size:13px; line-height:1.45;
}
#st .zoneB { color:var(--b); font-weight:800; font-size:18px; }
#st .zoneC { color:var(--c); font-weight:800; font-size:18px; }
#st .zoneD { color:var(--d); font-weight:800; font-size:18px; }
.main {
display:flex; gap:12px; padding:12px; max-width:1400px;
margin:0 auto; align-items:flex-start;
}
.stage { flex:1; min-width:0; }
.stage img {
width:100%; height:auto; display:block;
border-radius:10px; border:1px solid var(--line); background:#000;
}
.side { width:320px; flex-shrink:0; display:flex; flex-direction:column; gap:10px; }
.side h3 {
margin:0; font-size:12px; text-transform:uppercase; letter-spacing:.08em;
color:var(--muted);
}
.card {
background:var(--card); border:1px solid var(--line); border-radius:10px;
padding:10px; font-size:13px; line-height:1.5;
}
.card img {
width:100%; height:auto; display:block; border-radius:6px;
background:#000; margin-bottom:8px;
}
.card .hd { font-weight:800; font-size:14px; }
.card .mut { color:var(--muted); font-size:12px; }
#feed { display:flex; flex-direction:column; gap:8px; overflow-y:auto; max-height:60vh; }
.fitem {
display:flex; gap:8px; background:var(--card); border:1px solid var(--line);
border-radius:10px; padding:8px; font-size:12px; line-height:1.45;
}
.fitem img {
width:86px; height:64px; object-fit:cover; border-radius:6px;
background:#000; flex-shrink:0;
}
.fitem .hd { font-weight:800; font-size:13px; }
.fitem .mut { color:var(--muted); }
.zB { color:var(--b); } .zC { color:var(--c); } .zD { color:var(--d); }
.zU { color:#f97316; }
@media (max-width:900px) {
.main { flex-direction:column; }
.side { width:100%; }
}
</style>
</head>
<body>
<div class="top">
<h1>Трек 3 — демо классификации (B / C / D)</h1>
<p class="sub">Ползунки СВЕРХУ (отдельная панель). Картинка только камера. Выход: Ctrl+C в терминале. Обновите страницу Ctrl+F5.</p>
<div class="sliders">
<div>
<label>Высота до ленты, мм <b id="v_belt">600</b></label>
<input id="belt" type="range" min="300" max="2000" step="5" value="600"/>
</div>
<div>
<label>Порог уверенности, % <b id="v_conf">75</b></label>
<input id="conf" type="range" min="10" max="100" step="5" value="75"/>
</div>
<div>
<label>Мин. высота объекта, мм <b id="v_hmin">8</b></label>
<input id="hmin" type="range" min="2" max="80" step="1" value="8"/>
</div>
<div>
<label>Порог «круг» <b id="v_circ">0.80</b></label>
<input id="circ" type="range" min="0.50" max="0.95" step="0.01" value="0.80"/>
</div>
<div>
<label>Мин. площадь, px <b id="v_area">800</b></label>
<input id="area" type="range" min="100" max="5000" step="50" value="800"/>
</div>
</div>
<div class="actions">
<div id="st">Загрузка…</div>
<button type="button" id="auto">Авто-высота</button>
<button type="button" class="sec" id="reset">Сброс</button>
</div>
</div>
<div class="main">
<div class="stage">
<img id="f" src="/frame.jpg?t=0" alt="camera"/>
</div>
<aside class="side">
<h3>Текущий объект</h3>
<div id="live"><div class="card mut">объектов нет</div></div>
<h3>Лента</h3>
<div id="feed"><div class="card mut">пока пусто</div></div>
</aside>
</div>
<script>
const img = document.getElementById('f');
const ids = ['belt','conf','hmin','circ','area'];
const defaults = { belt:600, conf:75, hmin:8, circ:0.80, area:800 };
let dragging = false;
function syncLabels() {
v_belt.textContent = belt.value;
v_conf.textContent = conf.value;
v_hmin.textContent = hmin.value;
v_circ.textContent = Number(circ.value).toFixed(2);
v_area.textContent = area.value;
}
async function pushParams() {
syncLabels();
await fetch('/api/params', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
belt_mm: Number(belt.value),
confidence_pct: Number(conf.value),
min_object_height_mm: Number(hmin.value),
circle_threshold: Number(circ.value),
min_area_px: Number(area.value),
}),
});
}
async function pullStatus() {
try {
const s = await (await fetch('/api/status')).json();
let zoneHtml = '<span style="color:#888">объектов нет</span>';
if (s.objects && s.objects.length) {
zoneHtml = s.objects.map(o => {
const dims = o.dims ? (o.dims.map(x => Math.round(x)).join('×') + ' мм') : '';
const extra = ' · ' + dims + ' · круг ' + (o.ratio ?? '');
if (o.locked && o.uncertain)
return '<span style="color:#f97316;font-weight:800">#' + o.id + ' НЕУВЕРЕННО' + o.label + '</span>' + extra;
if (o.locked)
return '<span class="zone' + o.zone + '">#' + o.id + ' ' + o.label + ' ✓</span>' + extra;
return '<span style="color:#38bdf8">#' + o.id + ' анализ… ' + o.conf + '%</span>' + extra;
}).join('<br/>');
}
const stt = s.stats || {};
const statsLine = 'Итого: <b style="color:#22c55e">ГОТОВ ' + (stt.B || 0) +
'</b> · <b style="color:#ef4444">НЕГАБАРИТ ' + (stt.C || 0) +
'</b> · <b style="color:#f59e0b">ДОУПАКОВКА ' + (stt.D || 0) + '</b>' +
(stt.uncertain ? ' · неуверенно ' + stt.uncertain : '');
st.innerHTML = zoneHtml + '<br/>' + statsLine + '<br/>высота <b>' + s.belt_mm + '</b> мм';
renderLive(s.objects || []);
if (s.feed_seq !== window.__feedSeq) {
window.__feedSeq = s.feed_seq;
refreshFeed(s.feed || null);
}
if (!window.__inited && !dragging) {
belt.value = s.belt_mm;
conf.value = s.confidence_pct;
hmin.value = s.min_object_height_mm;
circ.value = s.circle_threshold;
area.value = s.min_area_px;
syncLabels();
window.__inited = true;
}
} catch (e) { st.textContent = 'Нет связи с demo.py — перезапустите ./demo.sh'; }
}
function zcls(o) {
if (o.uncertain) return 'zU';
return o.zone ? ('z' + o.zone) : '';
}
function dimsStr(d) {
return d ? d.map(x => Math.round(x)).join('×') + ' мм' : '';
}
function objSig(o) {
return o.id + '|' + (o.locked ? 'L' : 'P') + '|' + o.conf + '|' + (o.zone || '') +
'|' + (o.label || '') + '|' + (o.dims || []).map(x => Math.round(x)).join(',');
}
function renderLive(objs) {
const box = document.getElementById('live');
const sig = objs.map(objSig).join(';');
if (sig === window.__liveSig) return;
window.__liveSig = sig;
if (!objs.length) {
box.innerHTML = '<div class="card mut">объектов нет</div>';
return;
}
box.innerHTML = objs.map(o => {
const img = (o.locked && o.crop) ? '<img src="data:image/jpeg;base64,' + o.crop + '"/>' : '';
const head = o.locked
? '<span class="hd ' + zcls(o) + '">#' + o.id + ' ' + (o.uncertain ? 'НЕУВЕРЕННО' : '') + o.label + (o.zone ? ' · зона ' + o.zone : '') + '</span>'
: '<span class="hd" style="color:#38bdf8">#' + o.id + ' анализ… ' + o.conf + '%</span>';
const reason = o.reason ? '<div class="mut">' + o.reason + '</div>' : '';
return '<div class="card">' + img + head +
'<div>' + dimsStr(o.dims) + ' · круг ' + (o.ratio ?? '') + '</div>' + reason + '</div>';
}).join('');
}
function refreshFeed(items) {
const box = document.getElementById('feed');
if (!window.__feedKeys) window.__feedKeys = new Set();
if (!items || !items.length) {
if (!window.__feedKeys.size) box.innerHTML = '<div class="card mut">пока пусто</div>';
return;
}
if (window.__feedKeys.size === 0) box.innerHTML = '';
for (const it of items.slice().reverse()) {
const key = it.slot || ('#' + it.id);
if (window.__feedKeys.has(key)) continue;
window.__feedKeys.add(key);
const img = it.crop ? '<img src="data:image/jpeg;base64,' + it.crop + '"/>' : '<img/>';
const el = document.createElement('div');
el.className = 'fitem';
el.dataset.slot = key;
el.innerHTML = img + '<div>' +
'<div class="hd ' + zcls(it) + '">#' + it.id + ' ' + (it.uncertain ? 'НЕУВЕР' : '') + 'зона ' + it.zone + '</div>' +
'<div>' + it.label + '</div>' +
'<div class="mut">' + dimsStr(it.dims) + ' · круг ' + it.ratio + ' · ' + it.time + '</div>' +
'</div>';
box.insertBefore(el, box.firstChild);
}
}
ids.forEach(id => {
const el = document.getElementById(id);
el.addEventListener('pointerdown', () => { dragging = true; });
el.addEventListener('pointerup', () => { dragging = false; pushParams(); });
el.addEventListener('input', () => { syncLabels(); pushParams(); });
});
document.getElementById('auto').onclick = async () => {
st.textContent = 'Калибровка… уберите объекты с ленты';
const s = await (await fetch('/api/autocalib', {method:'POST'})).json();
if (s.ok) {
belt.value = Math.round(s.belt_mm);
syncLabels();
await pushParams();
} else st.textContent = 'Ошибка: ' + (s.error || '');
};
document.getElementById('reset').onclick = () => {
belt.value = defaults.belt; conf.value = defaults.conf;
hmin.value = defaults.hmin; circ.value = defaults.circ; area.value = defaults.area;
syncLabels(); pushParams();
};
setInterval(() => { img.src = '/frame.jpg?t=' + Date.now(); }, 280);
setInterval(pullStatus, 350);
pullStatus();
</script>
</body>
</html>
"""
class Params:
def __init__(self) -> None:
self.lock = threading.Lock()
self.belt_mm: float = 800.0
self.confidence_pct: int = 75 # порог фиксации
self.min_object_height_mm: float = 8.0
self.circle_threshold: float = 0.80
self.min_area_px: int = 400
# runtime status
self.confidence_now: int = 0
self.zone: Optional[str] = None
self.locked: bool = False
self.uncertain: bool = False
self.circle_ratio: Optional[float] = None
self.dims: Optional[tuple] = None
self.reason: str = ""
self.objects: list = [] # [{id, zone, label, dims, ratio, locked, uncertain, conf, crop}]
self.stats: dict = {} # счётчики за сессию
self.feed: list = [] # лента LOCK-событий (новые в конце)
self.feed_seq: int = 0 # версия ленты — клиент тянет только при изменении
self.feed_slots: set = set() # slot_key — один товар = одна карточка в ленте
self.crop_cache: dict = {} # track_id → base64, фиксируется при LOCK
self.jpeg: bytes = b""
self.last_print: str = ""
self.cam: Any = None
self.request_autocalib: bool = False
self.autocalib_result: Optional[Dict[str, Any]] = None
self.background: Optional[np.ndarray] = None # карта глубины пустой сцены
self.color_background: Optional[np.ndarray] = None # RGB пустой сцены (плоские товары)
STATE = Params()
def make_handler() -> type:
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
return
def _json(self, code: int, obj: Dict[str, Any]) -> None:
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
path = urlparse(self.path).path
if path.startswith("/frame.jpg"):
with STATE.lock:
data = STATE.jpeg
if not data:
self.send_error(503, "no frame yet")
return
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
elif path == "/api/status":
with STATE.lock:
self._json(
200,
{
"belt_mm": round(STATE.belt_mm),
"confidence_pct": STATE.confidence_pct,
"confidence_now": STATE.confidence_now,
"min_object_height_mm": STATE.min_object_height_mm,
"circle_threshold": STATE.circle_threshold,
"min_area_px": STATE.min_area_px,
"zone": STATE.zone,
"locked": STATE.locked,
"uncertain": STATE.uncertain,
"circle_ratio": STATE.circle_ratio,
"dims": STATE.dims,
"reason": STATE.reason,
"objects": STATE.objects,
"stats": STATE.stats,
"feed_seq": STATE.feed_seq,
"feed": STATE.feed,
},
)
elif path == "/api/feed":
with STATE.lock:
self._json(200, {"seq": STATE.feed_seq, "items": STATE.feed})
else:
body = HTML.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self) -> None:
path = urlparse(self.path).path
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8") or "{}")
except json.JSONDecodeError:
data = {}
if path == "/api/params":
with STATE.lock:
if "belt_mm" in data:
new_belt = float(np.clip(float(data["belt_mm"]), 200, 3000))
# ручная правка высоты → фоновая карта устарела
if abs(new_belt - STATE.belt_mm) > 2.0:
STATE.background = None
STATE.belt_mm = new_belt
if "confidence_pct" in data:
STATE.confidence_pct = int(np.clip(int(data["confidence_pct"]), 10, 100))
if "min_object_height_mm" in data:
STATE.min_object_height_mm = float(np.clip(float(data["min_object_height_mm"]), 1, 200))
if "circle_threshold" in data:
STATE.circle_threshold = float(np.clip(float(data["circle_threshold"]), 0.4, 0.99))
if "min_area_px" in data:
STATE.min_area_px = int(np.clip(int(data["min_area_px"]), 50, 20000))
self._json(200, {"ok": True})
elif path == "/api/autocalib":
with STATE.lock:
STATE.request_autocalib = True
STATE.autocalib_result = None
# ждём результат от цикла камеры
for _ in range(80):
time.sleep(0.1)
with STATE.lock:
if STATE.autocalib_result is not None:
self._json(200, STATE.autocalib_result)
return
self._json(500, {"ok": False, "error": "timeout"})
else:
self.send_error(404)
return Handler
def resolve_config_path(path: Path) -> Path:
if path.exists():
return path
example = path.with_name("config.example.yaml")
if example.exists():
return example
raise FileNotFoundError(f"Config not found: {path} (and no config.example.yaml)")
def load_config(path: Path) -> Dict[str, Any]:
resolved = resolve_config_path(Path(path))
with open(resolved, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def frames_needed(confidence_pct: int) -> int:
# 10% → 5, 100% → 12 кадров одной зоны после прогрева окна
return max(5, int(round(5 + (confidence_pct / 100.0) * 7)))
def crop_b64(img: np.ndarray, contour: np.ndarray, pad: int = 14, max_w: int = 260) -> Optional[str]:
"""Кроп объекта по bounding box контура → JPEG base64 для веб-панели."""
x, y, w, h = cv2.boundingRect(contour)
H, W = img.shape[:2]
x0, y0 = max(0, x - pad), max(0, y - pad)
x1, y1 = min(W, x + w + pad), min(H, y + h + pad)
if x1 - x0 < 4 or y1 - y0 < 4:
return None
crop = img[y0:y1, x0:x1]
if crop.shape[1] > max_w:
s = max_w / crop.shape[1]
crop = cv2.resize(crop, (max_w, max(1, int(crop.shape[0] * s))))
ok, buf = cv2.imencode(".jpg", crop, [int(cv2.IMWRITE_JPEG_QUALITY), 78])
return base64.b64encode(buf.tobytes()).decode("ascii") if ok else None
def main() -> int:
parser = argparse.ArgumentParser(description="Демо классификации с ползунками")
parser.add_argument("-c", "--config", default=str(Path(__file__).with_name("config.yaml")))
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
cfg = load_config(Path(args.config))
cam_cfg = cfg["camera"]
cls_cfg = cfg["classification"]
min_mm = cls_cfg.get("min_mm", [10, 10, 10])
max_mm = cls_cfg.get("max_mm", [450, 320, 320])
out_dir = Path(cfg.get("runtime", {}).get("debug_dir", "debug_frames"))
out_dir.mkdir(parents=True, exist_ok=True)
out_jpg = out_dir / "demo_live.jpg"
print("[demo] открываю RealSense D415…")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=bool(cfg.get("use_color", False)),
)
STATE.cam = cam
belt0 = float(cfg.get("belt_distance_mm") or 0)
if belt0 <= 0:
print("[demo] калибровка ленты — уберите объекты…")
belt0 = cam.estimate_belt_distance_mm()
with STATE.lock:
STATE.belt_mm = belt0
STATE.circle_threshold = float(cls_cfg.get("circle_ratio_threshold", 0.8))
STATE.min_object_height_mm = float(cfg.get("min_object_height_mm", 8))
STATE.min_area_px = int(cfg.get("min_object_area_px", 400))
STATE.confidence_pct = 75
print(f"[demo] belt_distance_mm = {belt0:.0f}")
max_objects = int(cfg.get("max_objects_in_frame", 3))
detect_flat_rgb = bool(cfg.get("detect_flat_rgb", False))
print(f"[demo] max_objects={max_objects}, flat_rgb={'ON' if detect_flat_rgb else 'OFF'}")
print("[demo] фоновая карта: кнопка «Авто-высота» на пустой ленте")
server = ThreadingHTTPServer((args.host, args.port), make_handler())
threading.Thread(target=server.serve_forever, daemon=True).start()
print(f"[demo] браузер → http://127.0.0.1:{args.port}/")
print("[demo] ползунки СВЕРХУ страницы (не на картинке)")
print("[demo] зона только после LOCK (медиана 12 кадров + голосование)")
print("[demo] Ctrl+C — выход\n")
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
fallback_zone = str(cls_cfg.get("uncertain_fallback_zone", "C")).upper()
thr0 = float(cls_cfg.get("circle_ratio_threshold", 0.8))
settings = {"confirm": frames_needed(75), "circ": thr0}
def make_stabilizer() -> DecisionStabilizer:
return DecisionStabilizer(
window=12,
confirm_frames=settings["confirm"],
lost_frames=12,
enter_circle=settings["circ"],
exit_circle=settings["circ"] - 0.08,
uncertain_after=int(cls_cfg.get("uncertain_after_frames", 45)),
fallback=ZONE_TO_CATEGORY.get(fallback_zone, Category.OVERSIZE),
)
# lost_frames=30 ≈ 1.52 с: глянцевые/тёмные предметы (мышка) дают
# кратковременные выпадения depth — трек не должен умирать от них
tracker = MultiObjectTracker(make_stabilizer, max_dist_px=120, lost_frames=30)
contour_smoother = ContourSmoother(alpha=0.3)
color_align = (
float(cam_cfg.get("color_dx", 0.0)),
float(cam_cfg.get("color_dy", 0.0)),
float(cam_cfg.get("color_scale", 1.0)),
)
decisions_log = Path(cfg.get("runtime", {}).get("decisions_log", "logs/decisions.jsonl"))
frame_i = 0
last_conf_setting = 75
last_circ = thr0
try:
while True:
# автокалибровка по запросу из UI
with STATE.lock:
need_auto = STATE.request_autocalib
if need_auto:
STATE.request_autocalib = False
if need_auto:
try:
print("[demo] автокалибровка: снимаю фоновую карту (сцена должна быть пустой)…")
bg = cam.capture_background(samples=15)
color_bg = cam.capture_background_rgb(samples=10)
h, w = bg.shape
center = bg[h // 4 : 3 * h // 4, w // 4 : 3 * w // 4].astype(np.float32)
center = center[(center > 200) & (center < 4000)]
new_belt = float(np.median(center)) if center.size > 100 else cam.estimate_belt_distance_mm(samples=10)
with STATE.lock:
STATE.belt_mm = new_belt
STATE.background = bg
STATE.color_background = color_bg
STATE.autocalib_result = {"ok": True, "belt_mm": new_belt}
tracker.reset()
rgb_tag = "RGB-фон есть" if color_bg is not None else "RGB-фон недоступен"
print(f"[demo] высота = {new_belt:.0f} mm, фоновая карта активна, {rgb_tag}")
except Exception as exc:
with STATE.lock:
STATE.autocalib_result = {"ok": False, "error": str(exc)}
pair = cam.read()
if pair is None:
time.sleep(0.02)
continue
frame_i += 1
with STATE.lock:
belt_mm = STATE.belt_mm
conf_pct = STATE.confidence_pct
hmin = STATE.min_object_height_mm
circ_thr = STATE.circle_threshold
min_area = STATE.min_area_px
background = STATE.background
color_background = STATE.color_background
if conf_pct != last_conf_setting:
settings["confirm"] = frames_needed(conf_pct)
tracker.reset()
last_conf_setting = conf_pct
if abs(circ_thr - last_circ) > 1e-6:
settings["circ"] = float(circ_thr)
tracker.reset()
last_circ = circ_thr
# несколько объектов в кадре → трекер с ID
measurements = []
depth_union = None
seg_n = 0
for mask, contour in segment_objects(
pair.depth_mm,
belt_distance_mm=belt_mm,
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
min_object_height_mm=hmin,
min_area_px=min_area,
background_mm=background,
max_objects=max_objects,
):
seg_n += 1
depth_union = mask if depth_union is None else cv2.bitwise_or(depth_union, mask)
m = measure_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
min_object_height_mm=hmin,
)
if m is not None and is_plausible_measurement(m):
measurements.append(m)
# плоские товары — только если detect_flat_rgb: true (иначе тени → ложные C)
if (
detect_flat_rgb
and color_background is not None
and not pair.color_is_depth_preview
):
for mask, contour in segment_rgb_objects(
pair.color_bgr,
color_background,
min_area_px=min_area,
diff_threshold=int(cfg.get("rgb_diff_threshold", 35)),
max_objects=max(0, max_objects - len(measurements)),
exclude_mask=depth_union,
):
m = measure_flat_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
color_bgr=pair.color_bgr,
color_bg_bgr=color_background,
)
if m is not None and is_plausible_measurement(m):
measurements.append(m)
measurements = merge_overlapping_measurements(measurements, overlap_thr=0.5)
tracks, events = tracker.update(measurements, min_mm=min_mm, max_mm=max_mm)
rgb_available = not pair.color_is_depth_preview
if rgb_available and float(np.std(pair.color_bgr)) > 4.0:
base_img = pair.color_bgr
if base_img.shape[:2] != pair.depth_mm.shape[:2]:
base_img = cv2.resize(base_img, (pair.depth_mm.shape[1], pair.depth_mm.shape[0]))
base_img = align_color(base_img, *color_align)
else:
# без живого RGB — colorize(depth), иначе веб был бы чёрным
from camera import depth_colormap
base_img = depth_colormap(pair.depth_mm)
rgb_available = False
crops: Dict[int, Optional[str]] = {}
for tr in tracks:
if tr.measurement is not None:
crops[tr.track_id] = crop_b64(base_img, tr.measurement.contour)
crop_updates: Dict[int, str] = {}
feed_add = []
for ev in events:
r = ev.decision.result
tag = "UNCERTAIN→" if ev.decision.uncertain else "LOCK "
print(
f"[demo] #{ev.track_id} {tag}{r.category.zone} | {r.category.short_label} | "
f"LWH={tuple(round(x, 1) for x in r.dims_sorted_mm)} | circle={r.circle_ratio:.3f}"
)
append_decision(
decisions_log, r,
uncertain=ev.decision.uncertain, source="demo", track_id=ev.track_id,
)
L, W, H = r.dims_sorted_mm
tr_ev = next((t for t in tracks if t.track_id == ev.track_id), None)
cx, cy = (tr_ev.centroid if tr_ev else (0, 0))
sk = slot_key(cx, cy, L, W, H, r.category.zone)
crop = crops.get(ev.track_id)
if crop:
crop_updates[ev.track_id] = crop
feed_add.append({
"slot": sk,
"id": ev.track_id,
"time": time.strftime("%H:%M:%S"),
"zone": r.category.zone,
"label": r.category.short_label,
"dims": [round(x, 1) for x in r.dims_sorted_mm],
"ratio": round(r.circle_ratio, 3),
"uncertain": bool(ev.decision.uncertain),
"crop": crop,
})
with STATE.lock:
STATE.crop_cache.update(crop_updates)
crop_cache = dict(STATE.crop_cache)
# статус для веба: список объектов + «главный» (первый залоченный)
objects_json = []
primary = None
for tr in tracks:
d = tr.decision
m = tr.measurement
if d is None or m is None:
continue
is_locked = bool(d.locked and d.result is not None)
if is_locked:
obj = {
"id": tr.track_id,
"locked": True,
"uncertain": bool(d.uncertain),
"conf": 100,
"zone": d.result.category.zone,
"label": d.result.category.short_label,
"dims": [round(x, 1) for x in d.result.dims_sorted_mm],
"ratio": round(d.result.circle_ratio, 3),
"reason": d.result.reason,
"crop": crop_cache.get(tr.track_id),
}
else:
obj = {
"id": tr.track_id,
"locked": False,
"uncertain": False,
"conf": d.confidence_pct,
"zone": None,
"label": "анализ…",
"dims": [round(m.length_mm, 1), round(m.width_mm, 1), round(m.height_mm, 1)],
"ratio": round(m.circle_ratio, 3),
"reason": "",
"crop": None,
}
objects_json.append(obj)
if primary is None or (obj["locked"] and not primary["locked"]):
primary = obj
with STATE.lock:
STATE.objects = objects_json
STATE.stats = dict(tracker.stats)
if feed_add:
fresh = [it for it in feed_add if it["slot"] not in STATE.feed_slots]
for it in fresh:
STATE.feed_slots.add(it["slot"])
if fresh:
STATE.feed.extend(fresh)
STATE.feed = STATE.feed[-20:]
STATE.feed_seq += 1
if primary is not None:
STATE.confidence_now = primary["conf"]
STATE.locked = primary["locked"]
STATE.uncertain = primary["uncertain"]
STATE.zone = primary["zone"]
STATE.circle_ratio = primary["ratio"]
STATE.dims = tuple(primary["dims"])
STATE.reason = primary["reason"] or (
f"накопление {primary['conf']}% → ждём LOCK" if not primary["locked"] else ""
)
else:
STATE.confidence_now = 0
STATE.locked = False
STATE.uncertain = False
STATE.zone = None
STATE.circle_ratio = None
STATE.dims = None
STATE.reason = ""
hud = build_demo_frame(
base_img,
pair.depth_mm,
tracks,
belt_mm,
stats=tracker.stats,
confidence_pct=conf_pct,
rgb_available=rgb_available,
background_active=background is not None,
color_align=(0.0, 0.0, 1.0),
contour_smoother=contour_smoother,
)
ok, buf = cv2.imencode(".jpg", hud, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
if ok:
jpeg = buf.tobytes()
with STATE.lock:
STATE.jpeg = jpeg
if frame_i % 3 == 0:
out_jpg.write_bytes(jpeg)
if seg_n > len(measurements) and STATE.last_print != "seg_drop":
print(f"[demo] depth: контуров {seg_n}, измерено {len(measurements)} "
f"(часть отфильтрована: низкая высота < {hmin:.0f} мм или шум)")
STATE.last_print = "seg_drop"
elif not tracks and STATE.last_print != "empty":
print("[demo] объектов нет")
STATE.last_print = "empty"
elif tracks and STATE.last_print in ("seg_drop", "empty"):
STATE.last_print = ""
time.sleep(0.03)
except KeyboardInterrupt:
print("\n[demo] stop")
finally:
server.shutdown()
cam.release()
return 0
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main())

19
cv/demo.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
if [[ ! -f config.yaml && -f config.example.yaml ]]; then
cp config.example.yaml config.yaml
echo "[cv] created config.yaml from config.example.yaml (MQTT disabled)"
fi
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install -U pip
.venv/bin/pip install -r requirements.txt
fi
echo "Демо классификации (без моторов/серво)"
echo "Браузер: http://127.0.0.1:8080/"
exec .venv/bin/python demo.py "$@"

302
cv/demo_hud.py Normal file
View File

@@ -0,0 +1,302 @@
"""HUD демо: RGB-подложка + depth, несколько объектов с ID, кириллица через PIL."""
from __future__ import annotations
from functools import lru_cache
from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
ZONE_COLOR = { # BGR
"B": (40, 180, 40),
"C": (40, 40, 220),
"D": (0, 165, 255),
}
UNCERTAIN_COLOR = (0, 130, 250) # оранжевый
PENDING_COLOR = (0, 255, 255) # жёлтый — идёт накопление
_FONT_CANDIDATES = [
"/usr/share/fonts/noto/NotoSans-Bold.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
]
@lru_cache(maxsize=8)
def _font(size: int) -> ImageFont.FreeTypeFont:
for path in _FONT_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def _draw_texts(
img_bgr: np.ndarray,
texts: List[Tuple[int, int, str, Tuple[int, int, int], int]],
) -> np.ndarray:
"""texts: (x, y, строка, цвет BGR, размер). Кириллица через PIL."""
if not texts:
return img_bgr
pil = Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(pil)
for x, y, s, bgr, size in texts:
rgb = (bgr[2], bgr[1], bgr[0])
draw.text((x, y), s, font=_font(size), fill=rgb, stroke_width=2, stroke_fill=(0, 0, 0))
return cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR)
class DepthSmoother:
"""Временное сглаживание depth только для отображения (не для измерений)."""
def __init__(self, alpha: float = 0.25) -> None:
self.alpha = float(alpha)
self._acc: Optional[np.ndarray] = None
def update(self, depth_mm: np.ndarray) -> np.ndarray:
d = depth_mm.astype(np.float32)
if self._acc is None or self._acc.shape != d.shape:
self._acc = d.copy()
valid = d > 0
self._acc[valid] = (1.0 - self.alpha) * self._acc[valid] + self.alpha * d[valid]
out = self._acc.astype(np.uint16)
out[~valid & (self._acc <= 0)] = 0
return out
class ContourSmoother:
"""Стабильная окантовка: EMA маски по каждому треку + аппроксимация контура,
плюс «примагничивание» контура к краям объекта на RGB (снимает остаточный
параллакс depth↔color и распухание depth-маски).
Только для отрисовки — измерения идут по сырому контуру.
"""
def __init__(self, alpha: float = 0.3, snap_alpha: float = 0.35) -> None:
self.alpha = float(alpha)
self.snap_alpha = float(snap_alpha)
self._acc: Dict[int, np.ndarray] = {}
self._snap: Dict[int, Tuple[float, float, float]] = {} # tid -> (dx, dy, shrink)
def smooth(
self, track_id: int, mask: np.ndarray, edge_img: Optional[np.ndarray] = None
) -> Optional[np.ndarray]:
m = mask.astype(np.float32) / 255.0
acc = self._acc.get(track_id)
if acc is None or acc.shape != m.shape:
acc = m.copy()
else:
acc = (1.0 - self.alpha) * acc + self.alpha * m
self._acc[track_id] = acc
soft = cv2.GaussianBlur((acc * 255.0).astype(np.uint8), (11, 11), 0)
_, binm = cv2.threshold(soft, 127, 255, cv2.THRESH_BINARY)
contours, _ = cv2.findContours(binm, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
contour = max(contours, key=cv2.contourArea)
pts = contour.reshape(-1, 2).astype(np.float32)
if edge_img is not None and pts.shape[0] >= 8:
pts = self._snap_to_edges(track_id, pts, edge_img)
contour = pts.reshape(-1, 1, 2).astype(np.int32)
eps = 0.008 * cv2.arcLength(contour, True)
return cv2.approxPolyDP(contour, eps, True)
def _snap_to_edges(
self, track_id: int, pts: np.ndarray, edge: np.ndarray
) -> np.ndarray:
"""Локальный поиск сдвига (±14 px) и поджатия контура, при которых под
контуром максимум RGB-краёв. Найденная поправка сглаживается по времени."""
H, W = edge.shape[:2]
xs, ys = pts[:, 0], pts[:, 1]
def score(dx: float, dy: float, f: float, c: np.ndarray) -> float:
x = np.clip((c[0] + f * (xs - c[0]) + dx).astype(np.int32), 0, W - 1)
y = np.clip((c[1] + f * (ys - c[1]) + dy).astype(np.int32), 0, H - 1)
return float(edge[y, x].mean()) - 0.6 * float(np.hypot(dx, dy))
c = pts.mean(axis=0)
best_dx, best_dy, best_s = 0.0, 0.0, score(0, 0, 1.0, c)
for dy in range(-14, 15, 2):
for dx in range(-14, 15, 2):
s = score(dx, dy, 1.0, c)
if s > best_s:
best_s, best_dx, best_dy = s, float(dx), float(dy)
best_f = 1.0
# только сдвиг и лёгкое РАСШИРЕНИЕ — поджатие (f<1) отрезало часть объекта
for f in (1.0, 1.04, 1.08):
s = score(best_dx, best_dy, f, c)
if s > best_s:
best_s, best_f = s, f
prev = self._snap.get(track_id, (0.0, 0.0, 1.0))
a = self.snap_alpha
sm = (
(1 - a) * prev[0] + a * best_dx,
(1 - a) * prev[1] + a * best_dy,
max(1.0, (1 - a) * prev[2] + a * best_f),
)
self._snap[track_id] = sm
out = pts.copy()
out[:, 0] = c[0] + sm[2] * (xs - c[0]) + sm[0]
out[:, 1] = c[1] + sm[2] * (ys - c[1]) + sm[1]
return out
def drop_missing(self, alive_ids: set) -> None:
for tid in list(self._acc.keys()):
if tid not in alive_ids:
del self._acc[tid]
self._snap.pop(tid, None)
def align_color(color_bgr: np.ndarray, dx: float, dy: float, scale: float) -> np.ndarray:
"""Совмещение RGB с depth: сдвиг+масштаб (у D415 сенсоры разнесены)."""
if abs(dx) < 0.5 and abs(dy) < 0.5 and abs(scale - 1.0) < 1e-3:
return color_bgr
h, w = color_bgr.shape[:2]
M = np.float32([
[scale, 0, dx + (1.0 - scale) * w / 2.0],
[0, scale, dy + (1.0 - scale) * h / 2.0],
])
return cv2.warpAffine(color_bgr, M, (w, h), flags=cv2.INTER_LINEAR)
def build_demo_frame(
color_bgr: np.ndarray,
depth_mm: np.ndarray,
tracks: list, # List[tracker.Track]
belt_mm: float,
stats: Optional[Dict[str, int]] = None,
confidence_pct: int = 75,
rgb_available: bool = False,
background_active: bool = False,
color_align: Tuple[float, float, float] = (0.0, 0.0, 1.0),
contour_smoother: Optional[ContourSmoother] = None,
) -> np.ndarray:
from camera import depth_colormap
# RGB для веба; если цвет недоступен — colorize(depth), НЕ чёрный экран
if rgb_available and color_bgr is not None and float(np.std(color_bgr)) > 4.0:
base = color_bgr
if base.shape[:2] != depth_mm.shape[:2]:
base = cv2.resize(base, (depth_mm.shape[1], depth_mm.shape[0]))
base = align_color(base, color_align[0], color_align[1], color_align[2])
view = base.copy()
rgb_ok = True
else:
view = depth_colormap(depth_mm)
view = cv2.medianBlur(view, 3)
base = view
rgb_ok = False
h, w = view.shape[:2]
# мягкое поле RGB-краёв для «примагничивания» контуров
edge_field: Optional[np.ndarray] = None
if rgb_ok and contour_smoother is not None and tracks:
gray = cv2.cvtColor(base, cv2.COLOR_BGR2GRAY)
gray = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)).apply(gray)
edge_field = cv2.Canny(cv2.GaussianBlur(gray, (5, 5), 0), 40, 120)
edge_field = cv2.GaussianBlur(edge_field, (13, 13), 0)
texts: List[Tuple[int, int, str, Tuple[int, int, int], int]] = []
max_conf_pending = 0
all_locked = bool(tracks)
alive_ids = set()
for tr in tracks:
m = tr.measurement
d = tr.decision
if m is None or d is None:
continue
alive_ids.add(tr.track_id)
locked = d.locked and d.result is not None
if locked:
color = UNCERTAIN_COLOR if d.uncertain else ZONE_COLOR.get(d.result.category.zone, PENDING_COLOR)
else:
color = PENDING_COLOR
all_locked = False
max_conf_pending = max(max_conf_pending, d.confidence_pct)
# bbox / класс — по сырому контуру измерения; сглаживание только для окантовки «анализ»
raw_contour = m.contour
draw_contour = raw_contour
if not locked and contour_smoother is not None:
sm = contour_smoother.smooth(tr.track_id, m.mask, edge_img=edge_field)
if sm is not None:
draw_contour = sm
if locked:
bx, by, bw, bh = cv2.boundingRect(raw_contour)
# небольшой запас, чтобы рамка не обрезала края
pad = 4
bx, by = max(0, bx - pad), max(0, by - pad)
bw = min(w - bx, bw + 2 * pad)
bh = min(h - by, bh + 2 * pad)
cv2.rectangle(view, (bx, by), (bx + bw, by + bh), color, 2, lineType=cv2.LINE_AA)
cl = max(8, min(bw, bh) // 5)
for px, py, sx, sy in ((bx, by, 1, 1), (bx + bw, by, -1, 1),
(bx, by + bh, 1, -1), (bx + bw, by + bh, -1, -1)):
cv2.line(view, (px, py), (px + sx * cl, py), color, 4, lineType=cv2.LINE_AA)
cv2.line(view, (px, py), (px, py + sy * cl), color, 4, lineType=cv2.LINE_AA)
contour = raw_contour
else:
cv2.drawContours(view, [draw_contour], -1, color, 2, lineType=cv2.LINE_AA)
contour = draw_contour
ccx, ccy = contour.reshape(-1, 2).mean(axis=0)
cv2.circle(view, (int(ccx), int(ccy)), 4, (0, 0, 255), -1, lineType=cv2.LINE_AA)
x0, y0, _, _ = cv2.boundingRect(contour)
tx = int(np.clip(x0, 4, w - 220))
ty = int(np.clip(y0 - 46, 4, h - 46))
if locked:
label = d.result.category.short_label
if d.uncertain:
label = "НЕУВЕРЕННО" + label
texts.append((tx, ty, f"#{tr.track_id} {label}", color, 20))
else:
texts.append((tx, ty, f"#{tr.track_id} анализ… {d.confidence_pct}%", color, 20))
dims = d.result.dims_sorted_mm if (locked and d.result) else (m.length_mm, m.width_mm, m.height_mm)
ratio = d.result.circle_ratio if (locked and d.result) else m.circle_ratio
src = " · RGB" if getattr(m, "source", "depth") == "rgb" else ""
texts.append(
(tx, ty + 24, f"{dims[0]:.0f}×{dims[1]:.0f}×{dims[2]:.0f} мм · круг {ratio:.2f}{src}", (235, 235, 235), 15)
)
if contour_smoother is not None:
contour_smoother.drop_missing(alive_ids)
view = _draw_texts(view, texts)
# надписи — в отдельной полосе НАД кадром, чтобы не закрывать камеру
top_bar = np.full((36, w, 3), 18, np.uint8)
st = stats or {}
stats_line = (
f"ГОТОВ {st.get('B', 0)} · НЕГАБАРИТ {st.get('C', 0)} · ДОУПАК {st.get('D', 0)}"
+ (f" · неувер. {st['uncertain']}" if st.get("uncertain") else "")
)
bg_tag = " · фон:карта" if background_active else ""
left = "объектов нет" if not tracks else f"объектов: {len(alive_ids)}"
top_texts = [
(10, 6, left, (150, 150, 255) if not tracks else (200, 230, 200), 17),
(max(200, w - 440), 8, f"{stats_line} | h={belt_mm:.0f}мм{bg_tag}", (200, 230, 200), 14),
]
top_bar = _draw_texts(top_bar, top_texts)
view = np.vstack([top_bar, view])
h = view.shape[0]
# прогресс уверенности внизу
bar_y = h - 8
cv2.rectangle(view, (0, bar_y), (w, h), (40, 40, 40), -1)
conf_show = 100 if (all_locked and tracks) else max_conf_pending
fill = int(w * min(1.0, conf_show / 100.0))
col = (40, 200, 40) if (all_locked and tracks) else (0, 200, 255)
cv2.rectangle(view, (0, bar_y), (fill, h), col, -1)
thr = int(w * confidence_pct / 100.0)
cv2.line(view, (thr, bar_y), (thr, h), (255, 255, 255), 1)
return view

25
cv/docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
vision:
build: .
container_name: realsense_vision
restart: unless-stopped
network_mode: host
privileged: true
devices:
- /dev/video0:/dev/video0
- /dev/video1:/dev/video1
- /dev/video2:/dev/video2
- /dev/video3:/dev/video3
- /dev/video4:/dev/video4
- /dev/video5:/dev/video5
volumes:
# Copy config.example.yaml → config.yaml locally before enabling MQTT/hardware.
- ./config.yaml:/app/config.yaml:ro
- ./debug_frames:/app/debug_frames
# Prefer: command with --no-mqtt until credentials are configured.
group_add:
- video
environment:
- QT_X11_NO_MITSHM=1
# Без GUI в контейнере по умолчанию; превью — через native run
command: ["python", "main.py", "-c", "config.yaml"]

47
cv/journal.py Normal file
View File

@@ -0,0 +1,47 @@
"""JSONL-журнал решений классификатора — метрики для отчёта и защиты.
Каждая строка — одно зафиксированное решение (LOCK):
время, зона, габариты, circle_ratio, флаг «неуверенно», причина.
Анализ (корректность, доля неуверенных, распределение зон):
.venv/bin/python -c "
import json;
rows=[json.loads(l) for l in open('logs/decisions.jsonl')];
from collections import Counter;
print(Counter(r['zone'] for r in rows));
print('uncertain:', sum(r['uncertain'] for r in rows), '/', len(rows))"
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from classify import ClassificationResult
def append_decision(
path: str | Path,
result: ClassificationResult,
uncertain: bool = False,
source: str = "main",
track_id: int | None = None,
) -> None:
entry = {
"track_id": track_id,
"ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
"ts_ms": int(time.time() * 1000),
"zone": result.category.zone,
"category": result.category.value,
"label_ru": result.category.ru_label,
"dims_mm": [round(float(x), 1) for x in result.dims_sorted_mm],
"circle_ratio": round(float(result.circle_ratio), 4),
"uncertain": bool(uncertain),
"reason": result.reason,
"source": source,
}
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")

330
cv/main.py Executable file
View File

@@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""
Алгоритмическая часть трека 3: Intel RealSense D415 на Orange PI.
Пайплайн:
depth+color → сегментация объекта на ленте → габариты L×W×H + circle_ratio
→ классификация (B/C/D) → MQTT → сервоприводы Arduino (без правок arduino_code).
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional
import cv2
import yaml
from camera import RealSenseV4L2, depth_colormap
from classify import Category, ClassificationResult
from journal import append_decision
from measure import measure_flat_object, measure_object, segment_objects, segment_rgb_objects, is_plausible_measurement
from mqtt_bridge import MqttBridge
from stabilize import DecisionStabilizer
ZONE_TO_CATEGORY = {
"B": Category.SUITABLE,
"C": Category.OVERSIZE,
"D": Category.NEED_PACK,
}
def resolve_config_path(path: Path) -> Path:
if path.exists():
return path
example = path.with_name("config.example.yaml")
if example.exists():
return example
raise FileNotFoundError(f"Config not found: {path} (and no config.example.yaml)")
def load_config(path: Path) -> Dict[str, Any]:
resolved = resolve_config_path(Path(path))
with open(resolved, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def draw_overlay(
color_bgr,
depth_mm,
measurement,
result: Optional[ClassificationResult],
belt_mm: float,
):
vis = color_bgr.copy()
depth_vis = depth_colormap(depth_mm)
if measurement is not None:
cv2.drawContours(vis, [measurement.contour], -1, (0, 255, 0), 2)
cx, cy = measurement.centroid_px
cv2.circle(vis, (cx, cy), 4, (0, 0, 255), -1)
lines = [
f"L={measurement.length_mm:.0f} W={measurement.width_mm:.0f} H={measurement.height_mm:.0f} mm",
f"circle_ratio={measurement.circle_ratio:.3f}",
]
if result is not None:
lines.append(f"{result.category.zone}: {result.category.ru_label}")
y = 24
for line in lines:
cv2.putText(vis, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (20, 20, 20), 3, cv2.LINE_AA)
cv2.putText(vis, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 1, cv2.LINE_AA)
y += 22
cv2.putText(
vis,
f"belt={belt_mm:.0f}mm",
(10, vis.shape[0] - 12),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(200, 200, 200),
1,
cv2.LINE_AA,
)
return vis, depth_vis
def main() -> int:
parser = argparse.ArgumentParser(description="RealSense D415 classifier for hackathon track 3")
parser.add_argument(
"-c",
"--config",
default=str(Path(__file__).with_name("config.yaml")),
help="Путь к config.yaml",
)
parser.add_argument("--once", action="store_true", help="Один кадр и выход")
parser.add_argument("--no-mqtt", action="store_true", help="Не публиковать в MQTT")
parser.add_argument(
"--preview",
action="store_true",
help="Живое превью в debug_frames/live_*.jpg (без GTK-окон)",
)
parser.add_argument("--dry-route", action="store_true", help="Не двигать серво")
parser.add_argument("--no-motor", action="store_true", help="Не включать шаговик ленты")
args = parser.parse_args()
cfg = load_config(Path(args.config))
cam_cfg = cfg["camera"]
cls_cfg = cfg["classification"]
rt = cfg.get("runtime", {})
mqtt_cfg = dict(cfg.get("mqtt", {}))
routing_cfg = dict(cfg.get("routing", {}))
motor_cfg = dict(cfg.get("motor", {}))
if args.no_mqtt:
mqtt_cfg["enabled"] = False
if args.dry_route:
routing_cfg["enabled"] = False
if args.no_motor:
motor_cfg["enabled"] = False
mqtt_cfg["_routing"] = routing_cfg
mqtt_cfg["_motor"] = motor_cfg
show_preview = args.preview or bool(rt.get("show_preview", False))
save_debug = bool(rt.get("save_debug_frames", False)) or show_preview
debug_dir = Path(rt.get("debug_dir", "debug_frames"))
if save_debug or show_preview:
debug_dir.mkdir(parents=True, exist_ok=True)
live_color = debug_dir / "live_color.jpg"
live_depth = debug_dir / "live_depth.jpg"
preview_every = max(1, int(rt.get("preview_every_n", 3)))
print("[vision] открываю RealSense D415…")
cam = RealSenseV4L2(
depth_device=cam_cfg.get("depth_device", "/dev/video0"),
color_device=cam_cfg.get("color_device", "/dev/video4"),
width=int(cam_cfg.get("width", 640)),
height=int(cam_cfg.get("height", 480)),
fps=int(cam_cfg.get("fps", 30)),
depth_scale_mm=float(cam_cfg.get("depth_scale_mm", 1.0)),
use_color=bool(cfg.get("use_color", False)),
)
belt_mm = float(cfg.get("belt_distance_mm") or 0)
if belt_mm <= 0:
print("[vision] калибровка плоскости ленты (уберите объекты)…")
belt_mm = cam.estimate_belt_distance_mm()
print(f"[vision] belt_distance_mm ≈ {belt_mm:.1f}")
else:
print(f"[vision] belt_distance_mm из конфига: {belt_mm:.1f}")
background = None
color_background = None
if bool(cfg.get("use_background_map", False)):
print("[vision] снимаю фоновую карту сцены — лента должна быть ПУСТОЙ…")
try:
background = cam.capture_background(samples=15)
print("[vision] фоновая карта активна (сегментация относительно фона)")
except RuntimeError as exc:
print(f"[vision] фоновая карта не снята ({exc}), работаю по скалярной высоте")
color_background = cam.capture_background_rgb(samples=10)
if color_background is not None:
print("[vision] RGB-фон снят — плоские товары (телефон) будут детектироваться")
bridge = MqttBridge(mqtt_cfg)
print(f"[vision] MQTT: {'OK' if bridge.connected else 'offline/disabled'}")
if bridge.connected:
bridge.start_conveyor()
if show_preview:
print(f"[vision] превью → {live_color} и {live_depth} (обновляются на лету)")
print("[vision] откройте файлы в IDE/файловом менеджере или: eog debug_frames/live_color.jpg")
confirm_need = int(rt.get("confirm_frames", 8))
process_every_n = max(1, int(rt.get("process_every_n", 1)))
frame_i = 0
thr = float(cls_cfg.get("circle_ratio_threshold", 0.8))
fallback_zone = str(cls_cfg.get("uncertain_fallback_zone", "C")).upper()
stabilizer = DecisionStabilizer(
window=12,
confirm_frames=confirm_need,
lost_frames=12,
enter_circle=thr,
exit_circle=thr - 0.08,
uncertain_after=int(cls_cfg.get("uncertain_after_frames", 45)),
fallback=ZONE_TO_CATEGORY.get(fallback_zone, Category.OVERSIZE),
)
last_routed_zone: Optional[str] = None
decisions_log = Path(rt.get("decisions_log", "logs/decisions.jsonl"))
fx, fy = float(cam_cfg["fx"]), float(cam_cfg["fy"])
cx, cy = float(cam_cfg["cx"]), float(cam_cfg["cy"])
try:
while True:
pair = cam.read()
if pair is None:
print("[vision] нет кадра", file=sys.stderr)
time.sleep(0.05)
continue
frame_i += 1
measurement = None
result = None
if frame_i % process_every_n == 0:
candidates = segment_objects(
pair.depth_mm,
belt_distance_mm=belt_mm,
belt_tolerance_mm=float(cfg.get("belt_tolerance_mm", 25)),
min_object_height_mm=float(cfg.get("min_object_height_mm", 5)),
min_area_px=int(cfg.get("min_object_area_px", 800)),
background_mm=background,
max_objects=int(cfg.get("max_objects_in_frame", 3)),
roi_margin=cfg.get("roi_margin"),
)
seg = None
best = None # (score, mask, contour, measurement)
for mask, contour in candidates:
m_try = measure_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
min_object_height_mm=float(cfg.get("min_object_height_mm", 8)),
roi_margin=cfg.get("roi_margin"),
)
if m_try is None or not is_plausible_measurement(m_try):
continue
# приоритет: круглый и более высокий товар над шумом ленты
score = float(m_try.circle_ratio) * 2.0 + min(float(m_try.height_mm), 200.0) / 100.0
if best is None or score > best[0]:
best = (score, mask, contour, m_try)
if best is not None:
_, mask, contour, measurement = best
seg = (mask, contour)
else:
measurement = None
# depth ничего не видит → плоский товар (телефон) ищем по RGB
if (
measurement is None
and bool(cfg.get("detect_flat_rgb", False))
and color_background is not None
and not pair.color_is_depth_preview
):
rgb_objs = segment_rgb_objects(
pair.color_bgr,
color_background,
min_area_px=int(cfg.get("min_object_area_px", 800)),
diff_threshold=int(cfg.get("rgb_diff_threshold", 35)),
max_objects=1,
exclude_mask=seg[0] if seg is not None else None,
)
if rgb_objs:
mask, contour = rgb_objs[0]
measurement = measure_flat_object(
pair.depth_mm,
mask,
contour,
belt_distance_mm=belt_mm,
fx=fx,
fy=fy,
cx=cx,
cy=cy,
background_mm=background,
color_bgr=pair.color_bgr,
color_bg_bgr=color_background,
)
if measurement is not None and not is_plausible_measurement(measurement):
measurement = None
decision = stabilizer.update(
measurement,
min_mm=cls_cfg.get("min_mm", [10, 10, 10]),
max_mm=cls_cfg.get("max_mm", [450, 320, 320]),
)
if decision.locked and decision.result is not None:
result = decision.result
zone = result.category.zone
if zone != last_routed_zone:
tag = "UNCERTAIN→" if decision.uncertain else "LOCK "
print(
f"[vision] {tag}{zone} | {result.category.ru_label} | "
f"dims={result.dims_sorted_mm} | ratio={result.circle_ratio:.3f} | {result.reason}"
)
append_decision(decisions_log, result, uncertain=decision.uncertain, source="main")
bridge.publish_result(result)
bridge.route(result.category)
last_routed_zone = zone
elif not decision.present:
last_routed_zone = None
if show_preview or save_debug:
vis, depth_vis = draw_overlay(pair.color_bgr, pair.depth_mm, measurement, result, belt_mm)
if save_debug and result is not None and not show_preview:
out = debug_dir / f"frame_{frame_i:06d}_{result.category.value}.jpg"
cv2.imwrite(str(out), vis)
# headless OpenCV: пишем JPEG вместо cv2.imshow
if show_preview and frame_i % preview_every == 0:
cv2.imwrite(str(live_color), vis)
cv2.imwrite(str(live_depth), depth_vis)
if args.once:
if result is not None:
print(result)
if show_preview:
vis, depth_vis = draw_overlay(pair.color_bgr, pair.depth_mm, measurement, result, belt_mm)
cv2.imwrite(str(live_color), vis)
cv2.imwrite(str(live_depth), depth_vis)
print(f"[vision] кадр сохранён: {live_color}")
break
except KeyboardInterrupt:
print("\n[vision] stop")
finally:
bridge.close()
cam.release()
return 0
if __name__ == "__main__":
# Чтобы импорты работали и как пакет, и как скрипт
sys.path.insert(0, str(Path(__file__).resolve().parent))
raise SystemExit(main())

719
cv/measure.py Normal file
View File

@@ -0,0 +1,719 @@
"""Сегментация и измерение по ТЗ трека 3 — без эвристик «подкрутки»."""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Tuple
import cv2
import numpy as np
@dataclass
class ObjectMeasurement:
length_mm: float
width_mm: float
height_mm: float
circle_ratio: float # итог для классификатора: max устойчивых сечений
area_px: int
centroid_px: Tuple[int, int]
contour: np.ndarray
mask: np.ndarray
top_ratio: float = 0.0 # rin/rout вида сверху
section_ratio: float = 0.0 # лучший устойчивый 3D-срез
source: str = "depth" # "depth" | "rgb" (плоский товар, найден по RGB)
clipped_by_frame: bool = False # объект упирается в край кадра → габарит неполный
def is_plausible_measurement(
m: ObjectMeasurement,
min_footprint_mm: float = 15.0,
) -> bool:
"""Отсев шума depth/RGB до классификации (не путать с ТЗ-негабаритом).
Мелкие пятна и «0×0×0 мм» не должны попадать в трекер — иначе
check_size(<10 мм) даёт ложный класс C.
"""
L, W, H = float(m.length_mm), float(m.width_mm), float(m.height_mm)
if L <= 1.5 or W <= 1.5:
return False
if int(m.area_px) < 120:
return False
a, b, c = sorted((L, W, H), reverse=True)
if a < min_footprint_mm:
return False
if c <= 0.5:
return False
# RGB-шум: нет высоты и крошечное пятно на ленте
if getattr(m, "source", "depth") == "rgb" and H < 2.0 and a < 45.0:
return False
return True
def mask_iou(a: np.ndarray, b: np.ndarray) -> float:
"""Классический IoU масок."""
inter = int(np.count_nonzero((a > 0) & (b > 0)))
if inter == 0:
return 0.0
ua = int(np.count_nonzero(a > 0))
ub = int(np.count_nonzero(b > 0))
return inter / float(ua + ub - inter)
def mask_overlap_min(a: np.ndarray, b: np.ndarray) -> float:
"""Доля пересечения относительно меньшей маски (0..1).
≥0.5 ≈ «хотя бы половина одного объекта лежит на другом».
Удобнее IoU, когда кусок намного меньше целого.
"""
inter = int(np.count_nonzero((a > 0) & (b > 0)))
if inter == 0:
return 0.0
ua = int(np.count_nonzero(a > 0))
ub = int(np.count_nonzero(b > 0))
return inter / float(max(1, min(ua, ub)))
def merge_overlapping_masks(
items: List[Tuple[np.ndarray, np.ndarray]],
overlap_thr: float = 0.5,
near_gap_px: int = 14,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Склеить контуры при IoU/пересечении ≥ thr или узкой дыре depth (near_gap)."""
if len(items) <= 1:
return items
items = sorted(items, key=lambda ic: cv2.contourArea(ic[1]), reverse=True)
used = [False] * len(items)
out: List[Tuple[np.ndarray, np.ndarray]] = []
def _near(a: np.ndarray, b: np.ndarray) -> bool:
xa, ya, wa, ha = cv2.boundingRect(a)
xb, yb, wb, hb = cv2.boundingRect(b)
g = int(near_gap_px)
return not (
xa + wa + g < xb or xb + wb + g < xa or ya + ha + g < yb or yb + hb + g < ya
)
for i, (mask_i, _) in enumerate(items):
if used[i]:
continue
merged = mask_i.copy()
used[i] = True
changed = True
while changed:
changed = False
for j, (mask_j, _) in enumerate(items):
if used[j]:
continue
hit = (
mask_overlap_min(merged, mask_j) >= overlap_thr
or mask_iou(merged, mask_j) >= overlap_thr
or _near(merged, mask_j)
)
if hit:
merged = cv2.bitwise_or(merged, mask_j)
used[j] = True
changed = True
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
merged = cv2.morphologyEx(merged, cv2.MORPH_CLOSE, k, iterations=1)
contours, _ = cv2.findContours(merged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
continue
contour = max(contours, key=cv2.contourArea)
clean = np.zeros_like(merged)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
out.append((clean, contour))
return out
def merge_overlapping_measurements(
measurements: List[ObjectMeasurement],
overlap_thr: float = 0.5,
) -> List[ObjectMeasurement]:
"""Склеить измерения с пересекающимися масками (оставить более крупное)."""
if len(measurements) <= 1:
return measurements
ms = sorted(measurements, key=lambda m: m.area_px, reverse=True)
kept: List[ObjectMeasurement] = []
for m in ms:
drop = False
for k in kept:
if m.mask.shape != k.mask.shape:
continue
if mask_overlap_min(m.mask, k.mask) >= overlap_thr or mask_iou(m.mask, k.mask) >= overlap_thr:
drop = True
break
if not drop:
kept.append(m)
return kept
def apply_roi_margin(
mask: np.ndarray,
margin: Optional[dict] = None,
) -> np.ndarray:
"""Обнулить края кадра (ролики, борта, плата), доли 0..1 от H/W."""
if not margin:
return mask
h, w = mask.shape[:2]
top = int(h * float(margin.get("top", 0)))
bottom = int(h * float(margin.get("bottom", 0)))
left = int(w * float(margin.get("left", 0)))
right = int(w * float(margin.get("right", 0)))
out = mask.copy()
if top > 0:
out[:top, :] = 0
if bottom > 0:
out[h - bottom :, :] = 0
if left > 0:
out[:, :left] = 0
if right > 0:
out[:, w - right :] = 0
return out
def contour_touches_border(
contour: np.ndarray,
shape: Tuple[int, ...],
margin_px: int = 3,
roi_margin: Optional[dict] = None,
) -> bool:
"""True если контур упирается в край кадра/ROI — реальный размер может быть больше."""
h, w = int(shape[0]), int(shape[1])
top = int(h * float((roi_margin or {}).get("top", 0)))
bottom = int(h * float((roi_margin or {}).get("bottom", 0)))
left = int(w * float((roi_margin or {}).get("left", 0)))
right = int(w * float((roi_margin or {}).get("right", 0)))
y0, y1 = top + margin_px, h - bottom - 1 - margin_px
x0, x1 = left + margin_px, w - right - 1 - margin_px
pts = contour.reshape(-1, 2)
xs, ys = pts[:, 0], pts[:, 1]
return bool(
(xs <= x0).any()
or (xs >= x1).any()
or (ys <= y0).any()
or (ys >= y1).any()
)
def segment_objects(
depth_mm: np.ndarray,
belt_distance_mm: float,
belt_tolerance_mm: float = 25.0,
min_object_height_mm: float = 5.0,
min_area_px: int = 800,
background_mm: Optional[np.ndarray] = None,
max_objects: int = 3,
roi_margin: Optional[dict] = None,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Все объекты в кадре (крупнейшие первыми), до max_objects штук.
Порог высоты — как раньше (строгий): мягкий «ореол» раздувал маску на ленту
и ломал габариты/круг → путаница B/C/D.
"""
valid = (depth_mm > 50) & (depth_mm < 5000)
hmin = float(min_object_height_mm)
if background_mm is not None:
bg = background_mm.astype(np.float32)
d = depth_mm.astype(np.float32)
raised = valid & (bg > 50) & (d < bg - hmin)
near_belt_band = d > (bg - 520.0)
else:
raised = valid & (depth_mm < (belt_distance_mm - hmin))
near_belt_band = depth_mm > (belt_distance_mm - 450)
mask = (raised & near_belt_band).astype(np.uint8) * 255
mask = apply_roi_margin(mask, roi_margin)
k_open = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
k_close = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open, iterations=1)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
frame_area = mask.shape[0] * mask.shape[1]
raw: List[Tuple[np.ndarray, np.ndarray]] = []
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
area = cv2.contourArea(contour)
# крупные товары могут занимать почти весь кадр — не отсекать как шум
if area < min_area_px or area > frame_area * 0.92:
continue
clean = np.zeros_like(mask)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
raw.append((clean, contour))
# только явное пересечение / узкая щель — не склеивать соседние товары
merged = merge_overlapping_masks(raw, overlap_thr=0.5, near_gap_px=14)
return merged[:max_objects]
def segment_object(
depth_mm: np.ndarray,
belt_distance_mm: float,
belt_tolerance_mm: float = 25.0,
min_object_height_mm: float = 5.0,
min_area_px: int = 800,
background_mm: Optional[np.ndarray] = None,
roi_margin: Optional[dict] = None,
) -> Optional[Tuple[np.ndarray, np.ndarray]]:
"""Крупнейший объект (для main.py/calibrate.py — один товар в накопителе)."""
objs = segment_objects(
depth_mm,
belt_distance_mm,
belt_tolerance_mm=belt_tolerance_mm,
min_object_height_mm=min_object_height_mm,
min_area_px=min_area_px,
background_mm=background_mm,
max_objects=1,
roi_margin=roi_margin,
)
return objs[0] if objs else None
def _pixel_to_xy_mm(
u: float, v: float, z_mm: float, fx: float, fy: float, cx: float, cy: float
) -> Tuple[float, float]:
return (u - cx) * z_mm / fx, (v - cy) * z_mm / fy
def measure_object(
depth_mm: np.ndarray,
mask: np.ndarray,
contour: np.ndarray,
belt_distance_mm: float,
fx: float,
fy: float,
cx: float,
cy: float,
background_mm: Optional[np.ndarray] = None,
min_object_height_mm: float = 5.0,
roi_margin: Optional[dict] = None,
) -> Optional[ObjectMeasurement]:
ys, xs = np.where(mask > 0)
if xs.size < 50:
return None
z_vals = depth_mm[ys, xs].astype(np.float32)
z_vals = z_vals[z_vals > 0]
if z_vals.size < 50:
return None
z_med = float(np.median(z_vals))
# высота относительно локального фона (платформа/лента под объектом)
belt_local = belt_distance_mm
if background_mm is not None:
bg_vals = background_mm[ys, xs].astype(np.float32)
bg_vals = bg_vals[bg_vals > 50]
if bg_vals.size >= 50:
belt_local = float(np.median(bg_vals))
height_mm = max(0.0, belt_local - z_med)
pts_mm = []
for p in contour.reshape(-1, 2):
u, v = float(p[0]), float(p[1])
x, y = _pixel_to_xy_mm(u, v, z_med, fx, fy, cx, cy)
pts_mm.append([x, y])
pts_mm = np.asarray(pts_mm, dtype=np.float32)
if pts_mm.shape[0] < 5:
return None
rect = cv2.minAreaRect(pts_mm.reshape(-1, 1, 2))
rw, rh = rect[1]
length_mm = float(max(rw, rh))
width_mm = float(min(rw, rh))
touches_edge = contour_touches_border(contour, mask.shape, roi_margin=roi_margin)
# Негабарит «не влезает в кадр» только если реально занимает большую долю FOV.
# Лежачая бутылка может чуть касаться ROI — это не повод форсировать 500 мм.
span_x = float(xs.max() - xs.min())
span_y = float(ys.max() - ys.min())
mh = float((roi_margin or {}).get("top", 0.0)) + float((roi_margin or {}).get("bottom", 0.0))
mw = float((roi_margin or {}).get("left", 0.0)) + float((roi_margin or {}).get("right", 0.0))
usable_w = mask.shape[1] * max(0.5, 1.0 - mw)
usable_h = mask.shape[0] * max(0.5, 1.0 - mh)
spans_frame = (span_x >= 0.72 * usable_w) or (span_y >= 0.72 * usable_h)
clipped = bool(touches_edge and spans_frame)
# отсев шума ленты / руки на краю (низкий «холм» большой площади → не товар)
# но не отсекаем крупные обрезанные объекты — они уйдут в C
if not clipped and height_mm < 30.0 and max(length_mm, width_mm) > 150.0:
return None
# жёсткий пол — иначе складки ленты дают ложный C
if height_mm < max(20.0, float(min_object_height_mm)):
return None
# раньше >520 отбрасывали → ложный B на негабарите в FOV;
# оставляем измерение: classify отправит в C (>450)
if max(length_mm, width_mm) > 2000.0 and not clipped:
return None
# объект не помещается в кадр → габарит занижен; форсируем > max ТЗ
if clipped:
length_mm = max(length_mm, 500.0)
top_ratio = rin_rout(pts_mm)
section_ratio = 0.0
cloud = _point_cloud(depth_mm, mask, fx, fy, cx, cy)
if cloud is not None:
section_ratio = robust_section_ratio(cloud)
# ТЗ: круг в любом сечении. Один шумный 3D-срез не считаем:
# D только если top>=0.8 ИЛИ ≥2 среза >=0.8 (внутри robust_section_ratio).
circle_ratio = max(top_ratio, section_ratio)
if clipped:
# обрезанный негабарит не классифицируем по кругу
circle_ratio = min(circle_ratio, 0.5)
m = cv2.moments(contour)
if m["m00"] > 0:
cx_px = int(m["m10"] / m["m00"])
cy_px = int(m["m01"] / m["m00"])
else:
cx_px, cy_px = int(xs.mean()), int(ys.mean())
return ObjectMeasurement(
length_mm=length_mm,
width_mm=width_mm,
height_mm=height_mm,
circle_ratio=float(circle_ratio),
area_px=int(xs.size),
centroid_px=(cx_px, cy_px),
contour=contour,
mask=mask,
top_ratio=float(top_ratio),
section_ratio=float(section_ratio),
clipped_by_frame=bool(clipped),
)
def segment_rgb_objects(
color_bgr: np.ndarray,
color_bg_bgr: np.ndarray,
min_area_px: int = 800,
diff_threshold: int = 35,
max_objects: int = 3,
exclude_mask: Optional[np.ndarray] = None,
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""Плоские товары (телефон и т.п.) по разнице с RGB-фоном пустой ленты.
exclude_mask — зоны, уже найденные по depth (не дублируем объекты).
Тени (пропорциональное затемнение каналов) отбрасываются.
"""
if color_bgr.shape != color_bg_bgr.shape:
return []
fg = color_bgr.astype(np.float32)
bg = color_bg_bgr.astype(np.float32)
gray = np.max(np.abs(fg - bg), axis=2).astype(np.uint8)
_, m = cv2.threshold(gray, int(diff_threshold), 255, cv2.THRESH_BINARY)
ratio = (fg + 8.0) / (bg + 8.0)
r_med = np.median(ratio, axis=2)
r_spread = np.max(ratio, axis=2) - np.min(ratio, axis=2)
is_shadow = (r_med < 0.93) & (r_med > 0.38) & (r_spread < 0.14)
m[is_shadow] = 0
k = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k, iterations=1)
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k, iterations=2)
if exclude_mask is not None:
excl = cv2.dilate(exclude_mask, cv2.getStructuringElement(cv2.MORPH_RECT, (31, 31)))
m[excl > 0] = 0
contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out: List[Tuple[np.ndarray, np.ndarray]] = []
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
if cv2.contourArea(contour) < min_area_px or len(out) >= max_objects:
break
clean = np.zeros_like(m)
cv2.drawContours(clean, [contour], -1, 255, thickness=-1)
out.append((clean, contour))
return out
def measure_flat_object(
depth_mm: np.ndarray,
mask: np.ndarray,
contour: np.ndarray,
belt_distance_mm: float,
fx: float,
fy: float,
cx: float,
cy: float,
background_mm: Optional[np.ndarray] = None,
color_bgr: Optional[np.ndarray] = None,
color_bg_bgr: Optional[np.ndarray] = None,
) -> Optional[ObjectMeasurement]:
"""Измерение товара, найденного по RGB: без отсева по минимальной высоте."""
ys, xs = np.where(mask > 0)
if xs.size < 50:
return None
z_plane = belt_distance_mm
if background_mm is not None:
bg_vals = background_mm[ys, xs].astype(np.float32)
bg_vals = bg_vals[bg_vals > 50]
if bg_vals.size >= 50:
z_plane = float(np.median(bg_vals))
z_vals = depth_mm[ys, xs].astype(np.float32)
z_vals = z_vals[z_vals > 0]
height_mm = max(0.0, z_plane - float(np.median(z_vals))) if z_vals.size >= 50 else 0.0
pts_mm = []
for p in contour.reshape(-1, 2):
x, y = _pixel_to_xy_mm(float(p[0]), float(p[1]), z_plane, fx, fy, cx, cy)
pts_mm.append([x, y])
pts_mm = np.asarray(pts_mm, dtype=np.float32)
if pts_mm.shape[0] < 3:
return None
rect = cv2.minAreaRect(pts_mm.reshape(-1, 1, 2))
rw, rh = rect[1]
length_mm = float(max(rw, rh))
width_mm = float(min(rw, rh))
if max(length_mm, width_mm) > 520.0 or max(length_mm, width_mm) < 5.0:
return None
top_ratio = rin_rout(pts_mm)
m = cv2.moments(contour)
if m["m00"] > 0:
cx_px, cy_px = int(m["m10"] / m["m00"]), int(m["m01"] / m["m00"])
else:
cx_px, cy_px = int(xs.mean()), int(ys.mean())
return ObjectMeasurement(
length_mm=length_mm,
width_mm=width_mm,
height_mm=float(height_mm),
circle_ratio=float(top_ratio),
area_px=int(xs.size),
centroid_px=(cx_px, cy_px),
contour=contour,
mask=mask,
top_ratio=float(top_ratio),
section_ratio=0.0,
source="rgb",
)
def rin_rout(pts_xy: np.ndarray) -> float:
"""ТЗ: r_in / r_out по выпуклой оболочке сечения."""
pts = np.asarray(pts_xy, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] < 3:
return 0.0
hull = cv2.convexHull(pts.reshape(-1, 1, 2))
hull_pts = hull.reshape(-1, 2)
if hull_pts.shape[0] < 3:
return 0.0
(_center, r_out) = cv2.minEnclosingCircle(hull)
r_out = float(r_out)
if r_out < 1e-6:
return 0.0
r_in = _inscribed_radius_mm(hull_pts)
if r_in <= 0:
return 0.0
return float(np.clip(r_in / r_out, 0.0, 1.0))
def robust_section_ratio(cloud: np.ndarray, thr: float = 0.8) -> float:
"""
Поперечные срезы. Из логов: у круга часто 12 среза ≥0.85, иногда медиана падает.
- ≥1 срез с score≥0.85 → принимаем (уверенный круг/дуга);
- иначе ≥2 среза ≥0.8 → max;
- иначе медиана (антишум для коробки).
"""
ratios = _section_ratios_3d(cloud)
if not ratios:
return 0.0
very = [r for r in ratios if r >= 0.85]
if very:
return float(max(very))
strong = [r for r in ratios if r >= thr]
if len(strong) >= 2:
return float(max(strong))
return float(np.median(ratios))
def _point_cloud(
depth_mm: np.ndarray,
mask: np.ndarray,
fx: float,
fy: float,
cx: float,
cy: float,
) -> Optional[np.ndarray]:
ys, xs = np.where(mask > 0)
if xs.size < 150:
return None
z = depth_mm[ys, xs].astype(np.float32)
ok = (z > 50) & (z < 5000)
xs, ys, z = xs[ok], ys[ok], z[ok]
if xs.size < 150:
return None
# детерминированный даунсэмпл (без random)
if xs.size > 4000:
step = int(np.ceil(xs.size / 4000))
xs, ys, z = xs[::step], ys[::step], z[::step]
X = (xs.astype(np.float32) - cx) * z / fx
Y = (ys.astype(np.float32) - cy) * z / fy
return np.column_stack([X, Y, z]).astype(np.float32)
def _section_ratios_3d(cloud: np.ndarray) -> List[float]:
mean = cloud.mean(axis=0)
centered = cloud - mean
try:
_u, s, vt = np.linalg.svd(centered, full_matrices=False)
except np.linalg.LinAlgError:
return []
out: List[float] = []
# только вдоль самой длинной оси — поперечные сечения цилиндра/коробки
for axis_i in range(min(1, vt.shape[0])):
if float(s[axis_i]) < 1e-6:
continue
axis = vt[axis_i]
axis = axis / (np.linalg.norm(axis) + 1e-9)
along = centered @ axis
ref = np.array([0.0, 0.0, 1.0], dtype=np.float32)
if abs(float(np.dot(axis, ref))) > 0.9:
ref = np.array([1.0, 0.0, 0.0], dtype=np.float32)
u = np.cross(axis, ref)
u /= np.linalg.norm(u) + 1e-9
v = np.cross(axis, u)
a0, a1 = float(np.percentile(along, 15)), float(np.percentile(along, 85))
if a1 - a0 < 10.0:
continue
for t in (0.2, 0.35, 0.5, 0.65, 0.8):
ca = a0 + t * (a1 - a0)
half = max(4.0, 0.06 * (a1 - a0))
band = np.abs(along - ca) <= half
if int(band.sum()) < 40:
continue
pts = centered[band]
sec = np.column_stack([pts @ u, pts @ v]).astype(np.float32)
out.append(_section_score(sec))
return out
def _section_score(sec: np.ndarray) -> float:
"""
Чистый rin/rout. Для дуги лежачего цилиндра (depth видит полкруга)
допускаем score 0.85 только при жёстком circle-fit:
малый residual, почти равные радиусы, покрытие ≥200°, bbox не «палка».
Прямоугольное сечение fit не проходит → остаётся rin/rout < 0.8.
"""
direct = rin_rout(sec)
if direct >= 0.8:
return float(direct)
fit = _fit_circle_arc(sec)
if fit is None:
return float(direct)
return float(max(direct, 0.85))
def _fit_circle_arc(pts: np.ndarray) -> Optional[Tuple[np.ndarray, float]]:
pts = np.asarray(pts, dtype=np.float64).reshape(-1, 2)
if pts.shape[0] < 35:
return None
c0 = pts.mean(axis=0)
x0 = pts - c0
try:
_u, s, _vt = np.linalg.svd(x0, full_matrices=False)
except np.linalg.LinAlgError:
return None
if s.shape[0] < 2 or float(s[0]) < 1e-6:
return None
# сечение не должно быть линией
if float(s[1] / (s[0] + 1e-9)) < 0.45:
return None
x, y = pts[:, 0], pts[:, 1]
A = np.column_stack([2 * x, 2 * y, np.ones_like(x)])
b = x * x + y * y
try:
sol, *_ = np.linalg.lstsq(A, b, rcond=None)
except np.linalg.LinAlgError:
return None
cx_, cy_, c = sol
r2 = c + cx_ * cx_ + cy_ * cy_
if r2 <= 1.0:
return None
r = float(np.sqrt(r2))
rad = np.sqrt((x - cx_) ** 2 + (y - cy_) ** 2)
rel = float(np.sqrt(np.mean((rad - r) ** 2)) / (r + 1e-9))
rad_cv = float(rad.std() / (rad.mean() + 1e-9))
if rel > 0.04 or rad_cv > 0.04:
return None
bw = float(x.max() - x.min())
bh = float(y.max() - y.min())
aspect = max(bw, bh) / max(min(bw, bh), 1e-6)
# у круга/полукруга bbox близок к квадрату; у прямоугольника 2:1 — нет
if aspect > 1.45:
return None
if r < 0.40 * max(bw, bh) or r > 0.70 * max(bw, bh):
return None
ang = np.arctan2(y - cy_, x - cx_)
ang = np.sort(ang)
gaps = np.diff(ang)
gaps = np.append(gaps, ang[0] + 2 * np.pi - ang[-1])
coverage = float(2 * np.pi - gaps.max())
if coverage < np.deg2rad(200.0):
return None
return np.array([cx_, cy_], dtype=np.float32), r
def _inscribed_radius_mm(pts_xy_mm: np.ndarray, grid: int = 192) -> float:
x_min, y_min = pts_xy_mm.min(axis=0)
x_max, y_max = pts_xy_mm.max(axis=0)
span = max(float(x_max - x_min), float(y_max - y_min), 1.0)
pad = 8
inner = grid - 2 * pad
if inner < 16:
return 0.0
scale = inner / span
img = np.zeros((grid, grid), dtype=np.uint8)
pts_px = ((pts_xy_mm - np.array([x_min, y_min], dtype=np.float32)) * scale).astype(np.int32)
pts_px[:, 0] = np.clip(pts_px[:, 0] + pad, 0, grid - 1)
pts_px[:, 1] = np.clip(pts_px[:, 1] + pad, 0, grid - 1)
cv2.fillPoly(img, [pts_px], 255)
if img.max() == 0 or float((img > 0).mean()) > 0.98:
return 0.0
dist = cv2.distanceTransform(img, cv2.DIST_L2, 5)
return float(dist.max()) / scale
def circularity_ratio(pts_xy_mm: np.ndarray) -> float:
return rin_rout(pts_xy_mm)
# совместимость со старыми вызовами
def max_section_circle_ratio(
depth_mm: np.ndarray,
mask: np.ndarray,
top_pts_mm: np.ndarray,
fx: float,
fy: float,
cx: float,
cy: float,
) -> float:
top = rin_rout(top_pts_mm)
cloud = _point_cloud(depth_mm, mask, fx, fy, cx, cy)
sec = robust_section_ratio(cloud) if cloud is not None else 0.0
return float(max(top, sec))
def section_rin_rout(sec: np.ndarray) -> float:
return rin_rout(sec)

172
cv/mqtt_bridge.py Normal file
View File

@@ -0,0 +1,172 @@
"""MQTT: публикация категории + команды серво/мотору (существующий API Arduino)."""
from __future__ import annotations
import json
import threading
import time
from typing import Any, Dict, Optional
import paho.mqtt.client as mqtt
from classify import Category, ClassificationResult
class MqttBridge:
def __init__(self, cfg: Dict[str, Any]) -> None:
self.cfg = cfg
self.enabled = bool(cfg.get("enabled", False))
self.routing_cfg = cfg.get("_routing", {})
self.motor_cfg = cfg.get("_motor", {})
self._last_route_ts = 0.0
self._last_category: Optional[str] = None
self.client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=cfg.get("client_id", "vision_classifier_opi"),
)
user = cfg.get("user")
password = cfg.get("password")
if user:
self.client.username_pw_set(user, password)
self._connected = False
if self.enabled:
try:
self.client.connect(cfg["broker"], int(cfg.get("port", 1883)), 30)
self.client.loop_start()
# короткая проверка
time.sleep(0.3)
self._connected = True
print(f"[mqtt] подключено к {cfg['broker']}:{cfg.get('port', 1883)}")
except Exception as exc:
print(f"[mqtt] не удалось подключиться: {exc}")
self._connected = False
@property
def connected(self) -> bool:
return self._connected
def start_conveyor(self) -> None:
"""Включить шаговик ленты через уже существующие топики motor/control/*."""
m = self.motor_cfg
if not m.get("enabled", False):
return
if not self.enabled or not self._connected:
return
rpm = int(m.get("rpm", 200))
current = int(m.get("current_percent", 50))
microsteps = int(m.get("microsteps", 16))
self.client.publish("motor/control/driver", "on", qos=1)
self.client.publish("motor/control/tmc/enable", "on", qos=1)
self.client.publish("motor/control/tmc/current_percent", str(current), qos=1)
self.client.publish("motor/control/tmc/microsteps", str(microsteps), qos=1)
if m.get("stealthchop", True):
self.client.publish("motor/control/tmc/stealthchop", "on", qos=1)
self.client.publish("motor/control/rpm", str(rpm), qos=1)
print(f"[mqtt] конвейер: driver ON, rpm={rpm}")
def stop_conveyor(self) -> None:
m = self.motor_cfg
if not m.get("enabled", False):
return
if not self.enabled or not self._connected:
return
self.client.publish("motor/control/rpm", "0", qos=1)
if m.get("disable_on_stop", False):
self.client.publish("motor/control/driver", "off", qos=1)
print("[mqtt] конвейер: rpm=0")
def publish_result(self, result: ClassificationResult) -> None:
if not self.enabled or not self._connected:
return
l, w, h = result.dims_sorted_mm
self.client.publish(
self.cfg.get("topic_result", "vision/feedback/category"),
result.category.value,
qos=1,
)
self.client.publish(
self.cfg.get("topic_dims", "vision/feedback/dimensions"),
f"{l:.1f},{w:.1f},{h:.1f}",
qos=0,
)
self.client.publish(
self.cfg.get("topic_circle", "vision/feedback/circle_ratio"),
f"{result.circle_ratio:.4f}",
qos=0,
)
payload = {
"category": result.category.value,
"zone": result.category.zone,
"label_ru": result.category.ru_label,
"dims_mm": [round(l, 1), round(w, 1), round(h, 1)],
"circle_ratio": round(result.circle_ratio, 4),
"reason": result.reason,
}
self.client.publish(
self.cfg.get("topic_debug", "vision/feedback/debug"),
json.dumps(payload, ensure_ascii=False),
qos=0,
)
def route(self, category: Category) -> None:
"""Отправка команды серво через servo/control/{ch}/angle|enable."""
routing = self.routing_cfg
if not routing.get("enabled", True):
return
if not self.enabled or not self._connected:
return
now = time.time() * 1000.0
cooldown = float(routing.get("cooldown_ms", 1500))
if category.value == self._last_category and (now - self._last_route_ts) < cooldown:
return
zones = routing.get("zones", {})
zone_key = category.zone
zone = zones.get(zone_key)
if not zone:
return
for zk, zcfg in zones.items():
ch = int(zcfg["servo"])
idle = int(zcfg.get("idle_angle", 0))
if zk == zone_key:
continue
self._set_servo(ch, idle, enable=True)
ch = int(zone["servo"])
divert = int(zone.get("divert_angle", 90))
idle = int(zone.get("idle_angle", 0))
hold_ms = int(zone.get("hold_ms", 800))
if category == Category.SUITABLE and divert == idle:
self._set_servo(ch, idle, enable=True)
print(f"[mqtt] зона B — пропуск (servo {ch} idle)")
else:
self._set_servo(ch, divert, enable=True)
print(f"[mqtt] зона {zone_key} — divert servo {ch}{divert}°")
def _return_idle(channel: int = ch, angle: int = idle, delay_s: float = hold_ms / 1000.0) -> None:
time.sleep(delay_s)
self._set_servo(channel, angle, enable=True)
threading.Thread(target=_return_idle, daemon=True).start()
self._last_category = category.value
self._last_route_ts = now
def _set_servo(self, channel: int, angle: int, enable: bool = True) -> None:
base = f"servo/control/{channel}"
self.client.publish(f"{base}/enable", "on" if enable else "off", qos=1)
self.client.publish(f"{base}/angle", str(int(angle)), qos=1)
def close(self) -> None:
try:
self.stop_conveyor()
except Exception:
pass
if self._connected:
self.client.loop_stop()
self.client.disconnect()

6
cv/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
opencv-python-headless>=4.8
numpy>=1.24
paho-mqtt>=2.0
PyYAML>=6.0
pillow>=10.0 # кириллица на HUD (demo_hud.py)
# ffmpeg должен быть в системе (pacman/apt: ffmpeg) — depth Z16 читается через него

17
cv/run.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
if [[ ! -f config.yaml && -f config.example.yaml ]]; then
cp config.example.yaml config.yaml
echo "[cv] created config.yaml from config.example.yaml (MQTT disabled)"
fi
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install -U pip
.venv/bin/pip install -r requirements.txt
fi
exec .venv/bin/python main.py "$@"

306
cv/stabilize.py Normal file
View File

@@ -0,0 +1,306 @@
"""
Стабильный консенсус по кадрам.
Из логов RealSense: круг даёт sec≈0.820.87, но early LOCK на B
залипал навсегда. Поэтому:
• классификация по медиане окна;
• LOCK после прогрева;
• апгрейд B→D при устойчивом круге (≥ confirm кадров подряд);
• D→B и смена зоны после LOCK запрещены (пока объект не исчез).
"""
from __future__ import annotations
from collections import Counter, deque
from dataclasses import dataclass
from typing import Deque, Optional, Sequence
from classify import Category, ClassificationResult, check_size
from measure import ObjectMeasurement
@dataclass
class StableDecision:
result: Optional[ClassificationResult]
locked: bool
confidence_pct: int
ratio_smooth: float
present: bool
uncertain: bool = False # LOCK по правилу «нет консенсуса → безопасная зона»
class DecisionStabilizer:
def __init__(
self,
window: int = 12,
confirm_frames: int = 8,
lost_frames: int = 12,
enter_circle: float = 0.80,
exit_circle: float = 0.72,
uncertain_after: int = 45,
fallback: Category = Category.OVERSIZE,
) -> None:
self.window = max(5, int(window))
self.confirm_frames = max(3, int(confirm_frames))
self.lost_frames = max(3, int(lost_frames))
self.enter_circle = float(enter_circle)
self.exit_circle = float(exit_circle)
# нет консенсуса за uncertain_after кадров → безопасная зона (ТЗ:
# неоднозначные товары не должны идти в основной поток)
self.uncertain_after = max(self.window + 5, int(uncertain_after))
self.fallback = fallback
self._ratios: Deque[float] = deque(maxlen=self.window)
self._tops: Deque[float] = deque(maxlen=self.window)
self._secs: Deque[float] = deque(maxlen=self.window)
self._Ls: Deque[float] = deque(maxlen=self.window)
self._Ws: Deque[float] = deque(maxlen=self.window)
self._Hs: Deque[float] = deque(maxlen=self.window)
self._zones: Deque[str] = deque(maxlen=self.window)
self._pending_zone: Optional[str] = None
self._pending_count: int = 0
self._upgrade_count: int = 0
self._locked: Optional[ClassificationResult] = None
self._miss: int = 0
self._frames_seen: int = 0
self._uncertain_locked: bool = False
def reset(self) -> None:
self._ratios.clear()
self._tops.clear()
self._secs.clear()
self._Ls.clear()
self._Ws.clear()
self._Hs.clear()
self._zones.clear()
self._pending_zone = None
self._pending_count = 0
self._upgrade_count = 0
self._locked = None
self._miss = 0
self._frames_seen = 0
self._uncertain_locked = False
def update(
self,
measurement: Optional[ObjectMeasurement],
min_mm: Sequence[float] = (10, 10, 10),
max_mm: Sequence[float] = (450, 320, 320),
) -> StableDecision:
if measurement is None:
self._miss += 1
if self._miss >= self.lost_frames:
self.reset()
return StableDecision(None, False, 0, 0.0, False)
if self._locked is not None:
return StableDecision(
self._locked, True, 100, self._locked.circle_ratio, True,
uncertain=self._uncertain_locked,
)
return StableDecision(None, False, 0, 0.0, False)
self._miss = 0
self._frames_seen += 1
self._ratios.append(float(measurement.circle_ratio))
self._tops.append(float(getattr(measurement, "top_ratio", measurement.circle_ratio)))
self._secs.append(float(getattr(measurement, "section_ratio", 0.0)))
self._Ls.append(float(measurement.length_mm))
self._Ws.append(float(measurement.width_mm))
self._Hs.append(float(measurement.height_mm))
ratio = _median(self._ratios)
top_m = _median(self._tops)
sec_m = _median(self._secs)
ratio_p75 = _percentile(self._ratios, 75)
# устойчивый круг: медиана >= 0.8 ИЛИ (медиана сечений >= 0.8 и ≥ половины окна сильные)
sec_strong = (
sum(1 for x in self._secs if x >= self.enter_circle) / max(1, len(self._secs))
)
circular = ratio >= self.enter_circle or (
sec_m >= self.enter_circle and sec_strong >= 0.55
)
ratio_show = max(ratio, sec_m) if circular else ratio
L, W, H = _median(self._Ls), _median(self._Ws), _median(self._Hs)
dims = tuple(sorted([L, W, H], reverse=True))
passes = check_size(dims, min_mm, max_mm)
clipped = bool(getattr(measurement, "clipped_by_frame", False))
if clipped:
# неполный габарит из-за края кадра → безопасный негабарит
passes = False
if not passes:
instant = ClassificationResult(
category=Category.OVERSIZE,
dims_sorted_mm=(dims[0], dims[1], dims[2]),
circle_ratio=ratio_show,
passes_size=False,
is_circular=circular,
reason=(
"объект обрезан краем кадра → габарит неполный, считаем негабаритом"
if clipped
else "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм"
),
)
elif circular:
instant = ClassificationResult(
category=Category.NEED_PACK,
dims_sorted_mm=(dims[0], dims[1], dims[2]),
circle_ratio=ratio_show,
passes_size=True,
is_circular=True,
reason=(
f"круг: med={ratio:.3f} p75={ratio_p75:.3f} "
f"top={top_m:.3f} sec={sec_m:.3f} strong={sec_strong:.0%} "
f">= {self.enter_circle}"
),
)
else:
instant = ClassificationResult(
category=Category.SUITABLE,
dims_sorted_mm=(dims[0], dims[1], dims[2]),
circle_ratio=ratio_show,
passes_size=True,
is_circular=False,
reason=(
f"не круг: med={ratio:.3f} sec={sec_m:.3f} "
f"strong={sec_strong:.0%} < {self.enter_circle}"
),
)
zone = instant.category.zone
self._zones.append(zone)
# --- уже есть LOCK ---
if self._locked is not None:
prev = self._locked.category
cur = instant.category
locked_dims = self._locked.dims_sorted_mm
# новый объект (габариты сильно сменились) — сброс LOCK и набор заново
if _dims_changed(locked_dims, dims, rel=0.28):
self._locked = None
self._uncertain_locked = False
self._pending_zone = zone
self._pending_count = 1
self._upgrade_count = 0
self._frames_seen = 1
conf = int(min(99, round(100.0 * 1 / max(1, self.confirm_frames))))
return StableDecision(instant, False, conf, ratio_show, True)
can_upgrade = (
(prev == Category.OVERSIZE and cur in (Category.SUITABLE, Category.NEED_PACK))
or (prev == Category.SUITABLE and cur == Category.NEED_PACK)
)
if can_upgrade:
self._upgrade_count += 1
need_up = max(5, self.confirm_frames // 2)
if self._upgrade_count >= need_up:
self._locked = instant
self._upgrade_count = 0
self._uncertain_locked = False # появился консенсус
else:
self._upgrade_count = 0
return StableDecision(
self._locked, True, 100, ratio_show, True,
uncertain=self._uncertain_locked,
)
# нет консенсуса слишком долго → «неуверенно», безопасная зона
if self._frames_seen >= self.uncertain_after:
fallback_result = ClassificationResult(
category=self.fallback,
dims_sorted_mm=(dims[0], dims[1], dims[2]),
circle_ratio=ratio_show,
passes_size=passes,
is_circular=circular,
reason="НЕУВЕРЕННО → безопасная зона: " + self._uncertain_reason(
ratio, dims, min_mm, max_mm
),
)
self._locked = fallback_result
self._uncertain_locked = True
return StableDecision(fallback_result, True, 100, ratio_show, True, uncertain=True)
# прогрев окна
if len(self._ratios) < self.window:
conf = int(min(99, round(100.0 * len(self._ratios) / self.window)))
return StableDecision(instant, False, conf, ratio_show, True)
votes = Counter(self._zones)
winner, win_n = votes.most_common(1)[0]
if winner != zone:
self._pending_zone = None
self._pending_count = 0
conf = int(round(100.0 * win_n / len(self._zones)))
return StableDecision(instant, False, conf, ratio_show, True)
need = max(self.confirm_frames, (self.window * 2 + 2) // 3)
if zone == self._pending_zone:
self._pending_count += 1
else:
self._pending_zone = zone
self._pending_count = 1
conf = int(min(100, round(100.0 * max(self._pending_count, win_n) / need)))
if self._pending_count >= need and win_n >= need:
self._locked = instant
self._uncertain_locked = False
return StableDecision(instant, True, 100, ratio_show, True)
return StableDecision(instant, False, conf, ratio_show, True)
def _uncertain_reason(
self,
ratio: float,
dims: Sequence[float],
min_mm: Sequence[float],
max_mm: Sequence[float],
) -> str:
votes = Counter(self._zones)
parts = [
f"нет консенсуса {self._frames_seen} кадров",
"голоса " + " ".join(f"{z}:{n}" for z, n in votes.most_common()),
]
if abs(ratio - self.enter_circle) <= 0.06:
parts.append(f"ratio {ratio:.3f} у порога {self.enter_circle}")
max_s = sorted([float(x) for x in max_mm], reverse=True)
min_s = sorted([float(x) for x in min_mm], reverse=True)
for d, mx, mn in zip(dims, max_s, min_s):
if abs(d - mx) <= 0.05 * mx:
parts.append(f"сторона {d:.0f} мм у лимита {mx:.0f}")
elif mn > 0 and abs(d - mn) <= max(3.0, 0.3 * mn):
parts.append(f"сторона {d:.0f} мм у минимума {mn:.0f}")
return "; ".join(parts)
def _median(vals: Deque[float]) -> float:
return _percentile(vals, 50)
def _dims_changed(
a: Sequence[float], b: Sequence[float], rel: float = 0.28
) -> bool:
"""True если хотя бы одна сторона изменилась больше чем на rel (новый объект)."""
if len(a) < 3 or len(b) < 3:
return False
for x, y in zip(a[:3], b[:3]):
base = max(abs(float(x)), abs(float(y)), 1.0)
if abs(float(x) - float(y)) / base > rel:
return True
return False
def _percentile(vals: Deque[float], q: float) -> float:
arr = sorted(vals)
n = len(arr)
if n == 0:
return 0.0
if n == 1:
return float(arr[0])
pos = (q / 100.0) * (n - 1)
lo = int(pos)
hi = min(lo + 1, n - 1)
frac = pos - lo
return float(arr[lo] * (1 - frac) + arr[hi] * frac)

86
cv/test_classify.py Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Юнит-тесты правил классификации (без камеры)."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from classify import Category, classify_from_dims
def test_suitable_box():
r = classify_from_dims(120, 80, 40, circle_ratio=0.5)
assert r.category == Category.SUITABLE
assert r.category.zone == "B"
def test_oversize_priority_over_circle():
# Большой цилиндр: габариты важнее круга
r = classify_from_dims(500, 100, 100, circle_ratio=0.95)
assert r.category == Category.OVERSIZE
assert r.category.zone == "C"
def test_too_small():
r = classify_from_dims(5, 5, 5, circle_ratio=0.2)
assert r.category == Category.OVERSIZE
def test_need_pack_cylinder():
r = classify_from_dims(100, 50, 50, circle_ratio=0.85)
assert r.category == Category.NEED_PACK
assert r.category.zone == "D"
def test_lying_bottle_must_be_D_not_B():
"""ТЗ: круг в ЛЮБОМ сечении. Лежачая бутылка сверху не круг, но сечение круглое."""
# имитация: top-view низкий, но итоговый circle_ratio после 3D-срезов высокий
r = classify_from_dims(220, 70, 70, circle_ratio=0.86)
assert r.category == Category.NEED_PACK
assert r.category.zone == "D"
def test_border_circle():
r = classify_from_dims(100, 50, 50, circle_ratio=0.8)
assert r.category == Category.NEED_PACK
r2 = classify_from_dims(100, 50, 50, circle_ratio=0.799)
assert r2.category == Category.SUITABLE
def test_border_dims_strict():
"""ТЗ: строго больше 10×10×10 и строго меньше 450×320×320."""
# ровно на максимуме → C
assert classify_from_dims(450, 320, 320, 0.3).category == Category.OVERSIZE
# ровно на минимуме → C
assert classify_from_dims(10, 10, 10, 0.3).category == Category.OVERSIZE
# чуть внутри границ → B
assert classify_from_dims(449, 319, 319, 0.3).category == Category.SUITABLE
assert classify_from_dims(11, 11, 11, 0.3).category == Category.SUITABLE
# 321 мм влезает вдоль оси 450 → B (сопоставление после сортировки)
assert classify_from_dims(100, 321, 100, 0.3).category == Category.SUITABLE
# а вот две стороны > 320 уже не влезают → C
assert classify_from_dims(400, 330, 100, 0.3).category == Category.OVERSIZE
def test_dims_order_independent():
"""Стороны сопоставляются после сортировки — порядок L/W/H не важен."""
assert classify_from_dims(319, 449, 318, 0.3).category == Category.SUITABLE
assert classify_from_dims(318, 319, 449, 0.3).category == Category.SUITABLE
# ровно 320 по строгому правилу «меньше» → C, в любом порядке
assert classify_from_dims(320, 449, 319, 0.3).category == Category.OVERSIZE
assert classify_from_dims(319, 320, 449, 0.3).category == Category.OVERSIZE
if __name__ == "__main__":
test_suitable_box()
test_oversize_priority_over_circle()
test_too_small()
test_need_pack_cylinder()
test_lying_bottle_must_be_D_not_B()
test_border_circle()
test_border_dims_strict()
test_dims_order_independent()
print("OK: all classification tests passed")

384
cv/test_geometry.py Normal file
View File

@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""Геометрические тесты: прямоугольник → B, круг → D (без камеры)."""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import cv2
sys.path.insert(0, str(Path(__file__).resolve().parent))
from measure import (
ObjectMeasurement,
_fit_circle_arc,
_section_score,
measure_object,
rin_rout,
section_rin_rout,
segment_object,
)
from stabilize import DecisionStabilizer
def _circle(n=64, r=40.0):
a = np.linspace(0, 2 * np.pi, n, endpoint=False)
return np.column_stack([50 + r * np.cos(a), 50 + r * np.sin(a)]).astype(np.float32)
def _rect(w=150.0, h=90.0, n=20):
pts = (
[[x, 0] for x in np.linspace(0, w, n)]
+ [[w, y] for y in np.linspace(0, h, n)]
+ [[x, h] for x in np.linspace(w, 0, n)]
+ [[0, y] for y in np.linspace(h, 0, n)]
)
return np.array(pts, dtype=np.float32)
def _round_rect(w=150.0, h=90.0, rad=8.0, n=12, ne=15):
pts = []
pts += [[x, 0] for x in np.linspace(rad, w - rad, ne)]
pts += [[w, y] for y in np.linspace(rad, h - rad, ne)]
pts += [[x, h] for x in np.linspace(w - rad, rad, ne)]
pts += [[0, y] for y in np.linspace(h - rad, rad, ne)]
corners = [
(rad, rad, np.pi, 1.5 * np.pi),
(w - rad, rad, 1.5 * np.pi, 2 * np.pi),
(w - rad, h - rad, 0, 0.5 * np.pi),
(rad, h - rad, 0.5 * np.pi, np.pi),
]
for cx, cy, a0, a1 in corners:
for a in np.linspace(a0, a1, n):
pts.append([cx + rad * np.cos(a), cy + rad * np.sin(a)])
return np.array(pts, dtype=np.float32)
def _arc(span_deg=252.0, r=35.0, n=80):
a0 = -np.deg2rad(span_deg) / 2
a1 = np.deg2rad(span_deg) / 2
a = np.linspace(a0, a1, n)
return np.column_stack([50 + r * np.cos(a), 50 + r * np.sin(a)]).astype(np.float32)
def test_geometry_ratios():
assert rin_rout(_circle()) >= 0.8, "круг сверху должен быть D"
assert rin_rout(_rect()) < 0.8, "прямоугольник должен быть B"
assert rin_rout(_round_rect()) < 0.8, "скруглённый прямоугольник — B"
# квадрат < 0.8 (теоретически ~0.707)
sq = np.array([[0, 0], [100, 0], [100, 100], [0, 100]], dtype=np.float32)
assert rin_rout(sq) < 0.8
# дуга цилиндра: fit → 0.85
arc = _arc()
score = _section_score(arc)
assert score >= 0.8, f"дуга цилиндра должна давать >=0.8, got {score}"
assert _fit_circle_arc(_rect()) is None, "прямоугольник не должен проходить circle-fit"
def test_stabilizer_box_vs_cylinder():
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
# коробка с редкими ложными пиками → LOCK B
s = DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
locked_zone = None
seq = [0.55, 0.84, 0.56, 0.58, 0.57, 0.59, 0.55, 0.56, 0.58, 0.57, 0.55, 0.56, 0.57, 0.58]
for r in seq:
m = ObjectMeasurement(120, 80, 40, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.5)
d = s.update(m)
if d.locked and d.result:
locked_zone = d.result.category.zone
assert locked_zone == "B", f"коробка должна LOCK B, got {locked_zone}"
# цилиндр → LOCK D
s2 = DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
locked_zone = None
for r in [0.90] * 16:
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.88)
d = s2.update(m)
if d.locked and d.result:
locked_zone = d.result.category.zone
assert locked_zone == "D", f"цилиндр должен LOCK D, got {locked_zone}"
# после LOCK D не прыгаем в B
for r in [0.4] * 10:
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.4)
d = s2.update(m)
assert d.locked and d.result and d.result.category.zone == "D"
# early B, then устойчивый круг → апгрейд в D (те же габариты)
s3 = DecisionStabilizer(window=8, confirm_frames=6, enter_circle=0.8)
for r in [0.55] * 20:
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=0.5)
d = s3.update(m)
assert d.locked and d.result.category.zone == "B"
for r in [0.86] * 16:
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=0.72, section_ratio=0.86)
d = s3.update(m)
assert d.locked and d.result.category.zone == "D", f"ожидали апгрейд B→D, got {d.result.category.zone}"
# смена объекта круг→коробка по габаритам → новый LOCK B
saw_reset = False
locked_b = False
for r in [0.45] * 25:
m = ObjectMeasurement(240, 120, 50, r, 1000, (1, 1), dummy, mask, top_ratio=0.45, section_ratio=0.27)
d = s3.update(m)
if d.present and not d.locked:
saw_reset = True
if d.locked and d.result and d.result.category.zone == "B":
locked_b = True
assert saw_reset, "при смене габаритов должен быть сброс LOCK"
assert locked_b, "коробка после смены должна LOCK B"
def test_background_map_splits_object_from_platform():
"""Цилиндр на платформе: скалярная высота сливает их, фоновая карта — нет."""
H, W = 480, 640
belt = 600
bg = np.full((H, W), belt, np.uint16)
bg[100:340, 130:470] = belt - 80 # платформа 80 мм — часть фона
depth = bg.copy()
yy, xx = np.ogrid[:H, :W]
circ = (yy - 220) ** 2 + (xx - 300) ** 2 <= 70**2
depth[circ] = belt - 80 - 60 # круглый предмет 60 мм на платформе
# старый способ: платформа+цилиндр в одном контуре (большая площадь)
seg_old = segment_object(depth, belt_distance_mm=belt, min_area_px=400)
assert seg_old is not None
area_old = int((seg_old[0] > 0).sum())
assert area_old > 240 * 340 * 0.8, "скалярный способ должен захватить платформу"
# с фоновой картой: только цилиндр
seg_bg = segment_object(depth, belt_distance_mm=belt, min_area_px=400, background_mm=bg)
assert seg_bg is not None
mask, contour = seg_bg
area = int((mask > 0).sum())
circle_area = np.pi * 70 * 70
assert abs(area - circle_area) / circle_area < 0.15, f"площадь {area} vs круг {circle_area:.0f}"
m = measure_object(
depth, mask, contour,
belt_distance_mm=belt, fx=670, fy=670, cx=320, cy=240,
background_mm=bg,
)
assert m is not None
assert abs(m.height_mm - 60) < 8, f"высота от платформы должна быть ~60, got {m.height_mm:.1f}"
assert m.top_ratio >= 0.8, f"вид сверху круг, got {m.top_ratio:.3f}"
def test_uncertain_fallback_to_safe_zone():
"""Нет консенсуса (ratio скачет у порога) → «неуверенно» → безопасная зона C."""
from classify import Category
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
s = DecisionStabilizer(window=8, confirm_frames=6, uncertain_after=25, fallback=Category.OVERSIZE)
seq = ([0.70] * 6 + [0.90] * 6) * 5 # блоками вокруг порога 0.8
final = None
for i, r in enumerate(seq):
m = ObjectMeasurement(100, 50, 50, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=r)
d = s.update(m)
if d.locked:
final = (i + 1, d)
break
assert final is not None, "fallback должен сработать"
n_frames, d = final
assert d.uncertain, "LOCK должен быть помечен как неуверенный"
assert d.result is not None and d.result.category.zone == "C"
assert "НЕУВЕРЕННО" in d.result.reason
assert n_frames <= 30, f"fallback должен сработать около 25 кадров, got {n_frames}"
# уверенная коробка не должна помечаться «неуверенно»
s2 = DecisionStabilizer(window=8, confirm_frames=6, uncertain_after=25)
got = None
for r in [0.5] * 20:
m = ObjectMeasurement(120, 80, 40, r, 1000, (1, 1), dummy, mask, top_ratio=r, section_ratio=r)
d2 = s2.update(m)
if d2.locked:
got = d2
break
assert got is not None and not got.uncertain
assert got.result is not None and got.result.category.zone == "B"
def test_tracker_two_objects_ids_and_stats():
"""Коробка + цилиндр одновременно: два ID, статистика считает каждого один раз."""
from tracker import MultiObjectTracker
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
def factory():
return DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
t = MultiObjectTracker(factory, max_dist_px=120, lost_frames=8)
all_events = []
for _ in range(20):
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
top_ratio=0.55, section_ratio=0.5)
cyl = ObjectMeasurement(100, 50, 50, 0.90, 900, (500, 300), dummy, mask,
top_ratio=0.90, section_ratio=0.88)
tracks, events = t.update([box, cyl])
all_events.extend(events)
assert len(tracks) == 2, f"должно быть 2 трека, got {len(tracks)}"
ids = sorted(tr.track_id for tr in tracks)
assert ids == [1, 2], f"ID должны быть 1 и 2, got {ids}"
zones = sorted(ev.decision.result.category.zone for ev in all_events)
assert zones == ["B", "D"], f"события LOCK для B и D, got {zones}"
assert t.stats["B"] == 1 and t.stats["D"] == 1 and t.stats["total"] == 2
# объекты убрали → треки умирают (залоченные живут дольше), статистика остаётся
for _ in range(40):
tracks, _ = t.update([])
assert not tracks
assert t.stats["total"] == 2
# тот же товар на том же месте вернулся → без нового LOCK и без роста счётчика
events_back = []
for _ in range(15):
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
top_ratio=0.55, section_ratio=0.5)
tracks, events = t.update([box])
events_back.extend(events)
assert not events_back, "повторный захват уже учтённого товара не должен давать LOCK"
assert tracks and tracks[0].track_id == 1
assert tracks[0].frozen is not None and tracks[0].decision.locked
assert t.stats["B"] == 1 and t.stats["total"] == 2
# новый объект в другом месте получает следующий ID
box2 = ObjectMeasurement(200, 100, 60, 0.5, 1200, (300, 200), dummy, mask,
top_ratio=0.5, section_ratio=0.4)
for _ in range(20):
tracks, events = t.update([box2])
assert tracks and any(tr.track_id == 3 for tr in tracks)
assert t.stats["B"] == 2 and t.stats["total"] == 3
def test_tracker_slot_dedup_same_object():
"""Тот же товар на том же месте с новым track_id — без повторного LOCK."""
from tracker import MultiObjectTracker, slot_key
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
def factory():
return DecisionStabilizer(window=8, confirm_frames=5, enter_circle=0.8)
t = MultiObjectTracker(factory, max_dist_px=120, lost_frames=8)
box = ObjectMeasurement(120, 80, 40, 0.55, 1000, (100, 100), dummy, mask,
top_ratio=0.55, section_ratio=0.5)
all_events = []
for _ in range(20):
tracks, events = t.update([box])
all_events.extend(events)
assert all_events, "первый LOCK должен быть"
sk = slot_key(100, 100, 120, 80, 40, "B")
assert sk in t._seen_slots
# новый track_id, та же позиция — события нет, счётчик не растёт
for _ in range(40):
tracks, _ = t.update([])
box2 = ObjectMeasurement(118, 82, 41, 0.56, 1000, (102, 98), dummy, mask,
top_ratio=0.56, section_ratio=0.5)
extra = []
for _ in range(20):
tracks, events = t.update([box2])
extra.extend(events)
assert not extra, "повтор того же слота не должен давать LOCK"
assert t.stats["total"] == 1
def test_shadow_not_detected_as_object():
"""Тень на ленте (затемнение без смены цвета) не должна давать RGB-объект."""
from measure import segment_rgb_objects
H, W = 480, 640
bg = np.full((H, W, 3), (90, 130, 160), np.uint8) # коричневая лента BGR
color = bg.copy()
# мягкая тень: все каналы ×0.55 — типичная тень от коробки
color[150:300, 200:420] = (bg[150:300, 200:420].astype(np.float32) * 0.55).astype(np.uint8)
objs = segment_rgb_objects(color, bg, min_area_px=400, diff_threshold=35)
assert not objs, f"тень не должна детектироваться, got {len(objs)}"
def test_merge_overlapping_halves():
"""Две половины одного объекта с пересечением ≥50% → один контур."""
from measure import merge_overlapping_masks, mask_overlap_min
H, W = 100, 100
a = np.zeros((H, W), np.uint8)
b = np.zeros((H, W), np.uint8)
a[20:60, 20:60] = 255 # 40×40
b[20:60, 35:75] = 255 # перекрытие 40×25 = 1000 / 1600 = 0.625
assert mask_overlap_min(a, b) >= 0.5
ca, _ = cv2.findContours(a, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cb, _ = cv2.findContours(b, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
merged = merge_overlapping_masks([(a, ca[0]), (b, cb[0])], overlap_thr=0.5)
assert len(merged) == 1, f"ожидали 1 объект, got {len(merged)}"
def test_noise_measurement_rejected():
"""Шум 0×0×0 / мелкие RGB-пятна не должны классифицироваться как негабарит."""
from measure import ObjectMeasurement, is_plausible_measurement
dummy = np.zeros((10, 1, 2), np.int32)
mask = np.zeros((10, 10), np.uint8)
noise = ObjectMeasurement(0.0, 0.0, 0.0, 0.1, 80, (10, 10), dummy, mask, source="rgb")
assert not is_plausible_measurement(noise)
speckle = ObjectMeasurement(12.0, 8.0, 0.0, 0.2, 500, (50, 50), dummy, mask, source="rgb")
assert not is_plausible_measurement(speckle)
ok = ObjectMeasurement(110.0, 70.0, 28.0, 0.6, 1200, (100, 100), dummy, mask)
assert is_plausible_measurement(ok)
def test_flat_phone_via_rgb():
"""Телефон 8 мм: depth не видит (порог 12 мм), RGB-фон находит → класс C."""
from classify import Category, classify
from measure import measure_flat_object, segment_rgb_objects
H, W = 480, 640
belt = 534
bg_color = np.full((H, W, 3), 120, np.uint8) # серая лента
color = bg_color.copy()
x0, y0, pw, ph = 250, 180, 172, 80 # ≈160×75 мм при fx=564, z=534
color[y0 : y0 + ph, x0 : x0 + pw] = (30, 30, 30) # тёмный телефон
depth = np.full((H, W), belt, np.uint16)
depth[y0 : y0 + ph, x0 : x0 + pw] = belt - 8 # всего 8 мм над лентой
rng = np.random.default_rng(0)
depth = (depth.astype(np.int32) + rng.integers(-3, 4, size=depth.shape)).astype(np.uint16)
objs = segment_rgb_objects(color, bg_color, min_area_px=400)
assert objs, "телефон должен найтись по RGB-фону"
mask, contour = objs[0]
m = measure_flat_object(
depth, mask, contour,
belt_distance_mm=belt, fx=564.0, fy=564.0, cx=320, cy=240,
background_mm=np.full((H, W), belt, np.uint16),
)
assert m is not None and m.source == "rgb"
assert abs(m.length_mm - 160) < 15, f"длина ~160, got {m.length_mm:.0f}"
assert abs(m.width_mm - 75) < 12, f"ширина ~75, got {m.width_mm:.0f}"
assert m.height_mm < 12, f"высота должна быть маленькой, got {m.height_mm:.0f}"
r = classify(m)
assert r.category == Category.OVERSIZE, "тоньше 10 мм → C по ТЗ (меньше минимума)"
if __name__ == "__main__":
test_geometry_ratios()
test_stabilizer_box_vs_cylinder()
test_background_map_splits_object_from_platform()
test_uncertain_fallback_to_safe_zone()
test_tracker_two_objects_ids_and_stats()
test_tracker_slot_dedup_same_object()
test_noise_measurement_rejected()
test_merge_overlapping_halves()
test_shadow_not_detected_as_object()
test_flat_phone_via_rgb()
print("OK: geometry + stabilizer tests passed")

279
cv/tracker.py Normal file
View File

@@ -0,0 +1,279 @@
"""Трекинг нескольких объектов в кадре: ID + статистика зон за сессию.
Сопоставление кадр-к-кадру по ближайшему центроиду. На каждый трек —
свой DecisionStabilizer. LOCK в ленту/MQTT — один раз на товар: после
фиксации запоминаем «отпечаток» (центр+габариты); если depth кратковременно
пропал и объект нашёлся снова рядом — трек возрождается без нового LOCK.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional, Sequence, Tuple
from classify import ClassificationResult
from measure import ObjectMeasurement
from stabilize import DecisionStabilizer, StableDecision
@dataclass
class Track:
track_id: int
stabilizer: DecisionStabilizer
centroid: Tuple[int, int]
miss: int = 0
counted_zone: Optional[str] = None
counted_uncertain: bool = False
measurement: Optional[ObjectMeasurement] = None
decision: Optional[StableDecision] = None
reported: bool = False # уже ушёл в ленту — повторно не пишем
frozen: Optional[StableDecision] = None # снимок первого LOCK (для возрождения)
@dataclass
class LockEvent:
track_id: int
decision: StableDecision
@dataclass
class _Fingerprint:
"""Товар уже зафиксирован — не считаем повторно при перезахвате."""
track_id: int
cx: int
cy: int
length_mm: float
width_mm: float
height_mm: float
zone: str
uncertain: bool
result: ClassificationResult
age: int = 0 # кадров без детекции
def slot_key(cx: int, cy: int, L: float, W: float, H: float, zone: str) -> str:
"""Стабильный ключ товара на ленте — один физический предмет = одна запись."""
a = sorted((L, W, H))
return f"{cx // 35}_{cy // 35}_{int(a[0] // 12)}_{int(a[1] // 12)}_{int(a[2] // 8)}_{zone}"
class MultiObjectTracker:
def __init__(
self,
stabilizer_factory: Callable[[], DecisionStabilizer],
max_dist_px: int = 120,
lost_frames: int = 12,
fingerprint_ttl: int = 450, # ~1520 с помнить «уже учтён»
) -> None:
self._factory = stabilizer_factory
self.max_dist_px = int(max_dist_px)
self.lost_frames = int(lost_frames)
self.fingerprint_ttl = int(fingerprint_ttl)
self._tracks: Dict[int, Track] = {}
self._fps: List[_Fingerprint] = []
self._seen_slots: Dict[str, _Fingerprint] = {} # уже учтённые товары (позиция+габариты)
self._next_id = 1
self.stats: Dict[str, int] = {"B": 0, "C": 0, "D": 0, "total": 0, "uncertain": 0}
def reset(self) -> None:
self._tracks.clear()
self._fps.clear()
def update(
self,
measurements: List[ObjectMeasurement],
min_mm: Sequence[float] = (10, 10, 10),
max_mm: Sequence[float] = (450, 320, 320),
) -> Tuple[List[Track], List[LockEvent]]:
free_meas = list(range(len(measurements)))
assigned: Dict[int, int] = {}
pairs = []
for tid, tr in self._tracks.items():
for mi in free_meas:
m = measurements[mi]
d2 = (tr.centroid[0] - m.centroid_px[0]) ** 2 + (tr.centroid[1] - m.centroid_px[1]) ** 2
pairs.append((d2, tid, mi))
for d2, tid, mi in sorted(pairs):
if tid in assigned or mi not in free_meas:
continue
if d2 > self.max_dist_px**2:
continue
assigned[tid] = mi
free_meas.remove(mi)
events: List[LockEvent] = []
for tid in list(self._tracks.keys()):
tr = self._tracks[tid]
# залоченный трек держим дольше — глянец даёт короткие выпадения depth
kill_after = self.lost_frames * 3 if tr.counted_zone is not None else self.lost_frames
if tid in assigned:
m = measurements[assigned[tid]]
tr.centroid = m.centroid_px
tr.miss = 0
tr.measurement = m
tr.decision = tr.stabilizer.update(m, min_mm=min_mm, max_mm=max_mm)
# после возрождения стабилизатор ещё «холодный» — держим прошлый LOCK на экране
if tr.reported and tr.frozen is not None and not (tr.decision and tr.decision.locked):
tr.decision = tr.frozen
self._account(tr, events)
if tr.reported:
self._touch_fp(tr)
else:
tr.miss += 1
tr.measurement = None
tr.decision = tr.stabilizer.update(None, min_mm=min_mm, max_mm=max_mm)
if tr.reported and tr.frozen is not None and not (tr.decision and tr.decision.locked):
tr.decision = tr.frozen
if tr.miss >= kill_after:
if tr.reported and tr.frozen is not None and tr.frozen.result is not None:
self._remember(tr)
del self._tracks[tid]
# старение отпечатков
for fp in self._fps:
fp.age += 1
self._fps = [fp for fp in self._fps if fp.age < self.fingerprint_ttl]
for mi in free_meas:
m = measurements[mi]
fp = self._match_fp(m)
if fp is not None:
# тот же товар вернулся после выпадения depth — без нового LOCK
fp.age = 0
fp.cx, fp.cy = m.centroid_px
frozen = StableDecision(
fp.result, True, 100, fp.result.circle_ratio, True, uncertain=fp.uncertain,
)
tr = Track(
track_id=fp.track_id,
stabilizer=self._factory(),
centroid=m.centroid_px,
measurement=m,
counted_zone=fp.zone,
counted_uncertain=fp.uncertain,
reported=True,
frozen=frozen,
decision=frozen,
)
self._tracks[tr.track_id] = tr
self._fps = [x for x in self._fps if x.track_id != fp.track_id]
continue
tr = Track(
track_id=self._next_id,
stabilizer=self._factory(),
centroid=m.centroid_px,
measurement=m,
)
self._next_id += 1
tr.decision = tr.stabilizer.update(m, min_mm=min_mm, max_mm=max_mm)
self._tracks[tr.track_id] = tr
self._account(tr, events)
alive = sorted(self._tracks.values(), key=lambda t: t.track_id)
return [t for t in alive if t.measurement is not None or t.miss < (
self.lost_frames * 3 if t.counted_zone else self.lost_frames
)], events
def _account(self, tr: Track, events: List[LockEvent]) -> None:
d = tr.decision
if d is None or not d.locked or d.result is None:
return
zone = d.result.category.zone
L, W, H = d.result.dims_sorted_mm
sk = slot_key(tr.centroid[0], tr.centroid[1], L, W, H, zone)
if tr.counted_zone is None:
if sk in self._seen_slots:
# тот же товар уже был в ленте/статистике — только показываем на экране
prev = self._seen_slots[sk]
tr.counted_zone = prev.zone
tr.counted_uncertain = prev.uncertain
tr.frozen = StableDecision(
prev.result, True, 100, prev.result.circle_ratio, True, uncertain=prev.uncertain,
)
tr.reported = True
tr.decision = tr.frozen
return
self.stats[zone] = self.stats.get(zone, 0) + 1
self.stats["total"] += 1
if d.uncertain:
self.stats["uncertain"] += 1
tr.counted_zone = zone
tr.counted_uncertain = d.uncertain
tr.frozen = StableDecision(
d.result, True, 100, d.result.circle_ratio, True, uncertain=d.uncertain,
)
self._seen_slots[sk] = _Fingerprint(
track_id=tr.track_id,
cx=tr.centroid[0],
cy=tr.centroid[1],
length_mm=float(L),
width_mm=float(W),
height_mm=float(H),
zone=zone,
uncertain=d.uncertain,
result=d.result,
)
if not tr.reported:
tr.reported = True
events.append(LockEvent(tr.track_id, tr.frozen))
elif tr.counted_zone != zone:
# смена зоны на экране/в счётчиках — в ленту повторно не пишем
self.stats[tr.counted_zone] = max(0, self.stats.get(tr.counted_zone, 0) - 1)
self.stats[zone] = self.stats.get(zone, 0) + 1
if tr.counted_uncertain and not d.uncertain:
self.stats["uncertain"] = max(0, self.stats["uncertain"] - 1)
tr.counted_uncertain = d.uncertain
tr.counted_zone = zone
tr.frozen = StableDecision(
d.result, True, 100, d.result.circle_ratio, True, uncertain=d.uncertain,
)
def _remember(self, tr: Track) -> None:
assert tr.frozen is not None and tr.frozen.result is not None
r = tr.frozen.result
L, W, H = r.dims_sorted_mm
self._fps = [fp for fp in self._fps if fp.track_id != tr.track_id]
self._fps.append(
_Fingerprint(
track_id=tr.track_id,
cx=tr.centroid[0],
cy=tr.centroid[1],
length_mm=float(L),
width_mm=float(W),
height_mm=float(H),
zone=tr.counted_zone or r.category.zone,
uncertain=tr.counted_uncertain,
result=r,
age=0,
)
)
def _touch_fp(self, tr: Track) -> None:
for fp in self._fps:
if fp.track_id == tr.track_id:
fp.age = 0
fp.cx, fp.cy = tr.centroid
def _match_fp(self, m: ObjectMeasurement) -> Optional[_Fingerprint]:
best: Optional[_Fingerprint] = None
best_d2 = self.max_dist_px**2
for fp in self._fps:
d2 = (fp.cx - m.centroid_px[0]) ** 2 + (fp.cy - m.centroid_px[1]) ** 2
if d2 > best_d2:
continue
if not _dims_close(fp.length_mm, fp.width_mm, fp.height_mm, m.length_mm, m.width_mm, m.height_mm):
continue
best, best_d2 = fp, d2
return best
def _dims_close(L0: float, W0: float, H0: float, L1: float, W1: float, H1: float, tol: float = 0.40) -> bool:
"""Габариты «похожи» (порядок осей уже отсортирован в classify, здесь — сырые L×W×H)."""
a = sorted((L0, W0, H0))
b = sorted((L1, W1, H1))
for x, y in zip(a, b):
if abs(x - y) / max(x, y, 1.0) > tol:
return False
return True

View File

@@ -61,6 +61,23 @@ CCD for light/thin items exists in runtime/sim; that alone is **not** full conta
- Missing: `input_info/extracted/Постановка_Задача_3_сжато_2.pdf` — never claim it is available.
- Internal engineering criteria are not automatic Ozon pass/fail.
## Соответствие подтверждённым требованиям
Grounded only in present sources + current `main` code/tests (not a full official scorecard):
| Area | Official source present | Current implementation | Evidence | Status |
|---|---|---|---|---|
| B/C/D bounds & roundness | `official_sources/doc-1783095831.pdf` | web `classifier.ts`, CV `cv/classify.py` | unit tests | PARTIAL (PDF not re-parsed each pass) |
| Digital twin demo | workspace PDFs in `input_info/` | `/` + `/documentation` | e2e smoke/routes, production | PASS (demo present) |
| CAD conveyor | author pack / STL references | `3d_models/`, `conveyor-clean.glb` | checksums in README | PASS (assets present) |
| Real measurement CV | Track 3 camera intent in briefs | `cv/` RealSense+OpenCV | `cv/README.md`, `test_classify.py` | PARTIAL (prototype, not live web) |
| Physical industrial line | scoring/workspace PDFs | web physics + optional MQTT CV | code; contact not fully validated | PARTIAL |
| Presentation / video | platform rules | cloud links | README §17 | NOT_VERIFIED (links missing) |
## Real CV prototype
Path `cv/` — OpenCV + RealSense D415 depth pipeline. Same B/C/D domain as the web twin. **Not** consumed by https://arhipovdan.ru. Details: `cv/README.md`.
## Layout drawing
`docs/engineering/work-area-layout-source.png` — workspace layout provenance image retained for engineering reference.