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 +23 -0
- pnotp/client.py +76 -0
- pnotp/exceptions.py +13 -0
- pnotp/models.py +87 -0
- pnotp-0.1.0.dist-info/METADATA +65 -0
- pnotp-0.1.0.dist-info/RECORD +14 -0
- pnotp-0.1.0.dist-info/WHEEL +4 -0
- pnotp_api/__init__.py +4 -0
- pnotp_api/db.py +118 -0
- pnotp_api/main.py +171 -0
- pnotp_api/models_registry.py +102 -0
- pnotp_api/pathovision_bridge.py +463 -0
- pnotp_api/schemas.py +22 -0
- pnotp_api/security.py +40 -0
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,,
|
pnotp_api/__init__.py
ADDED
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"]
|