mp_yam_code / source /bimanual /yam /conditions.py
yqi19's picture
YAM bimanual task suite: env, solvers, tasks, converters
7399b6f verified
Raw
History Blame Contribute Delete
12.6 kB
"""Reusable success conditions for the YAM task suite.
Modelled on RoboLab's ``robolab/core/task/conditionals.py``: a task declares *what* counts as
success as a list of small predicates, instead of open-coding the geometry test in the demo
script. Each predicate takes the world state dict produced by a task script and returns a bool,
so the same condition can be reused by the runner, by an eval harness, or by a policy's reward.
State dict shape (env-local metres, table top at ``TABLE_TOP``)::
{"objects": {"grape": np.array([x, y, z]), ...},
"quats": {"pot": np.array([w, x, y, z]), ...}, # optional
"regions": {"basket": {"xy": (x, y), "half": 0.085, "top_z": 0.52}},
"start_z": {"grape": 0.449}}
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
import numpy as np
TABLE_TOP = 0.45
def _pos(state, name):
p = state.get("objects", {}).get(name)
if p is None:
raise KeyError(f"object {name!r} not in state; have {sorted(state.get('objects', {}))}")
return np.asarray(p, dtype=float)
def _region(state, name):
r = state.get("regions", {}).get(name)
if r is None:
raise KeyError(f"region {name!r} not in state; have {sorted(state.get('regions', {}))}")
return r
def object_in_region(object: str, region: str, pad: float = 0.02, below_top: float = 0.16):
"""Object's centre is inside the region footprint and low enough to be *in* it, not on top."""
def cond(state):
p = _pos(state, object); r = _region(state, region)
half = float(r["half"])+pad
dx, dy = abs(p[0]-r["xy"][0]), abs(p[1]-r["xy"][1])
ok_xy = dx < half and dy < half
ok_z = p[2] < TABLE_TOP+below_top
# Say WHICH test failed. "not in the region" is ambiguous between missed sideways and
# left sitting on the rim, and those want opposite fixes.
if not (ok_xy and ok_z):
print(f"[cond] {object} at ({p[0]:+.3f},{p[1]:+.3f},{p[2]:.3f}) vs {region} "
f"centre ({r['xy'][0]:+.3f},{r['xy'][1]:+.3f}) half={half:.3f}: "
f"dx={dx:.3f} dy={dy:.3f} {'' if ok_xy else 'OUTSIDE-XY '}"
f"{'' if ok_z else 'TOO-HIGH(sitting on the rim?)'}", flush=True)
return ok_xy and ok_z
return cond
def object_on_surface(object: str, region: str, pad: float = 0.03, tol: float = 0.01):
"""Object rests ON the region's top surface (a scale, plate, coaster)."""
def cond(state):
p = _pos(state, object); r = _region(state, region)
half = float(r["half"])+pad
return (abs(p[0]-r["xy"][0]) < half and abs(p[1]-r["xy"][1]) < half
and p[2] > float(r["top_z"])-tol)
return cond
def object_not_in_region(object: str, region: str, pad: float = 0.02):
"""A distractor must be left alone -- the negative half of a semantic-selection task."""
inner = object_in_region(object, region, pad=pad)
return lambda state: not inner(state)
def object_lifted(object: str, min_dz: float = 0.05):
"""Object rose by at least ``min_dz`` from where it started (a real grasp, not a nudge)."""
def cond(state):
z0 = state.get("start_z", {}).get(object)
peak = state.get("peak_z", {}).get(object, _pos(state, object)[2])
return z0 is not None and (float(peak)-float(z0)) > min_dz
return cond
def object_seated_in_hole(object: str, region: str, xy_tol: float = 0.02, half_h: float = 0.065):
"""Peg inserted: centred on the hole AND down at table level, not perched on the socket."""
def cond(state):
p = _pos(state, object); r = _region(state, region)
return (abs(p[0]-r["xy"][0]) < xy_tol and abs(p[1]-r["xy"][1]) < xy_tol
and p[2] < TABLE_TOP+half_h+0.02)
return cond
def objects_stacked(lower: str, upper: str, min_dz: float = 0.03, xy_tol: float = 0.03):
"""``upper`` sits on top of ``lower``, roughly aligned."""
def cond(state):
a = _pos(state, lower); b = _pos(state, upper)
return (b[2]-a[2]) > min_dz and abs(b[0]-a[0]) < xy_tol and abs(b[1]-a[1]) < xy_tol
return cond
def _up_axis(q):
w, x, y, z = [float(v) for v in q]
return np.array([2*(x*z+y*w), 2*(y*z-x*w), 1-2*(x*x+y*y)])
def object_level(object: str, max_tilt_deg: float = 25.0):
"""Object has not tilted away from HOW IT WAS RESTING.
Not "its local +z points up": a mesh that needs a 90 deg roll to stand upright (most RoboTwin
assets) has its local +z horizontal by construction, so comparing to world up marks a
perfectly level pot as 90 deg tilted.
"""
def cond(state):
q = state.get("quats", {}).get(object)
q0 = state.get("start_quats", {}).get(object)
if q is None:
return False
now = _up_axis(q)
ref = _up_axis(q0) if q0 is not None else np.array([0.0, 0.0, 1.0])
cosang = float(np.dot(now, ref)/((np.linalg.norm(now)*np.linalg.norm(ref))+1e-9))
return float(np.degrees(np.arccos(np.clip(cosang, -1.0, 1.0)))) < max_tilt_deg
return cond
def count_in_region(objects: list[str], region: str, at_least: int, pad: float = 0.03,
below_top: float = 0.10):
"""At least N of the listed objects ended up in the region (beads swept/poured).
`below_top` is not optional in practice: without a height bound this counts a bead that is
still sitting in the vessel being carried over the target, which reports a successful pour
for an episode that never poured.
"""
def cond(state):
r = _region(state, region); half = float(r["half"])+pad
top = float(r.get("top_z", TABLE_TOP))
n, where = 0, []
for o in objects:
p = state.get("objects", {}).get(o)
if p is None:
where.append(f"{o}=missing")
continue
p = np.asarray(p, dtype=float)
ok = (abs(p[0]-r["xy"][0]) < half and abs(p[1]-r["xy"][1]) < half
and p[2] < top+below_top)
where.append(f"{o}=({p[0]:+.3f},{p[1]:+.3f},{p[2]:.3f}){'IN' if ok else ''}")
n += int(ok)
print(f"[cond] {region} half={half:.3f} top={top:.3f} -> {n}/{len(objects)} in: "
+ " ".join(where), flush=True)
return n >= at_least
return cond
def object_moved_to(object: str, region: str, tol: float = 0.07):
"""Object ended near the target -- used by push/slide, where nothing is grasped."""
def cond(state):
p = _pos(state, object); r = _region(state, region)
return float(np.hypot(p[0]-r["xy"][0], p[1]-r["xy"][1])) < tol
return cond
def joint_moved(fixture: str, index: int = 0, by: float = 0.5):
"""A jointed fixture's joint travelled at least `by` from where the episode started.
Radians for a hinge (door, laptop lid, toggle), metres for a slider (drawer). Signed
magnitude, so it reads the same whether the task is to open or to close.
"""
def cond(state):
q0 = state.get("start_joints", {}).get((fixture, index))
q = state.get("joints", {}).get((fixture, index))
if q is None or q0 is None:
return False
return abs(float(q)-float(q0)) >= float(by)
return cond
def link_span_at_least(fixture: str, a: int, b: int, min_span: float):
"""Two links of a chain ended at least `min_span` apart -- i.e. the rope is taut.
Measured on the rope's own geometry rather than on where the hands are: a hand can be in the
right place while the rope hangs slack between them.
"""
def cond(state):
pa = state.get("links", {}).get((fixture, a))
pb = state.get("links", {}).get((fixture, b))
if pa is None or pb is None:
print(f"[cond] {fixture}: link {a}/{b} not in state", flush=True); return False
d = float(np.linalg.norm(np.asarray(pa)-np.asarray(pb)))
print(f"[cond] {fixture} span link{a}-link{b} = {d:.3f} m (need {min_span:.3f})", flush=True)
return d >= float(min_span)
return cond
def link_above(fixture: str, index: int, min_z: float = 0.04):
"""One link of a chain is at least `min_z` above the table."""
def cond(state):
p = state.get("links", {}).get((fixture, index))
ok = p is not None and float(p[2]) >= TABLE_TOP+float(min_z)
print(f"[cond] {fixture} link{index} z="
f"{'?' if p is None else round(float(p[2])-TABLE_TOP,3)} m above the table "
f"(need {min_z})", flush=True)
return ok
return cond
def joint_driven(fixture: str, index: int = 0, by: float = 0.3):
"""The ROBOT moved this joint by `by`, measured across the manipulation itself.
joint_moved() compares against a baseline the env recorded earlier, which also counts the
fixture settling under gravity and any pre-action blow-up. This reads the delta the solver
measured between "about to act" and "finished acting", and refuses a joint that ended
outside its own limits.
"""
def cond(state):
d = state.get("joint_delta", {}).get((fixture, index))
sane = state.get("joint_sane", {}).get(fixture, True)
print(f"[cond] {fixture} joint {index}: robot moved it "
f"{'none' if d is None else round(float(d), 3)} (need {by}), "
f"within limits={sane}", flush=True)
return d is not None and float(d) >= float(by) and sane
return cond
def joint_at_least(fixture: str, index: int = 0, value: float = 0.6):
"""A jointed fixture reached an absolute joint value (a door actually standing open)."""
def cond(state):
q = state.get("joints", {}).get((fixture, index))
return q is not None and float(q) >= float(value)
return cond
def object_tipped_over(object: str, region: str, min_tilt_deg: float = 75.0, xy_tol: float = 0.12):
"""Vessel was brought over a target and rotated far enough to pour out of.
This is the honest success test for pouring in a rigid-body sim. A vessel that has to be
LIFTED must be a convex dynamic body -- PhysX will not simulate a hollow carried mesh -- so
its interior is solid and anything "inside" is pushed out to the table before the episode
starts. What can be checked is the pour itself: the vessel above the target, tipped past the
angle at which its contents would leave it.
"""
def cond(state):
p = _pos(state, object); r = _region(state, region)
q = state.get("peak_tilt", {}).get(object)
d = float(np.hypot(p[0]-r["xy"][0], p[1]-r["xy"][1]))
ok = q is not None and float(q) >= float(min_tilt_deg) and d < float(xy_tol)
print(f"[cond] {object}: peak tilt {q if q is None else round(float(q),1)} deg "
f"(need {min_tilt_deg}), ended {d:.3f} m from {region} (need <{xy_tol})", flush=True)
return ok
return cond
def object_settled_on(object: str, region: str, max_dz: float = 0.03):
"""Object came to rest ON a fixture's base rather than hanging up partway.
A ring balanced on the tip of a post satisfies "centred over the post" just as well as one
that slid all the way down, so the height above the base is what separates them.
"""
def cond(state):
p = _pos(state, object); r = _region(state, region)
return p[2]-float(r["top_z"]) < float(max_dz)
return cond
def object_clear_of(object: str, region: str, extent: float, margin: float = 0.0):
"""Object has been drawn ENTIRELY out of a fixture -- the inverse of object_moved_to.
`extent` is the object's own half-length along the pull axis, so the check is "its trailing
end is past the fixture's mouth", not merely "its centre moved a bit".
"""
def cond(state):
p = _pos(state, object); r = _region(state, region)
d = float(np.hypot(p[0]-r["xy"][0], p[1]-r["xy"][1]))
return d > float(r.get("half", 0.0))+float(extent)-float(margin)
return cond
@dataclass
class Subtask:
"""A named step whose ``conditions`` must ALL hold (RoboLab's Subtask, trimmed)."""
name: str
conditions: list[Callable]
def satisfied(self, state) -> bool:
return all(c(state) for c in self.conditions)
def evaluate(subtasks: list[Subtask], state) -> tuple[bool, dict[str, bool]]:
"""Returns (all_passed, per-subtask results)."""
per = {s.name: s.satisfied(state) for s in subtasks}
return all(per.values()), per
def labelled(label: str, cond):
"""Attach a human-readable label to a predicate, for the per-condition PASS/FAIL report."""
cond.label = label
return cond