twanghcmut/backup-foundation-physics / scripts /build_droid_coverage_sample.py
twanghcmut's picture
download
raw
11.4 kB
#!/usr/bin/env python
"""Pick a small DROID subset chosen to COVER the dataset's axes of variation.
Not a random draw. A uniform sample of DROID returns mostly TRI-lab pick-and-place
of rigid objects, which is exactly the regime this project already works in and
therefore the least informative thing to look at. This instead stratifies over the
properties that actually change what a geometry pipeline has to do:
* **Object physics class** -- the axis that decides whether a single rigid SE(3)
pose per frame is even the right model. Compact-rigid and thin-rigid are the
regime this project handles; symmetric-rigid breaks pose *fitting* (measured in
this repo: two bit-identical runs recovered a brick's orientation ~178 deg apart
with equally good silhouette IoU); articulated has no representation beyond the
"barely rotates" fixture role; deformable has none at all.
* **Task template** -- DROID's ``current_task`` is usually a *category template*
("Move object into or out of container (ex: drawer, ...)"), not a description.
Templates therefore label the interaction type, which is what we want to spread
over, and the concrete object has to come from the human annotations instead.
* **Lab** -- each site built its own scenes, props and lighting, so lab is the
cheapest available proxy for scene diversity.
* **Outcome and length** -- failures and very short/long episodes exercise
different failure modes (aborted grasps, idle frames) than the median clip.
Object identity comes from ``aggregated-annotations-030724.json`` (3 human
instructions per episode) and is only trusted when >= 2 of the 3 annotators name
the same object; everything else comes from each episode's own
``metadata_<uuid>.json``.
Usage:
PYTHONPATH=src python scripts/build_droid_coverage_sample.py \\
--n 48 --out-json outputs/droid_coverage/manifest.json
"""
from __future__ import annotations
import argparse
import json
import pathlib
import re
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from fpgm.data.droid_raw import DroidRawClient
from fpgm.utils.logging import setup_logging
#: Object physics classes, in the order they stress this pipeline. The keywords are
#: matched against the human annotations, not against ``current_task``.
OBJECT_CLASSES: dict[str, list[str]] = {
"rigid_compact": ["block", "box", "can", "cube", "carton"],
"rigid_thin": ["marker", "pen", "screwdriver", "spoon", "fork", "knife", "brush"],
"rigid_symmetric": ["cup", "mug", "bottle", "bowl", "jar", "glass"],
"articulated": ["drawer", "lid", "cabinet", "door", "oven", "microwave"],
"deformable": ["towel", "cloth", "shirt", "napkin", "rag", "bag", "sock"],
"flat_thin": ["plate", "book", "paper", "tray", "card"],
"organic": ["banana", "apple", "orange", "carrot", "fruit", "bread"],
"transparent": ["glass", "transparent", "clear bottle", "water bottle"],
}
#: Task templates DROID actually ships, keyed by a short label. Matched as a
#: case-insensitive substring of ``current_task``; ``free_form`` is the catch-all
#: for the "do any task" / "Suggested task:" strings this project's own config
#: already filters with ``meta_tokens``.
TASK_TEMPLATES: dict[str, str] = {
"into_container": "into or out of container",
"reposition": "new position and orientation",
"clean_with_cloth": "use cloth to clean",
"hang": "hang or unhang",
"press_button": "press button",
"open_close": "open or close slidable",
"fold": "fold, spread out, or clump",
"lid_on_off": "move lid on or off",
}
def classify_object(instructions: list[str]) -> str | None:
"""Object physics class if >=2 of 3 annotators name a member of the same class."""
joined = [s.lower() for s in instructions if s]
best: tuple[str, int] | None = None
for cls, words in OBJECT_CLASSES.items():
hits = sum(1 for s in joined if any(re.search(r"\b" + w, s) for w in words))
if hits >= 2 and (best is None or hits > best[1]):
best = (cls, hits)
return best[0] if best else None
def classify_task(current_task: str) -> str:
t = (current_task or "").lower()
for label, needle in TASK_TEMPLATES.items():
if needle in t:
return label
if not t or any(m in t for m in ("do any task", "anything", "suggested task")):
return "free_form"
return "concrete" # a lab that wrote a real instruction instead of a template
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--annotations", type=pathlib.Path,
default=pathlib.Path("data/droid_annotations/aggregated-annotations-030724.json"))
ap.add_argument("--cameras-dir", type=pathlib.Path,
default=pathlib.Path("data/pointworld/droid/cameras"))
ap.add_argument("--meta-cache", type=pathlib.Path, default=pathlib.Path("data/droid_meta_survey"))
ap.add_argument("--out-json", required=True, type=pathlib.Path)
ap.add_argument("--n", type=int, default=48)
ap.add_argument("--per-class-pool", type=int, default=14,
help="candidates fetched per object class before final selection")
ap.add_argument("--n-failures", type=int, default=8,
help="failure episodes to append, drawn WITHOUT object labels (see below)")
ap.add_argument("--per-lab-in-class", type=int, default=2,
help="cap per lab inside one object class when building the candidate pool. "
"Raising this widens lab coverage and, because outcome is only known "
"after the metadata fetch, is also the only way to get failure episodes "
"into the pool at all -- they are the minority everywhere.")
ap.add_argument("--workers", type=int, default=12)
ap.add_argument("--log-level", default="WARNING")
args = ap.parse_args()
setup_logging(args.log_level)
ann = json.loads(args.annotations.read_text())
covered = {p.name.removesuffix("_cameras.json") for p in args.cameras_dir.glob("*_cameras.json")}
pool = {k: list(v.values()) for k, v in ann.items() if k in covered}
covered_annotated = set(pool)
print(f"annotated {len(ann)} | pointworld-covered {len(covered)} | usable {len(pool)}")
by_class: dict[str, list[str]] = defaultdict(list)
for uuid, ins in pool.items():
cls = classify_object(ins)
if cls:
by_class[cls].append(uuid)
print("object-class pool sizes: " + ", ".join(f"{c}={len(v)}" for c, v in sorted(by_class.items())))
# Candidates: spread labs inside each class before spending any network call.
cands: list[tuple[str, str]] = []
for cls, uuids in by_class.items():
seen_lab: Counter[str] = Counter()
for uuid in sorted(uuids, key=lambda u: (u.split("+")[0], u)):
lab = uuid.split("+")[0]
if seen_lab[lab] >= args.per_lab_in_class:
continue
seen_lab[lab] += 1
cands.append((cls, uuid))
if seen_lab.total() >= args.per_class_pool:
break
print(f"fetching metadata for {len(cands)} candidates")
client = DroidRawClient(args.meta_cache)
def fetch(item: tuple[str, str]) -> dict | None:
cls, uuid = item
try:
m = client.load_episode_metadata(uuid)
except Exception: # noqa: BLE001 - a dead uuid must not kill the build
return None
return {
"uuid": uuid, "object_class": cls, "lab": uuid.split("+")[0],
"scene_id": m.get("scene_id"), "success": bool(m.get("success")),
"trajectory_length": int(m.get("trajectory_length") or 0),
"current_task": (m.get("current_task") or "").strip(),
"task_class": classify_task(m.get("current_task") or ""),
"ext1_cam_serial": m.get("ext1_cam_serial"),
"instructions": pool[uuid],
}
rows: list[dict] = []
with ThreadPoolExecutor(max_workers=args.workers) as ex:
for fut in as_completed([ex.submit(fetch, c) for c in cands]):
r = fut.result()
if r:
rows.append(r)
print(f"metadata ok for {len(rows)}")
# Greedy max-coverage: repeatedly take the row adding the rarest still-unseen
# combination, so every class/task/lab/outcome appears before any is doubled.
picked: list[dict] = []
seen = {k: Counter() for k in ("object_class", "task_class", "lab", "success")}
def gain(r: dict) -> tuple:
return (
seen["object_class"][r["object_class"]],
seen["task_class"][r["task_class"]],
seen["lab"][r["lab"]],
seen["success"][r["success"]],
-r["trajectory_length"],
)
remaining = list(rows)
while remaining and len(picked) < args.n:
remaining.sort(key=gain)
r = remaining.pop(0)
picked.append(r)
for k in seen:
seen[k][r[k]] += 1
# Failure episodes must be drawn separately and WITHOUT an object label.
# Measured on this box's metadata cache: of 1,153 episodes, all 832 that carry
# human annotations are successes and all 125 failures carry none -- DROID's
# annotation pass covered successful episodes only. So any object-identity
# filter silently selects for success, and the only way to see a failure is to
# accept not knowing what object is in it.
if args.n_failures:
fails: list[dict] = []
seen_fail_lab: Counter[str] = Counter()
for path in sorted(args.meta_cache.glob("*/metadata.json")):
try:
m = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
continue
uuid = m.get("uuid")
if not uuid or uuid in covered_annotated or uuid not in covered or m.get("success"):
continue
lab = uuid.split("+")[0]
if seen_fail_lab[lab] >= 2:
continue
seen_fail_lab[lab] += 1
fails.append({
"uuid": uuid, "object_class": "unknown_no_annotation", "lab": lab,
"scene_id": m.get("scene_id"), "success": False,
"trajectory_length": int(m.get("trajectory_length") or 0),
"current_task": (m.get("current_task") or "").strip(),
"task_class": classify_task(m.get("current_task") or ""),
"ext1_cam_serial": m.get("ext1_cam_serial"), "instructions": [],
})
if len(fails) >= args.n_failures:
break
picked.extend(fails)
for r in fails:
for k in seen:
seen[k][r[k]] += 1
print(f"added {len(fails)} failure episodes (no object label available)")
args.out_json.parent.mkdir(parents=True, exist_ok=True)
args.out_json.write_text(json.dumps(picked, indent=1))
print(f"\npicked {len(picked)} -> {args.out_json}")
for k, c in seen.items():
print(f" {k:<14}" + ", ".join(f"{kk}={vv}" for kk, vv in sorted(c.items(), key=lambda x: str(x[0]))))
lens = sorted(r["trajectory_length"] for r in picked)
print(f" length min={lens[0]} median={lens[len(lens)//2]} max={lens[-1]}")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
11.4 kB
·
Xet hash:
e5f5e81ce953688247971b8f76f43ca3994d037376dc504ba774a9a74d8d7672

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.