Files
gps-denied-onboard/src/gps_denied_onboard/helpers/descriptor_normaliser.py
T
Oleksandr Bezdieniezhnykh af0dbe863a [AZ-338] [AZ-283] C2 NetVLAD mandatory simple-baseline VprStrategy
NetVLAD is the C2 comparative baseline per the engine rule (every
production-default backbone ships with a simple-baseline alongside).
Runs on the C7 PyTorch FP16 runtime (NOT TRT) so a TRT engine compile
bug cannot simultaneously break NetVLAD AND UltraVPR.

Production changes:
- c2_vpr/net_vlad.py — NetVladStrategy + module-level create() factory.
  Constructor wires InferenceRuntimeCut + DescriptorIndexCut +
  NetVladBackbonePreprocessor + DescriptorNormaliser + FaissBridge.
  embed_query pipeline: preprocess -> runtime.infer -> dual-stage
  normalisation (intra-cluster THEN global L2) -> VprQuery.
  retrieve_topk delegates one-line to FaissBridge.
- c2_vpr/_net_vlad_architecture.py — Arandjelovic et al. 2016 NetVLAD
  layer over torchvision VGG16 features + optional Linear PCA
  projection to descriptor_dim (default 4096; published Pittsburgh
  reference uses K*D=64*512=32768 raw + Linear(32768, 4096) PCA).
- c2_vpr/_preprocessor_net_vlad.py — OpenCV-based image preprocessor:
  decode -> centre-crop square -> resize (480, 480) -> ImageNet
  normalisation -> FP16 NCHW. Calibration is not consumed (NetVLAD
  is calibration-agnostic per published preprocessing chain).
- c2_vpr/inference_runtime_cut.py — NEW AZ-507 consumer-side cut
  mirroring C7 InferenceRuntime; lets c2_vpr stay AZ-507-clean.
- c2_vpr/config.py — added netvlad_descriptor_dim: int = 4096 knob.
- helpers/descriptor_normaliser.py — added intra_cluster_normalise
  (DescriptorNormaliser v1.0.0 -> v1.1.0; backward-compatible add).
- runtime_root/vpr_factory.py — added _register_strategy_architecture
  helper that binds (MODEL_NAME, architecture_factory(descriptor_dim))
  to C7's architecture registry before delegating to the strategy's
  create() factory. Keeps the c7 import at L4, preserves AZ-507.
- fdr_client/records.py — registered vpr.embed_query,
  vpr.backbone_error, vpr.preprocess_error record kinds.

Tests:
- tests/unit/c2_vpr/test_net_vlad.py — 31 tests covering all 11 ACs +
  preprocessor contract + architecture factory + constructor
  validation + FDR record emission.
- tests/unit/test_az283_descriptor_normaliser.py — +8 tests for the
  new intra_cluster_normalise.
- tests/unit/test_az272_fdr_record_schema.py — +3 fixture payloads.

Full unit suite: 1608 passed / 80 env-skipped (+43 new tests).
Per-batch code review (batch_46_review.md): PASS_WITH_WARNINGS
(4 Low-severity hygiene findings; no Critical/High/Medium).

Architectural notes:
- The spec implied c2_vpr.net_vlad.create() registers the architecture
  with C7. That violates AZ-507 (no cross-component imports). Resolved
  by exposing MODEL_NAME + architecture_factory(descriptor_dim) on the
  strategy module and having the composition root perform the C7 bind.
- C7 PyTorch runtime API names in the spec (forward, load_engine)
  were outdated; aligned implementation with the live v1.0.0 Protocol
  (infer, compile_engine + deserialize_engine). Spec hygiene flagged
  in review F2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 22:30:29 +03:00

151 lines
5.9 KiB
Python

"""L2 descriptor normaliser aligning cosine similarity to FAISS inner-product (AZ-283).
Public surface frozen by
``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0.
Used on both the corpus side (C10 index build) and the query side (C2 runtime
lookup). The two sides MUST go through the same helper so the FAISS HNSW
search returns useful neighbours.
"""
from __future__ import annotations
from typing import Final
import numpy as np
__all__ = [
"ALLOWED_DTYPES",
"DescriptorNormaliser",
"DescriptorNormaliserError",
]
# Allowed input dtypes; anything else is rejected to keep the FAISS index and
# query path on the same precision.
ALLOWED_DTYPES: Final[tuple[np.dtype, ...]] = (
np.dtype(np.float16),
np.dtype(np.float32),
)
_METRIC_VALUE: Final[str] = "inner_product"
class DescriptorNormaliserError(ValueError):
"""Raised on shape / dtype violations (AZ-283)."""
def _validate_dtype(arr: np.ndarray, label: str) -> None:
if arr.dtype not in ALLOWED_DTYPES:
raise DescriptorNormaliserError(
f"{label}: dtype {arr.dtype} not in allowed set (float16, float32)"
)
class DescriptorNormaliser:
"""Stateless L2-normalisation helper; dtype-preserving; zero-norm safe."""
@staticmethod
def l2_normalise(descriptor: np.ndarray) -> np.ndarray:
if not isinstance(descriptor, np.ndarray):
raise DescriptorNormaliserError(
f"l2_normalise: expected np.ndarray; got {type(descriptor).__name__}"
)
if descriptor.ndim != 1:
raise DescriptorNormaliserError(
f"l2_normalise: expected 1-D shape (D,); got shape {descriptor.shape}"
)
if descriptor.shape[0] < 1:
raise DescriptorNormaliserError(
f"l2_normalise: dimension must be >= 1; got shape {descriptor.shape}"
)
_validate_dtype(descriptor, "l2_normalise")
in_dtype = descriptor.dtype
# Compute norm in float32 to stabilise float16 inputs against overflow /
# underflow; cast back to the caller dtype so we never silently up-cast.
as_f32 = descriptor.astype(np.float32, copy=False)
norm = float(np.linalg.norm(as_f32))
if norm == 0.0:
return np.zeros_like(descriptor)
normalised_f32 = as_f32 / norm
return normalised_f32.astype(in_dtype, copy=False)
@staticmethod
def l2_normalise_batch(descriptors: np.ndarray) -> np.ndarray:
if not isinstance(descriptors, np.ndarray):
raise DescriptorNormaliserError(
f"l2_normalise_batch: expected np.ndarray; got {type(descriptors).__name__}"
)
if descriptors.ndim != 2:
raise DescriptorNormaliserError(
f"l2_normalise_batch: expected 2-D shape (N, D); got shape {descriptors.shape}"
)
if descriptors.shape[0] < 1 or descriptors.shape[1] < 1:
raise DescriptorNormaliserError(
f"l2_normalise_batch: N and D must be >= 1; got shape {descriptors.shape}"
)
_validate_dtype(descriptors, "l2_normalise_batch")
in_dtype = descriptors.dtype
as_f32 = descriptors.astype(np.float32, copy=False)
norms = np.linalg.norm(as_f32, axis=1, keepdims=True)
# Avoid division-by-zero: leave zero rows as zero.
safe = np.where(norms == 0.0, 1.0, norms)
normalised_f32 = np.where(norms == 0.0, 0.0, as_f32 / safe)
return normalised_f32.astype(in_dtype, copy=False)
@staticmethod
def intra_cluster_normalise(
descriptor: np.ndarray, num_clusters: int
) -> np.ndarray:
"""Per-cluster L2 normalisation for VLAD-aggregated descriptors (AZ-338).
NetVLAD's published preprocessing chain L2-normalises each
per-cluster sub-vector BEFORE the global L2 step. The input is
a flat 1-D VLAD descriptor of shape ``(num_clusters * cluster_dim,)``
which is reshaped to ``(num_clusters, cluster_dim)``, normalised
row-wise, then flattened back. ``num_clusters`` must divide
``descriptor.shape[0]``.
Zero-norm sub-vectors are returned as zero (consistent with
:meth:`l2_normalise`).
"""
if not isinstance(descriptor, np.ndarray):
raise DescriptorNormaliserError(
f"intra_cluster_normalise: expected np.ndarray; "
f"got {type(descriptor).__name__}"
)
if descriptor.ndim != 1:
raise DescriptorNormaliserError(
f"intra_cluster_normalise: expected 1-D shape (K*D,); "
f"got shape {descriptor.shape}"
)
if not isinstance(num_clusters, int) or isinstance(num_clusters, bool):
raise DescriptorNormaliserError(
f"intra_cluster_normalise: num_clusters must be a non-bool "
f"int; got {num_clusters!r}"
)
if num_clusters < 1:
raise DescriptorNormaliserError(
f"intra_cluster_normalise: num_clusters must be >= 1; "
f"got {num_clusters}"
)
total_dim = descriptor.shape[0]
if total_dim % num_clusters != 0:
raise DescriptorNormaliserError(
f"intra_cluster_normalise: descriptor length {total_dim} "
f"not divisible by num_clusters={num_clusters}"
)
_validate_dtype(descriptor, "intra_cluster_normalise")
in_dtype = descriptor.dtype
cluster_dim = total_dim // num_clusters
reshaped = descriptor.reshape(num_clusters, cluster_dim).astype(
np.float32, copy=False
)
norms = np.linalg.norm(reshaped, axis=1, keepdims=True)
safe = np.where(norms == 0.0, 1.0, norms)
normalised = np.where(norms == 0.0, 0.0, reshaped / safe)
return normalised.reshape(total_dim).astype(in_dtype, copy=False)
@staticmethod
def descriptor_metric() -> str:
return _METRIC_VALUE