mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 19:01:14 +00:00
af0dbe863a
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>
151 lines
5.9 KiB
Python
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
|