File size: 20,186 Bytes
5d31c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d07aeb2
 
 
 
 
 
 
 
5d31c67
 
d07aeb2
5d31c67
 
 
 
 
 
 
 
5c73a43
5d31c67
3c12e23
d07aeb2
c9b579e
4a5c225
5d31c67
 
d07aeb2
 
5c73a43
 
5d31c67
 
c9b579e
 
5d31c67
d07aeb2
 
 
 
5d31c67
c9b579e
5d31c67
 
 
 
d07aeb2
5d31c67
d07aeb2
5d31c67
 
 
d07aeb2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d31c67
 
 
 
 
 
 
 
 
5c73a43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d07aeb2
5c73a43
5d31c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c73a43
5d31c67
 
 
 
 
 
 
 
 
 
 
3c12e23
 
 
 
 
 
5d31c67
 
 
 
 
 
 
 
3c12e23
5d31c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d07aeb2
5c73a43
d07aeb2
5d31c67
 
 
5c73a43
 
 
 
 
 
 
 
 
3c12e23
5c73a43
 
 
 
 
 
 
d07aeb2
c9b579e
 
5c73a43
c9b579e
 
 
5c73a43
c9b579e
 
 
 
5c73a43
 
 
 
 
 
 
 
 
 
4a5c225
5c73a43
 
4a5c225
5c73a43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9b579e
 
5d31c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d07aeb2
5d31c67
d07aeb2
 
5d31c67
 
 
 
 
4a5c225
5c73a43
 
4a5c225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c12e23
4a5c225
 
 
5c73a43
 
4a5c225
 
 
 
 
5c73a43
4a5c225
 
 
 
5c73a43
4a5c225
5c73a43
4a5c225
 
 
 
 
 
 
 
 
 
 
 
 
 
5c73a43
 
 
5d31c67
c9b579e
5d31c67
 
5c73a43
d07aeb2
 
5d31c67
 
 
 
 
d07aeb2
5d31c67
 
 
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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
#!/usr/bin/env python3
"""CellQuant Gradio app (Hugging Face Space entry point).

Upload one or more fluorescence images (TIF/PNG/JPG; blue nuclei, green antibody,
red puncta) and get per-image counts of the six annotation categories, colour
overlays, and downloadable CSV/JSON of every detected object.
"""
from __future__ import annotations

import csv
import io
import json
import os
import sys
import tempfile
import zipfile

os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")

# ZeroGPU (Hugging Face Spaces): `spaces` must be imported before torch; its GPU decorator
# borrows a GPU for the duration of a call and is a no-op everywhere else.
try:
    import spaces  # noqa: E402
except ImportError:  # local runs and tests without the package
    spaces = None
ZERO_GPU = spaces is not None and str(os.environ.get("SPACES_ZERO_GPU", "")).lower() in ("1", "true")

import gradio as gr  # noqa: E402
import numpy as np  # noqa: E402
import time  # noqa: E402
from PIL import Image  # noqa: E402

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))  # run from any cwd / python -I

from cellquant import __version__  # noqa: E402
from cellquant.annotations import load_image
from cellquant.gemini_summary import enabled as gemini_enabled
from cellquant.gemini_summary import summarize as gemini_summarize
from cellquant.gemini_summary import summarize_comparison as gemini_summarize_comparison
from cellquant.infer import Predictor
from cellquant.labels import COUNT_COLUMNS, LABEL_COLORS, TOTAL_NUCLEI, TOTAL_PUNCTA, count_row
from cellquant.model import load_checkpoint
from cellquant.postprocess import PostConfig, analyze, render_overlay, result_to_json
from cellquant.validation import COMPARISON_HEADERS, COMPARISON_NOTE, DIFF_HEADERS, ValidationSet, compare, write_bundle

WEIGHTS = os.environ.get("CELLQUANT_WEIGHTS", os.path.join(os.path.dirname(os.path.abspath(__file__)), "weights", "cellquant.pt"))
# On ZeroGPU the model must sit on cuda from start-up (CUDA is emulated outside GPU calls); elsewhere CPU unless told otherwise.
DEVICE = os.environ.get("CELLQUANT_DEVICE", "cuda" if ZERO_GPU else "cpu")
TTA = int(os.environ.get("CELLQUANT_TTA", "8"))     # 1, 4 or 8 passes per image; 8 scored best in cross-validation
TTA_LABEL = {1: "a single pass", 4: "4 flips", 8: "8 rotations and flips"}.get(TTA, f"{TTA} passes")

EX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples")
VAL_DIR = os.environ.get("CELLQUANT_VALIDATION_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "validation"))
VALIDATION = ValidationSet(VAL_DIR)

MODEL = None
if os.path.exists(WEIGHTS):
    MODEL, _META = load_checkpoint(WEIGHTS)
    MODEL.to(DEVICE).eval()
_predictors: dict[int, Predictor] = {}
_val_probs: dict[tuple[str, int], dict] = {}   # (image key, tta) -> network probabilities, a few entries


def get_predictor(tta: int) -> Predictor:
    if tta not in _predictors:
        if MODEL is None:
            raise gr.Error(f"Model weights not found at {WEIGHTS}. Train with train.py or set CELLQUANT_WEIGHTS.")
        _predictors[tta] = Predictor(MODEL, device=DEVICE, tta=tta)
    return _predictors[tta]


def gpu(duration):
    """`@spaces.GPU(duration=...)` on ZeroGPU, identity elsewhere. ``duration`` may be a
    callable of the decorated function's arguments (seconds of GPU time to reserve;
    shorter reservations get a better place in the queue)."""
    if spaces is None:
        return lambda f: f
    return spaces.GPU(duration=duration)


def _device_name() -> str:
    if DEVICE.startswith("cuda"):
        return "GPU (ZeroGPU)" if ZERO_GPU else "GPU"
    return "Apple GPU" if DEVICE == "mps" else "CPU"


def _legend_html() -> str:
    items = "".join(
        f'<span style="display:inline-block;margin:2px 10px 2px 0"><span style="display:inline-block;width:14px;height:14px;'
        f'background:rgb{c};border:1px solid #888;vertical-align:middle;margin-right:4px"></span>{lab}</span>'
        for lab, c in LABEL_COLORS.items()
    )
    return f"<div>{items}</div>"


def _gpu_seconds(n_images: int) -> int:
    """GPU time to reserve on ZeroGPU: a fixed start-up share plus a per-image share that grows with TTA."""
    return int(min(600, 15 + {1: 4, 4: 6, 8: 10}.get(TTA, 10) * max(1, n_images)))


def _safe_progress(progress, *args, **kwargs) -> None:
    try:
        progress(*args, **kwargs)
    except Exception:  # noqa: BLE001 - a progress bar must never break a run (e.g. inside a GPU worker)
        pass


def _gemini_section(text: str | None) -> str:
    if not gemini_enabled():
        return "*Gemini summary is off: add a GEMINI_API_KEY secret to this Space to enable it.*"
    return "### Summary (Gemini)\n" + (text or "")


@gpu(duration=lambda files, *a, **k: _gpu_seconds(len(files or [])))
def _analyze_gpu(files, antibody_thr, cellnos_thr, min_cell_area, nostril_rule, cellnos_rule, progress=gr.Progress()):
    """The GPU part of analyze_files: predictions, overlays and the download bundle."""
    t_start = time.time()
    tta = TTA
    cfg = PostConfig(antibody_thr=float(antibody_thr), cellnos_thr=float(cellnos_thr),
                     min_cell_area=int(min_cell_area), nostril_rule=nostril_rule, cellnos_rule=cellnos_rule)
    pred = get_predictor(tta)
    rows, gallery, per_image_json, csv_objects = [], [], {}, []
    tmpdir = tempfile.mkdtemp(prefix="cellquant_")
    overlays = {}
    warnings_: list[str] = []
    seen: dict[str, int] = {}
    for i, f in enumerate(files):
        path = f if isinstance(f, str) else f.name
        name = os.path.basename(path)
        k = seen.get(name, 0)
        seen[name] = k + 1
        if k:  # two uploads with the same file name: keep both
            stem, ext = os.path.splitext(name)
            name = f"{stem}_{k + 1}{ext}"
        _safe_progress(progress, (i, len(files)), desc=f"analysing {name}")
        try:
            img = load_image(path)
        except Exception as e:  # noqa: BLE001
            raise gr.Error(f"{name}: cannot read image ({e})")
        if np.array_equal(img[..., 0], img[..., 1]) and np.array_equal(img[..., 1], img[..., 2]):
            warnings_.append(f"{name}: all three channels are identical (greyscale input); the model needs blue nuclei, green antibody and red puncta.")
        res, _ = pred.predict(img, cfg)
        ov = render_overlay(img, res, thickness=1)
        overlays[name] = ov
        out_png = os.path.join(tmpdir, os.path.splitext(name)[0] + "_overlay.png")
        Image.fromarray(ov).save(out_png)
        row = count_row(res.counts)
        n_nuclei, n_puncta = row[COUNT_COLUMNS.index(TOTAL_NUCLEI)], row[COUNT_COLUMNS.index(TOTAL_PUNCTA)]
        gallery.append((out_png, f"{name}: {n_nuclei} nuclei, {n_puncta} puncta"))
        rows.append([name] + row)
        per_image_json[name] = {"image_size_hw": list(img.shape[:2]), **result_to_json(res),
                                "totals": {TOTAL_NUCLEI: n_nuclei, TOTAL_PUNCTA: n_puncta}}
        for c in res.cells:
            csv_objects.append([name, "nucleus", c["id"], c["label"], round(c["area_px"], 1), round(c["centroid_xy"][0], 1), round(c["centroid_xy"][1], 1), round(c["p_antibody"], 3), round(c["p_nostril"], 3), c["n_puncta_inside"]])
        for n_ in res.nostrils:
            csv_objects.append([name, "punctum", n_["id"], n_["label"], round(n_["area_px"], 1), round(n_["centroid_xy"][0], 1), round(n_["centroid_xy"][1], 1), "", round(n_["p_in_nucleus"], 3), n_["cell_id"]])
    # downloads
    counts_csv = os.path.join(tmpdir, "counts.csv")
    with open(counts_csv, "w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["image"] + list(COUNT_COLUMNS))
        w.writerows(rows)
    objects_csv = os.path.join(tmpdir, "objects.csv")
    with open(objects_csv, "w", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["image", "kind", "id", "label", "area_px", "centroid_x", "centroid_y", "p_antibody", "p_nostril_or_in_nucleus", "n_puncta_inside_or_cell_id"])
        w.writerows(csv_objects)
    json_path = os.path.join(tmpdir, "results.json")
    with open(json_path, "w") as fh:
        json.dump({"cellquant_version": __version__, "weights": os.path.basename(WEIGHTS), "tta": tta,
                   "postprocess": cfg.__dict__, "images": per_image_json}, fh, indent=1)
    zip_path = os.path.join(tmpdir, "cellquant_results.zip")
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
        for p in (counts_csv, objects_csv, json_path):
            zf.write(p, os.path.basename(p))
        for p, _ in gallery:
            zf.write(p, os.path.basename(p))
    summary = (f"Analysed {len(rows)} image{'s' if len(rows) != 1 else ''} in {time.time() - t_start:.1f} s "
               f"on the {_device_name()}, each averaged over {TTA_LABEL}.")
    summary += "".join(f"\n\nWarning: {w}" for w in warnings_)
    return rows, gallery, zip_path, summary


def analyze_files(files, antibody_thr, cellnos_thr, min_cell_area, nostril_rule, cellnos_rule, progress=gr.Progress()):
    """Section 1: counts, overlays and downloads for uploaded images, then the Gemini write-up
    (outside the GPU reservation, so the wait for Gemini costs no GPU time)."""
    if not files:
        raise gr.Error("Upload at least one image.")
    rows, gallery, zip_path, summary = _analyze_gpu(files, antibody_thr, cellnos_thr, min_cell_area, nostril_rule, cellnos_rule)
    _safe_progress(progress, 0.95, desc="writing the summary")
    text = None
    if gemini_enabled():
        text = gemini_summarize([{"image": r[0], "counts": dict(zip(COUNT_COLUMNS, r[1:]))} for r in rows],
                                overlay=Image.open(gallery[0][0]))
    return rows, gallery, zip_path, summary + "\n\n" + _gemini_section(text)


@gpu(duration=lambda key, *a, **k: _gpu_seconds(1))
def _validate_gpu(key) -> dict:
    """The GPU part of validate_image: prediction, matching, overlays and bundle (plain data only)."""
    t_start = time.time()
    img = VALIDATION.image(key)
    R = VALIDATION.raster(key)
    ck = (key, TTA)
    if ck not in _val_probs:
        if len(_val_probs) >= 4:
            _val_probs.pop(next(iter(_val_probs)))
        _val_probs[ck] = get_predictor(TTA).probs(img)
    res = analyze(_val_probs[ck], PostConfig())
    cmp = compare(key, img, R, res)
    tmpdir = tempfile.mkdtemp(prefix="cellquant_val_")
    zip_path = write_bundle(cmp, tmpdir)
    return {
        "summary": cmp.summary, "rows": cmp.rows, "diffs": cmp.diffs, "zip": zip_path,
        "manual": os.path.join(tmpdir, f"{key}_manual.png"), "predicted": os.path.join(tmpdir, f"{key}_predicted.png"),
        "missed": os.path.join(tmpdir, f"{key}_missed_only.png"),
        "n_gt": (R.n_cells, R.n_nostrils), "n_pred": (len(res.cells), len(res.nostrils)),
        "n_missed": (sum(1 for d in cmp.diffs if d[1] == "missed" and d[2] == "nucleus"),
                     sum(1 for d in cmp.diffs if d[1] == "missed" and d[2] == "punctum")),
        "seconds": time.time() - t_start,
    }


def validate_image(key, progress=gr.Progress()):
    """Section 2: run the model on one bundled annotated image and compare it with the annotation.

    Returns the summary (with the Gemini write-up), the three panels (manual / predicted /
    missed only, with their captions), the per-label table, the per-object table and the download."""
    if not key or key not in VALIDATION.keys:
        raise gr.Error("Pick one of the annotated images.")
    _safe_progress(progress, 0.1, desc=f"running the model on {key}")
    r = _validate_gpu(key)
    manual = gr.Image(value=r["manual"], label=f"1. Manual annotation of {key}: {r['n_gt'][0]} nuclei, {r['n_gt'][1]} puncta")
    predicted = gr.Image(value=r["predicted"], label=f"2. Model prediction: {r['n_pred'][0]} nuclei, {r['n_pred'][1]} puncta")
    missed = gr.Image(value=r["missed"], label=f"3. Missed only: {r['n_missed'][0]} nuclei and {r['n_missed'][1]} puncta annotated "
                                                f"but not predicted (red, numbered as in the list below)")
    _safe_progress(progress, 0.9, desc="writing the summary")
    text = None
    if gemini_enabled():
        text = gemini_summarize_comparison(key, r["rows"], COMPARISON_HEADERS, overlay=Image.open(r["missed"]))
    summary = (r["summary"] + f"\n* **Run time:** {r['seconds']:.1f} s on the {_device_name()}, averaged over {TTA_LABEL}."
               + "\n\n" + _gemini_section(text))
    return summary, manual, predicted, missed, r["rows"], r["diffs"], r["zip"]


def pick_validation_image(evt: gr.SelectData) -> str:
    """Gallery click -> key of the chosen annotated image (validate_image runs next)."""
    return VALIDATION.keys[evt.index]


FORCE_LIGHT_JS = """
function refresh() {
    const url = new URL(window.location);
    if (url.searchParams.get('__theme') !== 'light') {
        url.searchParams.set('__theme', 'light');
        window.location.href = url.href;
    }
}
"""

CSS = """
body, .gradio-container { background: #fff !important; }
.gradio-container .prose, .gradio-container .prose * { color: #000; }
.gradio-container .prose a { color: #000; text-decoration: underline; }
.gradio-container table, .gradio-container td, .gradio-container th { color: #000; }
footer { display: none !important; }
"""


def build_demo() -> gr.Blocks:
    theme = gr.themes.Monochrome(primary_hue="neutral", neutral_hue="neutral",
                                 font=["Helvetica Neue", "Helvetica", "Arial", "sans-serif"],
                                 font_mono=["Menlo", "Consolas", "monospace"])
    with gr.Blocks(title="AI Cell Quantification", theme=theme, css=CSS, js=FORCE_LIGHT_JS) as demo:
        gr.Markdown(
            f"# AI Cell Quantification\n"
            f"CellQuant {__version__}. "
            "Counts nuclei (antibody positive or negative), nuclei containing a red punctum (\"Nostril\") and puncta "
            "inside or outside nuclei in RGB fluorescence images (blue = DAPI, green = antibody, red = puncta). "
            "Trained on CVAT outlines of 35-day spinal-cord sections stained for Iba1 or TMEM119."
        )
        gr.HTML(_legend_html())
        # ---- section 1: the user's own images
        gr.Markdown(f"## 1. Analyse your images\nEvery image is analysed as {TTA_LABEL} and the outputs averaged "
                    "(the setting that scored best in cross-validation). A written summary follows the counts.")
        with gr.Row():
            with gr.Column(scale=1):
                files = gr.File(label="Images (TIF / PNG / JPG)", file_count="multiple", file_types=["image", ".tif", ".tiff"])
                with gr.Accordion("Advanced", open=False):
                    antibody_thr = gr.Slider(0.1, 0.9, value=0.5, step=0.05, label="Antibody + threshold")
                    cellnos_thr = gr.Slider(0.1, 0.9, value=0.5, step=0.05, label="Nucleus 'Nostril' threshold")
                    min_cell_area = gr.Slider(10, 300, value=50, step=5, label="Minimum nucleus area (px)")
                    nostril_rule = gr.Radio(["head", "geometric"], value="head", label="Punctum type from: network head / position relative to nuclei")
                    cellnos_rule = gr.Radio(["contains", "either", "head"], value="contains", label="Nucleus gets 'Nostril' if: contains an in-nucleus punctum / that OR the network head / head only")
                run = gr.Button("Analyse", variant="primary")
                examples = sorted(os.path.join(EX_DIR, f) for f in os.listdir(EX_DIR)
                                  if f.lower().endswith((".png", ".jpg", ".jpeg", ".tif", ".tiff"))) if os.path.isdir(EX_DIR) else []
                if examples:
                    gr.Examples(examples=[[[e]] for e in examples], inputs=[files], label="Example (512 px crop of a training image)")
            with gr.Column(scale=2):
                table = gr.Dataframe(headers=["image"] + list(COUNT_COLUMNS), label="Counts", interactive=False, wrap=True)
                gallery = gr.Gallery(label="Overlays", columns=2, height="auto")
                zip_out = gr.File(label="Download results (CSV + JSON + overlays)")
                summary = gr.Markdown()
        run.click(analyze_files, [files, antibody_thr, cellnos_thr, min_cell_area, nostril_rule, cellnos_rule],
                  [table, gallery, zip_out, summary], api_name="analyze_files")
        # ---- section 2: the annotated images, manual vs predicted vs missed
        if VALIDATION.keys:
            n = len(VALIDATION.keys)
            gr.Markdown(
                f"## 2. Check the model against the {n} annotated images\n"
                f"Click one of the {n} images. The model runs on it ({TTA_LABEL}, averaged) and three "
                "panels appear: the **manual annotation**, "
                "the **model prediction** (both in the label colours above) and **missed only**, the annotated objects the "
                "model did not find, outlined in red with their row number from the list at the bottom. "
                "The final model was trained on these images, so this shows its fit, not its accuracy on new images "
                "(those numbers are in the README). A written summary of the comparison follows the panels."
            )
            val_key = gr.Textbox(visible=False)
            val_gallery = gr.Gallery(value=VALIDATION.gallery(), label="Annotated images (click one)", columns=5,
                                     height="auto", allow_preview=False, object_fit="contain", interactive=False)
            val_summary = gr.Markdown("*No image selected yet.*")
            with gr.Row():
                img_manual = gr.Image(label="1. Manual annotation", type="filepath", interactive=False, height=460)
                img_pred = gr.Image(label="2. Model prediction", type="filepath", interactive=False, height=460)
                img_missed = gr.Image(label="3. Missed only (annotated, not predicted)", type="filepath", interactive=False, height=460)
            gr.Markdown("Open a panel full screen with the button in its corner. " + COMPARISON_NOTE)
            val_table = gr.Dataframe(headers=COMPARISON_HEADERS, label="Annotated vs predicted, per label", interactive=False,
                                     wrap=True, column_widths=["25%", "12%", "12%", "12%", "11%", "12%", "8%", "8%"])
            val_diffs = gr.Dataframe(headers=DIFF_HEADERS, interactive=False, wrap=True, max_height=600,
                                     label="Every missed, extra or wrongly labelled object, numbered as on the panels (x, y in pixels from the top-left corner)",
                                     column_widths=["4%", "9%", "8%", "15%", "15%", "6%", "6%", "6%", "31%"])
            val_zip = gr.File(label="Download the five overlays (manual, predicted, missed only, extra only, all differences) and both tables")
            val_gallery.select(pick_validation_image, None, val_key, api_name=False).then(
                validate_image, [val_key], [val_summary, img_manual, img_pred, img_missed, val_table, val_diffs, val_zip],
                api_name="validate_image")
        gr.Markdown(
            "Nucleus outlines come from a watershed over the network's distance and class maps. "
            "A nucleus is labelled Nostril when a punctum classified as in-nucleus lies inside it. "
            "Images larger than 1536 px are processed in overlapping tiles. Nothing leaves the Space except, "
            "the counts and one overlay thumbnail sent to Gemini for the written summary (only when the Space has a Gemini key). "
            + ("Inference runs on a shared GPU (Hugging Face ZeroGPU): no account is needed, each visitor has a daily "
               "allowance of GPU minutes and one image takes a few seconds." if ZERO_GPU else f"Inference runs on the {_device_name()}.")
        )
    return demo


if __name__ == "__main__":
    if MODEL is None:
        raise SystemExit(f"CellQuant weights not found at {WEIGHTS}: run train.py --out weights/cellquant.pt or set CELLQUANT_WEIGHTS")
    build_demo().launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
                        allowed_paths=[EX_DIR], show_error=True)