vision model

This commit is contained in:
2026-08-03 01:26:42 +07:00
parent 3f8f3a40ec
commit 8aa0c16359
26 changed files with 5795 additions and 0 deletions

7
vision_classifier/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
debug_frames/
*.jpg
*.png
.pytest_cache/

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"]

View File

@@ -0,0 +1,187 @@
# Полная инструкция: Vision + Arduino + шаговик (трек 3)
Код в `arduino_code/Test` **не меняем**. Связка идёт только через MQTT — те же топики, что уже использует SCADA (`backend_control/gui.py`).
## Архитектура
```
RealSense D415 ──► Orange PI (vision_classifier)
│
▼ MQTT
┌──── mosquitto :1883 ────┐
│ │
▼ ▼
ESP32 (уже прошит) SCADA gui.py
• motor/control/* (опционально)
• servo/control/*
│
▼
TMC2209 + шаговик ленты
серво-селекторы зон B/C/D
```
## 1. Сеть и MQTT (важно)
На Orange PI уже крутится Docker-контейнер **mosquitto** на порту **1883**
(логин/пароль: `test` / `1234` — как в SCADA).
| Кто | Куда подключается |
|-----|-------------------|
| `vision_classifier` | `127.0.0.1:1883` (уже в `config.yaml`) |
| SCADA `gui.py` | `192.168.31.225` или `127.0.0.1` |
| ESP32 (прошивка) | `MQTT_SERVER = "192.168.31.225"` (уже в `arduino_code/Test`) |
Orange PI в сети: **`192.168.31.225`**. Vision ходит на локальный брокер `127.0.0.1`; ESP и SCADA — на `192.168.31.225:1883`.
Краткая шпаргалка запуска/стопа: **[START.md](START.md)**.
Проверка с Orange PI:
```bash
# локальный брокер на OPI
python3 -c "
import paho.mqtt.client as m, time
c=m.Client(m.CallbackAPIVersion.VERSION2); c.username_pw_set('test','1234')
c.connect('127.0.0.1',1883); c.loop_start(); time.sleep(0.5); print('MQTT OK'); c.disconnect()
"
```
MQTT Explorer: http://localhost:3000 (контейнер `mqtt-explorer`).
## 2. Что уже должно работать на Arduino
Не трогая `arduino_code/Test`, убедитесь что ESP32 в сети и отвечает на команды (через SCADA или mosquitto):
| Действие | Топик | Payload |
|----------|-------|---------|
| Вкл. драйвер | `motor/control/driver` | `on` |
| Вкл. TMC | `motor/control/tmc/enable` | `on` |
| Ток % | `motor/control/tmc/current_percent` | `50` |
| Микрошаг | `motor/control/tmc/microsteps` | `16` |
| Скорость RPM | `motor/control/rpm` | `-200` (у вас направление с минусом; диапазон примерно −1000…1000) |
| Серво угол | `servo/control/{N}/angle` | `0`…`180` |
| Серво вкл | `servo/control/{N}/enable` | `on` |
Телеметрия: `motor/feedback/#`, `servo/+/feedback/#`.
Ручной тест ленты (если `mosquitto_pub` есть):
```bash
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/driver -m on
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/tmc/enable -m on
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/rpm -m -200
# стоп:
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/rpm -m 0
```
Или откройте `backend_control/gui.py` (`MQTT_BROKER = "192.168.31.225"`).
## 3. Камера RealSense D415
1. USB3 (скорость 5000M в `lsusb -t`).
2. Камера **сверху** над лентой, **0.5–1.5 м**.
3. В `config.yaml` задайте `belt_distance_mm` = реальная высота в мм.
```bash
lsusb | grep -i RealSense
v4l2-ctl --list-devices
```
## 4. Запуск vision (Orange PI)
```bash
cd ~/workdir/vision_classifier
# один кадр без железа
./run.sh --once --no-mqtt --no-motor
# превью без движения (JPEG обновляются на диске)
./run.sh --preview --dry-route --no-motor
# смотреть: debug_frames/live_color.jpg и live_depth.jpg
# полный контур: камера → класс → MQTT → мотор + серво
PYTHONUNBUFFERED=1 ./run.sh --preview
# стоп: Ctrl+C (rpm сбросится в 0)
```
Превью **не открывает окна** (у вас OpenCV headless) — пишет файлы:
- `debug_frames/live_color.jpg`
- `debug_frames/live_depth.jpg`
Откройте их в Cursor/VS Code или:
```bash
eog debug_frames/live_color.jpg &
# или периодически обновлять в браузере/просмотрщике
```
## 5. Настройка зон под вашу механику
В `config.yaml` → `routing.zones` и `motor`:
```yaml
motor:
enabled: true
rpm: -200 # у вас направление ленты — отрицательный RPM
mqtt:
broker: "127.0.0.1" # mosquitto на Orange PI; ESP и SCADA → 192.168.31.225
routing:
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 }
```
Подставьте **номера каналов PCA9685** и углы, которые уже проверены в SCADA.
Логика ТЗ:
| Класс | Зона | Действие |
|-------|------|----------|
| Подходит | B | лента едет, серво idle |
| Не по габаритам | C | серво C на `divert_angle`, потом idle |
| Круг (доупаковка) | D | серво D на `divert_angle`, потом idle |
## 6. SCADA параллельно
Можно держать GUI открытым для телеметрии:
```bash
cd ~/workdir/backend_control
# в gui.py: MQTT_BROKER = "192.168.31.225" (или 127.0.0.1 с OPI)
source testing/bin/activate # или свой venv
python gui.py
```
Vision и SCADA могут работать вместе на одном брокере.
## 7. Docker (опционально)
```bash
cd ~/workdir/vision_classifier
sudo docker compose up -d --build
sudo docker compose logs -f
```
## 8. Типичные проблемы
| Симптом | Что сделать |
|---------|-------------|
| `cv2.imshow` / GTK error | уже исправлено: `--preview` пишет JPEG |
| `mqtt timed out` | брокер на OPI → `broker: "127.0.0.1"`; ESP → `192.168.31.225` |
| ESP не реагирует | одна Wi‑Fi сеть, брокер `192.168.31.225:1883`, логин `test`/`1234` |
| depth valid < 30% | USB3, высота 0.5–1.5 м, не чёрная лента в упор |
| серво не те | поправьте `routing.zones.*.servo` под SCADA |
| лента не едет | `motor.enabled: true`, проверьте SCADA rpm вручную |
## 9. Чеклист «всё связано»
1. [ ] `mosquitto` Up (`docker ps`)
2. [ ] ESP32 в Wi‑Fi, в MQTT Explorer видны `motor/feedback/*`
3. [ ] Из SCADA крутится шаговик и двигаются серво
4. [ ] `config.yaml`: `mqtt.broker` = тот же брокер
5. [ ] `belt_distance_mm` и углы серво подогнаны
6. [ ] `./run.sh` печатает зону B/C/D и в Explorer появляются `vision/feedback/*`
7. [ ] При объекте срабатывает нужный серво, лента крутится

View File

@@ -0,0 +1,61 @@
# Vision Classifier — трек 3 (RealSense D415 на Orange PI)
## Правила ТЗ (фиксированные)
1. Габариты строго `>10×10×10` и `<450×320×320` мм → иначе **C**
2. Иначе если `r_in/r_out ≥ 0.8` (вид сверху **или** ≥2 поперечных среза) → **D**
3. Иначе → **B**
Стабилизация: медиана 12 кадров + голосование ≥2/3 окна → **LOCK**.
После LOCK зона не меняется, пока объект не убрали. Без «достройки» круга на коробках.
**Пограничные случаи:** если консенсуса нет `uncertain_after_frames` кадров
(ratio у порога 0.8, сторона у лимита 450/320) — товар помечается **«неуверенно»**
и уходит в безопасную зону (`uncertain_fallback_zone: C`), а не в основной поток.
Все LOCK-решения пишутся в `logs/decisions.jsonl` (метрики для отчёта: зоны,
габариты, ratio, доля неуверенных).
## Калибровка (обязательно перед замером габаритов!)
Габариты считаются через фокусное `fx/fy`. Дефолт `670` — расчёт по спецификации
D415 @ 640×480 (старое значение 430 завышало размеры в ~1.56 раза). Уточнить под
свою камеру по коробке известного размера (рулеткой, высота коробки ≥ 30 мм):
```bash
.venv/bin/python calibrate.py --length 300 --width 200
```
Скрипт сам замерит высоту ленты и запишет `fx/fy` + `belt_distance_mm` в `config.yaml`.
## Фоновая карта глубины
Сегментация умеет работать относительно карты фона (а не одной высоты):
товар не сливается с платформой/бортами накопителя, а предмет, лежащий
на другом предмете, выделяется отдельно.
- `main.py`: `use_background_map: true` в конфиге — карта снимается при старте
(лента должна быть **пустой**);
- `demo.py`: кнопка **«Авто-высота»** снимает карту фона (уберите объекты);
ручное движение ползунка высоты сбрасывает карту.
## Демо
```bash
cd vision_classifier
./demo.sh
```
Браузер: **http://127.0.0.1:8080/** (Ctrl+F5 после перезапуска)
Ползунки сверху: высота ленты, уверенность, мин. высота, порог круга, площадь.
**Авто-высота** — калибровка по пустой ленте. Выход: `Ctrl+C`.
Проверка без камеры:
```bash
.venv/bin/python test_classify.py
.venv/bin/python test_geometry.py
```
**Полный запуск / стоп:** [START.md](START.md)
Боевой режим (MQTT + шаговик + серво): [INTEGRATION.md](INTEGRATION.md)

140
vision_classifier/START.md Normal file
View File

@@ -0,0 +1,140 @@
# Полный запуск системы (трек 3)
Orange PI **192.168.31.225** · MQTT `test` / `1234` · RPM ленты **отрицательный** (`-200`)
---
## Стоп (сначала, если что-то уже крутится)
```bash
# 1) убить vision (иначе снова поднимет ленту)
pkill -f 'main.py' || true
# 2) остановить мотор
cd ~/workdir/vision_classifier
.venv/bin/python - <<'PY'
from paho.mqtt.client import Client, CallbackAPIVersion
import time
c = Client(CallbackAPIVersion.VERSION2, client_id='stop')
c.username_pw_set('test', '1234')
c.connect('127.0.0.1', 1883, 60)
c.loop_start(); time.sleep(0.3)
c.publish('motor/control/rpm', '0', qos=1)
time.sleep(0.3); c.loop_stop(); c.disconnect()
print('rpm=0')
PY
```
Или `Ctrl+C` в терминале `main.py` — при выходе сам шлёт `rpm=0`.
---
## Чеклист перед стартом
1. **Питание** конвейера / драйвера / ESP32 включено.
2. **RealSense D415** в USB3 (`lsusb | grep -i RealSense`).
3. **Mosquitto** на Orange PI:
```bash
docker ps | grep mosquitto
# должно быть 0.0.0.0:1883
```
4. **ESP32** в той же сети, в прошивке `MQTT_SERVER = "192.168.31.225"` (уже так).
5. Лента под камерой **пустая** (пока не стартовали фон/превью).
---
## Запуск — полный контур (камера → B/C/D → MQTT → лента + серво)
```bash
cd ~/workdir/vision_classifier
# превью JPEG + мотор + серво (рекомендуется)
PYTHONUNBUFFERED=1 ./run.sh --preview
```
Что должно появиться в логе:
```
[camera] RGB: /dev/video4
[camera] depth OK, valid≈…
[mqtt] подключено к 127.0.0.1:1883
```
После старта vision сам включает драйвер и крутит ленту с `motor.rpm: -200` из `config.yaml`.
### Куда смотреть во время работы
| Что | Где |
|-----|-----|
| Кадр RGB | `debug_frames/live_color.jpg` |
| Depth | `debug_frames/live_depth.jpg` |
| Решения LOCK | stdout + `logs/decisions.jsonl` |
| MQTT трафик | http://192.168.31.225:3000 (MQTT Explorer) |
| Зоны vision | топики `vision/feedback/*` |
Хвост логов:
```bash
tail -f logs/decisions.jsonl
# или фильтр по LOCK:
tail -f logs/decisions.jsonl | while read l; do echo "$l" | .venv/bin/python -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('ts'), 'LOCK', d.get('zone'), d.get('dims_mm'), d.get('circle_ratio'), d.get('reason',''))"; done
```
### Зоны
| LOCK | Смысл | Серво (config) |
|------|--------|----------------|
| **B** | подходит | servo 0 idle |
| **C** | габариты вне 10…450×320×320 | servo 1 → 90° |
| **D** | круг `ratio ≥ 0.8` | servo 2 → 90° |
Кладите один объект в поле камеры, ждите строку `LOCK B/C/D`.
---
## Запуск без движения ленты (только зрение)
```bash
cd ~/workdir/vision_classifier
PYTHONUNBUFFERED=1 ./run.sh --preview --dry-route --no-motor --no-mqtt
```
JPEG всё равно обновляются в `debug_frames/`.
---
## Ручной тест ленты (без vision)
```bash
# нужен mosquitto_pub или python из .venv
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/driver -m on
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/tmc/enable -m on
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/rpm -m -200
# стоп:
mosquitto_pub -h 127.0.0.1 -u test -P 1234 -t motor/control/rpm -m 0
```
---
## SCADA (опционально, параллельно)
```bash
cd ~/workdir/backend_control
# MQTT_BROKER = "192.168.31.225" или 127.0.0.1
python gui.py
```
---
## Если что-то не едет
| Проблема | Действие |
|----------|----------|
| Камера занята | `pkill -f main.py`; `fuser /dev/video0` |
| MQTT не коннектится | `docker ps \| grep mosquitto`, логин `test`/`1234` |
| ESP молчит | одна Wi‑Fi сеть, брокер **192.168.31.225:1883** |
| Лента в другую сторону | в `config.yaml` → `motor.rpm: -200` (минус обязателен) |
| Ложные LOCK на пустой ленте | уберите мусор/блики; при необходимости поднимите `min_object_height_mm` |
| Серво не те каналы | `routing.zones` в `config.yaml` |
Подробности сети/топиков: [INTEGRATION.md](INTEGRATION.md). Алгоритм: [README.md](README.md).

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

View File

@@ -0,0 +1,182 @@
#!/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.5–1.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)
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
vision_classifier/camera.py Normal file
View File

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

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)

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

View File

@@ -0,0 +1,110 @@
# Конфиг алгоритмической части трека 3 (RealSense D415 → классификация → MQTT)
camera:
depth_device: /dev/video0
color_device: /dev/video4
width: 640
height: 480
fps: 30
depth_scale_mm: 1.0
# Intrinsics депта D415 @ 640x480 (кроп 4:3 из 1280x720, HFOV 65°): fx=fy≈670.
# Старое значение 430 завышало габариты в ~1.56 раза!
# Уточнить под свою камеру: .venv/bin/python calibrate.py --length 300 --width 200
fx: 564.0
fy: 564.0
cx: 320.0
cy: 240.0
# Совмещение RGB с depth в демо (сенсоры D415 разнесены ~55 мм).
# Подбор: .venv/bin/python align_color.py (нужен объект с чёткими краями)
color_dx: -40.0
color_dy: -8.0
color_scale: 1.030
# Высота камеры над пустой лентой, мм (оценка по боковым полосам ленты: ~594)
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
# RGB-картинка в демо (измерения всё равно по depth)
use_color: true
# Фоновая карта глубины: в main.py при старте; в demo — только кнопка «Авто-высота»
# false пока тестируем с объектом на ленте (карту снимать только на ПУСТОЙ ленте)
use_background_map: false
# RGB-поиск плоских товаров (телефон). ВЫКЛ по умолчанию — тени/шум давали ложные C.
# Включить: true + «Авто-высота» на пустой ленте.
detect_flat_rgb: false
rgb_diff_threshold: 35
classification:
min_mm: [10, 10, 10]
max_mm: [450, 320, 320]
circle_ratio_threshold: 0.8
# Пограничные случаи: нет консенсуса N кадров (~4 с при 12 fps обработки)
# → товар помечается «неуверенно» и уходит в безопасную зону (по умолчанию C).
uncertain_after_frames: 45
uncertain_fallback_zone: C
# MQTT — тот же брокер, что слушает ESP32 и SCADA (backend_control)
# На этом Orange PI mosquitto уже слушает :1883 → используйте 127.0.0.1
# ESP32 в прошивке ждёт 192.168.0.200 — см. INTEGRATION.md
mqtt:
broker: "127.0.0.1"
port: 1883
user: "test"
password: "1234"
client_id: "vision_classifier_opi"
topic_result: "vision/feedback/category"
topic_dims: "vision/feedback/dimensions"
topic_circle: "vision/feedback/circle_ratio"
topic_debug: "vision/feedback/debug"
enabled: true
# Шаговик ленты — те же топики, что SCADA (arduino_code не меняем)
motor:
enabled: true
rpm: -200 # скорость ленты; у вас направление — с минусом
current_percent: 50
microsteps: 16
stealthchop: true
disable_on_stop: false # при Ctrl+C только rpm=0, драйвер не гасим
# Серво-селекторы зон B/C/D — подставьте свои каналы/углы
routing:
enabled: true
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"
# JSONL-журнал всех LOCK-решений (метрики для отчёта)
decisions_log: "logs/decisions.jsonl"
preview_every_n: 3
process_every_n: 1
confirm_frames: 8

863
vision_classifier/demo.py Executable file
View File

@@ -0,0 +1,863 @@
#!/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 load_config(path: Path) -> Dict[str, Any]:
with open(path, "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.5–2 с: глянцевые/тёмные предметы (мышка) дают
# кратковременные выпадения 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())

14
vision_classifier/demo.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
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 "$@"

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

View File

@@ -0,0 +1,23 @@
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:
- ./config.yaml:/app/config.yaml:ro
- ./debug_frames:/app/debug_frames
group_add:
- video
environment:
- QT_X11_NO_MITSHM=1
# Без GUI в контейнере по умолчанию; превью — через native run
command: ["python", "main.py", "-c", "config.yaml"]

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")

View File

@@ -0,0 +1,742 @@
{"ts": "2026-07-28T17:10:28", "ts_ms": 1785247828677, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [253.7, 116.5, 114.1], "circle_ratio": 0.5161, "uncertain": false, "reason": "не круг: med=0.516 sec=0.435 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829097, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829149, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829363, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829417, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829740, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:29", "ts_ms": 1785247829964, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830018, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830070, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830126, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830287, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830341, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830397, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830553, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830816, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:30", "ts_ms": 1785247830867, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831023, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831234, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831287, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831443, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831495, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831600, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831706, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831758, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:31", "ts_ms": 1785247831810, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832279, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832384, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832438, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832544, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.9, 71.1, 27.0], "circle_ratio": 0.6216, "uncertain": false, "reason": "не круг: med=0.622 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:32", "ts_ms": 1785247832989, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833045, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833100, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833156, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833324, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833379, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833434, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833490, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833599, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833654, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833821, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833877, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833933, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:33", "ts_ms": 1785247833987, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:10:34", "ts_ms": 1785247834243, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [279.3, 110.8, 90.0], "circle_ratio": 0.4175, "uncertain": false, "reason": "не круг: med=0.417 sec=0.367 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:27", "ts_ms": 1785247947835, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:27", "ts_ms": 1785247947951, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948058, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948116, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948171, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948229, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948283, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948335, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948394, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948451, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948611, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948874, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948926, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:28", "ts_ms": 1785247948977, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949029, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949188, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949295, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949348, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949451, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949502, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949550, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949705, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949757, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949867, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949919, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:29", "ts_ms": 1785247949971, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950074, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950135, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950242, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950295, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950449, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950607, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950658, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950713, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950765, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950884, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:30", "ts_ms": 1785247950992, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951044, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951097, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951261, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951376, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951628, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951680, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951731, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:31", "ts_ms": 1785247951949, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952152, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952357, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952411, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952616, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952669, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952876, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:32", "ts_ms": 1785247952932, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953193, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953248, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953300, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953352, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953404, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953457, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953562, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953615, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953663, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953773, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953828, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:33", "ts_ms": 1785247953885, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [108.0, 60.7, 28.1], "circle_ratio": 0.4153, "uncertain": false, "reason": "не круг: med=0.415 sec=0.322 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:34", "ts_ms": 1785247954389, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [196.1, 157.5, 95.9], "circle_ratio": 0.4872, "uncertain": false, "reason": "не круг: med=0.487 sec=0.398 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:34", "ts_ms": 1785247954549, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [196.1, 157.5, 95.9], "circle_ratio": 0.4872, "uncertain": false, "reason": "не круг: med=0.487 sec=0.398 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:34", "ts_ms": 1785247954969, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955608, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955711, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955764, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955922, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:35", "ts_ms": 1785247955975, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956029, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956187, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956449, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956657, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956761, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956874, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:36", "ts_ms": 1785247956927, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957084, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957407, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957619, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957834, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957888, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:37", "ts_ms": 1785247957944, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958158, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958266, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958477, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958690, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958744, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958906, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:38", "ts_ms": 1785247958959, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959011, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959063, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959170, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959223, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959280, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959335, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959445, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959552, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959606, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959660, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959715, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:39", "ts_ms": 1785247959875, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960037, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960410, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960516, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960746, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:40", "ts_ms": 1785247960810, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961072, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961126, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961232, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961294, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961669, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961773, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961826, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961878, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:41", "ts_ms": 1785247961984, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962040, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962199, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962251, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962305, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962358, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962412, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962519, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962624, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962730, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:42", "ts_ms": 1785247962783, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963046, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963100, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963314, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963523, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963628, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:43", "ts_ms": 1785247963942, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:44", "ts_ms": 1785247964154, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.9, 28.0], "circle_ratio": 0.6481, "uncertain": false, "reason": "не круг: med=0.648 sec=0.427 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:44", "ts_ms": 1785247964884, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [317.8, 128.5, 110.6], "circle_ratio": 0.4028, "uncertain": false, "reason": "не круг: med=0.403 sec=0.348 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965149, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [317.8, 128.5, 110.6], "circle_ratio": 0.4028, "uncertain": false, "reason": "не круг: med=0.403 sec=0.348 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965205, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [317.8, 128.5, 110.6], "circle_ratio": 0.4028, "uncertain": false, "reason": "не круг: med=0.403 sec=0.348 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965628, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965681, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965731, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965841, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965897, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:45", "ts_ms": 1785247965949, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966056, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966111, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966162, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966214, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966318, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966371, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966425, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966484, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966536, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966648, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966753, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:46", "ts_ms": 1785247966858, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967024, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967130, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967182, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967237, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967347, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967400, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967452, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967509, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967565, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967618, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967829, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967940, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:47", "ts_ms": 1785247967998, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968209, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968316, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968375, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968428, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968480, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968535, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968589, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968643, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968703, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:48", "ts_ms": 1785247968972, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969096, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969150, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969211, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969383, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969444, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969498, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969549, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969605, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969655, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969818, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969872, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:49", "ts_ms": 1785247969926, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970126, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970203, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970260, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970323, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970376, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970532, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970740, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970849, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:50", "ts_ms": 1785247970913, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971080, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971406, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971468, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971522, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971577, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971630, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971683, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971738, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971790, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:51", "ts_ms": 1785247971948, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972331, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972386, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972443, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972549, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972655, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972710, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972816, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972931, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:52", "ts_ms": 1785247972986, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973042, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973274, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973391, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973448, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973552, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973613, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973784, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973837, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973895, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:53", "ts_ms": 1785247973951, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974061, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974168, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974223, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974282, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974335, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974454, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974744, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974882, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:54", "ts_ms": 1785247974993, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975048, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975103, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975157, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975370, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975424, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975480, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975584, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975642, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975755, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975865, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975918, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:55", "ts_ms": 1785247975975, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976083, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976136, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976187, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976246, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976301, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976411, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976465, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976518, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976573, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976626, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:56", "ts_ms": 1785247976978, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977040, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977155, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977427, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977481, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977535, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977587, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977645, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977697, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977750, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977805, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977914, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:57", "ts_ms": 1785247977968, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978020, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978076, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978129, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978185, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978346, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978400, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978454, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:12:58", "ts_ms": 1785247978557, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 74.5, 28.4], "circle_ratio": 0.3878, "uncertain": false, "reason": "не круг: med=0.388 sec=0.334 strong=17% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:13:39", "ts_ms": 1785248019861, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:39", "ts_ms": 1785248019970, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020021, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020349, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020395, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020661, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:40", "ts_ms": 1785248020712, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021196, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021244, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021296, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021344, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021394, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021439, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021640, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:13:41", "ts_ms": 1785248021694, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [63.8, 41.5, 5.5], "circle_ratio": 0.1463, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080400, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080549, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080595, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080685, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:14:40", "ts_ms": 1785248080734, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:14:41", "ts_ms": 1785248081186, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:14:41", "ts_ms": 1785248081236, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [46.3, 43.2, 6.6], "circle_ratio": 0.2208, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"ts": "2026-07-28T17:16:13", "ts_ms": 1785248173367, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [87.5, 49.2, 27.6], "circle_ratio": 0.4132, "uncertain": false, "reason": "не круг: med=0.413 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:13", "ts_ms": 1785248173646, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [87.5, 49.2, 27.6], "circle_ratio": 0.4132, "uncertain": false, "reason": "не круг: med=0.413 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:13", "ts_ms": 1785248173696, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [87.5, 49.2, 27.6], "circle_ratio": 0.4132, "uncertain": false, "reason": "не круг: med=0.413 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:13", "ts_ms": 1785248173747, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [87.5, 49.2, 27.6], "circle_ratio": 0.4132, "uncertain": false, "reason": "не круг: med=0.413 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:14", "ts_ms": 1785248174810, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:14", "ts_ms": 1785248174995, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175093, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175186, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175236, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175281, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175377, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175425, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175471, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175519, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175565, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175885, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:15", "ts_ms": 1785248175931, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.2, 54.9, 27.2], "circle_ratio": 0.4447, "uncertain": false, "reason": "не круг: med=0.445 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177043, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177091, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177137, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177370, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177415, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177558, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:17", "ts_ms": 1785248177607, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [89.2, 80.0, 32.1], "circle_ratio": 0.3724, "uncertain": false, "reason": "не круг: med=0.372 sec=0.038 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:18", "ts_ms": 1785248178815, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179047, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179093, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179278, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179373, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179559, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:19", "ts_ms": 1785248179605, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.2, 51.4, 25.7], "circle_ratio": 0.4169, "uncertain": false, "reason": "не круг: med=0.417 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181095, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181144, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181191, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181331, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181376, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181560, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181606, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181791, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:21", "ts_ms": 1785248181836, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182112, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182159, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182253, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182303, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182399, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182492, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182540, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182590, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182636, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182729, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:22", "ts_ms": 1785248182775, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183142, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183192, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183238, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183657, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183704, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183842, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183893, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:23", "ts_ms": 1785248183940, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:24", "ts_ms": 1785248184080, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:24", "ts_ms": 1785248184127, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:24", "ts_ms": 1785248184175, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [95.0, 74.3, 28.1], "circle_ratio": 0.2798, "uncertain": false, "reason": "не круг: med=0.280 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:24", "ts_ms": 1785248184882, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [86.0, 51.2, 26.2], "circle_ratio": 0.4928, "uncertain": false, "reason": "не круг: med=0.493 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:25", "ts_ms": 1785248185114, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [86.0, 51.2, 26.2], "circle_ratio": 0.4928, "uncertain": false, "reason": "не круг: med=0.493 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:25", "ts_ms": 1785248185160, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [86.0, 51.2, 26.2], "circle_ratio": 0.4928, "uncertain": false, "reason": "не круг: med=0.493 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:26", "ts_ms": 1785248186520, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.5, 71.6, 27.0], "circle_ratio": 0.3388, "uncertain": false, "reason": "не круг: med=0.339 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:26", "ts_ms": 1785248186827, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.5, 71.6, 27.0], "circle_ratio": 0.3388, "uncertain": false, "reason": "не круг: med=0.339 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:26", "ts_ms": 1785248186875, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.5, 71.6, 27.0], "circle_ratio": 0.3388, "uncertain": false, "reason": "не круг: med=0.339 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:37", "ts_ms": 1785248197641, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [80.0, 60.9, 25.7], "circle_ratio": 0.4589, "uncertain": false, "reason": "не круг: med=0.459 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199474, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199525, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199572, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199620, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199669, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199859, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:39", "ts_ms": 1785248199904, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200048, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200094, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200191, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200237, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200333, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200383, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200432, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200481, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200625, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200728, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200773, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200833, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:40", "ts_ms": 1785248200881, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.0, 74.3, 23.4], "circle_ratio": 0.3172, "uncertain": false, "reason": "не круг: med=0.317 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201465, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201512, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201560, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201751, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201797, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:41", "ts_ms": 1785248201984, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202036, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202086, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202137, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202183, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202283, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202430, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202573, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202623, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202669, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202766, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202818, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202868, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202918, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:42", "ts_ms": 1785248202965, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203014, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203064, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203112, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203208, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203490, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203536, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203636, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203683, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203732, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203777, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203924, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:43", "ts_ms": 1785248203972, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:44", "ts_ms": 1785248204024, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:44", "ts_ms": 1785248204121, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:44", "ts_ms": 1785248204401, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.0, 90.3, 27.1], "circle_ratio": 0.2698, "uncertain": false, "reason": "не круг: med=0.270 sec=0.032 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205167, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205264, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205310, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205542, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205595, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205881, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:45", "ts_ms": 1785248205978, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.0, 98.2, 26.6], "circle_ratio": 0.2732, "uncertain": false, "reason": "не круг: med=0.273 sec=0.013 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:54", "ts_ms": 1785248214477, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:54", "ts_ms": 1785248214980, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:55", "ts_ms": 1785248215027, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:55", "ts_ms": 1785248215079, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:16:55", "ts_ms": 1785248215133, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.5, 68.9, 24.3], "circle_ratio": 0.3455, "uncertain": false, "reason": "не круг: med=0.346 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:10", "ts_ms": 1785248230685, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [100.5, 64.4, 24.0], "circle_ratio": 0.3657, "uncertain": false, "reason": "не круг: med=0.366 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:11", "ts_ms": 1785248231174, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [100.5, 64.4, 24.0], "circle_ratio": 0.3657, "uncertain": false, "reason": "не круг: med=0.366 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:11", "ts_ms": 1785248231225, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [100.5, 64.4, 24.0], "circle_ratio": 0.3657, "uncertain": false, "reason": "не круг: med=0.366 sec=0.012 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:16", "ts_ms": 1785248236220, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.8, 79.0, 25.8], "circle_ratio": 0.2664, "uncertain": false, "reason": "не круг: med=0.266 sec=0.037 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:16", "ts_ms": 1785248236271, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.8, 79.0, 25.8], "circle_ratio": 0.2664, "uncertain": false, "reason": "не круг: med=0.266 sec=0.037 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:16", "ts_ms": 1785248236324, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.8, 79.0, 25.8], "circle_ratio": 0.2664, "uncertain": false, "reason": "не круг: med=0.266 sec=0.037 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:16", "ts_ms": 1785248236370, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [83.8, 79.0, 25.8], "circle_ratio": 0.2664, "uncertain": false, "reason": "не круг: med=0.266 sec=0.037 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:36", "ts_ms": 1785248256641, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [80.2, 60.8, 23.0], "circle_ratio": 0.3659, "uncertain": false, "reason": "не круг: med=0.366 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:47", "ts_ms": 1785248267876, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:47", "ts_ms": 1785248267926, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:47", "ts_ms": 1785248267974, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268073, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268118, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268214, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268264, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268314, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268365, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268410, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268459, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268506, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268600, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268695, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268746, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268795, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268890, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:48", "ts_ms": 1785248268936, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [81.0, 81.0, 24.2], "circle_ratio": 0.2892, "uncertain": false, "reason": "не круг: med=0.289 sec=0.034 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:49", "ts_ms": 1785248269891, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:49", "ts_ms": 1785248269985, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270031, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270078, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270126, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270170, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270216, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.0, 21.2], "circle_ratio": 0.4214, "uncertain": false, "reason": "не круг: med=0.421 sec=0.030 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270666, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [234.0, 97.3, 44.1], "circle_ratio": 0.6204, "uncertain": false, "reason": "не круг: med=0.620 sec=0.215 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:50", "ts_ms": 1785248270735, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [234.0, 97.3, 44.1], "circle_ratio": 0.6204, "uncertain": false, "reason": "не круг: med=0.620 sec=0.215 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276282, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276375, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276423, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276605, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276651, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276837, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:56", "ts_ms": 1785248276883, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277020, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277068, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277116, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277164, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277307, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277353, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277542, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:17:57", "ts_ms": 1785248277587, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.4, 70.0, 31.4], "circle_ratio": 0.4418, "uncertain": false, "reason": "не круг: med=0.442 sec=0.015 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:11", "ts_ms": 1785248291218, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292087, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292134, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292231, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292283, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:12", "ts_ms": 1785248292426, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [99.4, 81.5, 29.6], "circle_ratio": 0.2931, "uncertain": false, "reason": "не круг: med=0.293 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327376, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327426, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327476, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327522, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327666, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327717, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:47", "ts_ms": 1785248327765, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [137.0, 125.5, 33.5], "circle_ratio": 0.2687, "uncertain": false, "reason": "не круг: med=0.269 sec=0.138 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328306, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328495, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328541, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328636, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328682, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328822, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328867, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:48", "ts_ms": 1785248328966, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329068, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329119, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329164, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329213, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329310, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329361, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329407, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329457, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329502, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329554, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329599, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329792, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329841, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329892, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"ts": "2026-07-28T17:18:49", "ts_ms": 1785248329938, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [118.0, 49.8, 18.0], "circle_ratio": 0.4061, "uncertain": false, "reason": "не круг: med=0.406 sec=0.241 strong=0% < 0.8", "source": "demo"}
{"track_id": 5, "ts": "2026-07-28T17:21:28", "ts_ms": 1785248488055, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [223.5, 119.1, 56.8], "circle_ratio": 0.4667, "uncertain": false, "reason": "не круг: med=0.467 sec=0.391 strong=0% < 0.8", "source": "demo"}
{"track_id": 6, "ts": "2026-07-28T17:21:30", "ts_ms": 1785248490891, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [166.0, 67.7, 62.6], "circle_ratio": 0.8492, "uncertain": false, "reason": "круг: med=0.849 p75=0.856 top=0.844 sec=0.710 strong=17% >= 0.8", "source": "demo"}
{"track_id": 6, "ts": "2026-07-28T17:21:46", "ts_ms": 1785248506696, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [180.9, 66.6, 57.0], "circle_ratio": 0.3862, "uncertain": false, "reason": "не круг: med=0.386 sec=0.348 strong=0% < 0.8", "source": "demo"}
{"track_id": 14, "ts": "2026-07-28T17:21:49", "ts_ms": 1785248509911, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [181.5, 65.2, 53.0], "circle_ratio": 0.3644, "uncertain": false, "reason": "не круг: med=0.364 sec=0.289 strong=0% < 0.8", "source": "demo"}
{"track_id": 16, "ts": "2026-07-28T17:22:50", "ts_ms": 1785248570894, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [116.4, 73.0, 30.0], "circle_ratio": 0.6249, "uncertain": false, "reason": "не круг: med=0.625 sec=0.383 strong=0% < 0.8", "source": "demo"}
{"track_id": 17, "ts": "2026-07-28T17:23:02", "ts_ms": 1785248582205, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [180.8, 65.0, 52.0], "circle_ratio": 0.3721, "uncertain": false, "reason": "не круг: med=0.372 sec=0.335 strong=0% < 0.8", "source": "demo"}
{"track_id": 19, "ts": "2026-07-28T17:23:07", "ts_ms": 1785248587859, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.1, 72.2, 30.0], "circle_ratio": 0.6361, "uncertain": false, "reason": "не круг: med=0.636 sec=0.404 strong=0% < 0.8", "source": "demo"}
{"track_id": 42, "ts": "2026-07-28T17:24:33", "ts_ms": 1785248673969, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [124.0, 78.0, 30.5], "circle_ratio": 0.6206, "uncertain": false, "reason": "не круг: med=0.621 sec=0.480 strong=0% < 0.8", "source": "demo"}
{"track_id": 43, "ts": "2026-07-28T17:25:45", "ts_ms": 1785248745349, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [159.0, 67.2, 66.5], "circle_ratio": 0.9183, "uncertain": false, "reason": "круг: med=0.918 p75=0.925 top=0.918 sec=0.699 strong=17% >= 0.8", "source": "demo"}
{"track_id": 46, "ts": "2026-07-28T17:26:24", "ts_ms": 1785248784437, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [159.0, 67.2, 66.5], "circle_ratio": 0.9223, "uncertain": false, "reason": "круг: med=0.922 p75=0.930 top=0.922 sec=0.710 strong=25% >= 0.8", "source": "demo"}
{"track_id": 48, "ts": "2026-07-28T17:26:54", "ts_ms": 1785248814404, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [114.8, 89.9, 29.0], "circle_ratio": 0.7409, "uncertain": false, "reason": "не круг: med=0.741 sec=0.431 strong=0% < 0.8", "source": "demo"}
{"track_id": 52, "ts": "2026-07-28T17:27:27", "ts_ms": 1785248847471, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [114.9, 90.6, 29.0], "circle_ratio": 0.7631, "uncertain": false, "reason": "не круг: med=0.763 sec=0.399 strong=0% < 0.8", "source": "demo"}
{"track_id": 53, "ts": "2026-07-28T17:27:35", "ts_ms": 1785248855158, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [115.4, 90.4, 29.0], "circle_ratio": 0.7412, "uncertain": false, "reason": "не круг: med=0.741 sec=0.405 strong=0% < 0.8", "source": "demo"}
{"track_id": 56, "ts": "2026-07-28T17:27:53", "ts_ms": 1785248873493, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [115.7, 93.2, 29.0], "circle_ratio": 0.7572, "uncertain": false, "reason": "не круг: med=0.757 sec=0.422 strong=0% < 0.8", "source": "demo"}
{"track_id": 62, "ts": "2026-07-28T17:28:21", "ts_ms": 1785248901811, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [115.3, 77.5, 27.0], "circle_ratio": 0.6672, "uncertain": false, "reason": "не круг: med=0.667 sec=0.468 strong=0% < 0.8", "source": "demo"}
{"track_id": 64, "ts": "2026-07-28T17:28:36", "ts_ms": 1785248916874, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [131.2, 129.8, 35.3], "circle_ratio": 0.2606, "uncertain": false, "reason": "не круг: med=0.261 sec=0.114 strong=0% < 0.8", "source": "demo"}
{"track_id": 71, "ts": "2026-07-28T17:28:42", "ts_ms": 1785248922987, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [132.0, 90.5, 41.1], "circle_ratio": 0.2861, "uncertain": false, "reason": "не круг: med=0.286 sec=0.203 strong=0% < 0.8", "source": "demo"}
{"track_id": 107, "ts": "2026-07-28T17:30:46", "ts_ms": 1785249046521, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [170.8, 107.6, 47.0], "circle_ratio": 0.4542, "uncertain": false, "reason": "не круг: med=0.454 sec=0.186 strong=0% < 0.8", "source": "demo"}
{"track_id": 106, "ts": "2026-07-28T17:30:51", "ts_ms": 1785249051449, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [140.7, 91.8, 33.2], "circle_ratio": 0.2186, "uncertain": false, "reason": "не круг: med=0.219 sec=0.080 strong=0% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T17:33:53", "ts_ms": 1785249233659, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.5, 74.3, 28.0], "circle_ratio": 0.6687, "uncertain": false, "reason": "не круг: med=0.669 sec=0.397 strong=0% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T17:35:04", "ts_ms": 1785249304167, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.4, 79.1, 28.0], "circle_ratio": 0.6869, "uncertain": false, "reason": "не круг: med=0.687 sec=0.361 strong=0% < 0.8", "source": "demo"}
{"track_id": 3, "ts": "2026-07-28T17:35:15", "ts_ms": 1785249315975, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [110.6, 81.8, 37.0], "circle_ratio": 0.7137, "uncertain": false, "reason": "не круг: med=0.714 sec=0.374 strong=0% < 0.8", "source": "demo"}
{"track_id": 5, "ts": "2026-07-28T17:35:25", "ts_ms": 1785249325707, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [262.9, 150.5, 104.2], "circle_ratio": 0.3802, "uncertain": false, "reason": "не круг: med=0.380 sec=0.351 strong=0% < 0.8", "source": "demo"}
{"track_id": 7, "ts": "2026-07-28T17:35:31", "ts_ms": 1785249331386, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [114.1, 70.8, 29.0], "circle_ratio": 0.6109, "uncertain": false, "reason": "не круг: med=0.611 sec=0.474 strong=0% < 0.8", "source": "demo"}
{"track_id": 12, "ts": "2026-07-28T17:35:47", "ts_ms": 1785249347253, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [166.0, 69.1, 66.9], "circle_ratio": 0.8663, "uncertain": false, "reason": "круг: med=0.866 p75=0.880 top=0.866 sec=0.591 strong=0% >= 0.8", "source": "demo"}
{"track_id": 13, "ts": "2026-07-28T17:36:14", "ts_ms": 1785249374567, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [114.1, 71.2, 28.0], "circle_ratio": 0.6187, "uncertain": false, "reason": "не круг: med=0.619 sec=0.456 strong=0% < 0.8", "source": "demo"}
{"track_id": 22, "ts": "2026-07-28T17:38:51", "ts_ms": 1785249531777, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [201.5, 81.5, 33.7], "circle_ratio": 0.4074, "uncertain": false, "reason": "не круг: med=0.407 sec=0.213 strong=0% < 0.8", "source": "demo"}
{"track_id": 34, "ts": "2026-07-28T17:39:44", "ts_ms": 1785249584407, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [289.8, 233.5, 164.3], "circle_ratio": 0.5186, "uncertain": false, "reason": "не круг: med=0.519 sec=0.493 strong=0% < 0.8", "source": "demo"}
{"track_id": 38, "ts": "2026-07-28T17:39:46", "ts_ms": 1785249586344, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [263.0, 79.0, 28.8], "circle_ratio": 0.4856, "uncertain": false, "reason": "не круг: med=0.486 sec=0.404 strong=0% < 0.8", "source": "demo"}
{"track_id": 37, "ts": "2026-07-28T17:39:48", "ts_ms": 1785249588100, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [144.0, 55.3, 46.2], "circle_ratio": 0.6627, "uncertain": false, "reason": "не круг: med=0.663 sec=0.270 strong=0% < 0.8", "source": "demo"}
{"track_id": 39, "ts": "2026-07-28T17:39:54", "ts_ms": 1785249594385, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [257.0, 52.6, 34.0], "circle_ratio": 0.6318, "uncertain": false, "reason": "не круг: med=0.632 sec=0.259 strong=0% < 0.8", "source": "demo"}
{"track_id": 40, "ts": "2026-07-28T17:39:55", "ts_ms": 1785249595702, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [294.0, 230.0, 171.1], "circle_ratio": 0.5363, "uncertain": false, "reason": "не круг: med=0.536 sec=0.458 strong=0% < 0.8", "source": "demo"}
{"track_id": 55, "ts": "2026-07-28T17:40:25", "ts_ms": 1785249625885, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.3, 71.0, 27.0], "circle_ratio": 0.6289, "uncertain": false, "reason": "не круг: med=0.629 sec=0.475 strong=0% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T17:44:06", "ts_ms": 1785249846381, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [111.0, 71.2, 27.0], "circle_ratio": 0.6391, "uncertain": false, "reason": "не круг: med=0.639 sec=0.471 strong=0% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T17:47:47", "ts_ms": 1785250067275, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [112.9, 72.5, 27.0], "circle_ratio": 0.6322, "uncertain": false, "reason": "не круг: med=0.632 sec=0.480 strong=0% < 0.8", "source": "demo"}
{"track_id": 2, "ts": "2026-07-28T17:47:57", "ts_ms": 1785250077360, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [157.0, 70.9, 67.3], "circle_ratio": 0.8678, "uncertain": false, "reason": "круг: med=0.868 p75=0.889 top=0.863 sec=0.795 strong=50% >= 0.8", "source": "demo"}
{"track_id": 3, "ts": "2026-07-28T17:48:15", "ts_ms": 1785250095948, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [158.0, 69.2, 66.6], "circle_ratio": 0.8646, "uncertain": false, "reason": "круг: med=0.865 p75=0.887 top=0.865 sec=0.647 strong=17% >= 0.8", "source": "demo"}
{"track_id": 2, "ts": "2026-07-28T17:51:16", "ts_ms": 1785250276251, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [111.4, 72.8, 27.0], "circle_ratio": 0.6383, "uncertain": false, "reason": "не круг: med=0.638 sec=0.476 strong=0% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T17:51:16", "ts_ms": 1785250276438, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [158.0, 68.4, 66.6], "circle_ratio": 0.8829, "uncertain": false, "reason": "круг: med=0.883 p75=0.909 top=0.883 sec=0.649 strong=17% >= 0.8", "source": "demo"}
{"track_id": 2, "ts": "2026-07-28T17:53:05", "ts_ms": 1785250385585, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [113.0, 71.3, 27.0], "circle_ratio": 0.6264, "uncertain": false, "reason": "не круг: med=0.626 sec=0.476 strong=0% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T17:53:05", "ts_ms": 1785250385762, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [158.0, 67.3, 66.0], "circle_ratio": 0.9261, "uncertain": false, "reason": "круг: med=0.926 p75=0.926 top=0.926 sec=0.537 strong=8% >= 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T17:58:13", "ts_ms": 1785250693238, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [158.0, 67.4, 66.3], "circle_ratio": 0.9117, "uncertain": false, "reason": "круг: med=0.912 p75=0.918 top=0.912 sec=0.875 strong=83% >= 0.8", "source": "demo"}
{"track_id": 2, "ts": "2026-07-28T17:58:13", "ts_ms": 1785250693405, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [111.8, 71.7, 27.0], "circle_ratio": 0.6342, "uncertain": false, "reason": "не круг: med=0.634 sec=0.486 strong=0% < 0.8", "source": "demo"}
{"track_id": 7, "ts": "2026-07-28T17:58:42", "ts_ms": 1785250722062, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [199.8, 134.5, 126.0], "circle_ratio": 0.5803, "uncertain": false, "reason": "не круг: med=0.580 sec=0.262 strong=0% < 0.8", "source": "demo"}
{"track_id": 8, "ts": "2026-07-28T17:58:48", "ts_ms": 1785250728812, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [196.0, 68.2, 64.5], "circle_ratio": 0.8816, "uncertain": false, "reason": "круг: med=0.882 p75=0.889 top=0.882 sec=0.593 strong=33% >= 0.8", "source": "demo"}
{"track_id": 11, "ts": "2026-07-28T17:59:19", "ts_ms": 1785250759939, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [264.0, 30.6, 16.7], "circle_ratio": 0.5504, "uncertain": false, "reason": "не круг: med=0.550 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"track_id": 19, "ts": "2026-07-28T18:01:29", "ts_ms": 1785250889343, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [132.0, 99.5, 73.8], "circle_ratio": 0.634, "uncertain": false, "reason": "не круг: med=0.634 sec=0.369 strong=0% < 0.8", "source": "demo"}
{"track_id": 21, "ts": "2026-07-28T18:01:41", "ts_ms": 1785250901079, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [162.0, 78.7, 69.2], "circle_ratio": 0.7707, "uncertain": false, "reason": "не круг: med=0.771 sec=0.536 strong=8% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T18:05:03", "ts_ms": 1785251103971, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [195.0, 71.4, 67.9], "circle_ratio": 0.8496, "uncertain": false, "reason": "круг: med=0.850 p75=0.850 top=0.829 sec=0.725 strong=50% >= 0.8", "source": "demo"}
{"track_id": 2, "ts": "2026-07-28T18:05:07", "ts_ms": 1785251107380, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [162.0, 74.2, 70.5], "circle_ratio": 0.7413, "uncertain": false, "reason": "не круг: med=0.741 sec=0.598 strong=0% < 0.8", "source": "demo"}
{"track_id": 5, "ts": "2026-07-28T18:05:35", "ts_ms": 1785251135664, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [115.8, 78.0, 30.0], "circle_ratio": 0.6635, "uncertain": false, "reason": "не круг: med=0.663 sec=0.443 strong=0% < 0.8", "source": "demo"}
{"track_id": 10, "ts": "2026-07-28T18:06:17", "ts_ms": 1785251177171, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [183.0, 67.8, 48.5], "circle_ratio": 0.6256, "uncertain": false, "reason": "не круг: med=0.626 sec=0.465 strong=0% < 0.8", "source": "demo"}
{"track_id": 11, "ts": "2026-07-28T18:07:07", "ts_ms": 1785251227738, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [161.0, 67.5, 64.0], "circle_ratio": 0.9018, "uncertain": false, "reason": "круг: med=0.902 p75=0.914 top=0.902 sec=0.329 strong=0% >= 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T18:09:48", "ts_ms": 1785251388160, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [169.1, 143.5, 0.0], "circle_ratio": 0.6503, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 3, "ts": "2026-07-28T18:10:00", "ts_ms": 1785251400844, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [468.5, 78.4, 0.0], "circle_ratio": 0.1729, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 4, "ts": "2026-07-28T18:10:01", "ts_ms": 1785251401838, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [193.6, 83.4, 0.0], "circle_ratio": 0.4252, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 9, "ts": "2026-07-28T18:10:01", "ts_ms": 1785251401838, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [186.8, 106.4, 16.7], "circle_ratio": 0.2125, "uncertain": false, "reason": "не круг: med=0.212 sec=0.202 strong=0% < 0.8", "source": "demo"}
{"track_id": 7, "ts": "2026-07-28T18:10:02", "ts_ms": 1785251402341, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [167.0, 96.5, 71.5], "circle_ratio": 0.6402, "uncertain": false, "reason": "не круг: med=0.640 sec=0.398 strong=0% < 0.8", "source": "demo"}
{"track_id": 4, "ts": "2026-07-28T18:10:17", "ts_ms": 1785251417745, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [167.0, 65.9, 65.2], "circle_ratio": 0.8773, "uncertain": false, "reason": "круг: med=0.877 p75=0.894 top=0.877 sec=0.659 strong=25% >= 0.8", "source": "demo"}
{"track_id": 6, "ts": "2026-07-28T18:10:18", "ts_ms": 1785251418644, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [160.9, 80.6, 0.0], "circle_ratio": 0.4968, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 16, "ts": "2026-07-28T18:10:45", "ts_ms": 1785251445863, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [107.5, 34.8, 0.0], "circle_ratio": 0.3281, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 20, "ts": "2026-07-28T18:10:47", "ts_ms": 1785251447110, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [162.7, 104.5, 0.0], "circle_ratio": 0.5509, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 21, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481084, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [72.2, 37.5, 34.9], "circle_ratio": 0.4618, "uncertain": false, "reason": "не круг: med=0.462 sec=0.226 strong=0% < 0.8", "source": "demo"}
{"track_id": 22, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481084, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [192.8, 110.8, 68.0], "circle_ratio": 0.5676, "uncertain": false, "reason": "не круг: med=0.568 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"track_id": 23, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481085, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [122.4, 62.8, 0.0], "circle_ratio": 0.4646, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 24, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481085, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [137.2, 31.9, 0.0], "circle_ratio": 0.2345, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 25, "ts": "2026-07-28T18:11:21", "ts_ms": 1785251481086, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [103.7, 47.0, 31.9], "circle_ratio": 0.3144, "uncertain": false, "reason": "не круг: med=0.314 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"track_id": 29, "ts": "2026-07-28T18:11:59", "ts_ms": 1785251519486, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [86.5, 54.9, 0.0], "circle_ratio": 0.595, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 28, "ts": "2026-07-28T18:12:00", "ts_ms": 1785251520717, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [89.6, 62.3, 0.0], "circle_ratio": 0.6456, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 30, "ts": "2026-07-28T18:12:13", "ts_ms": 1785251533754, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [105.2, 71.6, 52.0], "circle_ratio": 0.5827, "uncertain": false, "reason": "не круг: med=0.583 sec=0.000 strong=0% < 0.8", "source": "demo"}
{"track_id": 5, "ts": "2026-07-28T18:12:58", "ts_ms": 1785251578253, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [167.0, 68.5, 66.5], "circle_ratio": 0.902, "uncertain": false, "reason": "круг: med=0.902 p75=0.909 top=0.902 sec=0.574 strong=0% >= 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T18:13:14", "ts_ms": 1785251594940, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [156.6, 78.5, 1.0], "circle_ratio": 0.4847, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 6, "ts": "2026-07-28T18:13:20", "ts_ms": 1785251600288, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [126.3, 85.1, 1.0], "circle_ratio": 0.5677, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 10, "ts": "2026-07-28T18:13:40", "ts_ms": 1785251620975, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [59.9, 43.0, 1.0], "circle_ratio": 0.5168, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 27, "ts": "2026-07-28T18:14:27", "ts_ms": 1785251667925, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [362.4, 140.9, 1.0], "circle_ratio": 0.3353, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 26, "ts": "2026-07-28T18:15:02", "ts_ms": 1785251702136, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [150.8, 106.5, 37.4], "circle_ratio": 0.2563, "uncertain": false, "reason": "не круг: med=0.256 sec=0.244 strong=0% < 0.8", "source": "demo"}
{"track_id": 37, "ts": "2026-07-28T18:15:02", "ts_ms": 1785251702537, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [103.4, 61.4, 7.0], "circle_ratio": 0.6081, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 38, "ts": "2026-07-28T18:15:02", "ts_ms": 1785251702740, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [97.6, 28.9, 8.0], "circle_ratio": 0.3021, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 6, "ts": "2026-07-28T18:39:25", "ts_ms": 1785253165067, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [166.0, 69.6, 62.4], "circle_ratio": 0.8589, "uncertain": false, "reason": "круг: med=0.859 p75=0.864 top=0.859 sec=0.355 strong=0% >= 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T18:42:41", "ts_ms": 1785253361213, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [97.8, 94.8, 37.2], "circle_ratio": 0.4546, "uncertain": false, "reason": "не круг: med=0.455 sec=0.380 strong=0% < 0.8", "source": "demo"}
{"track_id": 3, "ts": "2026-07-28T18:42:41", "ts_ms": 1785253361214, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [102.4, 22.9, 10.0], "circle_ratio": 0.2332, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 4, "ts": "2026-07-28T18:42:41", "ts_ms": 1785253361214, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [42.8, 31.4, 9.5], "circle_ratio": 0.6757, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 2, "ts": "2026-07-28T18:42:44", "ts_ms": 1785253364281, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [54.8, 33.5, 7.0], "circle_ratio": 0.5549, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 6, "ts": "2026-07-28T18:42:50", "ts_ms": 1785253370683, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [47.7, 32.6, 11.0], "circle_ratio": 0.6536, "uncertain": true, "reason": "НЕУВЕРЕННО → безопасная зона: нет консенсуса 45 кадров; голоса B:11 C:1; сторона 11 мм у минимума 10", "source": "demo"}
{"track_id": 15, "ts": "2026-07-28T18:43:30", "ts_ms": 1785253410234, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [167.0, 67.6, 66.2], "circle_ratio": 0.9095, "uncertain": false, "reason": "круг: med=0.909 p75=0.925 top=0.909 sec=0.413 strong=0% >= 0.8", "source": "demo"}
{"track_id": 16, "ts": "2026-07-28T18:43:31", "ts_ms": 1785253411985, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [86.5, 51.3, 2.0], "circle_ratio": 0.55, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 2, "ts": "2026-07-28T18:46:01", "ts_ms": 1785253561603, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [200.0, 84.1, 66.1], "circle_ratio": 0.89, "uncertain": false, "reason": "круг: med=0.890 p75=0.897 top=0.763 sec=0.890 strong=92% >= 0.8", "source": "demo"}
{"track_id": 4, "ts": "2026-07-28T18:46:13", "ts_ms": 1785253573851, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [167.0, 71.8, 64.2], "circle_ratio": 0.8484, "uncertain": false, "reason": "круг: med=0.848 p75=0.855 top=0.848 sec=0.585 strong=0% >= 0.8", "source": "demo"}
{"track_id": 6, "ts": "2026-07-28T18:46:25", "ts_ms": 1785253585551, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [97.9, 73.4, 31.0], "circle_ratio": 0.698, "uncertain": false, "reason": "не круг: med=0.698 sec=0.393 strong=0% < 0.8", "source": "demo"}
{"track_id": 11, "ts": "2026-07-28T18:51:13", "ts_ms": 1785253873589, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [206.3, 166.0, 7.7], "circle_ratio": 0.0474, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T18:58:33", "ts_ms": 1785254313543, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [201.0, 78.6, 68.5], "circle_ratio": 0.8635, "uncertain": false, "reason": "круг: med=0.863 p75=0.911 top=0.816 sec=0.757 strong=50% >= 0.8", "source": "demo"}
{"track_id": 5, "ts": "2026-07-28T18:58:56", "ts_ms": 1785254336468, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [201.0, 80.8, 66.5], "circle_ratio": 0.7914, "uncertain": false, "reason": "не круг: med=0.791 sec=0.524 strong=0% < 0.8", "source": "demo"}
{"track_id": 8, "ts": "2026-07-28T18:59:15", "ts_ms": 1785254355875, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [165.0, 86.0, 63.6], "circle_ratio": 0.851, "uncertain": false, "reason": "круг: med=0.851 p75=0.870 top=0.707 sec=0.851 strong=75% >= 0.8", "source": "demo"}
{"track_id": 10, "ts": "2026-07-28T18:59:33", "ts_ms": 1785254373589, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [201.0, 90.2, 69.2], "circle_ratio": 0.7267, "uncertain": false, "reason": "не круг: med=0.727 sec=0.536 strong=17% < 0.8", "source": "demo"}
{"track_id": 13, "ts": "2026-07-28T18:59:39", "ts_ms": 1785254379729, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [167.0, 87.3, 64.5], "circle_ratio": 0.7114, "uncertain": false, "reason": "не круг: med=0.711 sec=0.605 strong=0% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T19:03:15", "ts_ms": 1785254595565, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [201.0, 81.8, 77.6], "circle_ratio": 0.822, "uncertain": false, "reason": "круг: med=0.822 p75=0.909 top=0.759 sec=0.704 strong=50% >= 0.8", "source": "demo"}
{"track_id": 5, "ts": "2026-07-28T19:03:34", "ts_ms": 1785254614891, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [176.5, 43.1, 16.0], "circle_ratio": 0.2907, "uncertain": false, "reason": "не круг: med=0.291 sec=0.291 strong=0% < 0.8", "source": "demo"}
{"track_id": 1, "ts": "2026-07-28T19:06:34", "ts_ms": 1785254794513, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [164.0, 67.6, 65.5], "circle_ratio": 0.9175, "uncertain": false, "reason": "круг: med=0.917 p75=0.920 top=0.917 sec=0.430 strong=17% >= 0.8", "source": "demo"}
{"track_id": 2, "ts": "2026-07-28T19:06:37", "ts_ms": 1785254797835, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [88.1, 47.0, 40.8], "circle_ratio": 0.4338, "uncertain": false, "reason": "не круг: med=0.434 sec=0.298 strong=0% < 0.8", "source": "demo"}
{"track_id": 4, "ts": "2026-07-28T19:06:46", "ts_ms": 1785254806783, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [164.0, 66.9, 66.2], "circle_ratio": 0.9188, "uncertain": false, "reason": "круг: med=0.919 p75=0.925 top=0.919 sec=0.351 strong=0% >= 0.8", "source": "demo"}
{"track_id": 6, "ts": "2026-07-28T19:06:57", "ts_ms": 1785254817413, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [183.0, 161.4, 95.1], "circle_ratio": 0.5757, "uncertain": false, "reason": "не круг: med=0.576 sec=0.287 strong=0% < 0.8", "source": "demo"}
{"track_id": 8, "ts": "2026-07-28T19:07:24", "ts_ms": 1785254844930, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [162.0, 75.6, 71.9], "circle_ratio": 0.7303, "uncertain": false, "reason": "не круг: med=0.730 sec=0.619 strong=17% < 0.8", "source": "demo"}
{"track_id": 9, "ts": "2026-07-28T19:07:34", "ts_ms": 1785254854571, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [54.4, 42.5, 24.2], "circle_ratio": 0.4419, "uncertain": false, "reason": "не круг: med=0.442 sec=0.239 strong=0% < 0.8", "source": "demo"}
{"track_id": 11, "ts": "2026-07-28T19:08:24", "ts_ms": 1785254904478, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [196.0, 67.9, 60.7], "circle_ratio": 0.8855, "uncertain": false, "reason": "круг: med=0.885 p75=0.888 top=0.885 sec=0.453 strong=0% >= 0.8", "source": "demo"}
{"track_id": 12, "ts": "2026-07-28T19:08:43", "ts_ms": 1785254923363, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [117.4, 73.2, 26.0], "circle_ratio": 0.6211, "uncertain": false, "reason": "не круг: med=0.621 sec=0.323 strong=0% < 0.8", "source": "demo"}
{"track_id": 16, "ts": "2026-07-28T19:09:31", "ts_ms": 1785254971629, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [93.9, 69.5, 14.0], "circle_ratio": 0.6304, "uncertain": false, "reason": "не круг: med=0.630 sec=0.248 strong=0% < 0.8", "source": "demo"}
{"track_id": null, "ts": "2026-08-02T21:12:54", "ts_ms": 1785679974276, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [517.1, 310.7, 63.0], "circle_ratio": 0.5169, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:12:56", "ts_ms": 1785679976189, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [506.7, 162.8, 63.0], "circle_ratio": 0.3102, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:13:02", "ts_ms": 1785679982132, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [514.2, 167.6, 63.0], "circle_ratio": 0.313, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:13:24", "ts_ms": 1785680004914, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [511.3, 163.0, 63.0], "circle_ratio": 0.3138, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:19:16", "ts_ms": 1785680356388, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [173.0, 66.5, 65.5], "circle_ratio": 0.9098, "uncertain": false, "reason": "круг: med=0.910 p75=0.912 top=0.910 sec=0.000 strong=0% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:19:17", "ts_ms": 1785680357229, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [111.7, 97.8, 95.5], "circle_ratio": 0.7744, "uncertain": false, "reason": "не круг: med=0.774 sec=0.134 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:19:17", "ts_ms": 1785680357697, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [173.0, 66.3, 65.6], "circle_ratio": 0.9091, "uncertain": false, "reason": "круг: med=0.909 p75=0.911 top=0.909 sec=0.000 strong=0% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:19:19", "ts_ms": 1785680359211, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [177.2, 132.3, 17.5], "circle_ratio": 0.5422, "uncertain": false, "reason": "не круг: med=0.542 sec=0.227 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:21:19", "ts_ms": 1785680480000, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [173.0, 66.0, 65.6], "circle_ratio": 0.9254, "uncertain": false, "reason": "круг: med=0.925 p75=0.930 top=0.925 sec=0.000 strong=0% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:25:16", "ts_ms": 1785680716591, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [242.0, 173.0, 101.4], "circle_ratio": 0.5338, "uncertain": false, "reason": "не круг: med=0.534 sec=0.424 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:27:38", "ts_ms": 1785680858442, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [209.5, 79.4, 55.3], "circle_ratio": 0.5325, "uncertain": false, "reason": "не круг: med=0.533 sec=0.316 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:28:12", "ts_ms": 1785680892822, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [286.8, 123.5, 115.0], "circle_ratio": 0.4296, "uncertain": false, "reason": "не круг: med=0.430 sec=0.422 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:49:09", "ts_ms": 1785682149066, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [251.7, 115.1, 113.0], "circle_ratio": 0.4398, "uncertain": false, "reason": "не круг: med=0.440 sec=0.410 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T21:58:32", "ts_ms": 1785682712615, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [342.4, 161.5, 89.4], "circle_ratio": 0.2802, "uncertain": false, "reason": "не круг: med=0.280 sec=0.247 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:04:17", "ts_ms": 1785683057585, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 337.6, 133.0], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:20:32", "ts_ms": 1785684032190, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 248.5, 108.1], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:20:36", "ts_ms": 1785684036590, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [172.0, 66.3, 65.6], "circle_ratio": 0.9237, "uncertain": false, "reason": "круг: med=0.924 p75=0.926 top=0.924 sec=0.000 strong=17% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:22:50", "ts_ms": 1785684170019, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 277.0, 71.8], "circle_ratio": 0.394, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:22:54", "ts_ms": 1785684174185, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [172.0, 67.2, 66.1], "circle_ratio": 0.9154, "uncertain": false, "reason": "круг: med=0.915 p75=0.919 top=0.915 sec=0.000 strong=0% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:23:49", "ts_ms": 1785684229059, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 235.5, 97.4], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:35:41", "ts_ms": 1785684941178, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [254.7, 80.2, 67.0], "circle_ratio": 0.3161, "uncertain": false, "reason": "не круг: med=0.316 sec=0.204 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:46:12", "ts_ms": 1785685572325, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [241.9, 116.9, 73.5], "circle_ratio": 0.4709, "uncertain": false, "reason": "не круг: med=0.471 sec=0.392 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:46:12", "ts_ms": 1785685572795, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 216.9, 65.0], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:46:13", "ts_ms": 1785685573398, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [254.0, 109.1, 77.0], "circle_ratio": 0.4083, "uncertain": false, "reason": "не круг: med=0.408 sec=0.225 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:49:22", "ts_ms": 1785685762401, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [183.6, 167.2, 52.0], "circle_ratio": 0.6645, "uncertain": false, "reason": "не круг: med=0.664 sec=0.221 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:49:30", "ts_ms": 1785685770563, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [72.1, 66.0, 24.4], "circle_ratio": 0.3429, "uncertain": false, "reason": "не круг: med=0.343 sec=0.297 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:50:10", "ts_ms": 1785685810145, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [68.5, 39.1, 22.0], "circle_ratio": 0.5736, "uncertain": false, "reason": "не круг: med=0.574 sec=0.260 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:51:59", "ts_ms": 1785685919615, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [69.0, 55.4, 21.0], "circle_ratio": 0.6302, "uncertain": false, "reason": "не круг: med=0.630 sec=0.170 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T22:54:22", "ts_ms": 1785686062886, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [70.0, 53.8, 21.0], "circle_ratio": 0.5805, "uncertain": false, "reason": "не круг: med=0.580 sec=0.236 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:29:06", "ts_ms": 1785688146105, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [66.4, 55.4, 21.0], "circle_ratio": 0.563, "uncertain": false, "reason": "не круг: med=0.563 sec=0.151 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:29:06", "ts_ms": 1785688146710, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [110.3, 63.1, 21.0], "circle_ratio": 0.5844, "uncertain": false, "reason": "не круг: med=0.584 sec=0.187 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:29:15", "ts_ms": 1785688155513, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [76.7, 59.0, 21.0], "circle_ratio": 0.5275, "uncertain": false, "reason": "не круг: med=0.527 sec=0.152 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:29:35", "ts_ms": 1785688175908, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [102.0, 56.3, 21.0], "circle_ratio": 0.5069, "uncertain": false, "reason": "не круг: med=0.507 sec=0.134 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:31:21", "ts_ms": 1785688281179, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [66.6, 28.9, 23.0], "circle_ratio": 0.4759, "uncertain": false, "reason": "не круг: med=0.476 sec=0.452 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:35:46", "ts_ms": 1785688546219, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [124.8, 46.5, 22.0], "circle_ratio": 0.3655, "uncertain": false, "reason": "не круг: med=0.365 sec=0.133 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:35:50", "ts_ms": 1785688550318, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [119.4, 52.3, 22.0], "circle_ratio": 0.4207, "uncertain": false, "reason": "не круг: med=0.421 sec=0.145 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:35:51", "ts_ms": 1785688551695, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [92.8, 33.2, 22.0], "circle_ratio": 0.4346, "uncertain": false, "reason": "не круг: med=0.435 sec=0.290 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:54:47", "ts_ms": 1785689687830, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [93.7, 30.0, 22.0], "circle_ratio": 0.3504, "uncertain": false, "reason": "не круг: med=0.350 sec=0.186 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:54:51", "ts_ms": 1785689691122, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [121.5, 41.8, 22.0], "circle_ratio": 0.3626, "uncertain": false, "reason": "не круг: med=0.363 sec=0.159 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-02T23:54:53", "ts_ms": 1785689693104, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [80.1, 29.2, 21.5], "circle_ratio": 0.4371, "uncertain": false, "reason": "не круг: med=0.437 sec=0.327 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:06:08", "ts_ms": 1785690368310, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 240.0, 105.8], "circle_ratio": 0.3071, "uncertain": false, "reason": "объект обрезан краем кадра → габарит неполный, считаем негабаритом", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:18", "ts_ms": 1785692778248, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [75.3, 55.6, 53.0], "circle_ratio": 0.6405, "uncertain": false, "reason": "не круг: med=0.641 sec=0.399 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:25", "ts_ms": 1785692785439, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [77.1, 70.0, 54.4], "circle_ratio": 0.6187, "uncertain": false, "reason": "не круг: med=0.619 sec=0.438 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:29", "ts_ms": 1785692789873, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [76.9, 71.5, 25.9], "circle_ratio": 0.3387, "uncertain": false, "reason": "не круг: med=0.339 sec=0.108 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:46", "ts_ms": 1785692806035, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [119.5, 56.8, 47.9], "circle_ratio": 0.6865, "uncertain": false, "reason": "не круг: med=0.686 sec=0.212 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:47", "ts_ms": 1785692807010, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [169.0, 68.6, 65.3], "circle_ratio": 0.8745, "uncertain": false, "reason": "круг: med=0.874 p75=0.899 top=0.874 sec=0.405 strong=8% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:49", "ts_ms": 1785692809570, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [126.0, 48.6, 34.2], "circle_ratio": 0.6214, "uncertain": false, "reason": "не круг: med=0.621 sec=0.243 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:52", "ts_ms": 1785692812625, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 204.4, 97.0], "circle_ratio": 0.5, "uncertain": false, "reason": "габариты вне допуска: нужно >10×10×10 и <450×320×320 мм", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:53", "ts_ms": 1785692813095, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [285.5, 204.4, 97.0], "circle_ratio": 0.6026, "uncertain": false, "reason": "не круг: med=0.603 sec=0.185 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:46:57", "ts_ms": 1785692817697, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [207.0, 204.5, 60.5], "circle_ratio": 0.9458, "uncertain": false, "reason": "круг: med=0.946 p75=0.952 top=0.946 sec=0.155 strong=0% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:47:00", "ts_ms": 1785692820148, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [204.8, 120.8, 70.0], "circle_ratio": 0.5863, "uncertain": false, "reason": "не круг: med=0.586 sec=0.345 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:48:01", "ts_ms": 1785692881972, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [132.0, 59.4, 51.6], "circle_ratio": 0.6828, "uncertain": false, "reason": "не круг: med=0.683 sec=0.295 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:48:02", "ts_ms": 1785692882612, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [168.0, 70.4, 66.4], "circle_ratio": 0.8631, "uncertain": false, "reason": "круг: med=0.863 p75=0.876 top=0.855 sec=0.735 strong=42% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:48:05", "ts_ms": 1785692885397, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [142.5, 48.4, 29.5], "circle_ratio": 0.5428, "uncertain": false, "reason": "не круг: med=0.543 sec=0.270 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:48:09", "ts_ms": 1785692889600, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [76.5, 71.5, 57.9], "circle_ratio": 0.6528, "uncertain": false, "reason": "не круг: med=0.653 sec=0.452 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:49:06", "ts_ms": 1785692946351, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 117.0, 45.2], "circle_ratio": 0.1306, "uncertain": false, "reason": "объект обрезан краем кадра → габарит неполный, считаем негабаритом", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:49:45", "ts_ms": 1785692985263, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 190.5, 161.5], "circle_ratio": 0.4301, "uncertain": false, "reason": "объект обрезан краем кадра → габарит неполный, считаем негабаритом", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:18", "ts_ms": 1785693018054, "zone": "C", "category": "oversize", "label_ru": "Не подходит для сортировки по габаритам", "dims_mm": [500.0, 113.5, 43.9], "circle_ratio": 0.1363, "uncertain": false, "reason": "объект обрезан краем кадра → габарит неполный, считаем негабаритом", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:25", "ts_ms": 1785693025762, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [86.5, 75.3, 72.0], "circle_ratio": 0.6856, "uncertain": false, "reason": "не круг: med=0.686 sec=0.363 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:45", "ts_ms": 1785693045940, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [55.0, 49.2, 26.3], "circle_ratio": 0.5988, "uncertain": false, "reason": "не круг: med=0.599 sec=0.221 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:47", "ts_ms": 1785693047050, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [81.0, 50.1, 48.4], "circle_ratio": 0.8419, "uncertain": false, "reason": "круг: med=0.842 p75=0.855 top=0.842 sec=0.528 strong=0% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:51", "ts_ms": 1785693051014, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [125.0, 55.1, 46.2], "circle_ratio": 0.6391, "uncertain": false, "reason": "не круг: med=0.639 sec=0.331 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:51", "ts_ms": 1785693051992, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [179.0, 48.6, 47.1], "circle_ratio": 0.9012, "uncertain": false, "reason": "круг: med=0.901 p75=0.909 top=0.901 sec=0.515 strong=0% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:54", "ts_ms": 1785693054037, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [158.5, 50.1, 46.1], "circle_ratio": 0.5833, "uncertain": false, "reason": "не круг: med=0.583 sec=0.326 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:57", "ts_ms": 1785693057600, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [172.9, 61.0, 54.8], "circle_ratio": 0.4696, "uncertain": false, "reason": "не круг: med=0.470 sec=0.470 strong=0% < 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:50:59", "ts_ms": 1785693059249, "zone": "D", "category": "need_pack", "label_ru": "Не подходит для сортировки без доупаковки", "dims_mm": [205.8, 205.0, 61.0], "circle_ratio": 0.9515, "uncertain": false, "reason": "круг: med=0.952 p75=0.952 top=0.952 sec=0.187 strong=0% >= 0.8", "source": "main"}
{"track_id": null, "ts": "2026-08-03T00:51:01", "ts_ms": 1785693061632, "zone": "B", "category": "suitable", "label_ru": "Подходит для сортировки", "dims_mm": [205.3, 117.9, 70.0], "circle_ratio": 0.5758, "uncertain": false, "reason": "не круг: med=0.576 sec=0.329 strong=0% < 0.8", "source": "main"}

320
vision_classifier/main.py Executable file
View File

@@ -0,0 +1,320 @@
#!/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 load_config(path: Path) -> Dict[str, Any]:
with open(path, "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())

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:
"""
Поперечные срезы. Из логов: у круга часто 1–2 среза ≥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)

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

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 читается через него

12
vision_classifier/run.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
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 "$@"

View File

@@ -0,0 +1,306 @@
"""
Стабильный консенсус по кадрам.
Из логов RealSense: круг даёт sec≈0.82–0.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)

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")

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")

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, # ~15–20 с помнить «уже учтён»
) -> 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