Files
RealSense-D435i-G1/scripts/compare_host_align.py
T
OliviaCandClaude Opus 5.5 11e5bf5af5 fix: compare_host_align refuses pyrealsense2 without software depth units
Tried on pyrealsense2 2.50 (PC2's version): software_video_frame has no
depth_units there, the software depth frames get scale 0 and rs.align
returns an almost empty depth (1 valid pixel instead of 306565) with NO
error. The read-only depth_units option on the sensor is not enough.
The script now checks for the attribute and stops with an explicit
message; 2.58 (this PC) works. rs.__version__ does not exist in 2.50
either: read with getattr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:18:04 +02:00

226 lines
10 KiB
Python

"""Compare Intel's align on PC2 with an align on the host, on the SAME frameset. Read-only.
python scripts/compare_host_align.py --url http://192.168.123.99:8878 [--framesets 10] [--tries 20]
For each frameset: GET /api/raw/latest (color + depth, not aligned) and
GET /api/capture (depth aligned on PC2) until both carry the same
frameset_seq; align the raw depth on the host with pyrealsense2 (a
software_device built from /api/intrinsics, then rs.align); report, pixel by
pixel, how the two aligned depths differ. Prints one JSON line per frameset
and a summary. Only GET requests; nothing on PC2 changes.
The host's pyrealsense2 may be newer than PC2's (2.58 here, 2.50 there): the
two aligns are then not even the same code version. It must have
software_video_frame.depth_units: pyrealsense2 2.50 has not, and there the
software frames' depth scale is 0, so rs.align returns an (almost) empty
depth WITHOUT any error. The script refuses to run on such a version.
"""
import argparse
import base64
import json
import sys
import cv2
import httpx
import numpy as np
import pyrealsense2 as rs
SYNC_WAIT_MS = 200 # wait for the syncer's frameset after a push
SYNC_PUSHES_MAX = 5 # the syncer may emit the first pair split (color alone): push the same pair again
# Calibration ------------------------------------------------------------------
def rs_intrinsics(stream: dict) -> rs.intrinsics:
"""rs.intrinsics from one stream of /api/intrinsics ("ir" or "rgb")."""
d = stream["distortion"] or {"k1": 0, "k2": 0, "k3": 0, "p1": 0, "p2": 0}
intr = rs.intrinsics()
intr.width, intr.height = stream["width"], stream["height"]
intr.fx, intr.fy, intr.ppx, intr.ppy = stream["fx"], stream["fy"], stream["cx"], stream["cy"]
intr.model = getattr(rs.distortion, stream["model"] or "none")
intr.coeffs = [d["k1"], d["k2"], d["p1"], d["p2"], d["k3"]] # the SDK's order
return intr
def rs_extrinsics(ext: dict) -> rs.extrinsics:
"""rs.extrinsics depth->color from the API's (rotation row-major, translation mm)."""
r = ext["rotation"]
out = rs.extrinsics()
out.rotation = [r[0], r[3], r[6], r[1], r[4], r[7], r[2], r[5], r[8]] # back to column-major
out.translation = [t / 1000.0 for t in ext["translation"]]
return out
# Host align ---------------------------------------------------------------------
def require_software_depth_units() -> None:
"""Raise unless this pyrealsense2 can give software depth frames their units (not 2.50)."""
if not hasattr(rs.software_video_frame(), "depth_units"):
raise RuntimeError(
f"pyrealsense2 {getattr(rs, '__version__', '(no __version__, e.g. 2.50)')} has no "
"software_video_frame.depth_units: the host align would come out empty. Use a newer one (2.58 works).")
class HostAligner:
"""rs.align(color) on frames pushed into a software_device with PC2's calibration."""
def __init__(self, calibration: dict):
require_software_depth_units()
self._device = rs.software_device()
self._depth = self._device.add_sensor("Depth")
self._color = self._device.add_sensor("Color")
self._depth.add_read_only_option(rs.option.depth_units, 0.001) # we push millimeters
self._depth_profile = self._depth.add_video_stream(
_video_stream(rs.stream.depth, 0, rs.format.z16, 2, rs_intrinsics(calibration["ir"])))
self._color_profile = self._color.add_video_stream(
_video_stream(rs.stream.color, 1, rs.format.bgr8, 3, rs_intrinsics(calibration["rgb"])))
self._depth_profile.register_extrinsics_to(self._color_profile, rs_extrinsics(calibration["extrinsics_ir_to_rgb"]))
self._sync = rs.syncer()
self._align = rs.align(rs.stream.color)
self._number = 0
self._started = []
for sensor, profile in ((self._depth, self._depth_profile), (self._color, self._color_profile)):
sensor.open(profile)
sensor.start(self._sync)
self._started.append(sensor)
def close(self) -> None:
for sensor in self._started:
sensor.stop()
sensor.close()
self._started = []
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
def align(self, depth_mm: np.ndarray, color_bgr: np.ndarray) -> np.ndarray:
"""Depth (mm, depth grid) aligned onto the color grid by rs.align, as uint16 mm."""
depth_mm, color_bgr = np.ascontiguousarray(depth_mm), np.ascontiguousarray(color_bgr)
for _ in range(SYNC_PUSHES_MAX):
self._number += 1
stamp_ms = self._number * 1000.0
_push(self._depth, self._depth_profile, depth_mm, 2, self._number, stamp_ms, depth=True)
_push(self._color, self._color_profile, color_bgr, 3, self._number, stamp_ms)
frames = self._complete_frameset(self._number)
if frames is not None:
aligned = self._align.process(frames)
return np.asanyarray(aligned.get_depth_frame().get_data()).copy()
raise RuntimeError(f"syncer gave no complete frameset in {SYNC_PUSHES_MAX} pushes")
def _complete_frameset(self, number: int):
"""The syncer's frameset holding depth AND color of frame `number`, or None."""
for _ in range(2): # a split pair comes out as two framesets
try:
frames = self._sync.wait_for_frames(SYNC_WAIT_MS)
except RuntimeError: # "Frame did not arrive in time": try pushing again
return None
depth = frames.get_depth_frame()
if frames.size() == 2 and depth and depth.get_frame_number() == number:
return frames
return None
def _video_stream(kind, uid: int, fmt, bpp: int, intrinsics: rs.intrinsics) -> rs.video_stream:
vs = rs.video_stream()
vs.type, vs.index, vs.uid, vs.fmt, vs.bpp = kind, 0, uid, fmt, bpp
vs.width, vs.height, vs.fps, vs.intrinsics = intrinsics.width, intrinsics.height, 30, intrinsics
return vs
def _push(sensor, profile, pixels: np.ndarray, bpp: int, number: int, stamp_ms: float, depth: bool = False) -> None:
frame = rs.software_video_frame()
frame.pixels = pixels
frame.bpp = bpp
frame.stride = pixels.shape[1] * bpp
frame.timestamp = stamp_ms
frame.domain = rs.timestamp_domain.hardware_clock
frame.frame_number = number
frame.profile = profile.as_video_stream_profile()
if depth:
frame.depth_units = 0.001 # millimeters (checked to exist by require_software_depth_units)
sensor.on_video_frame(frame)
# PC2 side (GET only) ------------------------------------------------------------
def _data(response: httpx.Response) -> dict:
response.raise_for_status()
body = response.json()
if not body.get("success"):
raise RuntimeError(f"{response.request.url}: {body.get('error')}")
return body["data"]
def _decode(b64: str, flags: int) -> np.ndarray:
return cv2.imdecode(np.frombuffer(base64.b64decode(b64), np.uint8), flags)
def fetch_same_frameset(client: httpx.Client, tries: int) -> tuple[dict, dict, int]:
"""(raw, capture, attempts): /api/raw/latest and /api/capture of one frameset_seq."""
for attempt in range(1, tries + 1):
raw = _data(client.get("/api/raw/latest"))
capture = _data(client.get("/api/capture"))
if capture.get("frameset_seq") == raw["frameset_seq"]:
return raw, capture, attempt
raise RuntimeError(f"no matching frameset in {tries} attempts (another client may keep the align busy)")
# Comparison (pure) -------------------------------------------------------------
def compare_depths(pc2: np.ndarray, host: np.ndarray) -> dict:
"""Pixel-by-pixel difference of two aligned depth maps in mm (same shape)."""
if pc2.shape != host.shape:
raise ValueError(f"shapes differ: PC2 {pc2.shape}, host {host.shape}")
both = (pc2 > 0) & (host > 0)
diff = np.abs(pc2[both].astype(np.int32) - host[both].astype(np.int32))
total = pc2.size
return {
"pixels": int(total),
"valid_both": int(both.sum()),
"only_pc2": int(((pc2 > 0) & (host == 0)).sum()),
"only_host": int(((host > 0) & (pc2 == 0)).sum()),
"equal_fraction_of_valid_both": float((diff == 0).mean()) if diff.size else None,
"abs_diff_mm": None if not diff.size else {
"mean": float(diff.mean()), "median": float(np.median(diff)),
"p99": float(np.percentile(diff, 99)), "max": int(diff.max()),
},
}
def compare_one(client: httpx.Client, aligner: HostAligner, tries: int) -> dict:
raw, capture, attempts = fetch_same_frameset(client, tries)
host = aligner.align(_decode(raw["depth_png_b64"], cv2.IMREAD_UNCHANGED),
_decode(raw["color_jpeg_b64"], cv2.IMREAD_COLOR))
pc2 = _decode(capture["depth_png_b64"], cv2.IMREAD_UNCHANGED)
return {"frameset_seq": raw["frameset_seq"], "attempts": attempts, **compare_depths(pc2, host)}
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--url", required=True, help="RealSense service, e.g. http://192.168.123.99:8878")
ap.add_argument("--framesets", type=int, default=10)
ap.add_argument("--tries", type=int, default=20, help="attempts to get raw and aligned of one frameset")
ap.add_argument("--api-key", default="")
args = ap.parse_args(argv)
headers = {"X-Api-Key": args.api_key} if args.api_key else {}
results = []
with httpx.Client(base_url=args.url, headers=headers, timeout=10) as client:
calibration = _data(client.get("/api/intrinsics"))
with HostAligner(calibration) as aligner:
for _ in range(args.framesets):
result = compare_one(client, aligner, args.tries)
results.append(result)
print(json.dumps(result))
means = [r["abs_diff_mm"]["mean"] for r in results if r["abs_diff_mm"]]
print(json.dumps({"summary": {"framesets": len(results), "pyrealsense2_host": getattr(rs, "__version__", "unknown"),
"mean_abs_diff_mm_per_frameset": means}}))
return 0
if __name__ == "__main__":
sys.exit(main())