File size: 8,481 Bytes
11758d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7975403
11758d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7975403
 
 
 
 
 
 
 
 
11758d4
 
7975403
11758d4
7975403
11758d4
7975403
11758d4
 
7975403
 
11758d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7975403
 
 
 
 
 
11758d4
 
7975403
 
 
 
 
 
 
11758d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Inference script for AFR-DFV-v1 (African tropical forests, DeepForestVision)

Model: DeepForestVision v1
Input: 224x224 RGB, ImageNet-normalised
Framework: PyTorch (HuggingFace transformers, DINOv2-large)
Classes: 34 African tropical forest species
Developer: MNHN-OFVI

Ported from AddaxAI's legacy classify_detections.py (dfv-v1), with one
deliberate change: the architecture is built from a local config.json
rather than fetched from the Hub. See below.

Files expected in the model directory:
    - DFV.pt        the fine-tuned weights
    - config.json   facebook/dinov2-large's config, copied verbatim

Author: Peter van Lunteren
"""

from __future__ import annotations

from collections import OrderedDict
from pathlib import Path

import numpy as np
import torch
import torch.nn as nn
from PIL import Image, ImageFile
from torch import tensor
from torchvision.transforms import InterpolationMode, transforms
from transformers import AutoConfig, AutoModelForImageClassification

# Don't freak out over truncated images
ImageFile.LOAD_TRUNCATED_IMAGES = True

CROP_SIZE = 224
RESIZE_SIZE = 256
BACKBONE = "dinov2_large"

# Class order is the model's output order and must not be reordered.
CLASS_NAMES = [
    'aardvark', 'baboon', 'honey badger', 'bird',
    'black-and-white colobus', 'blue duiker', 'blue monkey',
    'african buffalo', 'bushbuck', 'bushpig', 'chimpanzee', 'civet_genet',
    'elephant', 'galago_potto', 'african golden cat', 'gorilla',
    'guineafowl', 'hyrax', 'side-striped jackal', 'leopard',
    "l'hoest's monkey", 'mandrill', 'mongoose', 'monkey', 'pangolin',
    'porcupine', 'red colobus_red-capped mangabey', 'red duiker', 'rodent',
    'serval', 'spotted hyena', 'squirrel', 'water chevrotain',
    'yellow-backed duiker'
]


class _Model(nn.Module):
    """
    DINOv2-large with a 34-way head.

    The legacy adapter builds this with
    `AutoModelForImageClassification.from_pretrained('facebook/dinov2-large')`,
    which downloads 1.2GB of pretrained weights from the Hub and then
    throws every one of them away: `load_weights` below does a strict
    `load_state_dict`, and DFV.pt carries all 441 keys the model has, so
    nothing of the download survives.

    Building from a local config instead is therefore identical in
    result, needs no network at inference time, and does not make an
    offline machine fail. Verified: from_config + DFV.pt loads strict with
    missing=0, unexpected=0.
    """

    def __init__(self, model_dir: Path) -> None:
        super().__init__()
        config = AutoConfig.from_pretrained(str(model_dir))
        self.base_model = AutoModelForImageClassification.from_config(config)
        self.base_model.classifier = nn.Linear(
            self.base_model.classifier.in_features, len(CLASS_NAMES)
        )
        self.backbone = BACKBONE
        self.nbclasses = len(CLASS_NAMES)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.base_model(x)

    def load_weights(self, path: Path, map_location) -> None:
        weights = torch.load(path, map_location=map_location)
        # The checkpoint was saved from a module that held the backbone
        # directly, so its keys need the base_model prefix.
        renamed = OrderedDict(
            (
                key.replace("dinov2", "base_model.dinov2").replace(
                    "classifier", "base_model.classifier"
                ),
                value,
            )
            for key, value in weights.items()
        )
        self.load_state_dict(renamed)


class ModelInference:
    """DeepForestVision African tropical forest classifier."""

    def __init__(self, model_dir: Path, model_path: Path) -> None:
        self.model_dir = Path(model_dir)
        self.model_path = Path(model_path)
        self.model: _Model | None = None
        self.device: torch.device | None = None
        # Match DeepForestVision's own inference (DFV.py), which preprocesses
        # with AutoImageProcessor.from_pretrained('facebook/dinov2-large'):
        # resize the shortest edge to 256 (bicubic), centre-crop 224,
        # rescale to [0,1], ImageNet-normalize. Replicated with torchvision
        # so no processor config is fetched at inference. The earlier port
        # stretched straight to 224x224 (DeepFaune boilerplate), distorting
        # the aspect ratio DINOv2 is sensitive to, and cropped a
        # square-by-expand box rather than the plain box the processor
        # expects.
        self.preprocess = transforms.Compose([
            transforms.Resize(
                size=RESIZE_SIZE,
                interpolation=InterpolationMode.BICUBIC,
                antialias=True,
            ),
            transforms.CenterCrop(CROP_SIZE),
            transforms.ToTensor(),
            transforms.Normalize(
                mean=tensor([0.485, 0.456, 0.406]),
                std=tensor([0.229, 0.224, 0.225]),
            ),
        ])

    # ------------------------------------------------------------------
    # Required interface
    # ------------------------------------------------------------------

    def check_gpu(self) -> bool:
        if torch.cuda.is_available():
            return True
        try:
            return bool(torch.backends.mps.is_built() and torch.backends.mps.is_available())
        except AttributeError:
            return False

    def load_model(self) -> None:
        # Matches the legacy adapter's device order: CUDA first, then MPS.
        if torch.cuda.is_available():
            self.device = torch.device("cuda")
        else:
            try:
                mps = torch.backends.mps.is_built() and torch.backends.mps.is_available()
            except AttributeError:
                mps = False
            self.device = torch.device("mps" if mps else "cpu")

        model = _Model(self.model_dir)
        model.load_weights(self.model_path, self.device)
        self.model = model.to(self.device).eval()

    def get_crop(
        self, image: Image.Image, bbox: tuple[float, float, float, float]
    ) -> Image.Image:
        """
        Plain box crop, matching DeepForestVision's functions.py, which
        crops with supervision.crop_image (a plain xyxy box: no squaring,
        no pad). The AutoImageProcessor pipeline above does its own
        resize/centre-crop, so the crop handed to it must be the raw box.
        The earlier port squared the box by expanding the shorter side,
        which fed a different region and aspect than upstream.
        """
        width, height = image.size
        left = max(0, int(round(bbox[0] * width)))
        top = max(0, int(round(bbox[1] * height)))
        right = min(width, int(round((bbox[0] + bbox[2]) * width)))
        bottom = min(height, int(round((bbox[1] + bbox[3]) * height)))
        if right <= left or bottom <= top:
            raise ValueError(f"Invalid crop dimensions: ({left},{top}) to ({right},{bottom})")
        return image.crop((left, top, right, bottom))

    def get_classification(self, crop: Image.Image) -> list[list]:
        """Per-crop inference. Returns [[name, prob], ...] for all classes."""
        probs = self._forward(np.stack([self.get_tensor(crop)]))[0]
        return [[CLASS_NAMES[i], float(probs[i])] for i in range(len(probs))]

    def get_class_names(self) -> dict[str, str]:
        """1-indexed mapping {id: class_name} for the output JSON."""
        return {str(i + 1): name for i, name in enumerate(CLASS_NAMES)}

    # ------------------------------------------------------------------
    # Optional batch interface
    # ------------------------------------------------------------------

    def get_tensor(self, crop: Image.Image) -> np.ndarray:
        if crop.mode != "RGB":
            crop = crop.convert("RGB")
        return self.preprocess(crop).numpy()

    def classify_batch(self, batch: np.ndarray) -> list[list[list]]:
        probs = self._forward(batch)
        return [
            [[CLASS_NAMES[j], float(p[j])] for j in range(len(p))]
            for p in probs
        ]

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------

    def _forward(self, batch: np.ndarray) -> np.ndarray:
        assert self.model is not None
        tensor_in = torch.from_numpy(batch).to(self.device)
        with torch.no_grad():
            return self.model(tensor_in).logits.softmax(dim=1).cpu().numpy()