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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:07:24 +00:00

18 KiB
Raw Permalink Blame History

Context for Claude

Working notes for this repo: how the cell is put together, where the rest of the assets live, and the traps that are not visible from the code. Read this before changing anything in robozon_sorter/sim/ — several constants here are load-bearing and look arbitrary.


1. What this is

A conveyor sorting cell in Isaac Sim 6.0 (Kit 110). Goods ride a belt, three stereo pairs measure each one under a camera portal, and a pusher diverts the round ones onto a branch belt and into a bin.

infeed belt ──▶ camera portal ──▶ laser gate ──▶ pusher ──▶ Belt_01 ──▶ bin
   x=+2.3          x=-0.75          x=-3.74      x=-3.90              y=+1.3…2.0
                (CRE-ROI v2b)                  (class D only)

Goods travel in X at 1 m/s. Belt surface is at z = 1.781 everywhere on the main run and the branch.

Classes: B = sortable, C = oversize (any dim >450×320×320 mm or <10 mm), D = round (K = r_in/R_out > 0.8). Only D is diverted.


2. Where things are

This repo

scene/sorter.usd is the real authored cell, not a procedural rebuild. It was exported from 90_degree.usd with two changes: conveyor references re-pointed to ../assets/conveyors/, and the 48-object library dropped in favour of six bundled test meshes with baked geometry.

Do not "simplify" the scene by regenerating it from code. That was tried and rejected — the result looked nothing like the real cell.

scene/plow_cell.usd is the same authored build carried over whole, but as the bare mechanics: conveyors, the Y-split pusher and the plow, with no camera portal, no laser gate and no item library. Cameras, speed scenarios and laser sensors get added on top of it. Rebuild it with scripts/build_plow_cell.py, which re-points the S3 conveyor references at assets/conveyors/, points the plow at assets/plow/, and strips the baked targetPosition.timeSamples track off the pusher drive — time samples outrank the attribute default, so while they are present nothing can control that drive.

The plow meshes matter: assets/meshes/plow_*.usd are 8-point placeholder boxes that sorter.usd still uses, while assets/plow/plow_*.usd is the real geometry (72k / 23k points). scripts/smoke_plow_cell.py asserts the point count precisely because a stub composes without error and just looks wrong.

Remote machine (where Isaac Sim runs)

host      dasha@46.39.224.77
isaac     /home/whatevenif/isaacsim          (python.sh lives here)
project   /home/dasha/robozon-sorter          (deployed copy, ~308 MB, runnable)
assets    /home/dasha/isaac_assets            (the original working tree)

Connect with an SSH ControlMaster (plain repeated ssh exhausts local ephemeral ports on long sessions) and tunnel the Kit python server:

ssh -M -S ~/.ssh/cm/dasha -o ControlPersist=900 -fN dasha@46.39.224.77
ssh -fN -L 8226:127.0.0.1:8226 dasha@46.39.224.77       # isaacsim.code_editor.python_server

Then send code with the isaac-sim-remote skill's isaacsim_send.py. Named contexts keep state between calls, which is how the long experiments were run.

More meshes, if six are not enough

What Where Notes
31 classified objects /home/dasha/isaac_assets/categories.json 13 C, 11 D, 7 B; has zone, obb_extents_m, k_round, label_ru
the objects themselves /home/dasha/isaac_assets/robozon_conveyor_scaled.usd under /World/CVObjects 48 prims, 31 of them classified
raw source geometry /home/dasha/.objaverse/hf-objaverse-v1/glbs/ 19 .glb, 123 MB — what CVObjects reference
already-exported /home/dasha/isaac_assets/export_meshes/ flattened USDs, the six in this repo came from here
conveyor art /home/dasha/isaac_assets/Props/Conveyors/ 283 MB, mirrors the Omniverse S3 tree

To add a test mesh, flatten it out of the source scene so it carries no .glb reference:

ns = Usd.Stage.CreateInMemory()
root = UsdGeom.Xform.Define(ns, "/Item")
root.GetPrim().GetReferences().AddReference(
    "/home/dasha/isaac_assets/robozon_conveyor_scaled.usd", f"/World/CVObjects/{name}")
ns.SetDefaultPrim(root.GetPrim())
ns.Flatten().Export(f"assets/meshes/{name}.usd")

Then add an entry to assets/meshes/manifest.json with zone and gt_dims_mm. Objects in that scene sit at 0.49× real size (1/3-scale meshes × 1.4706), which is why config.DIM_SCALE = 1/2.041 converts metres to real millimetres.

Scene backups

/home/dasha/isaac_assets/backups/robozon_prescale_2252.usd is the clean pre-scale original, useful if the working scene ever gets damaged.


3. Mechanics

sim/scene.py opens the USD and re-applies the runtime settings USD does not carry. sim/mechanics.py is the per-item behaviour. sim/spawner.py hooks a PhysX step callback so the cell runs itself from the Play button.

Belts

Driven by PhysxSurfaceVelocityAPI on kinematic slabs, not by the authored ConveyorBeltGraph nodes — those carry no velocity and only fight the explicit setting, so they are switched off on load.

surfaceVelocity is expressed in the body's LOCAL frame. Belt_01 (/World/ConveyorTrack_03/Belt_01) is rotated: its local X points along world Y. Carrying goods toward the bin therefore needs (speed, 0, 0). Setting the intuitive (0, +speed, 0) drags them sideways and they sit there looking stuck. Resolve the local axes with XformCache.GetLocalToWorldTransform(prim).TransformDir(...) before setting it.

Pusher

A kinematic blade moved directly from script. It is not joint-driven: the authored PusherSlide prismatic drive is unusable because USD drive-target writes reach PhysX about a second late, so the blade never finishes its stroke while the item is still in reach. The joint is disabled and DiverterAnimGraph is switched off (it rewrites diverter targets every tick).

Blade collisions are filtered against every belt so it sweeps through the side rails instead of grinding on them.

Speed and physics rate are a matched pair: 2.5 m/s at 120 Hz. Do not change one alone.

  • The blade covers 0.70 m of belt; the beam trips with the item at x = 3.61, so the whole cycle must fit in 0.64 m of travel = 0.64 s at 1 m/s.
  • Stroke is 0.72 m → extension alone needs >1.12 m/s, extend+retract >2.25 m/s.
  • Measured: 1.0 / 1.5 / 2.0 / 2.5 m/s all deliver to the bin. 3.0 m/s throws the item (ends up at y≈122, z≈−2070) — the kinematic blade injects too much impulse.
  • Raising the solver rate to 480 Hz to "smooth" the impulse also breaks the landing. It was tried; the item stopped reaching the bin. 120 Hz is the validated setting.

Plow sorting station (plow_cell.usd)

Two-way sort at the plow, verified by measurement. Rebuild the geometry with scripts/narrow_plow.py then scripts/place_plow_lanes.py.

Arm width is 600 mm, not the authored 730. The wider arm overhung both rails and clipped goods it should have passed. narrow_plow.py does not guess which local axis carries the length - it tries each and measures; the answer is local Y, scale 0.8219. Hinge, drive and limits untouched.

Lane geometry, all verified against the belt (main belt x -7.00..-6.00, y +-0.45, top z 1.7805):

span contact
lane B, perpendicular, travel -Y x -7.03..-6.58, y -2.45..-0.45 height 0.0 mm, Y 0.0 mm
lane C, 45 deg, travel (-X,+Y) x -8.12..-6.39, y +0.45..+2.18 height 0.0 mm, Y 0.1 mm

Three placement mistakes were made and corrected, all of which looked fine in the tree and wrong in the viewport:

  1. Height. These tracks carry an authored -0.1 z offset, putting their belts at z 1.681 - 100 mm below the run, so they read as separate furniture. Use LANE_DROP = 0.
  2. X position. Lane B was first placed at x -7.48..-7.03, entirely past where the main belt stops (-7.00). A plow sweeps goods sideways while they are still on the belt, so a lane must run alongside it inside the arm's span (x -7.12..-6.52), never beyond the end. Same for C: at x -6.90 its near corner sat behind the plow and +Y deflections had nothing to land on; -6.55 puts the corner at the arm tip.
  3. A 45 deg lane does not meet a straight edge at its centreline. Its near corner runs ahead by 159 mm (measured), so the lane needs that much offset or it cuts into the belt.

An angled lane needs a transition DECK, not a corner patch. Offsetting by 159 mm makes its near corner touch, but only that one corner - the other stands off by width/sqrt2 = 318 mm. Patching just that triangle is not enough either: the plow can put a item anywhere across the discharge width, so anything pushed wide still drops through the second gap. place_plow_lanes.add_transition() therefore decks the whole corner - the convex span of the belt edge across the junction (x -7.00..-6.00) and both end-face corners, giving y 0.45..0.768 - coplanar with both belt surfaces. A square lane like B meets flush along its whole face, so the builder returns nothing rather than emitting a zero-area collider.

Every lane also needs a corner deck, flush or not. The belt is wider than the lane - the run reaches x=-6.00 while lane B stops at -6.58 - so the right angle between them is open air, and anything the plow pushes sideways in that leftover span drops through. add_corner_deck() fills it with a triangular fillet (B: legs 575 mm, corners (-6.575,-0.45), (-6.00,-0.45), (-6.575,-1.025); C: legs 391 mm), turning the right angle into a chute. Deck plus fillet together leave no open surface across the discharge.

Lanes start at y = +-0.45, which clears the arm's swept envelope: a 600 mm arm at +-35 deg reaches 0.60*sin(35) = 0.344 m either side.

The conveyor shell collides. SM_ConveyorBelt_*_02 has collision=True, and that includes the blue side rails - they physically block goods from leaving the belt, which is exactly what "nothing reaches the bins" looks like. plow_sort.open_junction() clears the collider on the three shells at the junction, the way a real plow station has its rails cut away. Each Belt keeps its own collider, so nothing falls through.

Plow delivery is geometrically impossible as currently placed - measured. The arm is 600 mm and pivots at the belt centre (-7.05, 0), so its lateral reach is 0.60*sin(35) = 0.344 m. Both lanes start at y = +-0.45. That leaves a 106 mm dead band: the blade can push an item to 0.344 and no further, the lane begins at 0.45, and the item runs off the belt end and stops at x ~= -7.0 with v = 0, or falls. Every trace in runs/gt_run5.json shows exactly that - barrel drifts from y=-0.09 to y=+0.148 and halts.

The arm sits at z 1.810..1.890 while the lane surfaces are at 1.7805, i.e. 29 mm above them, so it would sweep over a lane edge rather than into it. The earlier reasoning that lanes had to start beyond the swept envelope was therefore unnecessary, and it is what opened the dead band. Closing it means one of: lanes in to ~y=0.33, a longer arm, or a larger swing (the joint limit is +-35 authored).

The angle does not track the command. Measured in a demo run: an item commanded 0.0 saw the arm at +30.5, one commanded +30.0 saw -34.6. Both then went to the same lane. So on top of the reach gap the control sign and the settling are wrong. Two things are known about this path and both matter:

  • Plow.target(deg) writes the whole angle at once; calling it and step_toward() leaves the ramp nothing to do and the blade snaps. Use one or the other, and plow_sort uses the ramp.

  • Plow.home() does not settle the arm - it sets a target, and the compliant drive needs time. Consecutive trials therefore start from wherever the previous one left the blade (measured: -26.4 and -14.4 at the start of runs meant to begin at 0).

  • A positive command deflects to -Y (lane B), opposite to the natural reading. Measured, not assumed, exactly as the module docstring warns.

Until reach and tracking are fixed the plow does not deliver: scripts/run_demo.py reports the classification honestly and a delivery rate near zero.

Retract interlocks

The blade returns only when (a) the pushed item has cleared to y > 0.5 and (b) no other item is inside the blade's footprint. Retracting blindly sweeps the blade back through the next item and knocks it over — that was a real observed failure, not a hypothetical.

Plow (plow_cell.usd only)

The second diverter, at x = 7.05. Mechanically the opposite of the pusher: a dynamic arm on a revolute joint with an angular force drive (axis Z, limits ±35°, stiffness 120000, damping 1500, 12 kg with gravity disabled). Being force-driven it is compliant — it yields on contact instead of teleporting through cargo — so sim/plow.py commands a drive target rather than writing a transform.

Two consequences: a target is a request, not a position (read Plow.angle, which measures the arm's simulated pose, never assume it arrived), and the rate is not free. The authored graph swings 30° in 7 ms (72 rad/s), which is a display animation, not a sortable motion — at that rate the blade lands as an impulse, the same failure the pusher shows above 2.5 m/s. Plow.step_toward ramps the target at config.PLOW_RATE (180 °/s) instead; config.PLOW_RATE_AUTHORED keeps the original figure for reference.

The scene keeps its authored DiverterAnimGraph, so pressing Play alone demonstrates the cell. plow_cell.prepare(..., script_control=True) switches that graph off — it has to go, or it rewrites the drive target every tick and overwrites anything Python commands.

There is also a stray second ConveyorTrack_01 at stage root, outside /World, left over from how the cell was assembled; it composes as a duplicate belt in the same place and is deactivated on load rather than deleted, so the file stays as authored.

Laser gate

A genuine raycast_closest across the belt, not a coordinate test. The beam starts at y = 0.24, which is deliberately clear of the blade's retracted footprint (the blade spans y 0.33…−0.27). Start it any further out and the ray simply reads the blade and the gate never sees cargo.


4. Vision (CRE-ROI v2b)

cv/pipeline.py. Per item, once, while it sits under the portal: FastSAM segment-everything → keep the blob covering the projected inspection point in every view → ROI crop to a fixed 320 px side → one batched CRE-Stereo pass over all three crops → fuse, dropping views whose 3D centroid disagrees with the median by >10 cm → belt-plane OBB + r_in/R_out.

The crop must use the identical column window in both eyes, left-padded by the maximum disparity, or the right-hand counterpart falls outside the crop. Disparity is invariant to an equal column shift, so depth stays correct.

The stereo rig must be rectified. Both eyes of a pair share one orientation, with the right eye offset along its X. Aiming each eye separately at the target verges the pair and breaks depth = fx·b/disp — reconstruction came back at 25 m instead of 0.63 m. If config/calib.json ever loses its "rectified": true flag, the pipeline refuses to load rather than silently producing garbage.

Measured: dimensions land within ~34 mm of ground truth on the largest edge; the batched CRE pass costs ~170 ms per item.


5. Traps that cost real time

Each of these presented as a silent failure, not an error.

  • Copied items arrive kinematic and hidden. Kinematic bodies ignore gravity and belt friction; hidden ones are invisible to the cameras while still simulating. Both must be cleared, plus sleepThreshold = 0 or a settled item is never woken by the belt.
  • BBoxCache / XformCache return the AUTHORED transform during simulation. A moving item looks frozen. Use RigidPrim.get_world_poses().
  • timeline.stop() resets items to their authored poses, so captures of a finished run must be taken while still playing.
  • rep.orchestrator.step_async() stops the timeline. Any live loop that classifies must call timeline.play() again afterwards or the whole line freezes mid-run.
  • The viewport's active camera may not be Persp. Setting Persp's pose then does nothing; call vp.set_active_camera("/OmniverseKit_Persp") first.
  • Camera bodies carry cosmetic AimRay cones that sit right over the inspection point and dominate the frame. Hidden on load.
  • Stale composition: if a stage was opened while a referenced asset was missing, USD caches the failure. Dropping the file in later does not fix it — the prim stays valid with localErrors: none but empty typeName and bbox. Diagnose with GetPrimStack(), fix by re-opening the stage.
  • Stage.TraverseAll() crashes Isaac on this scene. Use targeted GetPrimAtPath, or run read-only checks headlessly via /home/whatevenif/isaacsim/python.sh (has pxr, no Kit).
  • Cube colliders: use size = 2.0 so the scale op equals the half-extent. Any other arrangement makes PhysX use the wrong bounds and goods drop through the belt.

6. Known limitations

  • Classification is weak. Metrology is sound but the roundness metric does not separate classes at this scale: genuinely round items read K ≈ 0.75 against a 0.80 threshold while a plain box reads 0.76. config.ROUND_K needs recalibration before the vision output should drive the pusher for real. Use --no-vision to exercise mechanics on ground truth.
  • Consecutive class-D items are missed at the default 700 mm pitch. Headway is 0.70 s and one pusher cycle is 0.62 s; the gate is blind while a cycle runs, so a D item immediately behind another D crosses the beam unseen. Verified: D,C,D,B sorts both D correctly, D,D back-to-back loses the second. Fixing it needs a larger pitch, a slower belt or a second diverter — not a faster blade, which is already at its stability limit.
  • The plow (DiverterEnd) is untouched in sorter.usd. There its geometry was restored and its arm returned to rest, but its control logic is left exactly as authored. It is scene/plow_cell.usd that makes the plow controllable — see below.