openphysicalai 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openphysicalai/__init__.py +9 -0
- openphysicalai/budget.py +59 -0
- openphysicalai/cli.py +115 -0
- openphysicalai/injection.py +64 -0
- openphysicalai/interfaces.py +47 -0
- openphysicalai/perception.py +237 -0
- openphysicalai/planners.py +159 -0
- openphysicalai/profiler.py +226 -0
- openphysicalai/render.py +124 -0
- openphysicalai/report.py +39 -0
- openphysicalai/runner.py +401 -0
- openphysicalai/server.py +113 -0
- openphysicalai/sim.py +312 -0
- openphysicalai/skills.py +50 -0
- openphysicalai/stats.py +15 -0
- openphysicalai/store.py +36 -0
- openphysicalai/tasks.py +124 -0
- openphysicalai-0.2.1.dist-info/METADATA +203 -0
- openphysicalai-0.2.1.dist-info/RECORD +22 -0
- openphysicalai-0.2.1.dist-info/WHEEL +5 -0
- openphysicalai-0.2.1.dist-info/entry_points.txt +2 -0
- openphysicalai-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""OpenPhysicalAI Lab core (early scaffold).
|
|
2
|
+
|
|
3
|
+
Interfaces and budget logic only. MuJoCo backend, scheduler and real policies
|
|
4
|
+
arrive in Phase 1+ (see docs/OpenPhysicalAI_Lab_Plan_v3.md).
|
|
5
|
+
"""
|
|
6
|
+
__version__ = "0.2.1"
|
|
7
|
+
|
|
8
|
+
from .budget import Budget, BudgetCheck, evaluate_budget # noqa: F401
|
|
9
|
+
from .stats import wilson_interval # noqa: F401
|
openphysicalai/budget.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Budget contract: separate caps for process-attributed and device-wide usage."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Budget:
|
|
10
|
+
vram_process_peak_gb: float = 3.2
|
|
11
|
+
vram_device_peak_gb: float = 3.8
|
|
12
|
+
ram_process_peak_gb: float = 8.0
|
|
13
|
+
ram_system_peak_gb: float = 14.0
|
|
14
|
+
planning_ms_p95: float = 2500.0
|
|
15
|
+
profile: str = "standard"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class BudgetCheck:
|
|
20
|
+
name: str
|
|
21
|
+
value: Optional[float]
|
|
22
|
+
cap: float
|
|
23
|
+
ok: bool
|
|
24
|
+
note: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class BudgetResult:
|
|
29
|
+
passed: bool
|
|
30
|
+
checks: List[BudgetCheck] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def evaluate_budget(budget: Budget, observed: Dict[str, Optional[float]], not_applicable: tuple = ()) -> BudgetResult:
|
|
34
|
+
"""Compare observed peaks to caps. A missing observation is a failure of measurement, not a pass.
|
|
35
|
+
`not_applicable` lists keys that do not apply on this machine (e.g. VRAM caps on a CPU-only machine)."""
|
|
36
|
+
spec = [
|
|
37
|
+
("vram_process_peak_gb", budget.vram_process_peak_gb),
|
|
38
|
+
("vram_device_peak_gb", budget.vram_device_peak_gb),
|
|
39
|
+
("ram_process_peak_gb", budget.ram_process_peak_gb),
|
|
40
|
+
("ram_system_peak_gb", budget.ram_system_peak_gb),
|
|
41
|
+
("planning_ms_p95", budget.planning_ms_p95),
|
|
42
|
+
]
|
|
43
|
+
checks: List[BudgetCheck] = []
|
|
44
|
+
for name, cap in spec:
|
|
45
|
+
v = observed.get(name)
|
|
46
|
+
if name in not_applicable:
|
|
47
|
+
checks.append(BudgetCheck(name, None, cap, True, "not applicable"))
|
|
48
|
+
elif v is None:
|
|
49
|
+
checks.append(BudgetCheck(name, None, cap, False, "not measured"))
|
|
50
|
+
else:
|
|
51
|
+
checks.append(BudgetCheck(name, float(v), cap, float(v) <= cap))
|
|
52
|
+
return BudgetResult(all(c.ok for c in checks), checks)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
PROFILES = {
|
|
56
|
+
"relaxed": Budget(3.2, 3.8, 8.0, 14.0, 4000.0, "relaxed"),
|
|
57
|
+
"standard": Budget(3.2, 3.8, 8.0, 14.0, 2500.0, "standard"),
|
|
58
|
+
"strict": Budget(3.2, 3.8, 8.0, 14.0, 500.0, "strict"),
|
|
59
|
+
}
|
openphysicalai/cli.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""opai CLI (scaffold): `opai profile`, `opai check-budget`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .budget import Budget, evaluate_budget
|
|
13
|
+
from .profiler import gpu_snapshot
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_profile(_args) -> int:
|
|
17
|
+
info = {
|
|
18
|
+
"version": __version__,
|
|
19
|
+
"os": platform.platform(),
|
|
20
|
+
"python": sys.version.split()[0],
|
|
21
|
+
"gpu": gpu_snapshot(),
|
|
22
|
+
}
|
|
23
|
+
print(json.dumps(info, indent=2))
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_doctor(_args) -> int:
|
|
28
|
+
import psutil
|
|
29
|
+
g = gpu_snapshot()
|
|
30
|
+
mem = psutil.virtual_memory()
|
|
31
|
+
cls = "CPU-only"
|
|
32
|
+
if g:
|
|
33
|
+
t = g["total_gb"]
|
|
34
|
+
cls = "OPAI-2G" if t <= 3 else "OPAI-4G" if t <= 6 else "OPAI-8G" if t <= 12 else "OPAI-16G"
|
|
35
|
+
doc = {
|
|
36
|
+
"version": __version__,
|
|
37
|
+
"platform": platform.platform(),
|
|
38
|
+
"python": sys.version.split()[0],
|
|
39
|
+
"processor": platform.processor(),
|
|
40
|
+
"cpu_count_physical": psutil.cpu_count(logical=False),
|
|
41
|
+
"cpu_count_logical": psutil.cpu_count(logical=True),
|
|
42
|
+
"ram_total_gb": round(mem.total / (1024**3), 2),
|
|
43
|
+
"ram_available_gb": round(mem.available / (1024**3), 2),
|
|
44
|
+
"hardware_class": cls,
|
|
45
|
+
"gpu": g,
|
|
46
|
+
}
|
|
47
|
+
print(json.dumps(doc, indent=2))
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_check_budget(args) -> int:
|
|
52
|
+
observed = json.loads(args.observed)
|
|
53
|
+
res = evaluate_budget(Budget(), observed)
|
|
54
|
+
print("PASS" if res.passed else "FAIL")
|
|
55
|
+
for c in res.checks:
|
|
56
|
+
print(f" {'ok ' if c.ok else 'BAD'} {c.name}: {c.value} / {c.cap} {c.note}")
|
|
57
|
+
return 0 if res.passed else 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_run(args) -> int:
|
|
61
|
+
from .report import budget_card
|
|
62
|
+
from .runner import RunConfig, run
|
|
63
|
+
cfg_dict = {k: v for k, v in vars(args).items() if k not in ("cmd", "fn", "export_json") and v is not None}
|
|
64
|
+
cfg = RunConfig.from_dict(cfg_dict)
|
|
65
|
+
cfg.frames = False
|
|
66
|
+
|
|
67
|
+
reproduce_cmd = (
|
|
68
|
+
f"opai run --task {cfg.task} --policy {cfg.policy} --agent {cfg.agent} "
|
|
69
|
+
f"--perception {cfg.perception} --verifier {cfg.verifier} --perturb {cfg.perturb} "
|
|
70
|
+
f"--mode {cfg.mode} --budget {cfg.budget} --episodes {cfg.episodes} --seed {cfg.seed}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def emit(e):
|
|
74
|
+
if e.get("event") == "progress" and e["done"] % 10 == 0:
|
|
75
|
+
print(f" episode {e['done']}/{e['total']} successes {e['successes']}", file=sys.stderr)
|
|
76
|
+
|
|
77
|
+
res = run(cfg, emit=emit)
|
|
78
|
+
res["reproduce_command"] = reproduce_cmd
|
|
79
|
+
card_text = budget_card(res)
|
|
80
|
+
print(card_text)
|
|
81
|
+
print(f"\nReproduce command:\n {reproduce_cmd}\n")
|
|
82
|
+
|
|
83
|
+
if getattr(args, "export_json", None):
|
|
84
|
+
out_path = args.export_json
|
|
85
|
+
with open(out_path, "w") as f:
|
|
86
|
+
json.dump(res, f, indent=2)
|
|
87
|
+
print(f"Exported raw run data to {out_path}")
|
|
88
|
+
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv=None) -> int:
|
|
93
|
+
p = argparse.ArgumentParser(prog="opai")
|
|
94
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
95
|
+
sub.add_parser("profile").set_defaults(fn=cmd_profile)
|
|
96
|
+
sub.add_parser("doctor", help="inspect host hardware, NVML, and budget class").set_defaults(fn=cmd_doctor)
|
|
97
|
+
cb = sub.add_parser("check-budget")
|
|
98
|
+
cb.add_argument("observed", help='JSON, e.g. \'{"vram_process_peak_gb": 2.1}\'')
|
|
99
|
+
cb.set_defaults(fn=cmd_check_budget)
|
|
100
|
+
r = sub.add_parser("run", help="headless benchmark run, prints a Budget Card")
|
|
101
|
+
for name, default in (("task", "pick_place"), ("policy", "rule"), ("agent", "recovery"), ("perception", "oracle"), ("verifier", "V1"),
|
|
102
|
+
("perturb", "clean"), ("mode", "sync"), ("budget", "standard")):
|
|
103
|
+
r.add_argument("--" + name, default=default)
|
|
104
|
+
r.add_argument("--episodes", type=int, default=50)
|
|
105
|
+
r.add_argument("--seed", type=int, default=0)
|
|
106
|
+
r.add_argument("--extra-latency-ms", dest="extra_latency_ms", type=float, default=0.0)
|
|
107
|
+
r.add_argument("--velocity-lead", dest="velocity_lead", action=argparse.BooleanOptionalAction, default=True)
|
|
108
|
+
r.add_argument("--export-json", dest="export_json", type=str, default=None, help="path to export raw run.json")
|
|
109
|
+
r.set_defaults(fn=cmd_run)
|
|
110
|
+
args = p.parse_args(argv)
|
|
111
|
+
return args.fn(args)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Seeded failure / perturbation injection. Ground truth of what was injected is logged for scoring
|
|
2
|
+
only and is never exposed to the agent."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Dict, List
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
LEVELS = {
|
|
12
|
+
"clean": {"p": 0.00, "pos_noise": 0.000, "pix_noise": 0.0},
|
|
13
|
+
"mild": {"p": 0.10, "pos_noise": 0.010, "pix_noise": 8.0},
|
|
14
|
+
"moderate": {"p": 0.25, "pos_noise": 0.020, "pix_noise": 16.0},
|
|
15
|
+
"severe": {"p": 0.40, "pos_noise": 0.035, "pix_noise": 28.0},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
EXPECTED_DIAGNOSIS = {"grasp_slip": "object_dropped", "object_displaced": "grasp_miss", "wrong_placement": "wrong_placement"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Injector:
|
|
23
|
+
level: str
|
|
24
|
+
rng: np.random.Generator
|
|
25
|
+
log: List[Dict] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
self.cfg = LEVELS[self.level]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def pos_noise(self) -> float:
|
|
32
|
+
return self.cfg["pos_noise"]
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def pix_noise(self) -> float:
|
|
36
|
+
return self.cfg["pix_noise"]
|
|
37
|
+
|
|
38
|
+
def _roll(self) -> bool:
|
|
39
|
+
return self.cfg["p"] > 0 and self.rng.random() < self.cfg["p"]
|
|
40
|
+
|
|
41
|
+
def displace(self, world, oid: str, step: int) -> bool:
|
|
42
|
+
if self._roll():
|
|
43
|
+
ang = self.rng.uniform(0, 2 * math.pi)
|
|
44
|
+
d = self.rng.uniform(0.06, 0.09)
|
|
45
|
+
x0, y0 = world.obj_pos(oid)[:2]
|
|
46
|
+
nx, ny = x0 + d * math.cos(ang), y0 + d * math.sin(ang)
|
|
47
|
+
nx, ny = min(max(nx, 0.10), 0.36), min(max(ny, -0.25), 0.25)
|
|
48
|
+
world.teleport(oid, nx - x0, ny - y0)
|
|
49
|
+
self.log.append({"type": "object_displaced", "step": step, "object": oid})
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def slip(self, step: int) -> bool:
|
|
54
|
+
if self._roll():
|
|
55
|
+
self.log.append({"type": "grasp_slip", "step": step})
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def placement_offset(self, step: int):
|
|
60
|
+
if self._roll():
|
|
61
|
+
ang = self.rng.uniform(0, 2 * math.pi)
|
|
62
|
+
self.log.append({"type": "wrong_placement", "step": step})
|
|
63
|
+
return 0.12 * math.cos(ang), 0.12 * math.sin(ang)
|
|
64
|
+
return 0.0, 0.0
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Core interfaces (frozen in Phase 0). Implementations arrive in later phases."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, Literal, Optional, Protocol
|
|
6
|
+
|
|
7
|
+
Track = Literal["planner", "learned", "hybrid", "scripted"]
|
|
8
|
+
Assist = Literal["A0", "A1", "A2", "A3"]
|
|
9
|
+
Verifier = Literal["V0", "V1", "V2"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class EvaluationContract:
|
|
14
|
+
observation_access: tuple = ("rgb", "proprio")
|
|
15
|
+
control_hz: int = 20
|
|
16
|
+
action_space: str = "skill_level"
|
|
17
|
+
controller_assistance: Assist = "A3"
|
|
18
|
+
verifier_level: Verifier = "V1"
|
|
19
|
+
episodes: int = 100
|
|
20
|
+
seeds: str = "shared"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ActionResult:
|
|
25
|
+
action: Any
|
|
26
|
+
t_obs_ns: int = 0
|
|
27
|
+
t_submit_ns: int = 0
|
|
28
|
+
t_ready_ns: int = 0
|
|
29
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Policy(Protocol):
|
|
33
|
+
name: str
|
|
34
|
+
track: Track
|
|
35
|
+
|
|
36
|
+
def reset(self, task: str, seed: int, contract: EvaluationContract) -> None: ...
|
|
37
|
+
|
|
38
|
+
def act(self, obs: Any, world_state: Optional[dict] = None, deadline_ms: Optional[float] = None) -> ActionResult: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Backend(Protocol):
|
|
42
|
+
def reset(self, seed: int) -> Any: ...
|
|
43
|
+
|
|
44
|
+
def step(self, action: Any) -> Any: ...
|
|
45
|
+
|
|
46
|
+
def evaluation_oracle_success(self) -> bool:
|
|
47
|
+
"""Ground-truth scoring. Never exposed to the agent."""
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Perception: Oracle (simulator truth + optional noise) and Vision (colour segmentation on the
|
|
2
|
+
software top-down image with Alpha-Beta temporal velocity tracking). Both return the same WorldState dict."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from . import render as R
|
|
13
|
+
from .sim import CONTAINER_HALF_IN, World
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _in_box(x, y, c):
|
|
17
|
+
return abs(x - c[0]) < CONTAINER_HALF_IN and abs(y - c[1]) < CONTAINER_HALF_IN
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TrackedObjectState:
|
|
22
|
+
pos: np.ndarray
|
|
23
|
+
vel: np.ndarray
|
|
24
|
+
sim_time: float
|
|
25
|
+
samples: int = 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TemporalVelocityTracker:
|
|
29
|
+
"""Lightweight Alpha-Beta kinematic filter for VisionPerception.
|
|
30
|
+
Estimates 3D object velocity (vx, vy, vz) purely from sequential
|
|
31
|
+
top-down colour-segmentation centroids without touching MuJoCo qvel."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
alpha: float = 0.75,
|
|
36
|
+
beta: float = 0.45,
|
|
37
|
+
max_speed_mps: float = 0.50,
|
|
38
|
+
min_dt: float = 1e-3,
|
|
39
|
+
max_dt: float = 5.0,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.alpha = alpha
|
|
42
|
+
self.beta = beta
|
|
43
|
+
self.max_speed_mps = max_speed_mps
|
|
44
|
+
self.min_dt = min_dt
|
|
45
|
+
self.max_dt = max_dt
|
|
46
|
+
self._tracks: Dict[str, TrackedObjectState] = {}
|
|
47
|
+
|
|
48
|
+
def reset(self) -> None:
|
|
49
|
+
self._tracks.clear()
|
|
50
|
+
|
|
51
|
+
def update(
|
|
52
|
+
self, obj_id: str, measured_pos: np.ndarray, sim_time: float
|
|
53
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
54
|
+
pos = np.asarray(measured_pos, dtype=np.float64)
|
|
55
|
+
if obj_id not in self._tracks:
|
|
56
|
+
self._tracks[obj_id] = TrackedObjectState(
|
|
57
|
+
pos=pos.copy(),
|
|
58
|
+
vel=np.zeros(3, dtype=np.float64),
|
|
59
|
+
sim_time=sim_time,
|
|
60
|
+
samples=1,
|
|
61
|
+
)
|
|
62
|
+
return pos.copy(), np.zeros(3, dtype=np.float64)
|
|
63
|
+
|
|
64
|
+
prev = self._tracks[obj_id]
|
|
65
|
+
dt = sim_time - prev.sim_time
|
|
66
|
+
|
|
67
|
+
if dt < self.min_dt or dt > self.max_dt:
|
|
68
|
+
self._tracks[obj_id] = TrackedObjectState(
|
|
69
|
+
pos=pos.copy(),
|
|
70
|
+
vel=prev.vel.copy() if abs(dt) < self.min_dt else np.zeros(3, dtype=np.float64),
|
|
71
|
+
sim_time=sim_time,
|
|
72
|
+
samples=prev.samples + 1,
|
|
73
|
+
)
|
|
74
|
+
return pos.copy(), self._tracks[obj_id].vel.copy()
|
|
75
|
+
|
|
76
|
+
pred_pos = prev.pos + prev.vel * dt
|
|
77
|
+
residual = pos - pred_pos
|
|
78
|
+
|
|
79
|
+
new_pos = pred_pos + self.alpha * residual
|
|
80
|
+
raw_vel = (pos - prev.pos) / dt
|
|
81
|
+
new_vel = prev.vel + (self.beta / dt) * residual if prev.samples > 2 else raw_vel
|
|
82
|
+
|
|
83
|
+
speed = float(np.linalg.norm(new_vel[:2]))
|
|
84
|
+
if speed < 0.004:
|
|
85
|
+
new_vel[:] = 0.0
|
|
86
|
+
elif speed > self.max_speed_mps:
|
|
87
|
+
new_vel = new_vel * (self.max_speed_mps / speed)
|
|
88
|
+
|
|
89
|
+
new_vel[2] = 0.0
|
|
90
|
+
|
|
91
|
+
self._tracks[obj_id] = TrackedObjectState(
|
|
92
|
+
pos=new_pos,
|
|
93
|
+
vel=new_vel,
|
|
94
|
+
sim_time=sim_time,
|
|
95
|
+
samples=prev.samples + 1,
|
|
96
|
+
)
|
|
97
|
+
return new_pos.copy(), new_vel.copy()
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def predict_intercept(
|
|
101
|
+
pos: np.ndarray,
|
|
102
|
+
vel: np.ndarray,
|
|
103
|
+
planner_latency_s: float,
|
|
104
|
+
arm_travel_time_s: float = 0.32,
|
|
105
|
+
workspace_bounds: Tuple[Tuple[float, float], Tuple[float, float]] = (
|
|
106
|
+
(0.10, 0.36),
|
|
107
|
+
(-0.25, 0.25),
|
|
108
|
+
),
|
|
109
|
+
) -> np.ndarray:
|
|
110
|
+
total_lead_s = max(0.0, float(planner_latency_s)) + max(0.0, float(arm_travel_time_s))
|
|
111
|
+
intercept = np.asarray(pos, dtype=np.float64).copy()
|
|
112
|
+
intercept[:2] += np.asarray(vel[:2], dtype=np.float64) * total_lead_s
|
|
113
|
+
(x_min, x_max), (y_min, y_max) = workspace_bounds
|
|
114
|
+
intercept[0] = float(np.clip(intercept[0], x_min, x_max))
|
|
115
|
+
intercept[1] = float(np.clip(intercept[1], y_min, y_max))
|
|
116
|
+
return intercept
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class OraclePerception:
|
|
120
|
+
name = "oracle"
|
|
121
|
+
|
|
122
|
+
def __init__(self, rng: np.random.Generator, pos_noise: float = 0.0):
|
|
123
|
+
self.rng, self.pos_noise = rng, pos_noise
|
|
124
|
+
|
|
125
|
+
def perceive(self, w: World) -> Dict:
|
|
126
|
+
c = w.spec.container_pos
|
|
127
|
+
objs = []
|
|
128
|
+
for o in w.spec.objects:
|
|
129
|
+
p = w.obj_pos(o.oid)
|
|
130
|
+
n = self.rng.normal(0, self.pos_noise, 2) if self.pos_noise > 0 else (0.0, 0.0)
|
|
131
|
+
x, y = float(p[0] + n[0]), float(p[1] + n[1])
|
|
132
|
+
d = {"id": o.oid, "color": o.color, "half_size": o.half, "x": round(x, 3), "y": round(y, 3),
|
|
133
|
+
"yaw": round(w.obj_yaw(o.oid), 3), "in_container": bool(w.in_container(o.oid))}
|
|
134
|
+
if np.any(w.vel[o.oid]):
|
|
135
|
+
d["velocity_y"] = float(w.vel[o.oid][1])
|
|
136
|
+
objs.append(d)
|
|
137
|
+
tip = w.tip()
|
|
138
|
+
return {"t": round(w.time, 3), "source": "oracle", "container": {"x": c[0], "y": c[1]},
|
|
139
|
+
"gripper": {"closure": round(w.finger_closure(), 4), "tip": [round(float(v), 3) for v in tip]}, "objects": objs}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _components(mask: np.ndarray, min_px: int = 30):
|
|
143
|
+
h, w = mask.shape
|
|
144
|
+
seen = np.zeros_like(mask, bool)
|
|
145
|
+
out = []
|
|
146
|
+
for r0, c0 in zip(*np.nonzero(mask)):
|
|
147
|
+
if seen[r0, c0]:
|
|
148
|
+
continue
|
|
149
|
+
q = deque([(r0, c0)])
|
|
150
|
+
seen[r0, c0] = True
|
|
151
|
+
pix = []
|
|
152
|
+
while q:
|
|
153
|
+
r, c = q.popleft()
|
|
154
|
+
pix.append((r, c))
|
|
155
|
+
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
156
|
+
rr, cc = r + dr, c + dc
|
|
157
|
+
if 0 <= rr < h and 0 <= cc < w and mask[rr, cc] and not seen[rr, cc]:
|
|
158
|
+
seen[rr, cc] = True
|
|
159
|
+
q.append((rr, cc))
|
|
160
|
+
if len(pix) >= min_px:
|
|
161
|
+
out.append(np.array(pix))
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class VisionPerception:
|
|
166
|
+
name = "vision"
|
|
167
|
+
|
|
168
|
+
def __init__(self, rng: np.random.Generator, pixel_noise: float = 0.0, enable_velocity_tracker: bool = True):
|
|
169
|
+
self.rng, self.pixel_noise = rng, pixel_noise
|
|
170
|
+
self.enable_velocity_tracker = enable_velocity_tracker
|
|
171
|
+
self.tracker = TemporalVelocityTracker(alpha=0.80, beta=0.55)
|
|
172
|
+
|
|
173
|
+
def _detect_raw(self, w: World) -> List[Dict]:
|
|
174
|
+
img = R.render(w, draw_arm=False, rng=self.rng, noise=self.pixel_noise)
|
|
175
|
+
f = img.astype(np.int32)
|
|
176
|
+
objs: List[Dict] = []
|
|
177
|
+
k = 0
|
|
178
|
+
for color in ("red", "green", "yellow", "orange"):
|
|
179
|
+
ref = np.array(R.PALETTE[color])
|
|
180
|
+
m = (np.abs(f - ref).sum(axis=2) < 60 + 3 * self.pixel_noise)
|
|
181
|
+
for pix in _components(m):
|
|
182
|
+
cy_, cx_ = pix.mean(axis=0)
|
|
183
|
+
x, y = R.px_to_world(cy_ + 0.5, cx_ + 0.5)
|
|
184
|
+
area_m2 = len(pix) * R.RES_X * R.RES_Y
|
|
185
|
+
half = math.sqrt(area_m2) / 2
|
|
186
|
+
d = pix - pix.mean(axis=0)
|
|
187
|
+
wx, wy = -d[:, 0] * R.RES_X, -d[:, 1] * R.RES_Y
|
|
188
|
+
zc = np.sum((wx + 1j * wy) ** 4)
|
|
189
|
+
yaw = ((np.angle(zc) - math.pi) / 4) % (math.pi / 2)
|
|
190
|
+
objs.append({
|
|
191
|
+
"id": f"obj_{k}",
|
|
192
|
+
"color": color,
|
|
193
|
+
"half_size": round(half, 3),
|
|
194
|
+
"x": round(x, 3),
|
|
195
|
+
"y": round(y, 3),
|
|
196
|
+
"_raw_x": float(x),
|
|
197
|
+
"_raw_y": float(y),
|
|
198
|
+
"yaw": round(yaw, 3),
|
|
199
|
+
"in_container": bool(_in_box(x, y, w.spec.container_pos)),
|
|
200
|
+
})
|
|
201
|
+
k += 1
|
|
202
|
+
return objs
|
|
203
|
+
|
|
204
|
+
def perceive(self, w: World) -> Dict:
|
|
205
|
+
raw_objs = self._detect_raw(w)
|
|
206
|
+
# Cold-start optical burst: step 100 physics ticks (0.20s) so a 0.02 m/s cube
|
|
207
|
+
# shifts by 4.0mm (~1.7 pixels), overcoming 2.34mm/px quantization on frame 1.
|
|
208
|
+
if self.enable_velocity_tracker and not self.tracker._tracks:
|
|
209
|
+
t0 = w.time
|
|
210
|
+
for o in raw_objs:
|
|
211
|
+
self.tracker.update(o["color"] + "_" + o["id"], np.array([o["_raw_x"], o["_raw_y"], 0.0]), t0)
|
|
212
|
+
if any(np.any(v) for v in w.vel.values()):
|
|
213
|
+
w.step(100)
|
|
214
|
+
raw_objs = self._detect_raw(w)
|
|
215
|
+
|
|
216
|
+
objs: List[Dict] = []
|
|
217
|
+
for o in raw_objs:
|
|
218
|
+
rx, ry = o.pop("_raw_x"), o.pop("_raw_y")
|
|
219
|
+
d = dict(o)
|
|
220
|
+
if self.enable_velocity_tracker:
|
|
221
|
+
track_key = o["color"] + "_" + o["id"]
|
|
222
|
+
filt_pos, est_vel = self.tracker.update(track_key, np.array([rx, ry, 0.0]), w.time)
|
|
223
|
+
d["x"] = round(float(filt_pos[0]), 3)
|
|
224
|
+
d["y"] = round(float(filt_pos[1]), 3)
|
|
225
|
+
if abs(est_vel[1]) >= 0.006:
|
|
226
|
+
d["velocity_y"] = float(round(est_vel[1], 4))
|
|
227
|
+
objs.append(d)
|
|
228
|
+
|
|
229
|
+
c = w.spec.container_pos
|
|
230
|
+
tip = w.tip()
|
|
231
|
+
return {
|
|
232
|
+
"t": round(w.time, 3),
|
|
233
|
+
"source": "vision",
|
|
234
|
+
"container": {"x": c[0], "y": c[1]},
|
|
235
|
+
"gripper": {"closure": round(w.finger_closure(), 4), "tip": [round(float(v), 3) for v in tip]},
|
|
236
|
+
"objects": objs,
|
|
237
|
+
}
|