Source: src/mcss/dense_geometry_certificates.py · Plain text

src/mcss/dense_geometry_certificates.py

"""Known-camera consistency certificates for dense supplier depth."""

from __future__ import annotations

import hashlib
import json
import os
import shutil
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Self

import numpy as np

from mcss.geometry_supplier import GeometrySupplierResult

DENSE_CERTIFICATE_CACHE_SCHEMA_VERSION = 1


@dataclass(frozen=True)
class DenseCertificateConfig:
    grid_stride: int = 4
    min_depth_m: float = 0.10
    max_depth_m: float = 20.0
    depth_absolute_tolerance_m: float = 0.10
    depth_relative_tolerance: float = 0.05
    roundtrip_threshold_px: float = 2.0
    surface_half_width_m: float = 0.05
    free_space_margin_m: float = 0.05
    minimum_supporting_views: int = 1
    maximum_conflicting_views: int = 0

    def __post_init__(self) -> None:
        if self.grid_stride < 1:
            raise ValueError("grid_stride must be positive")
        if not 0 < self.min_depth_m < self.max_depth_m:
            raise ValueError("depth range must be positive and increasing")
        values = (
            self.depth_absolute_tolerance_m,
            self.depth_relative_tolerance,
            self.roundtrip_threshold_px,
            self.surface_half_width_m,
            self.free_space_margin_m,
        )
        if min(values) < 0:
            raise ValueError("certificate tolerances must be nonnegative")
        if self.minimum_supporting_views < 1 or self.maximum_conflicting_views < 0:
            raise ValueError("support/conflict counts are invalid")


@dataclass
class DenseGeometryCertificates:
    frame_ids: tuple[int, ...]
    pixel_xy: np.ndarray
    depth_along_ray: np.ndarray
    world_points: np.ndarray
    raw_confidence: np.ndarray
    raw_valid: np.ndarray
    certified: np.ndarray
    support_count: np.ndarray
    conflict_count: np.ndarray
    occlusion_count: np.ndarray
    provenance: np.ndarray
    free_end_m: np.ndarray
    surface_near_m: np.ndarray
    surface_far_m: np.ndarray
    risk: np.ndarray

    def __post_init__(self) -> None:
        scalar_shape = self.depth_along_ray.shape
        if len(scalar_shape) != 3 or scalar_shape[0] != len(self.frame_ids):
            raise ValueError("dense certificate scalar fields require shape [V, Gh, Gw]")
        scalar_fields = (
            self.raw_confidence,
            self.raw_valid,
            self.certified,
            self.support_count,
            self.conflict_count,
            self.occlusion_count,
            self.free_end_m,
            self.surface_near_m,
            self.surface_far_m,
            self.risk,
        )
        if any(value.shape != scalar_shape for value in scalar_fields):
            raise ValueError("dense certificate scalar field shapes must match")
        if self.pixel_xy.shape != (*scalar_shape, 2):
            raise ValueError("pixel_xy must have shape [V, Gh, Gw, 2]")
        if self.world_points.shape != (*scalar_shape, 3):
            raise ValueError("world_points must have shape [V, Gh, Gw, 3]")
        if self.provenance.shape != (*scalar_shape, len(self.frame_ids)):
            raise ValueError("provenance must have one trailing source-view dimension")
        for value in (
            self.pixel_xy,
            self.depth_along_ray,
            self.world_points,
            self.raw_confidence,
            self.free_end_m,
            self.surface_near_m,
            self.surface_far_m,
            self.risk,
        ):
            if not np.isfinite(value).all():
                raise ValueError("dense certificate numeric fields must be finite")
        if np.any((self.risk < 0) | (self.risk > 1)):
            raise ValueError("risk must be in [0, 1]")

    def canonical(self) -> Self:
        order = np.argsort(np.asarray(self.frame_ids), kind="stable")
        if np.array_equal(order, np.arange(len(order))):
            return self
        provenance = self.provenance[order][..., order]
        values = {
            name: getattr(self, name)[order]
            for name in (
                "pixel_xy",
                "depth_along_ray",
                "world_points",
                "raw_confidence",
                "raw_valid",
                "certified",
                "support_count",
                "conflict_count",
                "occlusion_count",
                "free_end_m",
                "surface_near_m",
                "surface_far_m",
                "risk",
            )
        }
        return type(self)(
            tuple(self.frame_ids[int(index)] for index in order),
            provenance=provenance,
            **values,
        )


def build_dense_certificates(
    supplier: GeometrySupplierResult, config: DenseCertificateConfig
) -> DenseGeometryCertificates:
    supplier = supplier.canonical()
    view_count, height, width = supplier.depth_along_ray.shape
    y_values = np.arange(0, height, config.grid_stride, dtype=np.int64)
    x_values = np.arange(0, width, config.grid_stride, dtype=np.int64)
    grid_y, grid_x = np.meshgrid(y_values, x_values, indexing="ij")
    grid_shape = grid_y.shape
    pixel_grid = np.stack((grid_x, grid_y), axis=-1).astype(np.float32)
    pixel_xy = np.broadcast_to(pixel_grid, (view_count, *grid_shape, 2)).copy()

    depth = supplier.depth_along_ray[:, grid_y, grid_x]
    confidence = supplier.confidence[:, grid_y, grid_x]
    raw_mask = supplier.valid_mask[:, grid_y, grid_x]
    raw_valid = (
        raw_mask
        & np.isfinite(depth)
        & (depth >= config.min_depth_m)
        & (depth <= config.max_depth_m)
    )
    world_points = np.zeros((view_count, *grid_shape, 3), dtype=np.float32)
    directions = []
    for view in range(view_count):
        camera_directions = _camera_directions(pixel_grid, supplier.intrinsics[view])
        directions.append(camera_directions)
        world_directions = camera_directions @ supplier.c2w[view, :3, :3].T
        world_points[view] = (
            supplier.c2w[view, :3, 3]
            + world_directions * depth[view][..., None]
        )

    support = np.zeros((view_count, *grid_shape), dtype=np.int16)
    conflict = np.zeros_like(support)
    occlusion = np.zeros_like(support)
    provenance = np.zeros((view_count, *grid_shape, view_count), dtype=np.bool_)
    residual_sum = np.zeros((view_count, *grid_shape), dtype=np.float32)
    residual_count = np.zeros_like(support)
    for source in range(view_count):
        provenance[source, ..., source] = raw_valid[source]
        for target in range(view_count):
            if target == source:
                continue
            projected_xy, projected_distance, in_front = _project_world(
                world_points[source], supplier.intrinsics[target], supplier.c2w[target]
            )
            inside = (
                in_front
                & (projected_xy[..., 0] >= 0)
                & (projected_xy[..., 0] <= width - 1)
                & (projected_xy[..., 1] >= 0)
                & (projected_xy[..., 1] <= height - 1)
            )
            target_depth, target_valid = _sample_nearest(
                supplier.depth_along_ray[target],
                supplier.valid_mask[target],
                projected_xy,
            )
            comparable = (
                raw_valid[source]
                & inside
                & target_valid
                & np.isfinite(target_depth)
                & (target_depth >= config.min_depth_m)
                & (target_depth <= config.max_depth_m)
            )
            tolerance = (
                config.depth_absolute_tolerance_m
                + config.depth_relative_tolerance * projected_distance
            )
            difference = target_depth - projected_distance
            target_world = _world_from_pixels(
                projected_xy, target_depth, supplier.intrinsics[target], supplier.c2w[target]
            )
            roundtrip_xy, _, roundtrip_front = _project_world(
                target_world, supplier.intrinsics[source], supplier.c2w[source]
            )
            roundtrip_error = np.linalg.norm(roundtrip_xy - pixel_grid, axis=-1)
            agrees = (
                comparable
                & (np.abs(difference) <= tolerance)
                & roundtrip_front
                & (roundtrip_error <= config.roundtrip_threshold_px)
            )
            is_occlusion = comparable & (difference < -tolerance)
            is_conflict = comparable & ~agrees & ~is_occlusion
            support[source] += agrees.astype(np.int16)
            occlusion[source] += is_occlusion.astype(np.int16)
            conflict[source] += is_conflict.astype(np.int16)
            provenance[source, ..., target] = agrees
            normalized_residual = np.minimum(
                1.0,
                np.abs(difference) / np.maximum(tolerance, 1e-8),
            )
            residual_sum[source] += np.where(comparable, normalized_residual, 0.0)
            residual_count[source] += comparable.astype(np.int16)

    certified = (
        raw_valid
        & (support >= config.minimum_supporting_views)
        & (conflict <= config.maximum_conflicting_views)
    )
    free_end = np.where(certified, np.maximum(0.0, depth - config.free_space_margin_m), 0.0)
    surface_near = np.where(certified, np.maximum(0.0, depth - config.surface_half_width_m), 0.0)
    surface_far = np.where(certified, depth + config.surface_half_width_m, 0.0)
    possible_support = max(1, view_count - 1)
    support_deficit = 1.0 - np.minimum(support, possible_support) / possible_support
    conflict_rate = conflict / possible_support
    residual = residual_sum / np.maximum(residual_count, 1)
    confidence_uncertainty = 1.0 / (1.0 + np.maximum(confidence, 0.0))
    risk = np.clip(
        0.35 * support_deficit
        + 0.30 * residual
        + 0.20 * conflict_rate
        + 0.15 * confidence_uncertainty,
        0.0,
        1.0,
    ).astype(np.float32)

    return DenseGeometryCertificates(
        frame_ids=supplier.frame_ids,
        pixel_xy=pixel_xy,
        depth_along_ray=depth.astype(np.float32),
        world_points=world_points,
        raw_confidence=confidence.astype(np.float32),
        raw_valid=raw_valid,
        certified=certified,
        support_count=support,
        conflict_count=conflict,
        occlusion_count=occlusion,
        provenance=provenance,
        free_end_m=free_end.astype(np.float32),
        surface_near_m=surface_near.astype(np.float32),
        surface_far_m=surface_far.astype(np.float32),
        risk=risk,
    )


def write_dense_certificate_cache(
    destination: str | Path,
    certificates: DenseGeometryCertificates,
    config: DenseCertificateConfig,
    *,
    supplier_arrays_sha256: str,
) -> Path:
    """Seal dense certificates in an overwrite-refusing JSON+NPZ directory."""

    target = Path(destination).resolve()
    if target.exists():
        raise FileExistsError(f"refusing to overwrite dense certificate cache: {target}")
    if not _is_sha256(supplier_arrays_sha256):
        raise ValueError("supplier_arrays_sha256 must be a lowercase SHA-256 digest")
    target.parent.mkdir(parents=True, exist_ok=True)
    temporary = Path(
        tempfile.mkdtemp(prefix=f".{target.name}.tmp-", dir=str(target.parent))
    )
    canonical = certificates.canonical()
    try:
        arrays_path = temporary / "arrays.npz"
        arrays = _certificate_arrays(canonical)
        np.savez_compressed(arrays_path, **arrays)
        metadata = {
            "schema_version": DENSE_CERTIFICATE_CACHE_SCHEMA_VERSION,
            "frame_ids": list(canonical.frame_ids),
            "config": asdict(config),
            "supplier_arrays_sha256": supplier_arrays_sha256,
            "arrays_file": arrays_path.name,
            "arrays_sha256": _sha256_file(arrays_path),
            "arrays": {
                name: {"shape": list(value.shape), "dtype": str(value.dtype)}
                for name, value in arrays.items()
            },
        }
        (temporary / "metadata.json").write_text(
            json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8"
        )
        os.replace(temporary, target)
    except BaseException:
        shutil.rmtree(temporary, ignore_errors=True)
        raise
    return target


def read_dense_certificate_cache(
    source: str | Path,
) -> tuple[DenseGeometryCertificates, dict[str, Any]]:
    root = Path(source).resolve()
    metadata_path = root / "metadata.json"
    if not metadata_path.is_file():
        raise FileNotFoundError(metadata_path)
    metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
    if (
        not isinstance(metadata, dict)
        or metadata.get("schema_version") != DENSE_CERTIFICATE_CACHE_SCHEMA_VERSION
    ):
        raise ValueError("dense certificate cache metadata has an unsupported schema")
    arrays_path = root / str(metadata.get("arrays_file", ""))
    if not arrays_path.is_file():
        raise FileNotFoundError(arrays_path)
    if _sha256_file(arrays_path) != metadata.get("arrays_sha256"):
        raise ValueError("dense certificate cache arrays hash does not match metadata")
    with np.load(arrays_path, allow_pickle=False) as values:
        required = set(_certificate_array_names()) | {"frame_ids"}
        if set(values.files) != required:
            raise ValueError("dense certificate cache array keys do not match schema")
        frame_ids = tuple(int(value) for value in values["frame_ids"].tolist())
        certificates = DenseGeometryCertificates(
            frame_ids=frame_ids,
            **{name: values[name].copy() for name in _certificate_array_names()},
        )
    if list(frame_ids) != metadata.get("frame_ids"):
        raise ValueError("dense certificate metadata frame IDs do not match arrays")
    actual_schema = {
        name: {"shape": list(value.shape), "dtype": str(value.dtype)}
        for name, value in _certificate_arrays(certificates).items()
    }
    if actual_schema != metadata.get("arrays"):
        raise ValueError("dense certificate metadata array schema does not match arrays")
    return certificates, metadata


def _camera_directions(pixel_xy: np.ndarray, intrinsics: np.ndarray) -> np.ndarray:
    homogeneous = np.concatenate(
        (pixel_xy, np.ones((*pixel_xy.shape[:-1], 1), dtype=np.float32)), axis=-1
    )
    directions = homogeneous @ np.linalg.inv(intrinsics).T
    return directions / np.maximum(np.linalg.norm(directions, axis=-1, keepdims=True), 1e-8)


def _world_from_pixels(
    pixel_xy: np.ndarray, depth: np.ndarray, intrinsics: np.ndarray, c2w: np.ndarray
) -> np.ndarray:
    camera_direction = _camera_directions(pixel_xy, intrinsics)
    world_direction = camera_direction @ c2w[:3, :3].T
    return c2w[:3, 3] + world_direction * depth[..., None]


def _project_world(
    world: np.ndarray, intrinsics: np.ndarray, c2w: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    rotation = c2w[:3, :3]
    camera = (world - c2w[:3, 3]) @ rotation
    projected = camera @ intrinsics.T
    z = projected[..., 2]
    xy = projected[..., :2] / np.maximum(z[..., None], 1e-8)
    distance = np.linalg.norm(camera, axis=-1)
    return xy.astype(np.float32), distance.astype(np.float32), camera[..., 2] > 1e-8


def _sample_nearest(
    depth: np.ndarray, mask: np.ndarray, pixel_xy: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
    height, width = depth.shape
    x = np.clip(np.rint(pixel_xy[..., 0]).astype(np.int64), 0, width - 1)
    y = np.clip(np.rint(pixel_xy[..., 1]).astype(np.int64), 0, height - 1)
    return depth[y, x], mask[y, x]


def _certificate_array_names() -> tuple[str, ...]:
    return (
        "pixel_xy",
        "depth_along_ray",
        "world_points",
        "raw_confidence",
        "raw_valid",
        "certified",
        "support_count",
        "conflict_count",
        "occlusion_count",
        "provenance",
        "free_end_m",
        "surface_near_m",
        "surface_far_m",
        "risk",
    )


def _certificate_arrays(certificates: DenseGeometryCertificates) -> dict[str, np.ndarray]:
    result = {
        name: np.asarray(getattr(certificates, name)) for name in _certificate_array_names()
    }
    result["frame_ids"] = np.asarray(certificates.frame_ids, dtype=np.int64)
    return result


def _sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def _is_sha256(value: str) -> bool:
    return len(value) == 64 and all(character in "0123456789abcdef" for character in value)