CAM-AI4G-v1 / inference.py
Addax-Data-Science's picture
Upload 4 files
a6e745e verified
Raw
History Blame Contribute Delete
4.39 kB
"""
Inference script for CAM-AI4G-v1 (Colombian Amazon, AI for Good Lab)
Model: AI4GAmazonRainforest
Input: 224x224 RGB, ImageNet-normalised (applied by PytorchWildlife)
Framework: PyTorch (PytorchWildlife, ResNet)
Classes: 36 Amazon genera
Developer: AI for Good Lab, Microsoft
Ported from AddaxAI's legacy classify_detections.py (pywildlife), with
the double-transform bug fixed. See below.
Author: Peter van Lunteren
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import supervision as sv
import torch
from PIL import Image, ImageFile
from PytorchWildlife.models import classification as pw_classification
# Don't freak out over truncated images
ImageFile.LOAD_TRUNCATED_IMAGES = True
# The 36 classes are genera, and PytorchWildlife owns the list. It is
# read off the loaded model rather than restated here, so the two can
# never drift apart.
class ModelInference:
"""Colombian Amazon classifier (PytorchWildlife AI4GAmazonRainforest)."""
def __init__(self, model_dir: Path, model_path: Path) -> None:
self.model_dir = Path(model_dir)
self.model_path = Path(model_path)
self.model = None
# ------------------------------------------------------------------
# Required interface
# ------------------------------------------------------------------
def check_gpu(self) -> bool:
try:
if torch.backends.mps.is_built() and torch.backends.mps.is_available():
return True
except Exception:
pass
return torch.cuda.is_available()
def load_model(self) -> None:
self.model = pw_classification.AI4GAmazonRainforest(
weights=str(self.model_path)
)
def get_crop(
self, image: Image.Image, bbox: tuple[float, float, float, float]
) -> Image.Image:
"""
Plain crop, no squaring and no padding.
Kept on supervision's crop_image so it stays identical to the
legacy adapter, down to how it rounds and how numpy slicing
handles a box that runs past an edge.
"""
img_width, img_height = image.size
left = int(round(bbox[0] * img_width))
top = int(round(bbox[1] * img_height))
right = int(round(bbox[2] * img_width)) + left
bottom = int(round(bbox[3] * img_height)) + top
return Image.fromarray(
sv.crop_image(
np.array(image.convert("RGB")), xyxy=[left, top, right, bottom]
)
)
def get_classification(self, crop: Image.Image) -> list[list]:
"""
Per-crop inference. Returns [[name, prob], ...] for all classes.
PytorchWildlife's single_image_classification does the whole
preprocessing itself: Image.fromarray -> resize 224 -> ToTensor
-> ImageNet Normalize -> forward. So it takes the plain RGB crop.
The legacy adapter ran Classification_Inference_Transform first
and then cast the result back to uint8 before handing it over:
preprocessed = trans_clf(PIL_crop) # float, [-2.1, 2.5]
preprocessed = preprocessed.permute(1,2,0).numpy().astype(np.uint8)
model.single_image_classification(preprocessed) # transforms it AGAIN
That cast is destructive. The normalised floats sit in about
[-2.1, 2.5], so uint8 truncates them to {0, 1, 2} and wraps the
negatives: the 224x224x3 array reaching the model held three
distinct values, i.e. a near-black image. Measured on a real
crop, legacy answered `Mazama` at 0.362 with `Unknown` at 0.357
behind it (a model with no signal, guessing), where the same
weights on the same crop answer `Bos` at 0.917.
So this is not a faithful-port question: the legacy path never
classified the image at all. Passing the crop through once, the
way PytorchWildlife intends, is the fix.
"""
assert self.model is not None
return self.model.single_image_classification(
np.array(crop.convert("RGB"))
)["all_confidences"]
def get_class_names(self) -> dict[str, str]:
"""1-indexed mapping {id: class_name} for the output JSON."""
assert self.model is not None
names = self.model.CLASS_NAMES
return {str(i + 1): names[i] for i in range(len(names))}