# Common Helper — `RansacFilter`

## Purpose

Thin wrapper around OpenCV's RANSAC + reprojection-residual computation. Used by the cross-domain matcher (C3 — RANSAC over 2D-2D correspondences for the per-candidate inlier count), the conditional refiner (C3.5 — recompute residual after AdHoP refinement), and the pose estimator (C4 — RANSAC inside `solvePnPRansac` is OpenCV-internal, but C4 also computes per-frame final reprojection residual via this helper for FDR provenance).

## Used By

- C3 — Cross-domain Matcher.
- C3.5 — AdHoP-conditional Refiner.
- C4 — Pose Estimation.

## Interface (sketch)

```
class RansacFilter:
    @staticmethod
    def filter_correspondences(
        correspondences: ndarray[N, 4],
        ransac_threshold_px: float,
        min_inliers: int,
    ) -> RansacResult

    @staticmethod
    def compute_reprojection_residual(
        correspondences: ndarray[I, 4],
        K: ndarray[3, 3],
        distortion: ndarray,
        pose: SE3,
    ) -> float                                      # median residual in pixels
```

```
RansacResult:
  inlier_correspondences:        ndarray[I, 4]
  inlier_count:                  int
  outlier_count:                 int
  median_residual_px:            float
```

## Implementation Notes

- Backed by `cv2.findHomography(..., cv2.RANSAC)` for the 2D-2D case and `cv2.projectPoints(...)` for the post-pose residual.
- Stateless; pure function.

## Caveats

- The RANSAC threshold is a tunable; defaults are documented per-component (C3, C3.5, C4) in their specs.
- For 2D-3D RANSAC inside C4's `solvePnPRansac`, OpenCV does it internally — this helper is for the standalone reprojection-residual computation that lives outside the PnP call.

## Cycle-1 operational reality

The shipped surface in `src/gps_denied_onboard/helpers/ransac_filter.py` (AZ-282, extended via composition in AZ-623) is static-only and deterministic — `cv2.setRNGSeed(0)` is called immediately before every `cv2.findHomography(..., RANSAC)` so the same correspondences always produce the same inlier mask (AC-3 byte-equal determinism).

- **`RansacFilterError`** — single public exception type (subclasses `ValueError`). Raised on: non-`ndarray` correspondences; wrong-shape correspondences (anything other than `(N, 4)`); non-positive `ransac_threshold_px`; negative `min_inliers`; fewer than 4 correspondences for `filter_correspondences` (homography needs ≥4); non-`(3, 3)` `K`; distortion not shape `(5,)` or `(8,)`; OpenCV exceptions are wrapped (`cv2.error` → `RansacFilterError`).
- **`RansacResult` is a frozen `@dataclass`** — `inlier_correspondences: np.ndarray`, `inlier_count: int`, `outlier_count: int`, `median_residual_px: float`. The numpy array is NOT copied; consumers MUST treat it as read-only.
- **Median, not mean** — both `filter_correspondences` (homography residual) and `compute_reprojection_residual` (post-pose residual) pin **median** as the residual statistic. This matches the contract for C3.5 (post-AdHoP residual gate) and C4 (per-frame FDR residual). Mean is more sensitive to remaining outliers and would defeat the gate.
- **NaN residual for empty inliers** — both methods return `float("nan")` when the inlier set is empty. Consumers must NOT propagate `nan` as a numeric residual; treat it as "no residual computable" and fall back to the C3.5/C4 "matcher returned nothing useful" branch.
- **`min_inliers` is INFORMATIONAL only** — passed to `filter_correspondences`, validated for non-negativity, but does NOT gate the result. The returned `RansacResult` always reflects the actual RANSAC outcome; callers decide whether `result.inlier_count >= min_inliers` is acceptable. This is the contract's "Min-inliers semantics" invariant — encoding the gate in the helper would conflate three separate component thresholds (C3 / C3.5 / C4).
- **`filter_correspondences` residual** uses `cv2.perspectiveTransform` (the homography fit's own residual). `compute_reprojection_residual` uses `cv2.projectPoints` with the supplied pose, back-projecting image-a pixels through `K^{-1}` to `z=1` in camera-a frame. The two residuals are NOT interchangeable — one measures homography fit quality, the other measures pose fit quality.
- **Imports `se3_utils`** — `from gps_denied_onboard.helpers.se3_utils import SE3, se3_to_matrix`. Layer 1 helper-on-helper import is allowed (both are Layer 1). The `SE3 = gtsam.Pose3` alias is the runtime pose type; `se3_to_matrix(pose)` extracts the 4×4 transform.
- **OpenCV pin** — uses `cv2.findHomography`, `cv2.perspectiveTransform`, `cv2.projectPoints`, `cv2.Rodrigues`, `cv2.setRNGSeed`. All exist in `opencv-python>=4.5`, so the cycle-1 pin relaxation to `>=4.11.0.86,<4.12` (D-CROSS-CVE-1 leftover) does not affect this helper.

### Cycle-1 task lineage

- AZ-282 — initial helper, contract producer.
- AZ-623 (`pre_constructed_phase_e_ransac_c5_helpers`) — composition-root sweep that wires C5 to consume the same static helper as C3/C3.5/C4; no signature changes, no new public surface added by this task.
