Сортировочная ячейка Isaac Sim: CV-пайплайн и меши товаров
Замкнутый контур "поток -> CV -> механика": товары идут по конвейеру с шагом 700 мм, класс определяется стереопайплайном во время движения, пушер и плуг реагируют физически. Состав: * control_test/ - ячейка и CV. run_sorting_cv.py + cv_worker.py (два процесса, потому что torch внутри Isaac роняет сцену), cell.py (физика лент, плуга, пушера), measure_plane.py (замер габаритов), README.md и .memory.md с замерами, проблемами и ловушками * robozon_sorter/ - модули симуляции, scripts/ - утилиты, scene/ - сцены * assets/ - меши товаров, плуг, объекты Objaverse Бейзлайн CV: DEFOM-Stereo vitl, вход 480, iters 24, кроп зоны осмотра, без сегментации. На потоке 700 мм - классы 8/9, габариты MAE 32.8 мм, 469 мс на товар при такте 700 мс. Веса моделей (4.5 ГБ) и пропсы конвейера NVIDIA (274 МБ) не включены - источники и команды скачивания в MODELS.md. Выход прогонов (captures/, runtime/) не включён: воспроизводится. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-command demonstration of the whole cell: staging, conveyor, vision, pusher, plow.
|
||||
|
||||
./python.sh scripts/run_demo.py # 50 dispatches, lit, windowed
|
||||
./python.sh scripts/run_demo.py --headless --repeats 2
|
||||
./python.sh scripts/run_demo.py --preset harsh --no-vision
|
||||
./python.sh scripts/run_demo.py --items bucket,barrel,box_300x200x200
|
||||
|
||||
This is the entry point for someone who has not built the scenario before: it stages the
|
||||
cell, dispatches the item library, and writes one JSON with everything needed to judge the
|
||||
result. Nothing has to be assembled by hand first.
|
||||
|
||||
It reports two things that are easy to conflate and must be kept apart:
|
||||
|
||||
* **classification** - did the vision stack name the class correctly? Measured against
|
||||
ground truth from the manifest, with a confusion matrix.
|
||||
* **delivery** - did the item physically reach the tray its class routes to? A correct
|
||||
class that ends up on the floor is a delivery failure, not a vision failure, and the
|
||||
reverse happens too - a misread item can still land somewhere by luck.
|
||||
|
||||
Per dispatch it also records the plow's state *at the moment the item is level with it*:
|
||||
the commanded angle, the angle the arm actually reached, and its angular rate. Those three
|
||||
are different numbers because the plow is a compliant force drive, and the difference is
|
||||
usually what explains a miss.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
USER_SITE = "/home/dasha/.local/lib/python3.12/site-packages" # ultralytics lives here
|
||||
if Path(USER_SITE).exists() and USER_SITE not in sys.path:
|
||||
sys.path.append(USER_SITE)
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
p = argparse.ArgumentParser(description="Robozon sorting cell - full demonstration run")
|
||||
p.add_argument("--headless", action="store_true")
|
||||
p.add_argument("--preset", default="bright", choices=["bright", "dim", "harsh"],
|
||||
help="lighting preset the run is staged under")
|
||||
p.add_argument("--no-floor", action="store_true", help="skip the catch floor")
|
||||
p.add_argument("--repeats", type=int, default=2,
|
||||
help="passes over the library; 2 x 25 items = 50 dispatches")
|
||||
p.add_argument("--items", default=None, help="comma-separated subset, for a quick check")
|
||||
p.add_argument("--no-vision", action="store_true",
|
||||
help="route on ground truth instead of running CRE-ROI v2b")
|
||||
p.add_argument("--pitch", type=float, default=2.5, help="metres between dispatches")
|
||||
p.add_argument("--speed", type=float, default=None, help="belt speed override, m/s")
|
||||
p.add_argument("--settle", type=float, default=6.0,
|
||||
help="seconds to let the last item come to rest before scoring")
|
||||
p.add_argument("--out", default=None, help="where to write the run log")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- scoring
|
||||
def confusion(records):
|
||||
labels = ["B", "C", "D"]
|
||||
m = {g: {p: 0 for p in labels + ["?"]} for g in labels}
|
||||
for r in records:
|
||||
if r["gt"] in m:
|
||||
m[r["gt"]][r["pred"] if r["pred"] in m[r["gt"]] else "?"] += 1
|
||||
return m
|
||||
|
||||
|
||||
def summarise(records, expect):
|
||||
graded = [r for r in records if r.get("pred") not in (None, "?")]
|
||||
hits = sum(1 for r in graded if r["pred"] == r["gt"])
|
||||
delivered = [r for r in records if r.get("delivered")]
|
||||
by_class = {}
|
||||
for cls in ("B", "C", "D"):
|
||||
same = [r for r in records if r["gt"] == cls]
|
||||
if same:
|
||||
by_class[cls] = dict(
|
||||
dispatched=len(same),
|
||||
delivered=sum(1 for r in same if r.get("delivered")),
|
||||
classified=sum(1 for r in same if r.get("pred") == cls),
|
||||
target=expect.get(cls))
|
||||
where = {}
|
||||
for r in records:
|
||||
where[r["outcome"]] = where.get(r["outcome"], 0) + 1
|
||||
return dict(
|
||||
dispatched=len(records),
|
||||
classification=dict(graded=len(graded), correct=hits,
|
||||
accuracy=round(hits / len(graded), 3) if graded else None,
|
||||
confusion=confusion(records)),
|
||||
delivery=dict(delivered=len(delivered),
|
||||
rate=round(len(delivered) / len(records), 3) if records else None,
|
||||
by_class=by_class, resting_places=where),
|
||||
)
|
||||
|
||||
|
||||
async def _run(app_utils, args):
|
||||
from robozon_sorter import config as C
|
||||
from robozon_sorter.sim import plow_sort as PS
|
||||
from robozon_sorter.sim import plow_vision as PV
|
||||
from robozon_sorter.sim import staging
|
||||
from robozon_sorter.sim.mechanics import Cell
|
||||
from robozon_sorter.sim.spawner import AutoFeeder
|
||||
|
||||
if args.speed:
|
||||
C.BELT_SPEED = args.speed
|
||||
|
||||
print("=" * 74)
|
||||
print(f" Robozon sorting cell - {datetime.now():%Y-%m-%d %H:%M}")
|
||||
print(f" lighting {args.preset}, belt {C.BELT_SPEED} m/s, pitch {args.pitch} m, "
|
||||
f"vision {'off' if args.no_vision else 'on'}")
|
||||
print("=" * 74)
|
||||
|
||||
# ---- 1. scene ----------------------------------------------------------
|
||||
stage, info = PV.load(script_control=True)
|
||||
await app_utils.update_app_async(steps=50)
|
||||
staged = staging.stage_cell(stage, preset=args.preset, floor=not args.no_floor)
|
||||
PS.keep_lanes_active(stage)
|
||||
lanes = PS.configure_lanes(stage)
|
||||
opened = PS.open_junction(stage)
|
||||
print(f"[scene ] lighting={staged['lighting']['preset']} "
|
||||
f"floor={'yes' if 'floor' in staged else 'no'} lanes={len(lanes)} "
|
||||
f"junction shells opened={len(opened)}")
|
||||
|
||||
# ---- 2. item library ---------------------------------------------------
|
||||
lib = ROOT / "assets" / "items"
|
||||
if not (lib / "manifest.json").exists():
|
||||
lib = C.MESHES
|
||||
print(f"[items ] assets/items missing - falling back to {lib.name} "
|
||||
"(run scripts/export_item_library.py for the full catalogue)")
|
||||
items_meta = PV.load_items(stage, meshes_dir=lib)
|
||||
classes = {k: v["zone"] for k, v in items_meta.items()}
|
||||
await app_utils.update_app_async(steps=30)
|
||||
from collections import Counter
|
||||
print(f"[items ] {len(classes)} loaded from {lib.name}: {dict(Counter(classes.values()))}")
|
||||
|
||||
cell = Cell(stage, classes.keys())
|
||||
cell.park_all()
|
||||
await app_utils.update_app_async(steps=15)
|
||||
|
||||
# ---- 3. vision ---------------------------------------------------------
|
||||
vision = None
|
||||
if not args.no_vision:
|
||||
from robozon_sorter.cv.pipeline import CreRoiV2b
|
||||
vision = CreRoiV2b()
|
||||
vision.attach_cameras()
|
||||
print(f"[vision] CRE-ROI v2b ready, gate pixels {vision.gate_px}")
|
||||
|
||||
# ---- 4. dispatch order -------------------------------------------------
|
||||
if args.items:
|
||||
order = [n.strip() for n in args.items.split(",") if n.strip() in classes]
|
||||
else:
|
||||
order = [n for _ in range(args.repeats) for n in sorted(classes)]
|
||||
mapping = PS.calibrate_mapping()
|
||||
expect = {"D": "bin", "B": "container_B", "C": "container_C"}
|
||||
sorter = PS.PlowSorter(stage, cell, classes, mapping)
|
||||
print(f"[plan ] {len(order)} dispatches, plow mapping {mapping}, expect {expect}")
|
||||
|
||||
# ---- 5. run ------------------------------------------------------------
|
||||
route, records, seen = {}, {}, set()
|
||||
def rec(name):
|
||||
return records.setdefault(name + f"#{len([k for k in records if k.startswith(name)])}"
|
||||
if False else name, dict(item=name, gt=classes[name]))
|
||||
|
||||
log_events = []
|
||||
def on_event(kind, name, payload):
|
||||
log_events.append(dict(kind=kind, item=name, **payload))
|
||||
if kind in ("release", "divert", "done"):
|
||||
print(f" {kind:8s} {name:20s} {payload if payload else ''}")
|
||||
|
||||
feeder = AutoFeeder(cell, order=order, pitch=args.pitch, route=route,
|
||||
on_event=on_event).install()
|
||||
|
||||
import omni.timeline
|
||||
timeline = omni.timeline.get_timeline_interface()
|
||||
app_utils.play(commit=True)
|
||||
await app_utils.update_app_async(steps=20)
|
||||
|
||||
dt_block, blocks = 15, 0
|
||||
prev_angle, prev_t = sorter.plow.angle, time.time()
|
||||
while blocks < 900 and len(feeder.finished) < len(order):
|
||||
await app_utils.update_app_async(steps=dt_block)
|
||||
blocks += 1
|
||||
sorter.update(dt_block / 60.0)
|
||||
|
||||
for name in list(feeder.active):
|
||||
r = records.setdefault(name, dict(item=name, gt=classes[name], pred=None,
|
||||
dims=None, k=None, cre_ms=None, views=None,
|
||||
commanded=None, reached=None, rate=None,
|
||||
outcome=None, delivered=False))
|
||||
x = float(cell.pose(name)[0])
|
||||
|
||||
# vision, once, while the item is under the portal
|
||||
if name not in seen and abs(x - C.CAM_X) < 0.08:
|
||||
if vision is not None:
|
||||
playing = timeline.is_playing()
|
||||
res = vision.measure()
|
||||
if playing and not timeline.is_playing():
|
||||
timeline.play() # Replicator stops the timeline
|
||||
await app_utils.update_app_async(steps=2)
|
||||
r.update(pred=res["cls"], dims=res["dims"], k=res["k"],
|
||||
views=res["views"], cre_ms=res["cre_ms"])
|
||||
else:
|
||||
r["pred"] = classes[name]
|
||||
route[name] = r["pred"]
|
||||
seen.add(name)
|
||||
mark = "ok " if r["pred"] == r["gt"] else "MISS"
|
||||
print(f" vision {name:20s} pred={r['pred']} gt={r['gt']} {mark} "
|
||||
f"dims={r['dims']} K={r['k']}")
|
||||
|
||||
# plow state at the moment the item is level with the blade
|
||||
if r["commanded"] is None and abs(x - C.PLOW_POS[0]) < 0.25:
|
||||
now = time.time()
|
||||
ang = sorter.plow.angle
|
||||
r["commanded"] = sorter.decided.get(name)
|
||||
r["reached"] = round(ang, 2)
|
||||
r["rate"] = round((ang - prev_angle) / max(now - prev_t, 1e-6), 1)
|
||||
print(f" plow {name:20s} cmd={r['commanded']} reached={r['reached']} "
|
||||
f"rate={r['rate']} deg/s")
|
||||
prev_angle, prev_t = sorter.plow.angle, time.time()
|
||||
|
||||
# let the stragglers come to rest before scoring
|
||||
for _ in range(int(args.settle * 60 / dt_block)):
|
||||
await app_utils.update_app_async(steps=dt_block)
|
||||
sorter.update(dt_block / 60.0)
|
||||
|
||||
for name in order:
|
||||
r = records.setdefault(name, dict(item=name, gt=classes[name], pred=None,
|
||||
outcome=None, delivered=False))
|
||||
p = cell.pose(name)
|
||||
r["outcome"] = sorter.lane_of(name)
|
||||
r["final"] = [round(float(v), 3) for v in p]
|
||||
r["expected"] = expect.get(r["gt"])
|
||||
r["delivered"] = (r["outcome"] == r["expected"])
|
||||
|
||||
app_utils.stop()
|
||||
await app_utils.update_app_async(steps=15)
|
||||
feeder.remove()
|
||||
cell.blade_to(C.BLADE_HOME_Y)
|
||||
sorter.plow.home()
|
||||
|
||||
# ---- 6. report ---------------------------------------------------------
|
||||
recs = list(records.values())
|
||||
summary = summarise(recs, expect)
|
||||
print("\n" + "=" * 74)
|
||||
print(f" dispatched {summary['dispatched']}")
|
||||
cls_s = summary["classification"]
|
||||
if cls_s["graded"]:
|
||||
print(f" classification {cls_s['correct']}/{cls_s['graded']} "
|
||||
f"= {cls_s['accuracy']:.0%}")
|
||||
print(f" {'gt\\pred':>8} " + " ".join(f"{p:>5}" for p in ["B", "C", "D", "?"]))
|
||||
for g, row in cls_s["confusion"].items():
|
||||
print(f" {g:>8} " + " ".join(f"{row[p]:>5}" for p in ["B", "C", "D", "?"]))
|
||||
d = summary["delivery"]
|
||||
print(f" delivery {d['delivered']}/{summary['dispatched']} = {d['rate']:.0%}")
|
||||
for cls, row in d["by_class"].items():
|
||||
print(f" class {cls} -> {row['target']:<12} "
|
||||
f"delivered {row['delivered']}/{row['dispatched']}, "
|
||||
f"classified {row['classified']}/{row['dispatched']}")
|
||||
print(f" came to rest {d['resting_places']}")
|
||||
print("=" * 74)
|
||||
|
||||
out = Path(args.out) if args.out else ROOT / "runs" / f"demo_{datetime.now():%Y%m%d_%H%M%S}.json"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(dict(
|
||||
config=dict(preset=args.preset, floor=not args.no_floor, speed=C.BELT_SPEED,
|
||||
pitch=args.pitch, vision=not args.no_vision, repeats=args.repeats,
|
||||
mapping=mapping, expect=expect, library=lib.name),
|
||||
summary=summary, items=recs, events=log_events[-400:]), indent=2))
|
||||
print(f" log -> {out}")
|
||||
return summary
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
import omni.usd
|
||||
inside = omni.usd.get_context().get_stage() is not None
|
||||
except Exception:
|
||||
inside = False
|
||||
|
||||
app = None
|
||||
if not inside:
|
||||
from isaacsim import SimulationApp
|
||||
app = SimulationApp({"headless": args.headless, "width": 1600, "height": 900})
|
||||
|
||||
import asyncio
|
||||
|
||||
import isaacsim.core.experimental.utils.app as app_utils
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(_run(app_utils, args))
|
||||
finally:
|
||||
if app is not None:
|
||||
app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if main() else 1)
|
||||
Reference in New Issue
Block a user