Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
a884809
1
Parent(s): ee54f79
Deploy latest changes from main branch
Browse files- .env.example +7 -0
- src/config.py +22 -1
- src/core/services/abmil_checkpoint.py +128 -0
- src/core/services/ml_inference.py +3 -7
- tests/test_abmil_checkpoint.py +74 -0
- tests/test_inference_smoke.py +2 -1
.env.example
CHANGED
|
@@ -5,7 +5,14 @@ MODEL_VERSION=v3.0.0
|
|
| 5 |
REDIS_URL=redis://localhost:6379/0
|
| 6 |
|
| 7 |
HF_TOKEN=
|
|
|
|
| 8 |
MODEL_CHECKPOINT_PATH=/absolute/path/to/production_model.pt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
DINOV3_MODEL_ID=facebook/dinov3-vitb16-pretrain-lvd1689m
|
| 10 |
EMBEDDING_BATCH_SIZE=16
|
| 11 |
EMBEDDING_DEVICE=
|
|
|
|
| 5 |
REDIS_URL=redis://localhost:6379/0
|
| 6 |
|
| 7 |
HF_TOKEN=
|
| 8 |
+
# ABMIL: either local path OR Hub (Hub wins when ABMIL_HF_REPO_ID is set)
|
| 9 |
MODEL_CHECKPOINT_PATH=/absolute/path/to/production_model.pt
|
| 10 |
+
ABMIL_HF_REPO_ID=
|
| 11 |
+
ABMIL_HF_FILENAME=production_model.pt
|
| 12 |
+
ABMIL_HF_REVISION=
|
| 13 |
+
ABMIL_HF_SUBFOLDER=
|
| 14 |
+
ABMIL_HF_LOCAL_FILES_ONLY=false
|
| 15 |
+
|
| 16 |
DINOV3_MODEL_ID=facebook/dinov3-vitb16-pretrain-lvd1689m
|
| 17 |
EMBEDDING_BATCH_SIZE=16
|
| 18 |
EMBEDDING_DEVICE=
|
src/config.py
CHANGED
|
@@ -38,7 +38,28 @@ class Settings(BaseSettings):
|
|
| 38 |
model_checkpoint_path: str = Field(
|
| 39 |
default="",
|
| 40 |
alias="MODEL_CHECKPOINT_PATH",
|
| 41 |
-
description="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
)
|
| 43 |
|
| 44 |
dinov3_model_id: str = Field(
|
|
|
|
| 38 |
model_checkpoint_path: str = Field(
|
| 39 |
default="",
|
| 40 |
alias="MODEL_CHECKPOINT_PATH",
|
| 41 |
+
description="Local path to ABMIL .pt when ABMIL_HF_REPO_ID is unset; ignored when Hub repo id is set",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
abmil_hf_repo_id: Optional[str] = Field(
|
| 45 |
+
default=None,
|
| 46 |
+
description="Hugging Face Hub repo id (e.g. org/private-repo). If set, ABMIL checkpoint is fetched via hf_hub_download.",
|
| 47 |
+
)
|
| 48 |
+
abmil_hf_filename: str = Field(
|
| 49 |
+
default="production_model.pt",
|
| 50 |
+
description="Checkpoint filename inside the Hub repo (top level or under ABMIL_HF_SUBFOLDER)",
|
| 51 |
+
)
|
| 52 |
+
abmil_hf_revision: Optional[str] = Field(
|
| 53 |
+
default=None,
|
| 54 |
+
description="Optional Hub revision: branch, tag, or commit SHA (pin in production)",
|
| 55 |
+
)
|
| 56 |
+
abmil_hf_subfolder: Optional[str] = Field(
|
| 57 |
+
default=None,
|
| 58 |
+
description="Optional folder inside the repo containing ABMIL_HF_FILENAME",
|
| 59 |
+
)
|
| 60 |
+
abmil_hf_local_files_only: bool = Field(
|
| 61 |
+
default=False,
|
| 62 |
+
description="If true, Hub resolution uses only local HF cache (no network)",
|
| 63 |
)
|
| 64 |
|
| 65 |
dinov3_model_id: str = Field(
|
src/core/services/abmil_checkpoint.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Resolve ABMIL `.pt` checkpoint path from Hugging Face Hub or local filesystem."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Union
|
| 8 |
+
|
| 9 |
+
from huggingface_hub import hf_hub_download
|
| 10 |
+
from huggingface_hub.errors import EntryNotFoundError, HfHubHTTPError, RepositoryNotFoundError
|
| 11 |
+
|
| 12 |
+
from ...config import settings
|
| 13 |
+
from ..logging import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _hub_auth_token() -> Union[bool, str, None]:
|
| 19 |
+
"""Token for hf_hub_download: explicit string, or True to use cached CLI login."""
|
| 20 |
+
if settings.hf_token and settings.hf_token.strip():
|
| 21 |
+
return settings.hf_token.strip()
|
| 22 |
+
env_t = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 23 |
+
if env_t and env_t.strip():
|
| 24 |
+
return env_t.strip()
|
| 25 |
+
return True
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def resolve_abmil_checkpoint_path() -> str:
|
| 29 |
+
"""
|
| 30 |
+
Resolve the ABMIL checkpoint file path for ``torch.load``.
|
| 31 |
+
|
| 32 |
+
Precedence (mutually exclusive in practice):
|
| 33 |
+
|
| 34 |
+
1. **Hugging Face Hub** when ``ABMIL_HF_REPO_ID`` is set: ``hf_hub_download`` caches under
|
| 35 |
+
``HF_HOME`` / Hub layout and returns the local cache path.
|
| 36 |
+
2. **Local file** when ``MODEL_CHECKPOINT_PATH`` is set: must exist and be a regular file.
|
| 37 |
+
|
| 38 |
+
Raises ``RuntimeError`` with an actionable message if neither source is valid.
|
| 39 |
+
"""
|
| 40 |
+
repo_id = (settings.abmil_hf_repo_id or "").strip()
|
| 41 |
+
if repo_id:
|
| 42 |
+
filename = (settings.abmil_hf_filename or "").strip()
|
| 43 |
+
if not filename:
|
| 44 |
+
raise RuntimeError(
|
| 45 |
+
"ABMIL_HF_FILENAME must be set to a non-empty checkpoint filename when ABMIL_HF_REPO_ID is set.",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
if (settings.model_checkpoint_path or "").strip():
|
| 49 |
+
logger.info(
|
| 50 |
+
"abmil_checkpoint_source_hub",
|
| 51 |
+
repo_id=repo_id,
|
| 52 |
+
filename=filename,
|
| 53 |
+
note="MODEL_CHECKPOINT_PATH is ignored while ABMIL_HF_REPO_ID is set.",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
token = _hub_auth_token()
|
| 57 |
+
if token is True:
|
| 58 |
+
logger.warning(
|
| 59 |
+
"abmil_hf_download_implicit_token",
|
| 60 |
+
repo_id=repo_id,
|
| 61 |
+
message="No HF_TOKEN / hf_token in environment; using cached Hub credentials if any.",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
subfolder = (settings.abmil_hf_subfolder or "").strip() or None
|
| 65 |
+
revision = (settings.abmil_hf_revision or "").strip() or None
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
path = hf_hub_download(
|
| 69 |
+
repo_id=repo_id,
|
| 70 |
+
filename=filename,
|
| 71 |
+
subfolder=subfolder,
|
| 72 |
+
revision=revision,
|
| 73 |
+
token=token,
|
| 74 |
+
local_files_only=settings.abmil_hf_local_files_only,
|
| 75 |
+
)
|
| 76 |
+
except RepositoryNotFoundError as exc:
|
| 77 |
+
raise RuntimeError(
|
| 78 |
+
f"ABMIL Hub repository not found or no access: {repo_id!r}. "
|
| 79 |
+
"Verify ABMIL_HF_REPO_ID, HF_TOKEN read access, and network.",
|
| 80 |
+
) from exc
|
| 81 |
+
except EntryNotFoundError as exc:
|
| 82 |
+
raise RuntimeError(
|
| 83 |
+
f"ABMIL checkpoint file not in Hub repo {repo_id!r}: {filename!r}"
|
| 84 |
+
+ (f" (subfolder={subfolder!r})" if subfolder else "")
|
| 85 |
+
+ ". Check ABMIL_HF_FILENAME / ABMIL_HF_SUBFOLDER.",
|
| 86 |
+
) from exc
|
| 87 |
+
except HfHubHTTPError as exc:
|
| 88 |
+
raise RuntimeError(
|
| 89 |
+
f"Hugging Face Hub error while downloading ABMIL checkpoint: {exc}",
|
| 90 |
+
) from exc
|
| 91 |
+
except OSError as exc:
|
| 92 |
+
raise RuntimeError(
|
| 93 |
+
f"Filesystem error while caching ABMIL checkpoint from Hub: {exc}",
|
| 94 |
+
) from exc
|
| 95 |
+
|
| 96 |
+
resolved = Path(path)
|
| 97 |
+
if not resolved.is_file():
|
| 98 |
+
raise RuntimeError(f"Hub download did not produce a readable file: {path}")
|
| 99 |
+
|
| 100 |
+
logger.info(
|
| 101 |
+
"abmil_checkpoint_resolved_from_hub",
|
| 102 |
+
repo_id=repo_id,
|
| 103 |
+
filename=filename,
|
| 104 |
+
revision=revision,
|
| 105 |
+
local_files_only=settings.abmil_hf_local_files_only,
|
| 106 |
+
resolved_path=str(resolved),
|
| 107 |
+
)
|
| 108 |
+
return str(resolved)
|
| 109 |
+
|
| 110 |
+
local = (settings.model_checkpoint_path or "").strip()
|
| 111 |
+
if not local:
|
| 112 |
+
raise RuntimeError(
|
| 113 |
+
"ABMIL checkpoint not configured: set ABMIL_HF_REPO_ID and ABMIL_HF_FILENAME "
|
| 114 |
+
"(Hugging Face Hub), or set MODEL_CHECKPOINT_PATH to a local .pt file.",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
p = Path(local).expanduser()
|
| 118 |
+
try:
|
| 119 |
+
p = p.resolve()
|
| 120 |
+
except OSError:
|
| 121 |
+
pass
|
| 122 |
+
if not p.is_file():
|
| 123 |
+
raise RuntimeError(
|
| 124 |
+
f"MODEL_CHECKPOINT_PATH is set but file not found or not a file: {local}",
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
logger.info("abmil_checkpoint_resolved_local", resolved_path=str(p))
|
| 128 |
+
return str(p)
|
src/core/services/ml_inference.py
CHANGED
|
@@ -16,6 +16,7 @@ from starlette.concurrency import run_in_threadpool
|
|
| 16 |
from ...config import settings
|
| 17 |
from ..exceptions import InferenceServiceError, InvalidImageError, UnsupportedImageFormatError
|
| 18 |
from ..logging import get_logger
|
|
|
|
| 19 |
from .abmil_model import ABMILInferenceEngine
|
| 20 |
from .dino_embedding import DINOv3Embedder
|
| 21 |
from .preprocessing import extract_tiles_for_inference
|
|
@@ -32,11 +33,6 @@ DINO_EMBEDDER: Optional[DINOv3Embedder] = None
|
|
| 32 |
ABMIL_ENGINE: Optional[ABMILInferenceEngine] = None
|
| 33 |
|
| 34 |
|
| 35 |
-
def _assert_model_checkpoint_configured() -> None:
|
| 36 |
-
if not settings.model_checkpoint_path:
|
| 37 |
-
raise RuntimeError("MODEL_CHECKPOINT_PATH is required for ABMIL inference.")
|
| 38 |
-
|
| 39 |
-
|
| 40 |
def _configure_torch_cpu_threads() -> None:
|
| 41 |
"""Optional CPU parallelism tuning (set before heavy torch work). See PyTorch threading docs."""
|
| 42 |
try:
|
|
@@ -65,7 +61,7 @@ def initialize_models() -> None:
|
|
| 65 |
logger.info("Inference models already initialized")
|
| 66 |
return
|
| 67 |
|
| 68 |
-
|
| 69 |
_configure_torch_cpu_threads()
|
| 70 |
|
| 71 |
embedder = DINOv3Embedder(
|
|
@@ -79,7 +75,7 @@ def initialize_models() -> None:
|
|
| 79 |
embedder.load()
|
| 80 |
|
| 81 |
engine = ABMILInferenceEngine(
|
| 82 |
-
checkpoint_path=
|
| 83 |
device=settings.embedding_device,
|
| 84 |
)
|
| 85 |
engine.load()
|
|
|
|
| 16 |
from ...config import settings
|
| 17 |
from ..exceptions import InferenceServiceError, InvalidImageError, UnsupportedImageFormatError
|
| 18 |
from ..logging import get_logger
|
| 19 |
+
from .abmil_checkpoint import resolve_abmil_checkpoint_path
|
| 20 |
from .abmil_model import ABMILInferenceEngine
|
| 21 |
from .dino_embedding import DINOv3Embedder
|
| 22 |
from .preprocessing import extract_tiles_for_inference
|
|
|
|
| 33 |
ABMIL_ENGINE: Optional[ABMILInferenceEngine] = None
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def _configure_torch_cpu_threads() -> None:
|
| 37 |
"""Optional CPU parallelism tuning (set before heavy torch work). See PyTorch threading docs."""
|
| 38 |
try:
|
|
|
|
| 61 |
logger.info("Inference models already initialized")
|
| 62 |
return
|
| 63 |
|
| 64 |
+
abmil_checkpoint_path = resolve_abmil_checkpoint_path()
|
| 65 |
_configure_torch_cpu_threads()
|
| 66 |
|
| 67 |
embedder = DINOv3Embedder(
|
|
|
|
| 75 |
embedder.load()
|
| 76 |
|
| 77 |
engine = ABMILInferenceEngine(
|
| 78 |
+
checkpoint_path=abmil_checkpoint_path,
|
| 79 |
device=settings.embedding_device,
|
| 80 |
)
|
| 81 |
engine.load()
|
tests/test_abmil_checkpoint.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from src.config import settings
|
| 8 |
+
from src.core.services.abmil_checkpoint import resolve_abmil_checkpoint_path
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_resolve_abmil_checkpoint_local_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
| 12 |
+
pt = tmp_path / "production_model.pt"
|
| 13 |
+
pt.write_bytes(b"not-a-real-checkpoint")
|
| 14 |
+
|
| 15 |
+
monkeypatch.setattr(settings, "abmil_hf_repo_id", None)
|
| 16 |
+
monkeypatch.setattr(settings, "model_checkpoint_path", str(pt))
|
| 17 |
+
|
| 18 |
+
resolved = resolve_abmil_checkpoint_path()
|
| 19 |
+
assert Path(resolved) == pt.resolve()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_resolve_abmil_checkpoint_local_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 23 |
+
monkeypatch.setattr(settings, "abmil_hf_repo_id", None)
|
| 24 |
+
monkeypatch.setattr(settings, "model_checkpoint_path", "/nonexistent/path/model.pt")
|
| 25 |
+
|
| 26 |
+
with pytest.raises(RuntimeError, match="MODEL_CHECKPOINT_PATH"):
|
| 27 |
+
resolve_abmil_checkpoint_path()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_resolve_abmil_checkpoint_hub(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
| 31 |
+
cached = tmp_path / "from_hub.pt"
|
| 32 |
+
cached.write_bytes(b"x")
|
| 33 |
+
|
| 34 |
+
monkeypatch.setattr(settings, "abmil_hf_repo_id", "org/private-abmil")
|
| 35 |
+
monkeypatch.setattr(settings, "abmil_hf_filename", "weights.pt")
|
| 36 |
+
monkeypatch.setattr(settings, "abmil_hf_revision", "v1.0.0")
|
| 37 |
+
monkeypatch.setattr(settings, "abmil_hf_subfolder", "checkpoints")
|
| 38 |
+
monkeypatch.setattr(settings, "abmil_hf_local_files_only", False)
|
| 39 |
+
monkeypatch.setattr(settings, "model_checkpoint_path", "/ignored/local.pt")
|
| 40 |
+
|
| 41 |
+
captured: dict = {}
|
| 42 |
+
|
| 43 |
+
def fake_hf_hub_download(**kwargs: object) -> str:
|
| 44 |
+
captured.update(kwargs)
|
| 45 |
+
return str(cached)
|
| 46 |
+
|
| 47 |
+
monkeypatch.setattr(
|
| 48 |
+
"src.core.services.abmil_checkpoint.hf_hub_download",
|
| 49 |
+
fake_hf_hub_download,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
out = resolve_abmil_checkpoint_path()
|
| 53 |
+
assert out == str(cached)
|
| 54 |
+
assert captured["repo_id"] == "org/private-abmil"
|
| 55 |
+
assert captured["filename"] == "weights.pt"
|
| 56 |
+
assert captured["revision"] == "v1.0.0"
|
| 57 |
+
assert captured["subfolder"] == "checkpoints"
|
| 58 |
+
assert captured["local_files_only"] is False
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_resolve_abmil_checkpoint_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 62 |
+
monkeypatch.setattr(settings, "abmil_hf_repo_id", None)
|
| 63 |
+
monkeypatch.setattr(settings, "model_checkpoint_path", "")
|
| 64 |
+
|
| 65 |
+
with pytest.raises(RuntimeError, match="ABMIL checkpoint not configured"):
|
| 66 |
+
resolve_abmil_checkpoint_path()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_resolve_abmil_checkpoint_hub_empty_filename(monkeypatch: pytest.MonkeyPatch) -> None:
|
| 70 |
+
monkeypatch.setattr(settings, "abmil_hf_repo_id", "org/repo")
|
| 71 |
+
monkeypatch.setattr(settings, "abmil_hf_filename", " ")
|
| 72 |
+
|
| 73 |
+
with pytest.raises(RuntimeError, match="ABMIL_HF_FILENAME"):
|
| 74 |
+
resolve_abmil_checkpoint_path()
|
tests/test_inference_smoke.py
CHANGED
|
@@ -62,6 +62,7 @@ def test_initialize_models_requires_checkpoint(monkeypatch: pytest.MonkeyPatch)
|
|
| 62 |
monkeypatch.setattr(ml_inference, "DINO_EMBEDDER", None)
|
| 63 |
monkeypatch.setattr(ml_inference, "ABMIL_ENGINE", None)
|
| 64 |
monkeypatch.setattr(ml_inference.settings, "model_checkpoint_path", "")
|
|
|
|
| 65 |
|
| 66 |
-
with pytest.raises(RuntimeError, match="
|
| 67 |
ml_inference.initialize_models()
|
|
|
|
| 62 |
monkeypatch.setattr(ml_inference, "DINO_EMBEDDER", None)
|
| 63 |
monkeypatch.setattr(ml_inference, "ABMIL_ENGINE", None)
|
| 64 |
monkeypatch.setattr(ml_inference.settings, "model_checkpoint_path", "")
|
| 65 |
+
monkeypatch.setattr(ml_inference.settings, "abmil_hf_repo_id", None)
|
| 66 |
|
| 67 |
+
with pytest.raises(RuntimeError, match="ABMIL checkpoint not configured"):
|
| 68 |
ml_inference.initialize_models()
|