Skip to content

Commit 832d886

Browse files
committed
feat(fish): auto-download and cache Community Fish Detector weights
- Pin release URL + SHA-256; store under platformdirs user cache - Empty GUI/CLI weights triggers download; optional explicit path unchanged - Docs, README, website; version 3.1.1 Made-with: Cursor
1 parent 4bf7f1f commit 832d886

10 files changed

Lines changed: 262 additions & 38 deletions

File tree

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,10 @@ Classic motion mode:
9595
deepmeerkat run /path/to/video.mp4 --output ./out --mode motion
9696
```
9797

98-
**Community Fish Detector** (underwater; install `pip install "deepmeerkat[fish]"` and download `.pth` weights from the upstream repo’s releases):
98+
**Community Fish Detector** (underwater; install `pip install "deepmeerkat[fish]"`). Weights download **automatically** on first fish run (~116 MB, cached under the OS user cache). Optional: `--fish-weights /path/to/custom.pth`.
9999

100100
```bash
101-
deepmeerkat run /path/to/video.avi --output ./out --mode fish \
102-
--fish-weights /path/to/community-fish-detector-*.pth
101+
deepmeerkat run /path/to/video.avi --output ./out --mode fish
103102
```
104103

105104
See the docs: **[Community Fish Detector](https://deepmeerkat.readthedocs.io/en/latest/fish/)**.

docs/fish.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,21 @@ The implementation uses **RF-DETR** (`rfdetr` on PyPI), **supervision**, and **P
1010
pip install "deepmeerkat[fish]"
1111
```
1212

13-
## Download weights
13+
## Weights (automatic by default)
1414

15-
Download the published **`.pth`** checkpoint (for example **RF-DETR Nano @ 640**) from the project’s **[GitHub Releases](https://github.com/filippovarini/community-fish-detector/releases)** and note the path on disk.
15+
On first fish run, DeepMeerkat **downloads** the published RF-DETR Nano **`.pth`** (~116 MB) from the [Community Fish Detector release](https://github.com/filippovarini/community-fish-detector/releases/tag/cfd-2026.02.02-rf-detr-nano), verifies **SHA-256**, and caches it under the OS user cache (see `platformdirs` — typically `~/Library/Caches/deepmeerkat/fish/` on macOS). Later runs reuse the cache offline.
16+
17+
To use a **custom** checkpoint instead, pass `--fish-weights` / set **Weights** in the GUI.
1618

1719
## CLI
1820

1921
```bash
22+
# Uses cached weights, or downloads once if missing
23+
deepmeerkat run /path/to/video.avi --output ./out --mode fish
24+
```
25+
26+
```bash
27+
# Optional: point at a specific .pth file
2028
deepmeerkat run /path/to/video.avi --output ./out --mode fish \
2129
--fish-weights /path/to/community-fish-detector-2026.02.02-rf-detr-nano-640.pth
2230
```
@@ -25,7 +33,7 @@ Optional flags match the GUI: `--fish-confidence`, `--fish-resolution` (default
2533

2634
## GUI
2735

28-
Choose **Community Fish Detector (underwater)** in **Mode**, set **Weights (.pth)** to your checkpoint file, then **Run**.
36+
Choose **Community Fish Detector (underwater)** in **Mode**, then **Run**. Leave **Weights** empty to download automatically, or browse to a `.pth` file you manage yourself.
2937

3038
## Outputs
3139

docs/getting-started.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,10 @@ pip install -e ".[ui]"
3131
deepmeerkat run /path/to/video.mp4 --output ./out
3232
```
3333

34-
**Community Fish Detector** (underwater; requires `[fish]` extras and a downloaded `.pth` see [fish.md](fish.md)):
34+
**Community Fish Detector** (underwater; requires `[fish]` extras — weights download automatically on first run; see [fish.md](fish.md)):
3535

3636
```bash
37-
deepmeerkat run /path/to/video.avi --output ./out --mode fish \
38-
--fish-weights /path/to/community-fish-detector-*.pth
37+
deepmeerkat run /path/to/video.avi --output ./out --mode fish
3938
```
4039

4140
Classic motion mode:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "deepmeerkat"
7-
version = "3.1.0"
7+
version = "3.1.1"
88
description = "DeepMeerkat 3.0 — MegaDetector, Community Fish Detector, and classic motion for ecological video"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/deepmeerkat/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def run_cmd(
7070
fish_weights: Path | None = typer.Option( # noqa: B008
7171
None,
7272
"--fish-weights",
73-
help="[fish] Path to community-fish-detector .pth weights",
73+
help="[fish] Weights .pth (omit to download once to the user cache)",
7474
),
7575
fish_conf: float = typer.Option(0.3, "--fish-confidence", help="[fish] Minimum confidence"),
7676
fish_resolution: int = typer.Option(

src/deepmeerkat/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class MegaDetectorSettings:
3434
class FishDetectorSettings:
3535
"""Community Fish Detector (RF-DETR Nano) — see https://github.com/filippovarini/community-fish-detector"""
3636

37-
#: Path to downloaded .pth weights (required for fish mode).
37+
#: Path to `.pth` weights, or empty string to auto-download once to the app cache.
3838
weights_path: str = ""
3939
confidence_threshold: float = 0.3
4040
#: RF-DETR input resolution (community model trained at 640).

src/deepmeerkat/fish_pipeline.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from deepmeerkat.config import JobConfig
1414
from deepmeerkat.export import write_annotations_csv, write_json, write_parameters_csv
15+
from deepmeerkat.fish_weights import ensure_fish_weights
1516
from deepmeerkat.timecode import frame_to_clock_str
1617
from deepmeerkat.video import VideoMeta, bgr_to_rgb, effective_stride, iter_frames, probe_video
1718

@@ -22,9 +23,7 @@ def _try_import_rfdetr() -> Any:
2223
except ImportError as e:
2324
raise ImportError(
2425
"Community Fish Detector requires optional dependencies. Install with:\n"
25-
' pip install "deepmeerkat[fish]"\n'
26-
"Then download RF-DETR weights from:\n"
27-
" https://github.com/filippovarini/community-fish-detector/releases"
26+
' pip install "deepmeerkat[fish]"'
2827
) from e
2928
return RFDETRNano
3029

@@ -81,13 +80,18 @@ def run_fish_job(
8180
if not video_path.is_file():
8281
raise FileNotFoundError(f"Expected a video file for fish mode: {video_path}")
8382

84-
weights = Path(config.fish.weights_path).expanduser()
85-
if not weights.is_file():
86-
raise FileNotFoundError(
87-
f"Fish model weights not found: {weights}\n"
88-
"Download the RF-DETR .pth from:\n"
89-
"https://github.com/filippovarini/community-fish-detector/releases"
90-
)
83+
def report(p: float, msg: str) -> None:
84+
if progress:
85+
progress(p, msg)
86+
87+
def ensure_prog(p: float, msg: str) -> None:
88+
report(min(0.99, p * 0.08), msg)
89+
90+
weights = ensure_fish_weights(
91+
config.fish.weights_path,
92+
progress=ensure_prog if progress else None,
93+
cancel=cancel,
94+
)
9195

9296
meta = probe_video(video_path)
9397
stride = effective_stride(
@@ -101,11 +105,7 @@ def run_fish_job(
101105
out_dir = config.output_dir / stem
102106
out_dir.mkdir(parents=True, exist_ok=True)
103107

104-
def report(p: float, msg: str) -> None:
105-
if progress:
106-
progress(p, msg)
107-
108-
report(0.0, "Loading Community Fish Detector (RF-DETR)…")
108+
report(0.08, "Loading Community Fish Detector (RF-DETR)…")
109109
res = int(fish.resolution)
110110
model = RFDETRNano(pretrain_weights=str(weights), resolution=res)
111111
from PIL import Image
@@ -198,7 +198,9 @@ def report(p: float, msg: str) -> None:
198198
cv2.imwrite(str(det_dir / f"frame_{frame_idx:06d}.jpg"), vis)
199199

200200
processed += 1
201-
frac = min(0.99, processed / float(frame_total_guess))
201+
span = 0.91
202+
base = 0.08
203+
frac = base + min(span, (processed / float(frame_total_guess)) * span)
202204
report(frac, f"Frame {frame_idx} ({processed} processed)")
203205

204206
elapsed = time.time() - t0

src/deepmeerkat/fish_weights.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Download and cache Community Fish Detector weights (RF-DETR Nano)."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
from collections.abc import Callable
7+
from pathlib import Path
8+
from threading import Event
9+
from urllib.error import URLError
10+
from urllib.request import Request, urlopen
11+
12+
from platformdirs import user_cache_dir
13+
14+
# Pinned to upstream release cfd-2026.02.02-rf-detr-nano (digest from GitHub API).
15+
FISH_WEIGHTS_FILENAME = "community-fish-detector-2026.02.02-rf-detr-nano-640.pth"
16+
FISH_WEIGHTS_URL = (
17+
"https://github.com/filippovarini/community-fish-detector/releases/download/"
18+
f"cfd-2026.02.02-rf-detr-nano/{FISH_WEIGHTS_FILENAME}"
19+
)
20+
FISH_WEIGHTS_SHA256_HEX = "076b0a80d5dbc3361fb2be707d521ce96fe990b453fb1c215d99194fc65b4762"
21+
FISH_WEIGHTS_SIZE_BYTES = 122_064_141
22+
23+
_READ_CHUNK = 1024 * 1024 # 1 MiB
24+
25+
26+
def fish_weights_cache_dir() -> Path:
27+
"""Directory where the default fish `.pth` is stored after first download."""
28+
return Path(user_cache_dir("deepmeerkat", appauthor=False)) / "fish"
29+
30+
31+
def fish_weights_cache_path() -> Path:
32+
"""Full path to the cached default fish weights file."""
33+
return fish_weights_cache_dir() / FISH_WEIGHTS_FILENAME
34+
35+
36+
def _sha256_file(path: Path) -> str:
37+
h = hashlib.sha256()
38+
with path.open("rb") as f:
39+
while True:
40+
chunk = f.read(_READ_CHUNK)
41+
if not chunk:
42+
break
43+
h.update(chunk)
44+
return h.hexdigest()
45+
46+
47+
def _download_to_path(
48+
url: str,
49+
dest: Path,
50+
*,
51+
expected_size: int,
52+
progress: Callable[[float, str], None] | None,
53+
cancel: Event | None,
54+
) -> None:
55+
dest.parent.mkdir(parents=True, exist_ok=True)
56+
tmp = dest.with_suffix(dest.suffix + ".download")
57+
req = Request(
58+
url,
59+
headers={"User-Agent": "DeepMeerkat/3.1 (+https://github.com/bw4sz/DeepMeerkat)"},
60+
)
61+
try:
62+
with urlopen(req, timeout=300) as resp:
63+
total = int(resp.headers.get("Content-Length") or 0) or expected_size
64+
got = 0
65+
with tmp.open("wb") as out:
66+
while True:
67+
if cancel and cancel.is_set():
68+
raise RuntimeError("Cancelled while downloading fish weights.")
69+
chunk = resp.read(_READ_CHUNK)
70+
if not chunk:
71+
break
72+
out.write(chunk)
73+
got += len(chunk)
74+
if progress and total > 0:
75+
frac = min(1.0, got / float(total))
76+
mb_got = got // (1024 * 1024)
77+
mb_tot = max(1, total // (1024 * 1024))
78+
progress(
79+
frac,
80+
f"Downloading fish model… ({mb_got} / ~{mb_tot} MiB)",
81+
)
82+
tmp.replace(dest)
83+
except BaseException:
84+
tmp.unlink(missing_ok=True)
85+
raise
86+
87+
88+
def ensure_fish_weights(
89+
weights_path: str,
90+
*,
91+
progress: Callable[[float, str], None] | None = None,
92+
cancel: Event | None = None,
93+
) -> Path:
94+
"""
95+
Resolve `.pth` weights: use an explicit path if set and valid; otherwise use the
96+
cache path, downloading and verifying on first use.
97+
"""
98+
raw = (weights_path or "").strip()
99+
if raw:
100+
p = Path(raw).expanduser().resolve()
101+
if not p.is_file():
102+
raise FileNotFoundError(
103+
f"Fish model weights not found: {p}\n"
104+
"Leave weights empty to download automatically, or set a valid .pth path."
105+
)
106+
if progress:
107+
progress(1.0, "Using fish weights from disk…")
108+
return p
109+
110+
cache = fish_weights_cache_path()
111+
if cache.is_file():
112+
if progress:
113+
progress(0.0, "Verifying cached fish model…")
114+
digest = _sha256_file(cache)
115+
if digest == FISH_WEIGHTS_SHA256_HEX:
116+
if progress:
117+
progress(1.0, "Using cached fish model…")
118+
return cache
119+
cache.unlink(missing_ok=True)
120+
121+
if progress:
122+
progress(0.0, "Downloading Community Fish Detector weights (~116 MB, one-time)…")
123+
124+
try:
125+
_download_to_path(
126+
FISH_WEIGHTS_URL,
127+
cache,
128+
expected_size=FISH_WEIGHTS_SIZE_BYTES,
129+
progress=progress,
130+
cancel=cancel,
131+
)
132+
except URLError as e:
133+
raise RuntimeError(
134+
"Could not download fish detector weights. Check your network, or download manually "
135+
"from:\n"
136+
f" {FISH_WEIGHTS_URL}\n"
137+
"Then set --fish-weights or the GUI Weights field to that file."
138+
) from e
139+
140+
if progress:
141+
progress(0.95, "Verifying download…")
142+
digest = _sha256_file(cache)
143+
if digest != FISH_WEIGHTS_SHA256_HEX:
144+
cache.unlink(missing_ok=True)
145+
raise RuntimeError(
146+
"Downloaded fish weights failed checksum verification. Delete the cache folder and "
147+
f"try again, or install manually. Cache: {cache.parent}"
148+
)
149+
150+
if progress:
151+
progress(1.0, "Fish model ready.")
152+
return cache

src/deepmeerkat/ui/main_window.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,9 @@ def __init__(self) -> None:
193193
fish_form = QFormLayout()
194194
fw_row = QHBoxLayout()
195195
self.fish_weights_edit = QLineEdit()
196-
self.fish_weights_edit.setPlaceholderText("Path to .pth weights (see docs)")
196+
self.fish_weights_edit.setPlaceholderText(
197+
"Optional: path to .pth — leave blank to download automatically (~116 MB, once)"
198+
)
197199
btn_fw = QPushButton("Weights file…")
198200
btn_fw.clicked.connect(self._browse_fish_weights)
199201
fw_row.addWidget(self.fish_weights_edit)
@@ -224,7 +226,7 @@ def __init__(self) -> None:
224226
"Save JPEG frames for frames with detections (detection_frames/)"
225227
)
226228
self.fish_save_frames.setChecked(False)
227-
fish_form.addRow("Weights (.pth)", fw_w)
229+
fish_form.addRow("Weights (.pth, optional)", fw_w)
228230
fish_form.addRow("Min confidence", self.fish_conf)
229231
fish_form.addRow("RF-DETR resolution", self.fish_resolution)
230232
fish_form.addRow("Frame stride", self.fish_stride)
@@ -478,14 +480,6 @@ def _run(self) -> None:
478480
QMessageBox.warning(self, "DeepMeerkat", "Please set input and output paths.")
479481
return
480482
cfg = self._build_config()
481-
if cfg.mode == DetectionMode.FISH and not cfg.fish.weights_path.strip():
482-
QMessageBox.warning(
483-
self,
484-
"DeepMeerkat",
485-
"Fish mode requires weights.\n"
486-
"Download the .pth from the Community Fish Detector releases and set “Weights”.",
487-
)
488-
return
489483
self.log.clear()
490484
self.run_btn.setEnabled(False)
491485
self.cancel_btn.setEnabled(True)

0 commit comments

Comments
 (0)