twanghcmut's picture
download
raw
17.9 kB
#!/usr/bin/env python
"""Verify PointWorld-DROID ``scene_flows`` from two cameras share one world frame.
This is the single most load-bearing assumption of the datagen pipeline planned
on top of this dataset: that camera ext1's and camera ext2's ``scene_flows`` for
the *same clip* can be normalized (via :func:`fpgm.geometry.convention.detect_scene_flow_convention`
+ each clip's own ``extrinsic``) into one common world frame, so a single
canonical point cloud/robot-base frame can be built from either camera
indifferently. Getting this wrong would mean every downstream stage (dense
depth, object discovery, pose fitting) silently mixes two different frames.
Four independent checks, each with its own PASS/FAIL verdict:
1. Convention detection succeeds decisively for both cameras (reuses the
existing, already-adversarially-designed detector -- this script does not
re-implement frame-guessing).
2. Cross-camera colour agreement: frame-0 world points from camera A, projected
into camera B, should sample colours in B's ``initial_rgb`` close to what A
itself recorded (``scene_colors``) -- and vice versa. This is exactly the
convention detector's own scoring idea, just applied *across* cameras
instead of within one.
3. Cross-camera depth agreement: the same reprojected points' camera-B depth
should agree with B's own ``initial_depth`` at that pixel.
4. The rigid transform between the two cameras implied by their ``extrinsic``
matrices (as read from the clip) should match the transform implied by
``optimized_extrinsics`` in ``<uuid>_cameras.json`` -- an independent
calibration source for the same physical rig.
If cameras do NOT agree, this prints a loud FAIL with the actual numbers rather
than silently downgrading -- the whole one-world-frame design in the datagen
plan depends on this holding.
Usage:
PYTHONPATH=src python scripts/verify_world_frame.py \\
--episode AUTOLab+0d4edc83+2023-10-21-19h-07m-04s --clip 20:31 \\
--camera-a 22008760 --camera-b 24400334
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
import numpy as np # noqa: E402
from fpgm.config import ConventionConfig # noqa: E402
from fpgm.data.pointworld import FlowsReader, PointWorldStore # noqa: E402
from fpgm.geometry.camera import Camera # noqa: E402
from fpgm.geometry.convention import detect_scene_flow_convention # noqa: E402
from fpgm.geometry.transforms import invert_se3, matrix_to_rotvec # noqa: E402
from fpgm.types import ( # noqa: E402
AmbiguousConventionError,
CameraCalibrationMismatchError,
DataError,
FrameConvention,
SceneFlowClip,
)
from fpgm.utils.logging import get_logger, setup_logging # noqa: E402
logger = get_logger("verify_world_frame")
# Thresholds chosen to separate "same world frame, ordinary sensor/JPEG/lighting
# noise" from "off by a rigid transform" (which is not a subtle effect -- a wrong
# frame typically blows these numbers up by 5-10x, not by a few percent). They are
# deliberately loose, not scientific: this script's job is to catch a categorical
# frame bug, not to certify sub-pixel calibration quality.
_PASS_COLOR_DIFF_PER_CHANNEL = 30.0 # 0-255 scale
_PASS_DEPTH_ERROR_MM = 80.0
_PASS_TRANSLATION_ERROR_M = 0.01
_PASS_ROTATION_ERROR_DEG = 1.0
#: DROID's two exterior cameras only partially overlap (they are placed to see
#: the scene from different angles, not to stereo-overlap fully), so most of one
#: camera's points legitimately fall outside the other's frustum. A small but
#: nonzero fraction landing in-bounds is the expected signature of "same frame,
#: partial overlap"; near-zero would instead suggest a frame/transform bug.
_PASS_MIN_FRAC_IN_BOUNDS = 0.02
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--episode", required=True, help="DROID episode uuid")
parser.add_argument("--clip", required=True, help='PointWorld clip key, e.g. "20:31"')
parser.add_argument("--camera-a", required=True, help="camera serial, e.g. 22008760")
parser.add_argument("--camera-b", required=True, help="camera serial, e.g. 24400334")
parser.add_argument(
"--data-dir", type=Path, default=REPO_ROOT / "data" / "pointworld",
help="PointWorldStore local_dir (must already hold the flows.h5 + cameras.json)",
)
parser.add_argument(
"--out", type=Path, default=None,
help="JSON sidecar path; defaults to outputs/verify_world_frame/<uuid>__<clip>.json",
)
parser.add_argument("--log-level", default="INFO")
return parser.parse_args()
def _load_camera(clip: SceneFlowClip) -> Camera:
"""Build a :class:`Camera` rescaled to ``clip.initial_rgb``'s resolution.
PointWorld's annotation intrinsic is already valid at ``initial_rgb``'s
resolution (both are 320x180 in every clip inspected), so ``.rescaled()``
is a no-op here -- it is still called explicitly because it is the only
sanctioned way to guarantee that per :meth:`Camera.rescaled`'s docstring,
rather than relying on that equality holding by convention.
"""
if clip.initial_rgb is None:
raise DataError(f"clip {clip.key!r} camera {clip.camera_serial!r} has no initial_rgb")
h, w = clip.initial_rgb.shape[:2]
camera = Camera.from_pointworld(clip.intrinsic, clip.extrinsic, width=w, height=h)
return camera.rescaled(w, h)
def _to_world(clip: SceneFlowClip, camera: Camera, convention: FrameConvention) -> np.ndarray:
"""Normalize ``clip.scene_flows`` (all T frames) to the world frame.
The camera is static for the whole clip (one ``extrinsic`` per clip, not
per frame), so the same transform applies to every frame once the
convention is known from frame 0.
"""
flows = clip.scene_flows.astype(np.float64)
if convention == FrameConvention.WORLD:
return flows
return camera.cam_to_world(flows)
def _bilinear_sample(image: np.ndarray, uv: np.ndarray) -> np.ndarray:
"""Bilinearly sample an ``(H, W, C)`` image at float pixel coords ``(N, 2)``.
Duplicated from (not imported off of) ``fpgm.geometry.convention``'s private
``_bilinear_sample``: that module is owned by another workstream and its
underscore-prefixed helper isn't part of its public contract, so this script
keeps its own small copy rather than depending on it. Callers must
pre-filter ``uv`` to be in-bounds; this does not bounds-check.
"""
h, w = image.shape[:2]
u, v = uv[:, 0], uv[:, 1]
u0 = np.clip(np.floor(u).astype(np.int64), 0, w - 1)
v0 = np.clip(np.floor(v).astype(np.int64), 0, h - 1)
u1 = np.clip(u0 + 1, 0, w - 1)
v1 = np.clip(v0 + 1, 0, h - 1)
fu = (u - u0).reshape(-1, 1)
fv = (v - v0).reshape(-1, 1)
img = image.astype(np.float64)
top = img[v0, u0] * (1 - fu) + img[v0, u1] * fu
bottom = img[v1, u0] * (1 - fu) + img[v1, u1] * fu
return top * (1 - fv) + bottom * fv
def _cross_camera_test(
label: str,
world_pts_src: np.ndarray, # (M, 3) float64, valid frame-0 world points from the source camera
colors_src: np.ndarray, # (M, 3) uint8, that camera's own scene_colors for the same points
camera_dst: Camera,
rgb_dst: np.ndarray, # (H, W, 3) uint8
depth_dst_mm: np.ndarray | None, # (H, W) uint16 millimetres, or None if unavailable
) -> dict:
"""Project ``world_pts_src`` into camera B and compare colour/depth there.
Args:
label: Human-readable direction label, e.g. ``"A_into_B"``.
world_pts_src: Frame-0 world points already normalized to world frame,
restricted to points valid (visible + depth-valid) in the source
camera's own frame 0.
colors_src: The source camera's own recorded colour for each point --
the "ground truth" this projection is checked against.
camera_dst: Destination camera to project into.
rgb_dst: Destination camera's frame-0 RGB image.
depth_dst_mm: Destination camera's dense first-frame depth, if present.
Returns:
A dict report: point counts, in-bounds fraction, and median per-channel
colour error / median depth error (``None`` where no point landed
in-bounds, which must be surfaced as inconclusive rather than a false 0).
"""
uv, depth = camera_dst.project(world_pts_src)
h, w = rgb_dst.shape[:2]
in_bounds = (uv[:, 0] >= 0) & (uv[:, 0] < w - 1) & (uv[:, 1] >= 0) & (uv[:, 1] < h - 1) & (
depth > 0
)
n_points = int(world_pts_src.shape[0])
frac_in_bounds = float(np.mean(in_bounds)) if n_points else 0.0
report: dict = {
"label": label,
"n_points": n_points,
"n_in_bounds": int(np.count_nonzero(in_bounds)),
"frac_in_bounds": frac_in_bounds,
"median_abs_color_diff_per_channel": None,
"median_abs_color_diff_mean": None,
"median_abs_depth_error_mm": None,
"n_depth_compared": 0,
}
if not np.any(in_bounds):
return report
sampled = _bilinear_sample(rgb_dst, uv[in_bounds])
reference = colors_src[in_bounds].astype(np.float64)
diff = np.abs(sampled - reference)
median_per_channel = np.median(diff, axis=0).tolist()
report["median_abs_color_diff_per_channel"] = median_per_channel
report["median_abs_color_diff_mean"] = float(np.mean(median_per_channel))
if depth_dst_mm is not None:
u_px = np.clip(np.round(uv[in_bounds, 0]).astype(np.int64), 0, w - 1)
v_px = np.clip(np.round(uv[in_bounds, 1]).astype(np.int64), 0, h - 1)
depth_dst_at_px_mm = depth_dst_mm[v_px, u_px].astype(np.float64)
depth_valid = depth_dst_at_px_mm > 0 # 0 = no dense-depth sample at that pixel
if np.any(depth_valid):
reproj_depth_mm = depth[in_bounds][depth_valid] * 1000.0
abs_err = np.abs(reproj_depth_mm - depth_dst_at_px_mm[depth_valid])
report["median_abs_depth_error_mm"] = float(np.median(abs_err))
report["n_depth_compared"] = int(np.count_nonzero(depth_valid))
return report
def _rigid_transform_check(
clip_a: SceneFlowClip, clip_b: SceneFlowClip, cameras_json: dict
) -> dict:
"""Compare the A->B rigid transform implied by flows.h5 vs by cameras.json.
Both ``clip.extrinsic`` (from ``*_flows.h5``) and ``optimized_extrinsics``
(from ``<uuid>_cameras.json``) claim to be the same world->camera SE3 per
serial; this checks they actually agree by composing each source's own A/B
pair into a relative transform and comparing those, rather than comparing
the raw matrices directly (which would also catch a difference in *which*
world frame each source roots its extrinsic in -- exactly the failure mode
this whole script exists to catch).
"""
rel_flows = clip_b.extrinsic @ invert_se3(clip_a.extrinsic)
opt_a = np.asarray(
cameras_json[clip_a.camera_serial]["optimized_extrinsics"], dtype=np.float64
)
opt_b = np.asarray(
cameras_json[clip_b.camera_serial]["optimized_extrinsics"], dtype=np.float64
)
rel_json = opt_b @ invert_se3(opt_a)
translation_error_m = float(np.linalg.norm(rel_flows[:3, 3] - rel_json[:3, 3]))
r_err = rel_flows[:3, :3].T @ rel_json[:3, :3]
rotation_error_deg = float(np.degrees(np.linalg.norm(matrix_to_rotvec(r_err))))
return {
"translation_error_m": translation_error_m,
"rotation_error_deg": rotation_error_deg,
}
def _print_verdict(name: str, passed: bool, detail: str) -> None:
tag = "PASS" if passed else "FAIL"
print(f"[{tag}] {name}: {detail}")
def main() -> int:
args = parse_args()
setup_logging(args.log_level)
store = PointWorldStore(local_dir=args.data_dir)
flows_path = store.flows_path(args.episode)
with FlowsReader(flows_path, episode_uuid=args.episode) as reader:
clip_a = reader.read_clip(args.clip, args.camera_a)
clip_b = reader.read_clip(args.clip, args.camera_b)
camera_a = _load_camera(clip_a)
camera_b = _load_camera(clip_b)
report: dict = {
"episode": args.episode,
"clip": args.clip,
"camera_a": args.camera_a,
"camera_b": args.camera_b,
"n_points_a": clip_a.n_points,
"n_points_b": clip_b.n_points,
"n_frames": clip_a.n_frames,
}
# -- 1. convention detection ------------------------------------------------
cfg = ConventionConfig()
conventions: dict[str, FrameConvention | None] = {}
convention_report: dict = {}
for label, clip, camera in (("a", clip_a, camera_a), ("b", clip_b, camera_b)):
try:
det = detect_scene_flow_convention(
camera, clip.scene_flows[0], clip.scene_colors[0], clip.initial_rgb, cfg
)
conventions[label] = det.convention
convention_report[label] = {
"convention": det.convention.value,
"score_world": det.score_world,
"score_camera": det.score_camera,
"margin": det.margin,
"frac_in_bounds": det.frac_in_bounds,
}
except (AmbiguousConventionError, CameraCalibrationMismatchError) as exc:
conventions[label] = None
convention_report[label] = {"error": str(exc)}
report["convention"] = convention_report
convention_ok = conventions["a"] is not None and conventions["b"] is not None
if convention_ok:
detail = f"a={conventions['a'].value}, b={conventions['b'].value}"
else:
detail = f"detection failed: {convention_report}"
_print_verdict("convention_detection", convention_ok, detail)
if not convention_ok:
report["verdict"] = "INCONCLUSIVE"
report["verdict_detail"] = "convention detection failed for at least one camera"
_write_report(args, report)
print("\nOVERALL: INCONCLUSIVE -- cannot test cross-camera agreement without a "
"decided convention for both cameras.")
return 1
# -- 2 & 3. cross-camera colour + depth agreement ----------------------------
world_a = _to_world(clip_a, camera_a, conventions["a"])
world_b = _to_world(clip_b, camera_b, conventions["b"])
valid_a0 = clip_a.scene_visibility[0] & clip_a.scene_depth_valid[0]
valid_b0 = clip_b.scene_visibility[0] & clip_b.scene_depth_valid[0]
a_into_b = _cross_camera_test(
"A_into_B", world_a[0][valid_a0], clip_a.scene_colors[0][valid_a0],
camera_b, clip_b.initial_rgb, clip_b.initial_depth,
)
b_into_a = _cross_camera_test(
"B_into_A", world_b[0][valid_b0], clip_b.scene_colors[0][valid_b0],
camera_a, clip_a.initial_rgb, clip_a.initial_depth,
)
report["cross_camera"] = {"a_into_b": a_into_b, "b_into_a": b_into_a}
color_checks = []
depth_checks = []
for direction in (a_into_b, b_into_a):
color_ok = (
direction["frac_in_bounds"] >= _PASS_MIN_FRAC_IN_BOUNDS
and direction["median_abs_color_diff_mean"] is not None
and direction["median_abs_color_diff_mean"] <= _PASS_COLOR_DIFF_PER_CHANNEL
)
color_checks.append(color_ok)
_print_verdict(
f"color_agreement[{direction['label']}]",
color_ok,
f"frac_in_bounds={direction['frac_in_bounds']:.3f}, "
f"median_abs_diff_per_channel={direction['median_abs_color_diff_per_channel']}",
)
depth_ok = (
direction["n_depth_compared"] > 0
and direction["median_abs_depth_error_mm"] is not None
and direction["median_abs_depth_error_mm"] <= _PASS_DEPTH_ERROR_MM
)
depth_checks.append(depth_ok)
_print_verdict(
f"depth_agreement[{direction['label']}]",
depth_ok,
f"n_compared={direction['n_depth_compared']}, "
f"median_abs_depth_error_mm={direction['median_abs_depth_error_mm']}",
)
# -- 4. rigid transform vs cameras.json --------------------------------------
cameras_json = store.load_camera_calibration(args.episode)
rigid = _rigid_transform_check(clip_a, clip_b, cameras_json)
report["rigid_transform_vs_cameras_json"] = rigid
rigid_ok = (
rigid["translation_error_m"] <= _PASS_TRANSLATION_ERROR_M
and rigid["rotation_error_deg"] <= _PASS_ROTATION_ERROR_DEG
)
_print_verdict(
"rigid_transform_vs_cameras_json",
rigid_ok,
f"translation_error_m={rigid['translation_error_m']:.6f}, "
f"rotation_error_deg={rigid['rotation_error_deg']:.6f}",
)
overall_ok = convention_ok and all(color_checks) and all(depth_checks) and rigid_ok
report["verdict"] = "PASS" if overall_ok else "FAIL"
_write_report(args, report)
print(f"\nOVERALL: {'PASS' if overall_ok else 'FAIL'} -- cameras {args.camera_a} and "
f"{args.camera_b} of clip {args.clip} "
f"{'agree on' if overall_ok else 'DO NOT clearly agree on'} a common world frame.")
if not overall_ok:
print("See per-test numbers above and the JSON sidecar before building anything "
"on a single canonical world frame assumption.")
return 0 if overall_ok else 1
def _write_report(args: argparse.Namespace, report: dict) -> None:
out = args.out
if out is None:
out_dir = REPO_ROOT / "outputs" / "verify_world_frame"
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / f"{args.episode}__{args.clip.replace(':', '-')}.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(report, indent=2))
logger.info("wrote %s", out)
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
17.9 kB
·
Xet hash:
8bb6bc36db96e00b738d210cc57435968792b97dfb300905037a00efed2778cf

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