[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 22:30:29 +03:00
parent dd2f1cbae6
commit af0dbe863a
15 changed files with 2200 additions and 17 deletions
@@ -92,6 +92,59 @@ class DescriptorNormaliser:
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