Сортировочная ячейка 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
+283
View File
@@ -0,0 +1,283 @@
# .memory — состояние пайплайна control_test
Живая справка по замкнутому контуру «поток → CV → механика». Обновлять при изменении
конфигурации или при появлении нового замеренного факта. Здесь только то, что **измерено**;
предположения помечены отдельно.
Последнее обновление: 2026-08-01.
---
## 1. Что сейчас работает
**Полный прогон с кинематикой и CV — `run_sorting_cv.py` в паре с `cv_worker.py`.**
Класс товара приходит от стереопайплайна **во время движения**, а не из разметки. Пушер и
плуг реагируют физически на предсказанный класс. Разметка используется только для подсчёта
ошибки в конце.
Прежний `run_pipeline.py` (классы из `labels.json`, без CV) остаётся рабочим и нужен как
контроль: он разделяет ошибки механики и ошибки распознавания.
### Почему два процесса
torch внутри Isaac роняет процесс. Поэтому CV живёт отдельно, обмен через каталог:
```
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)
```
### Запуск
```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
```
---
## 2. Зафиксированный бейзлайн CV
В `measure_plane.py` значения по умолчанию:
| параметр | значение |
|---|---|
| стереодвижок | **DEFOM-Stereo vitl** (`STEREO=defom`) |
| вход сети | **480** px по ширине (`SW=480`) |
| итерации | **24** + scale_iters 8 |
| окно | **кроп зоны осмотра** (`CROP=1`) |
| сегментация | **не используется** |
Товар отделяется от полотна превышением над плоскостью (порог 20 мм) плюс отсев по
плотности; сегментация не участвует вовсе.
**Замер на статичных кадрах потока 700 мм (9 товаров):** классы 8/9 = 89 %, габариты MAE
медиана 32.8 мм, 469 мс на товар при такте 700 мс.
Веса: `/home/dasha/isaac_assets/cv/defom-stereo/checkpoints/`
`defomstereo_vitl_sceneflow.pth` (1.53 ГБ), `defomstereo_vits_sceneflow.pth` (173 МБ),
энкодер `depth_anything_v2_vitl.pth` (1.34 ГБ).
### Сравнение движков на том же потоке
| конфигурация | MAE медиана | классы | время | в такт 700 мс |
|---|---|---|---|---|
| **DEFOM vitl, вход 480, iters 24** | 32.8 мм | **8/9 = 89 %** | **469 мс** | да, +231 мс |
| DEFOM vits, вход 640, iters 24 | 33.9 мм | 7/9 | 502 мс | да |
| DEFOM vitl, вход 640, iters 12 | 37.5 мм | 6/9 | 627 мс | да |
| DEFOM vitl, вход 640, iters 24 | 29.9 мм | 7/9 | 735 мс | нет, 35 мс |
| CRE, кроп, вход 640 | **23.5 мм** | 7/9 | 551 мс | да |
| CRE, кроп, исходный вход | 25.6 мм | 7/9 | 1366 мс | нет |
| CRE, вся лента, исходный вход | 32.4 мм | 7/9 | 1701 мс | нет |
| FastSAM + CRE | 40.0 мм | 5/9 | 753 мс | нет |
| yolo26n-seg + CRE | 41.2 мм | 2/6 | 238 мс | да |
| FoundationStereo (только CPU) | 152.8 мм | 4/9 | 7159 мс | нет |
**CRE точнее по габаритам (23.5 против 32.8), DEFOM лучше по классам (8/9 против 7/9).**
---
## 3. Кинематика
### Ленты — 1.0 м/с
Семь дорожек, привод через `PhysxSurfaceVelocityAPI`. Скорость задаётся **в локальной
системе тела**, а дорожки уложены по-разному: у `_04` и `_06` локальный +X смотрит в
мировой −X. Направление выводится из мировой цели, величина делится на то, сколько
мирового стоит одна локальная единица (у `_06` масштаб 0.5).
`ConveyorTrack_06`**криволинейный** угол на 90°, четверть кольца с центром
(8.005, +1.042), радиусы 0.517…1.018. Линейный привод уводил товар в пустую середину
кольца; направление задаётся хордой, сохраняющей радиус, и в мировых координатах.
Проверено: товар 160 мм проходит **7.93 м за 7.9 с ровно на 1.00 м/с** без замедлений;
поток из 9 товаров проходит всю линию 10.9 м.
### Пушер — класс D
Нож двигается **записью трансформа** (`Cell.blade_to`), его призматический сустав
**выключен**: иначе сустав тянет нож к своей цели, пока скрипт пишет его в другое место,
и нож дрожит весь прогон. Ход 720 мм, срабатывание у `PUSH_X = -3.900`.
### Плуг — классы B и C
Лезвие **кинематическое**, шарнир **выключен** (`physics:jointEnabled=False`), угол
пишется напрямую (`Plow.target`). Силовой привод перенастраивали трижды и он не держал:
звенел на ±21.4° быстрее, чем его успевала вести команда.
Углы: `PLOW_PRESET = {"B": -16.0, "C": +16.0, "D": 0.0}`. Положительный поворот отклоняет
в −Y. Лезвие ставится **заранее**, до подхода товара. Скольжение товара вдоль кромки
измерено: 315–350 мм, то есть товар ведётся, а не отбрасывается ударом.
### Камеры — E60
Шесть камер, три стереопары, все на 700 мм над лентой, возвышение 60°, дистанция 808 мм,
азимуты 90° / 208.3° / 330°. Точка осмотра (0.750, 0.0, 1.781).
Базы: D435 73.5 мм, Gemini305 26.5 мм, Gemini345 129.4 мм. Цена глубины соответственно
13.2 / 37.2 / 8.1 мм на пиксель диспаратности.
**Расхождение с реальным стендом:** у настоящего D435 база **50.0 мм** (прочитано из
прошивки), а в симуляции 73.5 — в 1.47 раза больше. Значит результаты симуляции для этого
рига оптимистичнее реальности примерно в полтора раза. Не исправлено сознательно.
---
## 4. Замер последнего прогона замкнутого контура
**Классов получено 7 из 9, верно 5 из 7.** Верно: `bag` (D), `backpack` (C), `lunchbox` (B),
`detergent` (B), `box_400x400x300` (C).
**Задержка от ворот до класса — медиана 0.70 с** при 3.15 с до пушера и 7.10 с до плуга.
Инференс 631–1284 мс. Запас четырёх- и десятикратный.
Пушер сработал по классу D от камер. Плуг предпозиционировался шесть раз.
**Сквозная доставка: в контейнеры попало 5 из 9, в СВОЙ контейнер - 3 из 9.**
| причина потери | сколько | что именно |
|---|---|---|
| ошибка CV | 2 | bucket (D->C), box_300x200x200 (B->C) |
| класс верный, механика не довела | 1 | bag - пушер сработал, товар остался в лотке B вместо BinD |
| столкновение на входе | 2 | helmet и pillow, выпущены подряд |
| бросок пушера | 1 | detergent улетел на (+262, +2085) |
| плуг сдвинул недостаточно | 1 | box_400x400x300 на y = -1.49, за краем лотка C |
Из четырёх потерь по механике ни одна не связана с распознаванием. Подробная таблица с
координатами и зонами лотков - в README, раздел 10.6.
Сквозную доставку осмысленно мерить на шаге 1.4 м (там прежний прогон давал 9/9), а шаг
0.7 м использовать для замера классификации и габаритов.
---
## 5. Проблемы, которые сейчас есть
### Не решены
1. **Класс D берётся неустойчиво.** `bucket` (истинный k = 0.995) не определяется ни одной
конфигурацией. На **эталонной геометрии меша** та же функция даёт 0.934, на нашем облаке
0.66 — разрыв целиком в качестве облака, не в метрике. Габарит ведра выходит
вытянутым (333 × 239 при истинных 287 × 287), а вытянутое сечение высокого k дать не может.
2. **Габариты в движении хуже статичных.** `box_300x200x200` в потоке дал 513 × 452 против
311 × 218 на статичных кадрах и из-за этого ушёл в C вместо B. Причина видна в логе:
`detergent` попал на ворота уже на x = −1.708, то есть **мимо точки осмотра**, а кроп
привязан к неподвижной точке (−0.750). Товар в кадре смещён, в кроп попадает соседний.
3. **Два товара не доехали до ворот.** `helmet` встал на x = +4.02, `pillow` на +1.15. Оба
выпускались подряд (2.15 и 2.85 с); при их габаритах (354 и 455 мм) шаг 700 мм оставляет
мало зазора, и они, судя по позициям, столкнулись у входа.
4. **Пушер выбрасывает товар.** `detergent` закончил на (+262, +2085) — улетел на километры.
Скорость ножа на пределе: `PUSHER_MAX_SAFE = 2.5` м/с с пометкой «выше ~2.5 м/с
кинематический нож сбрасывает товар с линии».
5. **Шаг 700 мм механически не даёт B/C.** Лезвие плуга 0.63 м, на смену угла остаётся
0.07 м (10 % шага). Замерено: при 1.4 м — 9/9, при 0.7 м — 5/9. От скорости ленты не
зависит: доля занятости лезвия = 0.63/0.70 = 90 %. Нужен шаг > ~0.95 м либо другой
отводящий орган.
6. **Очень тонкие товары проходят под лезвием плуга.** `watch` (4.2 мм) класса C проехал
мимо: класс определяется верно, механика — нет.
### Проверено и НЕ помогло
Каждый пункт — отдельный замер, все ухудшили результат:
| попытка | результат |
|---|---|
| выбор маски по плоскости ленты вместо воротного пикселя | MAE 39.9 (было 40.0), классы 4/9 (было 5/9) |
| k подгонкой окружности P10/P90 | подняло k без разбора формы: коробки пошли в D |
| k по трём **мировым** сечениям | классы 6/9 (было 7/9) |
| сглаживание контура по угловым секторам | k макс 0.67 (было 0.85), классы 6/9 |
| проверка лево-право 0.5 / 1.0 / 1.5 px | отсеивает 28–52 % пикселей, MAE 34.6 (было 32.4), время ×2 |
| подгонка цилиндра RANSAC | не сработала ни на одном товаре: доля точек в допуске < 60 % |
| отбраковка ракурса по центроиду, порог 60 мм | MAE 65.4 (было 49.5) — откидывала два вида из трёх |
| ICP/RANSAC-совмещение облаков | 11.9 → 40.5 мм, три ракурса видят разные поверхности |
**Общий вывод из этой серии:** у нас не выбросы, а **дырки в диспаратности**. Любая правка,
которая *вычитает* точки (сглаживание, лево-право, отбраковка), делает хуже. Помогает то,
что *повышает плотность* или *уменьшает область поиска*: кроп зоны осмотра (MAE 32.4 → 25.6)
и понижение входа сети (25.6 → 23.5).
Недоделанная половина рецепта лево-право: заполнение мелких внутренних дырок и edge-aware
фильтр с запретом интерполяции через границу. Сейчас реализовано только удаление.
### Установлено, что НЕ виновато
- **Калибровка камер.** Восстановленное полотно садится на эталонную плоскость со смещением
0.59 / 0.56 / 1.13 мм и наклоном 0.56° / 0.35° / 1.72° по трём ригам. Ни интринсики, ни
боковое расположение, ни положение виртуальных камер не при чём.
- **Стереодвижок как таковой.** CRE проверен против штатной глубины RealSense на физическом
стенде: отношение 0.998 и 1.000 на 249 тыс. пикселей.
- **Покрытие ракурсами.** Дуга сечения у ведра покрыта на 295–360°.
---
## 6. Ловушки, на которых уже теряли время
Каждая давала правдоподобный, но неверный результат.
1. **Узлы OmniGraph надо УДАЛЯТЬ, а не деактивировать.** `SetActive(False)` убирает прем из
обхода, но собранный граф продолжает работать: `ConveyorBeltGraph` обнулял
`surfaceVelocity` за 5 шагов после `play`, `DiverterAnimGraph` останавливал таймлайн.
2. **Невидимость не убирает коллайдер.** `capture_roi.py` прятал снятый товар через
`MakeInvisible()`, и после двух прогонов захвата в точке осмотра стояло **18 невидимых,
но твёрдых предметов**. Поток вставал на них «посреди ConveyorTrack_02». Лечится
`cell.clear_capture_parks()`, вызывается в `prepare()`.
3. **`BBoxCache` во время прогона врёт.** Он читает авторские трансформы из слоя USD, а
физика пишет в Fabric. Отчёт показывал, что все товары стоят в точках выпуска, хотя
таймлайн отработал 26 с. Положения читать через `RigidPrim.get_world_poses()`.
4. **`play()` после `stop()` перематывает в начало** и сбрасывает физику. Обработчик,
«возобновляющий» остановившийся таймлайн, обнуляет весь опыт.
5. **Заданная частота физики не применяется.** `timeStepsPerSecond=120` не подействовал,
фактический шаг 83.33 мс (60 Гц). Скорости выходили ровно вдвое завышенными. Время
брать из таймлайна.
6. **Файл `runtime/worker_ready` остаётся от прошлого запуска** и даёт ложную готовность.
Проверять работника по PID.
7. **`cloud_from_roi` возвращает ПАРУ** (облако товара, облако полотна). Складывание
кортежа целиком роняет `np.vstack` на разнородных формах.
8. **Меши `items_flow/` уже в каталожном масштабе и уже посажены на z = 0**, коллайдеры в
них уже есть. Домасштабирование и свои коллайдеры ломают спавн — товары не едут.
9. **ArUco-метки не видны в ИК** (на физическом стенде): типографская краска на 850 нм
в значительной мере прозрачна. Позу брать из цветного кадра с ЦВЕТНЫМИ интринсиками и
переводить в систему ИК заводскими экстринсиками.
---
## 7. Что делать дальше — по приоритету
1. **Привязать кроп к товару, а не к неподвижной точке осмотра.** Это лечит проблему 2 —
самую вредную из открытых: из-за неё габариты в движении вдвое хуже статичных.
2. **Снизить скорость ножа пушера** — проблема 4, товар улетает.
3. **Разнести выпуск товаров по времени** либо увеличить шаг — проблема 3.
4. Доделать вторую половину фильтрации лево-право (заполнение дырок, edge-aware).
5. FoundationStereo на GPU: заблокировано внешне — `onnxruntime-gpu` требует CUDA 13, на
сервере 12.8; колёс под CUDA 12 для python 3.12 нет; `onnx2torch` падает на динамическом
`Clip`; TensorRT не установлен. Нужен либо `.pth` через код репозитория, либо CUDA 13
(установка требует прав root).
+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 м использовать для
замера классификации и габаритов.
+1
View File
@@ -0,0 +1 @@
/home/dasha/robozon-sorter/assets
+63
View File
@@ -0,0 +1,63 @@
"""Rebuild the camera body markers at the new poses, then look through the D435 pair."""
import omni.usd, omni.kit.viewport.utility as vp
import isaacsim.core.experimental.utils.app as app_utils
from pxr import Gf, Usd, UsdGeom, UsdPhysics
stage = omni.usd.get_context().get_stage()
xc = UsdGeom.XformCache()
ROOT = "/World/CameraBodiesSide"
NAMES = ["RealSense_D435_Left", "RealSense_D435_Right",
"Orbbec_Gemini305_Left", "Orbbec_Gemini305_Right",
"Orbbec_Gemini345_Left", "Orbbec_Gemini345_Right"]
COLOR = {"RealSense": (0.15, 0.5, 0.95), "Orbbec": (0.95, 0.45, 0.1)}
def cam(name):
for p in stage.Traverse():
if p.IsA(UsdGeom.Camera) and p.GetName() == name:
return p
raise KeyError(name)
# the old bodies are still at the old poses and would now be both wrong and in the way
old = stage.GetPrimAtPath("/World/CameraBodies")
if old.IsValid():
UsdGeom.Imageable(old).MakeInvisible()
for d in Usd.PrimRange(old):
a = d.GetAttribute("physics:collisionEnabled")
if a:
a.Set(False)
print("old /World/CameraBodies hidden (stale poses, and they occluded the new views)")
UsdGeom.Xform.Define(stage, ROOT)
for n in NAMES:
c = cam(n)
M = xc.GetLocalToWorldTransform(c)
pos = Gf.Vec3d(M.ExtractTranslation())
fwd = M.TransformDir(Gf.Vec3d(0, 0, -1)); fwd = fwd / (fwd.GetLength() or 1)
path = f"{ROOT}/{n}"
if stage.GetPrimAtPath(path).IsValid():
stage.RemovePrim(path)
cube = UsdGeom.Cube.Define(stage, path)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
# BEHIND the lens plane: a housing centred on the camera looks into its own inside
# and the frame comes back black - that failure is documented in this project.
xf.AddTranslateOp().Set(pos - fwd * 0.045)
xf.AddScaleOp().Set(Gf.Vec3f(0.05, 0.05, 0.05))
key = "RealSense" if n.startswith("RealSense") else "Orbbec"
UsdGeom.Gprim(cube.GetPrim()).CreateDisplayColorAttr().Set([Gf.Vec3f(*COLOR[key])])
print(f"rebuilt {len(NAMES)} body markers under {ROOT}, each offset behind its lens")
w = vp.get_active_viewport()
orig = w.camera_path
shots = []
for n in ("RealSense_D435_Left", "RealSense_D435_Right"):
w.camera_path = cam(n).GetPath()
await app_utils.update_app_async(steps=45)
f = f"/tmp/cam_{n}.png"
vp.capture_viewport_to_file(w, file_path=f)
await app_utils.update_app_async(steps=20)
shots.append(f)
print("captured", f)
w.camera_path = orig
await app_utils.update_app_async(steps=10)
print("viewport camera restored:", orig)
+74
View File
@@ -0,0 +1,74 @@
"""Verify the rigs against each other: standoff, pair geometry, and a reprojection test.
The reprojection test is the end-to-end one: take the inspection point, transform it into
each camera's frame with that camera's own extrinsics, project with its intrinsics, and
check where it lands. A correctly aimed camera puts it on the principal point.
"""
import math
import omni.usd
from pxr import Gf, Usd, UsdGeom
stage = omni.usd.get_context().get_stage()
xc = UsdGeom.XformCache()
TARGET = Gf.Vec3d(-0.750, 0.0, 1.781)
def cam(name):
for p in stage.Traverse():
if p.IsA(UsdGeom.Camera) and p.GetName() == name:
return p
raise KeyError(name)
NAMES = ["RealSense_D435_Left", "RealSense_D435_Right",
"Orbbec_Gemini305_Left", "Orbbec_Gemini305_Right",
"Orbbec_Gemini345_Left", "Orbbec_Gemini345_Right"]
RES = {"RealSense_D435": (1280, 720), "Orbbec_Gemini305": (1280, 800),
"Orbbec_Gemini345": (1280, 800)}
print("=== standoff (requested 600 mm) ===")
P, F = {}, {}
for n in NAMES:
M = xc.GetLocalToWorldTransform(cam(n))
p = Gf.Vec3d(M.ExtractTranslation())
f = M.TransformDir(Gf.Vec3d(0, 0, -1)); f = f / (f.GetLength() or 1)
P[n], F[n] = p, f
print(f" {n:>24}: {(TARGET-p).GetLength()*1000:7.1f} mm")
print("\n=== pair geometry ===")
for rig in ("RealSense_D435", "Orbbec_Gemini305", "Orbbec_Gemini345"):
l, r = P[f"{rig}_Left"], P[f"{rig}_Right"]
fl, fr = F[f"{rig}_Left"], F[f"{rig}_Right"]
base = (r - l).GetLength()
ang = math.degrees(math.acos(max(-1, min(1, Gf.Dot(fl, fr)))))
kind = ("OPPOSING - not a stereo pair" if ang > 150 else
"rectified (parallel axes)" if ang < 0.5 else f"verged {ang:.2f} deg")
print(f" {rig:>18}: baseline {base*1000:8.1f} mm axes {ang:6.2f} deg {kind}")
print("\n=== reprojection of the inspection point (should land on the principal point) ===")
for n in NAMES:
prim = cam(n)
c = UsdGeom.Camera(prim)
fl_mm = c.GetFocalLengthAttr().Get()
ha = c.GetHorizontalApertureAttr().Get()
va = c.GetVerticalApertureAttr().Get()
rig = n.rsplit("_", 1)[0]
W, H = RES[rig]
fx = fl_mm / ha * W
fy = fl_mm / va * H
cx, cy = W / 2.0, H / 2.0
M = xc.GetLocalToWorldTransform(prim)
Pcam = M.GetInverse().Transform(TARGET) # world -> camera frame
z = -Pcam[2] # camera looks down -Z
if z <= 1e-6:
print(f" {n:>24}: BEHIND the camera"); continue
u = cx + fx * (Pcam[0] / z)
v = cy - fy * (Pcam[1] / z)
print(f" {n:>24}: depth {z*1000:6.1f} mm pixel ({u:7.1f},{v:7.1f}) "
f"offset from centre ({u-cx:+5.1f},{v-cy:+5.1f}) px")
print("\n=== cross-rig consistency: does every camera see the same point in front of it? ===")
depths = []
for n in NAMES:
M = xc.GetLocalToWorldTransform(cam(n))
depths.append(-M.GetInverse().Transform(TARGET)[2])
print(f" depth spread across all six: {min(depths)*1000:.1f} .. {max(depths)*1000:.1f} mm"
f" (max-min = {(max(depths)-min(depths))*1000:.1f} mm)")
+89
View File
@@ -0,0 +1,89 @@
{
"target": [
-0.75,
0.0,
1.781
],
"standoff_m": 0.6,
"side_elevation_deg": 20.0,
"cameras": {
"RealSense_D435_Left": {
"pos": [
-0.75,
0.5638,
1.9862
],
"fwd": [
0.0,
-0.9397,
-0.342
],
"dist_mm": 600.0
},
"RealSense_D435_Right": {
"pos": [
-0.75,
-0.5638,
1.9862
],
"fwd": [
0.0,
0.9397,
-0.342
],
"dist_mm": 600.0
},
"Orbbec_Gemini305_Left": {
"pos": [
-1.1442,
-0.2125,
2.1806
],
"fwd": [
0.6459,
0.3733,
-0.6659
],
"dist_mm": 600.1
},
"Orbbec_Gemini305_Right": {
"pos": [
-1.1309,
-0.2354,
2.1806
],
"fwd": [
0.6459,
0.3733,
-0.6659
],
"dist_mm": 600.1
},
"Orbbec_Gemini345_Left": {
"pos": [
-0.3943,
-0.2798,
2.1802
],
"fwd": [
-0.6467,
0.373,
-0.6654
],
"dist_mm": 603.5
},
"Orbbec_Gemini345_Right": {
"pos": [
-0.3297,
-0.1677,
2.1802
],
"fwd": [
-0.6467,
0.373,
-0.6654
],
"dist_mm": 603.5
}
}
}
+174
View File
@@ -0,0 +1,174 @@
"""Named camera arrangements. The canonical one is E60 - see DEFAULT below.
Each rig is described by (azimuth, elevation) of its CENTRE as seen from the inspection
point, plus its native baseline. Both eyes of a rig always share one orientation and the
right eye is offset along the camera's own +X - that is what keeps a pair rectified, which
CREStereo needs (depth = fx*B/disp assumes parallel axes).
"""
import json, math
import omni.usd
from pxr import Gf, Usd, UsdGeom
TARGET = Gf.Vec3d(-0.750, 0.0, 1.781)
# HEIGHT drives the layout: every rig sits this far ABOVE the belt surface, and its
# standoff follows from its elevation angle (standoff = HEIGHT / sin(elev)). At the old
# 600 mm standoff / 400 mm height the vertical field at the target was +-320 mm and an
# object taller than ~220 mm had its top outside four of the six frames - the height is
# what buys headroom for the 450x320x320 envelope.
HEIGHT = 0.70 # every rig this far above the belt surface
STANDOFF = 0.60 # kept only for the SPLIT rig, which is deliberately low+sideways
BASELINE = {"RealSense_D435": 0.0735, "Orbbec_Gemini305": 0.0265, "Orbbec_Gemini345": 0.1294}
# azimuth measured in the belt plane (deg, 0 = +X downstream, 90 = +Y side), elevation
# above the belt plane. The ORIGINAL rig measured out at ~90/208/330 deg azimuth and ~42
# deg elevation - i.e. three views already spread ~120 deg apart, which is a sane merge
# geometry; only the distance was 628 mm rather than 600.
CONFIGS = {
"A_original": { # previous layout, brought to 600 mm
"RealSense_D435": (90.0, 41.8),
"Orbbec_Gemini305": (208.3, 41.8),
"Orbbec_Gemini345": (330.0, 41.7),
},
"B_side_opposed": { # D435 split to face itself across the belt, low and sideways
"RealSense_D435": ("SPLIT", 20.0),
"Orbbec_Gemini305": (208.3, 41.8),
"Orbbec_Gemini345": (330.0, 41.7),
},
"C_low_triad": { # same 120 deg spread, but LOWER - more side/height coverage,
"RealSense_D435": (90.0, 25.0), # which is where the old pipeline lost accuracy
"Orbbec_Gemini305": (210.0, 25.0),
"Orbbec_Gemini345": (330.0, 25.0),
},
"D_mixed_elev": { # one overhead for footprint + two low for height/silhouette
"RealSense_D435": (90.0, 65.0),
"Orbbec_Gemini305": (210.0, 22.0),
"Orbbec_Gemini345": (330.0, 22.0),
},
}
AZ = (90.0, 208.3, 330.0) # rig azimuths in the belt plane, ~120 deg apart
# ============================ CANONICAL ARRANGEMENT ============================
# E60: height 700 mm, elevation 60 deg -> working distance 808 mm, azimuths 90 /
# 208.3 / 330 deg. Chosen by measurement on 2026-08-01, not by preference:
#
# config elev distance MAE med D435 G305 G345
# A_orig 42 1051 mm 30.0 29.3 109.5 (1/9) 67.4 (5/9)
# E45 45 991 mm 25.5 29.9 50.8 (1/9) 32.8
# E60 60 808 mm 22.3 25.8 27.1 28.0 <-- all 9/9
# E75 75 726 mm 23.6 27.8 26.3 29.4
# EQ15/20 45 per-rig 29.8 42.2 31.3 30.9
#
# Two findings are load-bearing:
# * Gemini305's 26.5 mm baseline does NOT need its own short distance. At 1051 mm it
# produced a cloud once in nine tries because the disparity at the target was only
# 17 px; at 808 mm it is 22 px and the rig works. Giving each rig its own
# "equal depth precision" distance (EQ15/EQ20) fixed G305 but wrecked D435
# (25.8 -> 42.2) and made the merge worse than any common-distance layout.
# * E60 is the first layout where fusing three rigs beats the best single rig
# (22.3 vs 25.8). At 1051 mm fusion bought nothing.
#
# E75 is nearly as accurate but its shared belt region is a third smaller
# (5756 vs 7681 cm2), i.e. less room for the item to sit off-centre.
DEFAULT = "E60"
CONFIGS["E60"] = {
"RealSense_D435": (AZ[0], 60.0),
"Orbbec_Gemini305": (AZ[1], 60.0),
"Orbbec_Gemini345": (AZ[2], 60.0),
}
# ---- kept for reproducing the sweep above; not used by the pipeline ----
for _el in (45.0, 75.0):
CONFIGS[f"E{int(_el)}"] = {rig: (az, _el) for rig, az in zip(
("RealSense_D435", "Orbbec_Gemini305", "Orbbec_Gemini345"), AZ)}
_FX = 674.419
_B = {"RealSense_D435": 0.0735, "Orbbec_Gemini305": 0.0265, "Orbbec_Gemini345": 0.1294}
for _r_mm in (15.0, 20.0): # per-rig distance for one shared mm-per-disp-px
CONFIGS[f"EQ{int(_r_mm)}"] = {
rig: (az, 45.0, math.sqrt(_r_mm / 1000.0 * _FX * _B[rig]))
for rig, az in zip(("RealSense_D435", "Orbbec_Gemini305", "Orbbec_Gemini345"), AZ)}
def _cam(stage, name):
for p in stage.Traverse():
if p.IsA(UsdGeom.Camera) and p.GetName() == name:
return p
raise KeyError(name)
def _look_at(pos, target):
fwd = target - pos
fwd = fwd / (fwd.GetLength() or 1.0)
zax = -fwd
up = Gf.Vec3d(0, 0, 1)
if abs(Gf.Dot(up, zax)) > 0.999:
up = Gf.Vec3d(0, 1, 0)
xax = Gf.Cross(up, zax); xax = xax / (xax.GetLength() or 1.0)
yax = Gf.Cross(zax, xax)
M = Gf.Matrix4d(1.0)
M.SetRow3(0, xax); M.SetRow3(1, yax); M.SetRow3(2, zax)
M.SetTranslateOnly(pos)
return M, xax, fwd
def _place(stage, name, M):
xf = UsdGeom.Xformable(_cam(stage, name))
xf.ClearXformOpOrder()
xf.AddTransformOp().Set(M)
def apply_config(stage, cfg_name, res=(1280, 720)):
"""position all six cameras, and square up the apertures for the render resolution.
The aperture aspect must match the image aspect or fx != fy and every back-projected
point is stretched - a silent scale error in exactly the dimension we are measuring.
"""
cfg = CONFIGS[cfg_name]
W, H = res
out = {}
for rig, spec in cfg.items():
b = BASELINE[rig]
if spec[0] == "SPLIT":
th = math.radians(spec[1])
standoff = HEIGHT / max(math.sin(th), 1e-6)
for side, sgn in (("Left", +1.0), ("Right", -1.0)):
pos = TARGET + Gf.Vec3d(0.0, sgn * standoff * math.cos(th),
standoff * math.sin(th))
M, _, _ = _look_at(pos, TARGET)
_place(stage, f"{rig}_{side}", M)
else:
az, el = math.radians(spec[0]), math.radians(spec[1])
d = Gf.Vec3d(math.cos(az) * math.cos(el), math.sin(az) * math.cos(el), math.sin(el))
# a third element pins this rig's own distance: depth cost is Z^2/(fx*B),
# so rigs with different baselines need different distances to reach the
# same mm-per-disparity-pixel. One shared distance always starves the
# narrowest baseline.
standoff = (spec[2] if len(spec) > 2 else HEIGHT / max(math.sin(el), 1e-6))
centre = TARGET + d * standoff
M, xax, _ = _look_at(centre, TARGET)
for side, off in (("Left", -b / 2.0), ("Right", +b / 2.0)):
Mi = Gf.Matrix4d(M)
Mi.SetTranslateOnly(centre + xax * off)
_place(stage, f"{rig}_{side}", Mi)
xc = UsdGeom.XformCache()
for rig in cfg:
for side in ("Left", "Right"):
n = f"{rig}_{side}"
prim = _cam(stage, n)
c = UsdGeom.Camera(prim)
ha = c.GetHorizontalApertureAttr().Get()
c.CreateVerticalApertureAttr().Set(ha * H / W) # square pixels
fl = c.GetFocalLengthAttr().Get()
M = xc.GetLocalToWorldTransform(prim)
out[n] = dict(
fx=fl / ha * W, fy=fl / (ha * H / W) * H, cx=W / 2.0, cy=H / 2.0,
width=W, height=H, baseline=BASELINE[rig], rig=rig, side=side,
M=[[M[r][col] for col in range(4)] for r in range(4)],
pos=[M.ExtractTranslation()[i] for i in range(3)],
height_mm=round((M.ExtractTranslation()[2] - TARGET[2]) * 1000, 1),
standoff_mm=round((M.ExtractTranslation() - TARGET).GetLength() * 1000, 1))
return out
+60
View File
@@ -0,0 +1,60 @@
"""Background plate: the same cameras, the same lighting, no object.
In simulation this gives an EXACT object mask by image difference, which beats every
height-threshold heuristic - and the height threshold is precisely what failed: CREStereo
puts the belt a few mm above belt_z, so a z-crop returns a belt patch whose footprint is
the crop window (measured 1700x1550x64 mm for every item, i.e. the crop, not the object).
"""
import json, os, sys
REPO = "/home/dasha/robozon-sorter"
for e in (REPO, f"{REPO}/control_test"):
if e not in sys.path:
sys.path.insert(0, e)
for _m in [k for k in list(sys.modules) if k.startswith("cam_configs")]:
del sys.modules[_m]
import importlib; importlib.invalidate_caches()
import asyncio
import omni.usd, omni.timeline
import omni.kit.viewport.utility as vp
import isaacsim.core.experimental.utils.app as app_utils
from pxr import UsdGeom
import cam_configs as CC
CFG = globals().get("cfg", CC.DEFAULT)
OUT = f"{REPO}/control_test/captures/{CFG}"
os.makedirs(OUT, exist_ok=True)
stage = omni.usd.get_context().get_stage()
tl = omni.timeline.get_timeline_interface()
if tl.is_playing():
tl.stop(); await app_utils.update_app_async(steps=10)
calib = CC.apply_config(stage, CFG)
root = stage.GetPrimAtPath("/World/CapItems")
if root.IsValid():
for c in root.GetChildren():
UsdGeom.Imageable(c).MakeInvisible()
await app_utils.update_app_async(steps=25)
w = vp.get_active_viewport()
orig = w.camera_path
bg = {}
for cam_name in calib:
w.camera_path = CC._cam(stage, cam_name).GetPath()
await app_utils.update_app_async(steps=22)
await asyncio.sleep(0)
f = f"{OUT}/__background__{cam_name}.png"
vp.capture_viewport_to_file(w, file_path=f)
await app_utils.update_app_async(steps=12)
await asyncio.sleep(0)
bg[cam_name] = f
w.camera_path = orig
await app_utils.update_app_async(steps=10)
man_path = f"{OUT}/manifest.json"
man = json.load(open(man_path))
man["background"] = bg
json.dump(man, open(man_path, "w"), indent=1)
print(f"background plate: {len(bg)} views -> {man_path}")
+112
View File
@@ -0,0 +1,112 @@
"""SUPERSEDED - kept for history only.
SUPERSEDED by capture_roi.py. This version composes the item reference
onto the prim whose xformOpOrder it then clears, which destroys the mesh's own
placement and buries every item 0.6 m under the belt, and it never clears the
authored visibility=invisible the item layers carry. Both failures are silent:
the captures look like a normal empty belt. Do not use.
"""
"""STAGE 1 (inside Isaac, no torch): render a rectified L/R pair from every rig for every
item, for one named camera configuration.
Items are parked kinematic at the inspection point so the pair is perfectly consistent -
a moving object between the two eyes would fake a disparity that is not there.
"""
import json, os, sys
REPO = "/home/dasha/robozon-sorter"
for e in (REPO, f"{REPO}/control_test"):
if e not in sys.path:
sys.path.insert(0, e)
for _m in [k for k in list(sys.modules) if k.startswith(("cam_configs", "classify", "cell"))]:
del sys.modules[_m]
import importlib; importlib.invalidate_caches()
import asyncio
import omni.usd, omni.timeline
import omni.kit.viewport.utility as vp
import isaacsim.core.experimental.utils.app as app_utils
from pxr import Gf, Usd, UsdGeom, UsdLux, UsdPhysics
import cam_configs as CC
import classify as CL
CFG = globals().get("cfg", "A_original")
ITEMS = globals().get("items", ["bag", "backpack", "lunchbox", "helmet", "pillow",
"detergent", "bucket", "box_400x400x300", "box_300x200x200"])
OUT = f"/home/dasha/robozon-sorter/control_test/captures/{CFG}"
os.makedirs(OUT, exist_ok=True)
stage = omni.usd.get_context().get_stage()
tl = omni.timeline.get_timeline_interface()
if tl.is_playing():
tl.stop(); await app_utils.update_app_async(steps=10)
calib = CC.apply_config(stage, CFG)
print(f"config {CFG}: {len(calib)} cameras placed at {CC.STANDOFF*1000:.0f} mm")
# side views look at shadowed faces, so light the cell from several directions - the
# earlier study found texture matters far more than brightness, but a black frame has
# neither
for i, (nm, pos) in enumerate((("K0", (-0.75, 1.6, 2.6)), ("K1", (-0.75, -1.6, 2.6)),
("K2", (0.6, 0.0, 2.6)), ("K3", (-2.1, 0.0, 2.6)))):
p = f"/World/_CapLight_{nm}"
if not stage.GetPrimAtPath(p).IsValid():
sl = UsdLux.SphereLight.Define(stage, p)
sl.CreateRadiusAttr().Set(0.25)
sl.CreateIntensityAttr().Set(90000.0)
UsdGeom.Xformable(sl.GetPrim()).AddTranslateOp().Set(Gf.Vec3d(*pos))
dome = stage.GetPrimAtPath("/Environment/_BrightFill")
if dome.IsValid():
dome.GetAttribute("inputs:intensity").Set(3500.0)
lib = {r["name"]: r for r in CL.load_library(f"{REPO}/control_test/items") if "error" not in r}
ROOT = "/World/CapItems"
UsdGeom.Xform.Define(stage, ROOT)
def spawn(name):
path = f"{ROOT}/{name}"
if stage.GetPrimAtPath(path).IsValid():
stage.RemovePrim(path)
prim = UsdGeom.Xform.Define(stage, path).GetPrim()
prim.GetReferences().AddReference(lib[name]["path"])
# sit it ON the belt at the inspection point
bb = UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
r = bb.ComputeWorldBound(prim).ComputeAlignedRange()
drop = r.GetMin()[2]
xf = UsdGeom.Xformable(prim); xf.ClearXformOpOrder()
xf.AddTranslateOp(precision=UsdGeom.XformOp.PrecisionDouble).Set(
Gf.Vec3d(CC.TARGET[0], CC.TARGET[1], CC.TARGET[2] - drop + 0.001))
UsdGeom.Imageable(prim).MakeVisible()
return prim
w = vp.get_active_viewport()
orig_cam = w.camera_path
manifest = {"config": CFG, "target": [float(v) for v in CC.TARGET],
"standoff_m": CC.STANDOFF, "calib": calib, "items": {}}
for name in ITEMS:
if name not in lib:
print(f" skip {name} (not in library)"); continue
prim = spawn(name)
await app_utils.update_app_async(steps=20)
files = {}
for cam_name in calib:
w.camera_path = f"/RigRS/{cam_name}" if stage.GetPrimAtPath(f"/RigRS/{cam_name}").IsValid() \
else CC._cam(stage, cam_name).GetPath()
await app_utils.update_app_async(steps=22)
await asyncio.sleep(0)
f = f"{OUT}/{name}__{cam_name}.png"
vp.capture_viewport_to_file(w, file_path=f)
await app_utils.update_app_async(steps=12)
await asyncio.sleep(0)
files[cam_name] = f
manifest["items"][name] = dict(files=files, gt=dict(
cls=lib[name]["cls"], dims_mm=lib[name]["dims_mm"], k=lib[name]["k"]))
print(f" {name}: {len(files)} views")
UsdGeom.Imageable(prim).MakeInvisible()
w.camera_path = orig_cam
await app_utils.update_app_async(steps=10)
json.dump(manifest, open(f"{OUT}/manifest.json", "w"), indent=1)
print(f"\nmanifest -> {OUT}/manifest.json")
+207
View File
@@ -0,0 +1,207 @@
"""STAGE 1 (inside Isaac, no torch): render rectified L/R pairs + an EXACT object mask.
Two fixes over capture_cfg.py:
* the reference used to be composed onto the same prim whose xformOpOrder we then cleared,
which destroyed the mesh's own placement and dropped every item 0.6 m under the belt.
The reference now lives on a child, so only our holder carries the placement.
* ground truth is the mesh's real bbox in the scene, not the product catalogue - the two
disagree by 2.0-2.8x per item, so catalogue dims cannot score a size prediction.
The mask is analytic (mesh points projected through the camera), the same trick the earlier
ROI pipeline used for its GT channel - no segmentation error contaminates a geometry study.
"""
import json, os, sys
REPO = "/home/dasha/robozon-sorter"
for e in (REPO, f"{REPO}/control_test"):
if e not in sys.path:
sys.path.insert(0, e)
for _m in [k for k in list(sys.modules) if k.startswith(("cam_configs", "classify", "cell"))]:
del sys.modules[_m]
import importlib; importlib.invalidate_caches()
import asyncio, numpy as np, cv2
import omni.usd, omni.timeline
import omni.kit.viewport.utility as vp
import isaacsim.core.experimental.utils.app as app_utils
from pxr import UsdPhysics, Gf, Usd, UsdGeom, UsdLux
import cam_configs as CC
import cell
import classify as CL
try: # --file runs isolated, so an injected arg lands in
CFG = cfg # LOCALS, not globals() - the bare name catches both
except NameError:
CFG = CC.DEFAULT
ITEMS = globals().get("items", ["bag", "backpack", "lunchbox", "helmet", "pillow",
"detergent", "bucket", "box_400x400x300", "box_300x200x200"])
OUT = f"{REPO}/control_test/captures/{CFG}"
os.makedirs(OUT, exist_ok=True)
stage = omni.usd.get_context().get_stage()
tl = omni.timeline.get_timeline_interface()
if tl.is_playing():
tl.stop(); await app_utils.update_app_async(steps=10)
calib = CC.apply_config(stage, CFG)
print(f"config {CFG}: {len(calib)} камер | " + ", ".join(
f"{n.replace('_Left','')} h={c['height_mm']:.0f} d={c['standoff_mm']:.0f}"
for n, c in calib.items() if n.endswith("_Left")))
# without this the cell has no floor and no dome: everything off the belt renders as
# void, which both looks wrong over WebRTC and starves the side views of bounce light
_gl = cell.add_ground_and_light(stage)
print(f"пол {_gl['ground']}, купол {_gl['light']}")
for nm, pos in (("K0", (-0.75, 1.6, 2.6)), ("K1", (-0.75, -1.6, 2.6)),
("K2", (0.6, 0.0, 2.6)), ("K3", (-2.1, 0.0, 2.6))):
p = f"/World/_CapLight_{nm}"
if not stage.GetPrimAtPath(p).IsValid():
sl = UsdLux.SphereLight.Define(stage, p)
sl.CreateRadiusAttr().Set(0.25); sl.CreateIntensityAttr().Set(90000.0)
UsdGeom.Xformable(sl.GetPrim()).AddTranslateOp().Set(Gf.Vec3d(*pos))
dome = stage.GetPrimAtPath("/Environment/_BrightFill")
if dome.IsValid():
dome.GetAttribute("inputs:intensity").Set(3500.0)
lib = {r["name"]: r for r in CL.load_library(f"{REPO}/control_test/items") if "error" not in r}
ROOT = "/World/CapItems2"
UsdGeom.Xform.Define(stage, ROOT)
BB = lambda: UsdGeom.BBoxCache(Usd.TimeCode.Default(),
[UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
async def spawn(name):
"""holder Xform carries the placement; the reference sits on a child so its own
transform survives."""
path = f"{ROOT}/{name}"
if stage.GetPrimAtPath(path).IsValid():
stage.RemovePrim(path)
holder = UsdGeom.Xform.Define(stage, path).GetPrim()
inner = UsdGeom.Xform.Define(stage, f"{path}/mesh").GetPrim()
inner.GetReferences().AddReference(lib[name]["path"])
r = None
for _ in range(12): # a reference does not compose within the tick
await app_utils.update_app_async(steps=2)
r = BB().ComputeWorldBound(inner).ComputeAlignedRange()
if not r.IsEmpty():
break
if r is None or r.IsEmpty():
raise RuntimeError(f"{name}: пустой bbox - ссылка не разрешилась")
mn, mx = r.GetMin(), r.GetMax()
UsdGeom.Xformable(holder).AddTranslateOp(precision=UsdGeom.XformOp.PrecisionDouble).Set(
Gf.Vec3d(CC.TARGET[0] - (mn[0] + mx[0]) / 2.0,
CC.TARGET[1] - (mn[1] + mx[1]) / 2.0,
CC.TARGET[2] - mn[2] + 0.001))
# the exported item layers author visibility=invisible on their own root, and
# MakeVisible on an ancestor does NOT clear a descendant's authored value - that is
# why every earlier capture showed bare belt
for d in Usd.PrimRange(holder):
if d.IsA(UsdGeom.Imageable):
UsdGeom.Imageable(d).GetVisibilityAttr().Set(UsdGeom.Tokens.inherited)
UsdGeom.Imageable(holder).MakeVisible()
r2 = BB().ComputeWorldBound(inner).ComputeAlignedRange()
ext = [(r2.GetMax()[i] - r2.GetMin()[i]) * 1000.0 for i in range(3)]
return holder, ext, [r2.GetMin()[i] for i in range(3)], [r2.GetMax()[i] for i in range(3)]
def world_geom(prim):
"""every mesh of the item in world space, as vertices + triangles. Vertices alone are
not enough: a box has eight of them, so a point-splat mask covers ~60 px and the ROI
collapses. Filling the projected triangles gives the true silhouette."""
xc = UsdGeom.XformCache(Usd.TimeCode.Default()); V = []; T = []; base = 0
for d in Usd.PrimRange(prim):
if not d.IsA(UsdGeom.Mesh):
continue
m = UsdGeom.Mesh(d)
pts = m.GetPointsAttr().Get()
if not pts:
continue
M = np.array(xc.GetLocalToWorldTransform(d), dtype=np.float64)
P = np.asarray(pts, dtype=np.float64)
V.append((np.c_[P, np.ones(len(P))] @ M)[:, :3])
cnt = m.GetFaceVertexCountsAttr().Get() or []
idx = m.GetFaceVertexIndicesAttr().Get() or []
o = 0
for c in cnt: # fan-triangulate each polygon
for k in range(1, c - 1):
T.append((base + idx[o], base + idx[o + k], base + idx[o + k + 1]))
o += c
base += len(P)
if not V:
return np.zeros((0, 3)), np.zeros((0, 3), int)
return np.concatenate(V, 0), np.asarray(T, dtype=np.int64).reshape(-1, 3)
def gt_mask(V, T, cam):
W, H = cam["width"], cam["height"]
Minv = np.linalg.inv(np.array(cam["M"]))
c = (np.c_[V, np.ones(len(V))] @ Minv)[:, :3]
z = -c[:, 2]
u = c[:, 0] / np.maximum(z, 1e-9) * cam["fx"] + cam["cx"]
v = -c[:, 1] / np.maximum(z, 1e-9) * cam["fy"] + cam["cy"]
uv = np.c_[u, v]
m = np.zeros((H, W), np.uint8)
if len(T):
good = (z[T] > 1e-3).all(1)
tri = uv[T[good]].astype(np.int32)
tri = np.clip(tri, [-4 * W, -4 * H], [4 * W, 4 * H])
cv2.fillPoly(m, list(tri), 1)
else:
ok = (z > 1e-3) & (u >= 0) & (u < W) & (v >= 0) & (v < H)
m[v[ok].astype(int), u[ok].astype(int)] = 1
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
n, lab, st, _ = cv2.connectedComponentsWithStats(m)
if n > 1:
m = (lab == (1 + np.argmax(st[1:, cv2.CC_STAT_AREA]))).astype(np.uint8)
return m.astype(bool)
w = vp.get_active_viewport(); orig_cam = w.camera_path
manifest = {"config": CFG, "target": [float(v) for v in CC.TARGET],
"standoff_m": CC.STANDOFF, "calib": calib, "items": {}}
for name in ITEMS:
if name not in lib:
print(f" пропуск {name} (нет в библиотеке)"); continue
prim, ext, bmin, bmax = await spawn(name)
await app_utils.update_app_async(steps=20)
VV, TT = world_geom(prim)
files, masks, cover, seen = {}, {}, {}, {}
for cam_name, cam in calib.items():
w.camera_path = f"/RigRS/{cam_name}" if stage.GetPrimAtPath(f"/RigRS/{cam_name}").IsValid() \
else CC._cam(stage, cam_name).GetPath()
await app_utils.update_app_async(steps=22); await asyncio.sleep(0)
f = f"{OUT}/{name}__{cam_name}.png"
vp.capture_viewport_to_file(w, file_path=f)
await app_utils.update_app_async(steps=12); await asyncio.sleep(0)
files[cam_name] = f
mk = gt_mask(VV, TT, cam)
_img = cv2.imread(f)
if _img is not None and _img.shape[:2] == mk.shape:
_g = cv2.cvtColor(_img, cv2.COLOR_BGR2GRAY).astype(float)
_ring = cv2.dilate(mk.astype(np.uint8), np.ones((41, 41), np.uint8)).astype(bool) & ~mk
seen[cam_name] = round(float(abs(_g[mk].mean() - _g[_ring].mean())), 1)
np.savez_compressed(f"{OUT}/{name}__{cam_name}_mask.npz", m=mk)
masks[cam_name] = f"{OUT}/{name}__{cam_name}_mask.npz"
cover[cam_name] = int(mk.sum())
manifest["items"][name] = dict(
files=files, masks=masks, mask_px=cover, nverts=int(len(VV)), ntris=int(len(TT)),
contrast=seen, gt_catalogue=lib[name]["dims_mm"], cls=lib[name]["cls"],
gt_scene_mm=[round(e, 1) for e in ext], bmin=bmin, bmax=bmax)
print(f" {name}: в сцене {[round(e) for e in sorted(ext, reverse=True)]} мм "
f"маска {min(cover.values())}-{max(cover.values())} px, контраст {min(seen.values()):.0f}-{max(seen.values()):.0f}"
+ (" <-- НЕ ВИДЕН" if min(seen.values()) < 3 else ""))
# Спрятать МАЛО: невидимость не убирает коллайдер, и снятый товар остаётся твёрдой
# стеной ровно в точке осмотра, посреди рабочей линии. После двух прогонов там стояло
# 18 невидимых предметов, и поток вставал на них, не доезжая до плуга.
UsdGeom.Imageable(prim).MakeInvisible()
for _d in Usd.PrimRange(prim):
_a = _d.GetAttribute("physics:collisionEnabled")
if _a and _a.IsValid():
_a.Set(False)
elif _d.HasAPI(UsdPhysics.CollisionAPI):
UsdPhysics.CollisionAPI(_d).CreateCollisionEnabledAttr().Set(False)
w.camera_path = orig_cam
await app_utils.update_app_async(steps=10)
json.dump(manifest, open(f"{OUT}/manifest.json", "w"), indent=1)
print(f"\nmanifest -> {OUT}/manifest.json")
+766
View File
@@ -0,0 +1,766 @@
"""Runtime setup for scene/plow_cell_90_45_test.usd - the plow cell with the 90-degree
corner exit (ConveyorTrack_06) replacing plow_cell.usd's 45-degree lane.
Topology differences from plow_cell.usd, all measured on the live stage (not assumed):
* ConveyorTrack_01 is now part of the MAIN RUN (local +X -> world -X) instead of being
the plow's own lane - it is what carries class C onward to its container.
* ConveyorTrack_06 is new: a 90-degree corner that carries class B out to +Y.
* config.PLOW_PRESET needs no change: B=-16 deg was measured driving items to +Y (onto
ConveyorTrack_06 -> container B), C=+16 deg to -Y (onto ConveyorTrack_01 ->
container C) - the same signs plow_sort.py already uses for the old layout.
Two bugs fixed here for good, both cost a session each to find:
* `prim.SetActive(False)` on a ConveyorBeltGraph/DiverterAnimGraph does NOT stop an
already-instantiated OmniGraph exec - it keeps writing zero into surfaceVelocity (or
the plow's drive target) every tick regardless of the prim's active state. The graph
node has to be REMOVED (`stage.RemovePrim`), not deactivated.
* The plow's corner decks (PlowCornerDeck_B/C, PlowTransition_B/C) are static plates:
an item that slides off the belt onto one, under only the sideways push the plow gave
it, loses its drive the instant it clears the belt and stops dead on the plate -
exactly plow_sort.py's "touches and then just sits there" symptom. They have to be
driven too, toward whichever real belt segment is physically next - by MEASURED
position, not by the deck's own name: PlowCornerDeck_B in this build sits on the
geometric path toward container C, not container B.
"""
from __future__ import annotations
import pathlib
from pxr import Gf, PhysxSchema, Usd, UsdGeom, UsdLux, UsdPhysics, UsdShade
# absolute imports: control_test/cell.py is loaded as a top-level module, not as part of
# the robozon_sorter package it was copied out of. robozon_sorter must be importable -
# see control_test/README.md ("Dependencies").
from robozon_sorter import config as C
from robozon_sorter.sim import scene as _scene
from robozon_sorter.sim.plow_cell import GRIP_MATERIAL, configure_plow, drive_belt
SCENE = pathlib.Path(__file__).resolve().parent / "scene" / "plow_cell_90_45_test.usd"
# _scene.BELTS (5: ConveyorTrack, _02, _03, _04, _01) is the SORTER scene's list and does
# not cover this cell at all - it is missing ConveyorTrack_05, the entry segment items are
# actually spawned onto (x 0..+2, the first belt in the run). Driven the same -X way as the
# rest of the main run below. ConveyorTrack_06 (the 90-degree corner) is NOT in this list -
# it needs a different world direction (0,+1,0) and is driven separately in configure_belts.
BELTS = _scene.BELTS + ["/World/ConveyorTrack_05/Belt"]
TRACKS = ("ConveyorTrack", "ConveyorTrack_01", "ConveyorTrack_02", "ConveyorTrack_03",
"ConveyorTrack_04", "ConveyorTrack_05", "ConveyorTrack_06")
# Belt top z=1.781 everywhere on the main run; ConveyorTrack_05 is the line's entry, local
# +X -> world +X (the only segment laid that way - everything else is world -X already).
ENTRY_BELT = "/World/ConveyorTrack_05/Belt"
ENTRY_X, ENTRY_Y = 1.80, 0.0 # near the +X (upstream) end of ConveyorTrack_05's 0..+2 span
GROUND_Z = C.FLOOR_Z # 0.0 - matches the sorter scene's own floor constant
GROUND_PATH = "/World/_Ground"
LIGHT_PATH = "/Environment/_BrightFill"
# Deck -> unit world direction aiming at the CENTRE of the real belt it physically feeds
# into. Computed from UsdGeom.BBoxCache on the live stage, not guessed from the deck's
# name - the names are stale (see module docstring). Re-derive if the scene is re-laid.
DECK_DIR = {
"/World/PlowTransition_B": (-0.9995, 0.0309, 0.0), # feeds ConveyorTrack_01 (class C)
"/World/PlowCornerDeck_B": (-0.9716, 0.2367, 0.0), # feeds ConveyorTrack_01 (class C)
"/World/PlowTransition_C": (-0.9945, -0.1047, 0.0), # feeds ConveyorTrack_06 (class B)
"/World/PlowCornerDeck_C": (-0.9995, -0.0302, 0.0), # feeds ConveyorTrack_06 (class B)
}
PUSHER_GEOM = "/World/Diverters/DiverterY_Split/Pusher/Geom"
# Footprint along the belt. The authored blade was 1200 mm - a near-wall - and 500 mm was
# the requested replacement, but 500 mm is provably too narrow for THIS belt speed:
# * momentum transfer falls off with blade speed (measured dy: 1.3 m/s -> 0.17..0.22 m,
# 1.8 m/s -> 0.01..0.08 m), because a transform-driven kinematic blade shoves by
# depenetration rather than by carrying - so the stroke wants to be SLOW;
# * a slow stroke (0.82 m at 1.3 m/s = 0.63 s) needs 0.63 m of blade to stay in contact
# at 1 m/s belt speed, but 500 mm only gives 0.50 s, so the item slid off the trailing
# edge halfway through and left with a third of the needed displacement.
# 800 mm satisfies both (0.80 s of contact for a 0.63 s stroke) and is still a third
# shorter than the 1200 mm original.
PUSHER_X_MM = 500.0
def resize_pusher_blade(stage, x_mm=PUSHER_X_MM):
"""the authored blade is a Cube scaled (1.2, 0.06, 0.3) - 1200 mm along the belt
(X), a near-wall rather than a paddle. Only the X (along-belt) scale changes; Y
(cross-belt thickness) and Z (height) are load-bearing as measured elsewhere and
stay put. Idempotent: re-reads and re-derives from whatever scale is currently there."""
prim = stage.GetPrimAtPath(PUSHER_GEOM)
if not prim.IsValid():
return None
xf = UsdGeom.Xformable(prim)
for op in xf.GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeScale:
s = op.Get()
op.Set(Gf.Vec3f(x_mm / 1000.0, s[1], s[2]))
return (x_mm / 1000.0, s[1], s[2])
return None
PUSHER_GRIP_MATERIAL = "/World/_PusherGrip"
def grip_pusher_blade(stage, static_f=1.1, dynamic_f=0.95):
"""the blade face is bound to /World/Diverters/DiverterMaterial (static/dynamic
friction 0.12/0.08) - deliberately slick for the PLOW's blade (config.PLOW_BLADE_
FRICTION, so goods slide along its edge instead of piling up), but the pusher shares
that same authored material and inherits the slickness for free. Measured on an
isolated item: it picks up a brief lateral velocity spike on contact and then the
blade sweeps clean past it - a flick, not a carry (0.42 m commanded stroke, item ends
up 0.05 m over). A high-friction grip material, bound stronger-than-descendants same
as the belts' own grip, is what a real pusher gate needs: it should carry the item
with it, not glance off."""
prim = stage.GetPrimAtPath(PUSHER_GEOM)
if not prim.IsValid():
return None
grip = stage.GetPrimAtPath(PUSHER_GRIP_MATERIAL)
if not grip.IsValid():
grip = stage.DefinePrim(PUSHER_GRIP_MATERIAL, "Material")
pm = UsdPhysics.MaterialAPI.Apply(grip)
pm.CreateStaticFrictionAttr().Set(static_f)
pm.CreateDynamicFrictionAttr().Set(dynamic_f)
pm.CreateRestitutionAttr().Set(0.0)
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(UsdShade.Material(grip), bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return (static_f, dynamic_f)
PUSHER_XFORM = "/World/Diverters/DiverterY_Split/Pusher"
PUSHER_CLEARANCE = 0.002 # target gap between the blade's bottom edge and the belt top
def seat_pusher_blade(stage, clearance=PUSHER_CLEARANCE):
"""scene.py's configure_pusher() seats the blade at a hardcoded local z=-0.135,
which measured 14 mm above the belt (1.795 vs belt top 1.781) - fine for the boxy
items it was tuned on, but taller than `plate` (9 mm) or `pen` (5 mm), which pass
clean underneath no matter how the sweep speed/friction is tuned. Lower it to a
small measured clearance above the belt instead of trusting the hardcoded offset."""
blade = stage.GetPrimAtPath(PUSHER_XFORM)
belt = stage.GetPrimAtPath("/World/ConveyorTrack_03/Belt")
if not blade.IsValid() or not belt.IsValid():
return None
bbc = UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
blade_bottom = bbc.ComputeWorldBound(blade).ComputeAlignedRange().GetMin()[2]
belt_top = bbc.ComputeWorldBound(belt).ComputeAlignedRange().GetMax()[2]
drop = (blade_bottom - belt_top) - clearance
if drop <= 0:
return blade_bottom, belt_top, 0.0
for op in UsdGeom.Xformable(blade).GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
v = op.Get()
op.Set(Gf.Vec3d(v[0], v[1], v[2] - drop))
return blade_bottom, belt_top, drop
return None
def _kill_stale_graphs(stage):
"""remove (not deactivate) every ConveyorBeltGraph and the DiverterAnimGraph - see
module docstring. Safe to call more than once; RemovePrim on a missing path is a no-op
check via IsValid() first."""
killed = []
for track in TRACKS:
for graph in (f"/World/{track}/ConveyorBeltGraph", f"/World/{track}/ConveyorBeltGraph_01"):
p = stage.GetPrimAtPath(graph)
if p.IsValid():
stage.RemovePrim(p.GetPath())
killed.append(graph)
p = stage.GetPrimAtPath("/World/Diverters/DiverterAnimGraph")
if p.IsValid():
stage.RemovePrim(p.GetPath())
killed.append("/World/Diverters/DiverterAnimGraph")
return killed
CAPTURE_PARKS = ("/World/CapItems", "/World/CapItems2", "/World/_CapItems")
def clear_capture_parks(stage, parks=CAPTURE_PARKS):
"""Снять коллизию с товаров, оставленных стендом захвата кадров в точке осмотра.
capture_roi.py ставит очередной товар в точку осмотра (-0.750, 0.0, 1.781), снимает
его шестью камерами и в конце прячет вызовом MakeInvisible(). НЕВИДИМОСТЬ НЕ УБИРАЕТ
КОЛЛАЙДЕР: после двух прогонов захвата в сцене осталось 18 невидимых, но твёрдых
предметов (/World/CapItems и /World/CapItems2 по девять), все в одной точке на ленте.
Симптом ровно тот, на который жалуются: товар идёт 1.00 м/с и встаёт "посреди
ConveyorTrack_02" - середина этой секции как раз x ~ -1.0, а стена стоит на -0.75.
Проба 60 мм в замере вставала на x = -0.667 и уползала вбок на y = -0.11, обтекая
невидимое препятствие.
Коллизия снимается, а не удаляются премы: кадры в captures/ ссылаются на эти пути,
и стенд замера должен продолжать работать.
"""
off = []
for root in parks:
r = stage.GetPrimAtPath(root)
if not r.IsValid():
continue
for d in Usd.PrimRange(r):
a = d.GetAttribute("physics:collisionEnabled")
if a and a.IsValid():
if a.Get() is not False:
a.Set(False); off.append(str(d.GetPath()))
elif d.HasAPI(UsdPhysics.CollisionAPI):
UsdPhysics.CollisionAPI(d).CreateCollisionEnabledAttr().Set(False)
off.append(str(d.GetPath()))
return off
def add_ground_and_light(stage):
"""this bare mechanical cell (see module docstring: no camera portal, no laser gate,
no item library) also ships with no ground plane and a single DistantLight - fine for
a dry mechanics smoke test, useless for watching goods over WebRTC: anything that
overshoots a belt or a container (the pusher has thrown items tens of metres in this
same cell before) free-falls forever and the scene reads as half-lit. A big static
collider under the whole cell plus a bright DomeLight fix both, idempotently."""
ground = stage.GetPrimAtPath(GROUND_PATH)
if not ground.IsValid():
cube = UsdGeom.Cube.Define(stage, GROUND_PATH)
cube.CreateSizeAttr().Set(1.0) # unit cube, half-extent 0.5 before scale
xf = UsdGeom.Xformable(cube.GetPrim())
# covers x -15..+25 (both the conveyor/container area AND the item park slots
# off at x 9..21), y -8..+10, top surface at GROUND_Z
xf.AddTranslateOp().Set(Gf.Vec3d(5.0, 1.0, GROUND_Z - 0.5))
xf.AddScaleOp().Set(Gf.Vec3f(40.0, 18.0, 1.0))
prim = cube.GetPrim()
UsdPhysics.CollisionAPI.Apply(prim)
ground = prim
UsdGeom.Imageable(ground).MakeVisible()
light = stage.GetPrimAtPath(LIGHT_PATH)
if not light.IsValid():
dome = UsdLux.DomeLight.Define(stage, LIGHT_PATH)
dome.CreateIntensityAttr().Set(2500.0)
dome.CreateColorAttr().Set(Gf.Vec3f(1.0, 1.0, 1.0))
light = dome.GetPrim()
UsdGeom.Imageable(light).MakeVisible()
return dict(ground=str(ground.GetPath()), light=str(light.GetPath()))
RAIL_PATH = "/World/_Rails"
# Straight transport-only segments where NOTHING is ever meant to leave sideways.
# ConveyorTrack_04 was already excluded (the plow deflects goods clear off its edge onto
# the junction decks). Measured live and fixed here: ConveyorTrack_03 (the pusher shoves
# goods off ITS +Y edge onto the branch), ConveyorTrack_06 and ConveyorTrack_01 (the
# plow's own two deflection targets) all got the same treatment as _04 - and each grew a
# rail directly across its own intended entry/exit, which is exactly the pile-up seen at
# the plow and the "pusher pushes but the item just stays on the belt" symptom: the pusher
# WAS working (an isolated single-item test got it 97% of the way to the branch) - it was
# arriving at a wall this module had just built.
RAIL_BELTS = ("/World/ConveyorTrack_05/Belt", "/World/ConveyorTrack/Belt",
"/World/ConveyorTrack_02/Belt")
RAIL_HEIGHT = 0.08 # low guard, enough to stop a bounce/overshoot, not a wall
WIDEN_PATH = "/World/_Widen"
LINE_CLEAR = 0.50 # required clear width between the guards, metres
def widen_line(stage, clear=LINE_CLEAR, speed=None):
"""Widen the straight runs to `clear` between guards, without touching the belts.
The conveyor asset's belt is 450 mm wide (rails ended up at y +-0.22), so a parcel
presented across an axis longer than that wedges between the guards and the whole
queue stops behind it - measured with catalogue-scale goods, where the first 455 mm
item jammed at x ~ -0.4 and the following eight piled up nose to tail.
Rather than rescale the conveyor (its surface velocity is authored in LOCAL space and
a non-uniform Y scale would skew the drive direction - the same trap that made the
corner belt drop items), this bolts a driven strip along each edge at exactly the
belt's top height, bound to the SAME grip material and carrying the SAME world-space
velocity, then moves the guards out to the new edge. Friction and drive are unchanged
because they are literally the same material and the same velocity vector.
"""
v_belt = C.BELT_SPEED if speed is None else speed
grip = UsdShade.Material(_ensure_grip_material(stage))
bbc = UsdGeom.BBoxCache(Usd.TimeCode.Default(),
[UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
if not stage.GetPrimAtPath(WIDEN_PATH).IsValid():
UsdGeom.Xform.Define(stage, WIDEN_PATH)
xc = UsdGeom.XformCache()
made = []
for belt in RAIL_BELTS:
prim = stage.GetPrimAtPath(belt)
if not prim.IsValid():
continue
r = bbc.ComputeWorldBound(prim).ComputeAlignedRange()
mn, mx = r.GetMin(), r.GetMax()
if (mx[0] - mn[0]) < (mx[1] - mn[1]):
continue # not an X-running straight segment
width = mx[1] - mn[1]
pad = (clear - width) / 2.0
if pad <= 0.001:
continue
# the belt's drive direction in WORLD terms, whatever frame it was authored in
api = PhysxSchema.PhysxSurfaceVelocityAPI(prim)
vloc = api.GetSurfaceVelocityAttr().Get() if prim.HasAPI(
PhysxSchema.PhysxSurfaceVelocityAPI) else None
local = bool(api.GetSurfaceVelocityLocalSpaceAttr().Get()) if vloc else False
if vloc is None:
vw = Gf.Vec3f(-v_belt, 0.0, 0.0)
elif local:
M = xc.GetLocalToWorldTransform(prim)
d = M.TransformDir(Gf.Vec3d(vloc[0], vloc[1], vloc[2]))
n = d.GetLength() or 1.0
vw = Gf.Vec3f(*[float(c) / n * v_belt for c in d])
else:
vw = Gf.Vec3f(*[float(c) for c in vloc])
safe = belt.replace("/", "_")
for side, y_edge, sgn in ((0, mn[1], -1.0), (1, mx[1], +1.0)):
path = f"{WIDEN_PATH}/{safe}_{side}"
if stage.GetPrimAtPath(path).IsValid():
made.append(path)
continue
cube = UsdGeom.Cube.Define(stage, path)
cube.CreateSizeAttr().Set(1.0)
p = cube.GetPrim()
xf = UsdGeom.Xformable(p)
xf.AddTranslateOp().Set(Gf.Vec3d((mn[0] + mx[0]) / 2.0,
y_edge + sgn * pad / 2.0,
mx[2] - 0.02))
xf.AddScaleOp().Set(Gf.Vec3f(mx[0] - mn[0], pad, 0.04))
UsdPhysics.CollisionAPI.Apply(p)
UsdShade.MaterialBindingAPI.Apply(p).Bind(
grip, UsdShade.Tokens.weakerThanDescendants, "physics")
sv = PhysxSchema.PhysxSurfaceVelocityAPI.Apply(p)
sv.CreateSurfaceVelocityEnabledAttr().Set(True)
sv.CreateSurfaceVelocityLocalSpaceAttr().Set(False)
sv.CreateSurfaceAngularVelocityAttr().Set(Gf.Vec3f(0.0, 0.0, 0.0))
sv.CreateSurfaceVelocityAttr().Set(vw)
UsdGeom.Imageable(p).MakeInvisible()
made.append(path)
# the guards were built off the old edge - rebuild them on the new one
rails = stage.GetPrimAtPath(RAIL_PATH)
if rails.IsValid():
stage.RemovePrim(RAIL_PATH)
UsdGeom.Xform.Define(stage, RAIL_PATH)
for belt in RAIL_BELTS:
prim = stage.GetPrimAtPath(belt)
if not prim.IsValid():
continue
r = bbc.ComputeWorldBound(prim).ComputeAlignedRange()
mn, mx = r.GetMin(), r.GetMax()
if (mx[0] - mn[0]) < (mx[1] - mn[1]):
continue
cy = (mn[1] + mx[1]) / 2.0
safe = belt.replace("/", "_")
for side, sgn in ((0, -1.0), (1, +1.0)):
path = f"{RAIL_PATH}/{safe}_{side}"
cube = UsdGeom.Cube.Define(stage, path)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
xf.AddTranslateOp().Set(Gf.Vec3d((mn[0] + mx[0]) / 2.0,
cy + sgn * clear / 2.0,
mx[2] + RAIL_HEIGHT / 2.0))
xf.AddScaleOp().Set(Gf.Vec3f(mx[0] - mn[0] + 0.10, 0.02, RAIL_HEIGHT))
UsdPhysics.CollisionAPI.Apply(cube.GetPrim())
UsdGeom.Imageable(cube.GetPrim()).MakeInvisible()
return dict(strips=len(made), clear_mm=round(clear * 1000))
def add_side_rails(stage):
"""low invisible guards along the long edges of straight runs, so a jostled item
rolls back onto the belt instead of pitching off into open air (measured happening -
the pusher alone has thrown items metres off the line before). Computed from each
belt's OWN live bbox, not hand-picked numbers - segments are laid at different
orientations and a constant y +-0.45 is wrong on at least one of them."""
bbc = UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
root = stage.GetPrimAtPath(RAIL_PATH)
if not root.IsValid():
UsdGeom.Xform.Define(stage, RAIL_PATH)
built = []
for belt in RAIL_BELTS:
prim = stage.GetPrimAtPath(belt)
if not prim.IsValid():
continue
r = bbc.ComputeWorldBound(prim).ComputeAlignedRange()
mn, mx = r.GetMin(), r.GetMax()
dx, dy = mx[0] - mn[0], mx[1] - mn[1]
top = mx[2]
long_axis_x = dx >= dy # which local axis is the belt's length vs its width
safe_name = belt.replace("/", "_")
for side, edge in ((0, mn), (1, mx)):
path = f"{RAIL_PATH}/{safe_name}_{side}"
if stage.GetPrimAtPath(path).IsValid():
built.append(path)
continue
cube = UsdGeom.Cube.Define(stage, path)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
if long_axis_x:
cx, hx = (mn[0] + mx[0]) / 2.0, dx / 2.0 + 0.05
cy = edge[1]
sx, sy = hx * 2.0, 0.02
else:
cx = edge[0]
cy, hy = (mn[1] + mx[1]) / 2.0, dy / 2.0 + 0.05
sx, sy = 0.02, hy * 2.0
xf.AddTranslateOp().Set(Gf.Vec3d(cx, cy, top + RAIL_HEIGHT / 2.0))
xf.AddScaleOp().Set(Gf.Vec3f(sx, sy, RAIL_HEIGHT))
UsdPhysics.CollisionAPI.Apply(cube.GetPrim())
UsdGeom.Imageable(cube.GetPrim()).MakeInvisible()
built.append(path)
return built
def _ensure_grip_material(stage):
"""drive_belt()'s default grip_path (plow_cell.GRIP_MATERIAL, /World/PlowCell/
M_beltPhysics) is only ever CREATED inside plow_cell.configure_belts() - this module
calls drive_belt() directly and never that function, so the material prim never
existed, `grip.IsValid()` was False on every single call, and every deck/belt driven
here kept whatever friction it already had (or nothing) instead of getting bound to
the intended high-grip surface. The main belts happened to already carry their own
per-track authored material (0.9/0.9) and looked fine by accident; the plow-junction
decks have no such authored material and were the ones left exposed."""
grip = stage.GetPrimAtPath(GRIP_MATERIAL)
if not grip.IsValid():
grip = stage.DefinePrim(GRIP_MATERIAL, "Material")
pm = UsdPhysics.MaterialAPI.Apply(grip)
pm.CreateStaticFrictionAttr().Set(1.1)
pm.CreateDynamicFrictionAttr().Set(0.95)
pm.CreateRestitutionAttr().Set(0.02)
return grip
def regrip_decks(stage, static_f=1.1, dynamic_f=0.95):
"""configure_plow() runs after configure_belts() and rebinds the transition plates
(PlowTransition_B/C) to /World/PlowCell/M_plowSection - a deliberately slippery
material (0.7/0.6, config.PLOW_SECTION_FRICTION) by original design, so the plow's
blade can slide an item across rather than have the plate fight it. This module also
tries to conveyor-DRIVE those same plates (DECK_DIR), which needs grip, not slip - the
two designs are in direct conflict, and 'strongerThanDescendants' meant the slippery
one always won. Measured effect: items sitting on a plate that is moving under them
but barely dragging them - the multi-second "stuck" crawl on the kinematics log.
PlowCornerDeck_B/C had no material bound at all (checked live) for the same reason as
_ensure_grip_material above. Re-bind all four, stronger again, after configure_plow."""
grip = _ensure_grip_material(stage)
mat = UsdShade.Material(grip)
bound = []
for path in DECK_DIR:
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
continue
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(mat, bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
bound.append(path)
return bound
# The conveyor ART prim of each track (SM_ConveyorBelt_*) carries its own collider, and
# that includes the blue SIDE RAILS running the full length of the track. At a plow/pusher
# station the rails have to be cut away on the discharge side - goods leave the belt
# sideways there by design. plow_sort.py documents this exactly ("Left in place they simply
# stop everything at the lane entry, which is what 'nothing reaches the bins' looked like")
# and provides open_junction() for it; this module never called it, so ConveyorTrack_04's
# shell (y -0.58..+0.58, collision on) stood as a wall right where class-B goods are pushed
# out - measured: B items deflected correctly to y~+0.48 then sat there for 55-58 s.
# Only the decorative shell loses its collider; every Belt keeps its own, so goods still
# ride on a real surface and cannot fall through.
JUNCTION_SHELLS = (
"/World/ConveyorTrack_04/SM_ConveyorBelt_A06_02", # the run through the plow
"/World/ConveyorTrack_04/SM_ConveyorBelt_A06_Decal_02",
"/World/ConveyorTrack_01/SM_ConveyorBelt_A06_02", # class-C lane
"/World/ConveyorTrack_01/SM_ConveyorBelt_A06_Decal_02",
"/World/ConveyorTrack_06/SM_ConveyorBelt_A03", # class-B lane (90 deg corner)
"/World/ConveyorTrack_06/SM_ConveyorBelt_A03_Decal",
"/World/ConveyorTrack_03/SM_ConveyorBelt_A21_02", # the pusher's own discharge
"/World/ConveyorTrack_03/SM_ConveyorBelt_A21_Decal_02",
)
def open_junction(stage):
"""drop the decorative shell colliders at the plow and pusher discharge points"""
opened = []
for path in JUNCTION_SHELLS:
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
continue
attr = prim.GetAttribute("physics:collisionEnabled")
if not attr:
attr = UsdPhysics.CollisionAPI.Apply(prim).CreateCollisionEnabledAttr()
attr.Set(False)
opened.append(path)
return opened
PUSH_SECTION_MATERIAL = "/World/_PushSectionSlip"
def slip_pusher_section(stage, static_f=0.30, dynamic_f=0.25):
"""lower the friction of the belt the pusher discharges from.
The grip material this module binds to every belt (1.1/0.95) is right for carrying
goods along the line, but at the pusher it is the thing the blade has to fight: a
0.6 kg item on mu=0.95 resists lateral motion with ~5.3 N, and the measured result was
the blade sweeping its full 0.82 m stroke while the item slid only 0.15-0.22 m across
it - a slip, not a transfer. The project's own plow code solves the same problem the
same way (config.PLOW_SECTION_FRICTION 0.70/0.60 on the transition plates, and 0.05/
0.04 on the blade face) so goods can slide sideways off the belt.
Applied to ConveyorTrack_03/Belt only - the pusher's own discharge section. Its
surfaceVelocity still carries items along the line; 0.30/0.25 is ample for that at
1 m/s while letting the blade drive them across.
"""
prim = stage.GetPrimAtPath("/World/ConveyorTrack_03/Belt")
if not prim.IsValid():
return None
mat_prim = stage.GetPrimAtPath(PUSH_SECTION_MATERIAL)
if not mat_prim.IsValid():
mat_prim = stage.DefinePrim(PUSH_SECTION_MATERIAL, "Material")
pm = UsdPhysics.MaterialAPI.Apply(mat_prim)
pm.CreateStaticFrictionAttr().Set(static_f)
pm.CreateDynamicFrictionAttr().Set(dynamic_f)
pm.CreateRestitutionAttr().Set(0.0)
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(UsdShade.Material(mat_prim),
bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return (static_f, dynamic_f)
BRIDGE_PATH = "/World/_TransferBridge"
# The plow discharges class-B goods over ConveyorTrack_04's +Y edge (y = +0.45) while they
# are still at x -7.95..-7.32 (the blade's own span). ConveyorTrack_06 - the belt that
# takes them to container B - only starts at x = -8.00, and the authored transition plates
# sit UPSTREAM of the plow at x -7.39..-6.39 (they belong to the old layout). So between
# _04's edge and _06 there is simply no floor at the exact point the plow pushes goods
# across, and they drop through it. Measured: an item placed directly on _06 rides it and
# lands in container B at z=1.236, but the same item arriving via the plow ends up on the
# ground at z~0.00.
#
# This plate bridges that corner. Its top sits 3 mm BELOW the belt surface (1.778 vs
# 1.781) so it clears the plow arm, whose underside measured z=1.78 - a bridge flush with
# the belt would foul the blade.
BRIDGE_X0, BRIDGE_X1 = -8.06, -7.24
BRIDGE_Y0, BRIDGE_Y1 = 0.40, 1.08
BRIDGE_TOP_Z = 1.778
BRIDGE_THICK = 0.03
def add_transfer_bridge(stage, speed=None):
"""floor the _04 -> _06 corner and drive it toward container B"""
speed = speed if speed is not None else C.BELT_SPEED
prim = stage.GetPrimAtPath(BRIDGE_PATH)
if not prim.IsValid():
cube = UsdGeom.Cube.Define(stage, BRIDGE_PATH)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
xf.AddTranslateOp().Set(Gf.Vec3d((BRIDGE_X0 + BRIDGE_X1) / 2.0,
(BRIDGE_Y0 + BRIDGE_Y1) / 2.0,
BRIDGE_TOP_Z - BRIDGE_THICK / 2.0))
xf.AddScaleOp().Set(Gf.Vec3f(BRIDGE_X1 - BRIDGE_X0, BRIDGE_Y1 - BRIDGE_Y0, BRIDGE_THICK))
prim = cube.GetPrim()
UsdPhysics.CollisionAPI.Apply(prim)
UsdGeom.Imageable(prim).MakeInvisible()
# carry goods across it toward container B instead of letting them sit on a dead plate
drive_belt(stage, BRIDGE_PATH, (-0.846, 0.532, 0.0), speed)
grip = _ensure_grip_material(stage)
UsdShade.MaterialBindingAPI.Apply(prim).Bind(
UsdShade.Material(grip), bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return (BRIDGE_X0, BRIDGE_X1, BRIDGE_Y0, BRIDGE_Y1, BRIDGE_TOP_Z)
CATCHERS = {
# tray floor footprint -> its top z. Measured off the authored prims.
"/World/PlowContainers/B_Floor": None,
"/World/PlowContainers/C_Floor": None,
"/World/SortingRig/BinD_Floor": None,
}
CATCH_DEPTH = 0.30
def add_container_catchers(stage):
"""thicken the tray floors downward with an invisible slab.
The authored floors are 40 mm thick. Goods arrive off the belt (z 1.781) and land on a
tray floor at z~1.18 - a 0.6 m drop, so ~3.4 m/s, which at the scene's step is ~57 mm
of travel per step against a 40 mm slab: the item can pass straight through between
two steps. Measured exactly that - class-B goods reached container B's footprint
(x -8.58..-9.05, y 1.07..1.40, all inside the tray) and then ended up on the ground at
z~0.00. A single item dropped gently onto the same floor in isolation was caught, which
is the signature of tunnelling rather than a missing collider.
Deepening the collider (not the visible tray) means the item has several steps' worth
of solid to hit, so it cannot pass through. Purely additive: the slab sits BELOW each
existing floor, so nothing that already worked changes.
"""
bbc = UsdGeom.BBoxCache(Usd.TimeCode.Default(),
[UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
made = []
for path in CATCHERS:
src = stage.GetPrimAtPath(path)
if not src.IsValid():
continue
r = bbc.ComputeWorldBound(src).ComputeAlignedRange()
mn, mx = r.GetMin(), r.GetMax()
out = f"/World/_Catch{src.GetName()}"
if stage.GetPrimAtPath(out).IsValid():
made.append(out)
continue
cube = UsdGeom.Cube.Define(stage, out)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
xf.AddTranslateOp().Set(Gf.Vec3d((mn[0] + mx[0]) / 2.0, (mn[1] + mx[1]) / 2.0,
mx[2] - CATCH_DEPTH / 2.0))
xf.AddScaleOp().Set(Gf.Vec3f(mx[0] - mn[0], mx[1] - mn[1], CATCH_DEPTH))
UsdPhysics.CollisionAPI.Apply(cube.GetPrim())
UsdGeom.Imageable(cube.GetPrim()).MakeInvisible()
made.append(out)
return made
CORNER_BELT = "/World/ConveyorTrack_06/Belt"
def drive_corner_belt(stage, path=CORNER_BELT, speed=None):
"""drive the 90-degree corner along the CHORD that stays on its arc.
ConveyorBelt_A03 is curved: sampling the top surface gives a quarter-annulus centred on
(-8.005, 1.042) with radii 0.517..1.018 - not the rectangle its bounding box implies.
The original linear direction (-0.545, +0.839) was too +Y-heavy, so goods cut across
the hollow middle of the annulus and fell through: a traced class-B item dropped at
(-8.49, +0.88), which is r=0.511 from the centre - just inside r_in=0.517.
PhysX's angular surface velocity would be the textbook answer, but it measured inert on
this body (goods crept at ~0.02 m/s in both local and world space), so the drive stays
linear and is instead AIMED so the straight chord never leaves the band. Goods enter at
(-8.05, 0.1), i.e. r=0.943; leaving at the same radius a quarter-turn round is
(-8.948, 1.042), giving direction (-0.69, 0.7238). That chord's midpoint sits at
r=0.683, comfortably inside 0.517..1.018 - the 0.5 m band is wide enough to
swallow the ~0.26 m a 90-degree chord deviates from its arc.
"""
speed = speed if speed is not None else C.BELT_SPEED
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
return None
if not prim.HasAPI(UsdPhysics.RigidBodyAPI):
UsdPhysics.RigidBodyAPI.Apply(prim)
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(True)
# WORLD space, bypassing drive_belt's world->local conversion. That conversion
# normalises the direction *in local space*, which does not preserve the world
# direction when the frame carries a non-uniform scale - and this belt does. The
# symptom was unmistakable: asking for (-0.69, +0.72) drove goods to y = -0.098, i.e.
# the wrong way across the line and into container C.
api = PhysxSchema.PhysxSurfaceVelocityAPI.Apply(prim)
api.CreateSurfaceVelocityEnabledAttr().Set(True)
api.CreateSurfaceVelocityLocalSpaceAttr().Set(False)
api.CreateSurfaceAngularVelocityAttr().Set(Gf.Vec3f(0.0, 0.0, 0.0))
v = Gf.Vec3f(-0.69 * speed, 0.7238 * speed, 0.0)
api.CreateSurfaceVelocityAttr().Set(v)
grip = _ensure_grip_material(stage)
UsdShade.MaterialBindingAPI.Apply(prim).Bind(
UsdShade.Material(grip), bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return tuple(round(c, 3) for c in v)
def configure_belts(stage, speed=None):
"""drive all 7 main belts plus the 4 static plow-junction decks, each by its
measured world direction. Must run AFTER _kill_stale_graphs - otherwise the graphs
zero the velocity this sets a few physics steps after play()."""
speed = speed if speed is not None else C.BELT_SPEED
_ensure_grip_material(stage)
driven = {}
for path in BELTS:
v = drive_belt(stage, path, (-1, 0, 0), speed)
if v:
driven[path] = v
# ConveyorTrack_06 is a CURVED corner and is driven rotationally instead - see
# drive_corner_belt(). Driving it linearly walked goods off the arc.
# the pusher's own branch - carries a pushed D item on from the shove into BinD.
# plow_cell.py's configure_belts() drives this; this module's own list above never
# did, so a pushed item landed on a branch with no belt force and just sat there.
# Same pure-+Y bug as ConveyorTrack_06 had, measured the same way: an item placed on
# Belt_01 at (-4.10,+0.70) rode +Y to y=1.92 at CONSTANT x=-4.10 and fell off the far
# edge - BinD's floor is x -6.21..-4.95, so it missed by 0.85 m. The belt does carry
# (friction 1.1/0.95, |v|=1.0 confirmed); it was simply pointed past the bin. Aim it
# at the BinD floor centre instead.
v = drive_belt(stage, _scene.BRANCH, (-0.6976, 0.7165, 0), speed)
if v:
driven[_scene.BRANCH] = v
for path, direction in DECK_DIR.items():
v = drive_belt(stage, path, direction, speed)
if v:
driven[path] = v
return driven
async def open_scene(usd_path=None):
"""the SYNC `open_stage` + a settle margin, not `open_stage_async` - the async loader
returns while background layer composition is still touching the stage on another
thread, which trips Kit's 'Detected usd threading violation' guard the moment
configure_physics() edits the stage. A live WebRTC stream keeps Hydra populating the
freshly-opened ~360 prims on its own thread well after `is_stage_loading()` clears, so
the margin here is generous on purpose - short margins measured flaky on this scene
while streaming is active."""
import asyncio
import omni.usd
import isaacsim.core.experimental.utils.app as app_utils
path = str(usd_path or SCENE)
omni.usd.get_context().open_stage(path)
await app_utils.update_app_async(steps=120)
await asyncio.sleep(3.0)
await app_utils.update_app_async(steps=60)
return omni.usd.get_context().get_stage()
async def _retrying(fn, *args, tries=12, **kwargs):
"""call fn(*args) with a small settle-and-retry loop.
UsdPhysics/PhysX edits on a just-opened stage race a live WebRTC session's background
Hydra-populate thread: 'Detected usd threading violation' (pxr.Tf.ErrorException,
which derives from BaseException, not Exception, and carries no message in str() - the
diagnostic text is printed separately by Tf's own delegate). It clears within a step
or two once that thread catches up, so each of prepare()'s five sub-calls gets its own
short retry here rather than re-running the whole sequence from the top on every miss.
"""
import asyncio
import isaacsim.core.experimental.utils.app as app_utils
last_exc = None
for attempt in range(tries):
try:
return fn(*args, **kwargs)
except BaseException as exc:
last_exc = exc
await app_utils.update_app_async(steps=60)
await asyncio.sleep(1.0)
raise last_exc
async def prepare(stage, belt_speed=None, script_control: bool = True, kinematic_arm: bool = True):
"""everything the new-topology scene needs before the belts and the plow will run"""
await _retrying(_scene.configure_physics, stage)
killed = await _retrying(_kill_stale_graphs, stage)
parked = await _retrying(clear_capture_parks, stage)
belts = await _retrying(configure_belts, stage, belt_speed)
plow = await _retrying(configure_plow, stage, script_control, kinematic_arm)
regripped = await _retrying(regrip_decks, stage)
await _retrying(_scene.configure_pusher, stage)
pusher_dims = await _retrying(resize_pusher_blade, stage)
await _retrying(grip_pusher_blade, stage)
seat = await _retrying(seat_pusher_blade, stage)
# slip_pusher_section() is deliberately NOT called: lowering the pusher belt's
# friction to 0.30/0.25 did not improve the push at all (dy stayed ~0.21 m, the
# same value it holds across every blade speed, width and fire-timing tried) and
# it cost a class-C delivery. Kept above for the record - the ~0.21 m ceiling is
# not a friction problem.
env = await _retrying(add_ground_and_light, stage)
rails = await _retrying(add_side_rails, stage)
widened = await _retrying(widen_line, stage, LINE_CLEAR, belt_speed)
bridge = await _retrying(add_transfer_bridge, stage, belt_speed)
catchers = await _retrying(add_container_catchers, stage)
corner = await _retrying(drive_corner_belt, stage, CORNER_BELT, belt_speed)
opened = await _retrying(open_junction, stage)
return dict(script_control=script_control, plow_ready=plow, belts=belts,
graphs_removed=killed, parks_cleared=len(parked), env=env, pusher_dims=pusher_dims, rails=len(rails),
widened=widened, pusher_seat=seat, decks_regripped=regripped, bridge=bridge, catchers=len(catchers), corner_dir=corner, junction_opened=len(opened),
belt_speed=C.BELT_SPEED if belt_speed is None else belt_speed)
async def load(usd_path=None, belt_speed=None, script_control: bool = True):
stage = await open_scene(usd_path)
return stage, await prepare(stage, belt_speed, script_control)
+85
View File
@@ -0,0 +1,85 @@
"""Item library for control_test: meshes are DISCOVERED in items/, classes are READ from
items/labels.json.
No geometric auto-measurement. Dimensions and k are taken from the labelling, which is the
ground truth this cell is verified against - measuring them from the mesh was tried and
the roundness estimate under-read handled/hollow bodies (bucket 0.737 vs 0.995, mug 0.731
vs 0.985), i.e. class D silently became B. Reading the label removes that whole class of
error from the mechanics test.
The folder is still the source of items: drop a .usd in, add one line to labels.json, and
it joins the next run. Anything in the folder without a label is reported and skipped
rather than guessed at.
The documented rules are kept in `classify()` so a labelling can be checked for internal
consistency (`verify_labels()`), not to derive it:
D "не подходит без доупаковки" габариты как у B, но k > 0.8 хотя бы в одном сечении
C "не подходит по габаритам" любой размер < 10 мм ИЛИ не влезает в 450x320x320 мм.
Форма не важна.
B "подходит для сортировки" всё от 10x10x10 до 450x320x320 мм и k <= 0.8
Fit is tested with the item's extents and the envelope both sorted descending - a parcel
may be presented on any face.
"""
from __future__ import annotations
import json
import pathlib
ENVELOPE_MM = sorted((450.0, 320.0, 320.0), reverse=True)
MIN_DIM_MM = 10.0
K_THRESHOLD = 0.8
LABELS_FILE = "labels.json"
def classify(dims_mm, k):
"""the documented decision, D checked first"""
d = sorted(dims_mm, reverse=True)
undersize = min(d) < MIN_DIM_MM
fits = all(a <= b + 1e-9 for a, b in zip(d, ENVELOPE_MM))
if undersize or not fits:
return "C" # shape irrelevant
return "D" if k > K_THRESHOLD else "B"
def load_library(items_dir):
"""every .usd in items_dir, sorted, paired with its label.
Returns dicts with name/path/cls/dims_mm/k, or name/path/error for meshes that have
no entry in labels.json - those are skipped by the runner, never guessed.
"""
items_dir = pathlib.Path(items_dir)
labels_path = items_dir / LABELS_FILE
if not labels_path.exists():
raise FileNotFoundError(f"{labels_path} missing - the item classes live there")
labels = json.loads(labels_path.read_text())
out = []
for f in sorted(items_dir.glob("*.usd")):
rec = labels.get(f.stem)
if rec is None:
out.append(dict(name=f.stem, path=str(f),
error="no entry in labels.json"))
continue
out.append(dict(name=f.stem, path=str(f), cls=rec["zone"],
dims_mm=rec.get("dims_mm"), k=rec.get("k")))
return out
def verify_labels(items_dir):
"""check each label against the documented rules; returns the rows that disagree.
A label whose own dims/k imply a different class is a labelling bug, and it would
otherwise show up as a mysterious mechanical failure.
"""
bad = []
for r in load_library(items_dir):
if "error" in r or r.get("dims_mm") is None or r.get("k") is None:
continue
implied = classify(r["dims_mm"], r["k"])
if implied != r["cls"]:
bad.append(dict(name=r["name"], labelled=r["cls"], implied=implied,
dims_mm=r["dims_mm"], k=r["k"]))
return bad
+119
View File
@@ -0,0 +1,119 @@
"""Рабочий процесс замкнутого контура: слушает заявки от Isaac, отвечает классом товара.
Зачем отдельный процесс. torch внутри Isaac роняет процесс, поэтому CV не может жить в
одном процессе со сценой. Обмен идёт через каталог: Isaac кладёт заявку с путями к шести
кадрам, работник отвечает файлом с габаритами, k и классом.
Времени на это хватает с запасом. От ворот осмотра (x = -0.750) до пушера (x = -3.900)
товар при 1 м/с едет 3.15 с, до плуга (x = -7.85) - 7.1 с, а инференс на зафиксированном
бейзлайне занимает 469 мс. То есть класс успевает к обоим механизмам, и контур замыкается
по-настоящему, а не постфактум.
БЕЙЗЛАЙН ЗАФИКСИРОВАН: DEFOM-Stereo vitl, вход сети 480, iters 24, кроп зоны осмотра,
без сегментации (товар отделяется превышением над плоскостью полотна + отсев по плотности).
На потоке 700 мм он дал классы 8/9 = 89 % и габариты MAE 32.8 мм.
"""
import os, sys, json, time, glob, traceback
os.environ["IMPORT_ONLY"] = "1" # measure_plane импортируется как библиотека
CT = "/home/dasha/robozon-sorter/control_test"
sys.path.insert(0, CT)
import numpy as np
import cv2
import measure_plane as MP
import measure_flow as MF
import classify as CL
RT = f"{CT}/runtime"
REQ, RES = f"{RT}/req", f"{RT}/res"
for d in (REQ, RES):
os.makedirs(d, exist_ok=True)
calib = MF.calib
TARGET, BELT_Z, RIGS = MF.TARGET, MF.BELT_Z, MF.RIGS
def measure(files):
"""шесть кадров -> габариты, k, класс. Тот же тракт, что в зафиксированном бейзлайне."""
pairs, metas = [], []
for rig in RIGS:
cam = calib[f"{rig}_Left"]
IL = cv2.imread(files[f"{rig}_Left"])
IR = cv2.imread(files[f"{rig}_Right"])
if IL is None or IR is None:
continue
x0, y0, x1, y1 = MP.gate_crop_px(cam)
maxd = int(np.ceil(MF.DPAD * cam["fx"] * cam["baseline"] / MF.ZMIN))
x0 = max(0, x0 - maxd)
pairs.append((IL[y0:y1, x0:x1].astype(np.float32), IR[y0:y1, x0:x1].astype(np.float32)))
metas.append(((x0, y0, x1, y1), cam))
if not pairs:
return None
disps = MP.cre_scaled(pairs, MP.SW) # движок выбирается внутри (бейзлайн: DEFOM)
clouds = []
for d, (win, cam) in zip(disps, metas):
# cloud_from_roi отдаёт ПАРУ: облако товара и облако полотна. В первой версии я
# складывал кортеж целиком, и vstack падал на разнородных формах.
obj, _belt = MP.cloud_from_roi(d, win, cam)
if len(obj):
clouds.append(obj)
if not clouds:
return None
P = np.vstack(clouds)
sel = MP.dense_only(P)
if sel.sum() >= 60:
P = P[sel]
P = P[MP.biggest_blob_idx(P)]
out = MF.dims_and_k(P)
if out is None:
return None
dims, _ = out
k, is_round, sec = MP.circular_section_K(P)
cls = CL.classify(dims, 0.0 if not k else k)
return dict(dims=[round(v, 1) for v in dims], k=round(float(k), 3),
cls=cls, n=len(P))
print(f"работник запущен: движок {MP.STEREO} {MP.DEFOM_CKPT}, вход {MP.SW}, "
f"iters {MP.DEFOM_ITERS}, кроп {MP.CROP}")
print(f"заявки: {REQ} ответы: {RES}")
# прогрев, чтобы первый настоящий товар не ждал загрузку энкодера
try:
z = np.zeros((240, 480, 3), np.float32)
MP.cre_scaled([(z, z)], MP.SW)
print("прогрев выполнен")
except Exception as ex:
print("прогрев не удался:", ex)
open(f"{RT}/worker_ready", "w").write(str(time.time()))
seen = set()
idle = 0.0
while True:
reqs = sorted(glob.glob(f"{REQ}/*.json"))
if not reqs:
time.sleep(0.05); idle += 0.05
if idle > 1800:
print("1800 с без заявок - выхожу"); break
continue
idle = 0.0
for rq in reqs:
try:
j = json.load(open(rq))
except Exception:
continue
name = j["name"]
t0 = time.time()
try:
r = measure(j["files"])
except Exception:
traceback.print_exc(); r = None
dt = (time.time() - t0) * 1000
ans = dict(name=name, ms=round(dt), ok=r is not None)
if r:
ans.update(r)
json.dump(ans, open(f"{RES}/{name}.json", "w"), ensure_ascii=False)
os.remove(rq)
print(f" {name}: {ans.get('cls','-')} dims={ans.get('dims')} k={ans.get('k')} "
f"за {dt:.0f} мс", flush=True)
+194
View File
@@ -0,0 +1,194 @@
"""STAGE 1 of the real-time flow bench (inside Isaac, no torch).
Items ride the real belt at PITCH spacing, exactly like run_pipeline.py. When one
crosses the inspection gate the timeline is paused, all six cameras are rendered, and the
run continues. Nothing about the item is written into the capture except its frames -
class and size are what stage 2 has to predict.
Meshes in items/ are 2.0-2.8x smaller than the catalogue dims they are labelled with, so
each is scaled up uniformly to catalogue scale first. Left small, every item fits the
450x320x320 envelope and class C becomes geometrically unreachable - the class metric
would be measuring nothing. Ground truth is the bbox actually in the scene after scaling.
"""
import asyncio, json, os, pathlib, sys, time
import numpy as np
import omni.timeline, omni.usd
import omni.kit.viewport.utility as vp
import isaacsim.core.experimental.utils.app as app_utils
from pxr import Gf, Usd, UsdGeom, UsdPhysics, PhysxSchema
from isaacsim.core.experimental.prims import RigidPrim
HERE = pathlib.Path("/home/dasha/robozon-sorter/control_test")
for extra in (str(HERE), "/home/dasha/robozon-sorter"):
if extra not in sys.path:
sys.path.insert(0, extra)
for _m in [k for k in list(sys.modules)
if k.startswith(("robozon_sorter", "cell", "classify", "cam_configs"))]:
del sys.modules[_m]
import importlib; importlib.invalidate_caches()
import cell
import cam_configs as CC
import classify as CL
from robozon_sorter import config as C
try: PITCH = float(pitch)
except NameError: PITCH = 0.70
try: SPEED = float(speed)
except NameError: SPEED = 1.0
try: ITEMS = list(items)
except NameError:
ITEMS = ["bag", "backpack", "lunchbox", "helmet", "pillow",
"detergent", "bucket", "box_400x400x300", "box_300x200x200"]
GATE_X = float(CC.TARGET[0]) # inspection point, items travel -X past it
OUT = str(HERE / "captures" / "flow")
os.makedirs(OUT, exist_ok=True)
ROOT = "/World/FlowItems"
stage = omni.usd.get_context().get_stage()
tl = omni.timeline.get_timeline_interface()
if tl.is_playing():
tl.stop(); await app_utils.update_app_async(steps=10)
info = await cell.prepare(stage, belt_speed=SPEED, script_control=True)
_gl = cell.add_ground_and_light(stage)
print(f"prepare: belts={len(info['belts'])} | пол {_gl['ground']}, купол {_gl['light']}")
calib = CC.apply_config(stage, CC.DEFAULT)
print(f"камеры {CC.DEFAULT}: " + ", ".join(
f"{n.replace('_Left','')} h={c['height_mm']:.0f} d={c['standoff_mm']:.0f}"
for n, c in calib.items() if n.endswith("_Left")))
# extra fill so the side views do not sit in shadow; the belt cell itself is unlit metal
for nm, pos in (("K0", (-0.75, 1.6, 2.6)), ("K1", (-0.75, -1.6, 2.6)),
("K2", (0.6, 0.0, 2.6)), ("K3", (-2.1, 0.0, 2.6))):
p = f"/World/_CapLight_{nm}"
if not stage.GetPrimAtPath(p).IsValid():
from pxr import UsdLux
sl = UsdLux.SphereLight.Define(stage, p)
sl.CreateRadiusAttr().Set(0.25); sl.CreateIntensityAttr().Set(90000.0)
UsdGeom.Xformable(sl.GetPrim()).AddTranslateOp().Set(Gf.Vec3d(*pos))
lib = {r["name"]: r for r in CL.load_library(str(HERE / "items_flow")) if "error" not in r}
ITEMS = [n for n in ITEMS if n in lib]
BB = lambda: UsdGeom.BBoxCache(Usd.TimeCode.Default(),
[UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
UsdGeom.Xform.Define(stage, ROOT)
GT, ORDER = {}, []
for i, name in enumerate(ITEMS):
path = f"{ROOT}/{name}"
if stage.GetPrimAtPath(path).IsValid():
stage.RemovePrim(path)
# items_flow/ meshes are already catalogue-scale and already seated on z=0 (see
# scale_items.py), so this is run_pipeline's proven load path verbatim: reference on
# the body prim, one translate op, nothing nested.
prim = UsdGeom.Xform.Define(stage, path).GetPrim()
prim.GetReferences().ClearReferences()
prim.GetReferences().AddReference(lib[name]["path"])
xf = UsdGeom.Xformable(prim); xf.ClearXformOpOrder()
xf.AddTranslateOp(precision=UsdGeom.XformOp.PrecisionDouble).Set(
Gf.Vec3d(9.0 + 1.5 * i, 5.0, 0.4))
UsdPhysics.RigidBodyAPI.Apply(prim)
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(False)
UsdPhysics.MassAPI.Apply(prim).CreateMassAttr().Set(0.6)
px = PhysxSchema.PhysxRigidBodyAPI.Apply(prim)
px.CreateEnableCCDAttr().Set(True)
px.CreateSolverPositionIterationCountAttr().Set(24)
px.CreateSolverVelocityIterationCountAttr().Set(8)
px.CreateSleepThresholdAttr().Set(0.0)
px.CreateMaxDepenetrationVelocityAttr().Set(C.MAX_DEPENETRATION)
for d in Usd.PrimRange(prim):
if d.HasAPI(UsdPhysics.CollisionAPI):
pc = PhysxSchema.PhysxCollisionAPI.Apply(d)
pc.CreateContactOffsetAttr().Set(0.004)
pc.CreateRestOffsetAttr().Set(0.001)
await app_utils.update_app_async(steps=4)
r = BB().ComputeWorldBound(prim).ComputeAlignedRange()
dims = sorted([(r.GetMax()[k] - r.GetMin()[k]) * 1000.0 for k in range(3)], reverse=True)
GT[name] = dict(dims_mm=[round(v, 1) for v in dims], k=lib[name]["k"],
zone_label=lib[name]["cls"],
zone_scene=CL.classify(dims, lib[name]["k"]))
UsdGeom.Imageable(prim).MakeInvisible()
ORDER.append(name)
print(f" {name:18s} {[round(v) for v in dims]} мм метка {lib[name]['cls']}, "
f"по геометрии сцены {GT[name]['zone_scene']}")
view = RigidPrim(paths=[f"{ROOT}/{n}" for n in ORDER])
plow_home = None
try:
from robozon_sorter.sim.plow import Plow
plow = Plow(stage, kinematic=True); plow.home()
except BaseException:
pass
w = vp.get_active_viewport(); orig_cam = w.camera_path
CAMS = list(calib.keys())
manifest = dict(config=CC.DEFAULT, target=[float(v) for v in CC.TARGET],
pitch_m=PITCH, speed_mps=SPEED, calib=calib, items={})
def activate(name):
prim = stage.GetPrimAtPath(f"{ROOT}/{name}")
for op in UsdGeom.Xformable(prim).GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
op.Set(Gf.Vec3d(cell.ENTRY_X, cell.ENTRY_Y, C.BELT_Z + 0.02)); break
UsdGeom.Imageable(prim).MakeVisible()
async def shoot(name, x_at):
tl.pause()
for _ in range(3):
await app_utils.update_app_async(steps=2)
files = {}
for cam in CAMS:
w.camera_path = (f"/RigRS/{cam}" if stage.GetPrimAtPath(f"/RigRS/{cam}").IsValid()
else CC._cam(stage, cam).GetPath())
await app_utils.update_app_async(steps=18); await asyncio.sleep(0)
f = f"{OUT}/{name}__{cam}.png"
vp.capture_viewport_to_file(w, file_path=f)
await app_utils.update_app_async(steps=10); await asyncio.sleep(0)
files[cam] = f
manifest["items"][name] = dict(files=files, x_at=round(float(x_at), 4), gt=GT[name])
tl.play()
print(f" снят {name} на x={x_at:+.3f}")
tl.play()
await app_utils.update_app_async(steps=10)
print(f"\n===== ПОТОК: {len(ORDER)} товаров, шаг {PITCH*1000:.0f} мм @ {SPEED} м/с "
f"(интервал {PITCH/SPEED:.2f} с) =====")
released, shot, prev_x = [], set(), {}
t_next = float(tl.get_current_time())
idx = 0
t_end = t_next + (len(ORDER) + 1) * PITCH / SPEED + 25.0
while float(tl.get_current_time()) < t_end and len(shot) < len(ORDER):
now = float(tl.get_current_time())
if idx < len(ORDER) and now >= t_next:
activate(ORDER[idx]); released.append(ORDER[idx])
print(f" {now - (t_end - (len(ORDER)+1)*PITCH/SPEED - 25.0):6.2f}с выпущен {ORDER[idx]}")
idx += 1; t_next = now + PITCH / SPEED
try:
pos = view.get_world_poses()[0].numpy()
except BaseException:
pos = None
if pos is not None:
for j, n in enumerate(ORDER):
if n in shot or n not in released:
continue
x = float(pos[j][0])
if prev_x.get(n, 9e9) > GATE_X >= x and \
abs(float(pos[j][2]) - C.BELT_Z) < 0.5:
shot.add(n)
await shoot(n, x)
prev_x[n] = x
await app_utils.update_app_async(steps=2)
await asyncio.sleep(0)
tl.stop()
w.camera_path = orig_cam
await app_utils.update_app_async(steps=10)
json.dump(manifest, open(f"{OUT}/manifest.json", "w"), indent=1)
print(f"\nснято {len(manifest['items'])}/{len(ORDER)} -> {OUT}/manifest.json")
missed = [n for n in ORDER if n not in manifest["items"]]
if missed:
print("не прошли ворота:", ", ".join(missed))
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+227
View File
@@ -0,0 +1,227 @@
{
"backpack": {
"dims_mm": [
454.7,
370.3,
300.9
],
"k": 0.82,
"zone": "C"
},
"bag": {
"dims_mm": [
201.7,
175.3,
170.3
],
"k": 0.896,
"zone": "D"
},
"banana": {
"dims_mm": [
182.6,
70.6,
33.0
],
"k": 0.94,
"zone": "D"
},
"bolts_cluster": {
"dims_mm": [
193.6,
135.6,
52.6
],
"k": 0.718,
"zone": "B"
},
"bottle": {
"dims_mm": [
304.8,
91.0,
91.0
],
"k": 0.995,
"zone": "D"
},
"box_300x200x200": {
"dims_mm": [
301.0,
200.5,
200.0
],
"k": 0.72,
"zone": "B"
},
"box_400x400x300": {
"dims_mm": [
401.0,
400.0,
300.5
],
"k": 0.716,
"zone": "C"
},
"bucket": {
"dims_mm": [
287.4,
287.4,
272.3
],
"k": 0.995,
"zone": "D"
},
"chip_bag": {
"dims_mm": [
250.0,
162.2,
69.1
],
"k": 0.811,
"zone": "D"
},
"cone": {
"dims_mm": [
500.0,
350.5,
350.5
],
"k": 0.991,
"zone": "C"
},
"cylinder": {
"dims_mm": [
434.9,
50.0,
43.0
],
"k": 0.867,
"zone": "D"
},
"detergent": {
"dims_mm": [
278.2,
259.9,
179.8
],
"k": 0.742,
"zone": "B"
},
"headphones": {
"dims_mm": [
198.4,
194.9,
93.3
],
"k": 0.807,
"zone": "D"
},
"helmet": {
"dims_mm": [
353.5,
297.1,
279.9
],
"k": 0.895,
"zone": "D"
},
"lunchbox": {
"dims_mm": [
201.1,
152.4,
62.3
],
"k": 0.646,
"zone": "B"
},
"mug": {
"dims_mm": [
112.9,
99.0,
83.2
],
"k": 0.985,
"zone": "D"
},
"parcel_box": {
"dims_mm": [
344.4,
155.1,
143.7
],
"k": 0.699,
"zone": "B"
},
"pen": {
"dims_mm": [
148.5,
13.1,
9.0
],
"k": 0.842,
"zone": "C"
},
"perfume": {
"dims_mm": [
120.0,
53.1,
53.1
],
"k": 0.924,
"zone": "D"
},
"pillow": {
"dims_mm": [
455.1,
430.6,
212.7
],
"k": 0.905,
"zone": "C"
},
"plate": {
"dims_mm": [
209.4,
209.4,
26.6
],
"k": 0.998,
"zone": "D"
},
"pouf": {
"dims_mm": [
488.9,
488.9,
264.0
],
"k": 0.994,
"zone": "C"
},
"sneaker": {
"dims_mm": [
270.4,
208.0,
125.4
],
"k": 0.706,
"zone": "B"
},
"tool_case": {
"dims_mm": [
300.0,
143.5,
60.0
],
"k": 0.454,
"zone": "B"
},
"watch": {
"dims_mm": [
230.0,
230.0,
4.6
],
"k": 0.995,
"zone": "C"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More