Files
isaac/control_test/README.md
T
dasha_f 0d32f32db0 Сортировочная ячейка 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>
2026-08-01 13:07:24 +00:00

494 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 м использовать для
замера классификации и габаритов.