File size: 17,306 Bytes
4b98524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
"""Frozen visual embeddings, through onnxruntime.

Directive Β§3 on DINOv3: "Do not assume fine-tuning is required. Start with frozen
embeddings + nearest-neighbor retrieval." That is what this is β€” a backbone with
no head, one vector per image, and every capability that wants it (identity,
breed, BCS reference, fecal reference, footpad reference) built as retrieval on
top rather than as a trained classifier.

**Why ONNX and not `transformers`.** ADR 0017 took roughly a gigabyte of torch
out of the serving image and cut start-up from about 28 seconds to under one.
Reaching for `transformers` at serve time hands all of that back for a model
whose forward pass is a fixed graph with no control flow. So torch is a
*build-time* tool: `scripts/export_embedding.py` runs it once on a developer's
machine, and the service ships an `.onnx` that `onnxruntime` β€” already a
production dependency β€” loads in about a fifth of a second.

The export puts the pooling and the L2 normalisation inside the graph, so there
is no post-processing convention that can drift between whoever exported the
artefact and whoever serves it. A vector out of this adapter is always unit
length and cosine similarity is always a dot product.

**The artefact is still governed by a card.** This is not a second way to load a
model β€” `providers.load_card` checksums it and `adapters.licences.gate` checks
what the runtime really loads under, which is the check ADR 0017 added after a
watchdog defeated the card's own licence field.
"""

from __future__ import annotations

import threading
from functools import lru_cache

import numpy as np
from PIL import Image

from app.adapters.base import (
    Adapter,
    AdapterError,
    AdapterSpec,
    AdapterUnavailable,
    Availability,
    MeasuredCost,
    Modality,
    Placement,
    Task,
)
from app.adapters.licences import LicenceRefused, gate
from app.providers import ModelArtefact

_session_lock = threading.Lock()


@lru_cache(maxsize=4)
def _session(artefact_path: str):
    """One session per artefact. Building one costs about as much as an
    inference, so a request that rebuilds it doubles its own latency."""
    import onnxruntime as ort

    return ort.InferenceSession(
        artefact_path, providers=["CPUExecutionProvider"]
    )


def preprocess(
    image: Image.Image,
    size: int,
    mean: tuple[float, float, float],
    std: tuple[float, float, float],
) -> np.ndarray:
    """Resize the short side, centre crop, normalise. RGB, NCHW, float32.

    This is torchvision's standard eval transform written out, for the same
    reason `detectors/yolox_onnx.py` writes out its own NMS: pulling in a
    training framework for one resize is what the ONNX path exists to avoid.
    Getting it wrong does not raise β€” it quietly returns worse vectors β€” so the
    numbers come from the card rather than from a constant here.
    """
    rgb = image.convert("RGB")
    width, height = rgb.size
    scale = size / min(width, height)
    resized = rgb.resize(
        (max(size, round(width * scale)), max(size, round(height * scale))),
        Image.BICUBIC,
    )

    new_width, new_height = resized.size
    left = (new_width - size) // 2
    top = (new_height - size) // 2
    cropped = resized.crop((left, top, left + size, top + size))

    array = np.asarray(cropped, dtype=np.float32) / 255.0
    array = (array - np.asarray(mean, dtype=np.float32)) / np.asarray(
        std, dtype=np.float32
    )
    return np.ascontiguousarray(array.transpose(2, 0, 1)[None])


class OnnxEmbeddingAdapter(Adapter):
    """A frozen backbone that turns an image into one unit vector.

    Constructed from a `ModelArtefact` that `providers.load_card` has already
    checksummed. It reads nothing else and downloads nothing.
    """

    def __init__(
        self,
        artefact: ModelArtefact | None,
        spec: AdapterSpec,
        *,
        input_size: int = 224,
        mean: tuple[float, float, float] = (0.485, 0.456, 0.406),
        std: tuple[float, float, float] = (0.229, 0.224, 0.225),
        dimensions: int = 768,
    ) -> None:
        self.artefact = artefact
        self.spec = spec
        self.input_size = input_size
        self.mean = mean
        self.std = std
        self.dimensions = dimensions
        self._session = None
        self._input_name = ""

    def availability(self) -> Availability:
        if self.artefact is None:
            return Availability(
                False,
                f"No artefact is installed for {self.spec.adapter_id}.",
                "Run scripts/install_models.py, which fetches what the "
                "committed card names and refuses anything whose checksum "
                "does not match.",
            )
        try:
            gate(self.spec.runtime, self.artefact.license)
        except LicenceRefused as refusal:
            return Availability(
                False,
                str(refusal),
                "Move the capability to a permissively licensed backbone.",
            )
        if not self.artefact.is_validated:
            return Availability(
                False,
                f"{self.artefact.model_id} has empty validation notes, so "
                f"nothing attests that it works.",
                "Fill in what was tested, on what data, with what result.",
            )
        return Availability(True)

    def load(self) -> "OnnxEmbeddingAdapter":
        availability = self.availability()
        if not availability.ready:
            raise AdapterUnavailable(availability)

        assert self.artefact is not None  # availability() proved it
        with _session_lock:
            session = _session(str(self.artefact.path))

        inputs = session.get_inputs()
        if len(inputs) != 1:
            raise AdapterError(
                f"{self.artefact.path.name} takes {len(inputs)} inputs; this "
                f"adapter was written for a single image tensor."
            )
        # Shape-checked rather than trusted, because an export at a different
        # resolution produces vectors that are the right length and the wrong
        # thing, and nothing downstream would notice.
        expected = [3, self.input_size, self.input_size]
        actual = list(inputs[0].shape[1:])
        if actual != expected:
            raise AdapterError(
                f"{self.artefact.path.name} takes {actual}, but the card "
                f"describes a {expected} input. The artefact and its card "
                f"disagree about what was exported."
            )

        self._session = session
        self._input_name = inputs[0].name
        return self

    def embed(self, image: Image.Image) -> np.ndarray:
        if self._session is None:
            raise AdapterError(
                "embed() called before load(). There is no path to a vector "
                "that skipped the artefact check, and this is it refusing."
            )
        blob = preprocess(image, self.input_size, self.mean, self.std)
        vector = self._session.run(None, {self._input_name: blob})[0][0]

        if vector.shape[0] != self.dimensions:
            raise AdapterError(
                f"The graph returned {vector.shape[0]} dimensions; the card "
                f"says {self.dimensions}. An index built at one and queried at "
                f"the other fails silently, so this fails loudly."
            )
        return vector.astype(np.float32)

    def embed_many(self, images: list[Image.Image]) -> np.ndarray:
        """One row per image. Kept separate because building a retrieval index
        is the batch case and a request is the single case, and batching a
        request would only add latency."""
        return np.stack([self.embed(image) for image in images])


#: Β§3's first choice. Bespoke Meta licence with a live ambiguity about
#: attribution β€” see `licences.RUNTIME_LICENCES["dinov3-onnx"]`.
DINOV3_SPEC = AdapterSpec(
    adapter_id="dinov3-vits16",
    runtime="dinov3-onnx",
    tasks=(Task.EMBED,),
    modalities=(Modality.IMAGE,),
    directive_role=(
        "Β§3 DINOv3 β€” general visual embeddings, image similarity, retrieval, "
        "cattle identity experiments, breed similarity, BCS and fecal and "
        "footpad reference retrieval. Frozen, with nearest-neighbour on top."
    ),
    placement=Placement.CPU_SERVICE,
    placement_reason=(
        "86.6 MB of ONNX, 59 ms a frame and a 470 MB peak single-threaded. It "
        "belongs beside the API, and it is small enough that an on-device "
        "build is worth investigating β€” ADR 0002 makes Animap offline-first, "
        "and identity is exactly the capability a worker wants in a pen with "
        "no signal."
    ),
    measured=MeasuredCost(
        hardware="Apple M-series laptop (NOT the target container)",
        threads=1,
        sample="61 Commons frames, evaluation/dataset.json",
        runs=61,
        median_seconds=0.059,
        peak_rss_mb=470.0,
        measured_on="2026-08-21",
    ),
    notes=(
        "**Measured at re-identification, which is what it is registered for.** "
        "On 169 enrolled cattle from the CC BY 4.0 Zenodo 6324361 muzzle "
        "database, five enrolment images each: closed-set top-1 0.977, top-3 "
        "0.994, MRR 0.985, against a 0.0059 chance rate. Best of the two "
        "servable backbones; the two unservable ones were only run on a "
        "30-animal set, where all four saturate. "
        "**And the open-set result is the one that shapes the product**: with no "
        "threshold it names an unenrolled animal 100% of the time, because every "
        "query has a nearest neighbour. The similarity cutoff that admits no "
        "impostor accepts only 24.1% of the correct matches β€” the two "
        "distributions overlap badly, enrolled probes median 0.971 against "
        "unenrolled median 0.904 with an unenrolled maximum of 0.978. A margin "
        "rule does not rescue it. That is why the confirm step in Β§6.4 is "
        "load-bearing rather than decorative. "
        "No Nigerian and no zebu animal has been through this; the database is "
        "US beef breeds. "
        "Exported at 224 px so the comparison against DINOv2-small is "
        "like-for-like β€” timm resolves this checkpoint's native config to 256 px, "
        "so these figures understate it slightly. On the older Commons proxy it "
        "measured 1.000 species 1-NN and 1.000 Nigerian-cattle 1-NN against a "
        "0.357 base rate, better than every other backbone and faster than all "
        "but DINOv2."
    ),
)

#: The same interface over Apache-2.0 weights of the same size and embedding
#: width. Not a downgrade chosen for convenience β€” Β§4 asks for the benchmark,
#: and `experiments/cattle_identity/` is where the two are compared.
DINOV2_SPEC = AdapterSpec(
    adapter_id="dinov2-small",
    runtime="dinov2-onnx",
    tasks=(Task.EMBED,),
    modalities=(Modality.IMAGE,),
    directive_role=(
        "Β§3 DINOv3's role, served from the Apache-2.0 generation. 22.06M "
        "parameters against DINOv3 ViT-S/16's 21.60M, and the same 384-wide "
        "patch embedding."
    ),
    placement=Placement.CPU_SERVICE,
    placement_reason=(
        "88.4 MB of ONNX, 81 ms a frame, 390 MB peak. The registered artefact, "
        "because it is the one with no licence question attached."
    ),
    measured=MeasuredCost(
        hardware="Apple M-series laptop (NOT the target container)",
        threads=1,
        sample="61 Commons frames, evaluation/dataset.json",
        runs=61,
        median_seconds=0.081,
        peak_rss_mb=390.0,
        measured_on="2026-08-21",
    ),
    notes=(
        "Re-identification on the same 169 enrolled cattle as DINOv3: closed-set "
        "top-1 0.957 against DINOv3's 0.977, top-3 0.986 against 0.994, MRR "
        "0.971 against 0.985. Its "
        "open-set behaviour is worse in the same shape β€” 21.3% true accepts at "
        "the 1% false-accept point against DINOv3's 24.1%. "
        "On the Commons proxy, 0.984 species 1-NN and 0.727 Nigerian-cattle 1-NN "
        "against a 0.357 base rate. **Measurably worse than DINOv3 on every "
        "figure taken on both arms**, which is what makes DINOv3's licence "
        "ambiguity worth somebody's time rather than an academic point: the "
        "permissive fallback costs about two points of top-1 on a task where "
        "the errors are somebody's cow."
    ),
)

#: Β§40.2's head-to-head, and the reason it can exist at all.
#:
#: **This runs and it is not servable, and both halves are deliberate.** The
#: weights are CC-BY-NC-4.0, which a commercial product cannot satisfy at any
#: size; what the founder lifted was the rule that a licence like that stops the
#: model being *measured*. So the artefact is installed, the adapter is built,
#: and `licences.gate` refuses it under the default `enforce` policy and records
#: it under `record`. `describe()` reports `servable: False` either way.
MEGADESCRIPTOR_SPEC = AdapterSpec(
    adapter_id="megadescriptor",
    runtime="megadescriptor-timm",
    tasks=(Task.EMBED,),
    modalities=(Modality.IMAGE,),
    directive_role=(
        "Β§4 and Β§40.2 MegaDescriptor β€” wildlife re-ID embeddings for cattle "
        "identity, benchmarked head-to-head against DINOv3."
    ),
    placement=Placement.CPU_SERVICE,
    placement_reason=(
        "837 MB of ONNX and a measured 1,296 MB peak, which is inside the "
        "2,000 MB the CPU worker is judged against but 3.7x DINOv3's peak on "
        "the same run. The 0.723 s median is also inside the inline ceiling. "
        "It fits; it is simply not worth the room, because it lost the "
        "benchmark it was installed to win."
    ),
    measured=MeasuredCost(
        hardware="Apple M-series laptop (NOT the target container)",
        threads=1,
        sample="61 Commons frames, evaluation/dataset.json",
        runs=61,
        median_seconds=0.723,
        peak_rss_mb=1295.8,
        measured_on="2026-08-21",
    ),
    notes=(
        "**Β§40.2 answered: DINOv3 wins, and not narrowly.** MegaDescriptor-L-384 "
        "measured 0.934 species 1-NN and 0.636 Nigerian-cattle 1-NN against a "
        "0.357 base rate; DINOv3 measured 1.000 and 1.000 on the same 61 frames "
        "in the same run. It is beaten by Apache-2.0 DINOv2-small on both "
        "accuracy figures as well, at roughly 10x the artefact size and 7x the "
        "latency. Its nearest-neighbour cosines are much flatter β€” 0.257 median "
        "against DINOv3's 0.672. "
        "**None of that measures re-identification**, which is what "
        "MegaDescriptor is for: no available image set has the same animal "
        "twice, so this says the space is worse *organised* for cattle and "
        "geography, not that it cannot tell two White Fulani apart. "
        "L-384 was chosen over the smaller variants because Β§40.2 names it and "
        "because 837 MB of ONNX exports cleanly under the 2 GB protobuf limit; "
        "T-224, S-224, B-224 and L-224 publish checkpoints of 204, 290, 473 and "
        "1,922 MB and none was exported."
    ),
)

#: The other non-commercial contender, installed for the same reason and to no
#: better end. Its licence problem is quieter than MegaDescriptor's: nothing was
#: granted at all, and silence defaults to all rights reserved.
MIEWID_SPEC = AdapterSpec(
    adapter_id="miewid-msv3",
    runtime="miewid",
    tasks=(Task.EMBED,),
    modalities=(Modality.IMAGE,),
    directive_role=(
        "Β§4's 'Wildlife ReID embeddings' β€” the alternative to MegaDescriptor, "
        "benchmarked alongside it under Β§40.2."
    ),
    placement=Placement.CPU_SERVICE,
    placement_reason=(
        "206 MB of ONNX, 0.213 s a frame, 502 MB peak. Comfortably the cheapest "
        "of the two wildlife re-ID models and still 2.9x DINOv3's latency for "
        "the worst Nigerian retrieval of the four."
    ),
    measured=MeasuredCost(
        hardware="Apple M-series laptop (NOT the target container)",
        threads=1,
        sample="61 Commons frames, evaluation/dataset.json",
        runs=61,
        median_seconds=0.213,
        peak_rss_mb=502.2,
        measured_on="2026-08-21",
    ),
    notes=(
        "Measured 0.951 species 1-NN and 0.455 Nigerian-cattle 1-NN against a "
        "0.357 base rate β€” a lift of 1.27x on 11 frames, which is a failure to "
        "show anything rather than a measured floor. Last of the four on the "
        "figure that matters most for Nigerian farms. "
        "**Building this artefact meant running a third party's Python.** The "
        "upstream repo ships `modeling_miewid.py` instead of a `transformers` "
        "architecture, so `scripts/export_embedding.py` loads it with "
        "`trust_remote_code=True`. That is a build-step supply-chain exposure, "
        "not a serving one β€” the service loads a fixed ONNX graph with no "
        "Python in it β€” and the three modules were read before they were run. "
        "Preprocessing deviates from the published transform: the model card "
        "specifies `Resize((440, 440))` and this pipeline centre-crops, so its "
        "figures here may understate it."
    ),
)