"""
Sequence data comparator for navigation pipeline tests.
Provides functions to compare generated navigation sequences with reference data.
"""
import json
import os
import math
import numpy as np
import cv2 # OpenCV is available per requirements
from pathlib import Path
from typing import Tuple, Any, Dict, Optional
def compare_navigation_results(generated_dir: str, reference_dir: str) -> Tuple[bool, str]:
"""Original JSON trajectory sequence comparison (unchanged logic).
NOTE: Do not modify this function's core behavior. Image comparison is handled by a separate
wrapper function `compare_navigation_and_images` to avoid side effects on existing tests.
"""
# --- Enhanced logic ---
# To support both "caller passes seq_path root directory" and "legacy call (leaf trajectory directory)" forms,
# here we use a symmetric data.json discovery strategy for both generated and reference sides:
# 1. If the current directory directly contains data.json, use that file.
# 2. Otherwise, traverse one level down into subdirectories (sorted alphabetically), looking for
/data.json.
# 3. Otherwise, search within two nested levels (dir/subdir/data.json) and use the first match found.
# 4. If not found, report an error. This is compatible with the legacy "generated=root, reference=leaf" usage,
# and also allows both sides to provide the root directory.
if not os.path.isdir(generated_dir):
return False, f"Generated directory does not exist or is not a directory: {generated_dir}"
if not os.path.isdir(reference_dir):
return False, f"Reference directory does not exist or is not a directory: {reference_dir}"
try:
generated_file = _locate_first_data_json(generated_dir)
if generated_file is None:
return False, f"Could not locate data.json under generated directory: {generated_dir}"
except Exception as e: # pylint: disable=broad-except
return False, f"Error locating generated data file: {e}"
try:
reference_file = _locate_first_data_json(reference_dir)
if reference_file is None:
# To preserve legacy behavior, if reference_dir/data.json exists but was not detected above (should not happen in theory), check once more
candidate = os.path.join(reference_dir, "data.json")
if os.path.isfile(candidate):
reference_file = candidate
else:
return False, f"Could not locate data.json under reference directory: {reference_dir}"
except Exception as e: # pylint: disable=broad-except
return False, f"Error locating reference data file: {e}"
return compare_trajectory_sequences(generated_file, reference_file)
def compare_navigation_and_images(
generated_seq_dir: str,
reference_seq_dir: str,
generated_root_for_images: Optional[str] = None,
reference_root_for_images: Optional[str] = None,
rgb_abs_tolerance: int = 0,
depth_abs_tolerance: float = 0.0,
allowed_rgb_diff_ratio: float = 0.0,
allowed_depth_diff_ratio: float = 0.5,
depth_scale_auto: bool = False,
fail_if_images_missing: bool = False,
) -> Tuple[bool, str]:
"""Wrapper that preserves original JSON comparison while optionally adding first-frame image comparison.
Args:
generated_seq_dir: Path to generated seq_path root used by original comparator.
reference_seq_dir: Path to reference seq_path root.
generated_root_for_images: Root (parent of obs_path) or the obs_path itself for generated images.
reference_root_for_images: Same as above for reference. If None, image comparison may be skipped.
rgb_abs_tolerance: RGB absolute per-channel tolerance.
depth_abs_tolerance: Depth absolute tolerance.
allowed_rgb_diff_ratio: Allowed differing RGB pixel ratio.
allowed_depth_diff_ratio: Allowed differing depth pixel ratio.
depth_scale_auto: Auto scale depth if uint16 millimeters.
fail_if_images_missing: If True, treat missing obs_path as failure; otherwise skip.
Returns:
(success, message) combined result.
"""
traj_ok, traj_msg = compare_navigation_results(generated_seq_dir, reference_seq_dir)
# Determine image roots; default to parent of seq_dir if not explicitly provided
gen_img_root = generated_root_for_images or os.path.dirname(generated_seq_dir.rstrip(os.sep))
ref_img_root = reference_root_for_images or os.path.dirname(reference_seq_dir.rstrip(os.sep))
img_ok = True
img_msg = "image comparison skipped"
if generated_root_for_images is not None or reference_root_for_images is not None:
# User explicitly passed at least one root -> attempt compare
img_ok, img_msg = compare_first_frame_images(
generated_root=gen_img_root,
reference_root=ref_img_root,
rgb_abs_tolerance=rgb_abs_tolerance,
depth_abs_tolerance=depth_abs_tolerance,
allowed_rgb_diff_ratio=allowed_rgb_diff_ratio,
allowed_depth_diff_ratio=allowed_depth_diff_ratio,
depth_scale_auto=depth_scale_auto,
)
else:
# Implicit attempt only if both obs_path exist under parent paths
gen_obs_candidate = os.path.join(gen_img_root, "obs_path")
ref_obs_candidate = os.path.join(ref_img_root, "obs_path")
if os.path.isdir(gen_obs_candidate) and os.path.isdir(ref_obs_candidate):
img_ok, img_msg = compare_first_frame_images(
generated_root=gen_img_root,
reference_root=ref_img_root,
rgb_abs_tolerance=rgb_abs_tolerance,
depth_abs_tolerance=depth_abs_tolerance,
allowed_rgb_diff_ratio=allowed_rgb_diff_ratio,
allowed_depth_diff_ratio=allowed_depth_diff_ratio,
depth_scale_auto=depth_scale_auto,
)
else:
if fail_if_images_missing:
missing = []
if not os.path.isdir(gen_obs_candidate):
missing.append(f"generated:{gen_obs_candidate}")
if not os.path.isdir(ref_obs_candidate):
missing.append(f"reference:{ref_obs_candidate}")
img_ok = False
img_msg = "obs_path missing -> " + ", ".join(missing)
else:
img_msg = "obs_path not found in one or both roots; skipped"
overall = traj_ok and img_ok
message = f"trajectory: {traj_msg}; images: {img_msg}"
return overall, message if overall else f"Mismatch - {message}"
def compare_trajectory_sequences(generated_file: str, reference_file: str, tolerance: float = 1e-6) -> Tuple[bool, str]:
"""
Compare trajectory sequence files with numerical tolerance.
Args:
generated_file: Path to generated trajectory file
reference_file: Path to reference trajectory file
tolerance: Numerical tolerance for floating point comparisons
Returns:
Tuple[bool, str]: (success, message)
"""
try:
# Check if files exist
if not os.path.exists(generated_file):
return False, f"Generated file does not exist: {generated_file}"
if not os.path.exists(reference_file):
return False, f"Reference file does not exist: {reference_file}"
# Load JSON files
with open(generated_file, 'r') as f:
generated_data = json.load(f)
with open(reference_file, 'r') as f:
reference_data = json.load(f)
# Compare the JSON structures
success, message = _compare_data_structures(generated_data, reference_data, tolerance)
if success:
return True, "Trajectory sequences match within tolerance"
else:
return False, f"Trajectory sequences differ: {message}"
except json.JSONDecodeError as e:
return False, f"JSON decode error: {e}"
except Exception as e:
return False, f"Error comparing trajectory sequences: {e}"
def _compare_data_structures(data1: Any, data2: Any, tolerance: float, path: str = "") -> Tuple[bool, str]:
"""
Recursively compare two data structures with numerical tolerance.
Args:
data1: First data structure
data2: Second data structure
tolerance: Numerical tolerance for floating point comparisons
path: Current path in the data structure for error reporting
Returns:
Tuple[bool, str]: (success, error_message)
"""
# Check if types are the same
if type(data1) != type(data2):
return False, f"Type mismatch at {path}: {type(data1)} vs {type(data2)}"
# Handle dictionaries
if isinstance(data1, dict):
if set(data1.keys()) != set(data2.keys()):
return False, f"Key mismatch at {path}: {set(data1.keys())} vs {set(data2.keys())}"
for key in data1.keys():
new_path = f"{path}.{key}" if path else key
success, message = _compare_data_structures(data1[key], data2[key], tolerance, new_path)
if not success:
return False, message
# Handle lists
elif isinstance(data1, list):
if len(data1) != len(data2):
return False, f"List length mismatch at {path}: {len(data1)} vs {len(data2)}"
for i, (item1, item2) in enumerate(zip(data1, data2)):
new_path = f"{path}[{i}]" if path else f"[{i}]"
success, message = _compare_data_structures(item1, item2, tolerance, new_path)
if not success:
return False, message
# Handle numerical values
elif isinstance(data1, (int, float)):
if isinstance(data2, (int, float)):
if abs(data1 - data2) > tolerance:
return (
False,
f"Numerical difference at {path}: {data1} vs {data2} (diff: {abs(data1 - data2)}, tolerance: {tolerance})",
)
else:
return False, f"Type mismatch at {path}: number vs {type(data2)}"
# Handle strings and other exact comparison types
elif isinstance(data1, (str, bool, type(None))):
if data1 != data2:
return False, f"Value mismatch at {path}: {data1} vs {data2}"
# Handle unknown types
else:
if data1 != data2:
return False, f"Value mismatch at {path}: {data1} vs {data2}"
return True, ""
def _locate_first_data_json(root: str) -> Optional[str]:
"""Locate a data.json file under root with a shallow, deterministic strategy.
Strategy (stop at first match to keep behavior predictable & lightweight):
1. If root/data.json exists -> return it.
2. Enumerate immediate subdirectories (sorted). For each d:
- if d/data.json exists -> return it.
3. Enumerate immediate subdirectories again; for each d enumerate its subdirectories (sorted) and
look for d/sub/data.json -> return first match.
4. If none found -> return None.
"""
# 1. root/data.json
candidate = os.path.join(root, "data.json")
if os.path.isfile(candidate):
return candidate
try:
first_level = [d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))]
except FileNotFoundError:
return None
first_level.sort()
# 2. d/data.json
for d in first_level:
c = os.path.join(root, d, "data.json")
if os.path.isfile(c):
return c
# 3. d/sub/data.json
for d in first_level:
d_path = os.path.join(root, d)
try:
second_level = [s for s in os.listdir(d_path) if os.path.isdir(os.path.join(d_path, s))]
except FileNotFoundError:
continue
second_level.sort()
for s in second_level:
c = os.path.join(d_path, s, "data.json")
if os.path.isfile(c):
return c
return None
def compare_first_frame_images(
generated_root: str,
reference_root: str,
rgb_dir_name: str = "rgb",
depth_dir_name: str = "depth",
scene_dir: Optional[str] = None,
traj_dir: Optional[str] = None,
rgb_abs_tolerance: int = 0,
depth_abs_tolerance: float = 0.0,
allowed_rgb_diff_ratio: float = 0.0,
allowed_depth_diff_ratio: float = 0.0,
compute_psnr: bool = True,
compute_mse: bool = True,
depth_scale_auto: bool = False,
) -> Tuple[bool, str]:
"""Compare only the first frame (index 0) of RGB & depth images between generated and reference.
This is a lightweight check to validate pipeline correctness without scanning all frames.
Args:
generated_root: Path to generated run root (may contain `obs_path` or be the `obs_path`).
reference_root: Path to reference run root (same structure as generated_root).
rgb_dir_name: Subdirectory name for RGB frames under a trajectory directory.
depth_dir_name: Subdirectory name for depth frames under a trajectory directory.
scene_dir: Optional explicit scene directory name (e.g. "6f"); if None will auto-pick first.
traj_dir: Optional explicit trajectory directory (e.g. "0"); if None will auto-pick first.
rgb_abs_tolerance: Per-channel absolute pixel tolerance (0 requires exact match).
depth_abs_tolerance: Absolute tolerance for depth value differences (after optional scaling).
allowed_rgb_diff_ratio: Max allowed ratio of differing RGB pixels (0.01 -> 1%).
allowed_depth_diff_ratio: Max allowed ratio of differing depth pixels beyond tolerance.
compute_psnr: Whether to compute PSNR metric for reporting.
compute_mse: Whether to compute MSE metric for reporting.
depth_scale_auto: If True, attempt simple heuristic scaling for uint16 depth (divide by 1000 if max > 10000).
Returns:
(success, message) summary of comparison.
"""
try:
gen_obs = _resolve_obs_path(generated_root)
ref_obs = _resolve_obs_path(reference_root)
if gen_obs is None:
return False, f"Cannot locate obs_path under generated root: {generated_root}"
if ref_obs is None:
return False, f"Cannot locate obs_path under reference root: {reference_root}"
scene_dir = scene_dir or _pick_first_subdir(gen_obs)
if scene_dir is None:
return False, f"No scene directory found in {gen_obs}"
ref_scene_dir = scene_dir if os.path.isdir(os.path.join(ref_obs, scene_dir)) else _pick_first_subdir(ref_obs)
if ref_scene_dir is None:
return False, f"No matching scene directory in reference: {ref_obs}"
gen_scene_path = os.path.join(gen_obs, scene_dir)
ref_scene_path = os.path.join(ref_obs, ref_scene_dir)
traj_dir = traj_dir or _pick_first_subdir(gen_scene_path)
if traj_dir is None:
return False, f"No trajectory directory in {gen_scene_path}"
ref_traj_dir = (
traj_dir if os.path.isdir(os.path.join(ref_scene_path, traj_dir)) else _pick_first_subdir(ref_scene_path)
)
if ref_traj_dir is None:
return False, f"No trajectory directory in reference scene path {ref_scene_path}"
gen_traj_path = os.path.join(gen_scene_path, traj_dir)
ref_traj_path = os.path.join(ref_scene_path, ref_traj_dir)
# RGB comparison
rgb_result, rgb_msg = _compare_single_frame_rgb(
gen_traj_path,
ref_traj_path,
rgb_dir_name,
rgb_abs_tolerance,
allowed_rgb_diff_ratio,
compute_psnr,
compute_mse,
)
# Depth comparison (optional if depth folder exists)
depth_result, depth_msg = _compare_single_frame_depth(
gen_traj_path,
ref_traj_path,
depth_dir_name,
depth_abs_tolerance,
allowed_depth_diff_ratio,
compute_psnr,
compute_mse,
depth_scale_auto,
)
success = rgb_result and depth_result
combined_msg = f"RGB: {rgb_msg}; Depth: {depth_msg}"
return success, ("Images match - " + combined_msg) if success else ("Image mismatch - " + combined_msg)
except Exception as e: # pylint: disable=broad-except
return False, f"Error during first-frame image comparison: {e}"
def _resolve_obs_path(root: str) -> Optional[str]:
"""Return the obs_path directory. Accept either the root itself or its child."""
if not os.path.isdir(root):
return None
if os.path.basename(root) == "obs_path":
return root
candidate = os.path.join(root, "obs_path")
return candidate if os.path.isdir(candidate) else None
def _pick_first_subdir(parent: str) -> Optional[str]:
"""Pick the first alphanumerically sorted subdirectory name under parent."""
try:
subs = [d for d in os.listdir(parent) if os.path.isdir(os.path.join(parent, d))]
if not subs:
return None
subs.sort()
return subs[0]
except FileNotFoundError:
return None
def _find_first_frame_file(folder: str, exts: Tuple[str, ...]) -> Optional[str]:
"""Find the smallest numeral file with one of extensions; returns absolute path."""
if not os.path.isdir(folder):
return None
candidates = []
for f in os.listdir(folder):
lower = f.lower()
for e in exts:
if lower.endswith(e):
num_part = os.path.splitext(f)[0]
if num_part.isdigit():
candidates.append((int(num_part), f))
elif f.startswith("0"): # fallback for names like 0.jpg
candidates.append((0, f))
break
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
return os.path.join(folder, candidates[0][1])
def _compare_single_frame_rgb(
gen_traj_path: str,
ref_traj_path: str,
rgb_dir_name: str,
abs_tol: int,
allowed_ratio: float,
compute_psnr: bool,
compute_mse: bool,
) -> Tuple[bool, str]:
rgb_gen_dir = os.path.join(gen_traj_path, rgb_dir_name)
rgb_ref_dir = os.path.join(ref_traj_path, rgb_dir_name)
if not os.path.isdir(rgb_gen_dir) or not os.path.isdir(rgb_ref_dir):
return (
False,
f"RGB directory missing (generated: {os.path.isdir(rgb_gen_dir)}, reference: {os.path.isdir(rgb_ref_dir)})",
)
gen_file = _find_first_frame_file(rgb_gen_dir, (".jpg", ".png", ".jpeg"))
ref_file = _find_first_frame_file(rgb_ref_dir, (".jpg", ".png", ".jpeg"))
if not gen_file or not ref_file:
return False, "First RGB frame file not found in one of the directories"
gen_img = cv2.imread(gen_file, cv2.IMREAD_COLOR)
ref_img = cv2.imread(ref_file, cv2.IMREAD_COLOR)
if gen_img is None or ref_img is None:
return False, "Failed to read RGB images"
if gen_img.shape != ref_img.shape:
return False, f"RGB shape mismatch {gen_img.shape} vs {ref_img.shape}"
diff = np.abs(gen_img.astype(np.int16) - ref_img.astype(np.int16))
diff_mask = np.any(diff > abs_tol, axis=2)
diff_ratio = float(diff_mask.sum()) / diff_mask.size
metrics_parts = [f"diff_pixels_ratio={diff_ratio:.4f}"]
flag = False
if compute_mse or compute_psnr:
mse = float((diff**2).mean())
if compute_mse:
metrics_parts.append(f"mse={mse:.2f}")
if compute_psnr:
if mse == 0.0:
psnr = float('inf')
flag = True
else:
psnr = 10.0 * math.log10((255.0**2) / mse)
if math.isinf(psnr):
metrics_parts.append("psnr=inf")
flag = True
else:
metrics_parts.append(f"psnr={psnr:.2f}dB")
if psnr >= 40.0:
flag = True
passed = diff_ratio <= allowed_ratio or flag
status = "OK" if passed else "FAIL"
return passed, f"{status} (abs_tol={abs_tol}, allowed_ratio={allowed_ratio}, {' '.join(metrics_parts)})"
def _compare_single_frame_depth(
gen_traj_path: str,
ref_traj_path: str,
depth_dir_name: str,
abs_tol: float,
allowed_ratio: float,
compute_psnr: bool,
compute_mse: bool,
auto_scale: bool,
) -> Tuple[bool, str]:
depth_gen_dir = os.path.join(gen_traj_path, depth_dir_name)
depth_ref_dir = os.path.join(ref_traj_path, depth_dir_name)
if not os.path.isdir(depth_gen_dir) or not os.path.isdir(depth_ref_dir):
return (
False,
f"Depth directory missing (generated: {os.path.isdir(depth_gen_dir)}, reference: {os.path.isdir(depth_ref_dir)})",
)
gen_file = _find_first_frame_file(depth_gen_dir, (".png", ".exr"))
ref_file = _find_first_frame_file(depth_ref_dir, (".png", ".exr"))
if not gen_file or not ref_file:
return False, "First depth frame file not found in one of the directories"
gen_img = cv2.imread(gen_file, cv2.IMREAD_UNCHANGED)
ref_img = cv2.imread(ref_file, cv2.IMREAD_UNCHANGED)
if gen_img is None or ref_img is None:
return False, "Failed to read depth images"
if gen_img.shape != ref_img.shape:
return False, f"Depth shape mismatch {gen_img.shape} vs {ref_img.shape}"
gen_depth = _prepare_depth_array(gen_img, auto_scale)
ref_depth = _prepare_depth_array(ref_img, auto_scale)
if gen_depth.shape != ref_depth.shape:
return False, f"Depth array shape mismatch {gen_depth.shape} vs {ref_depth.shape}"
diff = np.abs(gen_depth - ref_depth)
diff_mask = diff > abs_tol
diff_ratio = float(diff_mask.sum()) / diff_mask.size
metrics_parts = [f"diff_pixels_ratio={diff_ratio:.4f}"]
if compute_mse or compute_psnr:
mse = float((diff**2).mean())
if compute_mse:
metrics_parts.append(f"mse={mse:.6f}")
if compute_psnr:
# Estimate dynamic range from reference depth
dr = float(ref_depth.max() - ref_depth.min()) or 1.0
if mse == 0.0:
psnr = float('inf')
else:
psnr = 10.0 * math.log10((dr**2) / mse)
metrics_parts.append("psnr=inf" if math.isinf(psnr) else f"psnr={psnr:.2f}dB")
passed = diff_ratio <= allowed_ratio
status = "OK" if passed else "FAIL"
return passed, f"{status} (abs_tol={abs_tol}, allowed_ratio={allowed_ratio}, {' '.join(metrics_parts)})"
def _prepare_depth_array(arr: np.ndarray, auto_scale: bool) -> np.ndarray:
"""Convert raw depth image to float32 array; apply simple heuristic scaling if requested."""
if arr.dtype == np.uint16:
depth = arr.astype(np.float32)
if auto_scale and depth.max() > 10000: # likely millimeters
depth /= 1000.0
return depth
if arr.dtype == np.float32:
return arr
# Fallback: convert to float
return arr.astype(np.float32)