stevafernandes commited on
Commit
d07aeb2
·
verified ·
1 Parent(s): 4a5c225

Deploy CellQuant

Browse files
Files changed (6) hide show
  1. README.md +21 -4
  2. app.py +58 -11
  3. cellquant/__init__.py +1 -1
  4. requirements.txt +4 -3
  5. scripts/deploy_hf.py +22 -0
  6. tests/test_app.py +2 -1
README.md CHANGED
@@ -1,17 +1,18 @@
1
  ---
2
- title: CellQuant
3
  emoji: "⬜"
4
  colorFrom: gray
5
  colorTo: gray
6
  sdk: gradio
7
  sdk_version: 5.49.1
8
- python_version: "3.11"
 
9
  app_file: app.py
10
  pinned: false
11
  license: mit
12
  ---
13
 
14
- # CellQuant: nucleus, antibody and puncta counting for fluorescence sections
15
 
16
  CellQuant counts the six categories that were annotated in CVAT on 35-day spinal-cord
17
  sections stained with DAPI (blue), an antibody (green: Iba1 or TMEM119) and a red punctum
@@ -131,9 +132,25 @@ huggingface-cli login
131
  huggingface-cli upload <your-user>/cellquant . . --repo-type space --exclude "runs/*" --exclude "tests/*" --exclude ".pytest_cache/*"
132
  ```
133
 
134
- Create the Space first (SDK: Gradio, hardware: free CPU) or add `--create`. Add the optional
135
  `GEMINI_API_KEY` secret under *Settings → Variables and secrets*.
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  ## Running locally
138
 
139
  ```bash
 
1
  ---
2
+ title: AI Cell Quantification
3
  emoji: "⬜"
4
  colorFrom: gray
5
  colorTo: gray
6
  sdk: gradio
7
  sdk_version: 5.49.1
8
+ python_version: "3.12"
9
+ suggested_hardware: zero-a10g
10
  app_file: app.py
11
  pinned: false
12
  license: mit
13
  ---
14
 
15
+ # AI Cell Quantification (CellQuant): nucleus, antibody and puncta counting for fluorescence sections
16
 
17
  CellQuant counts the six categories that were annotated in CVAT on 35-day spinal-cord
18
  sections stained with DAPI (blue), an antibody (green: Iba1 or TMEM119) and a red punctum
 
132
  huggingface-cli upload <your-user>/cellquant . . --repo-type space --exclude "runs/*" --exclude "tests/*" --exclude ".pytest_cache/*"
133
  ```
134
 
135
+ Create the Space first (SDK: Gradio) or add `--create`. Add the optional
136
  `GEMINI_API_KEY` secret under *Settings → Variables and secrets*.
137
 
138
+ The Space runs on **ZeroGPU** (a shared NVIDIA GPU that is attached only while a call runs):
139
+ `app.py` imports `spaces` before torch, puts the model on `cuda` at start-up and wraps the two
140
+ inference functions in `@spaces.GPU` with a duration estimated from the number of images and the
141
+ TTA setting. Visitors need no account: an unauthenticated visitor has 2 GPU minutes a day (about
142
+ 60 full images at 8× TTA), a free account 5, a PRO account 40. Hosting a ZeroGPU Space needs a
143
+ Hugging Face account in good standing (two Spaces free; PRO accounts up to ten), Python 3.12 and
144
+ torch ≥ 2.8 (`README.md` front matter and `requirements.txt`). The deploy script does the whole
145
+ round trip and times a full image on the Space:
146
+
147
+ ```bash
148
+ python scripts/deploy_hf.py --repo <your-user>/cellquant --hardware zero-a10g --bench
149
+ ```
150
+
151
+ Without `spaces` installed, or on CPU hardware, the same code runs unchanged on the CPU
152
+ (`CELLQUANT_DEVICE` overrides the device).
153
+
154
  ## Running locally
155
 
156
  ```bash
app.py CHANGED
@@ -17,8 +17,17 @@ import zipfile
17
 
18
  os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
19
 
 
 
 
 
 
 
 
 
20
  import gradio as gr # noqa: E402
21
  import numpy as np # noqa: E402
 
22
  from PIL import Image # noqa: E402
23
 
24
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # run from any cwd / python -I
@@ -29,28 +38,56 @@ from cellquant.gemini_summary import enabled as gemini_enabled
29
  from cellquant.gemini_summary import summarize as gemini_summarize
30
  from cellquant.infer import Predictor
31
  from cellquant.labels import ALL_LABELS, LABEL_COLORS
 
32
  from cellquant.postprocess import PostConfig, analyze, render_overlay, result_to_json
33
  from cellquant.validation import COMPARISON_HEADERS, COMPARISON_NOTE, DIFF_HEADERS, ValidationSet, compare, write_bundle
34
 
35
  WEIGHTS = os.environ.get("CELLQUANT_WEIGHTS", os.path.join(os.path.dirname(os.path.abspath(__file__)), "weights", "cellquant.pt"))
36
- DEVICE = os.environ.get("CELLQUANT_DEVICE", "cpu")
 
 
37
 
38
  EX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples")
39
  VAL_DIR = os.environ.get("CELLQUANT_VALIDATION_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "validation"))
40
  VALIDATION = ValidationSet(VAL_DIR)
41
 
 
 
 
 
42
  _predictors: dict[int, Predictor] = {}
43
  _val_probs: dict[tuple[str, int], dict] = {} # (image key, tta) -> network probabilities, a few entries
44
 
45
 
46
  def get_predictor(tta: int) -> Predictor:
47
  if tta not in _predictors:
48
- if not os.path.exists(WEIGHTS):
49
  raise gr.Error(f"Model weights not found at {WEIGHTS}. Train with train.py or set CELLQUANT_WEIGHTS.")
50
- _predictors[tta] = Predictor(WEIGHTS, device=DEVICE, tta=tta)
51
  return _predictors[tta]
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  def _legend_html() -> str:
55
  items = "".join(
56
  f'<span style="display:inline-block;margin:2px 10px 2px 0"><span style="display:inline-block;width:14px;height:14px;'
@@ -60,11 +97,13 @@ def _legend_html() -> str:
60
  return f"<div>{items}</div>"
61
 
62
 
 
63
  def analyze_files(files, tta_choice, antibody_thr, cellnos_thr, min_cell_area, nostril_rule, cellnos_rule,
64
  want_summary, progress=gr.Progress()):
65
  if not files:
66
  raise gr.Error("Upload at least one image.")
67
- tta = {"none (fastest)": 1, "4x flips": 4, "8x rotations+flips": 8}[tta_choice]
 
68
  cfg = PostConfig(antibody_thr=float(antibody_thr), cellnos_thr=float(cellnos_thr),
69
  min_cell_area=int(min_cell_area), nostril_rule=nostril_rule, cellnos_rule=cellnos_rule)
70
  pred = get_predictor(tta)
@@ -121,7 +160,9 @@ def analyze_files(files, tta_choice, antibody_thr, cellnos_thr, min_cell_area, n
121
  zf.write(p, os.path.basename(p))
122
  for p, _ in gallery:
123
  zf.write(p, os.path.basename(p))
124
- summary = "\n\n".join(f"Warning: {w}" for w in warnings_)
 
 
125
  if want_summary:
126
  if gemini_enabled():
127
  first = next(iter(overlays.values()))
@@ -132,6 +173,7 @@ def analyze_files(files, tta_choice, antibody_thr, cellnos_thr, min_cell_area, n
132
  return rows, gallery, zip_path, summary
133
 
134
 
 
135
  def validate_image(key, tta_choice="none (fastest)", progress=gr.Progress()):
136
  """Run the model on one bundled annotated image and compare it with the annotation.
137
 
@@ -139,7 +181,8 @@ def validate_image(key, tta_choice="none (fastest)", progress=gr.Progress()):
139
  their captions), the per-label table, the per-object table and the download."""
140
  if not key or key not in VALIDATION.keys:
141
  raise gr.Error("Pick one of the annotated images.")
142
- tta = {"none (fastest)": 1, "4x flips": 4, "8x rotations+flips": 8}[tta_choice]
 
143
  img = VALIDATION.image(key)
144
  R = VALIDATION.raster(key)
145
  progress(0.1, desc=f"running the model on {key}")
@@ -162,7 +205,8 @@ def validate_image(key, tta_choice="none (fastest)", progress=gr.Progress()):
162
  missed = gr.Image(value=os.path.join(tmpdir, f"{key}_missed_only.png"),
163
  label=f"3. Missed only: {n_missed_nuc} nuclei and {n_missed_pun} puncta annotated but not predicted "
164
  f"(red, numbered as in the list below)")
165
- return cmp.summary, manual, predicted, missed, cmp.rows, cmp.diffs, zip_path
 
166
 
167
 
168
  def pick_validation_image(tta_choice, evt: gr.SelectData):
@@ -197,9 +241,10 @@ def build_demo() -> gr.Blocks:
197
  tta_default = os.environ.get("CELLQUANT_TTA_DEFAULT", "8x rotations+flips")
198
  if tta_default not in tta_choices:
199
  tta_default = tta_choices[-1]
200
- with gr.Blocks(title="CellQuant", theme=theme, css=CSS, js=FORCE_LIGHT_JS) as demo:
201
  gr.Markdown(
202
- f"# CellQuant {__version__}\n"
 
203
  "Counts nuclei (antibody positive or negative), nuclei containing a red punctum (\"Nostril\") and puncta "
204
  "inside or outside nuclei in RGB fluorescence images (blue = DAPI, green = antibody, red = puncta). "
205
  "Trained on CVAT outlines of 35-day spinal-cord sections stained for Iba1 or TMEM119."
@@ -264,13 +309,15 @@ def build_demo() -> gr.Blocks:
264
  "Nucleus outlines come from a watershed over the network's distance and class maps. "
265
  "A nucleus is labelled Nostril when a punctum classified as in-nucleus lies inside it. "
266
  "Images larger than 1536 px are processed in overlapping tiles. Nothing leaves the Space except, "
267
- "optionally, the counts and an overlay thumbnail sent to Gemini when the summary is requested."
 
 
268
  )
269
  return demo
270
 
271
 
272
  if __name__ == "__main__":
273
- if not os.path.exists(WEIGHTS):
274
  raise SystemExit(f"CellQuant weights not found at {WEIGHTS}: run train.py --out weights/cellquant.pt or set CELLQUANT_WEIGHTS")
275
  build_demo().launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
276
  allowed_paths=[EX_DIR], show_error=True)
 
17
 
18
  os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
19
 
20
+ # ZeroGPU (Hugging Face Spaces): `spaces` must be imported before torch; its GPU decorator
21
+ # borrows a GPU for the duration of a call and is a no-op everywhere else.
22
+ try:
23
+ import spaces # noqa: E402
24
+ except ImportError: # local runs and tests without the package
25
+ spaces = None
26
+ ZERO_GPU = spaces is not None and str(os.environ.get("SPACES_ZERO_GPU", "")).lower() in ("1", "true")
27
+
28
  import gradio as gr # noqa: E402
29
  import numpy as np # noqa: E402
30
+ import time # noqa: E402
31
  from PIL import Image # noqa: E402
32
 
33
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # run from any cwd / python -I
 
38
  from cellquant.gemini_summary import summarize as gemini_summarize
39
  from cellquant.infer import Predictor
40
  from cellquant.labels import ALL_LABELS, LABEL_COLORS
41
+ from cellquant.model import load_checkpoint
42
  from cellquant.postprocess import PostConfig, analyze, render_overlay, result_to_json
43
  from cellquant.validation import COMPARISON_HEADERS, COMPARISON_NOTE, DIFF_HEADERS, ValidationSet, compare, write_bundle
44
 
45
  WEIGHTS = os.environ.get("CELLQUANT_WEIGHTS", os.path.join(os.path.dirname(os.path.abspath(__file__)), "weights", "cellquant.pt"))
46
+ # On ZeroGPU the model must sit on cuda from start-up (CUDA is emulated outside GPU calls); elsewhere CPU unless told otherwise.
47
+ DEVICE = os.environ.get("CELLQUANT_DEVICE", "cuda" if ZERO_GPU else "cpu")
48
+ TTA_OF = {"none (fastest)": 1, "4x flips": 4, "8x rotations+flips": 8}
49
 
50
  EX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples")
51
  VAL_DIR = os.environ.get("CELLQUANT_VALIDATION_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "validation"))
52
  VALIDATION = ValidationSet(VAL_DIR)
53
 
54
+ MODEL = None
55
+ if os.path.exists(WEIGHTS):
56
+ MODEL, _META = load_checkpoint(WEIGHTS)
57
+ MODEL.to(DEVICE).eval()
58
  _predictors: dict[int, Predictor] = {}
59
  _val_probs: dict[tuple[str, int], dict] = {} # (image key, tta) -> network probabilities, a few entries
60
 
61
 
62
  def get_predictor(tta: int) -> Predictor:
63
  if tta not in _predictors:
64
+ if MODEL is None:
65
  raise gr.Error(f"Model weights not found at {WEIGHTS}. Train with train.py or set CELLQUANT_WEIGHTS.")
66
+ _predictors[tta] = Predictor(MODEL, device=DEVICE, tta=tta)
67
  return _predictors[tta]
68
 
69
 
70
+ def gpu(duration):
71
+ """`@spaces.GPU(duration=...)` on ZeroGPU, identity elsewhere. ``duration`` may be a
72
+ callable of the decorated function's arguments (seconds of GPU time to reserve;
73
+ shorter reservations get a better place in the queue)."""
74
+ if spaces is None:
75
+ return lambda f: f
76
+ return spaces.GPU(duration=duration)
77
+
78
+
79
+ def _analyze_duration(files, tta_choice, *args, **kwargs) -> int:
80
+ n = max(1, len(files or []))
81
+ per_image = {1: 4, 4: 6, 8: 10}.get(TTA_OF.get(tta_choice, 1), 10)
82
+ return int(min(600, 15 + per_image * n))
83
+
84
+
85
+ def _device_name() -> str:
86
+ if DEVICE.startswith("cuda"):
87
+ return "GPU (ZeroGPU)" if ZERO_GPU else "GPU"
88
+ return "Apple GPU" if DEVICE == "mps" else "CPU"
89
+
90
+
91
  def _legend_html() -> str:
92
  items = "".join(
93
  f'<span style="display:inline-block;margin:2px 10px 2px 0"><span style="display:inline-block;width:14px;height:14px;'
 
97
  return f"<div>{items}</div>"
98
 
99
 
100
+ @gpu(duration=_analyze_duration)
101
  def analyze_files(files, tta_choice, antibody_thr, cellnos_thr, min_cell_area, nostril_rule, cellnos_rule,
102
  want_summary, progress=gr.Progress()):
103
  if not files:
104
  raise gr.Error("Upload at least one image.")
105
+ t_start = time.time()
106
+ tta = TTA_OF[tta_choice]
107
  cfg = PostConfig(antibody_thr=float(antibody_thr), cellnos_thr=float(cellnos_thr),
108
  min_cell_area=int(min_cell_area), nostril_rule=nostril_rule, cellnos_rule=cellnos_rule)
109
  pred = get_predictor(tta)
 
160
  zf.write(p, os.path.basename(p))
161
  for p, _ in gallery:
162
  zf.write(p, os.path.basename(p))
163
+ summary = (f"Analysed {len(rows)} image{'s' if len(rows) != 1 else ''} in {time.time() - t_start:.1f} s "
164
+ f"on the {_device_name()} ({tta_choice}).")
165
+ summary += "".join(f"\n\nWarning: {w}" for w in warnings_)
166
  if want_summary:
167
  if gemini_enabled():
168
  first = next(iter(overlays.values()))
 
173
  return rows, gallery, zip_path, summary
174
 
175
 
176
+ @gpu(duration=lambda key, tta_choice="none (fastest)", *a, **k: 15 + {1: 4, 4: 6, 8: 10}.get(TTA_OF.get(tta_choice, 1), 10))
177
  def validate_image(key, tta_choice="none (fastest)", progress=gr.Progress()):
178
  """Run the model on one bundled annotated image and compare it with the annotation.
179
 
 
181
  their captions), the per-label table, the per-object table and the download."""
182
  if not key or key not in VALIDATION.keys:
183
  raise gr.Error("Pick one of the annotated images.")
184
+ t_start = time.time()
185
+ tta = TTA_OF[tta_choice]
186
  img = VALIDATION.image(key)
187
  R = VALIDATION.raster(key)
188
  progress(0.1, desc=f"running the model on {key}")
 
205
  missed = gr.Image(value=os.path.join(tmpdir, f"{key}_missed_only.png"),
206
  label=f"3. Missed only: {n_missed_nuc} nuclei and {n_missed_pun} puncta annotated but not predicted "
207
  f"(red, numbered as in the list below)")
208
+ summary = cmp.summary + f"\n* **Run time:** {time.time() - t_start:.1f} s on the {_device_name()} ({tta_choice})."
209
+ return summary, manual, predicted, missed, cmp.rows, cmp.diffs, zip_path
210
 
211
 
212
  def pick_validation_image(tta_choice, evt: gr.SelectData):
 
241
  tta_default = os.environ.get("CELLQUANT_TTA_DEFAULT", "8x rotations+flips")
242
  if tta_default not in tta_choices:
243
  tta_default = tta_choices[-1]
244
+ with gr.Blocks(title="AI Cell Quantification", theme=theme, css=CSS, js=FORCE_LIGHT_JS) as demo:
245
  gr.Markdown(
246
+ f"# AI Cell Quantification\n"
247
+ f"CellQuant {__version__}. "
248
  "Counts nuclei (antibody positive or negative), nuclei containing a red punctum (\"Nostril\") and puncta "
249
  "inside or outside nuclei in RGB fluorescence images (blue = DAPI, green = antibody, red = puncta). "
250
  "Trained on CVAT outlines of 35-day spinal-cord sections stained for Iba1 or TMEM119."
 
309
  "Nucleus outlines come from a watershed over the network's distance and class maps. "
310
  "A nucleus is labelled Nostril when a punctum classified as in-nucleus lies inside it. "
311
  "Images larger than 1536 px are processed in overlapping tiles. Nothing leaves the Space except, "
312
+ "optionally, the counts and an overlay thumbnail sent to Gemini when the summary is requested. "
313
+ + ("Inference runs on a shared GPU (Hugging Face ZeroGPU): no account is needed, each visitor has a daily "
314
+ "allowance of GPU minutes and one image takes a few seconds." if ZERO_GPU else f"Inference runs on the {_device_name()}.")
315
  )
316
  return demo
317
 
318
 
319
  if __name__ == "__main__":
320
+ if MODEL is None:
321
  raise SystemExit(f"CellQuant weights not found at {WEIGHTS}: run train.py --out weights/cellquant.pt or set CELLQUANT_WEIGHTS")
322
  build_demo().launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
323
  allowed_paths=[EX_DIR], show_error=True)
cellquant/__init__.py CHANGED
@@ -3,4 +3,4 @@
3
  Trained on CVAT polygon annotations of 1024x1024 RGB images (blue = DAPI nuclei,
4
  green = antibody stain such as Iba1 or Tmem119, red = puncta).
5
  """
6
- __version__ = "1.2.1"
 
3
  Trained on CVAT polygon annotations of 1024x1024 RGB images (blue = DAPI nuclei,
4
  green = antibody stain such as Iba1 or Tmem119, red = puncta).
5
  """
6
+ __version__ = "1.2.2"
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
- --extra-index-url https://download.pytorch.org/whl/cpu
2
- torch>=2.2
3
- torchvision>=0.17
 
4
  timm>=0.9.7
5
  numpy>=1.26
6
  scipy>=1.11
 
1
+ # CUDA build of torch from PyPI (ZeroGPU needs torch >= 2.8); locally any build works.
2
+ torch>=2.8
3
+ torchvision>=0.23
4
+ spaces>=0.40
5
  timm>=0.9.7
6
  numpy>=1.26
7
  scipy>=1.11
scripts/deploy_hf.py CHANGED
@@ -29,6 +29,8 @@ def main(argv=None):
29
  ap.add_argument("--public", action="store_true")
30
  ap.add_argument("--no-wait", action="store_true")
31
  ap.add_argument("--timeout", type=int, default=1800)
 
 
32
  a = ap.parse_args(argv)
33
  api = HfApi()
34
  me = whoami()["name"]
@@ -46,6 +48,13 @@ def main(argv=None):
46
  api.upload_folder(folder_path=ROOT, repo_id=repo, repo_type="space", ignore_patterns=IGNORE,
47
  commit_message="Deploy CellQuant")
48
  print("uploaded")
 
 
 
 
 
 
 
49
  if a.no_wait:
50
  return
51
  t0 = time.time()
@@ -83,6 +92,19 @@ def main(argv=None):
83
  t = time.time()
84
  out = client.predict([handle_file(served)], "none (fastest)", 0.5, 0.5, 50, "head", "contains", False, api_name="/analyze_files")
85
  print(f"API call ok in {time.time()-t:.1f}s; counts row: {out[0]['data'][0] if isinstance(out[0], dict) else out[0]}")
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
 
88
  if __name__ == "__main__":
 
29
  ap.add_argument("--public", action="store_true")
30
  ap.add_argument("--no-wait", action="store_true")
31
  ap.add_argument("--timeout", type=int, default=1800)
32
+ ap.add_argument("--hardware", default="", help="Space hardware to request after the upload, e.g. zero-a10g (ZeroGPU) or cpu-basic")
33
+ ap.add_argument("--bench", action="store_true", help="after the smoke test, time a full validation image with no TTA and with 8x TTA")
34
  a = ap.parse_args(argv)
35
  api = HfApi()
36
  me = whoami()["name"]
 
48
  api.upload_folder(folder_path=ROOT, repo_id=repo, repo_type="space", ignore_patterns=IGNORE,
49
  commit_message="Deploy CellQuant")
50
  print("uploaded")
51
+ if a.hardware:
52
+ rt = api.get_space_runtime(repo)
53
+ if str(rt.hardware) != a.hardware or (rt.requested_hardware and str(rt.requested_hardware) != a.hardware):
54
+ api.request_space_hardware(repo, a.hardware)
55
+ print(f"requested hardware {a.hardware} (was {rt.hardware})")
56
+ else:
57
+ print(f"hardware already {a.hardware}")
58
  if a.no_wait:
59
  return
60
  t0 = time.time()
 
92
  t = time.time()
93
  out = client.predict([handle_file(served)], "none (fastest)", 0.5, 0.5, 50, "head", "contains", False, api_name="/analyze_files")
94
  print(f"API call ok in {time.time()-t:.1f}s; counts row: {out[0]['data'][0] if isinstance(out[0], dict) else out[0]}")
95
+ print(f" app reports: {str(out[3]).splitlines()[0] if out[3] else '(no summary line)'}")
96
+ if a.bench:
97
+ val_dir = os.path.join(ROOT, "validation")
98
+ pngs = sorted(f for f in os.listdir(val_dir) if f.lower().endswith(".png")) if os.path.isdir(val_dir) else []
99
+ if not pngs:
100
+ print("bench: no validation image to time")
101
+ return
102
+ full = os.path.join(val_dir, pngs[0])
103
+ for tta in ("none (fastest)", "8x rotations+flips"):
104
+ t = time.time()
105
+ out = client.predict([handle_file(full)], tta, 0.5, 0.5, 50, "head", "contains", False, api_name="/analyze_files")
106
+ row = out[0]["data"][0] if isinstance(out[0], dict) else out[0]
107
+ print(f"bench {os.path.basename(full)} {tta}: round trip {time.time()-t:.1f}s; {str(out[3]).splitlines()[0] if out[3] else ''}; nuclei {sum(row[1:5])}")
108
 
109
 
110
  if __name__ == "__main__":
tests/test_app.py CHANGED
@@ -20,7 +20,8 @@ def test_analyze_files_end_to_end(tmp_path):
20
  rows, gallery, zip_path, summary = app.analyze_files([str(p1), str(p2)], "none (fastest)", 0.5, 0.5, 50, "head", "contains", False)
21
  assert len(rows) == 2 and rows[0][0] == "a.png" and len(rows[0]) == 7
22
  assert len(gallery) == 2 and os.path.exists(gallery[0][0])
23
- assert os.path.exists(zip_path) and summary == ""
 
24
  import zipfile
25
  with zipfile.ZipFile(zip_path) as zf:
26
  names = zf.namelist()
 
20
  rows, gallery, zip_path, summary = app.analyze_files([str(p1), str(p2)], "none (fastest)", 0.5, 0.5, 50, "head", "contains", False)
21
  assert len(rows) == 2 and rows[0][0] == "a.png" and len(rows[0]) == 7
22
  assert len(gallery) == 2 and os.path.exists(gallery[0][0])
23
+ assert os.path.exists(zip_path) and summary.startswith("Analysed 2 images in ") and "on the CPU" in summary
24
+ assert app._analyze_duration(["a", "b", "c"], "8x rotations+flips") == 45 and app._analyze_duration([], "none (fastest)") == 19
25
  import zipfile
26
  with zipfile.ZipFile(zip_path) as zf:
27
  names = zf.namelist()