perceiving-object-parts
Two-step zoom-in perception. The full image gives a small subpart
(e.g. a frypan handle is ~3% of pixels) bad signal-to-noise for SAM3
text segmentation; cropping to the parent first brings the subpart up
to ~30% of pixels in the cropped image — within SAM3's reliable range.
About parent_prompt and subpart_prompt: they are literal
Python strings, NOT subgraph inputs. They are author-time constants
per subgraph instance. DO NOT declare them in the subgraph's
top-level inputs block, and DO NOT write Ref("in.parent_prompt")
or any other Ref(...) for them. Write the strings directly on the
inner script node, e.g.
"parent_prompt": "frying pan", "subpart_prompt": "long horizontal handle of the frying pan".
Only cameras is a flowed subgraph input (wired from the workflow's
observation source, identical to perceiving-objects's cameras
input).
When to use
- The grasp/place affordance is a part of a larger object (pan handle,
drawer pull, moka-pot grip, mug rim, stove burner).
- Plain
perceiving-objects with object_name="handle" fails because
there are multiple handles in the scene (drawer pull, microwave
door, cabinet, ...) and DINO can't disambiguate.
When NOT to use
- The whole object IS the target (
perceiving-objects is faster and
produces a cleaner OBB).
- The subpart spans the majority of the image already (skip the crop).
Pipeline
observation # rgb + depth + intrinsics + camera pose
│
▼ grounding-dino.detect(rgb, parent_prompt)
parent_box (BoundingBox2D) # broadest of the boxes, or VLM-picked
│
▼ crop_rgb_to_box(parent_box, padding=30)
cropped_image # H_new × W_new × 3 uint8
│
▼ grounding-dino.detect(crop, subpart_prompt) → sam3.segment_text
cropped_mask # subpart mask in crop coordinates
│
▼ uncrop(cropped_mask → original H × W)
full_mask # H × W uint8, zeros outside crop
│
▼ geometry.mask_to_world_points(full_mask, depth, K, T_cam)
world_cloud (PointCloud)
│
▼ geometry.filter_noise → geometry.compute_obb
subpart_obb # the split calls keep the unfiltered-cloud
# fallback when DBSCAN strips too many points
Canonical subgraph layout (mirror perceiving-objects)
{
"skill": "perceiving-object-parts",
"inputs": {},
"nodes": {
"observe": {
"type": "tool",
"tool": "robot.get_observation"
},
"perceive_handle": {
"type": "script",
"script": "scripts/perceive_subpart.py",
"inputs": {
"cameras": {"$ref": "observe.cameras"},
"parent_prompt": "frying pan",
"subpart_prompt": "long horizontal handle of the frying pan",
"padding_px": 30
}
},
"found": {"type": "noop"}
},
"edges": [
["START", "observe"],
["observe", "perceive_handle"],
["perceive_handle", "found"],
["found", "END"]
],
"outputs": {
"target_obb": {"$ref": "perceive_handle.obb"},
"target_mask": {"$ref": "perceive_handle.mask"},
"target_cloud": {"$ref": "perceive_handle.cloud"},
"target_parent_obb": {"$ref": "perceive_handle.parent_obb"},
"target_parent_cloud": {"$ref": "perceive_handle.parent_cloud"}
},
"exit": {"router_field": null, "success_values": ["found"]},
"on_error": "not_found"
}
HARD RULE — do NOT add a geometry.filter_and_compute_obb node
and bind target_obb to it. Unlike perceiving-objects, this
skill's script already returns a clean, noise-filtered OBB in its
obb output (computed via geometry.filter_noise +
geometry.compute_obb, with a fallback to the unfiltered cloud when
DBSCAN strips a thin part below min_points). You MUST bind
target_obb directly to {"$ref": "perceive_handle.obb"}. A
redundant filter_and_compute_obb node re-filters an already-tiny
subpart cloud — DBSCAN on a thin handle shell routinely classifies
most of it as noise, collapsing the OBB — and loses the script's
unfiltered-cloud fallback.
Key points:
"inputs": {} — no subgraph-level inputs.
cameras is produced inside the subgraph by robot.get_observation, identical to perceiving-objects.
parent_prompt and subpart_prompt are literal strings on the perceive_handle node, NOT subgraph inputs.
- Note the output
mask is the PARENT object's mask (used for collision
isolation downstream); the subpart's own mask is the subpart_mask
output. The obb/cloud outputs ARE the subpart's.
1---2name: perceiving-object-parts3description: Hierarchical perception for subpart targeting. Detects a parent object first (DINO+VLM), crops the camera image to the parent's bounding box, then detects and segments the named subpart inside the crop (DINO + SAM3), and uncrops + fuses depth to a world-frame OBB/mask/cloud — plus the parent object's OBB and cloud for downstream placement/collision reasoning. Use when the graspable affordance is a subpart of a larger object — pan handle, drawer pull, mug rim, moka-pot handle, stove burner — where detecting the subpart at full image resolution is unreliable because it occupies few pixels.4license: MIT5---67# perceiving-object-parts89Two-step zoom-in perception. The full image gives a small subpart10(e.g. a frypan handle is ~3% of pixels) bad signal-to-noise for SAM311text segmentation; cropping to the parent first brings the subpart up12to ~30% of pixels in the cropped image — within SAM3's reliable range.1314> **About `parent_prompt` and `subpart_prompt`:** they are **literal15> Python strings**, NOT subgraph inputs. They are author-time constants16> per subgraph instance. **DO NOT** declare them in the subgraph's17> top-level `inputs` block, and **DO NOT** write `Ref("in.parent_prompt")`18> or any other `Ref(...)` for them. Write the strings directly on the19> inner script node, e.g.20> `"parent_prompt": "frying pan", "subpart_prompt": "long horizontal handle of the frying pan"`.21> Only `cameras` is a flowed subgraph input (wired from the workflow's22> observation source, identical to `perceiving-objects`'s `cameras`23> input).2425## When to use2627- The grasp/place affordance is a part of a larger object (pan handle,28 drawer pull, moka-pot grip, mug rim, stove burner).29- Plain `perceiving-objects` with `object_name="handle"` fails because30 there are multiple handles in the scene (drawer pull, microwave31 door, cabinet, ...) and DINO can't disambiguate.3233## When NOT to use3435- The whole object IS the target (`perceiving-objects` is faster and36 produces a cleaner OBB).37- The subpart spans the majority of the image already (skip the crop).3839## Pipeline4041```42observation # rgb + depth + intrinsics + camera pose43 │44 ▼ grounding-dino.detect(rgb, parent_prompt)45parent_box (BoundingBox2D) # broadest of the boxes, or VLM-picked46 │47 ▼ crop_rgb_to_box(parent_box, padding=30)48cropped_image # H_new × W_new × 3 uint849 │50 ▼ grounding-dino.detect(crop, subpart_prompt) → sam3.segment_text51cropped_mask # subpart mask in crop coordinates52 │53 ▼ uncrop(cropped_mask → original H × W)54full_mask # H × W uint8, zeros outside crop55 │56 ▼ geometry.mask_to_world_points(full_mask, depth, K, T_cam)57world_cloud (PointCloud)58 │59 ▼ geometry.filter_noise → geometry.compute_obb60subpart_obb # the split calls keep the unfiltered-cloud61 # fallback when DBSCAN strips too many points62```6364## Canonical subgraph layout (mirror `perceiving-objects`)6566```json67{68 "skill": "perceiving-object-parts",69 "inputs": {},70 "nodes": {71 "observe": {72 "type": "tool",73 "tool": "robot.get_observation"74 },75 "perceive_handle": {76 "type": "script",77 "script": "scripts/perceive_subpart.py",78 "inputs": {79 "cameras": {"$ref": "observe.cameras"},80 "parent_prompt": "frying pan",81 "subpart_prompt": "long horizontal handle of the frying pan",82 "padding_px": 3083 }84 },85 "found": {"type": "noop"}86 },87 "edges": [88 ["START", "observe"],89 ["observe", "perceive_handle"],90 ["perceive_handle", "found"],91 ["found", "END"]92 ],93 "outputs": {94 "target_obb": {"$ref": "perceive_handle.obb"},95 "target_mask": {"$ref": "perceive_handle.mask"},96 "target_cloud": {"$ref": "perceive_handle.cloud"},97 "target_parent_obb": {"$ref": "perceive_handle.parent_obb"},98 "target_parent_cloud": {"$ref": "perceive_handle.parent_cloud"}99 },100 "exit": {"router_field": null, "success_values": ["found"]},101 "on_error": "not_found"102}103```104105> **HARD RULE — do NOT add a `geometry.filter_and_compute_obb` node106> and bind `target_obb` to it.** Unlike `perceiving-objects`, this107> skill's script already returns a clean, noise-filtered OBB in its108> `obb` output (computed via `geometry.filter_noise` +109> `geometry.compute_obb`, with a fallback to the unfiltered cloud when110> DBSCAN strips a thin part below `min_points`). You MUST bind111> `target_obb` directly to `{"$ref": "perceive_handle.obb"}`. A112> redundant `filter_and_compute_obb` node re-filters an already-tiny113> subpart cloud — DBSCAN on a thin handle shell routinely classifies114> most of it as noise, collapsing the OBB — and loses the script's115> unfiltered-cloud fallback.116117Key points:118- `"inputs": {}` — no subgraph-level inputs.119- `cameras` is produced inside the subgraph by `robot.get_observation`, identical to `perceiving-objects`.120- `parent_prompt` and `subpart_prompt` are **literal strings** on the `perceive_handle` node, NOT subgraph inputs.121- Note the output `mask` is the PARENT object's mask (used for collision122 isolation downstream); the subpart's own mask is the `subpart_mask`123 output. The `obb`/`cloud` outputs ARE the subpart's.