pharmascan-api / app.py
DavidPanSX's picture
deploy API
17c8aaa verified
Raw History Blame Contribute Delete
9.52 kB
"""
The Hugging Face Space entry point (Gradio SDK on ZeroGPU hardware, free for a personal account): the REST API of
serving/app/main.py unchanged, plus a demo page at /ui. scripts/deploy_hf.py uploads this file as the Space's app.py.
ZeroGPU lends a GPU per call. A request needs at most one (serving/app/vision.py): the pill model and every reading
(Florence-2-large: imprints, labels, the input check) run together in one @spaces.GPU call; requests that only embed
pills stay on the CPU, so the free allowance (a daily number of runs) lasts. When no GPU is granted (allowance used
up, queue too long) the request runs on the CPU with Florence-2-base within a time budget, and stays on the CPU for
COOLDOWN seconds, so answers keep coming.
Off ZeroGPU it is the plain CPU API with the demo page: python serving/space_app.py (http://localhost:7860/ui)
"""
import copy
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
BASE = HERE if (HERE / "serving").is_dir() else HERE.parent # the Space root (app.py), or the epills folder
sys.path[:0] = [str(BASE / "src"), str(BASE)]
try:
import spaces # before torch: on ZeroGPU it patches torch
except ImportError:
spaces = None
import gradio as gr
import torch
import uvicorn
from fastapi import HTTPException
from fastapi.responses import RedirectResponse
import threading
from epills.core.imprint.ocr import GPU_MODEL
from epills.core.inference.pill_finder import find_pills
from epills.core.inference.runtime import cpu_cores
from serving.app.fallback import GpuOrCpu
from serving.app.main import IDENTIFY_NOTE, LABEL_NOTE, app, asset, identify_pill, imprint_adapter_path, vision
ZERO_GPU = spaces is not None and os.environ.get("SPACES_ZERO_GPU", "").lower() in ("1", "t", "true")
COOLDOWN = 600 # seconds on the CPU after ZeroGPU refused a call
if os.environ.get("EPILLS_MODEL_REPO"):
token = os.environ.get("HF_TOKEN")
try:
from huggingface_hub import whoami
who = whoami(token)["name"] if token else "no token"
except Exception as error:
who = f"token rejected ({type(error).__name__})"
print(f"assets from {os.environ['EPILLS_MODEL_REPO']}: HF_TOKEN {'set' if token else 'NOT set'}, {who}", flush=True)
for name in ("safety_db", "embedder", "catalogue", "reader"): # now: ZeroGPU needs the models before start-up
try:
asset(name)
except HTTPException as error:
print(f"{name}: {error.detail}", flush=True)
embedder, reader = app.state.embedder, app.state.reader
print(f"CPU: {cpu_cores()} usable cores (os.cpu_count {os.cpu_count()}); torch threads {torch.get_num_threads()}",
flush=True)
pipeline = vision() if embedder is not None else None
if pipeline is not None and reader is not None:
pipeline.warm_up() # times a CPU reading, for the CPU time budget
print(f"CPU reading: {pipeline.seconds_per_image:.2f} s per image; budget {pipeline.budget:.0f} s", flush=True)
threading.Thread(target=lambda: asset("label_reader").vocab, daemon=True).start() # RxNorm names, ~3 s
if ZERO_GPU and pipeline is not None:
from transformers import Florence2ForConditionalGeneration
gpu_model = copy.deepcopy(embedder.model).to("cuda") # placed now; ZeroGPU moves them to a real GPU per call
gpu_ocr = Florence2ForConditionalGeneration.from_pretrained(GPU_MODEL, torch_dtype=torch.float16).eval()
adapter = imprint_adapter_path() # trained on pill imprints; switched on for imprint reads only
if adapter is not None: # added on the CPU, then moved with the model
from epills.core.imprint.lora import load_lora, set_lora
app.state.gpu_imprint_adapter = load_lora(gpu_ocr, adapter).get("name", "imprint adapter")
print(f"imprint adapter: {app.state.gpu_imprint_adapter}", flush=True)
gpu_ocr = gpu_ocr.to("cuda")
@spaces.GPU(duration=30)
def gpu_compute(payload):
"""One request's work in one GPU call: the pill model, then every reading (large model)."""
out = {"embed": None, "gen": [], "device": "zerogpu"}
with torch.no_grad():
if payload["pixels"] is not None:
with torch.autocast("cuda", dtype=torch.bfloat16):
out["embed"] = gpu_model(payload["pixels"].cuda())["embedding"].float().cpu().numpy()
for job in payload["jobs"]:
if adapter is not None:
set_lora(gpu_ocr, job["task"] == "read")
out["gen"].append(gpu_ocr.generate(input_ids=job["ids"].cuda(), pixel_values=job["px"].cuda().half(),
max_new_tokens=job["tokens"], num_beams=3).cpu())
return out
pipeline.compute = GpuOrCpu(gpu_compute, pipeline.cpu_compute, COOLDOWN)
# ---- demo page -------------------------------------------------------------------------------------------------------
def candidates(vectors, imprint=None, top_k=3, reads=None):
try:
return identify_pill(vectors, imprint=imprint or None, top_k=top_k, reads=reads)["candidates"]
except HTTPException as error:
raise gr.Error(str(error.detail))
def label(c):
read = ", imprint read" if c.get("imprint_read") else ""
return f"{c['name']} (imprint {c['imprint'] or 'none'}{read}), similarity {c['similarity']:.2f}"
def ui_identify(photo, imprint):
if photo is None or embedder is None:
raise gr.Error("Add a photo of one pill." if embedder else "The model isn't loaded; see /health.")
cat, out = app.state.catalogue, []
crops = embedder.frame(photo)
r = vision().run(crops, None if imprint else crops)
for c in candidates(r["embeddings"], imprint, top_k=6, reads=(r["reads"] or [None])[0]):
images = cat.product(c["pill_id"])["images"]
if images:
out.append((str(cat.image_file(c["pill_id"], images[0]["n"])), label(c)))
return out
def ui_scan(photo):
if photo is None or embedder is None:
raise gr.Error("Add a photo of the pills." if embedder else "The model isn't loaded; see /health.")
boxes = find_pills(photo)
if not boxes:
raise gr.Error("No pill found. Spread the pills apart on a plain background and photograph them again.")
rows, marks = [], []
crops = embedder.frame(photo, boxes)
r = vision().run(crops, crops)
for k, (box, vector, read) in enumerate(zip(boxes, r["embeddings"], r["reads"] or [None] * len(crops)), 1):
rows.append([k] + [label(c) for c in candidates(vector[None], reads=read)])
marks.append(([round(v) for v in box], f"pill {k}"))
return (photo, marks), rows
def ui_label(photo):
if photo is None or reader is None:
raise gr.Error("Add a photo of a box or label." if reader else "Text reading isn't loaded; see /health.")
try:
found = asset("label_reader").read(vision().run(text=photo)["lines"] or [])
except Exception as error:
raise gr.Error(f"Couldn't look the names up: {error}")
if not found:
raise gr.Error("No medicine name found. Photograph the label closer, straight on and in good light.")
return [[c["name"], ", ".join(i["name"] for i in c["ingredients"]), c["rxcui"], c["rxnorm_name"] or ""]
for c in found]
with gr.Blocks(title="PharmaScan") as demo:
gr.Markdown("# PharmaScan\nA demo of the PharmaScan API ([REST API docs](/docs)). " + IDENTIFY_NOTE)
with gr.Tab("Identify a pill"):
with gr.Row():
with gr.Column():
pill = gr.Image(type="pil", label="One pill in the middle of the photo, filling about 70% of it")
imprint = gr.Textbox(label="Imprint (optional)", placeholder="e.g. M 547")
identify_button = gr.Button("Identify", variant="primary")
gallery = gr.Gallery(label="Most alike pills", columns=3)
identify_button.click(ui_identify, [pill, imprint], gallery)
with gr.Tab("Scan a photo"):
with gr.Row():
with gr.Column():
full = gr.Image(type="pil", label="Several pills, apart, on a plain background")
scan_button = gr.Button("Scan", variant="primary")
found = gr.AnnotatedImage(label="Pills found")
table = gr.Dataframe(headers=["pill", "most alike", "2nd", "3rd"], label="Catalogue matches")
scan_button.click(ui_scan, full, [found, table])
with gr.Tab("Read a box or label"):
with gr.Row():
with gr.Column():
box_photo = gr.Image(type="pil", label="The medicine's box or pharmacy label, close up")
label_button = gr.Button("Read", variant="primary")
medicines = gr.Dataframe(headers=["medicine", "ingredients", "RxNorm code", "RxNorm name"],
label=LABEL_NOTE)
label_button.click(ui_label, box_photo, medicines)
app = gr.mount_gradio_app(app, demo, path="/ui", ssr_mode=False) # SSR's Node server would take port 7860
@app.get("/", include_in_schema=False)
def root():
return RedirectResponse("/ui")
if __name__ == "__main__":
if ZERO_GPU and pipeline is not None:
from spaces.zero import startup # what demo.launch() would run: hands the placed weights to ZeroGPU
startup()
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))