File size: 9,519 Bytes
17c8aaa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""

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)))