Сортировочная ячейка Isaac Sim: CV-пайплайн и меши товаров

Замкнутый контур "поток -> CV -> механика": товары идут по конвейеру с шагом 700 мм,
класс определяется стереопайплайном во время движения, пушер и плуг реагируют физически.

Состав:
* control_test/ - ячейка и CV. run_sorting_cv.py + cv_worker.py (два процесса, потому что
  torch внутри Isaac роняет сцену), cell.py (физика лент, плуга, пушера), measure_plane.py
  (замер габаритов), README.md и .memory.md с замерами, проблемами и ловушками
* robozon_sorter/ - модули симуляции, scripts/ - утилиты, scene/ - сцены
* assets/ - меши товаров, плуг, объекты Objaverse

Бейзлайн CV: DEFOM-Stereo vitl, вход 480, iters 24, кроп зоны осмотра, без сегментации.
На потоке 700 мм - классы 8/9, габариты MAE 32.8 мм, 469 мс на товар при такте 700 мс.

Веса моделей (4.5 ГБ) и пропсы конвейера NVIDIA (274 МБ) не включены - источники и
команды скачивания в MODELS.md. Выход прогонов (captures/, runtime/) не включён:
воспроизводится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dasha_f
2026-08-01 13:07:24 +00:00
parent 6ce460378a
commit 0d32f32db0
342 changed files with 18000 additions and 0 deletions
+493
View File
@@ -0,0 +1,493 @@
# control_test — сортировочная ячейка с папкой объектов
Автономный стенд: конвейер + пушер + плуг из `plow_cell_90_45_test.usd`, где **набор
товаров берётся из папки `items/`**. Положили новый `.usd` — он попадает в следующий
прогон. Ничего не зашито под конкретный объект.
```
control_test/
├── scene/plow_cell_90_45_test.usd сцена (ссылается на ../assets → симлинк на assets проекта)
├── items/ меши товаров: *.usd + textures/ + labels.json
├── classify.py чтение разметки + правила B/C/D (для проверки)
├── cell.py физика ячейки: ленты, плуг, пушер, стыки, свет, пол
├── run_pipeline.py прогон сортировки: спавн из items/ по очереди, отчёт
│ ── стенд замера габаритов камерами (раздел 9) ──
├── cam_configs.py расстановки камер; DEFAULT = "E60" — рабочая
├── capture_roi.py ЭТАП 1 в Isaac: рендер L/R по всем ригам
├── measure_roi.py ЭТАП 2 отдельным процессом: FastSAM + CREStereo
├── captures/<CFG>/ кадры, manifest.json, roi_compare.json
│ ── замкнутый контур: поток -> CV -> механика (раздел 10) ──
├── run_sorting_cv.py прогон сцены: поток, ворота, пушер, плуг по классу от CV
├── cv_worker.py процесс CV: DEFOM -> габариты -> k -> класс
├── measure_plane.py сам замер; здесь зафиксирован бейзлайн
├── runtime/ обмен заявками и ответами, кадры, результат прогона
├── .memory.md СОСТОЯНИЕ ПАЙПЛАЙНА: замеры, проблемы, ловушки
└── diag/ одноразовые диагностики, не часть пайплайна
```
**Устаревшие файлы** помечены заголовком `SUPERSEDED` и оставлены только как история:
`capture_cfg.py` (тихо снимал пустую ленту), `measure_cfg.py` (мерил ленту вместо
товара), `reposition.py` (зашитые 600 мм). Использовать их нельзя.
## 1. Запуск Isaac Sim
Isaac Sim 6.0.1 стоит под пользователем `whatevenif`, запускается со стримингом WebRTC
и включённым python-сервером (TCP 8226) — через него в симулятор шлётся код.
```bash
cd /home/whatevenif/isaacsim
nohup ./kit/kit ./apps/isaacsim.exp.full.streaming.kit \
--no-window --no-ros-env \
--enable isaacsim.code_editor.python_server \
--/exts/omni.kit.livestream.app/primaryStream.publicIp=46.39.224.77 \
--/exts/omni.services.livestream.session/quitOnSessionEnded=false \
> /tmp/isaac.log 2>&1 &
```
Готовность:
```bash
grep -q "app ready" /tmp/isaac.log && ss -ltn | grep 8226 # порт должен слушать
```
С другой машины порт 8226 пробрасывается ssh-туннелем:
```bash
ssh -N -L 8226:127.0.0.1:8226 dasha@46.39.224.77 &
```
## 2. Открыть сцену
```bash
cd /home/dasha/robozon-sorter
python3 isaacsim_send.py --timeout 120 \
--file ~/.claude/skills/isaac-sim-remote/scripts/open_stage.py \
--arg action=open \
--arg usd_path=/home/dasha/robozon-sorter/control_test/scene/plow_cell_90_45_test.usd
```
Должно ответить `Stage prims: 366`. Сцену надо открывать **заново перед каждым прогоном**
`run_pipeline.py` меняет состояние сцены (удаляет графы, снимает коллизии, спавнит тела).
## 3. Прогон
```bash
cd /home/dasha/robozon-sorter
python3 isaacsim_send.py --context ct --timeout 400 --execution-timeout 390 \
--file control_test/run_pipeline.py
```
Только часть объектов / другие параметры:
```bash
python3 isaacsim_send.py --context ct --timeout 400 --execution-timeout 390 \
--args-json '{"only": ["bag","lunchbox"], "pitch": 1.4, "plow_angle": 20}' \
--file control_test/run_pipeline.py
```
| аргумент | по умолчанию | что делает |
|---|---|---|
| `speed` | 1.0 | скорость лент, м/с |
| `pitch` | 1.4 | расстояние между товарами, м |
| `plow_angle` | 20 | угол плуга, град (B = −угол, C = +угол) |
| `swing_margin` | 0.25 | доля T_pitch на поворот; меньше → быстрее плуг |
| `plow_hold_max` | 4.0 | сколько плуг держит угол, с |
| `only` | все | список имён объектов |
| `limit` | 0 | взять первые N |
## 4. Как добавить свой товар
1. Положить `<имя>.usd` в `items/` (текстуры — в `items/textures/`).
2. Дописать строку в `items/labels.json`:
```json
"my_part": { "zone": "D", "dims_mm": [220, 180, 175], "k": 0.91 }
```
3. Прогнать — товар подхватится сам.
**Классы берутся из разметки, а не измеряются.** Меши обнаруживаются в папке и
спавнятся из неё, но `zone`, `dims_mm` и `k` читаются из `labels.json` — это ground
truth, относительно которого проверяется механика.
Почему не автозамер: он был реализован и отброшен. Габариты мерились точно (сверено со
всем каталогом: `pen` 148.5/13.2/9.0 против 148/13/9, `pouf` 488.9 против 489), но
геометрическая оценка круглости систематически занижала тела с ручкой или полостью —
`bucket` 0.737 против 0.995, `mug` 0.731 против 0.985, `cylinder` 0.749 против 0.867.
Класс D тихо превращался в B, и это выглядело как отказ механики. Совпадение с каталогом
было 20/25. Для стенда, где проверяется именно механика, надёжнее читать метку.
**Товары без записи в `labels.json` пропускаются** — не угадываются. Прогон печатает их
списком; сейчас это 12 мешей (`air_conditioner`, `briefcase_hard`, `carton_large`,
`cleat_small`, `clothespin_flat`, `cooler_cube`, `duffel_round`, `nailfile_mini`,
`printer_compact`, `safety_pin`, `toaster_compact`, `toaster_oven`).
**Разметка проверяется на согласованность.** `classify.verify_labels()` прогоняет каждую
запись через правила раздела 5, и прогон печатает `labels consistent with the documented
B/C/D rules` либо перечисляет расхождения: ошибка в метке иначе всплыла бы как
необъяснимый сбой механики.
## 5. Правила классификации
Порядок как в пайплайне — сначала D:
* **D** «не подходит без доупаковки» → пушер → BinD.
Габариты в норме (как у B), но `k > 0.8` хотя бы в одном сечении.
* **C** «не подходит по габаритам» → плуг `+angle` → ConveyorTrack_01 → контейнер C.
Хотя бы один размер `< 10 мм` **или** не влезает в `450×320×320 мм`. Форма не важна.
* **B** «подходит для сортировки» → плуг `angle` → ConveyorTrack_06 → контейнер B.
Все размеры в диапазоне `10×10×10 … 450×320×320 мм` и `k ≤ 0.8`.
Влезаемость проверяется по отсортированным габаритам против отсортированной рамки —
товар можно положить любой гранью.
## 6. Ограничения, которые стоит знать
* **Шаг 0.7 м из ТЗ: класс D работает, B/C — нет.** Замерено на 9 товарах:
| шаг | B | C | D | итого |
|---|---|---|---|---|
| 1.4 м | 3/3 | 3/3 | 3/3 | **9/9** |
| 0.7 м | 1/3 | 1/3 | 3/3 | 5/9 |
Причина чисто геометрическая: **лезвие плуга длиной 0.63 м**, и товар «занимает» его
0.63 м пути. При шаге 0.7 м на смену угла остаётся 0.7 − 0.63 = **0.07 м (10 % шага)**
это ниже уровня шума физики (товары подрагивают, интервал плывёт), поэтому регулярно
два товара разных классов оказываются на лезвии одновременно и второй получает чужой
угол.
**Скорость ленты тут не поможет:** доля занятости = длина_лезвия / шаг = 0.63/0.70 = 90 %
и от скорости не зависит — время сокращается пропорционально и у товара, и у лезвия.
Реально помогает только шаг: нужен **> ~1.5 × длины лезвия ≈ 0.95 м**. При 1.4 м —
100 % по всем классам.
Укоротить лезвие нельзя «просто так»: его длина и есть вылет, которым он перекладывает
товар через ленту шириной 0.9 м. Чтобы держать 0.7 м, нужен другой отводящий орган
(второй плуг в шахматном порядке или толкатель вместо плуга).
* **Очень тонкие товары не отрабатывают на плуге.** `watch` (4.2 мм) класса C
проехал мимо и остался на линии (x −7.64): лезвие плуга приподнято над полотном, и
такой товар проходит под ним. Класс определяется верно, механика — нет.
* 12 мешей в `items/` не имеют записи в `labels.json` и потому пропускаются
(см. раздел 4). Часть из них ещё и обмерена в других единицах, так что
зашивать им класс наугад нельзя — нужна честная разметка.
## 7. Что именно чинит `cell.py`
Каждый пункт — отдельная найденная и замеренная проблема, все включены в `prepare()`:
| функция | зачем |
|---|---|
| `_kill_stale_graphs` | авторские `ConveyorBeltGraph` / `DiverterAnimGraph` **удаляются**, а не деактивируются: `SetActive(False)` не останавливает уже собранный OmniGraph, и он обнуляет `surfaceVelocity` каждый тик |
| `configure_belts` | все 7 лент + 4 плиты стыка; `ConveyorTrack_05` (вход линии) и ветка пушера `Belt_01` отсутствовали в списке проекта |
| `drive_corner_belt` | `ConveyorTrack_06`**криволинейный** угол (четверть кольца, центр (−8.005, +1.042), радиусы 0.517…1.018). Линейный привод уводил товар в пустую середину кольца, и он проваливался. Направление задано хордой, сохраняющей радиус, и **в мировых координатах** — у ленты неравномерный масштаб, и пересчёт в локальные искажает направление |
| `add_transfer_bridge` | между краем `_04` (y = +0.45) и началом `_06` (x = −8.00) не было опоры ровно там, где плуг сталкивает товар |
| `add_container_catchers` | лотки толщиной 40 мм пробивались товаром при падении с ленты (~3.4 м/с ≈ 57 мм за шаг) |
| `open_junction` | у конвейерной «обшивки» есть коллайдер вместе с бортами — стена ровно там, где товар должен уходить вбок |
| `regrip_decks` / `_ensure_grip_material` | `configure_plow` перебивает плиты скользким материалом; сам grip-материал проект создаёт только внутри своей `configure_belts`, которую этот модуль не вызывает |
| `resize_pusher_blade` / `grip_pusher_blade` / `seat_pusher_blade` | нож 1200 → 500 мм; свой цепкий материал вместо скользкого «плужного»; посадка на 1 мм над полотном (было 14 мм — тонкие товары проходили под ним) |
| `add_ground_and_light` / `add_side_rails` | пол и купольный свет; борта только на прямых участках — на стыках они блокируют штатный сход товара |
## 8. Зависимости
`cell.py` и `run_pipeline.py` импортируют `robozon_sorter` (константы `config.py`, класс
`Plow`, помощники `sim/scene.py` и `sim/plow_cell.py`), поэтому `/home/dasha/robozon-sorter`
должен быть в `sys.path``run_pipeline.py` добавляет его сам.
`items/` и `scene/` — собственные копии, их правка проект не задевает. `assets`
симлинк на `../assets`, потому что сцена ссылается на ленты и плуг относительным путём.
## 9. Стенд замера габаритов камерами
Отдельная задача от сортировки: по стереопарам определить габариты **неизвестного**
товара. Классы тут не читаются из разметки — они и есть то, что надо предсказать.
### 9.1 Рабочая расстановка — E60
| параметр | значение |
|---|---|
| высота над лентой | **700 мм** (все три рига) |
| возвышение | **60°** |
| рабочая дистанция | **808 мм** |
| азимуты | **90° / 208.3° / 330°** (RealSense D435 / Gemini305 / Gemini345) |
| точка осмотра | `(-0.750, 0.0, 1.781)` — поверхность `ConveyorTrack_04` |
Зафиксирована в `cam_configs.DEFAULT` и записана в саму сцену. Применить заново:
```bash
python3 isaacsim_send.py --file /tmp/save_e60.py # apply_config(stage, CC.DEFAULT)
```
Калибровка проверена: поворот левой и правой камеры в паре совпадает точно
(отклонение 0.0e+00), база равна паспортной с нулевой поперечной составляющей,
Δv между кадрами 0.000 px — то есть `depth = fx·B/disp` применим без ректификации.
Цена глубины на 808 мм: Gemini345 8.0 мм/px, D435 13.2, Gemini305 37.2.
### 9.2 Почему именно E60
Развёртка по возвышению при фиксированной высоте 700 мм и, отдельно, попытка дать
каждому ригу свою дистанцию под одинаковую цену глубины (EQ15/EQ20). Медиана MAE по
9 товарам, слияние трёх ригов:
| конфиг | возвыш. | дистанция | MAE медиана | D435 | Gemini305 | Gemini345 |
|---|---|---|---|---|---|---|
| A_original | 42° | 1051 мм | 30.0 | 29.3 | **109.5 (1/9)** | 67.4 (5/9) |
| E45 | 45° | 991 мм | 25.5 | 29.9 | 50.8 (1/9) | 32.8 |
| **E60** | **60°** | **808 мм** | **22.3** | **25.8** | **27.1** | **28.0** |
| E75 | 75° | 726 мм | 23.6 | 27.8 | 26.3 | 29.4 |
| EQ15 | 45° | 518/863/1146 | 29.8 | 42.2 | 31.3 | 30.9 |
| EQ20 | 45° | 598/996/1323 | 24.6 | 33.4 | 28.2 | 32.7 |
Два вывода, на которых всё держится:
* **Gemini305 не нужна своя короткая дистанция.** На 1051 мм он давал облако один раз
из девяти, потому что диспаратность в точке осмотра была всего 17 px. На 808 мм она
21.7 px и риг работает наравне с остальными. Попытка подобрать каждому ригу свою
дистанцию (EQ15/EQ20) починила Gemini305, но испортила D435 (25.8 → 42.2) и сделала
слияние хуже любой общей дистанции.
* **E60 — первая расстановка, где слияние трёх ригов выигрывает у лучшего одиночного**
(22.3 против 25.8). На 1051 мм слияние не давало ничего.
E75 почти не хуже по точности, но его общая зона ленты на треть меньше
(5756 против 7681 см²) — меньше запас на смещение товара.
### 9.3 Как устроен замер
Два этапа, потому что torch внутри Isaac роняет процесс:
```bash
# ЭТАП 1 — в Isaac, без torch: рендер L/R со всех шести камер
python3 isaacsim_send.py --timeout 880 --file control_test/capture_roi.py
python3 isaacsim_send.py --timeout 880 --file control_test/capture_roi.py --arg cfg=E75
# ЭТАП 2 — отдельным процессом: FastSAM + CREStereo + обратная проекция
/home/whatevenif/isaacsim/python.sh measure_roi.py # E60, все 4 варианта ROI
/home/whatevenif/isaacsim/python.sh measure_roi.py E60 objroi # только рабочая схема
```
Схема ROI (`objroi`), она же рабочая:
```
общая зона ленты, видимая всеми 6 камерами
↓ ограничивает, где FastSAM ищет
FastSAM segment-everything → маска, содержащая «воротный» пиксель
↓ bbox + 48 px запаса
ОДНО окно колонок на левый и правый кадр, левый край расширен на 1.35 × макс. диспаратности
CREStereo → диспаратность → depth = fx·B/disp → обратная проекция по экстринсикам
```
Три правила, каждое подтверждено замером:
* **RGB не маскируется до CREStereo.** Матчеру нужен фон вокруг предмета; маска
применяется только к глубине, с эрозией 2 × 3×3, иначе в облако попадает кайма фона.
* **Левое и правое окно обязаны совпадать.** Контрольный вариант `objroi_bad`, где
правый кроп центрируется по своему bbox, разрушил **8 облаков из 9** — сдвиг окна
подменяет диспаратность.
* **Слияние идёт только по калиброванным экстринсикам, без RANSAC/ICP.** Повторная
регистрация ухудшала результат в каждой проверке (11.9 → 40.5 мм): три ракурса видят
разные поверхности, и ICP совмещает несоответствующие участки.
Общая зона ленты проецируется в 84–92 % кадра, поэтому сама по себе разрешения она не
экономит — её роль в том, чтобы ограничить область поиска сегментации. Выигрыш даёт
вторая ступень: `objroi` втрое быстрее полного кадра (519 против 1620 мс на товар) при
той же точности и не теряет мелкие предметы (`lunchbox` полный кадр терял вовсе).
### 9.4 Эталон габаритов — bbox меша в сцене, не каталог
Меши в `items/` **в 2.0–2.8 раза мельче** своих паспортных размеров, и коэффициент у
каждого свой (`bag` 2.05, `box_400x400x300` 2.41, `box_300x200x200` 2.79). Для сортировки
по меткам это безразлично, но оценивать предсказание масштаба против `labels.json`
нельзя. `capture_roi.py` пишет в манифест оба числа: `gt_scene_mm` (реальный bbox в
сцене — эталон замера) и `gt_catalogue` (паспорт из `labels.json` — эталон сортировки).
### 9.5 Что не решено
* **Систематическое занижение.** На E60: `pillow` 223 → 138, `bucket` 136 → 101,
`backpack` 170 → 158. Подушка худшая во всех шести расстановках (66–77 мм) — плоский
мягкий силуэт; у ведра, похоже, снимается кромка, а не корпус. От расстановки камер
это не зависит.
* **Сегментация иногда берёт ленту.** На EQ-расстановках `detergent` дал 157 мм вместо
108. Напрашивается отбраковка ракурса по 3D-центроиду: вид, чей центр дальше 6 см от
медианы по трём ригам, выбрасывать до слияния (в прошлом пайплайне это помогало).
* Замерено на 9 товарах из 25 — полный набор ещё не прогонялся.
### 9.6 Ловушки спавна, из-за которых стенд полгода мерил пустую ленту
Обе тихие, обе дают правдоподобные кадры пустого конвейера:
1. **`ClearXformOpOrder()` на приме, который несёт ссылку**, стирает собственное
размещение меша. Замер bbox до очистки и последующий перенос дают промах ровно на
этот сдвиг — товары уходили на 0.6 м под полотно. Ссылка должна жить на **дочернем**
приме, размещение — на родителе.
2. **Слои товаров прописывают `visibility = invisible` на своём корне**, а
`MakeVisible()` на родителе авторское значение потомка не снимает. Нужно пройти
`Usd.PrimRange` и выставить `inherited` всем Imageable.
Плюс: `BBoxCache.ComputeWorldBound()` на только что созданном родителе возвращает пустой
диапазон даже при скомпонованном потомке — мерить надо сам прим со ссылкой; и ссылка не
компонуется в том же тике, нужен цикл `await app_utils.update_app_async(steps=2)`.
Поэтому `capture_roi.py` печатает **контраст**: разницу средней яркости внутри ожидаемого
силуэта и в кольце вокруг него, по каждой камере. Меньше 3 — товар не отрендерился,
строка помечается `<-- НЕ ВИДЕН`. Без этой проверки четыре круга «настройки сегментации»
были потрачены на кадры, где предмета не было вовсе.
---
## 10. Замкнутый контур: поток → CV → механика
Полный прогон, где класс товара приходит **от стереопайплайна во время движения**, а не из
`labels.json`. Пушер и плуг реагируют физически на предсказанный класс. Разметка нужна
только для подсчёта ошибки в конце.
Прежний `run_pipeline.py` (классы из разметки) остаётся рабочим и служит контролем: он
разделяет ошибки механики и ошибки распознавания.
Подробное состояние пайплайна, все замеры и открытые проблемы — в `.memory.md`.
### 10.1 Два процесса
torch внутри Isaac роняет процесс, поэтому CV живёт отдельно, а обмен идёт через каталог
`runtime/`:
```
run_sorting_cv.py (в Isaac, без torch) cv_worker.py (отдельный процесс, torch+GPU)
поток 700 мм при 1 м/с
ворота x = -0.750 -> 6 кадров ──заявка──> runtime/req/<товар>.json
DEFOM vitl / вход 480 / iters 24
облако -> габариты -> k -> класс
класс <──ответ── runtime/res/<товар>.json
пушер: класс D -> Cell.stroke()
плуг: класс B/C -> Plow.target(-16 / +16)
```
Времени хватает с запасом: от ворот до пушера товар едет 3.15 с, до плуга 7.10 с, а полная
задержка от ворот до класса замерена в **0.70 с** (медиана; инференс 631–1284 мс).
### 10.2 Запуск
```bash
# 1. работник CV. Прогрев ~90 с - грузится vitl-энкодер
cd /home/dasha/robozon-sorter/control_test
nohup /home/whatevenif/isaacsim/python.sh cv_worker.py > /tmp/cvworker.log 2>&1 &
# 2. проверить ПО PID, а не по файлу runtime/worker_ready:
# файл остаётся от прошлого запуска и даёт ложную готовность
pgrep -af "python.*cv_worker.py"
# 3. открыть сцену заново (cell.prepare меняет её состояние), затем прогон
cd /home/dasha/robozon-sorter
python3 isaacsim_send.py --context cvsort --timeout 2580 --execution-timeout 2560 \
--file control_test/run_sorting_cv.py
```
Результат прогона: `runtime/sorting_cv.json`, кадры товаров `runtime/frames/`,
обзорный снимок `runtime/shots/overview.png`.
### 10.3 Зафиксированный бейзлайн CV
Значения по умолчанию в `measure_plane.py`:
| параметр | значение |
|---|---|
| стереодвижок | **DEFOM-Stereo vitl** (`STEREO=defom`) |
| вход сети | **480** px по ширине (`SW=480`) |
| итерации | **24** + `scale_iters` 8 |
| окно | **кроп зоны осмотра** (`CROP=1`) |
| сегментация | **не используется** |
Товар отделяется от полотна превышением над плоскостью (20 мм) плюс отсев по плотности:
товар даёт сплошную поверхность, полотно — редкие выбросы диспаратности. Сегментация не
участвует вовсе — она была источником и раздутых габаритов, и промахов по классу D.
Веса: `/home/dasha/isaac_assets/cv/defom-stereo/checkpoints/`.
**Замер на статичных кадрах потока 700 мм (9 товаров):** классы 8/9 = 89 %, габариты
MAE медиана 32.8 мм, 469 мс на товар при такте 700 мс.
**Замер замкнутого контура:** классов получено 7 из 9, верно 5 из 7.
Полная таблица сравнения движков и конфигураций — в `.memory.md`, раздел 2.
### 10.4 Кинематика
* **Ленты 1.0 м/с.** `surfaceVelocity` задаётся в ЛОКАЛЬНОЙ системе тела; у `_04` и `_06`
локальный +X смотрит в мировой −X. `ConveyorTrack_06` — криволинейный угол на 90°,
направление задаётся хордой в мировых координатах. Проверено: товар 160 мм идёт 7.93 м
ровно на 1.00 м/с.
* **Пушер** — нож двигается записью трансформа (`Cell.blade_to`), призматический сустав
выключен: иначе сустав и скрипт тянут нож в разные стороны и он дрожит весь прогон.
* **Плуг** — лезвие кинематическое, шарнир выключен, угол пишется напрямую
(`Plow.target`). Силовой привод не держал: звенел на ±21.4°. Углы
`{"B": -16, "C": +16, "D": 0}`, лезвие ставится ЗАРАНЕЕ. Скольжение товара вдоль кромки
замерено: 315–350 мм.
### 10.5 Открытые проблемы
1. **Габариты в движении вдвое хуже статичных.** Кроп привязан к неподвижной точке осмотра
(−0.750), а товар пересекает её не точно: `detergent` попал на ворота уже на x = −1.708.
Товар в кадре смещён, в кроп попадает соседний. Самая вредная из открытых.
2. **Класс D берётся неустойчиво.** `bucket` не определяется ни одной конфигурацией. На
эталонной геометрии меша та же функция даёт 0.934, на нашем облаке 0.66 — разрыв целиком
в качестве облака.
3. **Товары могут не доехать до ворот.** `helmet` и `pillow`, выпущенные подряд, столкнулись
у входа: при габаритах 354 и 455 мм шаг 700 мм оставляет мало зазора.
4. **Пушер выбрасывает товар с линии** при скорости ножа на пределе (`PUSHER_MAX_SAFE` 2.5 м/с).
5. **Шаг 700 мм механически не даёт B/C** — лезвие плуга 0.63 м, на смену угла остаётся
0.07 м. При 1.4 м — 9/9, при 0.7 м — 5/9 (см. раздел 6).
Что уже проверено и **не** помогло (сглаживание контура, проверка лево-право, цилиндр
RANSAC, отбраковка по центроиду и другое) — перечислено в `.memory.md`, раздел 5.
### 10.6 Сколько товаров доходит до контейнеров
Замер последнего прогона замкнутого контура. Зоны лотков берутся из самой сцены
(`B_Floor` x 9.26…−8.36 / y +1.07…+1.87; `C_Floor` x 10.90…−10.00 / y 0.63…+0.18;
`BinD_Floor` x 6.21…−4.95 / y +1.59…+2.85), допуск 0.35 м — падая с ленты, товар может
лечь у стенки, а не над серединой пола.
**В контейнеры попало 5 из 9. В СВОЙ контейнер — 3 из 9.**
| товар | эталон | CV | конец X, Y | куда попал |
|---|---|---|---|---|
| `backpack` | C | C | 10.44, 0.47 | контейнер C — **верно** |
| `lunchbox` | B | B | 8.95, +1.55 | контейнер B — **верно** |
| `bag` | D | D | 8.37, +1.03 | контейнер B — класс верный, доставка нет |
| `bucket` | D | C | 10.64, +0.14 | контейнер C — ошибка CV |
| `box_300x200x200` | B | C | 10.63, 0.28 | контейнер C — ошибка CV |
| `box_400x400x300` | C | C | 9.39, 1.49 | остался на линии, за краем лотка |
| `detergent` | B | B | +262.50, +2085.32 | улетел за пределы ячейки |
| `helmet` | D | — | +4.02, +0.78 | не доехал до ворот |
| `pillow` | C | — | +1.15, +0.91 | не доехал до ворот |
### Разложение потерь по причинам
Общая цифра здесь малополезна: причины разные и лечатся по-разному.
| причина | сколько | что именно |
|---|---|---|
| ошибка CV | 2 | `bucket` (D→C), `box_300x200x200` (B→C) |
| класс верный, механика не довела | 1 | `bag` — пушер сработал (есть в логе), но товар остался в лотке B вместо BinD |
| столкновение на входе | 2 | `helmet` и `pillow` выпущены подряд; при габаритах 354 и 455 мм шаг 700 мм слишком тесен |
| бросок пушера | 1 | `detergent` — скорость ножа на пределе `PUSHER_MAX_SAFE` = 2.5 м/с |
| плуг сдвинул недостаточно | 1 | `box_400x400x300` закончил на y = −1.49, за краем лотка C |
**Из четырёх потерь по механике ни одна не связана с распознаванием.** CV ошибся на двух
товарах из семи, кому вообще выдал класс.
### Фоновое ограничение
При шаге 700 мм классы B и C механически не отрабатывают в принципе: лезвие плуга 0.63 м,
на смену угла остаётся 0.07 м (10 % шага) — см. раздел 6. Прежний прогон с классами из
разметки давал при шаге 1.4 м результат **9/9**. То есть значительная часть этих потерь —
не про CV и не про настройку, а про геометрию плуга, и на шаге 0.7 м она не устраняется
ни скоростью ленты, ни точностью классификации.
Проверять сквозную доставку осмысленно **на шаге 1.4 м**, а шаг 0.7 м использовать для
замера классификации и габаритов.