pnotp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pnotp/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ """Public entrypoint for the pnotp client library."""
4
+
5
+ import os
6
+
7
+ from .client import Client as _Client, DEFAULT_BASE_URL
8
+
9
+
10
+ def client(api_key: str | None = None, base_url: str | None = None) -> _Client:
11
+ """
12
+ Factory for the PnotP client.
13
+
14
+ If no api_key is provided, the loader will pull it from the environment
15
+ using (in order): PNOTP_API_KEY, pnotp_API_KEY.
16
+ The base URL defaults to the production endpoint unless explicitly overridden.
17
+ """
18
+ resolved_key = api_key or os.getenv("PNOTP_API_KEY") or os.getenv("pnotp_API_KEY")
19
+ resolved_base = base_url or DEFAULT_BASE_URL
20
+ return _Client(api_key=resolved_key, base_url=resolved_base)
21
+
22
+
23
+ __all__ = ["client"]
pnotp/client.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional, Union
4
+
5
+ import httpx
6
+
7
+ from .models import RemoteModel
8
+
9
+ DEFAULT_BASE_URL = "https://api.pnotp.ai/v1"
10
+
11
+
12
+ class Client:
13
+ """
14
+ Main PnotP client.
15
+
16
+ Usage:
17
+ import pnotp as pnp
18
+ client = pnp.client(api_key="API_KEY", base_url="http://127.0.0.1:8000/v1")
19
+ client.models() # list models
20
+ model = client.models("pneumonia_binary_classification")
21
+ result = model.predict("image.jpg")
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ api_key: str | None,
27
+ base_url: str = DEFAULT_BASE_URL,
28
+ timeout: float = 30.0,
29
+ ) -> None:
30
+ if not api_key:
31
+ raise ValueError("api_key is required. Pass it explicitly or set PNOTP_API_KEY.")
32
+ base = base_url.rstrip("/")
33
+ base_for_httpx = base if base.endswith("/") else f"{base}/"
34
+
35
+ self.api_key = api_key
36
+ self.base_url = base
37
+ self._http = httpx.Client(
38
+ base_url=base_for_httpx,
39
+ timeout=timeout,
40
+ headers={"X-API-Key": self.api_key},
41
+ )
42
+
43
+ def close(self) -> None:
44
+ self._http.close()
45
+
46
+ def __enter__(self) -> "Client":
47
+ return self
48
+
49
+ def __exit__(self, exc_type, exc, tb) -> None:
50
+ self.close()
51
+
52
+ def models(
53
+ self,
54
+ model: Optional[str] = None,
55
+ ) -> Union[List[str], RemoteModel]:
56
+ """
57
+ - If called with no args: lists all available model ids.
58
+ - If `model` is provided: returns a RemoteModel handle.
59
+ """
60
+ if model is None:
61
+ resp = self._http.get("models")
62
+ resp.raise_for_status()
63
+ data = resp.json() # {"models": [{...}, ...]}
64
+
65
+ model_ids = [m["id"] for m in data.get("models", [])]
66
+
67
+ print("You have access to the following models:")
68
+ for mid in model_ids:
69
+ print(f"- {mid}")
70
+
71
+ return model_ids
72
+
73
+ return RemoteModel(name=model, http_client=self._http)
74
+
75
+
76
+ __all__ = ["Client", "DEFAULT_BASE_URL"]
pnotp/exceptions.py ADDED
@@ -0,0 +1,13 @@
1
+ """Custom exceptions for the pnotp client."""
2
+
3
+
4
+ class PnotPError(Exception):
5
+ """Base error for all pnotp client exceptions."""
6
+
7
+
8
+ class VisionOnlyError(PnotPError):
9
+ """Raised when attention maps are requested for non-vision models."""
10
+
11
+
12
+ __all__ = ["PnotPError", "VisionOnlyError"]
13
+
pnotp/models.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, IO, Tuple
5
+
6
+ import httpx
7
+
8
+ from .exceptions import VisionOnlyError
9
+
10
+
11
+ class RemoteModel:
12
+ """
13
+ High-level handle for a remote model.
14
+
15
+ Example:
16
+ model = client.models("pneumonia_binary_classification")
17
+ result = model.predict("image.jpg")
18
+ heatmap = model.attention_map("image.jpg")
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ name: str,
24
+ http_client: httpx.Client,
25
+ is_vision: bool = True,
26
+ ) -> None:
27
+ self.name = name
28
+ self._http = http_client
29
+ self.is_vision = is_vision
30
+
31
+ # ---------- Internal helpers ----------
32
+
33
+ def _normalize_image_input(self, img: Any) -> Tuple[IO[bytes], bool]:
34
+ """
35
+ Normalizes supported image inputs to a binary file object and indicates
36
+ whether the caller should close the file after use.
37
+ """
38
+ if isinstance(img, (str, Path)):
39
+ return open(str(img), "rb"), True
40
+ if hasattr(img, "read"):
41
+ return img, False # type: ignore[return-value]
42
+ raise TypeError("img must be a file path or file-like object")
43
+
44
+ # ---------- Public API ----------
45
+
46
+ def predict(self, img: Any):
47
+ """
48
+ Sends an image to the remote model and returns the JSON classification report.
49
+ """
50
+ file_obj, should_close = self._normalize_image_input(img)
51
+ try:
52
+ files = {"file": ("image", file_obj, "application/octet-stream")}
53
+ resp = self._http.post(f"models/{self.name}:predict", files=files)
54
+ resp.raise_for_status()
55
+ return resp.json()
56
+ finally:
57
+ if should_close:
58
+ try:
59
+ file_obj.close()
60
+ except Exception: # pragma: no cover
61
+ pass
62
+
63
+ def attention_map(self, img: Any) -> bytes:
64
+ """
65
+ Returns an attention/activation map for vision models as raw image bytes.
66
+ """
67
+ if not self.is_vision:
68
+ raise VisionOnlyError(
69
+ f"Model '{self.name}' does not support attention_map (non-vision model)."
70
+ )
71
+
72
+ file_obj, should_close = self._normalize_image_input(img)
73
+ try:
74
+ files = {"file": ("image", file_obj, "application/octet-stream")}
75
+ resp = self._http.post(f"models/{self.name}:attention-map", files=files)
76
+ resp.raise_for_status()
77
+ return resp.content
78
+ finally:
79
+ if should_close:
80
+ try:
81
+ file_obj.close()
82
+ except Exception: # pragma: no cover
83
+ pass
84
+
85
+
86
+ __all__ = ["RemoteModel"]
87
+
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: pnotp
3
+ Version: 0.1.0
4
+ Summary: PnotP client and API wrappers for PathoVision models.
5
+ Project-URL: Homepage, https://pnotp.ai
6
+ Author-email: PnotP <support@pnotp.ai>
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: httpx<0.29,>=0.24
9
+ Provides-Extra: server
10
+ Requires-Dist: fastapi<1.0,>=0.110; extra == 'server'
11
+ Requires-Dist: pillow>=9.0; extra == 'server'
12
+ Requires-Dist: pydantic<3.0,>=2.0; extra == 'server'
13
+ Requires-Dist: python-multipart>=0.0.6; extra == 'server'
14
+ Requires-Dist: uvicorn<0.29,>=0.23; extra == 'server'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # PnotP
18
+
19
+ Unified API and Python client for PathoVision vision models.
20
+
21
+ ## Packages
22
+ - `pnotp_api`: FastAPI service that wraps PathoVision ensembles under a consistent `/v1` surface with API-key auth.
23
+ - `pnotp`: Python client for calling the PnotP API.
24
+
25
+ ## Install (client only)
26
+ ```bash
27
+ pip install pnotp
28
+ ```
29
+
30
+ ## Install (client + server extra)
31
+ ```bash
32
+ pip install "pnotp[server]"
33
+ ```
34
+
35
+ ## Usage (client)
36
+ ```python
37
+ import pnotp as pnp
38
+
39
+ client = pnp.client(api_key="YOUR_KEY") # default base URL is production
40
+ client.models()
41
+ model = client.models("pneumonia_binary_classification")
42
+ result = model.predict("image.jpg")
43
+ heatmap = model.attention_map("image.jpg")
44
+ ```
45
+
46
+ Set your key once per shell:
47
+ ```bash
48
+ export PNOTP_API_KEY="your_api_key_here"
49
+ python your_script.py # pnp.client() will read the env var automatically
50
+ ```
51
+
52
+ ## Run the API locally
53
+ ```bash
54
+ export PNOTP_INTERNAL_SECRET="dev-secret"
55
+ export PATHOVISION_ROOT="/absolute/path/to/PathoVision" # if not default
56
+ uvicorn pnotp_api.main:app --host 0.0.0.0 --port 8000
57
+ ```
58
+
59
+ ## Issue an API key (internal)
60
+ ```bash
61
+ curl -X POST http://127.0.0.1:8000/internal/api-keys \
62
+ -H "X-Internal-Secret: $PNOTP_INTERNAL_SECRET" \
63
+ -H "Content-Type: application/json" \
64
+ -d '{"user_external_id":"demo-user","plan_code":"pathovision_starter"}'
65
+ ```
@@ -0,0 +1,14 @@
1
+ pnotp/__init__.py,sha256=HYuvgxR8O1cEKtb_07cz-RrlqQLBEbUKRVu2U4xuWqs,726
2
+ pnotp/client.py,sha256=Sio7xharzWR81g47gu-buZ3_jzNy7q_6qeEbfEJzT4w,2059
3
+ pnotp/exceptions.py,sha256=wyziSTUjI8KIU_8_-foBiLk7jb82CtjCyHSHvvAdYX8,289
4
+ pnotp/models.py,sha256=PQwvBVcV3zCJIhovU1Ex46RFdub3mp3qGgQ29LBB4_Y,2685
5
+ pnotp_api/__init__.py,sha256=hf6hBqS6prZbp0vc3aVQ_hobqh83nj5Ds03uEbIuG5M,114
6
+ pnotp_api/db.py,sha256=K3NIGL3aRecFyoNkHUsEjevHrD8m28Qn9nRJVGKNEpE,3269
7
+ pnotp_api/main.py,sha256=qBCzu4Xw-Awk68y0BMdP6Drrjl8U1qtTa2cGqFPKP2w,5555
8
+ pnotp_api/models_registry.py,sha256=FOA6JCHYugbOe_LI9c0MqeScG_Fd702TYc7Lje01QQg,3556
9
+ pnotp_api/pathovision_bridge.py,sha256=gFRBDLbccDqHj3sYd2mpZ9VSXXOVcVVGZqOb30s9YkM,19412
10
+ pnotp_api/schemas.py,sha256=6Ywf-O0MCuZiRWoalMTu1w99Oub5ymgPXRmKt30IdRI,456
11
+ pnotp_api/security.py,sha256=hUBFvAKaBSjAer6CIKeP4C_kQD6ZfctCgTKBWLxOs9w,1037
12
+ pnotp-0.1.0.dist-info/METADATA,sha256=FvPb7zUILXXnyK4yXNJ9ilK0eu8mYC9lYxm1FRE0oRQ,1866
13
+ pnotp-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ pnotp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
pnotp_api/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """PnotP API built on top of PathoVision models."""
2
+
3
+ # The FastAPI application is exposed in ``pnotp_api.main``.
4
+
pnotp_api/db.py ADDED
@@ -0,0 +1,118 @@
1
+ """Minimal SQLite-backed API key store for PnotP."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import secrets
6
+ import sqlite3
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Dict, Optional
10
+
11
+ DB_PATH = Path(__file__).resolve().parent.parent / "pnotp_api.db"
12
+
13
+
14
+ def _utc_now_iso() -> str:
15
+ return datetime.now(tz=timezone.utc).isoformat()
16
+
17
+
18
+ def _get_conn() -> sqlite3.Connection:
19
+ conn = sqlite3.connect(DB_PATH)
20
+ conn.execute("PRAGMA journal_mode=WAL;")
21
+ conn.execute("PRAGMA foreign_keys=ON;")
22
+ return conn
23
+
24
+
25
+ def init_db() -> None:
26
+ """Creates the api_keys table if missing."""
27
+ with _get_conn() as conn:
28
+ conn.execute(
29
+ """
30
+ CREATE TABLE IF NOT EXISTS api_keys (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ user_external_id TEXT NOT NULL,
33
+ plan_code TEXT NOT NULL,
34
+ key_hash TEXT NOT NULL UNIQUE,
35
+ key_prefix TEXT NOT NULL,
36
+ is_active INTEGER NOT NULL DEFAULT 1,
37
+ created_at TEXT NOT NULL,
38
+ last_used_at TEXT
39
+ );
40
+ """
41
+ )
42
+
43
+
44
+ def _hash_key(raw_key: str) -> str:
45
+ return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
46
+
47
+
48
+ def generate_api_key(user_external_id: str, plan_code: str) -> str:
49
+ """
50
+ Generates and stores a new API key, returning the raw secret.
51
+ """
52
+ # Ensure a ~32-character urlsafe suffix.
53
+ suffix = secrets.token_urlsafe(32)[:32]
54
+ raw_key = f"pnp_live_{suffix}"
55
+ key_hash = _hash_key(raw_key)
56
+ key_prefix = raw_key[:12]
57
+ created_at = _utc_now_iso()
58
+
59
+ with _get_conn() as conn:
60
+ conn.execute(
61
+ """
62
+ INSERT INTO api_keys (user_external_id, plan_code, key_hash, key_prefix, is_active, created_at)
63
+ VALUES (?, ?, ?, ?, 1, ?)
64
+ """,
65
+ (user_external_id, plan_code, key_hash, key_prefix, created_at),
66
+ )
67
+
68
+ return raw_key
69
+
70
+
71
+ def get_api_key(key_raw: str) -> Optional[Dict[str, str]]:
72
+ """
73
+ Looks up an API key by raw secret. Returns None if missing or inactive.
74
+ """
75
+ key_hash = _hash_key(key_raw)
76
+ with _get_conn() as conn:
77
+ row = conn.execute(
78
+ """
79
+ SELECT user_external_id, plan_code, key_prefix, is_active
80
+ FROM api_keys
81
+ WHERE key_hash = ?
82
+ """,
83
+ (key_hash,),
84
+ ).fetchone()
85
+ if not row:
86
+ return None
87
+ user_external_id, plan_code, key_prefix, is_active = row
88
+ if not is_active:
89
+ return None
90
+ return {
91
+ "user_external_id": user_external_id,
92
+ "plan_code": plan_code,
93
+ "key_prefix": key_prefix,
94
+ }
95
+
96
+
97
+ def touch_api_key_usage(key_raw: str) -> None:
98
+ """Updates last_used_at for an API key; ignores missing keys."""
99
+ key_hash = _hash_key(key_raw)
100
+ with _get_conn() as conn:
101
+ conn.execute(
102
+ """
103
+ UPDATE api_keys
104
+ SET last_used_at = ?
105
+ WHERE key_hash = ?
106
+ """,
107
+ (_utc_now_iso(), key_hash),
108
+ )
109
+
110
+
111
+ # Initialize table eagerly.
112
+ init_db()
113
+
114
+
115
+ if __name__ == "__main__":
116
+ init_db()
117
+ new_key = generate_api_key("local_dev_user", "pathovision_starter")
118
+ print(f"Generated API key: {new_key}")
pnotp_api/main.py ADDED
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile, status
6
+ from fastapi.responses import Response
7
+ from pydantic import BaseModel
8
+
9
+ from .db import generate_api_key, init_db
10
+ from .models_registry import MODELS_REGISTRY
11
+ from .schemas import PredictionResult
12
+ from .security import require_api_key
13
+
14
+ app = FastAPI(title="PnotP API", version="0.1.0")
15
+
16
+ INTERNAL_SECRET = os.getenv("PNOTP_INTERNAL_SECRET", "")
17
+
18
+
19
+ def _map_pathovision_report(model_id: str, report: dict) -> PredictionResult:
20
+ """
21
+ Normalizes the raw PathoVision report into the PnotP schema.
22
+ """
23
+ if report is None:
24
+ raise ValueError("Inference returned an empty report.")
25
+
26
+ positive_prob = report.get("prob")
27
+ if positive_prob is None:
28
+ positive_prob = report.get("positive_prob")
29
+ if positive_prob is None:
30
+ raise ValueError("Report did not include a probability.")
31
+
32
+ class_probabilities = report.get("class_probabilities") or {}
33
+ if isinstance(class_probabilities, dict) and "notumor" in class_probabilities:
34
+ neg = float(class_probabilities.get("notumor", 0.0))
35
+ positive_prob = max(0.0, min(1.0, 1.0 - neg))
36
+ negative_prob = neg
37
+ else:
38
+ negative_prob = max(0.0, 1.0 - float(positive_prob))
39
+
40
+ threshold = report.get("threshold", 0.5)
41
+ decision = report.get("decision")
42
+ if decision is None:
43
+ predicted_class = str(report.get("predicted_class", "")).lower()
44
+ if predicted_class:
45
+ decision = predicted_class not in {"normal", "notumor", "negative"}
46
+ else:
47
+ decision = not bool(report.get("abstain", False))
48
+
49
+ model_version = report.get("model_version") or report.get("task") or model_id
50
+
51
+ return PredictionResult(
52
+ task=model_id,
53
+ positive_prob=float(positive_prob),
54
+ negative_prob=float(negative_prob),
55
+ prediction=bool(decision),
56
+ threshold=float(threshold),
57
+ model_version=str(model_version),
58
+ metadata=report,
59
+ )
60
+
61
+
62
+ async def require_internal_secret(x_internal_secret: str = Header(..., alias="X-Internal-Secret")):
63
+ if not INTERNAL_SECRET or x_internal_secret != INTERNAL_SECRET:
64
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid internal secret")
65
+
66
+
67
+ class CreateApiKeyRequest(BaseModel):
68
+ user_external_id: str
69
+ plan_code: str
70
+
71
+
72
+ @app.on_event("startup")
73
+ async def _startup() -> None:
74
+ init_db()
75
+
76
+
77
+ @app.get("/v1/models")
78
+ async def list_models(api_key_info=Depends(require_api_key)):
79
+ return {
80
+ "models": [
81
+ {
82
+ "id": m.model_id,
83
+ "task": m.task,
84
+ "vertical": m.vertical,
85
+ "type": "vision" if m.is_vision else "generic",
86
+ }
87
+ for m in MODELS_REGISTRY.values()
88
+ ]
89
+ }
90
+
91
+
92
+ @app.post("/v1/models/{model_id}:predict", response_model=PredictionResult)
93
+ async def predict_model(
94
+ model_id: str,
95
+ file: UploadFile = File(...),
96
+ api_key_info=Depends(require_api_key),
97
+ ) -> PredictionResult:
98
+ model_info = MODELS_REGISTRY.get(model_id)
99
+ if model_info is None:
100
+ raise HTTPException(
101
+ status_code=status.HTTP_404_NOT_FOUND,
102
+ detail=f"Unknown model_id '{model_id}'",
103
+ )
104
+
105
+ data = await file.read()
106
+ if not data:
107
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Uploaded file is empty.")
108
+
109
+ try:
110
+ raw_report = await model_info.predict_fn(data)
111
+ return _map_pathovision_report(model_id, raw_report)
112
+ except ValueError as exc:
113
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
114
+ except HTTPException:
115
+ raise
116
+ except Exception as exc: # pragma: no cover
117
+ raise HTTPException(
118
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
119
+ detail=f"Inference failed: {exc}",
120
+ ) from exc
121
+
122
+
123
+ @app.post("/v1/models/{model_id}:attention-map")
124
+ async def attention_map(
125
+ model_id: str,
126
+ file: UploadFile = File(...),
127
+ api_key_info=Depends(require_api_key),
128
+ ):
129
+ model_info = MODELS_REGISTRY.get(model_id)
130
+ if model_info is None:
131
+ raise HTTPException(
132
+ status_code=status.HTTP_404_NOT_FOUND,
133
+ detail=f"Unknown model_id '{model_id}'",
134
+ )
135
+
136
+ if not model_info.is_vision or model_info.attention_map_fn is None:
137
+ raise HTTPException(
138
+ status_code=status.HTTP_400_BAD_REQUEST,
139
+ detail=f"Model '{model_id}' does not support attention_map",
140
+ )
141
+
142
+ data = await file.read()
143
+ if not data:
144
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Uploaded file is empty.")
145
+
146
+ try:
147
+ png_bytes = await model_info.attention_map_fn(data)
148
+ except ValueError as exc:
149
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
150
+ except HTTPException:
151
+ raise
152
+ except Exception as exc: # pragma: no cover
153
+ raise HTTPException(
154
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
155
+ detail=f"Attention map generation failed: {exc}",
156
+ ) from exc
157
+
158
+ return Response(content=png_bytes, media_type="image/png")
159
+
160
+
161
+ @app.post("/internal/api-keys")
162
+ async def create_api_key(
163
+ payload: CreateApiKeyRequest,
164
+ _: None = Depends(require_internal_secret),
165
+ ):
166
+ raw_key = generate_api_key(payload.user_external_id, payload.plan_code)
167
+ prefix = raw_key[:12]
168
+ return {"api_key": raw_key, "prefix": prefix}
169
+
170
+
171
+ __all__ = ["app"]
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Awaitable, Callable, Dict, Optional
4
+
5
+ from .pathovision_bridge import (
6
+ run_aorticenlargement_attention_map,
7
+ run_aorticenlargement_inference,
8
+ run_brainmri_attention_map,
9
+ run_brainmri_inference,
10
+ run_cardiomegaly_attention_map,
11
+ run_cardiomegaly_inference,
12
+ run_pneumonia_attention_map,
13
+ run_pneumonia_inference,
14
+ run_pleuraleffusion_attention_map,
15
+ run_pleuraleffusion_inference,
16
+ run_pleuralthickening_attention_map,
17
+ run_pleuralthickening_inference,
18
+ run_tuberculosis_attention_map,
19
+ run_tuberculosis_inference,
20
+ )
21
+
22
+
23
+ class ModelInfo:
24
+ """Metadata and callable hooks for a registered model."""
25
+
26
+ def __init__(
27
+ self,
28
+ model_id: str,
29
+ task: str,
30
+ vertical: str,
31
+ is_vision: bool,
32
+ predict_fn: Callable[[bytes], Awaitable[dict]],
33
+ attention_map_fn: Optional[Callable[[bytes], Awaitable[bytes]]] = None,
34
+ ) -> None:
35
+ self.model_id = model_id
36
+ self.task = task
37
+ self.vertical = vertical
38
+ self.is_vision = is_vision
39
+ self.predict_fn = predict_fn
40
+ self.attention_map_fn = attention_map_fn
41
+
42
+
43
+ MODELS_REGISTRY: Dict[str, ModelInfo] = {
44
+ "pneumonia_binary_classification": ModelInfo(
45
+ model_id="pneumonia_binary_classification",
46
+ task="P_vs_notP",
47
+ vertical="medical/pathovision/chest-xray",
48
+ is_vision=True,
49
+ predict_fn=run_pneumonia_inference,
50
+ attention_map_fn=run_pneumonia_attention_map,
51
+ ),
52
+ "cardiomegaly_binary_classification": ModelInfo(
53
+ model_id="cardiomegaly_binary_classification",
54
+ task="P_vs_notP",
55
+ vertical="medical/pathovision/chest-xray",
56
+ is_vision=True,
57
+ predict_fn=run_cardiomegaly_inference,
58
+ attention_map_fn=run_cardiomegaly_attention_map,
59
+ ),
60
+ "pleural_effusion_binary_classification": ModelInfo(
61
+ model_id="pleural_effusion_binary_classification",
62
+ task="P_vs_notP",
63
+ vertical="medical/pathovision/chest-xray",
64
+ is_vision=True,
65
+ predict_fn=run_pleuraleffusion_inference,
66
+ attention_map_fn=run_pleuraleffusion_attention_map,
67
+ ),
68
+ "pleural_thickening_binary_classification": ModelInfo(
69
+ model_id="pleural_thickening_binary_classification",
70
+ task="P_vs_notP",
71
+ vertical="medical/pathovision/chest-xray",
72
+ is_vision=True,
73
+ predict_fn=run_pleuralthickening_inference,
74
+ attention_map_fn=run_pleuralthickening_attention_map,
75
+ ),
76
+ "aortic_enlargement_binary_classification": ModelInfo(
77
+ model_id="aortic_enlargement_binary_classification",
78
+ task="P_vs_notP",
79
+ vertical="medical/pathovision/chest-xray",
80
+ is_vision=True,
81
+ predict_fn=run_aorticenlargement_inference,
82
+ attention_map_fn=run_aorticenlargement_attention_map,
83
+ ),
84
+ "tuberculosis_binary_classification": ModelInfo(
85
+ model_id="tuberculosis_binary_classification",
86
+ task="P_vs_notP",
87
+ vertical="medical/pathovision/chest-xray",
88
+ is_vision=True,
89
+ predict_fn=run_tuberculosis_inference,
90
+ attention_map_fn=run_tuberculosis_attention_map,
91
+ ),
92
+ "brain_mri_tumor_binary_classification": ModelInfo(
93
+ model_id="brain_mri_tumor_binary_classification",
94
+ task="P_vs_notP",
95
+ vertical="medical/pathovision/brain-mri",
96
+ is_vision=True,
97
+ predict_fn=run_brainmri_inference,
98
+ attention_map_fn=run_brainmri_attention_map,
99
+ ),
100
+ }
101
+
102
+ __all__ = ["MODELS_REGISTRY", "ModelInfo"]
@@ -0,0 +1,463 @@
1
+ """Thin adapter that reuses PathoVision inference code."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import base64
6
+ import io
7
+ import os
8
+ import re
9
+ import sys
10
+ from typing import Any, Dict, Optional
11
+
12
+ from PIL import Image, UnidentifiedImageError
13
+
14
+ # Location of the existing PathoVision checkout.
15
+ PATHOVISION_ROOT = os.environ.get("PATHOVISION_ROOT", os.path.expanduser("~/Desktop/PathoVision"))
16
+
17
+
18
+ def _ensure_pathovision_on_path() -> None:
19
+ if PATHOVISION_ROOT and PATHOVISION_ROOT not in sys.path:
20
+ sys.path.insert(0, PATHOVISION_ROOT)
21
+
22
+
23
+ _ensure_pathovision_on_path()
24
+
25
+ # Import PathoVision modules lazily but surface any import issue early.
26
+ _PNEUMONIA_IMPORT_ERROR: Optional[Exception] = None
27
+ _CARDIOMEGALY_IMPORT_ERROR: Optional[Exception] = None
28
+ _PLEURALEFFUSION_IMPORT_ERROR: Optional[Exception] = None
29
+ _PLEURALTHICKENING_IMPORT_ERROR: Optional[Exception] = None
30
+ _AORTICENLARGEMENT_IMPORT_ERROR: Optional[Exception] = None
31
+ _TUBERCULOSIS_IMPORT_ERROR: Optional[Exception] = None
32
+ _BRAINMRI_IMPORT_ERROR: Optional[Exception] = None
33
+
34
+ try: # pragma: no cover - depends on local environment
35
+ from main_chestxray_pneumonia import ( # type: ignore
36
+ MAX_UPLOAD_BYTES as _PNEUMONIA_MAX_BYTES,
37
+ TTA_ALLOWED as _PNEUMONIA_TTA_ALLOWED,
38
+ _PneumoniaEnsembleSingleton,
39
+ )
40
+ except Exception as exc: # pragma: no cover
41
+ _PNEUMONIA_IMPORT_ERROR = exc
42
+ _PNEUMONIA_MAX_BYTES = 10 * 1024 * 1024 # sensible fallback
43
+ _PNEUMONIA_TTA_ALLOWED = False
44
+
45
+ try: # pragma: no cover
46
+ from main_chestxray_cardiomegaly import ( # type: ignore
47
+ MAX_UPLOAD_BYTES as _CARDIOMEGALY_MAX_BYTES,
48
+ TTA_ALLOWED as _CARDIOMEGALY_TTA_ALLOWED,
49
+ _CardiomegalyEnsembleSingleton,
50
+ )
51
+ except Exception as exc: # pragma: no cover
52
+ _CARDIOMEGALY_IMPORT_ERROR = exc
53
+ _CARDIOMEGALY_MAX_BYTES = 10 * 1024 * 1024
54
+ _CARDIOMEGALY_TTA_ALLOWED = False
55
+
56
+ try: # pragma: no cover
57
+ from main_chestxray_pleuraleffusion import ( # type: ignore
58
+ MAX_UPLOAD_BYTES as _PLEURALEFFUSION_MAX_BYTES,
59
+ TTA_ALLOWED as _PLEURALEFFUSION_TTA_ALLOWED,
60
+ _PleuralEffusionEnsembleSingleton,
61
+ )
62
+ except Exception as exc: # pragma: no cover
63
+ _PLEURALEFFUSION_IMPORT_ERROR = exc
64
+ _PLEURALEFFUSION_MAX_BYTES = 10 * 1024 * 1024
65
+ _PLEURALEFFUSION_TTA_ALLOWED = False
66
+
67
+ try: # pragma: no cover
68
+ from main_chestxray_pleuralthickening import ( # type: ignore
69
+ MAX_UPLOAD_BYTES as _PLEURALTHICKENING_MAX_BYTES,
70
+ TTA_ALLOWED as _PLEURALTHICKENING_TTA_ALLOWED,
71
+ _PleuralThickeningEnsembleSingleton,
72
+ )
73
+ except Exception as exc: # pragma: no cover
74
+ _PLEURALTHICKENING_IMPORT_ERROR = exc
75
+ _PLEURALTHICKENING_MAX_BYTES = 10 * 1024 * 1024
76
+ _PLEURALTHICKENING_TTA_ALLOWED = False
77
+
78
+ try: # pragma: no cover
79
+ from main_chestxray_aorticenlargement import ( # type: ignore
80
+ MAX_UPLOAD_BYTES as _AORTICENLARGEMENT_MAX_BYTES,
81
+ TTA_ALLOWED as _AORTICENLARGEMENT_TTA_ALLOWED,
82
+ _AorticEnlargementEnsembleSingleton,
83
+ )
84
+ except Exception as exc: # pragma: no cover
85
+ _AORTICENLARGEMENT_IMPORT_ERROR = exc
86
+ _AORTICENLARGEMENT_MAX_BYTES = 10 * 1024 * 1024
87
+ _AORTICENLARGEMENT_TTA_ALLOWED = False
88
+
89
+ try: # pragma: no cover
90
+ from main_chestxray_tuberculosis import ( # type: ignore
91
+ MAX_UPLOAD_BYTES as _TUBERCULOSIS_MAX_BYTES,
92
+ TTA_ALLOWED as _TUBERCULOSIS_TTA_ALLOWED,
93
+ _TuberculosisEnsembleSingleton,
94
+ )
95
+ except Exception as exc: # pragma: no cover
96
+ _TUBERCULOSIS_IMPORT_ERROR = exc
97
+ _TUBERCULOSIS_MAX_BYTES = 10 * 1024 * 1024
98
+ _TUBERCULOSIS_TTA_ALLOWED = False
99
+
100
+ try: # pragma: no cover
101
+ from main_brainmri import ( # type: ignore
102
+ MAX_UPLOAD_BYTES as _BRAINMRI_MAX_BYTES,
103
+ TTA_ALLOWED as _BRAINMRI_TTA_ALLOWED,
104
+ _BrainMRIEnsembleSingleton,
105
+ )
106
+ except Exception as exc: # pragma: no cover
107
+ _BRAINMRI_IMPORT_ERROR = exc
108
+ _BRAINMRI_MAX_BYTES = 10 * 1024 * 1024
109
+ _BRAINMRI_TTA_ALLOWED = False
110
+
111
+
112
+ def _ensure_pneumonia_ready() -> None:
113
+ if _PNEUMONIA_IMPORT_ERROR is not None:
114
+ raise RuntimeError(
115
+ "PathoVision pneumonia module could not be imported. "
116
+ "Set PATHOVISION_ROOT to the PathoVision checkout and ensure dependencies are installed."
117
+ ) from _PNEUMONIA_IMPORT_ERROR
118
+
119
+
120
+ def _ensure_cardiomegaly_ready() -> None:
121
+ if _CARDIOMEGALY_IMPORT_ERROR is not None:
122
+ raise RuntimeError("Cardiomegaly module import failed.") from _CARDIOMEGALY_IMPORT_ERROR
123
+
124
+
125
+ def _ensure_pleuraleffusion_ready() -> None:
126
+ if _PLEURALEFFUSION_IMPORT_ERROR is not None:
127
+ raise RuntimeError("Pleural effusion module import failed.") from _PLEURALEFFUSION_IMPORT_ERROR
128
+
129
+
130
+ def _ensure_pleuralthickening_ready() -> None:
131
+ if _PLEURALTHICKENING_IMPORT_ERROR is not None:
132
+ raise RuntimeError("Pleural thickening module import failed.") from _PLEURALTHICKENING_IMPORT_ERROR
133
+
134
+
135
+ def _ensure_aorticenlargement_ready() -> None:
136
+ if _AORTICENLARGEMENT_IMPORT_ERROR is not None:
137
+ raise RuntimeError("Aortic enlargement module import failed.") from _AORTICENLARGEMENT_IMPORT_ERROR
138
+
139
+
140
+ def _ensure_tuberculosis_ready() -> None:
141
+ if _TUBERCULOSIS_IMPORT_ERROR is not None:
142
+ raise RuntimeError("Tuberculosis module import failed.") from _TUBERCULOSIS_IMPORT_ERROR
143
+
144
+
145
+ def _ensure_brainmri_ready() -> None:
146
+ if _BRAINMRI_IMPORT_ERROR is not None:
147
+ raise RuntimeError("Brain MRI module import failed.") from _BRAINMRI_IMPORT_ERROR
148
+
149
+
150
+ def _decode_data_uri(data_uri: str) -> Optional[bytes]:
151
+ """Decodes ``data:*;base64`` payloads emitted by PathoVision explainability helpers."""
152
+ match = re.match(r"^data:(?P<mime>[-\w./+]+);base64,(?P<data>.+)$", data_uri)
153
+ if not match:
154
+ return None
155
+ payload = match.group("data")
156
+ try:
157
+ return base64.b64decode(payload, validate=True)
158
+ except Exception: # pragma: no cover
159
+ return None
160
+
161
+
162
+ def _load_image_from_bytes(data: bytes) -> Image.Image:
163
+ if not data:
164
+ raise ValueError("Uploaded file is empty.")
165
+ try:
166
+ return Image.open(io.BytesIO(data)).convert("RGB")
167
+ except UnidentifiedImageError as exc:
168
+ raise ValueError("Uploaded file is not a valid image.") from exc
169
+ except Exception as exc: # pragma: no cover
170
+ raise ValueError(f"Failed to read image: {exc}") from exc
171
+
172
+
173
+ async def run_pneumonia_inference(
174
+ image_bytes: bytes,
175
+ *,
176
+ use_tta: bool = True,
177
+ explain: bool = False,
178
+ ) -> Dict[str, Any]:
179
+ """
180
+ Runs the PathoVision pneumonia ensemble and returns the raw classification report.
181
+ """
182
+
183
+ def _run() -> Dict[str, Any]:
184
+ _ensure_pneumonia_ready()
185
+ if len(image_bytes) > _PNEUMONIA_MAX_BYTES:
186
+ raise ValueError("File too large. Max size is 10MB.")
187
+ image = _load_image_from_bytes(image_bytes)
188
+ ensemble = _PneumoniaEnsembleSingleton.get()
189
+ result = ensemble.predict(image, use_tta=use_tta and _PNEUMONIA_TTA_ALLOWED, explain=explain)
190
+
191
+ ctx = getattr(ensemble, "context", {})
192
+ config = ctx.get("config", {}) if isinstance(ctx, dict) else {}
193
+ task_id = None
194
+ if isinstance(config, dict):
195
+ task_id = config.get("task")
196
+ model_version = task_id or ctx.get("task") if isinstance(ctx, dict) else None
197
+
198
+ enriched: Dict[str, Any] = dict(result)
199
+ if task_id:
200
+ enriched.setdefault("task", task_id)
201
+ enriched.setdefault("model_version", model_version or "pv-chestxray-pneumonia")
202
+ return enriched
203
+
204
+ return await asyncio.to_thread(_run)
205
+
206
+
207
+ async def run_pneumonia_attention_map(image_bytes: bytes) -> bytes:
208
+ """
209
+ Generates an attention/Grad-CAM style map for pneumonia as PNG bytes.
210
+ """
211
+
212
+ def _run() -> bytes:
213
+ _ensure_pneumonia_ready()
214
+ if len(image_bytes) > _PNEUMONIA_MAX_BYTES:
215
+ raise ValueError("File too large. Max size is 10MB.")
216
+ image = _load_image_from_bytes(image_bytes)
217
+ ensemble = _PneumoniaEnsembleSingleton.get()
218
+ result = ensemble.predict(image, use_tta=False, explain=True)
219
+
220
+ data_uri_candidates = []
221
+ explanation_url = result.get("explanation_url")
222
+ if explanation_url:
223
+ data_uri_candidates.append(explanation_url)
224
+
225
+ explanations = result.get("explanations")
226
+ if explanations is not None:
227
+ primary = getattr(explanations, "primary", None)
228
+ auxiliary = getattr(explanations, "auxiliary", None)
229
+ if primary is not None and getattr(primary, "url", None):
230
+ data_uri_candidates.append(primary.url)
231
+ if auxiliary is not None and getattr(auxiliary, "url", None):
232
+ data_uri_candidates.append(auxiliary.url)
233
+
234
+ for data_uri in data_uri_candidates:
235
+ decoded = _decode_data_uri(data_uri)
236
+ if decoded:
237
+ return decoded
238
+
239
+ raise RuntimeError("Attention map could not be generated for this image.")
240
+
241
+ return await asyncio.to_thread(_run)
242
+
243
+
244
+ async def run_cardiomegaly_inference(image_bytes: bytes, *, use_tta: bool = True, explain: bool = False) -> Dict[str, Any]:
245
+ def _run() -> Dict[str, Any]:
246
+ _ensure_cardiomegaly_ready()
247
+ if len(image_bytes) > _CARDIOMEGALY_MAX_BYTES:
248
+ raise ValueError("File too large. Max size is 10MB.")
249
+ image = _load_image_from_bytes(image_bytes)
250
+ ensemble = _CardiomegalyEnsembleSingleton.get()
251
+ result = ensemble.predict(image, use_tta=use_tta and _CARDIOMEGALY_TTA_ALLOWED, explain=explain)
252
+ result = dict(result)
253
+ result.setdefault("model_version", "pv-chestxray-cardiomegaly")
254
+ return result
255
+
256
+ return await asyncio.to_thread(_run)
257
+
258
+
259
+ async def run_cardiomegaly_attention_map(image_bytes: bytes) -> bytes:
260
+ def _run() -> bytes:
261
+ _ensure_cardiomegaly_ready()
262
+ if len(image_bytes) > _CARDIOMEGALY_MAX_BYTES:
263
+ raise ValueError("File too large. Max size is 10MB.")
264
+ image = _load_image_from_bytes(image_bytes)
265
+ ensemble = _CardiomegalyEnsembleSingleton.get()
266
+ result = ensemble.predict(image, use_tta=False, explain=True)
267
+ for candidate in [result.get("explanation_url"), getattr(getattr(result.get("explanations"), "primary", None), "url", None),
268
+ getattr(getattr(result.get("explanations"), "auxiliary", None), "url", None)]:
269
+ if candidate:
270
+ decoded = _decode_data_uri(candidate)
271
+ if decoded:
272
+ return decoded
273
+ raise RuntimeError("Attention map could not be generated for this image.")
274
+
275
+ return await asyncio.to_thread(_run)
276
+
277
+
278
+ async def run_pleuraleffusion_inference(image_bytes: bytes, *, use_tta: bool = True, explain: bool = False) -> Dict[str, Any]:
279
+ def _run() -> Dict[str, Any]:
280
+ _ensure_pleuraleffusion_ready()
281
+ if len(image_bytes) > _PLEURALEFFUSION_MAX_BYTES:
282
+ raise ValueError("File too large. Max size is 10MB.")
283
+ image = _load_image_from_bytes(image_bytes)
284
+ ensemble = _PleuralEffusionEnsembleSingleton.get()
285
+ result = ensemble.predict(image, use_tta=use_tta and _PLEURALEFFUSION_TTA_ALLOWED, explain=explain)
286
+ result = dict(result)
287
+ result.setdefault("model_version", "pv-chestxray-pleuraleffusion")
288
+ return result
289
+
290
+ return await asyncio.to_thread(_run)
291
+
292
+
293
+ async def run_pleuraleffusion_attention_map(image_bytes: bytes) -> bytes:
294
+ def _run() -> bytes:
295
+ _ensure_pleuraleffusion_ready()
296
+ if len(image_bytes) > _PLEURALEFFUSION_MAX_BYTES:
297
+ raise ValueError("File too large. Max size is 10MB.")
298
+ image = _load_image_from_bytes(image_bytes)
299
+ ensemble = _PleuralEffusionEnsembleSingleton.get()
300
+ result = ensemble.predict(image, use_tta=False, explain=True)
301
+ for candidate in [result.get("explanation_url"), getattr(getattr(result.get("explanations"), "primary", None), "url", None),
302
+ getattr(getattr(result.get("explanations"), "auxiliary", None), "url", None)]:
303
+ if candidate:
304
+ decoded = _decode_data_uri(candidate)
305
+ if decoded:
306
+ return decoded
307
+ raise RuntimeError("Attention map could not be generated for this image.")
308
+
309
+ return await asyncio.to_thread(_run)
310
+
311
+
312
+ async def run_pleuralthickening_inference(image_bytes: bytes, *, use_tta: bool = True, explain: bool = False) -> Dict[str, Any]:
313
+ def _run() -> Dict[str, Any]:
314
+ _ensure_pleuralthickening_ready()
315
+ if len(image_bytes) > _PLEURALTHICKENING_MAX_BYTES:
316
+ raise ValueError("File too large. Max size is 10MB.")
317
+ image = _load_image_from_bytes(image_bytes)
318
+ ensemble = _PleuralThickeningEnsembleSingleton.get()
319
+ result = ensemble.predict(image, use_tta=use_tta and _PLEURALTHICKENING_TTA_ALLOWED, explain=explain)
320
+ result = dict(result)
321
+ result.setdefault("model_version", "pv-chestxray-pleuralthickening")
322
+ return result
323
+
324
+ return await asyncio.to_thread(_run)
325
+
326
+
327
+ async def run_pleuralthickening_attention_map(image_bytes: bytes) -> bytes:
328
+ def _run() -> bytes:
329
+ _ensure_pleuralthickening_ready()
330
+ if len(image_bytes) > _PLEURALTHICKENING_MAX_BYTES:
331
+ raise ValueError("File too large. Max size is 10MB.")
332
+ image = _load_image_from_bytes(image_bytes)
333
+ ensemble = _PleuralThickeningEnsembleSingleton.get()
334
+ result = ensemble.predict(image, use_tta=False, explain=True)
335
+ for candidate in [result.get("explanation_url"), getattr(getattr(result.get("explanations"), "primary", None), "url", None),
336
+ getattr(getattr(result.get("explanations"), "auxiliary", None), "url", None)]:
337
+ if candidate:
338
+ decoded = _decode_data_uri(candidate)
339
+ if decoded:
340
+ return decoded
341
+ raise RuntimeError("Attention map could not be generated for this image.")
342
+
343
+ return await asyncio.to_thread(_run)
344
+
345
+
346
+ async def run_aorticenlargement_inference(image_bytes: bytes, *, use_tta: bool = True, explain: bool = False) -> Dict[str, Any]:
347
+ def _run() -> Dict[str, Any]:
348
+ _ensure_aorticenlargement_ready()
349
+ if len(image_bytes) > _AORTICENLARGEMENT_MAX_BYTES:
350
+ raise ValueError("File too large. Max size is 10MB.")
351
+ image = _load_image_from_bytes(image_bytes)
352
+ ensemble = _AorticEnlargementEnsembleSingleton.get()
353
+ result = ensemble.predict(image, use_tta=use_tta and _AORTICENLARGEMENT_TTA_ALLOWED, explain=explain)
354
+ result = dict(result)
355
+ result.setdefault("model_version", "pv-chestxray-aorticenlargement")
356
+ return result
357
+
358
+ return await asyncio.to_thread(_run)
359
+
360
+
361
+ async def run_aorticenlargement_attention_map(image_bytes: bytes) -> bytes:
362
+ def _run() -> bytes:
363
+ _ensure_aorticenlargement_ready()
364
+ if len(image_bytes) > _AORTICENLARGEMENT_MAX_BYTES:
365
+ raise ValueError("File too large. Max size is 10MB.")
366
+ image = _load_image_from_bytes(image_bytes)
367
+ ensemble = _AorticEnlargementEnsembleSingleton.get()
368
+ result = ensemble.predict(image, use_tta=False, explain=True)
369
+ for candidate in [result.get("explanation_url"), getattr(getattr(result.get("explanations"), "primary", None), "url", None),
370
+ getattr(getattr(result.get("explanations"), "auxiliary", None), "url", None)]:
371
+ if candidate:
372
+ decoded = _decode_data_uri(candidate)
373
+ if decoded:
374
+ return decoded
375
+ raise RuntimeError("Attention map could not be generated for this image.")
376
+
377
+ return await asyncio.to_thread(_run)
378
+
379
+
380
+ async def run_tuberculosis_inference(image_bytes: bytes, *, use_tta: bool = True, explain: bool = False) -> Dict[str, Any]:
381
+ def _run() -> Dict[str, Any]:
382
+ _ensure_tuberculosis_ready()
383
+ if len(image_bytes) > _TUBERCULOSIS_MAX_BYTES:
384
+ raise ValueError("File too large. Max size is 10MB.")
385
+ image = _load_image_from_bytes(image_bytes)
386
+ ensemble = _TuberculosisEnsembleSingleton.get()
387
+ result = ensemble.predict(image, use_tta=use_tta and _TUBERCULOSIS_TTA_ALLOWED, explain=explain)
388
+ result = dict(result)
389
+ result.setdefault("model_version", "pv-chestxray-tuberculosis")
390
+ return result
391
+
392
+ return await asyncio.to_thread(_run)
393
+
394
+
395
+ async def run_tuberculosis_attention_map(image_bytes: bytes) -> bytes:
396
+ def _run() -> bytes:
397
+ _ensure_tuberculosis_ready()
398
+ if len(image_bytes) > _TUBERCULOSIS_MAX_BYTES:
399
+ raise ValueError("File too large. Max size is 10MB.")
400
+ image = _load_image_from_bytes(image_bytes)
401
+ ensemble = _TuberculosisEnsembleSingleton.get()
402
+ result = ensemble.predict(image, use_tta=False, explain=True)
403
+ for candidate in [result.get("explanation_url"), getattr(getattr(result.get("explanations"), "primary", None), "url", None),
404
+ getattr(getattr(result.get("explanations"), "auxiliary", None), "url", None)]:
405
+ if candidate:
406
+ decoded = _decode_data_uri(candidate)
407
+ if decoded:
408
+ return decoded
409
+ raise RuntimeError("Attention map could not be generated for this image.")
410
+
411
+ return await asyncio.to_thread(_run)
412
+
413
+
414
+ async def run_brainmri_inference(image_bytes: bytes, *, use_tta: bool = True, explain: bool = False) -> Dict[str, Any]:
415
+ def _run() -> Dict[str, Any]:
416
+ _ensure_brainmri_ready()
417
+ if len(image_bytes) > _BRAINMRI_MAX_BYTES:
418
+ raise ValueError("File too large. Max size is 10MB.")
419
+ image = _load_image_from_bytes(image_bytes)
420
+ ensemble = _BrainMRIEnsembleSingleton.get()
421
+ result = ensemble.predict(image, use_tta=use_tta and _BRAINMRI_TTA_ALLOWED, explain=explain)
422
+ result = dict(result)
423
+ result.setdefault("model_version", "pv-brainmri-tumorclassification")
424
+ return result
425
+
426
+ return await asyncio.to_thread(_run)
427
+
428
+
429
+ async def run_brainmri_attention_map(image_bytes: bytes) -> bytes:
430
+ def _run() -> bytes:
431
+ _ensure_brainmri_ready()
432
+ if len(image_bytes) > _BRAINMRI_MAX_BYTES:
433
+ raise ValueError("File too large. Max size is 10MB.")
434
+ image = _load_image_from_bytes(image_bytes)
435
+ ensemble = _BrainMRIEnsembleSingleton.get()
436
+ result = ensemble.predict(image, use_tta=False, explain=True)
437
+ for candidate in [result.get("explanation_url"), getattr(getattr(result.get("explanations"), "primary", None), "url", None),
438
+ getattr(getattr(result.get("explanations"), "auxiliary", None), "url", None)]:
439
+ if candidate:
440
+ decoded = _decode_data_uri(candidate)
441
+ if decoded:
442
+ return decoded
443
+ raise RuntimeError("Attention map could not be generated for this image.")
444
+
445
+ return await asyncio.to_thread(_run)
446
+
447
+
448
+ __all__ = [
449
+ "run_pneumonia_inference",
450
+ "run_pneumonia_attention_map",
451
+ "run_cardiomegaly_inference",
452
+ "run_cardiomegaly_attention_map",
453
+ "run_pleuraleffusion_inference",
454
+ "run_pleuraleffusion_attention_map",
455
+ "run_pleuralthickening_inference",
456
+ "run_pleuralthickening_attention_map",
457
+ "run_aorticenlargement_inference",
458
+ "run_aorticenlargement_attention_map",
459
+ "run_tuberculosis_inference",
460
+ "run_tuberculosis_attention_map",
461
+ "run_brainmri_inference",
462
+ "run_brainmri_attention_map",
463
+ ]
pnotp_api/schemas.py ADDED
@@ -0,0 +1,22 @@
1
+ """Shared Pydantic schemas for the PnotP API."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, Optional
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class PredictionResult(BaseModel):
10
+ """Normalized binary classification response."""
11
+
12
+ task: str
13
+ positive_prob: float
14
+ negative_prob: float
15
+ prediction: bool
16
+ threshold: float
17
+ model_version: str
18
+ metadata: Optional[Dict[str, Any]] = None
19
+
20
+
21
+ __all__ = ["PredictionResult"]
22
+
pnotp_api/security.py ADDED
@@ -0,0 +1,40 @@
1
+ """API-key authentication for the PnotP API."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from fastapi import Header, HTTPException, status
7
+
8
+ from .db import get_api_key, touch_api_key_usage
9
+
10
+
11
+ @dataclass
12
+ class ApiKeyInfo:
13
+ user_external_id: str
14
+ plan_code: str
15
+ key_prefix: str
16
+
17
+
18
+ async def require_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> ApiKeyInfo:
19
+ """
20
+ Validates the ``X-API-Key`` header against the DB.
21
+ """
22
+ record = get_api_key(x_api_key)
23
+ if record is None:
24
+ raise HTTPException(
25
+ status_code=status.HTTP_401_UNAUTHORIZED,
26
+ detail="Invalid or missing API key",
27
+ )
28
+ try:
29
+ touch_api_key_usage(x_api_key)
30
+ except Exception:
31
+ # Best-effort; do not block requests if bookkeeping fails.
32
+ pass
33
+ return ApiKeyInfo(
34
+ user_external_id=record["user_external_id"],
35
+ plan_code=record["plan_code"],
36
+ key_prefix=record["key_prefix"],
37
+ )
38
+
39
+
40
+ __all__ = ["require_api_key", "ApiKeyInfo"]