Сортировочная ячейка 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>
@@ -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).
|
||||
@@ -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 м использовать для
|
||||
замера классификации и габаритов.
|
||||
@@ -0,0 +1 @@
|
||||
/home/dasha/robozon-sorter/assets
|
||||
@@ -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)
|
||||
@@ -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)")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 920 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 665 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 494 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 921 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 987 KiB |
|
After Width: | Height: | Size: 960 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 582 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 579 B |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 639 B |
|
After Width: | Height: | Size: 345 B |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 515 KiB |
|
After Width: | Height: | Size: 235 B |
|
After Width: | Height: | Size: 11 KiB |