Download rl_deploy/common.py from MadmanMischief/so101_candy_rl: direct link, hf CLI and curl.
- Browser
- Download file 6.33 kB
-
https://huggingface.co/MadmanMischief/so101_candy_rl/resolve/main/rl_deploy/common.py
- Command line
-
hf download hf://MadmanMischief/so101_candy_rl/rl_deploy/common.py
-
curl -L -o common.py https://huggingface.co/MadmanMischief/so101_candy_rl/resolve/main/rl_deploy/common.py
6.33 kB
| """Shared pieces for the RL policy on the real SO101: robot/camera setup, blue-candy detection, | |
| and the phone-pixel -> table homography.""" | |
| import json | |
| import os | |
| import time | |
| import cv2 | |
| import numpy as np | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| CALIB = os.path.join(HERE, "table_calib.json") | |
| FOLLOWER_PORT = "/dev/serial/by-id/usb-1a86_USB_Single_Serial_5AE6057813-if00" | |
| FOLLOWER_ID = "my_follower_arm" | |
| PHONE_URL = os.environ.get("PHONE_URL", "http://127.0.0.1:4747/video") # DroidCam on localhost; PHONE_URL=... to override | |
| IMG_W, IMG_H = 640, 360 # the phone view is used at this size (same as the half-res datasets) | |
| JOINTS = ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"] | |
| def connect_robot(): | |
| from lerobot.robots.so_follower.config_so_follower import SOFollowerRobotConfig | |
| from lerobot.robots.so_follower.so_follower import SOFollower | |
| robot = SOFollower(SOFollowerRobotConfig(port=FOLLOWER_PORT, id=FOLLOWER_ID)) | |
| robot.connect(calibrate=False) | |
| return robot | |
| def read_joints(robot): | |
| obs = robot.get_observation() | |
| return np.array([obs[f"{n}.pos"] for n in JOINTS], dtype=np.float64) | |
| class Phone: | |
| """DroidCam stream, resized to 640x360 RGB. | |
| A background thread reads the stream continuously and keeps only the newest frame, so a photo | |
| is always current (OpenCV otherwise hands back frames buffered seconds earlier, which made the | |
| "before" and "after" photos identical). Reconnects if the HTTP stream drops.""" | |
| def __init__(self, url=PHONE_URL): | |
| import threading | |
| self.url = url | |
| self.cap = None | |
| self.latest, self.stamp, self.count = None, 0.0, 0 | |
| self.lock = threading.Lock() | |
| self.running = True | |
| self._open() | |
| self.thread = threading.Thread(target=self._reader, daemon=True) | |
| self.thread.start() | |
| t0 = time.time() | |
| while self.latest is None and time.time() - t0 < 10: | |
| time.sleep(0.05) | |
| if self.latest is None: | |
| raise RuntimeError(f"no frames from {self.url} within 10 s") | |
| def _open(self): | |
| if self.cap is not None: | |
| self.cap.release() | |
| self.cap = cv2.VideoCapture(self.url) | |
| if not self.cap.isOpened(): | |
| raise RuntimeError(f"cannot open phone stream {self.url} (is DroidCam running? close any browser tab showing it)") | |
| def _reader(self): | |
| fails = 0 | |
| while self.running: | |
| ok, img = self.cap.read() | |
| if ok and img is not None: | |
| fails = 0 | |
| with self.lock: | |
| self.latest, self.stamp = img, time.time() | |
| self.count += 1 | |
| continue | |
| fails += 1 | |
| print(f" (phone stream dropped, reconnecting: attempt {fails})") | |
| time.sleep(1.0) | |
| try: | |
| self._open() | |
| except RuntimeError as e: | |
| print(" ", e) | |
| def frame(self, max_age=0.5, timeout=10.0): | |
| """Newest frame (taken after this call started), 640x360 RGB.""" | |
| t_call = time.time() | |
| while time.time() - t_call < timeout: | |
| with self.lock: | |
| img, stamp = self.latest, self.stamp | |
| if img is not None and stamp > t_call and time.time() - stamp < max_age: | |
| return cv2.cvtColor(cv2.resize(img, (IMG_W, IMG_H), interpolation=cv2.INTER_AREA), cv2.COLOR_BGR2RGB) | |
| time.sleep(0.02) | |
| raise RuntimeError("no fresh phone frame for %.0f s; check the DroidCam app and connection" % timeout) | |
| def close(self): | |
| self.running = False | |
| self.thread.join(timeout=2) | |
| self.cap.release() | |
| def blue_mask(rgb): | |
| hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) | |
| m = cv2.inRange(hsv, (95, 90, 70), (125, 255, 255)) # OpenCV hue 0..180: blue ~ 100-125 | |
| return cv2.morphologyEx(m, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) | |
| def blue_report(rgb): | |
| """Why a photo has (no) blue blobs: blue pixel count and the largest blue component.""" | |
| m = blue_mask(rgb) | |
| n, _, stats, cent = cv2.connectedComponentsWithStats(m) | |
| big = max(range(1, n), key=lambda i: stats[i, cv2.CC_STAT_AREA], default=None) | |
| hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) | |
| return {"blue_px": int((m > 0).sum()), "largest_area": int(stats[big, cv2.CC_STAT_AREA]) if big else 0, | |
| "largest_at": cent[big].round(0).tolist() if big else None, "mean_brightness": float(hsv[..., 2].mean())} | |
| def blue_blobs(rgb, min_px=25, ignore=None): | |
| """Blue wrapper blobs: list of dicts with pixel centroid, area and the blob's long-axis angle. | |
| ``ignore``: a reference image; blue that was already there (dilated a little) is left out.""" | |
| m = blue_mask(rgb) | |
| if ignore is not None: | |
| m = cv2.bitwise_and(m, cv2.bitwise_not(cv2.dilate(blue_mask(ignore), np.ones((9, 9), np.uint8)))) | |
| n, lab, stats, cent = cv2.connectedComponentsWithStats(m) | |
| out = [] | |
| for i in range(1, n): | |
| if stats[i, cv2.CC_STAT_AREA] < min_px: | |
| continue | |
| ys, xs = np.nonzero(lab == i) | |
| pts = np.c_[xs, ys].astype(np.float64) | |
| c = pts.mean(0) | |
| evals, evecs = np.linalg.eigh(np.cov((pts - c).T)) | |
| major = evecs[:, np.argmax(evals)] | |
| out.append({"uv": c, "area": int(stats[i, cv2.CC_STAT_AREA]), "axis_uv": major}) | |
| return out | |
| def fit_homography(uv, xy): | |
| """Least-squares DLT homography (u, v) -> (x, y).""" | |
| A = [] | |
| for (u, v), (x, y) in zip(uv, xy): | |
| A.append([-u, -v, -1, 0, 0, 0, x * u, x * v, x]) | |
| A.append([0, 0, 0, -u, -v, -1, y * u, y * v, y]) | |
| _, _, Vt = np.linalg.svd(np.array(A)) | |
| return Vt[-1].reshape(3, 3) / Vt[-1, -1] | |
| def apply_h(H, uv): | |
| uv = np.atleast_2d(uv) | |
| q = np.c_[uv, np.ones(len(uv))] @ H.T | |
| return q[:, :2] / q[:, 2:3] | |
| def load_calib(): | |
| if not os.path.exists(CALIB): | |
| raise SystemExit(f"No {CALIB}. Run calibrate_table.py first.") | |
| return np.array(json.load(open(CALIB))["H"]) | |
| def candy_on_table(H, blob): | |
| """Table x, y [m] and yaw [rad] of a blob (yaw from the blob's long axis mapped onto the table).""" | |
| c = blob["uv"] | |
| p0, p1 = apply_h(H, [c - 8 * blob["axis_uv"], c + 8 * blob["axis_uv"]]) | |
| d = p1 - p0 | |
| xy = apply_h(H, c)[0] | |
| return xy, float(np.arctan2(d[1], d[0])) | |