# Contract: ransac_filter

**Component**: shared_helpers / `helpers.ransac_filter` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-282 — `_docs/02_tasks/todo/AZ-282_ransac_filter.md`
**Consumer tasks**: every C2.5 task that runs RANSAC over single-pair LightGlue matches; every C3 task that runs RANSAC over 2D-2D correspondences for the per-candidate inlier count; every C3.5 task that recomputes residual after AdHoP refinement; every C4 task that computes the per-frame final reprojection residual for FDR provenance
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10

## Purpose

Thin, deterministic wrapper around OpenCV's RANSAC + reprojection-residual computation. Keeps the four call sites (C2.5, C3, C3.5, C4) on one canonical inlier-filtering algorithm and one canonical residual definition (median pixel residual). Per `_docs/02_document/common-helpers/07_helper_ransac_filter.md`.

## Shape

### For function / method APIs

```python
class RansacFilter:
    @staticmethod
    def filter_correspondences(
        correspondences: np.ndarray,             # shape (N, 4): [x_a, y_a, x_b, y_b]
        ransac_threshold_px: float,
        min_inliers: int,
    ) -> RansacResult: ...

    @staticmethod
    def compute_reprojection_residual(
        correspondences: np.ndarray,             # shape (I, 4): inlier set
        K: np.ndarray,                            # shape (3, 3): camera intrinsics
        distortion: np.ndarray,                   # shape (5,) or (8,): OpenCV distortion model
        pose: SE3,
    ) -> float: ...
```

`RansacResult` is a frozen dataclass:

```python
@dataclass(frozen=True)
class RansacResult:
    inlier_correspondences: np.ndarray   # shape (I, 4)
    inlier_count: int                    # I
    outlier_count: int                   # N - I
    median_residual_px: float
```

| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `filter_correspondences` | `(correspondences, ransac_threshold_px, min_inliers) -> RansacResult` | `RansacFilterError` if `correspondences` shape != `(N, 4)`, `ransac_threshold_px <= 0`, `min_inliers < 0`, or `N < 4` (RANSAC needs ≥4 points for homography) | sync, CPU |
| `compute_reprojection_residual` | `(correspondences, K, distortion, pose) -> float` | `RansacFilterError` on shape / dtype mismatch (correspondences must be `(I, 4)`, `K` must be `(3, 3)`, distortion must be `(5,)` or `(8,)`); returns `NaN` if `I == 0` | sync, CPU |

`SE3` is the type alias from `helpers.se3_utils` (re-exported GTSAM `Pose3`). All numpy arrays use `dtype=float64`.

## Invariants

- **Stateless**: no module-level state; static methods only. Stateless static-only design satisfies `coderule.mdc` ("only use static methods for pure self-contained computations").
- **Deterministic given fixed seed**: `cv2.findHomography(..., cv2.RANSAC)` is non-deterministic by default. The helper sets `cv2.setRNGSeed(0)` (or uses the explicit `seed` kwarg where the OpenCV API supports it) so the same input correspondences always produce the same `RansacResult`. Deterministic behaviour is part of the contract.
- **Median residual semantics**: `compute_reprojection_residual` returns the MEDIAN reprojection residual in pixels (NOT the mean — outliers in the 2D residual distribution should not bias the consumer's quality signal). Returns `NaN` if `correspondences.shape[0] == 0`.
- **OpenCV-internal RANSAC ownership note**: for C4's `solvePnPRansac` (2D-3D RANSAC), OpenCV does its own internal RANSAC. THIS helper's `filter_correspondences` is for the standalone 2D-2D case (C3, C2.5, C3.5). C4 uses ONLY `compute_reprojection_residual` from this helper.
- **Min-inliers semantics**: `min_inliers` is informational — `RansacResult.inlier_count` may be less than `min_inliers`. The helper does NOT raise when the count falls short; the consumer decides whether to proceed (`InsufficientInliersError` etc. live in the consuming components).
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, `helpers.se3_utils` (allowed — same Layer 1), `cv2`, `numpy`, and stdlib. No `gps_denied_onboard.components.*` imports.

## Non-Goals

- 2D-3D RANSAC inside `solvePnPRansac` — OpenCV does it internally; this helper does not wrap it.
- Per-component RANSAC threshold defaults — they are documented per-component in C2.5, C3, C3.5, C4 specs. This helper takes the threshold as a parameter; defaults belong to the consumers.
- Adaptive RANSAC (PROSAC, USAC) — out of scope for v1.0.0.
- GPU-accelerated RANSAC — out of scope for v1.0.0.
- Confidence / iteration-count tuning of the underlying `cv2.findHomography` call — exposed only via the `ransac_threshold_px` parameter; if a future consumer needs to tune iterations, that's a minor-version contract addition.

## Versioning Rules

- **Breaking changes** (function renamed/removed, signature changed, return shape changed, residual statistic changed from median to mean) require a new major version + a deprecation pass through C2.5, C3, C3.5, C4.
- **Non-breaking additions** (new optional kwarg with safe default, new accessor on `RansacResult`) require a minor version bump.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-clean-correspondences | 100 perfect homography correspondences | `inlier_count == 100`, `outlier_count == 0`, `median_residual_px ≈ 0.0` | Round-trip happy path |
| valid-mixed | 80 inliers + 20 outlier correspondences with threshold 1.5 px | `inlier_count` ∈ `[78, 82]` (RANSAC noise tolerance), `outlier_count == 100 - inlier_count` | Mixed-quality input |
| valid-determinism | same input run twice through `filter_correspondences` | byte-equal `RansacResult` outputs | Deterministic-seed invariant |
| valid-residual-zero-on-clean | 4 perfect 2D-2D correspondences with known pose | `median_residual_px ≈ 0.0` | Clean residual |
| valid-residual-nan-on-empty | empty inlier array | returns `NaN` (no exception) | Empty-input semantics |
| invalid-shape | `correspondences.shape = (10, 3)` | `RansacFilterError`; mentions `(N, 4)` shape | Shape contract |
| invalid-threshold | `ransac_threshold_px = -1.0` | `RansacFilterError`; mentions positive threshold | Threshold guard |
| invalid-too-few-points | `correspondences.shape = (3, 4)` | `RansacFilterError`; mentions minimum 4 points | RANSAC point-count guard |
| invalid-K-shape | `K.shape = (4, 4)` in residual call | `RansacFilterError`; mentions `(3, 3)` shape | K shape contract |
| no-upward-imports | static import scan | only `_types`, `helpers.se3_utils`, `cv2`, `numpy`, stdlib | Layer 1 invariant |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/07_helper_ransac_filter.md` | autodev decompose Step 2 |
