Spaces:
Running on Zero
Running on Zero
deploy API
Browse files- serving/app/main.py +194 -57
- serving/app/schemas.py +287 -0
- src/epills/core/catalogue/confidence.py +43 -0
- src/epills/core/inference/embedder.py +4 -1
- src/epills/core/inference/pill_finder.py +172 -28
- src/epills/core/safety/rules.py +254 -251
serving/app/main.py
CHANGED
|
@@ -34,21 +34,28 @@ from typing import Literal
|
|
| 34 |
|
| 35 |
import numpy as np
|
| 36 |
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
| 37 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 38 |
from fastapi.concurrency import run_in_threadpool
|
|
|
|
|
|
|
| 39 |
from fastapi.responses import FileResponse, JSONResponse
|
| 40 |
from PIL import Image, ImageOps, UnidentifiedImageError
|
| 41 |
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
| 42 |
|
|
|
|
|
|
|
| 43 |
from epills.core.imprint.match import GALLERY_MIN_WEIGHT, read_hits
|
| 44 |
from epills.core.inference.matching import check_imprint, decide
|
|
|
|
| 45 |
from epills.core.inference.preprocess import WINDOW, crop_window
|
| 46 |
from epills.core.safety import openfda
|
| 47 |
from epills.core.safety.db import SafetyDB
|
| 48 |
-
from epills.core.safety.rules import check_dose, check_medications, resolve_medication
|
| 49 |
from epills.core.safety.rxnorm import RxNormClient, RxNormError
|
|
|
|
| 50 |
|
| 51 |
-
API_VERSION = "0.
|
|
|
|
| 52 |
LOCAL = {"safety_db": Path("data/processed/safety/safety.sqlite"),
|
| 53 |
"embedder": Path("outputs/serving/stage2_model.pt"),
|
| 54 |
"catalogue": Path("data/processed/catalogue")}
|
|
@@ -56,11 +63,18 @@ IDENTIFY_NOTE = ("Possible matches from a catalogue of US pills, ranked by how a
|
|
| 56 |
"what a pill is. Check with your pharmacist.")
|
| 57 |
LABEL_NOTE = ("Medicines read from the photo. Check the name and strength against the box or label before adding "
|
| 58 |
"it to your list.")
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
"ambiguous": "This pill looks like more than one of your medicines. Turn it over and photograph it again.",
|
| 63 |
}
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
def _asset(env, repo_path, local):
|
|
@@ -163,7 +177,9 @@ app = FastAPI(
|
|
| 163 |
description="Pill verification against a patient's enrolled medications, pill identification and pictures from "
|
| 164 |
"a catalogue of US pills, and medication safety checks. A medication verification aid, not a medical "
|
| 165 |
"device: every alert defers to a pharmacist. Interaction data: DDInter 2.0 (CC BY-NC-SA 4.0); "
|
| 166 |
-
"ingredients: RxNorm (NLM); labels: openFDA; pill pictures: NLM C3PI (research and development use)."
|
|
|
|
|
|
|
| 167 |
)
|
| 168 |
app.state.rxnorm = RxNormClient()
|
| 169 |
app.state.safety_db = app.state.embedder = app.state.catalogue = app.state.feedback = None
|
|
@@ -178,10 +194,30 @@ async def api_key(request: Request, call_next):
|
|
| 178 |
key = os.environ.get("EPILLS_API_KEY")
|
| 179 |
if key and request.url.path.startswith("/v1") and request.method != "OPTIONS" \
|
| 180 |
and request.headers.get("X-API-Key") != key:
|
| 181 |
-
return JSONResponse({"detail": "missing or wrong X-API-Key"}, status_code=401)
|
| 182 |
return await call_next(request)
|
| 183 |
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
# ---- request models ---------------------------------------------------------------------------------------------
|
| 186 |
class Medication(BaseModel):
|
| 187 |
id: str = Field(description="The app's id for this medication")
|
|
@@ -223,7 +259,9 @@ class GalleryMedication(BaseModel):
|
|
| 223 |
class MatchRequest(BaseModel):
|
| 224 |
queries: list[list[float]] = Field(min_length=1, description="Embeddings of the pills to check (/v1/embed)")
|
| 225 |
gallery: list[GalleryMedication] = Field(min_length=1, description="The patient's enrolled medications")
|
| 226 |
-
model_version: str | None = Field(None, description="
|
|
|
|
|
|
|
| 227 |
query_reads: list[list[str]] | None = Field(None, description="Per query, its `imprint_reads` from /v1/embed "
|
| 228 |
"(read_imprint=true): turns on the imprint check")
|
| 229 |
|
|
@@ -287,6 +325,29 @@ def token_weights():
|
|
| 287 |
return None
|
| 288 |
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
def match_decisions(queries, gallery, embedder, reads=None):
|
| 291 |
"""Per query: the decision, and with reads (per query, its OCR strings) the imprint check on it."""
|
| 292 |
dim = queries.shape[1]
|
|
@@ -335,16 +396,22 @@ def product_summary(cat, pill_id, **extra):
|
|
| 335 |
|
| 336 |
def identify_pill(embeddings, imprint=None, shape=None, colour=None, top_k=5, reads=None):
|
| 337 |
cat = catalogue_for_identify()
|
| 338 |
-
candidates, notes = cat.identify(embeddings, top_k=top_k, imprint=imprint, shape=shape, colour=colour,
|
| 339 |
reads=reads)
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
|
| 344 |
# ---- endpoints: health ----------------------------------------------------------------------------------------------
|
| 345 |
-
@app.get("/health")
|
| 346 |
def health():
|
| 347 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 348 |
try:
|
| 349 |
db = asset("safety_db")
|
| 350 |
safety = {"loaded": True, "built_at": db.meta.get("built_at"), "pairs": db.meta.get("pairs")}
|
|
@@ -355,9 +422,12 @@ def health():
|
|
| 355 |
device = getattr(getattr(emb, "forward", None), "device", None) or getattr(emb, "device", None)
|
| 356 |
return {
|
| 357 |
"status": "ok",
|
|
|
|
| 358 |
"api_version": API_VERSION,
|
|
|
|
| 359 |
"model_repo": os.environ.get("EPILLS_MODEL_REPO", "not set"),
|
| 360 |
"model": {"loaded": emb is not None, "version": getattr(emb, "version", None),
|
|
|
|
| 361 |
"device": str(device) if device else None, "error": _errors.get("embedder")},
|
| 362 |
"catalogue": {"loaded": cat is not None, "products": len(cat) if cat else None,
|
| 363 |
"model_version": getattr(cat, "model_version", None), "error": _errors.get("catalogue")},
|
|
@@ -367,8 +437,37 @@ def health():
|
|
| 367 |
}
|
| 368 |
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
# ---- endpoints: Stage 2 -------------------------------------------------------------------------------------------
|
| 371 |
-
@app.post("/v1/embed")
|
| 372 |
async def embed(image: UploadFile = File(description="A pill crop, or a photo with `box`"),
|
| 373 |
box: str | None = Form(None, description="Optional [x0, y0, x1, y1] of the pill in the photo; the "
|
| 374 |
"server then crops it with the crop contract"),
|
|
@@ -386,11 +485,13 @@ async def embed(image: UploadFile = File(description="A pill crop, or a photo wi
|
|
| 386 |
vector = (await run_in_threadpool(emb, crops))[0]
|
| 387 |
reads = await run_in_threadpool(imprint_reads, crops) if read_imprint else None
|
| 388 |
return {"embedding": [round(float(v), 6) for v in vector], "dim": int(vector.shape[0]),
|
| 389 |
-
"model_version": emb.version, "
|
| 390 |
-
"
|
|
|
|
|
|
|
| 391 |
|
| 392 |
|
| 393 |
-
@app.post("/v1/match")
|
| 394 |
def match(request: MatchRequest):
|
| 395 |
"""
|
| 396 |
**Which of my medicines is this?** Pill embeddings + the patient's gallery -> per pill: `match` (with the
|
|
@@ -398,18 +499,17 @@ def match(request: MatchRequest):
|
|
| 398 |
The threshold and margin are the served model's calibration.
|
| 399 |
"""
|
| 400 |
emb = asset("embedder")
|
| 401 |
-
|
| 402 |
-
raise HTTPException(409, f"gallery embedded with model {request.model_version}, but {emb.version} is "
|
| 403 |
-
f"served: re-embed the enrolment photos with /v1/embed")
|
| 404 |
queries = np.asarray(request.queries, np.float32)
|
| 405 |
if request.query_reads is not None and len(request.query_reads) != len(queries):
|
| 406 |
raise HTTPException(422, "query_reads needs one list of reads per query")
|
| 407 |
return {"results": match_decisions(queries, request.gallery, emb, request.query_reads),
|
| 408 |
-
"model_version": emb.version,
|
| 409 |
-
"threshold": emb.calibration["threshold"], "margin": emb.calibration["margin"]
|
|
|
|
| 410 |
|
| 411 |
|
| 412 |
-
@app.post("/v1/identify")
|
| 413 |
async def identify(images: list[UploadFile] = File(description="One pill: 1 photo, or 2 (front and back)"),
|
| 414 |
boxes: str | None = Form(None, description="Optional [[x0, y0, x1, y1], ...], one per image"),
|
| 415 |
imprint: str | None = Form(None, description="Letters or numbers on the pill, e.g. 'M 547'"),
|
|
@@ -434,10 +534,24 @@ async def identify(images: list[UploadFile] = File(description="One pill: 1 phot
|
|
| 434 |
per_crop = await run_in_threadpool(imprint_reads, crops)
|
| 435 |
reads = [r for crop_reads in per_crop for r in crop_reads] if per_crop else None
|
| 436 |
return {**identify_pill(vectors, imprint, shape, colour, top_k, reads), "imprint_reads": reads,
|
| 437 |
-
"model_version": emb.version}
|
| 438 |
|
| 439 |
|
| 440 |
-
@app.post("/v1/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
async def scan(image: UploadFile = File(description="A full photo of the pills"),
|
| 442 |
boxes: str | None = Form(None, description="Pill boxes from a detector, [[x0, y0, x1, y1], ...]; "
|
| 443 |
"without them a stand-in finder looks for pills on a "
|
|
@@ -445,28 +559,34 @@ async def scan(image: UploadFile = File(description="A full photo of the pills")
|
|
| 445 |
gallery: str | None = Form(None, description="JSON list of {medication_id, name, embeddings}: the "
|
| 446 |
"patient's gallery. Without it, each pill is "
|
| 447 |
"identified against the catalogue"),
|
| 448 |
-
schedule: str | None = Form(None, description="
|
| 449 |
-
"adds
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
top_k: int = Form(3, ge=1, le=10),
|
| 452 |
read_imprint: bool = Form(True, description="Read each pill's imprint: checks the gallery decisions "
|
| 453 |
"(medications with an `imprint`), or re-ranks catalogue "
|
| 454 |
"candidates")):
|
| 455 |
"""
|
| 456 |
-
**
|
| 457 |
-
|
|
|
|
| 458 |
"""
|
| 459 |
emb = asset("embedder")
|
| 460 |
photo = await read_image(image)
|
| 461 |
box_list = parse_boxes(boxes)
|
| 462 |
-
found_by = "boxes sent"
|
| 463 |
if box_list is None:
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
|
|
|
|
|
|
|
|
|
| 467 |
if not box_list:
|
| 468 |
-
result["message"] =
|
| 469 |
-
"again.")
|
| 470 |
return result
|
| 471 |
crops = emb.frame(photo, box_list)
|
| 472 |
vectors = await run_in_threadpool(emb, crops) # in a thread: a GPU queue wait mustn't block the API
|
|
@@ -476,33 +596,49 @@ async def scan(image: UploadFile = File(description="A full photo of the pills")
|
|
| 476 |
meds = [GalleryMedication(**g) for g in parse_json(gallery, "gallery")]
|
| 477 |
except (TypeError, ValidationError) as error:
|
| 478 |
raise HTTPException(422, f"gallery: {error}")
|
| 479 |
-
|
| 480 |
-
raise HTTPException(409, f"gallery embedded with model {model_version}, but {emb.version} is served")
|
| 481 |
reads = None
|
| 482 |
if read_imprint and (meds is None or any(m.imprint for m in meds)):
|
| 483 |
reads = await run_in_threadpool(imprint_reads, crops)
|
| 484 |
|
| 485 |
if meds is not None:
|
| 486 |
decisions = match_decisions(vectors, meds, emb, reads)
|
| 487 |
-
result["pills"] = [{"box": list(b),
|
| 488 |
-
|
|
|
|
| 489 |
if schedule:
|
| 490 |
try:
|
| 491 |
sched = [ScheduledMedication(**s).model_dump() for s in parse_json(schedule, "schedule")]
|
| 492 |
except (TypeError, ValidationError) as error:
|
| 493 |
raise HTTPException(422, f"schedule: {error}")
|
| 494 |
-
|
| 495 |
-
for d in decisions],
|
| 496 |
-
{m.medication_id: m.name or m.medication_id for m in meds})
|
| 497 |
-
result["dose"] = {"alerts": [a.to_dict() for a in alerts], "summary": summary}
|
| 498 |
else:
|
| 499 |
-
result["pills"] = [{"box": list(b),
|
|
|
|
| 500 |
"imprint_reads": reads[i] if reads else None}
|
| 501 |
-
for i, (b, v) in enumerate(zip(box_list, vectors))]
|
| 502 |
return result
|
| 503 |
|
| 504 |
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
async def read_label(image: UploadFile = File(description="A photo of a medicine box or a pharmacy label, close "
|
| 507 |
"up and straight on")):
|
| 508 |
"""
|
|
@@ -525,7 +661,7 @@ async def read_label(image: UploadFile = File(description="A photo of a medicine
|
|
| 525 |
return result
|
| 526 |
|
| 527 |
|
| 528 |
-
@app.get("/v1/pills/search")
|
| 529 |
def pills_search(name: str = Query(min_length=2, description="Brand or generic name, e.g. 'metformin', 'Glucophage'"),
|
| 530 |
strength: str | None = Query(None, description="e.g. '500 MG'"),
|
| 531 |
limit: int = Query(50, ge=1, le=200)):
|
|
@@ -541,7 +677,7 @@ def pills_search(name: str = Query(min_length=2, description="Brand or generic n
|
|
| 541 |
"source": cat.meta.get("source")}
|
| 542 |
|
| 543 |
|
| 544 |
-
@app.get("/v1/pills/{pill_id}")
|
| 545 |
def pill(pill_id: str):
|
| 546 |
"""One catalogue product: names, ingredients, appearance, imprint and its pictures."""
|
| 547 |
cat = asset("catalogue")
|
|
@@ -560,7 +696,7 @@ def pill_image(pill_id: str, n: int):
|
|
| 560 |
|
| 561 |
|
| 562 |
# ---- endpoints: feedback for retraining (opt-in) -----------------------------------------------------------------
|
| 563 |
-
@app.post("/v1/feedback")
|
| 564 |
async def feedback(image: UploadFile = File(description="The pill: a crop, or a photo with `box`"),
|
| 565 |
box: str | None = Form(None, description="Optional [x0, y0, x1, y1] of the pill in the photo"),
|
| 566 |
consent: bool = Form(description="The user agreed to share this photo to improve the model"),
|
|
@@ -595,7 +731,7 @@ async def feedback(image: UploadFile = File(description="The pill: a crop, or a
|
|
| 595 |
return {"stored": True, "item": item, "note": "Thank you. The photo is used only to improve pill recognition."}
|
| 596 |
|
| 597 |
|
| 598 |
-
@app.delete("/v1/feedback/{consent_id}")
|
| 599 |
def feedback_delete(consent_id: str):
|
| 600 |
"""Withdrawal of consent: deletes every photo stored under this consent id."""
|
| 601 |
try:
|
|
@@ -605,7 +741,7 @@ def feedback_delete(consent_id: str):
|
|
| 605 |
|
| 606 |
|
| 607 |
# ---- endpoints: Stage 3 -------------------------------------------------------------------------------------------
|
| 608 |
-
@app.post("/v1/safety/medications")
|
| 609 |
def safety_medications(request: MedicationsRequest):
|
| 610 |
"""
|
| 611 |
Run when a medication is added or changed: interactions between every pair of the patient's medications
|
|
@@ -624,18 +760,19 @@ def safety_medications(request: MedicationsRequest):
|
|
| 624 |
}
|
| 625 |
|
| 626 |
|
| 627 |
-
@app.post("/v1/safety/dose")
|
| 628 |
def safety_dose(request: DoseRequest):
|
| 629 |
"""
|
| 630 |
-
|
| 631 |
-
|
|
|
|
| 632 |
"""
|
| 633 |
alerts, summary = check_dose([s.model_dump() for s in request.schedule],
|
| 634 |
[p.model_dump() for p in request.pills], request.names)
|
| 635 |
return {"alerts": [a.to_dict() for a in alerts], "summary": summary}
|
| 636 |
|
| 637 |
|
| 638 |
-
@app.get("/v1/safety/label")
|
| 639 |
def safety_label(rxcui: str | None = Query(None), ingredient: str | None = Query(None)):
|
| 640 |
"""The warning sections of the official US label (openFDA), quoted as written; only when the user asks."""
|
| 641 |
if not rxcui and not ingredient:
|
|
|
|
| 34 |
|
| 35 |
import numpy as np
|
| 36 |
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
|
|
|
| 37 |
from fastapi.concurrency import run_in_threadpool
|
| 38 |
+
from fastapi.exceptions import RequestValidationError
|
| 39 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 40 |
from fastapi.responses import FileResponse, JSONResponse
|
| 41 |
from PIL import Image, ImageOps, UnidentifiedImageError
|
| 42 |
from pydantic import BaseModel, Field, ValidationError
|
| 43 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 44 |
|
| 45 |
+
from epills.core.catalogue.confidence import assess
|
| 46 |
+
from epills.core.catalogue.store import IMPRINT_ALPHA, IMPRINT_TOP_K
|
| 47 |
from epills.core.imprint.match import GALLERY_MIN_WEIGHT, read_hits
|
| 48 |
from epills.core.inference.matching import check_imprint, decide
|
| 49 |
+
from epills.core.inference.pill_finder import detect_pills
|
| 50 |
from epills.core.inference.preprocess import WINDOW, crop_window
|
| 51 |
from epills.core.safety import openfda
|
| 52 |
from epills.core.safety.db import SafetyDB
|
| 53 |
+
from epills.core.safety.rules import SOURCE_SCHEDULE, Alert, check_dose, check_medications, resolve_medication
|
| 54 |
from epills.core.safety.rxnorm import RxNormClient, RxNormError
|
| 55 |
+
from serving.app import schemas as S
|
| 56 |
|
| 57 |
+
API_VERSION = "0.6.0"
|
| 58 |
+
STARTED_AT = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
|
| 59 |
LOCAL = {"safety_db": Path("data/processed/safety/safety.sqlite"),
|
| 60 |
"embedder": Path("outputs/serving/stage2_model.pt"),
|
| 61 |
"catalogue": Path("data/processed/catalogue")}
|
|
|
|
| 63 |
"what a pill is. Check with your pharmacist.")
|
| 64 |
LABEL_NOTE = ("Medicines read from the photo. Check the name and strength against the box or label before adding "
|
| 65 |
"it to your list.")
|
| 66 |
+
VISUAL_LIMITS = ("A visual comparison with reference photos only: it can't confirm what the medicine is, its strength, "
|
| 67 |
+
"or that it is safe to take.")
|
| 68 |
+
NO_PILL = "No pill found. Spread the pills apart on a plain background and photograph them again."
|
| 69 |
+
STAND_IN = "stand-in finder (plain backgrounds only)"
|
| 70 |
+
MATCH_MESSAGES = { # visual evidence only (blueprint D5): "looks like", never "is"
|
| 71 |
+
"match": "This pill looks like your {name}.",
|
| 72 |
+
"unknown": "This pill doesn't look like any of your medicines. Set it aside. Check with your pharmacist.",
|
| 73 |
"ambiguous": "This pill looks like more than one of your medicines. Turn it over and photograph it again.",
|
| 74 |
}
|
| 75 |
+
ERROR_CODES = {401: "unauthorised", 404: "not_found", 409: "version_mismatch", 422: "invalid_input",
|
| 76 |
+
502: "upstream_failed", 503: "not_ready"}
|
| 77 |
+
RETRY_AFTER = 20 # seconds, with 503 not_ready (a part still loading, or the Space waking up)
|
| 78 |
|
| 79 |
|
| 80 |
def _asset(env, repo_path, local):
|
|
|
|
| 177 |
description="Pill verification against a patient's enrolled medications, pill identification and pictures from "
|
| 178 |
"a catalogue of US pills, and medication safety checks. A medication verification aid, not a medical "
|
| 179 |
"device: every alert defers to a pharmacist. Interaction data: DDInter 2.0 (CC BY-NC-SA 4.0); "
|
| 180 |
+
"ingredients: RxNorm (NLM); labels: openFDA; pill pictures: NLM C3PI (research and development use). "
|
| 181 |
+
"Stage 2 answers are visual evidence only: they never confirm a medicine or that it is safe to take.",
|
| 182 |
+
responses={code: {"model": S.Error, "description": name} for code, name in ERROR_CODES.items()},
|
| 183 |
)
|
| 184 |
app.state.rxnorm = RxNormClient()
|
| 185 |
app.state.safety_db = app.state.embedder = app.state.catalogue = app.state.feedback = None
|
|
|
|
| 194 |
key = os.environ.get("EPILLS_API_KEY")
|
| 195 |
if key and request.url.path.startswith("/v1") and request.method != "OPTIONS" \
|
| 196 |
and request.headers.get("X-API-Key") != key:
|
| 197 |
+
return JSONResponse({"code": "unauthorised", "detail": "missing or wrong X-API-Key"}, status_code=401)
|
| 198 |
return await call_next(request)
|
| 199 |
|
| 200 |
|
| 201 |
+
@app.exception_handler(StarletteHTTPException)
|
| 202 |
+
async def http_error(_request, error):
|
| 203 |
+
"""One error shape everywhere (blueprint B5): code, detail, and retry_after with Retry-After on 503."""
|
| 204 |
+
body, headers = {"code": ERROR_CODES.get(error.status_code, "error"), "detail": error.detail}, \
|
| 205 |
+
dict(getattr(error, "headers", None) or {})
|
| 206 |
+
if error.status_code == 503:
|
| 207 |
+
body["retry_after"], headers["Retry-After"] = RETRY_AFTER, str(RETRY_AFTER)
|
| 208 |
+
return JSONResponse(body, status_code=error.status_code, headers=headers)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@app.exception_handler(RequestValidationError)
|
| 212 |
+
async def invalid_input(_request, error):
|
| 213 |
+
return JSONResponse({"code": "invalid_input", "detail": jsonable(error.errors())}, status_code=422)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def jsonable(errors):
|
| 217 |
+
return [{k: (v if isinstance(v, (str, int, float, list, type(None))) else str(v)) for k, v in e.items()}
|
| 218 |
+
for e in errors]
|
| 219 |
+
|
| 220 |
+
|
| 221 |
# ---- request models ---------------------------------------------------------------------------------------------
|
| 222 |
class Medication(BaseModel):
|
| 223 |
id: str = Field(description="The app's id for this medication")
|
|
|
|
| 259 |
class MatchRequest(BaseModel):
|
| 260 |
queries: list[list[float]] = Field(min_length=1, description="Embeddings of the pills to check (/v1/embed)")
|
| 261 |
gallery: list[GalleryMedication] = Field(min_length=1, description="The patient's enrolled medications")
|
| 262 |
+
model_version: str | None = Field(None, description="The model_version stored with the gallery's embeddings. "
|
| 263 |
+
"This or embedding_space_id is required")
|
| 264 |
+
embedding_space_id: str | None = Field(None, description="The embedding_space_id stored with the gallery")
|
| 265 |
query_reads: list[list[str]] | None = Field(None, description="Per query, its `imprint_reads` from /v1/embed "
|
| 266 |
"(read_imprint=true): turns on the imprint check")
|
| 267 |
|
|
|
|
| 325 |
return None
|
| 326 |
|
| 327 |
|
| 328 |
+
def space(emb):
|
| 329 |
+
return getattr(emb, "embedding_space_id", emb.version)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def check_gallery_version(emb, model_version, embedding_space_id):
|
| 333 |
+
"""Embeddings compare only within one space (blueprint B3): the gallery must say which space it was made in."""
|
| 334 |
+
if not model_version and not embedding_space_id:
|
| 335 |
+
raise HTTPException(422, "send the model_version (or embedding_space_id) stored with the gallery's embeddings")
|
| 336 |
+
if (model_version and model_version != emb.version) or (embedding_space_id and embedding_space_id != space(emb)):
|
| 337 |
+
raise HTTPException(409, f"gallery embedded with {embedding_space_id or model_version}, but {space(emb)} is "
|
| 338 |
+
f"served: re-embed the enrolment photos with /v1/embed")
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def detection_out(det=None, boxes=None):
|
| 342 |
+
"""The detection block: from the stand-in finder (det), or the boxes the app sent (trusted as given)."""
|
| 343 |
+
if det is None:
|
| 344 |
+
return {"boxes": [list(b) for b in boxes], "box_flags": [[] for _ in boxes], "pill_count": len(boxes),
|
| 345 |
+
"count_reliable": bool(boxes), "flags": [] if boxes else ["no_pills"], "found_by": "boxes sent"}
|
| 346 |
+
return {"boxes": [list(b) for b in det.boxes], "box_flags": det.box_flags, "pill_count": det.pill_count,
|
| 347 |
+
"count_reliable": bool(det.boxes) and "count_uncertain" not in det.flags, "flags": det.flags,
|
| 348 |
+
"found_by": STAND_IN}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
def match_decisions(queries, gallery, embedder, reads=None):
|
| 352 |
"""Per query: the decision, and with reads (per query, its OCR strings) the imprint check on it."""
|
| 353 |
dim = queries.shape[1]
|
|
|
|
| 396 |
|
| 397 |
def identify_pill(embeddings, imprint=None, shape=None, colour=None, top_k=5, reads=None):
|
| 398 |
cat = catalogue_for_identify()
|
| 399 |
+
candidates, notes = cat.identify(embeddings, top_k=max(top_k, 2), imprint=imprint, shape=shape, colour=colour,
|
| 400 |
reads=reads)
|
| 401 |
+
# calibrated on unfiltered searches only: with a typed imprint, shape or colour the confidence is left out
|
| 402 |
+
assessment = None if (imprint or shape or colour) else assess(candidates, cat.model_version)
|
| 403 |
+
return {"candidates": [product_summary(cat, c.pop("pill_id"), **c) for c in candidates[:top_k]], "notes": notes,
|
| 404 |
+
"note": IDENTIFY_NOTE, **(assessment or {"confidence": None, "catalogue_fit": None, "verdict": None,
|
| 405 |
+
"verdict_message": None})}
|
| 406 |
|
| 407 |
|
| 408 |
# ---- endpoints: health ----------------------------------------------------------------------------------------------
|
| 409 |
+
@app.get("/health", response_model=S.Health)
|
| 410 |
def health():
|
| 411 |
+
"""
|
| 412 |
+
Liveness check, and which parts are loaded. `ready` is true once the model, catalogue and safety database are
|
| 413 |
+
all loaded. A sleeping Space answers with an HTML page (not JSON) while it wakes up: retry until this is JSON.
|
| 414 |
+
"""
|
| 415 |
try:
|
| 416 |
db = asset("safety_db")
|
| 417 |
safety = {"loaded": True, "built_at": db.meta.get("built_at"), "pairs": db.meta.get("pairs")}
|
|
|
|
| 422 |
device = getattr(getattr(emb, "forward", None), "device", None) or getattr(emb, "device", None)
|
| 423 |
return {
|
| 424 |
"status": "ok",
|
| 425 |
+
"ready": emb is not None and cat is not None and safety["loaded"],
|
| 426 |
"api_version": API_VERSION,
|
| 427 |
+
"started_at": STARTED_AT,
|
| 428 |
"model_repo": os.environ.get("EPILLS_MODEL_REPO", "not set"),
|
| 429 |
"model": {"loaded": emb is not None, "version": getattr(emb, "version", None),
|
| 430 |
+
"embedding_space_id": space(emb) if emb is not None else None,
|
| 431 |
"device": str(device) if device else None, "error": _errors.get("embedder")},
|
| 432 |
"catalogue": {"loaded": cat is not None, "products": len(cat) if cat else None,
|
| 433 |
"model_version": getattr(cat, "model_version", None), "error": _errors.get("catalogue")},
|
|
|
|
| 437 |
}
|
| 438 |
|
| 439 |
|
| 440 |
+
@app.get("/v1/model-info", response_model=S.ModelInfo)
|
| 441 |
+
def model_info():
|
| 442 |
+
"""
|
| 443 |
+
The served model (blueprint B3): service version vs model version, the embedding space and preprocessing that
|
| 444 |
+
stored embeddings must match, the calibrated decision rule, and what the model was trained on and where it is weak.
|
| 445 |
+
"""
|
| 446 |
+
emb = asset("embedder")
|
| 447 |
+
cal = emb.calibration
|
| 448 |
+
return {
|
| 449 |
+
"api_version": API_VERSION, "model_version": emb.version, "embedding_space_id": space(emb),
|
| 450 |
+
"preprocess_version": getattr(emb, "preprocess_version", None) or "unknown", "embedding_dim": 256,
|
| 451 |
+
"normalized": True, "crop_window": float(getattr(emb, "window", WINDOW)),
|
| 452 |
+
"threshold": cal["threshold"], "margin": cal["margin"],
|
| 453 |
+
"calibration": {"rule": cal.get("rule"), "calibrated_on": cal.get("calibrated_on")},
|
| 454 |
+
"imprint": {"gallery_min_weight": GALLERY_MIN_WEIGHT, "identify_alpha": IMPRINT_ALPHA,
|
| 455 |
+
"identify_top_k": IMPRINT_TOP_K},
|
| 456 |
+
"training_coverage": "Loose-pill photos of US prescription pills (NLM C3PI): 2,362 medicines; catalogue of "
|
| 457 |
+
"4,936 US pill products. Not trained on Singapore products, packaging or blisters.",
|
| 458 |
+
"known_limits": [
|
| 459 |
+
"Other phones and lighting: 48% top-1 over 32 Hong Kong products (MEDISEG), against 83% on held-out C3PI.",
|
| 460 |
+
"Similarities are not probabilities: pills that aren't in the catalogue still score about 0.93 median.",
|
| 461 |
+
"Imprints are readable on about 38% of photos.",
|
| 462 |
+
"The stand-in pill finder works on plain backgrounds only; check count_reliable before counting pills.",
|
| 463 |
+
"Visual evidence only: never confirms the medicine, its strength, or that it is safe to take.",
|
| 464 |
+
],
|
| 465 |
+
"evidence": "visual",
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
|
| 469 |
# ---- endpoints: Stage 2 -------------------------------------------------------------------------------------------
|
| 470 |
+
@app.post("/v1/embed", response_model=S.Embedding)
|
| 471 |
async def embed(image: UploadFile = File(description="A pill crop, or a photo with `box`"),
|
| 472 |
box: str | None = Form(None, description="Optional [x0, y0, x1, y1] of the pill in the photo; the "
|
| 473 |
"server then crops it with the crop contract"),
|
|
|
|
| 485 |
vector = (await run_in_threadpool(emb, crops))[0]
|
| 486 |
reads = await run_in_threadpool(imprint_reads, crops) if read_imprint else None
|
| 487 |
return {"embedding": [round(float(v), 6) for v in vector], "dim": int(vector.shape[0]),
|
| 488 |
+
"model_version": emb.version, "embedding_space_id": space(emb),
|
| 489 |
+
"preprocess_version": getattr(emb, "preprocess_version", None) or "unknown", "normalized": True,
|
| 490 |
+
"framed_by": "box" if b else "as sent", "imprint_reads": reads[0] if reads else None,
|
| 491 |
+
"evidence": "visual"}
|
| 492 |
|
| 493 |
|
| 494 |
+
@app.post("/v1/match", response_model=S.MatchResponse)
|
| 495 |
def match(request: MatchRequest):
|
| 496 |
"""
|
| 497 |
**Which of my medicines is this?** Pill embeddings + the patient's gallery -> per pill: `match` (with the
|
|
|
|
| 499 |
The threshold and margin are the served model's calibration.
|
| 500 |
"""
|
| 501 |
emb = asset("embedder")
|
| 502 |
+
check_gallery_version(emb, request.model_version, request.embedding_space_id)
|
|
|
|
|
|
|
| 503 |
queries = np.asarray(request.queries, np.float32)
|
| 504 |
if request.query_reads is not None and len(request.query_reads) != len(queries):
|
| 505 |
raise HTTPException(422, "query_reads needs one list of reads per query")
|
| 506 |
return {"results": match_decisions(queries, request.gallery, emb, request.query_reads),
|
| 507 |
+
"model_version": emb.version, "embedding_space_id": space(emb),
|
| 508 |
+
"threshold": emb.calibration["threshold"], "margin": emb.calibration["margin"],
|
| 509 |
+
"evidence": "visual", "limits": VISUAL_LIMITS}
|
| 510 |
|
| 511 |
|
| 512 |
+
@app.post("/v1/identify", response_model=S.IdentifyResponse)
|
| 513 |
async def identify(images: list[UploadFile] = File(description="One pill: 1 photo, or 2 (front and back)"),
|
| 514 |
boxes: str | None = Form(None, description="Optional [[x0, y0, x1, y1], ...], one per image"),
|
| 515 |
imprint: str | None = Form(None, description="Letters or numbers on the pill, e.g. 'M 547'"),
|
|
|
|
| 534 |
per_crop = await run_in_threadpool(imprint_reads, crops)
|
| 535 |
reads = [r for crop_reads in per_crop for r in crop_reads] if per_crop else None
|
| 536 |
return {**identify_pill(vectors, imprint, shape, colour, top_k, reads), "imprint_reads": reads,
|
| 537 |
+
"model_version": emb.version, "evidence": "visual", "limits": VISUAL_LIMITS}
|
| 538 |
|
| 539 |
|
| 540 |
+
@app.post("/v1/detect", response_model=S.DetectResponse)
|
| 541 |
+
async def detect(image: UploadFile = File(description="A full photo of the pills")):
|
| 542 |
+
"""
|
| 543 |
+
**Where are the pills?** A photo -> a box per pill with quality flags (blueprint B6). Uses the stand-in finder for
|
| 544 |
+
plain backgrounds until the Stage 1 detector replaces it (same box format). Count pills only when
|
| 545 |
+
`count_reliable` is true; otherwise ask for a new photo (pills apart, plain surface).
|
| 546 |
+
"""
|
| 547 |
+
photo = await read_image(image)
|
| 548 |
+
out = detection_out(await run_in_threadpool(detect_pills, photo))
|
| 549 |
+
if not out["boxes"]:
|
| 550 |
+
out["message"] = NO_PILL
|
| 551 |
+
return out
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
@app.post("/v1/scan", response_model=S.ScanResponse)
|
| 555 |
async def scan(image: UploadFile = File(description="A full photo of the pills"),
|
| 556 |
boxes: str | None = Form(None, description="Pill boxes from a detector, [[x0, y0, x1, y1], ...]; "
|
| 557 |
"without them a stand-in finder looks for pills on a "
|
|
|
|
| 559 |
gallery: str | None = Form(None, description="JSON list of {medication_id, name, embeddings}: the "
|
| 560 |
"patient's gallery. Without it, each pill is "
|
| 561 |
"identified against the catalogue"),
|
| 562 |
+
schedule: str | None = Form(None, description="Deprecated (the main backend owns dose rules): JSON "
|
| 563 |
+
"list of {medication_id, count}; adds an advisory dose "
|
| 564 |
+
"check"),
|
| 565 |
+
model_version: str | None = Form(None, description="The model_version stored with the gallery "
|
| 566 |
+
"(this or embedding_space_id is required with one)"),
|
| 567 |
+
embedding_space_id: str | None = Form(None, description="The embedding_space_id stored with the "
|
| 568 |
+
"gallery"),
|
| 569 |
top_k: int = Form(3, ge=1, le=10),
|
| 570 |
read_imprint: bool = Form(True, description="Read each pill's imprint: checks the gallery decisions "
|
| 571 |
"(medications with an `imprint`), or re-ranks catalogue "
|
| 572 |
"candidates")):
|
| 573 |
"""
|
| 574 |
+
**Candidates for every pill in a photo** (diagnostics; blueprint D3): full photo -> each pill's box with quality
|
| 575 |
+
flags and its visual decision (with the patient's gallery) or catalogue candidates (without one). Not a dose
|
| 576 |
+
verification: the main backend decides with the regimen. Boxes flagged by the finder are not counted.
|
| 577 |
"""
|
| 578 |
emb = asset("embedder")
|
| 579 |
photo = await read_image(image)
|
| 580 |
box_list = parse_boxes(boxes)
|
|
|
|
| 581 |
if box_list is None:
|
| 582 |
+
detection = detection_out(await run_in_threadpool(detect_pills, photo))
|
| 583 |
+
else:
|
| 584 |
+
detection = detection_out(boxes=box_list)
|
| 585 |
+
box_list = [tuple(b) for b in detection["boxes"]]
|
| 586 |
+
result = {"found_by": detection["found_by"], "model_version": emb.version, "detection": detection, "pills": [],
|
| 587 |
+
"evidence": "visual", "limits": VISUAL_LIMITS}
|
| 588 |
if not box_list:
|
| 589 |
+
result["message"] = NO_PILL
|
|
|
|
| 590 |
return result
|
| 591 |
crops = emb.frame(photo, box_list)
|
| 592 |
vectors = await run_in_threadpool(emb, crops) # in a thread: a GPU queue wait mustn't block the API
|
|
|
|
| 596 |
meds = [GalleryMedication(**g) for g in parse_json(gallery, "gallery")]
|
| 597 |
except (TypeError, ValidationError) as error:
|
| 598 |
raise HTTPException(422, f"gallery: {error}")
|
| 599 |
+
check_gallery_version(emb, model_version, embedding_space_id)
|
|
|
|
| 600 |
reads = None
|
| 601 |
if read_imprint and (meds is None or any(m.imprint for m in meds)):
|
| 602 |
reads = await run_in_threadpool(imprint_reads, crops)
|
| 603 |
|
| 604 |
if meds is not None:
|
| 605 |
decisions = match_decisions(vectors, meds, emb, reads)
|
| 606 |
+
result["pills"] = [{"box": list(b), "quality_flags": fl, "counted": not fl, **d,
|
| 607 |
+
"imprint_reads": reads[i] if reads else None}
|
| 608 |
+
for i, (b, fl, d) in enumerate(zip(box_list, detection["box_flags"], decisions))]
|
| 609 |
if schedule:
|
| 610 |
try:
|
| 611 |
sched = [ScheduledMedication(**s).model_dump() for s in parse_json(schedule, "schedule")]
|
| 612 |
except (TypeError, ValidationError) as error:
|
| 613 |
raise HTTPException(422, f"schedule: {error}")
|
| 614 |
+
result["dose"] = advisory_dose(sched, result["pills"], detection, meds)
|
|
|
|
|
|
|
|
|
|
| 615 |
else:
|
| 616 |
+
result["pills"] = [{"box": list(b), "quality_flags": fl, "counted": not fl,
|
| 617 |
+
**identify_pill(v[None], top_k=top_k, reads=reads[i] if reads else None),
|
| 618 |
"imprint_reads": reads[i] if reads else None}
|
| 619 |
+
for i, (b, fl, v) in enumerate(zip(box_list, detection["box_flags"], vectors))]
|
| 620 |
return result
|
| 621 |
|
| 622 |
|
| 623 |
+
def advisory_dose(sched, pills, detection, meds):
|
| 624 |
+
"""/v1/scan's deprecated dose check: only when the pill count is reliable, and only counted pills."""
|
| 625 |
+
note = ("Advisory and deprecated: the main backend checks doses against the regimen and dose history "
|
| 626 |
+
"(docs/dose_rules.md).")
|
| 627 |
+
if not detection["count_reliable"]:
|
| 628 |
+
alert = Alert("count_uncertain", "moderate", "I can't count the pills in this photo reliably. Spread them "
|
| 629 |
+
"apart on a plain surface and photograph them again.", [], SOURCE_SCHEDULE,
|
| 630 |
+
details={"flags": detection["flags"]})
|
| 631 |
+
summary = {"pills_seen": len(pills), "matched": 0, "unknown": 0, "unsure": 0, "all_ok": False,
|
| 632 |
+
"all_visually_matched": False, "count_reliable": False,
|
| 633 |
+
"text": "I can't count the pills in this photo reliably."}
|
| 634 |
+
return {"alerts": [alert.to_dict()], "summary": summary, "deprecated": True, "note": note}
|
| 635 |
+
alerts, summary = check_dose(sched, [{"medication_id": p["medication_id"], "decision": p["decision"]}
|
| 636 |
+
for p in pills if p["counted"]],
|
| 637 |
+
{m.medication_id: m.name or m.medication_id for m in meds})
|
| 638 |
+
return {"alerts": [a.to_dict() for a in alerts], "summary": summary, "deprecated": True, "note": note}
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
@app.post("/v1/read-label", response_model=S.ReadLabelResponse)
|
| 642 |
async def read_label(image: UploadFile = File(description="A photo of a medicine box or a pharmacy label, close "
|
| 643 |
"up and straight on")):
|
| 644 |
"""
|
|
|
|
| 661 |
return result
|
| 662 |
|
| 663 |
|
| 664 |
+
@app.get("/v1/pills/search", response_model=S.SearchResponse)
|
| 665 |
def pills_search(name: str = Query(min_length=2, description="Brand or generic name, e.g. 'metformin', 'Glucophage'"),
|
| 666 |
strength: str | None = Query(None, description="e.g. '500 MG'"),
|
| 667 |
limit: int = Query(50, ge=1, le=200)):
|
|
|
|
| 677 |
"source": cat.meta.get("source")}
|
| 678 |
|
| 679 |
|
| 680 |
+
@app.get("/v1/pills/{pill_id}", response_model=S.Product)
|
| 681 |
def pill(pill_id: str):
|
| 682 |
"""One catalogue product: names, ingredients, appearance, imprint and its pictures."""
|
| 683 |
cat = asset("catalogue")
|
|
|
|
| 696 |
|
| 697 |
|
| 698 |
# ---- endpoints: feedback for retraining (opt-in) -----------------------------------------------------------------
|
| 699 |
+
@app.post("/v1/feedback", response_model=S.FeedbackStored)
|
| 700 |
async def feedback(image: UploadFile = File(description="The pill: a crop, or a photo with `box`"),
|
| 701 |
box: str | None = Form(None, description="Optional [x0, y0, x1, y1] of the pill in the photo"),
|
| 702 |
consent: bool = Form(description="The user agreed to share this photo to improve the model"),
|
|
|
|
| 731 |
return {"stored": True, "item": item, "note": "Thank you. The photo is used only to improve pill recognition."}
|
| 732 |
|
| 733 |
|
| 734 |
+
@app.delete("/v1/feedback/{consent_id}", response_model=S.FeedbackDeleted)
|
| 735 |
def feedback_delete(consent_id: str):
|
| 736 |
"""Withdrawal of consent: deletes every photo stored under this consent id."""
|
| 737 |
try:
|
|
|
|
| 741 |
|
| 742 |
|
| 743 |
# ---- endpoints: Stage 3 -------------------------------------------------------------------------------------------
|
| 744 |
+
@app.post("/v1/safety/medications", response_model=S.MedicationsResponse)
|
| 745 |
def safety_medications(request: MedicationsRequest):
|
| 746 |
"""
|
| 747 |
Run when a medication is added or changed: interactions between every pair of the patient's medications
|
|
|
|
| 760 |
}
|
| 761 |
|
| 762 |
|
| 763 |
+
@app.post("/v1/safety/dose", response_model=S.DoseResponse)
|
| 764 |
def safety_dose(request: DoseRequest):
|
| 765 |
"""
|
| 766 |
+
The dose rules' **reference implementation** (blueprint D3: the main backend owns the decision, with the regimen
|
| 767 |
+
and dose history; spec and test cases in docs/dose_rules.md). After /v1/match: extra pills, pills not part of
|
| 768 |
+
this dose, scheduled medicines not seen, and unknown or unsure pills. Visual evidence only.
|
| 769 |
"""
|
| 770 |
alerts, summary = check_dose([s.model_dump() for s in request.schedule],
|
| 771 |
[p.model_dump() for p in request.pills], request.names)
|
| 772 |
return {"alerts": [a.to_dict() for a in alerts], "summary": summary}
|
| 773 |
|
| 774 |
|
| 775 |
+
@app.get("/v1/safety/label", response_model=S.LabelResponse)
|
| 776 |
def safety_label(rxcui: str | None = Query(None), ingredient: str | None = Query(None)):
|
| 777 |
"""The warning sections of the official US label (openFDA), quoted as written; only when the user asks."""
|
| 778 |
if not rxcui and not ingredient:
|
serving/app/schemas.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
The shape of every PharmaScan API answer (blueprint job B5), so /openapi.json documents responses, not only requests.
|
| 3 |
+
Models allow extra fields: new fields can be added without breaking clients that validate against these.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Literal
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 8 |
+
|
| 9 |
+
Evidence = Literal["visual"]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Out(BaseModel):
|
| 13 |
+
model_config = ConfigDict(extra="allow")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Error(Out):
|
| 17 |
+
code: str = Field(description="unauthorised, not_found, version_mismatch, invalid_input, upstream_failed, "
|
| 18 |
+
"not_ready or error")
|
| 19 |
+
detail: str | list | dict = Field(description="What went wrong (for invalid_input: the fields at fault)")
|
| 20 |
+
retry_after: int | None = Field(None, description="Seconds to wait before retrying (with not_ready)")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ---- health and model info -------------------------------------------------------------------------------------------
|
| 24 |
+
class Part(Out):
|
| 25 |
+
loaded: bool
|
| 26 |
+
error: str | None = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ModelPart(Part):
|
| 30 |
+
version: str | None = None
|
| 31 |
+
embedding_space_id: str | None = None
|
| 32 |
+
device: str | None = Field(None, description="zerogpu, or cpu (slower, when no GPU is granted)")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class CataloguePart(Part):
|
| 36 |
+
products: int | None = None
|
| 37 |
+
model_version: str | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class SafetyPart(Part):
|
| 41 |
+
built_at: str | None = None
|
| 42 |
+
pairs: str | None = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class TextPart(Part):
|
| 46 |
+
model: str | None = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Health(Out):
|
| 50 |
+
status: Literal["ok"]
|
| 51 |
+
ready: bool = Field(description="The model, catalogue and safety database are all loaded")
|
| 52 |
+
api_version: str
|
| 53 |
+
started_at: str
|
| 54 |
+
model_repo: str
|
| 55 |
+
model: ModelPart
|
| 56 |
+
catalogue: CataloguePart
|
| 57 |
+
safety_db: SafetyPart
|
| 58 |
+
text_reading: TextPart
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ModelInfo(Out):
|
| 62 |
+
api_version: str
|
| 63 |
+
model_version: str
|
| 64 |
+
embedding_space_id: str = Field(description="Compare embeddings only within one space")
|
| 65 |
+
preprocess_version: str
|
| 66 |
+
embedding_dim: int
|
| 67 |
+
normalized: bool
|
| 68 |
+
crop_window: float = Field(description="Crop contract: square window = this x the pill box's long side")
|
| 69 |
+
threshold: float
|
| 70 |
+
margin: float
|
| 71 |
+
calibration: dict
|
| 72 |
+
imprint: dict
|
| 73 |
+
training_coverage: str
|
| 74 |
+
known_limits: list[str]
|
| 75 |
+
evidence: Evidence
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---- Stage 2 ---------------------------------------------------------------------------------------------------------
|
| 79 |
+
class Embedding(Out):
|
| 80 |
+
embedding: list[float]
|
| 81 |
+
dim: int
|
| 82 |
+
model_version: str
|
| 83 |
+
embedding_space_id: str
|
| 84 |
+
preprocess_version: str
|
| 85 |
+
normalized: bool
|
| 86 |
+
framed_by: Literal["box", "as sent"]
|
| 87 |
+
imprint_reads: list[str] | None = None
|
| 88 |
+
evidence: Evidence
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class Ref(Out):
|
| 92 |
+
medication_id: str
|
| 93 |
+
name: str
|
| 94 |
+
similarity: float
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class MatchResult(Out):
|
| 98 |
+
decision: Literal["match", "unknown", "ambiguous"]
|
| 99 |
+
medication_id: str | None
|
| 100 |
+
name: str | None
|
| 101 |
+
similarity: float
|
| 102 |
+
closest: Ref
|
| 103 |
+
runner_up: Ref | None
|
| 104 |
+
message: str
|
| 105 |
+
imprint_check: str | None = None
|
| 106 |
+
imprint_read_for: list[str] = []
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class MatchResponse(Out):
|
| 110 |
+
results: list[MatchResult]
|
| 111 |
+
model_version: str
|
| 112 |
+
embedding_space_id: str
|
| 113 |
+
threshold: float
|
| 114 |
+
margin: float
|
| 115 |
+
evidence: Evidence
|
| 116 |
+
limits: str
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class Picture(Out):
|
| 120 |
+
url: str
|
| 121 |
+
kind: str
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class Product(Out):
|
| 125 |
+
pill_id: str
|
| 126 |
+
name: str | None
|
| 127 |
+
rxnorm_name: str | None
|
| 128 |
+
generic_name: str | None
|
| 129 |
+
manufacturer: str | None
|
| 130 |
+
ingredients: list[str]
|
| 131 |
+
shape: str | None
|
| 132 |
+
colour: str | None
|
| 133 |
+
size_mm: float | None
|
| 134 |
+
imprint: str | None
|
| 135 |
+
images: list[Picture]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class Candidate(Product):
|
| 139 |
+
similarity: float = Field(description="Cosine similarity of appearance: not a probability")
|
| 140 |
+
imprint_match: bool = Field(description="The typed imprint fits this product")
|
| 141 |
+
imprint_read: float = Field(description="How well the imprint read on the photo fits this product, 0-1")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class Identification(Out):
|
| 145 |
+
candidates: list[Candidate]
|
| 146 |
+
notes: list[str]
|
| 147 |
+
note: str
|
| 148 |
+
confidence: float | None = Field(None, description="Chance the first candidate is the right product, if the pill "
|
| 149 |
+
"is a US catalogue product (calibrated; null with filters)")
|
| 150 |
+
catalogue_fit: Literal["good", "weak", "poor"] | None = None
|
| 151 |
+
verdict: Literal["possible_match", "uncertain", "no_confident_match"] | None = None
|
| 152 |
+
verdict_message: str | None = None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class IdentifyResponse(Identification):
|
| 156 |
+
imprint_reads: list[str] | None = None
|
| 157 |
+
model_version: str
|
| 158 |
+
evidence: Evidence
|
| 159 |
+
limits: str
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class Detection(Out):
|
| 163 |
+
boxes: list[list[float]] = Field(description="[x0, y0, x1, y1] per pill, in the photo's pixels")
|
| 164 |
+
box_flags: list[list[str]] = Field(description="Per box: touches_edge, merged, irregular, small")
|
| 165 |
+
pill_count: int
|
| 166 |
+
count_reliable: bool = Field(description="False when any box is flagged or blobs were left out: don't count "
|
| 167 |
+
"pills from this photo")
|
| 168 |
+
flags: list[str] = Field(description="Per photo: no_pills, count_uncertain, too_many")
|
| 169 |
+
found_by: str
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class DetectResponse(Detection):
|
| 173 |
+
message: str | None = None
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class Alert(Out):
|
| 177 |
+
type: str
|
| 178 |
+
severity: str
|
| 179 |
+
message: str
|
| 180 |
+
medication_ids: list[str]
|
| 181 |
+
source: str
|
| 182 |
+
severity_graded: bool
|
| 183 |
+
colour: str
|
| 184 |
+
speak: bool
|
| 185 |
+
notify_caregiver: bool
|
| 186 |
+
details: dict
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class DoseSummary(Out):
|
| 190 |
+
pills_seen: int
|
| 191 |
+
matched: int
|
| 192 |
+
unknown: int
|
| 193 |
+
unsure: int
|
| 194 |
+
all_ok: bool = Field(description="Deprecated name of all_visually_matched")
|
| 195 |
+
all_visually_matched: bool
|
| 196 |
+
count_reliable: bool = True
|
| 197 |
+
text: str
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
class DoseResponse(Out):
|
| 201 |
+
alerts: list[Alert]
|
| 202 |
+
summary: DoseSummary
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class ScanDose(DoseResponse):
|
| 206 |
+
deprecated: bool = True
|
| 207 |
+
note: str
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
class ScanPill(Out):
|
| 211 |
+
box: list[float]
|
| 212 |
+
quality_flags: list[str] = []
|
| 213 |
+
counted: bool = Field(True, description="False for a flagged box: not counted in the dose check")
|
| 214 |
+
imprint_reads: list[str] | None = None
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class ScanResponse(Out):
|
| 218 |
+
found_by: str
|
| 219 |
+
model_version: str
|
| 220 |
+
detection: Detection
|
| 221 |
+
pills: list[ScanPill] = Field(description="With a gallery: /v1/match's fields per pill; without: /v1/identify's")
|
| 222 |
+
dose: ScanDose | None = None
|
| 223 |
+
message: str | None = None
|
| 224 |
+
evidence: Evidence
|
| 225 |
+
limits: str
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class Ingredient(Out):
|
| 229 |
+
rxcui: str | None
|
| 230 |
+
name: str
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class LabelCandidate(Out):
|
| 234 |
+
name: str
|
| 235 |
+
brand: str | None
|
| 236 |
+
ingredients: list[Ingredient]
|
| 237 |
+
strength: str | None
|
| 238 |
+
rxcui: str | None
|
| 239 |
+
rxnorm_name: str | None
|
| 240 |
+
found_in: list[str] = Field(description="Only the lines that name the medicine")
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class ReadLabelResponse(Out):
|
| 244 |
+
candidates: list[LabelCandidate]
|
| 245 |
+
note: str
|
| 246 |
+
ocr_model: str
|
| 247 |
+
message: str | None = None
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
class SearchProduct(Product):
|
| 251 |
+
match: Literal["same", "contains", "name"]
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
class SearchResponse(Out):
|
| 255 |
+
resolution: dict
|
| 256 |
+
count: int
|
| 257 |
+
products: list[SearchProduct]
|
| 258 |
+
source: str | None
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# ---- Stage 3 and feedback --------------------------------------------------------------------------------------------
|
| 262 |
+
class MedicationsResponse(Out):
|
| 263 |
+
alerts: list[Alert]
|
| 264 |
+
medications: list[dict]
|
| 265 |
+
all_identified: bool
|
| 266 |
+
sources: dict
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
class LabelResponse(Out):
|
| 270 |
+
brand_name: str | None
|
| 271 |
+
generic_name: str | None
|
| 272 |
+
label_date: str | None
|
| 273 |
+
matched_by: str
|
| 274 |
+
sections: dict[str, str]
|
| 275 |
+
disclaimer: str
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class FeedbackStored(Out):
|
| 279 |
+
stored: bool
|
| 280 |
+
item: str
|
| 281 |
+
note: str
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
class FeedbackDeleted(Out):
|
| 285 |
+
deleted: bool
|
| 286 |
+
local_photos_removed: int
|
| 287 |
+
removed_from_repo: bool
|
src/epills/core/catalogue/confidence.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Honest confidence for catalogue identification (blueprint job B4). Raw similarities are not probabilities: pills that
|
| 3 |
+
aren't in the catalogue at all still score about 0.93. Fitted by scripts/calibrate_identify.py for one model; for any
|
| 4 |
+
other model version, assess() returns None (refit after every new model).
|
| 5 |
+
|
| 6 |
+
confidence chance the first candidate is the right product *if the pill is a US catalogue product*: logistic
|
| 7 |
+
fit of (top-1 similarity, gap to the runner-up, imprint read on the top-1) on C3PI validation photos
|
| 8 |
+
catalogue_fit poor / weak / good: poor below what 95% of rightly identified catalogue pills reach, good above what
|
| 9 |
+
90% of non-catalogue pills (MEDISEG) reach
|
| 10 |
+
verdict no_confident_match (poor fit or confidence < LOW), possible_match (good fit and confidence >= HIGH),
|
| 11 |
+
uncertain (the rest)
|
| 12 |
+
Test (run 4, held-out medicines; outputs/runs/stage2_run4/eval/identify_confidence.json): stated 0.93 -> 95% right,
|
| 13 |
+
0.71 -> 66%, 0.53 -> 48%, 0.31 -> 33%, 0.03 -> 3%. Catalogue pills: possible_match 73% (83% right), uncertain 11%,
|
| 14 |
+
no_confident_match 16%. Non-catalogue pills: no_confident_match 71%, uncertain 21%, possible_match 9%.
|
| 15 |
+
"""
|
| 16 |
+
import math
|
| 17 |
+
|
| 18 |
+
MODEL_VERSION = "stage2_run4@2026-10-09"
|
| 19 |
+
WEIGHTS = (-36.3393, 37.514, 31.6165, 1.0213) # intercept, top-1 similarity, gap to the runner-up, imprint read
|
| 20 |
+
T_POOR, T_GOOD = 0.9451, 0.9654
|
| 21 |
+
LOW, HIGH = 0.2, 0.5
|
| 22 |
+
MESSAGES = {
|
| 23 |
+
"possible_match": "The first candidate looks most like this pill. A photo can't confirm it. Check with your "
|
| 24 |
+
"pharmacist.",
|
| 25 |
+
"uncertain": "Several catalogue pills look alike and none stands out. Type the imprint, or check with your "
|
| 26 |
+
"pharmacist.",
|
| 27 |
+
"no_confident_match": "No catalogue pill matches this one well: it may not be a US catalogue product, or the photo "
|
| 28 |
+
"differs from the catalogue photos. Check with your pharmacist.",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def assess(candidates, model_version):
|
| 33 |
+
"""candidates: best first, each with similarity and imprint_read (at least two) -> dict, or None."""
|
| 34 |
+
if model_version != MODEL_VERSION or len(candidates) < 2:
|
| 35 |
+
return None
|
| 36 |
+
s1, s2 = candidates[0]["similarity"], candidates[1]["similarity"]
|
| 37 |
+
z = WEIGHTS[0] + WEIGHTS[1] * s1 + WEIGHTS[2] * (s1 - s2) + WEIGHTS[3] * candidates[0].get("imprint_read", 0.0)
|
| 38 |
+
confidence = 1 / (1 + math.exp(-z))
|
| 39 |
+
fit = "poor" if s1 < T_POOR else "good" if s1 >= T_GOOD else "weak"
|
| 40 |
+
verdict = ("no_confident_match" if fit == "poor" or confidence < LOW else
|
| 41 |
+
"possible_match" if fit == "good" and confidence >= HIGH else "uncertain")
|
| 42 |
+
return {"confidence": round(confidence, 3), "catalogue_fit": fit, "verdict": verdict,
|
| 43 |
+
"verdict_message": MESSAGES[verdict]}
|
src/epills/core/inference/embedder.py
CHANGED
|
@@ -9,7 +9,7 @@ import os
|
|
| 9 |
import numpy as np
|
| 10 |
import torch
|
| 11 |
|
| 12 |
-
from epills.core.inference.preprocess import WINDOW, crop_window, eval_transform
|
| 13 |
from epills.core.models.hybrid import HybridPillNet
|
| 14 |
|
| 15 |
|
|
@@ -22,6 +22,9 @@ class Embedder:
|
|
| 22 |
self.version = ckpt.get("version") or ckpt.get("config", {}).get("name", "unversioned")
|
| 23 |
self.calibration = ckpt.get("calibration") or {}
|
| 24 |
self.window = self.calibration.get("window") or WINDOW # the crop contract this model was calibrated with
|
|
|
|
|
|
|
|
|
|
| 25 |
self.transform = eval_transform()
|
| 26 |
self.batch_size = batch_size
|
| 27 |
|
|
|
|
| 9 |
import numpy as np
|
| 10 |
import torch
|
| 11 |
|
| 12 |
+
from epills.core.inference.preprocess import CROP_SIZE, IMAGE_SIZE, WINDOW, crop_window, eval_transform
|
| 13 |
from epills.core.models.hybrid import HybridPillNet
|
| 14 |
|
| 15 |
|
|
|
|
| 22 |
self.version = ckpt.get("version") or ckpt.get("config", {}).get("name", "unversioned")
|
| 23 |
self.calibration = ckpt.get("calibration") or {}
|
| 24 |
self.window = self.calibration.get("window") or WINDOW # the crop contract this model was calibrated with
|
| 25 |
+
# embeddings are comparable only within one space: same model, same crop contract, loose-pill photos
|
| 26 |
+
self.embedding_space_id = f"pill-crop:{self.version}:w{self.window:g}"
|
| 27 |
+
self.preprocess_version = f"crop-contract-1:window-{self.window:g}:crop-{CROP_SIZE}:input-{IMAGE_SIZE}"
|
| 28 |
self.transform = eval_transform()
|
| 29 |
self.batch_size = batch_size
|
| 30 |
|
src/epills/core/inference/pill_finder.py
CHANGED
|
@@ -1,46 +1,190 @@
|
|
| 1 |
"""
|
| 2 |
A stand-in for the Stage 1 detector: finds pills on a plain background (a mat, a table) in a full photo.
|
| 3 |
|
| 4 |
-
Works when the background is one
|
| 5 |
-
detector, whose boxes the API accepts instead.
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
|
|
|
|
|
|
| 8 |
import cv2
|
| 9 |
import numpy as np
|
| 10 |
|
| 11 |
WORK = 768 # long side of the working image
|
| 12 |
-
MIN_DISTANCE =
|
| 13 |
-
MIN_AREA = 0.
|
| 14 |
-
MAX_AREA = 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
sh, sw = lab.shape[:2]
|
| 25 |
-
|
| 26 |
-
frame = np.concatenate([lab[:b].reshape(-1, 3), lab[-b:].reshape(-1, 3),
|
| 27 |
-
lab[:, :b].reshape(-1, 3), lab[:, -b:].reshape(-1, 3)])
|
| 28 |
-
d = np.linalg.norm(lab - np.median(frame, axis=0), axis=2)
|
| 29 |
otsu, _ = cv2.threshold(np.clip(d * 2, 0, 255).astype(np.uint8), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 30 |
-
|
| 31 |
k = max(3, int(0.01 * min(sh, sw)) | 1)
|
| 32 |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
flood = np.pad(mask, 1)
|
| 35 |
cv2.floodFill(flood, np.zeros((sh + 4, sw + 4), np.uint8), (0, 0), 1)
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
for
|
|
|
|
| 41 |
share = area / (sh * sw)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
A stand-in for the Stage 1 detector: finds pills on a plain background (a mat, a table) in a full photo.
|
| 3 |
|
| 4 |
+
Works when the background is plain (one colour, possibly unevenly lit) and pills lie apart. Anything else needs the
|
| 5 |
+
real detector, whose boxes the API accepts instead.
|
| 6 |
+
1. Background: each pixel is compared with the mat's colour *at that place*: a smooth surface (degree-3 polynomial
|
| 7 |
+
per Lab channel) fitted robustly, so vignetting and lighting gradients don't turn corners into "pills". Pills are
|
| 8 |
+
outliers to that fit and don't move it.
|
| 9 |
+
2. A pill is a blob that differs from it (Otsu threshold, at least MIN_DISTANCE), after a morphological clean-up and
|
| 10 |
+
hole filling, with a pill-like shape: compact (solidity), not too elongated, not a sliver along the photo's edge.
|
| 11 |
+
3. Pieces of one pill (a light pill on a light background splits along its score line or imprint) are merged when
|
| 12 |
+
they nearly touch; small crumbs next to a pill are dropped.
|
| 13 |
+
Every box carries quality flags; any flag, or a dropped crumb, sets the photo flag `count_uncertain`, so a pill count
|
| 14 |
+
from this finder is never trusted blindly (blueprint job B1).
|
| 15 |
"""
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
|
| 18 |
import cv2
|
| 19 |
import numpy as np
|
| 20 |
|
| 21 |
WORK = 768 # long side of the working image
|
| 22 |
+
MIN_DISTANCE = 12.0 # Lab distance from the local background below which a pixel is background
|
| 23 |
+
MIN_AREA = 0.0008 # smallest blob, as a share of the photo
|
| 24 |
+
MAX_AREA = 0.25 # largest blob
|
| 25 |
+
MIN_SOLIDITY = 0.80 # blob area / its convex hull: pills are convex, lighting patches and shadows aren't
|
| 26 |
+
MAX_ASPECT = 4.5 # long / short side of the blob's rotated rectangle
|
| 27 |
+
MERGE_GAP = 0.12 # pieces closer than this x the larger piece's long side are one pill
|
| 28 |
+
CRUMB_RATIO = 0.2 # a blob smaller than this x a neighbouring pill's area ...
|
| 29 |
+
CRUMB_GAP = 0.6 # ... within this x its long side is a crumb (shadow edge, chip), dropped
|
| 30 |
+
TEXTURE_RATIO = 0.08 # anywhere, a blob below this x the largest pill's area is a speck of texture, dropped
|
| 31 |
+
LOW_FACTOR = 0.5 # hysteresis: the faint threshold, as a share of the clear one
|
| 32 |
|
| 33 |
|
| 34 |
+
@dataclass
|
| 35 |
+
class Detection:
|
| 36 |
+
boxes: list = field(default_factory=list) # (x0, y0, x1, y1) in the photo's pixels, largest first
|
| 37 |
+
box_flags: list = field(default_factory=list) # per box: "touches_edge", "merged", "irregular", "small"
|
| 38 |
+
flags: list = field(default_factory=list) # per photo: "no_pills", "count_uncertain", "too_many"
|
| 39 |
+
dropped: int = 0 # crumbs and non-pill blobs left out
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def pill_count(self):
|
| 43 |
+
return len(self.boxes)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _gap(a, b):
|
| 47 |
+
"""Distance between two boxes (0 when they overlap)."""
|
| 48 |
+
dx = max(a[0] - b[2], b[0] - a[2], 0.0)
|
| 49 |
+
dy = max(a[1] - b[3], b[1] - a[3], 0.0)
|
| 50 |
+
return float(np.hypot(dx, dy))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _long(b):
|
| 54 |
+
return max(b[2] - b[0], b[3] - b[1])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _area(b):
|
| 58 |
+
return (b[2] - b[0]) * (b[3] - b[1])
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _terms(x, y):
|
| 62 |
+
"""Polynomial terms up to degree 3 in x and y (in -1..1): enough for vignetting and lighting gradients."""
|
| 63 |
+
return np.stack([np.ones_like(x), x, y, x * x, x * y, y * y, x ** 3, x * x * y, x * y * y, y ** 3], axis=-1)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _background(lab):
|
| 67 |
+
"""The mat's colour at every pixel: a smooth surface per Lab channel, fitted robustly (pills are outliers)."""
|
| 68 |
+
sh, sw = lab.shape[:2]
|
| 69 |
+
tiny = cv2.resize(lab, (64, max(8, round(64 * sh / sw))), interpolation=cv2.INTER_AREA).astype(np.float32)
|
| 70 |
+
th, tw = tiny.shape[:2]
|
| 71 |
+
gy, gx = np.mgrid[0:th, 0:tw]
|
| 72 |
+
A = _terms(gx / (tw - 1) * 2 - 1, gy / (th - 1) * 2 - 1).reshape(-1, 10)
|
| 73 |
+
Y = tiny.reshape(-1, 3)
|
| 74 |
+
keep = np.ones(len(Y), bool)
|
| 75 |
+
for _ in range(4): # refit without the pixels that don't look like mat
|
| 76 |
+
coef, *_ = np.linalg.lstsq(A[keep], Y[keep], rcond=None)
|
| 77 |
+
resid = np.linalg.norm(Y - A @ coef, axis=1)
|
| 78 |
+
keep = resid <= max(2.5 * np.median(resid[keep]), 3.0)
|
| 79 |
+
gy, gx = np.mgrid[0:sh, 0:sw]
|
| 80 |
+
return (_terms(gx / (sw - 1) * 2 - 1, gy / (sh - 1) * 2 - 1) @ coef).astype(np.float32)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _mask(lab):
|
| 84 |
+
"""Pixels that differ from the mat's colour at that place."""
|
| 85 |
sh, sw = lab.shape[:2]
|
| 86 |
+
d = np.linalg.norm(lab.astype(np.float32) - _background(lab), axis=2)
|
|
|
|
|
|
|
|
|
|
| 87 |
otsu, _ = cv2.threshold(np.clip(d * 2, 0, 255).astype(np.uint8), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 88 |
+
high = max(otsu / 2, MIN_DISTANCE)
|
| 89 |
k = max(3, int(0.01 * min(sh, sw)) | 1)
|
| 90 |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
|
| 91 |
+
strong = cv2.morphologyEx((d > high).astype(np.uint8), cv2.MORPH_OPEN, kernel)
|
| 92 |
+
# hysteresis: a pill's faint parts (the white half of a capsule) join the clearly different part they touch
|
| 93 |
+
weak = cv2.morphologyEx((d > max(high * LOW_FACTOR, MIN_DISTANCE * 0.75)).astype(np.uint8), cv2.MORPH_OPEN, kernel)
|
| 94 |
+
n, labels = cv2.connectedComponents(weak, connectivity=8)
|
| 95 |
+
keep = np.zeros(n, bool)
|
| 96 |
+
keep[np.unique(labels[strong > 0])] = True
|
| 97 |
+
keep[0] = False
|
| 98 |
+
mask = cv2.morphologyEx(keep[labels].astype(np.uint8), cv2.MORPH_CLOSE, kernel)
|
| 99 |
flood = np.pad(mask, 1)
|
| 100 |
cv2.floodFill(flood, np.zeros((sh + 4, sw + 4), np.uint8), (0, 0), 1)
|
| 101 |
+
return mask | (1 - flood[1:-1, 1:-1])
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def detect_pills(image, max_pills=20):
|
| 105 |
+
"""PIL photo -> Detection: pill boxes in the photo's pixels with quality flags."""
|
| 106 |
+
rgb = np.asarray(image.convert("RGB"))
|
| 107 |
+
h, w = rgb.shape[:2]
|
| 108 |
+
f = WORK / max(h, w)
|
| 109 |
+
small = cv2.resize(rgb, (max(1, round(w * f)), max(1, round(h * f))), interpolation=cv2.INTER_AREA)
|
| 110 |
+
mask = _mask(cv2.cvtColor(small, cv2.COLOR_RGB2LAB))
|
| 111 |
+
sh, sw = mask.shape
|
| 112 |
|
| 113 |
+
blobs, dropped = [], 0
|
| 114 |
+
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
| 115 |
+
for i in range(1, n):
|
| 116 |
+
x, y, bw, bh, area = stats[i]
|
| 117 |
share = area / (sh * sw)
|
| 118 |
+
if not MIN_AREA <= share <= MAX_AREA:
|
| 119 |
+
dropped += share > MAX_AREA
|
| 120 |
+
continue
|
| 121 |
+
contours, _ = cv2.findContours((labels[y:y + bh, x:x + bw] == i).astype(np.uint8), cv2.RETR_EXTERNAL,
|
| 122 |
+
cv2.CHAIN_APPROX_SIMPLE)
|
| 123 |
+
contour = max(contours, key=cv2.contourArea)
|
| 124 |
+
hull = cv2.contourArea(cv2.convexHull(contour))
|
| 125 |
+
solidity = area / hull if hull > 0 else 0.0
|
| 126 |
+
(_, _), (rw, rh), _ = cv2.minAreaRect(contour)
|
| 127 |
+
aspect = max(rw, rh) / max(min(rw, rh), 1.0)
|
| 128 |
+
edges = (x <= 1) + (y <= 1) + (x + bw >= sw - 1) + (y + bh >= sh - 1)
|
| 129 |
+
if edges >= 2 or aspect > MAX_ASPECT or (edges and solidity < 0.9) or solidity < MIN_SOLIDITY - 0.15:
|
| 130 |
+
dropped += 1 # lighting patches, mat edges, slivers: not pills
|
| 131 |
+
continue
|
| 132 |
+
flags = set()
|
| 133 |
+
if edges:
|
| 134 |
+
flags.add("touches_edge")
|
| 135 |
+
if solidity < MIN_SOLIDITY:
|
| 136 |
+
flags.add("irregular")
|
| 137 |
+
blobs.append({"box": [float(x), float(y), float(x + bw), float(y + bh)], "area": float(area), "flags": flags})
|
| 138 |
+
|
| 139 |
+
merged = True
|
| 140 |
+
while merged: # pieces of one pill
|
| 141 |
+
merged = False
|
| 142 |
+
for a in range(len(blobs)):
|
| 143 |
+
for b in range(a + 1, len(blobs)):
|
| 144 |
+
A, B = blobs[a], blobs[b]
|
| 145 |
+
if _gap(A["box"], B["box"]) <= MERGE_GAP * max(_long(A["box"]), _long(B["box"])):
|
| 146 |
+
A["box"] = [min(A["box"][0], B["box"][0]), min(A["box"][1], B["box"][1]),
|
| 147 |
+
max(A["box"][2], B["box"][2]), max(A["box"][3], B["box"][3])]
|
| 148 |
+
A["area"] += B["area"]
|
| 149 |
+
A["flags"] |= B["flags"] | {"merged"}
|
| 150 |
+
del blobs[b]
|
| 151 |
+
merged = True
|
| 152 |
+
break
|
| 153 |
+
if merged:
|
| 154 |
+
break
|
| 155 |
+
|
| 156 |
+
if any("touches_edge" not in b["flags"] for b in blobs): # whole pills found: blobs cut by the edge are mat
|
| 157 |
+
dropped += sum("touches_edge" in b["flags"] for b in blobs)
|
| 158 |
+
blobs = [b for b in blobs if "touches_edge" not in b["flags"]]
|
| 159 |
+
blobs.sort(key=lambda t: -t["area"])
|
| 160 |
+
kept = []
|
| 161 |
+
for blob in blobs: # crumbs next to a bigger pill, specks of texture anywhere
|
| 162 |
+
if kept and (blob["area"] < TEXTURE_RATIO * kept[0]["area"] or any(
|
| 163 |
+
blob["area"] < CRUMB_RATIO * k["area"] and _gap(blob["box"], k["box"]) < CRUMB_GAP * _long(k["box"])
|
| 164 |
+
for k in kept)):
|
| 165 |
+
dropped += 1
|
| 166 |
+
continue
|
| 167 |
+
kept.append(blob)
|
| 168 |
+
if len(kept) >= 2:
|
| 169 |
+
median = float(np.median([k["area"] for k in kept]))
|
| 170 |
+
for k in kept:
|
| 171 |
+
if k["area"] < 0.15 * median:
|
| 172 |
+
k["flags"].add("small")
|
| 173 |
+
|
| 174 |
+
det = Detection(dropped=dropped)
|
| 175 |
+
for k in kept[:max_pills]:
|
| 176 |
+
det.boxes.append(tuple(round(v / f, 1) for v in k["box"]))
|
| 177 |
+
det.box_flags.append(sorted(k["flags"]))
|
| 178 |
+
if not det.boxes:
|
| 179 |
+
det.flags.append("no_pills")
|
| 180 |
+
if len(kept) > max_pills:
|
| 181 |
+
det.flags.append("too_many")
|
| 182 |
+
if any(det.box_flags) or len(kept) > max_pills or any(
|
| 183 |
+
"merged" in fl for fl in det.box_flags):
|
| 184 |
+
det.flags.append("count_uncertain")
|
| 185 |
+
return det
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def find_pills(image, max_pills=20):
|
| 189 |
+
"""PIL photo -> boxes (x0, y0, x1, y1) in the photo's pixels, largest first (see detect_pills for flags)."""
|
| 190 |
+
return detect_pills(image, max_pills).boxes
|
src/epills/core/safety/rules.py
CHANGED
|
@@ -1,251 +1,254 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Stage 3 rules: fixed logic on curated data, no trained model (ROADMAP §4).
|
| 3 |
-
|
| 4 |
-
check_medications when a medication is added or changed: drug-drug interactions (DDInter 2.0) and therapeutic
|
| 5 |
-
duplication (two medications sharing an active ingredient, via RxNorm)
|
| 6 |
-
check_dose at every dose check:
|
| 7 |
-
unsure pills (from Stage 2's matching)
|
| 8 |
-
|
| 9 |
-
Alert rules from the project plan:
|
| 10 |
-
- Major: red, spoken, caregiver notified. Moderate: amber, spoken. Minor: shown on screen only.
|
| 11 |
-
- Every alert ends with "Check with your pharmacist." The app never tells anyone to take or stop a medication.
|
| 12 |
-
- DDInter's "Unknown" level (listed, but not graded) gets its own severity, "ungraded": on screen only, not spoken
|
| 13 |
-
(decision D6, 2026-10-08), so common medication lists don't drown the graded alerts in noise.
|
| 14 |
-
- A medication that can't be identified is reported, never silently skipped: no alert must not mean "no
|
| 15 |
-
interaction" when the check couldn't run.
|
| 16 |
-
"""
|
| 17 |
-
from dataclasses import asdict, dataclass, field
|
| 18 |
-
from itertools import combinations
|
| 19 |
-
|
| 20 |
-
from epills.core.safety.db import normalise_name
|
| 21 |
-
|
| 22 |
-
PHARMACIST = "Check with your pharmacist."
|
| 23 |
-
UNKNOWN_LEVEL_AS = "ungraded" # decision D6
|
| 24 |
-
SOURCE_DDINTER = "DDInter 2.0 (CC BY-NC-SA 4.0), interaction severity"
|
| 25 |
-
SOURCE_RXNORM = "RxNorm (NLM), active ingredients"
|
| 26 |
-
SOURCE_SCHEDULE = "The patient's schedule and the pill check"
|
| 27 |
-
|
| 28 |
-
STYLE = { # severity -> how the app shows it (the plan's severity mapping)
|
| 29 |
-
"major": {"colour": "red", "speak": True, "notify_caregiver": True},
|
| 30 |
-
"moderate": {"colour": "amber", "speak": True, "notify_caregiver": False},
|
| 31 |
-
"ungraded": {"colour": "grey", "speak": False, "notify_caregiver": False},
|
| 32 |
-
"minor": {"colour": "grey", "speak": False, "notify_caregiver": False},
|
| 33 |
-
}
|
| 34 |
-
RANK = {"major": 4, "moderate": 3, "ungraded": 2, "minor": 1}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
@dataclass
|
| 38 |
-
class Alert:
|
| 39 |
-
type: str
|
| 40 |
-
severity: str
|
| 41 |
-
message: str
|
| 42 |
-
medication_ids: list
|
| 43 |
-
source: str
|
| 44 |
-
severity_graded: bool = True
|
| 45 |
-
colour: str = ""
|
| 46 |
-
speak: bool = False
|
| 47 |
-
notify_caregiver: bool = False
|
| 48 |
-
details: dict = field(default_factory=dict)
|
| 49 |
-
|
| 50 |
-
def __post_init__(self):
|
| 51 |
-
style = STYLE[self.severity]
|
| 52 |
-
self.colour, self.speak, self.notify_caregiver = style["colour"], style["speak"], style["notify_caregiver"]
|
| 53 |
-
if not self.message.endswith(PHARMACIST):
|
| 54 |
-
self.message = f"{self.message} {PHARMACIST}"
|
| 55 |
-
|
| 56 |
-
def to_dict(self):
|
| 57 |
-
return asdict(self)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
@dataclass
|
| 61 |
-
class Ingredient:
|
| 62 |
-
rxcui: str | None
|
| 63 |
-
name: str
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
@dataclass
|
| 67 |
-
class ResolvedMedication:
|
| 68 |
-
id: str
|
| 69 |
-
display_name: str
|
| 70 |
-
ingredients: list
|
| 71 |
-
resolved_by: str # ingredients / ndc / rxcui / name / approximate / unresolved
|
| 72 |
-
matched_name: str | None = None
|
| 73 |
-
note: str | None = None
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def resolve_medication(med, rxnorm):
|
| 77 |
-
"""
|
| 78 |
-
The active ingredients of one medication. med: dict with id and any of name, rxcui, ndc, ingredients (names
|
| 79 |
-
printed on the label, for medicines without US codes). Order of trust: explicit ingredients, NDC, RxCUI, exact
|
| 80 |
-
name, then approximate name (reported, so the app can ask "did you mean ...?").
|
| 81 |
-
"""
|
| 82 |
-
mid, name = str(med["id"]), med.get("name") or ""
|
| 83 |
-
display = name or ", ".join(med.get("ingredients") or []) or mid
|
| 84 |
-
try:
|
| 85 |
-
if med.get("ingredients"):
|
| 86 |
-
found = []
|
| 87 |
-
for ing in med["ingredients"]:
|
| 88 |
-
rxcui = rxnorm.rxcui_by_name(ing) if rxnorm else None
|
| 89 |
-
ins = rxnorm.ingredients(rxcui) if rxcui else []
|
| 90 |
-
found += [Ingredient(r, n) for r, n in ins] or [Ingredient(None, normalise_name(ing))]
|
| 91 |
-
return ResolvedMedication(mid, display, found, "ingredients")
|
| 92 |
-
if rxnorm is None:
|
| 93 |
-
return ResolvedMedication(mid, display, [], "unresolved", note="RxNorm lookups are switched off")
|
| 94 |
-
for how, rxcui in (("ndc", med.get("ndc") and rxnorm.rxcui_by_ndc(med["ndc"])),
|
| 95 |
-
("rxcui", med.get("rxcui")),
|
| 96 |
-
("name", name and rxnorm.rxcui_by_name(name))):
|
| 97 |
-
if rxcui:
|
| 98 |
-
ins = rxnorm.ingredients(rxcui)
|
| 99 |
-
if ins:
|
| 100 |
-
if not name: # entered by code only: show RxNorm's name, not the app's id
|
| 101 |
-
display = (rxnorm.properties(rxcui) or {}).get("name") or display
|
| 102 |
-
return ResolvedMedication(mid, display, [Ingredient(r, n) for r, n in ins], how)
|
| 103 |
-
if name:
|
| 104 |
-
for rxcui, matched in rxnorm.approximate(name): # best first; skip retired concepts
|
| 105 |
-
ins = rxnorm.ingredients(rxcui)
|
| 106 |
-
if ins:
|
| 107 |
-
return ResolvedMedication(mid, display, [Ingredient(r, n) for r, n in ins], "approximate",
|
| 108 |
-
matched_name=matched)
|
| 109 |
-
except Exception as error: # RxNorm unreachable: report it, don't guess
|
| 110 |
-
return ResolvedMedication(mid, display, [], "unresolved", note=f"lookup failed: {error}")
|
| 111 |
-
return ResolvedMedication(mid, display, [], "unresolved", note="no match in RxNorm")
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def _ingredient_list(names):
|
| 115 |
-
return names[0] if len(names) == 1 else ", ".join(names[:-1]) + " and " + names[-1]
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def check_medications(resolved, db):
|
| 119 |
-
"""
|
| 120 |
-
Interactions and duplications across a patient's medication list (list of ResolvedMedication).
|
| 121 |
-
Returns (alerts, per_medication), where per_medication says how each one was identified and checked.
|
| 122 |
-
"""
|
| 123 |
-
alerts, per_med = [], []
|
| 124 |
-
ddinter = {} # medication id -> [(ingredient, [ddinter rows])]
|
| 125 |
-
for m in resolved:
|
| 126 |
-
rows = [(ing, db.drugs_for_ingredient(ing.rxcui, ing.name)) for ing in m.ingredients]
|
| 127 |
-
ddinter[m.id] = rows
|
| 128 |
-
not_listed = [ing.name for ing, r in rows if not r]
|
| 129 |
-
per_med.append({
|
| 130 |
-
"id": m.id, "name": m.display_name, "resolved_by": m.resolved_by, "matched_name": m.matched_name,
|
| 131 |
-
"ingredients": [{"rxcui": i.rxcui, "name": i.name} for i in m.ingredients],
|
| 132 |
-
"not_in_interaction_database": not_listed, "note": m.note,
|
| 133 |
-
})
|
| 134 |
-
if m.resolved_by == "unresolved":
|
| 135 |
-
alerts.append(Alert(
|
| 136 |
-
"unresolved", "moderate",
|
| 137 |
-
f"I couldn't identify {m.display_name}, so it was not checked for interactions. "
|
| 138 |
-
"Please add its active ingredient from the label.",
|
| 139 |
-
[m.id], SOURCE_RXNORM, details={"reason": m.note}))
|
| 140 |
-
|
| 141 |
-
# therapeutic duplication: the same active ingredient in two medications (brand + generic, or a combination
|
| 142 |
-
# pill + a single one). Amber, not red: two strengths of one drug can be prescribed together on purpose.
|
| 143 |
-
for a, b in combinations(resolved, 2):
|
| 144 |
-
shared = sorted({i.name for i in a.ingredients} & {i.name for i in b.ingredients}
|
| 145 |
-
| {i.name for i in a.ingredients for j in b.ingredients if i.rxcui and i.rxcui == j.rxcui})
|
| 146 |
-
if shared:
|
| 147 |
-
alerts.append(Alert(
|
| 148 |
-
"duplication", "moderate",
|
| 149 |
-
f"{a.display_name} and {b.display_name} both contain {_ingredient_list(shared)}, "
|
| 150 |
-
"so together they may be a double dose.",
|
| 151 |
-
[a.id, b.id], SOURCE_RXNORM, details={"shared_ingredients": shared}))
|
| 152 |
-
|
| 153 |
-
# interactions: every ingredient of one medication against every ingredient of another
|
| 154 |
-
for a, b in combinations(resolved, 2):
|
| 155 |
-
found = []
|
| 156 |
-
for ing_a, rows_a in ddinter[a.id]:
|
| 157 |
-
for ing_b, rows_b in ddinter[b.id]:
|
| 158 |
-
if ing_a.rxcui and ing_a.rxcui == ing_b.rxcui:
|
| 159 |
-
continue # same ingredient: a duplication, handled above
|
| 160 |
-
for ra in rows_a:
|
| 161 |
-
for rb in rows_b:
|
| 162 |
-
level = db.level(ra[0], rb[0])
|
| 163 |
-
if level:
|
| 164 |
-
found.append({"ingredient_a": ing_a.name, "ingredient_b": ing_b.name,
|
| 165 |
-
"ddinter_a": ra[1], "ddinter_b": rb[1], "level": level})
|
| 166 |
-
if not found:
|
| 167 |
-
continue
|
| 168 |
-
severities = [UNKNOWN_LEVEL_AS if f["level"] == "Unknown" else f["level"].lower() for f in found]
|
| 169 |
-
severity = max(severities, key=RANK.get)
|
| 170 |
-
graded = severity != "ungraded"
|
| 171 |
-
words = {"major": "a serious", "moderate": "a moderate", "minor": "a minor", "ungraded": ""}[severity]
|
| 172 |
-
text = (f"{a.display_name} and {b.display_name} have {words} interaction." if graded else
|
| 173 |
-
f"{a.display_name} and {b.display_name} are listed as interacting, but the severity isn't graded.")
|
| 174 |
-
alerts.append(Alert("interaction", severity, text, [a.id, b.id], SOURCE_DDINTER,
|
| 175 |
-
severity_graded=graded, details={"pairs": found}))
|
| 176 |
-
|
| 177 |
-
alerts.sort(key=lambda al: -RANK[al.severity])
|
| 178 |
-
return alerts, per_med
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
def _number_word(n):
|
| 182 |
-
words = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]
|
| 183 |
-
return words[n] if 0 <= n < len(words) else str(n)
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
def _count_words(n, singular, plural=None):
|
| 187 |
-
return f"{_number_word(n)} {singular if n == 1 else (plural or singular + 's')}"
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
def check_dose(schedule, pills, names=None):
|
| 191 |
-
"""
|
| 192 |
-
Dose check after Stage 2's matching.
|
| 193 |
-
schedule: [{"medication_id", "count"}] for this dose (count = pills expected)
|
| 194 |
-
pills: [{"medication_id", "decision"}], decision = match / unknown / ambiguous, from /v1/match
|
| 195 |
-
names: optional {medication_id: display name} for messages (enrolled medications)
|
| 196 |
-
Returns (alerts, summary dict).
|
| 197 |
-
"""
|
| 198 |
-
names = {str(k): v for k, v in (names or {}).items()}
|
| 199 |
-
expected = {str(s["medication_id"]): int(s.get("count", 1)) for s in schedule}
|
| 200 |
-
matched, unknown, ambiguous = {}, 0, 0
|
| 201 |
-
for p in pills:
|
| 202 |
-
decision = p.get("decision", "unknown")
|
| 203 |
-
if decision == "match" and p.get("medication_id") is not None:
|
| 204 |
-
mid = str(p["medication_id"])
|
| 205 |
-
matched[mid] = matched.get(mid, 0) + 1
|
| 206 |
-
elif decision == "ambiguous":
|
| 207 |
-
ambiguous += 1
|
| 208 |
-
else:
|
| 209 |
-
unknown += 1
|
| 210 |
-
name = lambda mid: names.get(mid, mid)
|
| 211 |
-
alerts = []
|
| 212 |
-
if unknown:
|
| 213 |
-
what = "One pill
|
| 214 |
-
alerts.append(Alert("unknown_pill", "major",
|
| 215 |
-
f"{what}
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
f"
|
| 223 |
-
f"{
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
f"{
|
| 229 |
-
f"{'one' if n - e == 1 else 'ones'} aside.",
|
| 230 |
-
[mid], SOURCE_SCHEDULE, details={"found": n, "expected": e}))
|
| 231 |
-
for mid, e in sorted(expected.items()):
|
| 232 |
-
n = matched.get(mid, 0)
|
| 233 |
-
if n < e:
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
"
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
"
|
| 248 |
-
"
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stage 3 rules: fixed logic on curated data, no trained model (ROADMAP §4).
|
| 3 |
+
|
| 4 |
+
check_medications when a medication is added or changed: drug-drug interactions (DDInter 2.0) and therapeutic
|
| 5 |
+
duplication (two medications sharing an active ingredient, via RxNorm)
|
| 6 |
+
check_dose at every dose check: extra pills, pills not part of this dose, scheduled medicines not seen, unknown and
|
| 7 |
+
unsure pills (from Stage 2's matching)
|
| 8 |
+
|
| 9 |
+
Alert rules from the project plan:
|
| 10 |
+
- Major: red, spoken, caregiver notified. Moderate: amber, spoken. Minor: shown on screen only.
|
| 11 |
+
- Every alert ends with "Check with your pharmacist." The app never tells anyone to take or stop a medication.
|
| 12 |
+
- DDInter's "Unknown" level (listed, but not graded) gets its own severity, "ungraded": on screen only, not spoken
|
| 13 |
+
(decision D6, 2026-10-08), so common medication lists don't drown the graded alerts in noise.
|
| 14 |
+
- A medication that can't be identified is reported, never silently skipped: no alert must not mean "no
|
| 15 |
+
interaction" when the check couldn't run.
|
| 16 |
+
"""
|
| 17 |
+
from dataclasses import asdict, dataclass, field
|
| 18 |
+
from itertools import combinations
|
| 19 |
+
|
| 20 |
+
from epills.core.safety.db import normalise_name
|
| 21 |
+
|
| 22 |
+
PHARMACIST = "Check with your pharmacist."
|
| 23 |
+
UNKNOWN_LEVEL_AS = "ungraded" # decision D6
|
| 24 |
+
SOURCE_DDINTER = "DDInter 2.0 (CC BY-NC-SA 4.0), interaction severity"
|
| 25 |
+
SOURCE_RXNORM = "RxNorm (NLM), active ingredients"
|
| 26 |
+
SOURCE_SCHEDULE = "The patient's schedule and the pill check"
|
| 27 |
+
|
| 28 |
+
STYLE = { # severity -> how the app shows it (the plan's severity mapping)
|
| 29 |
+
"major": {"colour": "red", "speak": True, "notify_caregiver": True},
|
| 30 |
+
"moderate": {"colour": "amber", "speak": True, "notify_caregiver": False},
|
| 31 |
+
"ungraded": {"colour": "grey", "speak": False, "notify_caregiver": False},
|
| 32 |
+
"minor": {"colour": "grey", "speak": False, "notify_caregiver": False},
|
| 33 |
+
}
|
| 34 |
+
RANK = {"major": 4, "moderate": 3, "ungraded": 2, "minor": 1}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Alert:
|
| 39 |
+
type: str
|
| 40 |
+
severity: str
|
| 41 |
+
message: str
|
| 42 |
+
medication_ids: list
|
| 43 |
+
source: str
|
| 44 |
+
severity_graded: bool = True
|
| 45 |
+
colour: str = ""
|
| 46 |
+
speak: bool = False
|
| 47 |
+
notify_caregiver: bool = False
|
| 48 |
+
details: dict = field(default_factory=dict)
|
| 49 |
+
|
| 50 |
+
def __post_init__(self):
|
| 51 |
+
style = STYLE[self.severity]
|
| 52 |
+
self.colour, self.speak, self.notify_caregiver = style["colour"], style["speak"], style["notify_caregiver"]
|
| 53 |
+
if not self.message.endswith(PHARMACIST):
|
| 54 |
+
self.message = f"{self.message} {PHARMACIST}"
|
| 55 |
+
|
| 56 |
+
def to_dict(self):
|
| 57 |
+
return asdict(self)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class Ingredient:
|
| 62 |
+
rxcui: str | None
|
| 63 |
+
name: str
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class ResolvedMedication:
|
| 68 |
+
id: str
|
| 69 |
+
display_name: str
|
| 70 |
+
ingredients: list
|
| 71 |
+
resolved_by: str # ingredients / ndc / rxcui / name / approximate / unresolved
|
| 72 |
+
matched_name: str | None = None
|
| 73 |
+
note: str | None = None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def resolve_medication(med, rxnorm):
|
| 77 |
+
"""
|
| 78 |
+
The active ingredients of one medication. med: dict with id and any of name, rxcui, ndc, ingredients (names
|
| 79 |
+
printed on the label, for medicines without US codes). Order of trust: explicit ingredients, NDC, RxCUI, exact
|
| 80 |
+
name, then approximate name (reported, so the app can ask "did you mean ...?").
|
| 81 |
+
"""
|
| 82 |
+
mid, name = str(med["id"]), med.get("name") or ""
|
| 83 |
+
display = name or ", ".join(med.get("ingredients") or []) or mid
|
| 84 |
+
try:
|
| 85 |
+
if med.get("ingredients"):
|
| 86 |
+
found = []
|
| 87 |
+
for ing in med["ingredients"]:
|
| 88 |
+
rxcui = rxnorm.rxcui_by_name(ing) if rxnorm else None
|
| 89 |
+
ins = rxnorm.ingredients(rxcui) if rxcui else []
|
| 90 |
+
found += [Ingredient(r, n) for r, n in ins] or [Ingredient(None, normalise_name(ing))]
|
| 91 |
+
return ResolvedMedication(mid, display, found, "ingredients")
|
| 92 |
+
if rxnorm is None:
|
| 93 |
+
return ResolvedMedication(mid, display, [], "unresolved", note="RxNorm lookups are switched off")
|
| 94 |
+
for how, rxcui in (("ndc", med.get("ndc") and rxnorm.rxcui_by_ndc(med["ndc"])),
|
| 95 |
+
("rxcui", med.get("rxcui")),
|
| 96 |
+
("name", name and rxnorm.rxcui_by_name(name))):
|
| 97 |
+
if rxcui:
|
| 98 |
+
ins = rxnorm.ingredients(rxcui)
|
| 99 |
+
if ins:
|
| 100 |
+
if not name: # entered by code only: show RxNorm's name, not the app's id
|
| 101 |
+
display = (rxnorm.properties(rxcui) or {}).get("name") or display
|
| 102 |
+
return ResolvedMedication(mid, display, [Ingredient(r, n) for r, n in ins], how)
|
| 103 |
+
if name:
|
| 104 |
+
for rxcui, matched in rxnorm.approximate(name): # best first; skip retired concepts
|
| 105 |
+
ins = rxnorm.ingredients(rxcui)
|
| 106 |
+
if ins:
|
| 107 |
+
return ResolvedMedication(mid, display, [Ingredient(r, n) for r, n in ins], "approximate",
|
| 108 |
+
matched_name=matched)
|
| 109 |
+
except Exception as error: # RxNorm unreachable: report it, don't guess
|
| 110 |
+
return ResolvedMedication(mid, display, [], "unresolved", note=f"lookup failed: {error}")
|
| 111 |
+
return ResolvedMedication(mid, display, [], "unresolved", note="no match in RxNorm")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _ingredient_list(names):
|
| 115 |
+
return names[0] if len(names) == 1 else ", ".join(names[:-1]) + " and " + names[-1]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def check_medications(resolved, db):
|
| 119 |
+
"""
|
| 120 |
+
Interactions and duplications across a patient's medication list (list of ResolvedMedication).
|
| 121 |
+
Returns (alerts, per_medication), where per_medication says how each one was identified and checked.
|
| 122 |
+
"""
|
| 123 |
+
alerts, per_med = [], []
|
| 124 |
+
ddinter = {} # medication id -> [(ingredient, [ddinter rows])]
|
| 125 |
+
for m in resolved:
|
| 126 |
+
rows = [(ing, db.drugs_for_ingredient(ing.rxcui, ing.name)) for ing in m.ingredients]
|
| 127 |
+
ddinter[m.id] = rows
|
| 128 |
+
not_listed = [ing.name for ing, r in rows if not r]
|
| 129 |
+
per_med.append({
|
| 130 |
+
"id": m.id, "name": m.display_name, "resolved_by": m.resolved_by, "matched_name": m.matched_name,
|
| 131 |
+
"ingredients": [{"rxcui": i.rxcui, "name": i.name} for i in m.ingredients],
|
| 132 |
+
"not_in_interaction_database": not_listed, "note": m.note,
|
| 133 |
+
})
|
| 134 |
+
if m.resolved_by == "unresolved":
|
| 135 |
+
alerts.append(Alert(
|
| 136 |
+
"unresolved", "moderate",
|
| 137 |
+
f"I couldn't identify {m.display_name}, so it was not checked for interactions. "
|
| 138 |
+
"Please add its active ingredient from the label.",
|
| 139 |
+
[m.id], SOURCE_RXNORM, details={"reason": m.note}))
|
| 140 |
+
|
| 141 |
+
# therapeutic duplication: the same active ingredient in two medications (brand + generic, or a combination
|
| 142 |
+
# pill + a single one). Amber, not red: two strengths of one drug can be prescribed together on purpose.
|
| 143 |
+
for a, b in combinations(resolved, 2):
|
| 144 |
+
shared = sorted({i.name for i in a.ingredients} & {i.name for i in b.ingredients}
|
| 145 |
+
| {i.name for i in a.ingredients for j in b.ingredients if i.rxcui and i.rxcui == j.rxcui})
|
| 146 |
+
if shared:
|
| 147 |
+
alerts.append(Alert(
|
| 148 |
+
"duplication", "moderate",
|
| 149 |
+
f"{a.display_name} and {b.display_name} both contain {_ingredient_list(shared)}, "
|
| 150 |
+
"so together they may be a double dose.",
|
| 151 |
+
[a.id, b.id], SOURCE_RXNORM, details={"shared_ingredients": shared}))
|
| 152 |
+
|
| 153 |
+
# interactions: every ingredient of one medication against every ingredient of another
|
| 154 |
+
for a, b in combinations(resolved, 2):
|
| 155 |
+
found = []
|
| 156 |
+
for ing_a, rows_a in ddinter[a.id]:
|
| 157 |
+
for ing_b, rows_b in ddinter[b.id]:
|
| 158 |
+
if ing_a.rxcui and ing_a.rxcui == ing_b.rxcui:
|
| 159 |
+
continue # same ingredient: a duplication, handled above
|
| 160 |
+
for ra in rows_a:
|
| 161 |
+
for rb in rows_b:
|
| 162 |
+
level = db.level(ra[0], rb[0])
|
| 163 |
+
if level:
|
| 164 |
+
found.append({"ingredient_a": ing_a.name, "ingredient_b": ing_b.name,
|
| 165 |
+
"ddinter_a": ra[1], "ddinter_b": rb[1], "level": level})
|
| 166 |
+
if not found:
|
| 167 |
+
continue
|
| 168 |
+
severities = [UNKNOWN_LEVEL_AS if f["level"] == "Unknown" else f["level"].lower() for f in found]
|
| 169 |
+
severity = max(severities, key=RANK.get)
|
| 170 |
+
graded = severity != "ungraded"
|
| 171 |
+
words = {"major": "a serious", "moderate": "a moderate", "minor": "a minor", "ungraded": ""}[severity]
|
| 172 |
+
text = (f"{a.display_name} and {b.display_name} have {words} interaction." if graded else
|
| 173 |
+
f"{a.display_name} and {b.display_name} are listed as interacting, but the severity isn't graded.")
|
| 174 |
+
alerts.append(Alert("interaction", severity, text, [a.id, b.id], SOURCE_DDINTER,
|
| 175 |
+
severity_graded=graded, details={"pairs": found}))
|
| 176 |
+
|
| 177 |
+
alerts.sort(key=lambda al: -RANK[al.severity])
|
| 178 |
+
return alerts, per_med
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _number_word(n):
|
| 182 |
+
words = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]
|
| 183 |
+
return words[n] if 0 <= n < len(words) else str(n)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _count_words(n, singular, plural=None):
|
| 187 |
+
return f"{_number_word(n)} {singular if n == 1 else (plural or singular + 's')}"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def check_dose(schedule, pills, names=None):
|
| 191 |
+
"""
|
| 192 |
+
Dose check after Stage 2's matching.
|
| 193 |
+
schedule: [{"medication_id", "count"}] for this dose (count = pills expected)
|
| 194 |
+
pills: [{"medication_id", "decision"}], decision = match / unknown / ambiguous, from /v1/match
|
| 195 |
+
names: optional {medication_id: display name} for messages (enrolled medications)
|
| 196 |
+
Returns (alerts, summary dict).
|
| 197 |
+
"""
|
| 198 |
+
names = {str(k): v for k, v in (names or {}).items()}
|
| 199 |
+
expected = {str(s["medication_id"]): int(s.get("count", 1)) for s in schedule}
|
| 200 |
+
matched, unknown, ambiguous = {}, 0, 0
|
| 201 |
+
for p in pills:
|
| 202 |
+
decision = p.get("decision", "unknown")
|
| 203 |
+
if decision == "match" and p.get("medication_id") is not None:
|
| 204 |
+
mid = str(p["medication_id"])
|
| 205 |
+
matched[mid] = matched.get(mid, 0) + 1
|
| 206 |
+
elif decision == "ambiguous":
|
| 207 |
+
ambiguous += 1
|
| 208 |
+
else:
|
| 209 |
+
unknown += 1
|
| 210 |
+
name = lambda mid: names.get(mid, mid)
|
| 211 |
+
alerts = []
|
| 212 |
+
if unknown:
|
| 213 |
+
what = "One pill doesn't" if unknown == 1 else f"{_count_words(unknown, 'pill').capitalize()} don't"
|
| 214 |
+
alerts.append(Alert("unknown_pill", "major",
|
| 215 |
+
f"{what} look like any of your medicines. Please set {'it' if unknown == 1 else 'them'} "
|
| 216 |
+
"aside.",
|
| 217 |
+
[], SOURCE_SCHEDULE, details={"count": unknown}))
|
| 218 |
+
for mid, n in sorted(matched.items()):
|
| 219 |
+
e = expected.get(mid, 0)
|
| 220 |
+
if e == 0:
|
| 221 |
+
alerts.append(Alert("not_scheduled", "major",
|
| 222 |
+
f"{'One pill looks' if n == 1 else _count_words(n, 'pill').capitalize() + ' look'} like "
|
| 223 |
+
f"your {name(mid)}, which is not part of this dose. Please set "
|
| 224 |
+
f"{'it' if n == 1 else 'them'} aside.",
|
| 225 |
+
[mid], SOURCE_SCHEDULE, details={"found": n, "expected": 0}))
|
| 226 |
+
elif n > e:
|
| 227 |
+
alerts.append(Alert("double_dose", "major",
|
| 228 |
+
f"This photo shows {_count_words(n, 'pill')} that look like your {name(mid)} instead "
|
| 229 |
+
f"of {_number_word(e)}. Please set the extra {'one' if n - e == 1 else 'ones'} aside.",
|
| 230 |
+
[mid], SOURCE_SCHEDULE, details={"found": n, "expected": e}))
|
| 231 |
+
for mid, e in sorted(expected.items()):
|
| 232 |
+
n = matched.get(mid, 0)
|
| 233 |
+
if n < e: # not seen in this photo: never a missed dose (the backend knows what was taken)
|
| 234 |
+
text = (f"I can't see your {name(mid)} in this photo." if n == 0 else
|
| 235 |
+
f"I can see only {_count_words(n, 'pill')} that look like your {name(mid)}, instead of "
|
| 236 |
+
f"{_number_word(e)}.")
|
| 237 |
+
alerts.append(Alert("not_seen", "moderate", text, [mid], SOURCE_SCHEDULE,
|
| 238 |
+
details={"found": n, "expected": e}))
|
| 239 |
+
if ambiguous:
|
| 240 |
+
alerts.append(Alert("unsure_pill", "moderate",
|
| 241 |
+
f"I'm not sure about {'one pill' if ambiguous == 1 else _count_words(ambiguous, 'pill')}. "
|
| 242 |
+
"Please turn it over and photograph the dose again.", [], SOURCE_SCHEDULE,
|
| 243 |
+
details={"count": ambiguous}))
|
| 244 |
+
alerts.sort(key=lambda al: -RANK[al.severity])
|
| 245 |
+
total = len(pills)
|
| 246 |
+
summary = {
|
| 247 |
+
"pills_seen": total, "matched": sum(matched.values()), "unknown": unknown, "unsure": ambiguous,
|
| 248 |
+
"all_ok": not alerts, # deprecated name of all_visually_matched
|
| 249 |
+
"all_visually_matched": not alerts,
|
| 250 |
+
"count_reliable": True,
|
| 251 |
+
"text": (f"I see {_count_words(total, 'pill')}." +
|
| 252 |
+
(" They all look like the medicines of this dose." if not alerts and total else "")),
|
| 253 |
+
}
|
| 254 |
+
return alerts, summary
|