Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
92326d8
docs(specs): design for MegaDetector as a model choice
Chouffe May 16, 2026
cf37a5f
docs(plans): implementation plan for MegaDetector model choice
Chouffe May 16, 2026
ce74fa9
docs(megadetector): correct upstream weights URL to Zenodo + filename…
Chouffe May 16, 2026
7fdae5a
tools(megadetector): script to build MD v6 tarball for HF upload
Chouffe May 16, 2026
dec50e2
feat(megadetector): common-name source for animal/person/vehicle labels
Chouffe May 16, 2026
44ca493
feat(megadetector): map 'person' label to homo sapiens binomial
Chouffe May 16, 2026
96c8952
docs(megadetector): person->homo sapiens mapping in spec + plan
Chouffe May 16, 2026
7216cd4
feat(megadetector): MD v6 entry in model zoo with detectionOnly flag
Chouffe May 16, 2026
8dc5feb
feat(megadetector): bbox routing + detectModelType branch
Chouffe May 16, 2026
d2b86e8
feat(megadetector): LitServe HTTP server + e2e tests
Chouffe May 16, 2026
bb20bf3
feat(megadetector): main-process server launcher + switch case
Chouffe May 16, 2026
01c5cb3
feat(megadetector): hide species panel + show 'detection only' on MD …
Chouffe May 16, 2026
8baa18b
feat(megadetector): 'detection only' label in model picker dropdown
Chouffe May 16, 2026
dc8c428
docs(megadetector): add MD to http-servers supported-models table
Chouffe May 18, 2026
904cb24
tools(megadetector): downloader entry + Makefile env var for MD
Chouffe May 18, 2026
5f97777
fix(megadetector): parseScientificName branch for MD simple labels
Chouffe May 18, 2026
48bea5e
fix(megadetector): omit prediction_score on blank predictions (was nu…
Chouffe May 18, 2026
28bddca
feat(models): move MegaDetector to second position in model zoo
Chouffe May 18, 2026
3f0bc94
fix(ml-server): reject corrupt video metadata so false-positive 'anim…
Chouffe May 18, 2026
0754c72
fix(megadetector): per-detection classificationProbability for MD (no…
Chouffe May 18, 2026
6fd5b5c
fix(megadetector): mean-conf weighted vote for MD video winner selection
Chouffe May 18, 2026
bc24a2d
chore: prettier reformat MD test files
Chouffe May 18, 2026
08f970a
fix(export): MD pseudo-species map to correct Camtrap DP observationType
Chouffe May 18, 2026
5ce050e
chore: drop one-shot megadetector implementation plan
Chouffe May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions docs/http-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ Biowatch uses an **HTTP-based ML model serving architecture** where each machine

### Supported Models

| Model | Focus | Species Coverage |
| ------------------------ | ------------------- | ------------------------------------ |
| **SpeciesNet** (Google) | Global wildlife | 2,000+ species worldwide |
| **DeepFaune** (CNRS) | European fauna | 34 European species |
| **Manas** (OSI-Panthera) | Central Asian fauna | Snow leopard and 11 regional species |
| Model | Focus | Species Coverage |
| ----------------------------------------- | ---------------------------------- | --------------------------------------------- |
| **SpeciesNet** (Google) | Global wildlife | 2,000+ species worldwide |
| **DeepFaune** (CNRS) | European fauna | 34 European species |
| **Manas** (OSI-Panthera) | Central Asian fauna | Snow leopard and 11 regional species |
| **MegaDetector** (Microsoft AI for Earth) | Blank filter / worldwide detection | 3 categories (animal, person, vehicle) |

### Technology Stack

Expand Down Expand Up @@ -98,7 +99,8 @@ src/
python-environments/common/
├── run_speciesnet_server.py # SpeciesNet LitServe implementation
├── run_deepfaune_server.py # DeepFaune LitServe implementation
└── run_manas_server.py # Manas LitServe implementation
├── run_manas_server.py # Manas LitServe implementation
└── run_megadetector_server.py # MegaDetector LitServe implementation (detection-only)
```

## How HTTP Servers Work
Expand Down
261 changes: 261 additions & 0 deletions docs/specs/2026-05-16-megadetector-model-choice-design.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions python-environments/common/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export DEEPFAUNE_CLASSIFIER_WEIGHTS=/tmp/models/deepfaune/1.3/deepfaune-vit_larg
export MANAS_DETECTOR_WEIGHTS=/tmp/models/manas/1.0/MDV6-yolov10x.pt
export MANAS_CLASSIFIER_WEIGHTS=/tmp/models/manas/1.0/best_model_Fri_Sep__1_18_50_55_2023.pt
export MANAS_CLASSES=/tmp/models/manas/1.0/classes_Fri_Sep__1_18_50_55_2023.pickle
export MEGADETECTOR_DETECTOR_WEIGHTS=/tmp/models/megadetector/6.0/MDV6-yolov10-e-1280.pt

# Run all e2e tests (downloads models if not present)
test: download-models
Expand All @@ -41,3 +42,4 @@ download-models:
uv run python scripts/download_model.py --model speciesnet --output /tmp/models/speciesnet
uv run python scripts/download_model.py --model deepfaune --output /tmp/models/deepfaune
uv run python scripts/download_model.py --model manas --output /tmp/models/manas
uv run python scripts/download_model.py --model megadetector --output /tmp/models/megadetector
267 changes: 267 additions & 0 deletions python-environments/common/run_megadetector_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
"""
CLI script to run MegaDetector v6 as a LitServer.

MegaDetector v6 is a YOLO-based detector (animals / people / vehicles only — no
species classification). Used in Biowatch as a fast blank-filter before manual
species annotation.

Start the server:

```
run_megadetector_server.py \\
--port 8000 \\
--filepath-detector-weights ./path/to/MDV6-yolov10-e-1280.pt \\
--detection-confidence-threshold 0.2
```

A Swagger API documentation is served at localhost:${port}/docs

health:

```
$ curl http://localhost:${port}/health
"ok"
```

info:

```
$ curl http://localhost:${port}/info
{
"model": {"type": "megadetector", "version": "6.0"},
"server": {...}
}
```

predict (streaming):

```
$ curl -X POST http://localhost:${port}/predict \\
-H "Content-Type: application/json" \\
-d '{"instances": [{"filepath": "/path/to/your/image"}]}'
```

Output per image:

```json
{
"output": {
"predictions": [{
"filepath": "/path/to/image",
"classifications": {},
"detections": [
{"label": "animal", "conf": 0.94, "xywhn": [...], "xyxy": [...]}
],
"prediction": "animal",
"prediction_score": 0.94,
"model_version": "6.0"
}]
}
}
```

The top-level `prediction` field translates MD's `"person"` label to the
binomial `"homo sapiens"` (the only MD category that is genuinely a species),
so it integrates with Biowatch's species tooltips and IUCN lookups. Per-bbox
`detections[].label` stays raw.
"""

import logging
from pathlib import Path

import litserve as ls
from absl import app, flags
from fastapi import HTTPException
from ultralytics import YOLO

from detection_utils import propagate_extra_fields, to_detection_record
from utils import VideoCapableLitAPI, is_video_file, safe_imread

logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)

MODEL_VERSION = "6.0"

# Map MegaDetector detection labels to Biowatch-canonical scientific names
# for the top-level `prediction` field. Per-bbox `detections[].label` stays raw.
# `person` is genuinely a species — emit the binomial so it integrates with
# Biowatch's species tooltips and IUCN lookups. `animal` and `vehicle` are not
# species and pass through unchanged.
LABEL_TO_PREDICTION = {
"person": "homo sapiens",
}

_PORT = flags.DEFINE_integer("port", 8000, "Port to run the server on.")
_API_PATH = flags.DEFINE_string("api_path", "/predict", "URL path for the server endpoint.")
_WORKERS_PER_DEVICE = flags.DEFINE_integer("workers_per_device", 1, "Number of server replicas per device.")
_TIMEOUT = flags.DEFINE_integer("timeout", 30, "Timeout (in seconds) for requests.")
_BACKLOG = flags.DEFINE_integer("backlog", 2048, "Maximum number of connections to hold in backlog.")
_FILEPATH_DETECTOR_WEIGHTS = flags.DEFINE_string(
name="filepath-detector-weights",
default=None,
help="filepath for the weights of the MegaDetector detector",
required=True,
)
_DETECTION_CONFIDENCE_THRESHOLD = flags.DEFINE_float(
"detection-confidence-threshold",
0.2,
"Confidence threshold below which a detection is ignored when computing the top prediction. "
"Detections themselves are always returned; this only controls the 'prediction'/'prediction_score' fields.",
)
_EXTRA_FIELDS = flags.DEFINE_list(
"extra_fields",
None,
"Comma-separated list of extra fields to propagate from request to response.",
)


def predict_one(
detector: YOLO,
filepath: Path,
confidence_threshold: float,
model_version: str = MODEL_VERSION,
) -> dict:
"""Run MegaDetector on a single image and produce a Biowatch-compatible prediction dict.

The 'prediction' field is the label of the highest-confidence detection
whose conf >= confidence_threshold, translated via LABEL_TO_PREDICTION.
When no detection passes the threshold, 'prediction' is 'blank' and
the 'prediction_score' key is omitted entirely (matches DeepFaune's
behavior and the JS-side Zod schema, which accepts an absent
prediction_score but rejects null).
"""
imagecv = safe_imread(filepath)
ultralytics_results = detector(imagecv, verbose=False)
yolo_out = ultralytics_results[0]
bboxes = yolo_out.boxes
# class_names comes from the .pt file's embedded `names` attribute — never hard-code.
class_names = yolo_out.names

detection_records = [
to_detection_record(
conf=conf,
class_instance=class_instance,
xywhn=xywhn,
xyxy=xyxy,
class_label_mapping=class_names,
)
for conf, class_instance, xywhn, xyxy in zip(
bboxes.conf.cpu().tolist(),
bboxes.cls.cpu().numpy().astype(int).tolist(),
bboxes.xywhn.cpu().numpy().tolist(),
bboxes.xyxy.cpu().numpy().tolist(),
strict=True,
)
]

# MD detects across animal/person/vehicle — pick top-confidence across ALL classes.
# (We don't reuse select_best_animal_detection, which filters for the animal class.)
above = [d for d in detection_records if d["conf"] >= confidence_threshold]
if not above:
return {
"predictions": [
{
"filepath": str(filepath),
"classifications": {},
"detections": detection_records,
"prediction": "blank",
"model_version": model_version,
}
],
}

top = max(above, key=lambda d: d["conf"])
prediction_label = LABEL_TO_PREDICTION.get(top["label"], top["label"])
return {
"predictions": [
{
"filepath": str(filepath),
"classifications": {},
"detections": detection_records,
"prediction": prediction_label,
"prediction_score": top["conf"],
"model_version": model_version,
}
],
}


class MegaDetectorLitAPI(ls.LitAPI, VideoCapableLitAPI):
"""MegaDetector API server with video support."""

def __init__(
self,
filepath_detector_weights: Path,
detection_confidence_threshold: float,
extra_fields: list[str] | None = None,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.filepath_detector_weights = filepath_detector_weights
self.detection_confidence_threshold = detection_confidence_threshold
self.extra_fields = extra_fields or []

def setup(self, device):
del device # Unused.
self.detector = YOLO(self.filepath_detector_weights)

def decode_request(self, request, **kwargs):
for instance in request["instances"]:
filepath = instance["filepath"]
if not is_video_file(filepath) and not Path(filepath).exists():
raise HTTPException(400, f"Cannot access filepath: `{filepath}`")
return request

def _predict_single_image(self, filepath: str, **kwargs) -> dict:
single_instances_dict = {"instances": [{"filepath": filepath}]}
single_predictions_dict = predict_one(
detector=self.detector,
filepath=Path(filepath),
confidence_threshold=self.detection_confidence_threshold,
)
return propagate_extra_fields(self.extra_fields, single_instances_dict, single_predictions_dict)

def predict(self, x, **kwargs):
instances = x.get("instances", [])
logger.info(f"[MegaDetector] Processing {len(instances)} instances")
try:
yield from self.predict_with_video_support(x, **kwargs)
except Exception as e:
logger.error(f"[MegaDetector] Prediction failed: {e}", exc_info=True)
raise

def encode_response(self, output, **kwargs):
for out in output:
yield {"output": out}


def main(argv: list[str]) -> None:
del argv # Unused.
print("[STARTUP] Starting MegaDetector LitServer...")
api = MegaDetectorLitAPI(
filepath_detector_weights=Path(_FILEPATH_DETECTOR_WEIGHTS.value),
detection_confidence_threshold=_DETECTION_CONFIDENCE_THRESHOLD.value,
extra_fields=_EXTRA_FIELDS.value,
api_path=_API_PATH.value,
stream=True,
)
model_metadata = {"version": MODEL_VERSION, "type": "megadetector"}
server = ls.LitServer(
api,
accelerator="auto",
devices="auto",
workers_per_device=_WORKERS_PER_DEVICE.value,
model_metadata=model_metadata,
timeout=_TIMEOUT.value,
enable_shutdown_api=True,
)
server.run(
port=_PORT.value,
generate_client_file=False,
backlog=_BACKLOG.value,
)


if __name__ == "__main__":
app.run(main)
7 changes: 6 additions & 1 deletion python-environments/common/scripts/download_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
uv run python scripts/download_model.py --model speciesnet --output /tmp/models/speciesnet
uv run python scripts/download_model.py --model deepfaune --output /tmp/models/deepfaune
uv run python scripts/download_model.py --model manas --output /tmp/models/manas
uv run python scripts/download_model.py --model megadetector --output /tmp/models/megadetector
"""

import argparse
Expand All @@ -26,6 +27,10 @@
"repo_id": "earthtoolsmaker/manas",
"filename": "1.0.tar.gz",
},
"megadetector": {
"repo_id": "earthtoolsmaker/megadetector",
"filename": "6.0.tar.gz",
},
}


Expand Down Expand Up @@ -57,7 +62,7 @@ def main():
"--model",
required=True,
choices=list(MODELS.keys()),
help="Model to download (speciesnet, deepfaune, manas)",
help="Model to download (speciesnet, deepfaune, manas, megadetector)",
)
parser.add_argument(
"--output",
Expand Down
Loading
Loading