stevafernandes commited on
Commit
5d31c67
·
verified ·
1 Parent(s): 078e567

Deploy CellQuant

Browse files
.gitattributes CHANGED
@@ -1,35 +1,2 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.pt filter=lfs diff=lfs merge=lfs -text
2
+ examples/F100_crop_512.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,13 +1,120 @@
1
  ---
2
- title: Cellquant
3
- emoji: 🌍
4
- colorFrom: green
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.30.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
18
+ marker that the lab calls **"Nostril"**:
19
+
20
+ | Label | Meaning |
21
+ |---|---|
22
+ | Cell, Antibody + | nucleus with green antibody signal, no punctum |
23
+ | Cell, Antibody − | nucleus without antibody signal, no punctum |
24
+ | Cell, Nostril, Antibody + | nucleus containing a red punctum, antibody positive |
25
+ | Cell, Nostril, Antibody − | nucleus containing a red punctum, antibody negative |
26
+ | Nostril in Nucleus | red punctum inside a nucleus |
27
+ | Nostril External of Nucleus | red punctum outside every nucleus |
28
+
29
+ Every nucleus receives exactly one of the four *Cell* labels.
30
+
31
+ ## How it works
32
+
33
+ * **Model** – a UNet with an ImageNet-pretrained ResNet34 encoder (`cellquant/model.py`) that
34
+ predicts, per pixel: nucleus interior / border / background, P(antibody +), P(nucleus has
35
+ punctum) and punctum in-nucleus / external / background. A full-resolution refinement stage
36
+ keeps 5-pixel puncta.
37
+ * **Instances** – a marker-controlled watershed on the interior/border maps separates touching
38
+ nuclei; puncta are connected components (`cellquant/postprocess.py`).
39
+ * **Labels** – antibody status is the mean probability inside each nucleus; a nucleus is
40
+ "Nostril" when a punctum classified "in nucleus" lies inside it (the annotation protocol;
41
+ the network's own nucleus-level head is available as an alternative in *Advanced*);
42
+ punctum type comes from the network (switchable to a purely geometric rule).
43
+ * **Training data** – nine 1024×1024 RGB images with 1,569 nucleus outlines and 1,057
44
+ punctum outlines, parsed directly from the CVAT zip exports (`cellquant/annotations.py`).
45
+
46
+ ## Validation (see `reports/SUMMARY.md` and `reports/cv/cv_report.md`)
47
+
48
+ Leave-one-image-out cross-validation: nine models, each tested on an image it never saw, and
49
+ each image tested 23 times (repeat runs, TTA settings, 8 rotations/flips, 6 photometric
50
+ perturbations, rule variants). Micro-averaged over the nine held-out images:
51
+
52
+ | Measure | Precision | Recall | F1 |
53
+ |---|---:|---:|---:|
54
+ | Nucleus detection, IoU ≥ 0.5 | 77.5% | 78.4% | 77.9% |
55
+ | Nucleus detection, centroid inside the annotated nucleus | 87.0% | 88.0% | 87.5% |
56
+ | Punctum detection (centroid within 6 px) | 77.3% | 82.8% | 79.9% |
57
+
58
+ Among matched nuclei the antibody +/− call is right for 94% and the Nostril attribute for
59
+ 96%; puncta are classified in-nucleus vs external correctly 93% of the time. Scores vary by
60
+ at most ±2 F1 points across the 14 rotated/perturbed runs of each image, and repeated runs are
61
+ bit-identical. The weak spots are antibody-negative nuclei (F1 32%; only 102 examples, 62 of
62
+ them in one image), the five-example "Cell, Nostril, Antibody −" class, and clusters of touching
63
+ nuclei that are sometimes merged. The shipped `weights/cellquant.pt` was trained on all nine
64
+ images with the same recipe.
65
+
66
+ ## Using the Space
67
+
68
+ 1. Upload one or more images (TIF/PNG/JPG, blue = nuclei, green = antibody, red = puncta).
69
+ Use the 8-bit RGB export of the microscope software (what the model was trained on);
70
+ 16-bit inputs are auto-scaled so that their 99.95th percentile maps to 255. Multi-channel
71
+ TIFs with one channel per page (ImageJ composites, OME-TIFF) are supported; greyscale
72
+ inputs are flagged. Images larger than 1536 px are processed in overlapping tiles.
73
+ 2. Optional: enable test-time augmentation (slower, slightly more stable).
74
+ 3. Click **Analyse**. You get a counts table, colour overlays and a zip with
75
+ `counts.csv`, `objects.csv` (every nucleus and punctum with centroid, area, probabilities)
76
+ and `results.json`.
77
+ 4. Optional Gemini summary: add a Space secret `GEMINI_API_KEY` (and optionally
78
+ `GEMINI_MODEL`, default `gemini-4-argon`). Gemini only rewrites the counts in prose and
79
+ comments on image quality; it never changes a number.
80
+
81
+ ## Deploying to Hugging Face
82
+
83
+ The checkpoint is ~98 MB, so it must go through Git LFS (`.gitattributes` already tracks
84
+ `*.pt`). The simplest route uploads the folder with the CLI, which handles LFS automatically:
85
+
86
+ ```bash
87
+ pip install -U huggingface_hub
88
+ huggingface-cli login
89
+ huggingface-cli upload <your-user>/cellquant . . --repo-type space --exclude "runs/*" --exclude "tests/*" --exclude ".pytest_cache/*"
90
+ ```
91
+
92
+ Create the Space first (SDK: Gradio, hardware: free CPU) or add `--create`. Add the optional
93
+ `GEMINI_API_KEY` secret under *Settings → Variables and secrets*.
94
+
95
+ ## Running locally
96
+
97
+ ```bash
98
+ pip install -r requirements.txt
99
+ python app.py # http://localhost:7860
100
+ python -m pytest -q tests # unit + integration tests
101
+ ```
102
+
103
+ Retrain when more annotated images are available (put the TIFs and the CVAT zips in one folder):
104
+
105
+ ```bash
106
+ python train.py --data-dir /path/to/folder --out weights/cellquant.pt --iters 3000
107
+ python evaluate.py cv --data-dir /path/to/folder --runs-dir runs/cv --iters 3000
108
+ ```
109
+
110
+ Training uses Apple-silicon MPS, CUDA or CPU automatically.
111
+
112
+ ## Limitations
113
+
114
+ * The annotator skipped faint / out-of-focus nuclei; the model learned that criterion only
115
+ approximately, so very dim nuclei may be counted or not. Raise *Minimum nucleus area* or the
116
+ thresholds in *Advanced* to tune for your images.
117
+ * "Cell, Nostril, Antibody −" had only five training examples; its predictions are the
118
+ product of the (well-trained) antibody and punctum heads rather than a learned class.
119
+ * Images must have the same channel convention (blue nuclei, green antibody, red puncta) and a
120
+ similar magnification (nuclei ≈ 20–35 px across). The model tolerates ±25 % scale.
app.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """CellQuant Gradio app (Hugging Face Space entry point).
3
+
4
+ Upload one or more fluorescence images (TIF/PNG/JPG; blue nuclei, green antibody,
5
+ red puncta) and get per-image counts of the six annotation categories, colour
6
+ overlays, and downloadable CSV/JSON of every detected object.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import io
12
+ import json
13
+ import os
14
+ import sys
15
+ import tempfile
16
+ 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
25
+
26
+ from cellquant import __version__ # noqa: E402
27
+ from cellquant.annotations import load_image
28
+ 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, render_overlay, result_to_json
33
+
34
+ WEIGHTS = os.environ.get("CELLQUANT_WEIGHTS", os.path.join(os.path.dirname(os.path.abspath(__file__)), "weights", "cellquant.pt"))
35
+ DEVICE = os.environ.get("CELLQUANT_DEVICE", "cpu")
36
+
37
+ EX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "examples")
38
+
39
+ _predictors: dict[int, Predictor] = {}
40
+
41
+
42
+ def get_predictor(tta: int) -> Predictor:
43
+ if tta not in _predictors:
44
+ if not os.path.exists(WEIGHTS):
45
+ raise gr.Error(f"Model weights not found at {WEIGHTS}. Train with train.py or set CELLQUANT_WEIGHTS.")
46
+ _predictors[tta] = Predictor(WEIGHTS, device=DEVICE, tta=tta)
47
+ return _predictors[tta]
48
+
49
+
50
+ def _legend_html() -> str:
51
+ items = "".join(
52
+ f'<span style="display:inline-block;margin:2px 10px 2px 0"><span style="display:inline-block;width:14px;height:14px;'
53
+ f'background:rgb{c};border:1px solid #888;vertical-align:middle;margin-right:4px"></span>{lab}</span>'
54
+ for lab, c in LABEL_COLORS.items()
55
+ )
56
+ return f"<div>{items}</div>"
57
+
58
+
59
+ def analyze_files(files, tta_choice, antibody_thr, cellnos_thr, min_cell_area, nostril_rule, cellnos_rule,
60
+ want_summary, progress=gr.Progress()):
61
+ if not files:
62
+ raise gr.Error("Upload at least one image.")
63
+ tta = {"none (fastest)": 1, "4x flips": 4, "8x rotations+flips": 8}[tta_choice]
64
+ cfg = PostConfig(antibody_thr=float(antibody_thr), cellnos_thr=float(cellnos_thr),
65
+ min_cell_area=int(min_cell_area), nostril_rule=nostril_rule, cellnos_rule=cellnos_rule)
66
+ pred = get_predictor(tta)
67
+ rows, gallery, per_image_json, csv_objects = [], [], {}, []
68
+ tmpdir = tempfile.mkdtemp(prefix="cellquant_")
69
+ overlays = {}
70
+ warnings_: list[str] = []
71
+ seen: dict[str, int] = {}
72
+ for i, f in enumerate(files):
73
+ path = f if isinstance(f, str) else f.name
74
+ name = os.path.basename(path)
75
+ k = seen.get(name, 0)
76
+ seen[name] = k + 1
77
+ if k: # two uploads with the same file name: keep both
78
+ stem, ext = os.path.splitext(name)
79
+ name = f"{stem}_{k + 1}{ext}"
80
+ progress((i, len(files)), desc=f"analysing {name}")
81
+ try:
82
+ img = load_image(path)
83
+ except Exception as e: # noqa: BLE001
84
+ raise gr.Error(f"{name}: cannot read image ({e})")
85
+ if np.array_equal(img[..., 0], img[..., 1]) and np.array_equal(img[..., 1], img[..., 2]):
86
+ warnings_.append(f"{name}: all three channels are identical (greyscale input); the model needs blue nuclei, green antibody and red puncta.")
87
+ res, _ = pred.predict(img, cfg)
88
+ ov = render_overlay(img, res, thickness=1)
89
+ overlays[name] = ov
90
+ out_png = os.path.join(tmpdir, os.path.splitext(name)[0] + "_overlay.png")
91
+ Image.fromarray(ov).save(out_png)
92
+ gallery.append((out_png, f"{name}: {res.counts['Cell, Antibody +'] + res.counts['Cell, Antibody -'] + res.counts['Cell, Nostril, Antibody +'] + res.counts['Cell, Nostril, Antibody -']} nuclei"))
93
+ rows.append([name] + [res.counts[lab] for lab in ALL_LABELS])
94
+ per_image_json[name] = {"image_size_hw": list(img.shape[:2]), **result_to_json(res)}
95
+ for c in res.cells:
96
+ 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"]])
97
+ for n_ in res.nostrils:
98
+ 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"]])
99
+ # downloads
100
+ counts_csv = os.path.join(tmpdir, "counts.csv")
101
+ with open(counts_csv, "w", newline="") as fh:
102
+ w = csv.writer(fh)
103
+ w.writerow(["image"] + list(ALL_LABELS))
104
+ w.writerows(rows)
105
+ objects_csv = os.path.join(tmpdir, "objects.csv")
106
+ with open(objects_csv, "w", newline="") as fh:
107
+ w = csv.writer(fh)
108
+ 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"])
109
+ w.writerows(csv_objects)
110
+ json_path = os.path.join(tmpdir, "results.json")
111
+ with open(json_path, "w") as fh:
112
+ json.dump({"cellquant_version": __version__, "weights": os.path.basename(WEIGHTS), "tta": tta,
113
+ "postprocess": cfg.__dict__, "images": per_image_json}, fh, indent=1)
114
+ zip_path = os.path.join(tmpdir, "cellquant_results.zip")
115
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
116
+ for p in (counts_csv, objects_csv, json_path):
117
+ zf.write(p, os.path.basename(p))
118
+ for p, _ in gallery:
119
+ zf.write(p, os.path.basename(p))
120
+ summary = "\n\n".join(f"Warning: {w}" for w in warnings_)
121
+ if want_summary:
122
+ if gemini_enabled():
123
+ first = next(iter(overlays.values()))
124
+ summary += ("\n\n" if summary else "") + gemini_summarize(
125
+ [{"image": r[0], "counts": dict(zip(ALL_LABELS, r[1:]))} for r in rows], overlay=Image.fromarray(first))
126
+ else:
127
+ summary += ("\n\n" if summary else "") + "Gemini summary is off: add a GEMINI_API_KEY secret to this Space to enable it."
128
+ return rows, gallery, zip_path, summary
129
+
130
+
131
+ FORCE_LIGHT_JS = """
132
+ function refresh() {
133
+ const url = new URL(window.location);
134
+ if (url.searchParams.get('__theme') !== 'light') {
135
+ url.searchParams.set('__theme', 'light');
136
+ window.location.href = url.href;
137
+ }
138
+ }
139
+ """
140
+
141
+ CSS = """
142
+ body, .gradio-container { background: #fff !important; }
143
+ .gradio-container .prose, .gradio-container .prose * { color: #000; }
144
+ .gradio-container .prose a { color: #000; text-decoration: underline; }
145
+ .gradio-container table, .gradio-container td, .gradio-container th { color: #000; }
146
+ footer { display: none !important; }
147
+ """
148
+
149
+
150
+ def build_demo() -> gr.Blocks:
151
+ theme = gr.themes.Monochrome(primary_hue="neutral", neutral_hue="neutral",
152
+ font=["Helvetica Neue", "Helvetica", "Arial", "sans-serif"],
153
+ font_mono=["Menlo", "Consolas", "monospace"])
154
+ with gr.Blocks(title="CellQuant", theme=theme, css=CSS, js=FORCE_LIGHT_JS) as demo:
155
+ gr.Markdown(
156
+ f"# CellQuant {__version__}\n"
157
+ "Counts nuclei (antibody positive or negative), nuclei containing a red punctum (\"Nostril\") and puncta "
158
+ "inside or outside nuclei in RGB fluorescence images (blue = DAPI, green = antibody, red = puncta). "
159
+ "Trained on CVAT outlines of 35-day spinal-cord sections stained for Iba1 or TMEM119."
160
+ )
161
+ gr.HTML(_legend_html())
162
+ with gr.Row():
163
+ with gr.Column(scale=1):
164
+ files = gr.File(label="Images (TIF / PNG / JPG)", file_count="multiple", file_types=["image", ".tif", ".tiff"])
165
+ tta = gr.Radio(["none (fastest)", "4x flips", "8x rotations+flips"], value="none (fastest)", label="Test-time augmentation")
166
+ want_summary = gr.Checkbox(value=False, label="Plain-language summary (Gemini)", visible=True)
167
+ with gr.Accordion("Advanced", open=False):
168
+ antibody_thr = gr.Slider(0.1, 0.9, value=0.5, step=0.05, label="Antibody + threshold")
169
+ cellnos_thr = gr.Slider(0.1, 0.9, value=0.5, step=0.05, label="Nucleus 'Nostril' threshold")
170
+ min_cell_area = gr.Slider(10, 300, value=50, step=5, label="Minimum nucleus area (px)")
171
+ nostril_rule = gr.Radio(["head", "geometric"], value="head", label="Punctum type from: network head / position relative to nuclei")
172
+ 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")
173
+ run = gr.Button("Analyse", variant="primary")
174
+ examples = sorted(os.path.join(EX_DIR, f) for f in os.listdir(EX_DIR)
175
+ if f.lower().endswith((".png", ".jpg", ".jpeg", ".tif", ".tiff"))) if os.path.isdir(EX_DIR) else []
176
+ if examples:
177
+ gr.Examples(examples=[[[e]] for e in examples], inputs=[files], label="Example (512 px crop of a training image)")
178
+ with gr.Column(scale=2):
179
+ table = gr.Dataframe(headers=["image"] + list(ALL_LABELS), label="Counts", interactive=False, wrap=True)
180
+ gallery = gr.Gallery(label="Overlays", columns=2, height="auto")
181
+ zip_out = gr.File(label="Download results (CSV + JSON + overlays)")
182
+ summary = gr.Markdown()
183
+ run.click(analyze_files, [files, tta, antibody_thr, cellnos_thr, min_cell_area, nostril_rule, cellnos_rule, want_summary],
184
+ [table, gallery, zip_out, summary])
185
+ gr.Markdown(
186
+ "Nucleus outlines come from a watershed over the network's interior and border maps. "
187
+ "A nucleus is labelled Nostril when a punctum classified as in-nucleus lies inside it. "
188
+ "Images larger than 1536 px are processed in overlapping tiles. Nothing leaves the Space except, "
189
+ "optionally, the counts and an overlay thumbnail sent to Gemini when the summary is requested."
190
+ )
191
+ return demo
192
+
193
+
194
+ if __name__ == "__main__":
195
+ if not os.path.exists(WEIGHTS):
196
+ raise SystemExit(f"CellQuant weights not found at {WEIGHTS}: run train.py --out weights/cellquant.pt or set CELLQUANT_WEIGHTS")
197
+ build_demo().launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
198
+ allowed_paths=[EX_DIR], show_error=True)
cellquant/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """CellQuant: nucleus / antibody / puncta ("Nostril") quantification for fluorescence microscopy.
2
+
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.0.0"
cellquant/annotations.py ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read CVAT exports and rasterise them into instance label maps.
2
+
3
+ Supported inputs
4
+ ----------------
5
+ * ``annotations.xml`` in the "CVAT for images 1.1" format (``<image>`` with
6
+ ``<polygon>``, ``<polyline>``, ``<ellipse>`` children).
7
+ * CVAT task-backup zips containing ``annotations.json`` + ``task.json``.
8
+ * Either may be given as a path to a ``.zip`` or an extracted file.
9
+
10
+ Cells were traced as *closed* polylines (first and last vertex coincide within a
11
+ pixel in every file we received), so polylines are treated as polygons.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import json
17
+ import math
18
+ import os
19
+ import re
20
+ import zipfile
21
+ from dataclasses import dataclass, field
22
+ import xml.etree.ElementTree as ET
23
+
24
+ import numpy as np
25
+ from skimage.draw import polygon as _sk_polygon
26
+
27
+ from .labels import (
28
+ ALL_LABELS,
29
+ cell_attributes,
30
+ is_cell_label,
31
+ is_nostril_label,
32
+ nostril_in_nucleus,
33
+ )
34
+
35
+
36
+ @dataclass
37
+ class Shape:
38
+ label: str
39
+ kind: str # "polygon" or "ellipse"
40
+ points: np.ndarray # (N, 2) float32 x,y vertices (ellipses are sampled)
41
+ source: str = "manual"
42
+
43
+ @property
44
+ def centroid(self) -> np.ndarray:
45
+ return self.points.mean(axis=0)
46
+
47
+ @property
48
+ def area(self) -> float:
49
+ x, y = self.points[:, 0], self.points[:, 1]
50
+ return 0.5 * abs(float(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))))
51
+
52
+
53
+ @dataclass
54
+ class ImageAnnotation:
55
+ image_name: str
56
+ width: int
57
+ height: int
58
+ shapes: list[Shape] = field(default_factory=list)
59
+
60
+ @property
61
+ def key(self) -> str:
62
+ return image_key(self.image_name)
63
+
64
+ @property
65
+ def norm_name(self) -> str:
66
+ return normalized_name(self.image_name)
67
+
68
+ def count(self, label: str) -> int:
69
+ return sum(1 for s in self.shapes if s.label == label)
70
+
71
+ def counts(self) -> dict[str, int]:
72
+ return {lab: self.count(lab) for lab in ALL_LABELS}
73
+
74
+
75
+ # ----------------------------------------------------------------------------
76
+ # helpers
77
+
78
+
79
+ def image_key(name: str) -> str:
80
+ """First whitespace-delimited token of a file name, e.g. ``C110`` for
81
+ ``C110 F 35D SC Iba W001 - Focused EDF Document_RGB copy.tif`` (the animal id)."""
82
+ base = os.path.basename(name)
83
+ tok = re.split(r"\s+", base.strip())[0]
84
+ return tok
85
+
86
+
87
+ def normalized_name(name: str) -> str:
88
+ """Canonical form of an image file name used to pair annotations with images:
89
+ basename without extension, lower-cased, whitespace collapsed, and trailing
90
+ "copy" / "(n)" suffixes removed (``F110 ... RGB copy.tif`` == ``F110 ... RGB.tif``)."""
91
+ base = os.path.splitext(os.path.basename(name))[0].strip().lower()
92
+ base = re.sub(r"\s+", " ", base)
93
+ while True:
94
+ new = re.sub(r"\s*\(\d+\)$", "", base)
95
+ new = re.sub(r"(\s|-|_)copy$", "", new)
96
+ if new == base:
97
+ return base
98
+ base = new.strip()
99
+
100
+
101
+ def short_keys(basenames: list[str]) -> list[str]:
102
+ """Short unique keys for image file names: the animal id (first token, original
103
+ case), extended with the well token (``W001``) or an index only when two
104
+ images share the animal id."""
105
+ firsts = [image_key(b) for b in basenames]
106
+ keys = []
107
+ for b, f in zip(basenames, firsts):
108
+ if firsts.count(f) == 1:
109
+ keys.append(f)
110
+ continue
111
+ m = re.search(r"\b(w\d+)\b", normalized_name(b))
112
+ keys.append(f"{f}_{m.group(1).upper()}" if m else f)
113
+ # still colliding (same animal and well): append an index
114
+ seen: dict[str, int] = {}
115
+ out = []
116
+ for k in keys:
117
+ if keys.count(k) > 1:
118
+ seen[k] = seen.get(k, 0) + 1
119
+ out.append(f"{k}_{seen[k]}")
120
+ else:
121
+ out.append(k)
122
+ return out
123
+
124
+
125
+ def ellipse_points(cx: float, cy: float, rx: float, ry: float, rotation_deg: float = 0.0,
126
+ n: int = 48) -> np.ndarray:
127
+ t = np.linspace(0.0, 2.0 * math.pi, n, endpoint=False)
128
+ x = rx * np.cos(t)
129
+ y = ry * np.sin(t)
130
+ if rotation_deg:
131
+ a = math.radians(rotation_deg)
132
+ x, y = x * math.cos(a) - y * math.sin(a), x * math.sin(a) + y * math.cos(a)
133
+ return np.stack([cx + x, cy + y], axis=1).astype(np.float32)
134
+
135
+
136
+ def _parse_points(s: str) -> np.ndarray:
137
+ pts = [[float(v) for v in p.split(",")] for p in s.strip().split(";") if p.strip()]
138
+ return np.asarray(pts, dtype=np.float32).reshape(-1, 2)
139
+
140
+
141
+ # ----------------------------------------------------------------------------
142
+ # readers
143
+
144
+
145
+ def parse_cvat_xml(xml_bytes: bytes) -> list[ImageAnnotation]:
146
+ root = ET.fromstring(xml_bytes)
147
+ out: list[ImageAnnotation] = []
148
+ for im in root.iter("image"):
149
+ ann = ImageAnnotation(image_name=im.get("name"), width=int(im.get("width")),
150
+ height=int(im.get("height")))
151
+ for sh in im:
152
+ label = sh.get("label")
153
+ if label is None:
154
+ continue
155
+ src = sh.get("source", "manual")
156
+ if sh.tag in ("polygon", "polyline"):
157
+ pts = _parse_points(sh.get("points", ""))
158
+ if len(pts) < 3:
159
+ continue
160
+ ann.shapes.append(Shape(label, "polygon", pts, src))
161
+ elif sh.tag == "ellipse":
162
+ cx, cy, rx, ry = (float(sh.get(k)) for k in ("cx", "cy", "rx", "ry"))
163
+ rot = float(sh.get("rotation", 0.0) or 0.0)
164
+ ann.shapes.append(Shape(label, "ellipse", ellipse_points(cx, cy, rx, ry, rot), src))
165
+ elif sh.tag == "box":
166
+ xtl, ytl, xbr, ybr = (float(sh.get(k)) for k in ("xtl", "ytl", "xbr", "ybr"))
167
+ pts = np.array([[xtl, ytl], [xbr, ytl], [xbr, ybr], [xtl, ybr]], dtype=np.float32)
168
+ ann.shapes.append(Shape(label, "polygon", pts, src))
169
+ # points / masks / tags are not used by this project
170
+ out.append(ann)
171
+ return out
172
+
173
+
174
+ def parse_cvat_backup(zf: zipfile.ZipFile) -> list[ImageAnnotation]:
175
+ """CVAT task backup: annotations.json (list of jobs with shapes) + manifest."""
176
+ ann_json = json.loads(zf.read("annotations.json"))
177
+ frames: list[tuple[str, int, int]] = []
178
+ for name in zf.namelist():
179
+ if name.endswith("manifest.jsonl"):
180
+ for line in zf.read(name).decode("utf-8").splitlines():
181
+ line = line.strip()
182
+ if not line:
183
+ continue
184
+ rec = json.loads(line)
185
+ if "name" in rec and "width" in rec:
186
+ frames.append((rec["name"] + rec.get("extension", ""), int(rec["width"]), int(rec["height"])))
187
+ out: list[ImageAnnotation] = []
188
+ jobs = ann_json if isinstance(ann_json, list) else [ann_json]
189
+ by_frame: dict[int, ImageAnnotation] = {}
190
+ for job in jobs:
191
+ for sh in job.get("shapes", []):
192
+ fr = int(sh.get("frame", 0))
193
+ if fr not in by_frame:
194
+ name, w, h = frames[fr] if fr < len(frames) else (f"frame_{fr}", 0, 0)
195
+ by_frame[fr] = ImageAnnotation(name, w, h)
196
+ label = sh.get("label")
197
+ pts = np.asarray(sh.get("points", []), dtype=np.float32).reshape(-1, 2)
198
+ kind = sh.get("type")
199
+ if kind in ("polygon", "polyline") and len(pts) >= 3:
200
+ by_frame[fr].shapes.append(Shape(label, "polygon", pts, sh.get("source", "manual")))
201
+ elif kind == "ellipse" and len(pts) >= 2:
202
+ # CVAT stores ellipse as [cx, cy, right_x, top_y]
203
+ cx, cy, rxp, typ = pts[0, 0], pts[0, 1], pts[1, 0], pts[1, 1]
204
+ rx, ry = abs(rxp - cx), abs(cy - typ)
205
+ by_frame[fr].shapes.append(Shape(label, "ellipse", ellipse_points(cx, cy, rx, ry, float(sh.get("rotation", 0.0) or 0.0)), sh.get("source", "manual")))
206
+ elif kind == "rectangle" and len(pts) >= 2:
207
+ (x0, y0), (x1, y1) = pts[0], pts[1]
208
+ by_frame[fr].shapes.append(Shape(label, "polygon", np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], np.float32), sh.get("source", "manual")))
209
+ for fr in sorted(by_frame):
210
+ out.append(by_frame[fr])
211
+ return out
212
+
213
+
214
+ def load_annotation_file(path: str) -> list[ImageAnnotation]:
215
+ """Load a CVAT export given as .zip (xml or backup), .xml or .json."""
216
+ if path.lower().endswith(".zip"):
217
+ with zipfile.ZipFile(path) as zf:
218
+ names = zf.namelist()
219
+ if "annotations.xml" in names:
220
+ return parse_cvat_xml(zf.read("annotations.xml"))
221
+ xmls = [n for n in names if n.lower().endswith(".xml")]
222
+ if xmls:
223
+ return parse_cvat_xml(zf.read(xmls[0]))
224
+ if "annotations.json" in names:
225
+ return parse_cvat_backup(zf)
226
+ raise ValueError(f"{path}: no annotations.xml / annotations.json inside the zip")
227
+ if path.lower().endswith(".xml"):
228
+ with open(path, "rb") as f:
229
+ return parse_cvat_xml(f.read())
230
+ raise ValueError(f"unsupported annotation file: {path}")
231
+
232
+
233
+ # ----------------------------------------------------------------------------
234
+ # rasterisation
235
+
236
+
237
+ def rasterize_shape(shape: Shape, height: int, width: int) -> tuple[np.ndarray, np.ndarray]:
238
+ """Return (rows, cols) of the pixels covered by a shape, clipped to the image.
239
+
240
+ Shapes too thin to enclose any pixel centre still mark their rounded
241
+ centroid, so that tiny puncta (area < 3 px^2) are never lost.
242
+ """
243
+ pts = shape.points
244
+ rr, cc = _sk_polygon(pts[:, 1], pts[:, 0], shape=(height, width))
245
+ if rr.size == 0:
246
+ cx, cy = shape.centroid
247
+ r, c = int(round(cy)), int(round(cx))
248
+ if 0 <= r < height and 0 <= c < width:
249
+ rr, cc = np.array([r]), np.array([c])
250
+ return rr, cc
251
+
252
+
253
+ @dataclass
254
+ class RasterAnnotation:
255
+ """Instance maps plus per-instance attribute tables (index 0 = background)."""
256
+ cell_inst: np.ndarray # int32 (H, W), 0 = background
257
+ cell_labels: list[str] # len = n_cells + 1, [0] = ""
258
+ nostril_inst: np.ndarray # int32 (H, W)
259
+ nostril_labels: list[str] # len = n_nostrils + 1
260
+
261
+ @property
262
+ def n_cells(self) -> int:
263
+ return len(self.cell_labels) - 1
264
+
265
+ @property
266
+ def n_nostrils(self) -> int:
267
+ return len(self.nostril_labels) - 1
268
+
269
+ def cell_antibody(self) -> np.ndarray:
270
+ """bool per instance id (index 0 unused)."""
271
+ return np.array([False] + [cell_attributes(l)[0] for l in self.cell_labels[1:]])
272
+
273
+ def cell_has_nostril(self) -> np.ndarray:
274
+ return np.array([False] + [cell_attributes(l)[1] for l in self.cell_labels[1:]])
275
+
276
+ def nostril_inside(self) -> np.ndarray:
277
+ return np.array([False] + [nostril_in_nucleus(l) for l in self.nostril_labels[1:]])
278
+
279
+
280
+ def rasterize(ann: ImageAnnotation, height: int | None = None, width: int | None = None) -> RasterAnnotation:
281
+ H = height or ann.height
282
+ W = width or ann.width
283
+ cells = [s for s in ann.shapes if is_cell_label(s.label)]
284
+ nos = [s for s in ann.shapes if is_nostril_label(s.label)]
285
+ # Paint large shapes first so that any (rare) overlap keeps the smaller one.
286
+ cells.sort(key=lambda s: -s.area)
287
+ nos.sort(key=lambda s: -s.area)
288
+ cell_inst = np.zeros((H, W), dtype=np.int32)
289
+ cell_labels = [""]
290
+ for s in cells:
291
+ rr, cc = rasterize_shape(s, H, W)
292
+ if rr.size == 0:
293
+ continue
294
+ cell_labels.append(s.label)
295
+ cell_inst[rr, cc] = len(cell_labels) - 1
296
+ nos_inst = np.zeros((H, W), dtype=np.int32)
297
+ nos_labels = [""]
298
+ for s in nos:
299
+ rr, cc = rasterize_shape(s, H, W)
300
+ if rr.size == 0:
301
+ continue
302
+ nos_labels.append(s.label)
303
+ nos_inst[rr, cc] = len(nos_labels) - 1
304
+ # Drop instances that were completely over-painted (keeps ids contiguous).
305
+ cell_inst, cell_labels = _compact(cell_inst, cell_labels)
306
+ nos_inst, nos_labels = _compact(nos_inst, nos_labels)
307
+ return RasterAnnotation(cell_inst, cell_labels, nos_inst, nos_labels)
308
+
309
+
310
+ def _compact(inst: np.ndarray, labels: list[str]) -> tuple[np.ndarray, list[str]]:
311
+ present = np.unique(inst)
312
+ present = present[present > 0]
313
+ if len(present) == len(labels) - 1:
314
+ return inst, labels
315
+ remap = np.zeros(len(labels), dtype=np.int32)
316
+ new_labels = [""]
317
+ for old in present:
318
+ new_labels.append(labels[old])
319
+ remap[old] = len(new_labels) - 1
320
+ return remap[inst], new_labels
321
+
322
+
323
+ # ----------------------------------------------------------------------------
324
+ # dataset discovery
325
+
326
+
327
+ IMAGE_EXTS = (".tif", ".tiff", ".png", ".jpg", ".jpeg")
328
+ ANN_EXTS = (".zip", ".xml")
329
+
330
+
331
+ @dataclass
332
+ class Sample:
333
+ key: str
334
+ image_path: str
335
+ annotation: ImageAnnotation
336
+ annotation_path: str
337
+
338
+
339
+ class _quiet_tifffile:
340
+ """Silence tifffile's log line about the inconsistent OME block in the Keyence exports."""
341
+
342
+ def __enter__(self):
343
+ import logging
344
+ self.lg = logging.getLogger("tifffile")
345
+ self.lvl = self.lg.level
346
+ self.lg.setLevel(logging.ERROR)
347
+ return self
348
+
349
+ def __exit__(self, *exc):
350
+ self.lg.setLevel(self.lvl)
351
+ return False
352
+
353
+
354
+ def image_size(path: str) -> tuple[int, int]:
355
+ """(height, width) read from the file header only."""
356
+ if path.lower().endswith((".tif", ".tiff")):
357
+ import tifffile
358
+ with _quiet_tifffile(), tifffile.TiffFile(path) as tf:
359
+ try:
360
+ shp = tf.series[0].shape
361
+ except Exception: # noqa: BLE001
362
+ shp = tf.pages[0].shape
363
+ if len(shp) == 3 and shp[0] in (3, 4) and shp[-1] > 4:
364
+ return int(shp[1]), int(shp[2])
365
+ return int(shp[0]), int(shp[1])
366
+ from PIL import Image
367
+ with Image.open(path) as im:
368
+ w, h = im.size
369
+ return h, w
370
+
371
+
372
+ def discover(data_dir: str, verbose: bool = True, check_sizes: bool = True) -> tuple[list[Sample], list[str]]:
373
+ """Pair every annotation export in ``data_dir`` with its image.
374
+
375
+ Returns (samples, unannotated_image_paths). Pairing uses the image name
376
+ recorded *inside* the annotation (not the zip name): first an exact match on
377
+ the normalised file name (extension, case, whitespace and "copy"/"(n)"
378
+ suffixes ignored), else the animal id (first token) when it is unambiguous.
379
+ Byte-identical duplicate copies of an image collapse to one entry; two
380
+ *different* images or two different annotation files that map to the same
381
+ name are reported and refused rather than silently dropped. Sample keys are
382
+ the animal id, extended with the well token when one animal has several
383
+ sections (``F110_W002``).
384
+ """
385
+ files = sorted(os.listdir(data_dir))
386
+ images: dict[str, list[str]] = {}
387
+ for f in files:
388
+ if f.lower().endswith(IMAGE_EXTS):
389
+ images.setdefault(normalized_name(f), []).append(os.path.join(data_dir, f))
390
+ # collapse byte-identical copies; refuse different files with the same normalised name
391
+ image_path: dict[str, str] = {}
392
+ for n, paths in images.items():
393
+ if len(paths) > 1:
394
+ digests = {_digest(p) for p in paths}
395
+ if len(digests) > 1:
396
+ raise ValueError(f"{data_dir}: {len(paths)} different image files share the name '{n}': {[os.path.basename(p) for p in paths]}")
397
+ image_path[n] = paths[0]
398
+ by_first: dict[str, list[str]] = {}
399
+ for n in image_path:
400
+ by_first.setdefault(image_key(n), []).append(n)
401
+
402
+ paired: dict[str, tuple[ImageAnnotation, str]] = {} # norm image name -> (annotation, annotation path)
403
+ for f in files:
404
+ if not f.lower().endswith(ANN_EXTS):
405
+ continue
406
+ path = os.path.join(data_dir, f)
407
+ try:
408
+ anns = load_annotation_file(path)
409
+ except Exception as e: # noqa: BLE001
410
+ if verbose:
411
+ print(f"[discover] skip {f}: {e}")
412
+ continue
413
+ for ann in anns:
414
+ n = ann.norm_name
415
+ if n not in image_path:
416
+ cands = by_first.get(image_key(n), [])
417
+ if len(cands) == 1:
418
+ n = cands[0]
419
+ else:
420
+ if verbose:
421
+ print(f"[discover] {f}: no image for '{ann.image_name}'" + (f" (ambiguous: {cands})" if cands else ""))
422
+ continue
423
+ if n in paired:
424
+ prev_ann, prev_path = paired[n]
425
+ if _shapes_equal(prev_ann, ann):
426
+ if verbose:
427
+ print(f"[discover] {f}: same annotations as {os.path.basename(prev_path)} for '{ann.image_name}', keeping the first")
428
+ continue
429
+ raise ValueError(f"{data_dir}: two different annotation files for image '{ann.image_name}': "
430
+ f"{os.path.basename(prev_path)} and {f}")
431
+ paired[n] = (ann, path)
432
+ names = sorted(paired)
433
+ keys = short_keys([os.path.basename(image_path[n]) for n in names])
434
+ samples = []
435
+ for n, k in zip(names, keys):
436
+ ann, apath = paired[n]
437
+ ipath = image_path[n]
438
+ if check_sizes:
439
+ h, w = image_size(ipath)
440
+ if (h, w) != (ann.height, ann.width):
441
+ raise ValueError(f"{os.path.basename(ipath)}: image is {w}x{h} but the annotation canvas is {ann.width}x{ann.height}")
442
+ samples.append(Sample(k, ipath, ann, apath))
443
+ unannotated = [p for n, p in image_path.items() if n not in paired]
444
+ return samples, sorted(unannotated)
445
+
446
+
447
+ def _digest(path: str) -> str:
448
+ import hashlib
449
+ h = hashlib.sha256()
450
+ with open(path, "rb") as f:
451
+ for chunk in iter(lambda: f.read(1 << 20), b""):
452
+ h.update(chunk)
453
+ return h.hexdigest()
454
+
455
+
456
+ def _shapes_equal(a: ImageAnnotation, b: ImageAnnotation, tol: float = 0.5) -> bool:
457
+ """Same labels and the same shapes (centroids within ``tol`` px, same vertex
458
+ count), in any order. Tolerant to the coordinate rounding that differs
459
+ between CVAT's XML (2 decimals) and JSON exports."""
460
+ if len(a.shapes) != len(b.shapes):
461
+ return False
462
+ pool = list(b.shapes)
463
+ for s in a.shapes:
464
+ c = s.centroid
465
+ hit = None
466
+ for j, t in enumerate(pool):
467
+ if t.label == s.label and len(t.points) == len(s.points) and float(np.abs(t.centroid - c).max()) <= tol:
468
+ hit = j
469
+ break
470
+ if hit is None:
471
+ return False
472
+ pool.pop(hit)
473
+ return True
474
+
475
+
476
+ def load_image(path: str) -> np.ndarray:
477
+ """Load an RGB uint8 image (H, W, 3) from tif/png/jpg.
478
+
479
+ * TIFFs are read as a whole series, so channel-per-page files (ImageJ
480
+ composites, OME-TIFF ``CYX``) load as 3 channels; stacks with more than
481
+ four planes are refused rather than silently reduced to one plane.
482
+ * Compressed TIFFs that tifffile cannot decode fall back to Pillow.
483
+ * Palette / CMYK / LA images are converted to RGB(A) by Pillow.
484
+ * 16-bit inputs are rescaled by their 99.95th percentile (see to_rgb_uint8).
485
+ """
486
+ if path.lower().endswith((".tif", ".tiff")):
487
+ import logging
488
+ import tifffile
489
+ lg = logging.getLogger("tifffile")
490
+ lvl = lg.level
491
+ lg.setLevel(logging.ERROR) # the Keyence exports carry an inconsistent OME block; tifffile logs it
492
+ try:
493
+ with tifffile.TiffFile(path) as tf:
494
+ try:
495
+ a = tf.series[0].asarray()
496
+ except Exception: # noqa: BLE001 - malformed series metadata: stack the pages
497
+ pages = [p.asarray() for p in tf.pages]
498
+ a = pages[0] if len(pages) == 1 else np.stack(pages)
499
+ except Exception as e: # noqa: BLE001 - e.g. LZW/JPEG without imagecodecs
500
+ lg.setLevel(lvl)
501
+ from PIL import Image
502
+ try:
503
+ a = _pil_array(Image.open(path))
504
+ except Exception:
505
+ raise ValueError(f"cannot decode TIFF {os.path.basename(path)}: {e}") from e
506
+ finally:
507
+ lg.setLevel(lvl)
508
+ a = np.asarray(a)
509
+ if a.ndim == 3 and a.shape[0] > 4 and a.shape[-1] > 4:
510
+ raise ValueError(f"{os.path.basename(path)}: {a.shape[0]}-plane stack; export a single RGB plane (blue nuclei, green antibody, red puncta)")
511
+ if a.ndim == 4:
512
+ raise ValueError(f"{os.path.basename(path)}: unsupported {a.ndim}-D TIFF of shape {a.shape}")
513
+ else:
514
+ from PIL import Image
515
+ a = _pil_array(Image.open(path))
516
+ return to_rgb_uint8(a)
517
+
518
+
519
+ def _pil_array(im) -> np.ndarray:
520
+ """Pillow image -> array with a known mode (keeps 16-bit/float grey untouched)."""
521
+ if im.mode in ("RGB", "RGBA", "L", "I", "I;16", "I;16B", "I;16L", "F"):
522
+ return np.asarray(im)
523
+ if im.mode in ("LA", "PA"):
524
+ return np.asarray(im.convert("RGBA"))
525
+ return np.asarray(im.convert("RGB"))
526
+
527
+
528
+ def to_rgb_uint8(a: np.ndarray) -> np.ndarray:
529
+ a = np.asarray(a)
530
+ if a.ndim == 3 and a.shape[0] in (3, 4) and a.shape[-1] > 4:
531
+ a = np.moveaxis(a, 0, -1) # channel-first (C, H, W) -> channel-last
532
+ if a.ndim == 2:
533
+ a = np.repeat(a[..., None], 3, axis=-1)
534
+ if a.ndim != 3:
535
+ raise ValueError(f"expected an RGB image, got shape {a.shape}")
536
+ if a.shape[-1] == 4:
537
+ a = a[..., :3]
538
+ if a.shape[-1] != 3:
539
+ raise ValueError(f"expected an RGB image, got shape {a.shape}")
540
+ if a.dtype != np.uint8:
541
+ a = a.astype(np.float32)
542
+ if a.size == 0:
543
+ return np.zeros(a.shape, np.uint8)
544
+ hi = float(a.max())
545
+ if np.issubdtype(np.asarray(a).dtype, np.floating) and hi <= 1.0 and float(a.min()) >= 0.0:
546
+ scale = 255.0 # float image in [0, 1]
547
+ else:
548
+ # Robust auto-scale for 16-bit / float data: the 99.95th percentile maps to 255,
549
+ # so one saturated pixel cannot darken the whole image.
550
+ ref = float(np.percentile(a, 99.95))
551
+ if ref <= 0:
552
+ ref = hi if hi > 0 else 1.0
553
+ scale = 255.0 / ref
554
+ a = np.clip(np.round(a * scale), 0, 255).astype(np.uint8)
555
+ return np.ascontiguousarray(a)
cellquant/data.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training targets and augmentation.
2
+
3
+ Targets built from a :class:`RasterAnnotation` (all (H, W)):
4
+ cell3 int64 0 bg, 1 nucleus interior, 2 nucleus border (2-px inner ring)
5
+ antibody float 1 where nucleus is antibody+, 0 where antibody-; valid on nucleus px
6
+ cellnos float 1 where nucleus has a punctum ("Nostril"); valid on nucleus px
7
+ cellmask float 1 on nucleus pixels (loss mask for the two attribute heads)
8
+ nos3 int64 0 bg, 1 punctum in nucleus, 2 punctum external
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+ import torch
17
+ from scipy import ndimage as ndi
18
+ from skimage.segmentation import find_boundaries
19
+
20
+ from .annotations import RasterAnnotation
21
+
22
+ BORDER_WIDTH = 2 # pixels of the inner border ring
23
+
24
+
25
+ def make_targets(R: RasterAnnotation) -> dict[str, np.ndarray]:
26
+ inst = R.cell_inst
27
+ fg = inst > 0
28
+ border = find_boundaries(inst, mode="inner")
29
+ if BORDER_WIDTH > 1:
30
+ border = ndi.binary_dilation(border, iterations=BORDER_WIDTH - 1) & fg
31
+ cell3 = np.zeros(inst.shape, dtype=np.int64)
32
+ cell3[fg] = 1
33
+ cell3[border] = 2
34
+ ab = R.cell_antibody().astype(np.float32)[inst]
35
+ cn = R.cell_has_nostril().astype(np.float32)[inst]
36
+ ninst = R.nostril_inst
37
+ nin = R.nostril_inside()
38
+ nos3 = np.zeros(inst.shape, dtype=np.int64)
39
+ nos3[ninst > 0] = np.where(nin[ninst[ninst > 0]], 1, 2)
40
+ return {
41
+ "cell3": cell3,
42
+ "antibody": ab,
43
+ "cellnos": cn,
44
+ "cellmask": fg.astype(np.float32),
45
+ "nos3": nos3,
46
+ }
47
+
48
+
49
+ @dataclass
50
+ class AugConfig:
51
+ crop: int = 512
52
+ scale_min: float = 0.8
53
+ scale_max: float = 1.25
54
+ gain_min: float = 0.6
55
+ gain_max: float = 1.5
56
+ gamma_min: float = 0.7
57
+ gamma_max: float = 1.4
58
+ noise_max: float = 0.03
59
+ blur_max: float = 1.0
60
+ p_blur: float = 0.3
61
+ p_channel_shift: float = 0.5 # per-channel additive offset
62
+ offset_max: float = 0.03
63
+
64
+
65
+ class PatchSampler:
66
+ """Random augmented patches from whole images kept in memory.
67
+
68
+ Geometric: random scale (zoom), random crop, random dihedral transform.
69
+ Photometric: per-channel gain, gamma, additive offset, Gaussian noise, blur.
70
+ Label maps use nearest-neighbour resampling.
71
+ """
72
+
73
+ def __init__(self, images: list[np.ndarray], targets: list[dict[str, np.ndarray]],
74
+ cfg: AugConfig | None = None, seed: int = 0):
75
+ assert len(images) == len(targets) and len(images) > 0
76
+ self.images = images
77
+ self.targets = targets
78
+ self.cfg = cfg or AugConfig()
79
+ self.rng = np.random.default_rng(seed)
80
+
81
+ def _one(self) -> tuple[np.ndarray, dict[str, np.ndarray]]:
82
+ cfg, rng = self.cfg, self.rng
83
+ i = int(rng.integers(len(self.images)))
84
+ img, tg = self.images[i], self.targets[i]
85
+ H, W = img.shape[:2]
86
+ s = float(rng.uniform(cfg.scale_min, cfg.scale_max)) # zoom factor applied to the crop
87
+ src = int(round(cfg.crop / s)) # source window size
88
+ src = max(32, min(src, H, W))
89
+ y0 = int(rng.integers(0, H - src + 1))
90
+ x0 = int(rng.integers(0, W - src + 1))
91
+ patch = img[y0:y0 + src, x0:x0 + src].astype(np.float32) / 255.0
92
+ tpatch = {k: v[y0:y0 + src, x0:x0 + src] for k, v in tg.items()}
93
+ if src != cfg.crop:
94
+ zoom = cfg.crop / src
95
+ patch = ndi.zoom(patch, (zoom, zoom, 1), order=1)
96
+ tpatch = {k: ndi.zoom(v, (zoom, zoom), order=0) for k, v in tpatch.items()}
97
+ patch = patch[:cfg.crop, :cfg.crop]
98
+ tpatch = {k: v[:cfg.crop, :cfg.crop] for k, v in tpatch.items()}
99
+ if patch.shape[0] != cfg.crop or patch.shape[1] != cfg.crop: # rounding guard
100
+ ph, pw = cfg.crop - patch.shape[0], cfg.crop - patch.shape[1]
101
+ patch = np.pad(patch, ((0, ph), (0, pw), (0, 0)), mode="reflect")
102
+ tpatch = {k: np.pad(v, ((0, ph), (0, pw)), mode="reflect") for k, v in tpatch.items()}
103
+ # dihedral
104
+ k = int(rng.integers(4))
105
+ flip = bool(rng.integers(2))
106
+ patch = np.rot90(patch, k, axes=(0, 1))
107
+ tpatch = {kk: np.rot90(v, k, axes=(0, 1)) for kk, v in tpatch.items()}
108
+ if flip:
109
+ patch = patch[:, ::-1]
110
+ tpatch = {kk: v[:, ::-1] for kk, v in tpatch.items()}
111
+ # photometric
112
+ gain = rng.uniform(cfg.gain_min, cfg.gain_max, size=3).astype(np.float32)
113
+ gamma = float(rng.uniform(cfg.gamma_min, cfg.gamma_max))
114
+ patch = np.clip(patch * gain, 0, 1) ** gamma
115
+ if rng.random() < cfg.p_channel_shift:
116
+ patch = patch + rng.uniform(-cfg.offset_max, cfg.offset_max, size=3).astype(np.float32)
117
+ if rng.random() < cfg.p_blur:
118
+ sig = float(rng.uniform(0.3, cfg.blur_max))
119
+ patch = ndi.gaussian_filter(patch, sigma=(sig, sig, 0))
120
+ sigma_n = float(rng.uniform(0, cfg.noise_max))
121
+ if sigma_n > 0:
122
+ patch = patch + rng.normal(0, sigma_n, size=patch.shape).astype(np.float32)
123
+ patch = np.clip(patch, 0, 1).astype(np.float32)
124
+ return np.ascontiguousarray(patch), {k: np.ascontiguousarray(v) for k, v in tpatch.items()}
125
+
126
+ def batch(self, n: int) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
127
+ xs, ts = [], []
128
+ for _ in range(n):
129
+ x, t = self._one()
130
+ xs.append(x)
131
+ ts.append(t)
132
+ x = torch.from_numpy(np.stack(xs)).permute(0, 3, 1, 2).contiguous()
133
+ out = {}
134
+ for k in ts[0]:
135
+ arr = np.stack([t[k] for t in ts])
136
+ out[k] = torch.from_numpy(arr)
137
+ return x, out
138
+
139
+
140
+ class Prefetcher:
141
+ """Builds augmented batches in background threads so the GPU never waits.
142
+
143
+ Each worker owns its own sampler (independent RNG stream) and its own
144
+ queue; ``next()`` round-robins over the workers, so the batch sequence is
145
+ fully determined by (seed, workers) regardless of thread scheduling.
146
+ """
147
+
148
+ def __init__(self, images, targets, cfg: AugConfig, batch: int, seed: int = 0, workers: int = 3, depth: int = 6):
149
+ import queue
150
+ import threading
151
+ workers = max(1, int(workers))
152
+ self.batch = batch
153
+ self.workers = workers
154
+ self._i = 0
155
+ self._stop = threading.Event()
156
+ self.qs: list["queue.Queue"] = [queue.Queue(maxsize=max(1, depth // workers)) for _ in range(workers)]
157
+ self._threads = []
158
+ for w in range(workers):
159
+ s = PatchSampler(images, targets, cfg, seed=seed * 1000 + w)
160
+ th = threading.Thread(target=self._work, args=(s, self.qs[w]), daemon=True)
161
+ th.start()
162
+ self._threads.append(th)
163
+
164
+ def _work(self, sampler: PatchSampler, q) -> None:
165
+ import queue
166
+ while not self._stop.is_set():
167
+ b = sampler.batch(self.batch)
168
+ while not self._stop.is_set():
169
+ try:
170
+ q.put(b, timeout=0.5)
171
+ break
172
+ except queue.Full:
173
+ continue
174
+
175
+ def next(self):
176
+ q = self.qs[self._i]
177
+ self._i = (self._i + 1) % self.workers
178
+ return q.get()
179
+
180
+ def close(self) -> None:
181
+ self._stop.set()
cellquant/gemini_summary.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional plain-language summary of the counts via Gemini.
2
+
3
+ Enabled only when the ``GEMINI_API_KEY`` environment variable (a Hugging Face
4
+ Space *secret*) is set. The model id comes from ``GEMINI_MODEL``. Gemini never
5
+ produces or alters any count: it only receives the numbers computed by the
6
+ trained network and a thumbnail of the overlay, and is instructed to report
7
+ them verbatim.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ from PIL import Image
14
+
15
+ DEFAULT_MODEL = os.environ.get("GEMINI_MODEL", "gemini-4-argon")
16
+ FALLBACK_MODELS = ("gemini-3.8-flash", "gemini-2.5-flash")
17
+
18
+
19
+ def enabled() -> bool:
20
+ return bool(os.environ.get("GEMINI_API_KEY"))
21
+
22
+
23
+ def _prompt(rows: list[dict]) -> str:
24
+ lines = []
25
+ for r in rows:
26
+ lines.append(f"- {r['image']}: " + ", ".join(f"{k} = {v}" for k, v in r["counts"].items()))
27
+ return (
28
+ "You are helping a neuroscience lab read the output of an automated image analysis "
29
+ "of spinal-cord fluorescence sections (blue = DAPI nuclei, green = microglial antibody "
30
+ "stain such as Iba1 or TMEM119, red = puncta that the lab calls 'Nostril').\n"
31
+ "A trained segmentation network produced these counts per image:\n"
32
+ + "\n".join(lines)
33
+ + "\n\nLabel meanings: every nucleus is in exactly ONE of the four 'Cell' categories (they are mutually "
34
+ "exclusive, so total nuclei = sum of the four). 'Cell, Antibody +' = nucleus with green antibody signal and no "
35
+ "punctum; 'Cell, Antibody -' = nucleus without antibody signal and no punctum; 'Cell, Nostril, Antibody +/-' = "
36
+ "nucleus containing a red punctum, with/without antibody signal. 'Nostril in Nucleus' = punctum inside a nucleus; "
37
+ "'Nostril External of Nucleus' = punctum outside nuclei.\n"
38
+ "Write a concise summary (max 150 words) for the lab notebook: restate the counts exactly as given "
39
+ "(do not round, estimate or invent numbers), give the fraction of nuclei that are antibody positive "
40
+ "and the fraction that contain a punctum, and mention anything in the overlay thumbnail that looks "
41
+ "like a quality problem (out of focus, very dark, debris, edge artefacts). If nothing looks wrong, say so."
42
+ )
43
+
44
+
45
+ def summarize(rows: list[dict], overlay: Image.Image | None = None, model: str | None = None) -> str:
46
+ """rows: [{"image": name, "counts": {label: int}}]. Returns the summary text or an error message."""
47
+ key = os.environ.get("GEMINI_API_KEY")
48
+ if not key:
49
+ return "Gemini summary disabled: set the GEMINI_API_KEY secret."
50
+ try:
51
+ from google import genai
52
+ except ImportError:
53
+ return "Gemini summary unavailable: install google-genai."
54
+ contents: list = [_prompt(rows)]
55
+ if overlay is not None:
56
+ thumb = overlay.copy()
57
+ thumb.thumbnail((768, 768))
58
+ contents.append(thumb)
59
+ client = genai.Client(api_key=key)
60
+ candidates = [model or DEFAULT_MODEL] + [m for m in FALLBACK_MODELS if m != (model or DEFAULT_MODEL)]
61
+ last = ""
62
+ for mid in candidates:
63
+ try:
64
+ resp = client.models.generate_content(model=mid, contents=contents)
65
+ text = (resp.text or "").strip()
66
+ return text if text else "Gemini returned an empty response."
67
+ except Exception as e: # noqa: BLE001 - try the next model id, then surface the error to the UI
68
+ last = f"{type(e).__name__}: {e}"
69
+ if "NOT_FOUND" not in str(e) and "not found" not in str(e).lower():
70
+ break
71
+ return f"Gemini summary failed ({last})"
cellquant/infer.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference: image -> probabilities -> instances. CPU-friendly, optional TTA and tiling."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import torch
6
+
7
+ from .annotations import to_rgb_uint8
8
+ from .model import CellUNet, load_checkpoint, pad_to_multiple
9
+ from .postprocess import PostConfig, Result, analyze, logits_to_probs
10
+
11
+
12
+ def pick_device(pref: str = "auto") -> torch.device:
13
+ if pref != "auto":
14
+ return torch.device(pref)
15
+ if torch.cuda.is_available():
16
+ return torch.device("cuda")
17
+ if torch.backends.mps.is_available():
18
+ return torch.device("mps")
19
+ return torch.device("cpu")
20
+
21
+
22
+ # The 8 dihedral transforms as (k rotations, flip) with their inverses handled below.
23
+ DIHEDRAL = [(k, f) for f in (False, True) for k in range(4)]
24
+
25
+
26
+ def _fwd_t(x: torch.Tensor, k: int, flip: bool) -> torch.Tensor:
27
+ if flip:
28
+ x = torch.flip(x, dims=[-1])
29
+ return torch.rot90(x, k, dims=[-2, -1])
30
+
31
+
32
+ def _inv_t(x: torch.Tensor, k: int, flip: bool) -> torch.Tensor:
33
+ x = torch.rot90(x, -k, dims=[-2, -1])
34
+ if flip:
35
+ x = torch.flip(x, dims=[-1])
36
+ return x
37
+
38
+
39
+ class Predictor:
40
+ def __init__(self, weights: str | CellUNet, device: str = "auto", tta: int = 1,
41
+ tile: int = 1024, overlap: int = 96, max_whole: int = 1536):
42
+ """tta: 1 (none), 4 (flips + 180 rotation) or 8 (full dihedral group)."""
43
+ self.device = pick_device(device)
44
+ if isinstance(weights, CellUNet):
45
+ self.model, self.meta = weights, {}
46
+ else:
47
+ self.model, self.meta = load_checkpoint(weights)
48
+ self.model.to(self.device).eval()
49
+ assert tta in (1, 4, 8)
50
+ self.tta = tta
51
+ self.tile, self.overlap, self.max_whole = tile, overlap, max_whole
52
+
53
+ @torch.no_grad()
54
+ def _probs_tensor(self, x: torch.Tensor) -> torch.Tensor:
55
+ """x: (1, 3, H, W) float in [0,1] on device, H, W multiples of 32 -> (8-ch probabilities) (1, 8, H, W)."""
56
+ transforms = DIHEDRAL[:1] if self.tta == 1 else ([(0, False), (2, False), (0, True), (2, True)] if self.tta == 4 else DIHEDRAL)
57
+ acc = None
58
+ for k, f in transforms:
59
+ out = self.model(_fwd_t(x, k, f))
60
+ out = _inv_t(out, k, f)
61
+ p = torch.cat([out[:, 0:3].softmax(1), out[:, 3:4].sigmoid(), out[:, 4:5].sigmoid(), out[:, 5:8].softmax(1)], dim=1)
62
+ acc = p if acc is None else acc + p
63
+ return acc / len(transforms)
64
+
65
+ @torch.no_grad()
66
+ def probs(self, img: np.ndarray) -> dict[str, np.ndarray]:
67
+ img = to_rgb_uint8(img)
68
+ H, W = img.shape[:2]
69
+ x = torch.from_numpy(img).permute(2, 0, 1).float().div_(255.0).unsqueeze(0)
70
+ if max(H, W) <= self.max_whole:
71
+ xp, _ = pad_to_multiple(x, 32)
72
+ p = self._probs_tensor(xp.to(self.device))[0, :, :H, :W].cpu().numpy()
73
+ else:
74
+ p = self._tiled(x)[:, :H, :W]
75
+ return {"cell": p[0:3], "antibody": p[3], "cellnos": p[4], "nos": p[5:8]}
76
+
77
+ def _tiled(self, x: torch.Tensor) -> np.ndarray:
78
+ """Overlapping tiles blended with a linear ramp (no visible seams)."""
79
+ _, _, H, W = x.shape
80
+ t, o = self.tile, self.overlap
81
+ step = t - o
82
+ acc = np.zeros((8, H, W), np.float32)
83
+ wsum = np.zeros((1, H, W), np.float32)
84
+ ramp = np.minimum(np.arange(t) + 1, np.arange(t)[::-1] + 1).astype(np.float32)
85
+ ramp = np.minimum(ramp, o) / o
86
+ w2 = (ramp[:, None] * ramp[None, :])[None]
87
+ ys = list(range(0, max(H - t, 0) + 1, step)) or [0]
88
+ xs = list(range(0, max(W - t, 0) + 1, step)) or [0]
89
+ if ys[-1] + t < H:
90
+ ys.append(H - t)
91
+ if xs[-1] + t < W:
92
+ xs.append(W - t)
93
+ for y0 in ys:
94
+ for x0 in xs:
95
+ y0c, x0c = max(0, y0), max(0, x0)
96
+ tile = x[:, :, y0c:y0c + t, x0c:x0c + t]
97
+ th, tw = tile.shape[-2:]
98
+ tp, _ = pad_to_multiple(tile, 32)
99
+ p = self._probs_tensor(tp.to(self.device))[0, :, :th, :tw].cpu().numpy()
100
+ w = w2[:, :th, :tw]
101
+ acc[:, y0c:y0c + th, x0c:x0c + tw] += p * w
102
+ wsum[:, y0c:y0c + th, x0c:x0c + tw] += w
103
+ return acc / np.maximum(wsum, 1e-6)
104
+
105
+ def predict(self, img: np.ndarray, cfg: PostConfig | None = None) -> tuple[Result, dict[str, np.ndarray]]:
106
+ probs = self.probs(img)
107
+ return analyze(probs, cfg), probs
cellquant/labels.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Label vocabulary shared by training, post-processing and the app."""
2
+ from __future__ import annotations
3
+
4
+ # The six CVAT labels exactly as they appear in the annotation exports.
5
+ CELL_LABELS = (
6
+ "Cell, Antibody +",
7
+ "Cell, Antibody -",
8
+ "Cell, Nostril, Antibody +",
9
+ "Cell, Nostril, Antibody -",
10
+ )
11
+ NOSTRIL_LABELS = (
12
+ "Nostril in Nucleus",
13
+ "Nostril External of Nucleus",
14
+ )
15
+ ALL_LABELS = CELL_LABELS + NOSTRIL_LABELS
16
+
17
+ # Display colours (RGB) used for overlays, one per label.
18
+ LABEL_COLORS = {
19
+ "Cell, Antibody +": (0, 255, 0),
20
+ "Cell, Antibody -": (255, 0, 0),
21
+ "Cell, Nostril, Antibody +": (0, 255, 255),
22
+ "Cell, Nostril, Antibody -": (255, 0, 255),
23
+ "Nostril in Nucleus": (255, 255, 0),
24
+ "Nostril External of Nucleus": (255, 128, 0),
25
+ }
26
+
27
+
28
+ def is_cell_label(label: str) -> bool:
29
+ return label.startswith("Cell")
30
+
31
+
32
+ def is_nostril_label(label: str) -> bool:
33
+ return label.startswith("Nostril")
34
+
35
+
36
+ def cell_attributes(label: str) -> tuple[bool, bool]:
37
+ """Return (antibody_positive, has_nostril) for a cell label."""
38
+ if not is_cell_label(label):
39
+ raise ValueError(f"not a cell label: {label!r}")
40
+ antibody_positive = label.rstrip().endswith("+")
41
+ has_nostril = "Nostril" in label
42
+ return antibody_positive, has_nostril
43
+
44
+
45
+ def cell_label(antibody_positive: bool, has_nostril: bool) -> str:
46
+ """Compose the CVAT cell label from its two attributes."""
47
+ sign = "+" if antibody_positive else "-"
48
+ return f"Cell, Nostril, Antibody {sign}" if has_nostril else f"Cell, Antibody {sign}"
49
+
50
+
51
+ def nostril_label(in_nucleus: bool) -> str:
52
+ return "Nostril in Nucleus" if in_nucleus else "Nostril External of Nucleus"
53
+
54
+
55
+ def nostril_in_nucleus(label: str) -> bool:
56
+ if not is_nostril_label(label):
57
+ raise ValueError(f"not a nostril label: {label!r}")
58
+ return "in Nucleus" in label
cellquant/losses.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loss for the multi-head output (see model.py for the channel layout)."""
2
+ from __future__ import annotations
3
+
4
+ import torch
5
+ import torch.nn.functional as F
6
+
7
+ from .model import ANTIBODY_CH, CELLNOS_CH, CELL_SLICE, NOS_SLICE
8
+
9
+
10
+ def soft_dice(probs: torch.Tensor, target_onehot: torch.Tensor, eps: float = 1.0) -> torch.Tensor:
11
+ """Mean soft Dice loss over the given channels (B, C, H, W)."""
12
+ dims = (0, 2, 3)
13
+ inter = (probs * target_onehot).sum(dims)
14
+ denom = probs.sum(dims) + target_onehot.sum(dims)
15
+ return (1 - (2 * inter + eps) / (denom + eps)).mean()
16
+
17
+
18
+ def masked_bce(logit: torch.Tensor, target: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
19
+ l = F.binary_cross_entropy_with_logits(logit, target, reduction="none")
20
+ return (l * mask).sum() / mask.sum().clamp_min(1.0)
21
+
22
+
23
+ def multitask_loss(out: torch.Tensor, t: dict[str, torch.Tensor]) -> tuple[torch.Tensor, dict[str, float]]:
24
+ dev = out.device
25
+ cell_logits = out[:, CELL_SLICE]
26
+ nos_logits = out[:, NOS_SLICE]
27
+ cell3 = t["cell3"].to(dev)
28
+ nos3 = t["nos3"].to(dev)
29
+ w_cell = torch.tensor([1.0, 1.0, 2.0], device=dev)
30
+ w_nos = torch.tensor([1.0, 4.0, 4.0], device=dev)
31
+ ce_cell = F.cross_entropy(cell_logits, cell3, weight=w_cell)
32
+ ce_nos = F.cross_entropy(nos_logits, nos3, weight=w_nos)
33
+ p_cell = cell_logits.softmax(1)
34
+ p_nos = nos_logits.softmax(1)
35
+ oh_cell = F.one_hot(cell3, 3).permute(0, 3, 1, 2).float()
36
+ oh_nos = F.one_hot(nos3, 3).permute(0, 3, 1, 2).float()
37
+ dice_cell = soft_dice(p_cell[:, 1:], oh_cell[:, 1:])
38
+ dice_nos = soft_dice(p_nos[:, 1:], oh_nos[:, 1:])
39
+ mask = t["cellmask"].to(dev)
40
+ bce_ab = masked_bce(out[:, ANTIBODY_CH], t["antibody"].to(dev), mask)
41
+ bce_cn = masked_bce(out[:, CELLNOS_CH], t["cellnos"].to(dev), mask)
42
+ total = ce_cell + dice_cell + ce_nos + dice_nos + bce_ab + bce_cn
43
+ parts = {
44
+ "ce_cell": ce_cell.item(), "dice_cell": dice_cell.item(),
45
+ "ce_nos": ce_nos.item(), "dice_nos": dice_nos.item(),
46
+ "bce_ab": bce_ab.item(), "bce_cn": bce_cn.item(), "total": total.item(),
47
+ }
48
+ return total, parts
cellquant/metrics.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Instance-level evaluation against CVAT ground truth."""
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ from scipy import ndimage as ndi
6
+ from scipy.optimize import linear_sum_assignment
7
+
8
+ from .labels import ALL_LABELS, CELL_LABELS, NOSTRIL_LABELS, cell_attributes, nostril_in_nucleus
9
+
10
+
11
+ def iou_matrix(gt: np.ndarray, pred: np.ndarray) -> np.ndarray:
12
+ ng, npred = int(gt.max()), int(pred.max())
13
+ if ng == 0 or npred == 0:
14
+ return np.zeros((ng, npred), dtype=np.float64)
15
+ pairs = gt.astype(np.int64) * (npred + 1) + pred.astype(np.int64)
16
+ counts = np.bincount(pairs.ravel(), minlength=(ng + 1) * (npred + 1)).reshape(ng + 1, npred + 1)
17
+ inter = counts[1:, 1:].astype(np.float64)
18
+ area_g = counts[1:, :].sum(1, keepdims=True)
19
+ area_p = counts[:, 1:].sum(0, keepdims=True)
20
+ union = area_g + area_p - inter
21
+ return inter / np.maximum(union, 1)
22
+
23
+
24
+ def match_by_iou(gt: np.ndarray, pred: np.ndarray, thr: float = 0.5) -> list[tuple[int, int, float]]:
25
+ """Hungarian one-to-one matching; returns (gt_id, pred_id, iou) pairs with iou >= thr."""
26
+ iou = iou_matrix(gt, pred)
27
+ if iou.size == 0:
28
+ return []
29
+ valid = iou >= thr
30
+ if not valid.any():
31
+ return []
32
+ # maximise the number of valid matches first, IoU only as a tie-break
33
+ r, c = linear_sum_assignment(-(valid.astype(np.float64) + iou * 1e-3))
34
+ return [(int(i) + 1, int(j) + 1, float(iou[i, j])) for i, j in zip(r, c) if valid[i, j]]
35
+
36
+
37
+ def centroids(inst: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
38
+ n = int(inst.max())
39
+ if n == 0:
40
+ return np.zeros((0, 2)), np.zeros(0)
41
+ idx = np.arange(1, n + 1)
42
+ ones = np.ones_like(inst, dtype=np.float32)
43
+ c = np.array(ndi.center_of_mass(ones, inst, idx), dtype=np.float64)
44
+ a = np.array(ndi.sum_labels(ones, inst, idx), dtype=np.float64)
45
+ return c, a
46
+
47
+
48
+ def match_by_centroid(gt: np.ndarray, pred: np.ndarray, base_thr: float = 6.0) -> list[tuple[int, int, float]]:
49
+ """Hungarian matching on centroid distance; a pair matches when the distance
50
+ is below max(base_thr, equivalent radius of the GT object)."""
51
+ cg, ag = centroids(gt)
52
+ cp, _ = centroids(pred)
53
+ if len(cg) == 0 or len(cp) == 0:
54
+ return []
55
+ d = np.sqrt(((cg[:, None, :] - cp[None, :, :]) ** 2).sum(-1))
56
+ thr = np.maximum(base_thr, np.sqrt(ag / np.pi))[:, None]
57
+ cost = np.where(d <= thr, d, 1e6)
58
+ r, c = linear_sum_assignment(cost)
59
+ return [(int(i) + 1, int(j) + 1, float(d[i, j])) for i, j in zip(r, c) if d[i, j] <= thr[i, 0]]
60
+
61
+
62
+ def prf(tp: int, fp: int, fn: int) -> dict[str, float]:
63
+ p = tp / (tp + fp) if tp + fp else 0.0
64
+ r = tp / (tp + fn) if tp + fn else 0.0
65
+ f = 2 * p * r / (p + r) if p + r else 0.0
66
+ return {"tp": tp, "fp": fp, "fn": fn, "precision": p, "recall": r, "f1": f}
67
+
68
+
69
+ def _class_scores(gt_labels: list[str], pred_labels: list[str], matches, classes) -> dict:
70
+ """Per-class P/R/F1 where a TP needs a matched instance with the same label."""
71
+ gt_matched = {g: p for g, p, _ in matches}
72
+ pred_matched = {p: g for g, p, _ in matches}
73
+ out = {}
74
+ for c in classes:
75
+ tp = sum(1 for g, p, _ in matches if gt_labels[g] == c and pred_labels[p] == c)
76
+ n_gt = sum(1 for l in gt_labels[1:] if l == c)
77
+ n_pred = sum(1 for l in pred_labels[1:] if l == c)
78
+ out[c] = prf(tp, n_pred - tp, n_gt - tp)
79
+ out[c]["gt_count"] = n_gt
80
+ out[c]["pred_count"] = n_pred
81
+ return out
82
+
83
+
84
+ def match_by_centroid_in_mask(gt_inst: np.ndarray, pred_inst: np.ndarray) -> list[tuple[int, int, float]]:
85
+ """One-to-one matching where a predicted object matches the GT object that
86
+ contains its centroid (ties broken by IoU). Mirrors how a person counts."""
87
+ cp, _ = centroids(pred_inst)
88
+ if len(cp) == 0 or gt_inst.max() == 0:
89
+ return []
90
+ iou = iou_matrix(gt_inst, pred_inst)
91
+ H, W = gt_inst.shape
92
+ cost = np.full(iou.shape, 1e6)
93
+ for j, (cy, cx) in enumerate(cp):
94
+ g = int(gt_inst[min(H - 1, max(0, int(round(cy)))), min(W - 1, max(0, int(round(cx))))])
95
+ if g > 0:
96
+ cost[g - 1, j] = 1.0 - iou[g - 1, j]
97
+ r, c = linear_sum_assignment(cost)
98
+ return [(int(i) + 1, int(j) + 1, float(iou[i, j])) for i, j in zip(r, c) if cost[i, j] < 1e6]
99
+
100
+
101
+ def evaluate_cells(gt_inst: np.ndarray, gt_labels: list[str], pred_inst: np.ndarray, pred_labels: list[str],
102
+ iou_thr: float = 0.5) -> dict:
103
+ matches = match_by_iou(gt_inst, pred_inst, iou_thr)
104
+ n_gt, n_pred = int(gt_inst.max()), int(pred_inst.max())
105
+ det = prf(len(matches), n_pred - len(matches), n_gt - len(matches))
106
+ det["mean_iou_matched"] = float(np.mean([m[2] for m in matches])) if matches else 0.0
107
+ # looser, count-oriented matchings
108
+ m30 = match_by_iou(gt_inst, pred_inst, 0.3)
109
+ det["iou30"] = prf(len(m30), n_pred - len(m30), n_gt - len(m30))
110
+ mc = match_by_centroid_in_mask(gt_inst, pred_inst)
111
+ det["centroid"] = prf(len(mc), n_pred - len(mc), n_gt - len(mc))
112
+ # attribute accuracy on matched pairs
113
+ ab_ok = nos_ok = both_ok = 0
114
+ conf: dict[str, dict[str, int]] = {c: {d: 0 for d in CELL_LABELS} for c in CELL_LABELS}
115
+ for g, p, _ in matches:
116
+ ga, gn = cell_attributes(gt_labels[g])
117
+ pa, pn = cell_attributes(pred_labels[p])
118
+ ab_ok += ga == pa
119
+ nos_ok += gn == pn
120
+ both_ok += (ga == pa) and (gn == pn)
121
+ conf[gt_labels[g]][pred_labels[p]] += 1
122
+ nm = max(len(matches), 1)
123
+ return {
124
+ "detection": det,
125
+ "attribute_accuracy": {"antibody": ab_ok / nm, "nostril": nos_ok / nm, "both": both_ok / nm, "n_matched": len(matches)},
126
+ "per_class": _class_scores(gt_labels, pred_labels, matches, CELL_LABELS),
127
+ "confusion": conf,
128
+ "n_gt": n_gt,
129
+ "n_pred": n_pred,
130
+ }
131
+
132
+
133
+ def evaluate_nostrils(gt_inst: np.ndarray, gt_labels: list[str], pred_inst: np.ndarray, pred_labels: list[str],
134
+ dist_thr: float = 6.0) -> dict:
135
+ matches = match_by_centroid(gt_inst, pred_inst, dist_thr)
136
+ n_gt, n_pred = int(gt_inst.max()), int(pred_inst.max())
137
+ det = prf(len(matches), n_pred - len(matches), n_gt - len(matches))
138
+ det["mean_dist_matched"] = float(np.mean([m[2] for m in matches])) if matches else 0.0
139
+ type_ok = sum(1 for g, p, _ in matches if nostril_in_nucleus(gt_labels[g]) == nostril_in_nucleus(pred_labels[p]))
140
+ return {
141
+ "detection": det,
142
+ "type_accuracy": type_ok / max(len(matches), 1),
143
+ "per_class": _class_scores(gt_labels, pred_labels, matches, NOSTRIL_LABELS),
144
+ "n_gt": n_gt,
145
+ "n_pred": n_pred,
146
+ }
147
+
148
+
149
+ def count_errors(gt_counts: dict[str, int], pred_counts: dict[str, int]) -> dict[str, dict]:
150
+ out = {}
151
+ for lab in ALL_LABELS:
152
+ g, p = int(gt_counts.get(lab, 0)), int(pred_counts.get(lab, 0))
153
+ out[lab] = {"gt": g, "pred": p, "abs_err": abs(p - g), "rel_err": (abs(p - g) / g) if g else (0.0 if p == 0 else float("inf"))}
154
+ return out
155
+
156
+
157
+ def micro_average(per_image: list[dict], classes) -> dict:
158
+ """Sum TP/FP/FN over images and recompute P/R/F1 per class."""
159
+ out = {}
160
+ for c in classes:
161
+ tp = sum(r[c]["tp"] for r in per_image)
162
+ fp = sum(r[c]["fp"] for r in per_image)
163
+ fn = sum(r[c]["fn"] for r in per_image)
164
+ out[c] = prf(tp, fp, fn)
165
+ out[c]["gt_count"] = sum(r[c]["gt_count"] for r in per_image)
166
+ out[c]["pred_count"] = sum(r[c]["pred_count"] for r in per_image)
167
+ return out
cellquant/model.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-head UNet for nucleus / antibody / puncta segmentation.
2
+
3
+ Output channels (all at full input resolution):
4
+ 0..2 cell classes : background / nucleus interior / nucleus border (softmax)
5
+ 3 antibody logit : P(antibody positive) (meaningful on nucleus pixels)
6
+ 4 nostril-attr logit: P(nucleus contains punctum) (meaningful on nucleus pixels)
7
+ 5..7 punctum classes : background / in nucleus / external (softmax)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+
15
+ N_OUT = 8
16
+ CELL_SLICE = slice(0, 3)
17
+ ANTIBODY_CH = 3
18
+ CELLNOS_CH = 4
19
+ NOS_SLICE = slice(5, 8)
20
+
21
+ # ImageNet statistics; inputs are RGB in [0, 1].
22
+ MEAN = (0.485, 0.456, 0.406)
23
+ STD = (0.229, 0.224, 0.225)
24
+
25
+
26
+ def _block(cin: int, cout: int) -> nn.Sequential:
27
+ return nn.Sequential(
28
+ nn.Conv2d(cin, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
29
+ nn.Conv2d(cout, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
30
+ )
31
+
32
+
33
+ class CellUNet(nn.Module):
34
+ """UNet with a timm ResNet encoder (features at strides 2..32) and a
35
+ full-resolution refinement stage so that 5-pixel puncta are resolved."""
36
+
37
+ def __init__(self, encoder: str = "resnet34", pretrained: bool = False, n_out: int = N_OUT):
38
+ super().__init__()
39
+ import timm
40
+ self.encoder_name = encoder
41
+ self.encoder = timm.create_model(encoder, pretrained=pretrained, features_only=True)
42
+ chs = list(self.encoder.feature_info.channels()) # e.g. [64, 64, 128, 256, 512]
43
+ self.reductions = list(self.encoder.feature_info.reduction())
44
+ dec = []
45
+ prev = chs[-1]
46
+ for c in reversed(chs[:-1]):
47
+ dec.append(_block(prev + c, c))
48
+ prev = c
49
+ self.decoder = nn.ModuleList(dec)
50
+ self.stem = nn.Sequential(nn.Conv2d(3, 16, 3, padding=1, bias=False), nn.BatchNorm2d(16), nn.ReLU(inplace=True))
51
+ self.full = _block(prev + 16, 32)
52
+ self.head = nn.Conv2d(32, n_out, 1)
53
+ self.register_buffer("mean", torch.tensor(MEAN).view(1, 3, 1, 1))
54
+ self.register_buffer("std", torch.tensor(STD).view(1, 3, 1, 1))
55
+
56
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
57
+ """x: (B, 3, H, W) float in [0, 1]; H and W must be multiples of 32."""
58
+ H, W = x.shape[-2:]
59
+ xn = (x - self.mean) / self.std
60
+ feats = self.encoder(xn)
61
+ y = feats[-1]
62
+ for blk, f in zip(self.decoder, reversed(feats[:-1])):
63
+ y = F.interpolate(y, size=f.shape[-2:], mode="bilinear", align_corners=False)
64
+ y = blk(torch.cat([y, f], dim=1))
65
+ y = F.interpolate(y, size=(H, W), mode="bilinear", align_corners=False)
66
+ y = self.full(torch.cat([y, self.stem(x)], dim=1))
67
+ return self.head(y)
68
+
69
+
70
+ def pad_to_multiple(x: torch.Tensor, m: int = 32) -> tuple[torch.Tensor, tuple[int, int]]:
71
+ """Reflect-pad the bottom/right of (B, C, H, W) to multiples of ``m``."""
72
+ H, W = x.shape[-2:]
73
+ ph = (m - H % m) % m
74
+ pw = (m - W % m) % m
75
+ if ph or pw:
76
+ # reflect padding requires pad < dim; fall back to replicate for tiny inputs
77
+ mode = "reflect" if (ph < H and pw < W) else "replicate"
78
+ x = F.pad(x, (0, pw, 0, ph), mode=mode)
79
+ return x, (ph, pw)
80
+
81
+
82
+ def save_checkpoint(model: CellUNet, path: str, extra: dict | None = None) -> None:
83
+ payload = {
84
+ "state_dict": model.state_dict(),
85
+ "encoder": model.encoder_name,
86
+ "n_out": model.head.out_channels,
87
+ }
88
+ if extra:
89
+ payload.update(extra)
90
+ torch.save(payload, path)
91
+
92
+
93
+ def load_checkpoint(path: str, map_location: str = "cpu") -> tuple[CellUNet, dict]:
94
+ ck = torch.load(path, map_location=map_location, weights_only=False)
95
+ model = CellUNet(ck["encoder"], pretrained=False, n_out=ck.get("n_out", N_OUT))
96
+ model.load_state_dict(ck["state_dict"])
97
+ model.eval()
98
+ meta = {k: v for k, v in ck.items() if k != "state_dict"}
99
+ return model, meta
cellquant/postprocess.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn network probabilities into nucleus / punctum instances, labels and counts."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, dataclass, field
5
+
6
+ import numpy as np
7
+ from scipy import ndimage as ndi
8
+ from skimage.segmentation import find_boundaries, watershed
9
+
10
+ from .labels import ALL_LABELS, LABEL_COLORS, cell_label, nostril_label
11
+ from .model import ANTIBODY_CH, CELLNOS_CH, CELL_SLICE, NOS_SLICE
12
+
13
+
14
+ @dataclass
15
+ class PostConfig:
16
+ cell_fg_thr: float = 0.5 # P(interior) + P(border) > thr -> nucleus foreground
17
+ marker_thr: float = 0.5 # P(interior) > thr -> watershed markers
18
+ min_marker_area: int = 12 # px
19
+ min_cell_area: int = 50 # px (smallest traced nucleus in the training set is 35 px)
20
+ nos_fg_thr: float = 0.5 # 1 - P(background) > thr -> punctum foreground
21
+ min_nos_area: int = 3 # px
22
+ antibody_thr: float = 0.5
23
+ cellnos_thr: float = 0.5
24
+ nostril_rule: str = "head" # "head": punctum type from the network; "geometric": inside a nucleus => in nucleus
25
+ cellnos_rule: str = "contains" # "head" | "contains" | "either": how a nucleus gets the "Nostril" attribute
26
+ # ("contains" mirrors the annotation protocol and scored best in cross-validation)
27
+
28
+
29
+ def logits_to_probs(logits: np.ndarray) -> dict[str, np.ndarray]:
30
+ """logits: (8, H, W) float -> dict of probabilities."""
31
+ def softmax(z):
32
+ z = z - z.max(axis=0, keepdims=True)
33
+ e = np.exp(z)
34
+ return e / e.sum(axis=0, keepdims=True)
35
+ def sigmoid(z):
36
+ return 1.0 / (1.0 + np.exp(-z))
37
+ return {
38
+ "cell": softmax(logits[CELL_SLICE].astype(np.float32)),
39
+ "antibody": sigmoid(logits[ANTIBODY_CH].astype(np.float32)),
40
+ "cellnos": sigmoid(logits[CELLNOS_CH].astype(np.float32)),
41
+ "nos": softmax(logits[NOS_SLICE].astype(np.float32)),
42
+ }
43
+
44
+
45
+ def segment_cells(p_cell: np.ndarray, cfg: PostConfig) -> np.ndarray:
46
+ """p_cell: (3, H, W) softmax (bg, interior, border) -> int32 instance map."""
47
+ p_int, p_bor = p_cell[1], p_cell[2]
48
+ fg = (p_int + p_bor) > cfg.cell_fg_thr
49
+ markers, n = ndi.label(p_int > cfg.marker_thr)
50
+ if n:
51
+ sizes = np.bincount(markers.ravel())
52
+ small = sizes < cfg.min_marker_area
53
+ small[0] = False
54
+ markers[small[markers]] = 0
55
+ markers, n = ndi.label(markers > 0)
56
+ inst = watershed(-p_int, markers, mask=fg) if n else np.zeros(fg.shape, np.int32)
57
+ inst = inst.astype(np.int32)
58
+ # foreground components that received no marker become instances of their own
59
+ orphan, m = ndi.label(fg & (inst == 0))
60
+ if m:
61
+ sizes = np.bincount(orphan.ravel())
62
+ keep = sizes >= cfg.min_cell_area
63
+ keep[0] = False
64
+ orphan[~keep[orphan]] = 0
65
+ orphan_ids = np.unique(orphan)
66
+ orphan_ids = orphan_ids[orphan_ids > 0]
67
+ base = inst.max()
68
+ for j, oid in enumerate(orphan_ids, start=1):
69
+ inst[orphan == oid] = base + j
70
+ inst = _fill_and_filter(inst, cfg.min_cell_area)
71
+ return inst
72
+
73
+
74
+ def _fill_and_filter(inst: np.ndarray, min_area: int) -> np.ndarray:
75
+ """Fill holes inside every instance, drop instances below ``min_area`` and
76
+ keep only the largest connected piece of each instance; relabel 1..n."""
77
+ out = np.zeros_like(inst)
78
+ nxt = 1
79
+ for i, sl in enumerate(ndi.find_objects(inst), start=1):
80
+ if sl is None:
81
+ continue
82
+ m = inst[sl] == i
83
+ lab, k = ndi.label(m)
84
+ if k > 1: # keep the largest piece
85
+ sizes = np.bincount(lab.ravel())
86
+ sizes[0] = 0
87
+ m = lab == sizes.argmax()
88
+ # fill holes, but never absorb pixels that belong to another instance
89
+ m = m | (ndi.binary_fill_holes(m) & (inst[sl] == 0))
90
+ region = out[sl]
91
+ w = m & (region == 0)
92
+ if w.sum() < min_area:
93
+ continue
94
+ region[w] = nxt
95
+ nxt += 1
96
+ return out
97
+
98
+
99
+ def segment_nostrils(p_nos: np.ndarray, cfg: PostConfig) -> np.ndarray:
100
+ fg = (1.0 - p_nos[0]) > cfg.nos_fg_thr
101
+ inst, n = ndi.label(fg) # 4-connectivity keeps neighbouring puncta apart
102
+ if n == 0:
103
+ return inst.astype(np.int32)
104
+ sizes = np.bincount(inst.ravel())
105
+ keep = sizes >= cfg.min_nos_area
106
+ keep[0] = False
107
+ inst[~keep[inst]] = 0
108
+ inst, _ = ndi.label(inst > 0)
109
+ return inst.astype(np.int32)
110
+
111
+
112
+ @dataclass
113
+ class Result:
114
+ cell_inst: np.ndarray
115
+ nostril_inst: np.ndarray
116
+ cells: list[dict] = field(default_factory=list)
117
+ nostrils: list[dict] = field(default_factory=list)
118
+ counts: dict[str, int] = field(default_factory=dict)
119
+ config: dict = field(default_factory=dict)
120
+
121
+ @property
122
+ def cell_labels(self) -> list[str]:
123
+ return [""] + [c["label"] for c in self.cells]
124
+
125
+ @property
126
+ def nostril_labels(self) -> list[str]:
127
+ return [""] + [n["label"] for n in self.nostrils]
128
+
129
+
130
+ def analyze(probs: dict[str, np.ndarray], cfg: PostConfig | None = None) -> Result:
131
+ cfg = cfg or PostConfig()
132
+ cell_inst = segment_cells(probs["cell"], cfg)
133
+ nos_inst = segment_nostrils(probs["nos"], cfg)
134
+ n_cells = int(cell_inst.max())
135
+ n_nos = int(nos_inst.max())
136
+ H, W = cell_inst.shape
137
+ idx = np.arange(1, n_cells + 1)
138
+ # per-nucleus statistics
139
+ if n_cells:
140
+ areas = ndi.sum_labels(np.ones_like(cell_inst, dtype=np.float32), cell_inst, idx)
141
+ ab = ndi.mean(probs["antibody"], cell_inst, idx)
142
+ cn = ndi.mean(probs["cellnos"], cell_inst, idx)
143
+ cents = ndi.center_of_mass(np.ones_like(cell_inst, dtype=np.float32), cell_inst, idx)
144
+ slices = ndi.find_objects(cell_inst)
145
+ # puncta
146
+ nostrils: list[dict] = []
147
+ contains_in: np.ndarray = np.zeros(n_cells + 1, dtype=np.int32)
148
+ contains_any: np.ndarray = np.zeros(n_cells + 1, dtype=np.int32)
149
+ if n_nos:
150
+ nidx = np.arange(1, n_nos + 1)
151
+ nareas = ndi.sum_labels(np.ones_like(nos_inst, dtype=np.float32), nos_inst, nidx)
152
+ p_in = ndi.sum_labels(probs["nos"][1], nos_inst, nidx)
153
+ p_ext = ndi.sum_labels(probs["nos"][2], nos_inst, nidx)
154
+ ncents = ndi.center_of_mass(np.ones_like(nos_inst, dtype=np.float32), nos_inst, nidx)
155
+ for j in range(n_nos):
156
+ cy, cx = ncents[j]
157
+ r, c = min(H - 1, max(0, int(round(cy)))), min(W - 1, max(0, int(round(cx))))
158
+ cell_id = int(cell_inst[r, c])
159
+ if cell_id == 0: # a punctum touching a nucleus counts as inside it (majority of its pixels)
160
+ ids = cell_inst[nos_inst == j + 1]
161
+ ids = ids[ids > 0]
162
+ if ids.size and ids.size * 2 >= nareas[j]:
163
+ cell_id = int(np.bincount(ids).argmax())
164
+ head_in = bool(p_in[j] >= p_ext[j])
165
+ in_nuc = head_in if cfg.nostril_rule == "head" else cell_id > 0
166
+ nostrils.append({
167
+ "id": j + 1,
168
+ "label": nostril_label(in_nuc),
169
+ "area_px": float(nareas[j]),
170
+ "centroid_xy": [float(cx), float(cy)],
171
+ "p_in_nucleus": float(p_in[j] / (p_in[j] + p_ext[j] + 1e-9)),
172
+ "cell_id": cell_id,
173
+ })
174
+ if cell_id:
175
+ contains_any[cell_id] += 1
176
+ if in_nuc:
177
+ contains_in[cell_id] += 1
178
+ cells: list[dict] = []
179
+ for i in range(n_cells):
180
+ cid = i + 1
181
+ head_flag = bool(cn[i] > cfg.cellnos_thr)
182
+ cont_flag = bool(contains_in[cid] > 0)
183
+ if cfg.cellnos_rule == "head":
184
+ has_nos = head_flag
185
+ elif cfg.cellnos_rule == "contains":
186
+ has_nos = cont_flag
187
+ else:
188
+ has_nos = head_flag or cont_flag
189
+ sl = slices[i]
190
+ cells.append({
191
+ "id": cid,
192
+ "label": cell_label(bool(ab[i] > cfg.antibody_thr), has_nos),
193
+ "area_px": float(areas[i]),
194
+ "centroid_xy": [float(cents[i][1]), float(cents[i][0])],
195
+ "bbox_xyxy": [int(sl[1].start), int(sl[0].start), int(sl[1].stop), int(sl[0].stop)],
196
+ "p_antibody": float(ab[i]),
197
+ "p_nostril": float(cn[i]),
198
+ "n_puncta_inside": int(contains_in[cid]),
199
+ })
200
+ counts = {lab: 0 for lab in ALL_LABELS}
201
+ for c in cells:
202
+ counts[c["label"]] += 1
203
+ for n_ in nostrils:
204
+ counts[n_["label"]] += 1
205
+ return Result(cell_inst, nos_inst, cells, nostrils, counts, asdict(cfg))
206
+
207
+
208
+ def render_overlay(img: np.ndarray, res: Result, thickness: int = 1) -> np.ndarray:
209
+ """Draw coloured outlines (nuclei: inner boundary, puncta: outer ring) on a copy of ``img``."""
210
+ out = img.copy()
211
+ if res.cells:
212
+ lut = np.zeros((len(res.cells) + 1, 3), dtype=np.uint8)
213
+ for c in res.cells:
214
+ lut[c["id"]] = LABEL_COLORS[c["label"]]
215
+ b = find_boundaries(res.cell_inst, mode="inner")
216
+ if thickness > 1:
217
+ b = ndi.binary_dilation(b, iterations=thickness - 1) & (res.cell_inst > 0)
218
+ out[b] = lut[res.cell_inst[b]]
219
+ if res.nostrils:
220
+ lut = np.zeros((len(res.nostrils) + 1, 3), dtype=np.uint8)
221
+ for n_ in res.nostrils:
222
+ lut[n_["id"]] = LABEL_COLORS[n_["label"]]
223
+ dil = ndi.grey_dilation(res.nostril_inst, size=(3, 3))
224
+ ring = (dil > 0) & (res.nostril_inst == 0)
225
+ out[ring] = lut[dil[ring]]
226
+ return out
227
+
228
+
229
+ def result_to_json(res: Result) -> dict:
230
+ return {"counts": res.counts, "cells": res.cells, "nostrils": res.nostrils, "config": res.config}
evaluate.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Leave-one-image-out cross-validation with repeated, perturbed tests per image.
3
+
4
+ python evaluate.py cv --data-dir DATA --runs-dir runs/cv --iters 3000
5
+ python evaluate.py final --data-dir DATA --weights weights/cellquant.pt
6
+
7
+ ``cv`` trains one model per annotated image with that image held out (unless the
8
+ fold checkpoint already exists), then tests the held-out image many times:
9
+
10
+ * base run (no TTA), TTA x4 and TTA x8
11
+ * 8 dihedral variants (rotate/flip the image, predict, undo the transform)
12
+ * 6 photometric perturbations (per-channel gain, gamma, noise; fixed seeds)
13
+ * 3 identical repeats to prove determinism
14
+ * post-processing rule variants (how puncta get their type and how nuclei
15
+ get the "Nostril" attribute)
16
+
17
+ ``final`` reports the fit of one model on its own training images (NOT a
18
+ generalisation estimate) and writes overlays for un-annotated images.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+ import time
29
+
30
+ import numpy as np
31
+ from PIL import Image
32
+
33
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
+ from cellquant.annotations import discover, load_image, rasterize # noqa: E402
35
+ from cellquant.infer import Predictor # noqa: E402
36
+ from cellquant.labels import ALL_LABELS, CELL_LABELS, NOSTRIL_LABELS # noqa: E402
37
+ from cellquant.metrics import count_errors, evaluate_cells, evaluate_nostrils, micro_average # noqa: E402
38
+ from cellquant.postprocess import PostConfig, Result, analyze, render_overlay # noqa: E402
39
+
40
+ DIHEDRAL = [(k, f) for f in (False, True) for k in range(4)]
41
+
42
+
43
+ def fwd_np(a: np.ndarray, k: int, flip: bool) -> np.ndarray:
44
+ if flip:
45
+ a = a[..., ::-1]
46
+ return np.rot90(a, k, axes=(-2, -1))
47
+
48
+
49
+ def inv_np(a: np.ndarray, k: int, flip: bool) -> np.ndarray:
50
+ a = np.rot90(a, -k, axes=(-2, -1))
51
+ if flip:
52
+ a = a[..., ::-1]
53
+ return np.ascontiguousarray(a)
54
+
55
+
56
+ def photometric(img: np.ndarray, seed: int) -> np.ndarray:
57
+ rng = np.random.default_rng(seed)
58
+ x = img.astype(np.float32) / 255.0
59
+ gain = rng.uniform(0.7, 1.4, size=3).astype(np.float32)
60
+ gamma = float(rng.uniform(0.8, 1.25))
61
+ x = np.clip(x * gain, 0, 1) ** gamma
62
+ x = x + rng.normal(0, float(rng.uniform(0, 0.02)), size=x.shape).astype(np.float32)
63
+ return (np.clip(x, 0, 1) * 255).round().astype(np.uint8)
64
+
65
+
66
+ def fp_analysis(img: np.ndarray, R, res: Result) -> dict:
67
+ """Characterise unmatched predicted nuclei: how many overlap an annotated nucleus
68
+ at all (outline/split disagreement) versus none (extra detection), and how
69
+ bright/large they are compared with matched ones."""
70
+ from scipy import ndimage as ndi
71
+ from cellquant.metrics import match_by_iou
72
+ m = match_by_iou(R.cell_inst, res.cell_inst, 0.5)
73
+ mp = {p for _, p, _ in m}
74
+ mg = {g for g, _, _ in m}
75
+ n_pred = int(res.cell_inst.max())
76
+ if n_pred == 0:
77
+ return {"n_fp": 0}
78
+ ids = np.arange(1, n_pred + 1)
79
+ blue = ndi.mean(img[..., 2].astype(np.float32), res.cell_inst, ids)
80
+ areas = np.bincount(res.cell_inst.ravel())[1:]
81
+ gt_cover = ndi.mean((R.cell_inst > 0).astype(np.float32), res.cell_inst, ids)
82
+ fp = np.array([i not in mp for i in ids])
83
+ matched = ~fp
84
+ fn_ids = [g for g in range(1, R.n_cells + 1) if g not in mg]
85
+ fn_cover = [float((res.cell_inst[R.cell_inst == g] > 0).mean()) for g in fn_ids]
86
+ return {
87
+ "n_fp": int(fp.sum()),
88
+ "n_fp_overlapping_gt": int((fp & (gt_cover > 0.3)).sum()),
89
+ "n_fp_no_gt_overlap": int((fp & (gt_cover <= 0.3)).sum()),
90
+ "fp_median_blue": float(np.median(blue[fp])) if fp.any() else None,
91
+ "matched_median_blue": float(np.median(blue[matched])) if matched.any() else None,
92
+ "fp_median_area": float(np.median(areas[fp])) if fp.any() else None,
93
+ "matched_median_area": float(np.median(areas[matched])) if matched.any() else None,
94
+ "n_fn": len(fn_ids),
95
+ "n_fn_covered_by_prediction": int(sum(c > 0.3 for c in fn_cover)),
96
+ }
97
+
98
+
99
+ def matched_pairs(R, res: Result) -> list[dict]:
100
+ """Per matched nucleus (IoU >= 0.5): GT label and the network's attribute probabilities,
101
+ so that attribute thresholds can be studied across all held-out images."""
102
+ from cellquant.metrics import match_by_iou
103
+ by_id = {c["id"]: c for c in res.cells}
104
+ out = []
105
+ for g, p, iou in match_by_iou(R.cell_inst, res.cell_inst, 0.5):
106
+ c = by_id[p]
107
+ out.append({"gt": R.cell_labels[g], "pred": c["label"], "p_antibody": c["p_antibody"],
108
+ "p_nostril": c["p_nostril"], "n_puncta_inside": c["n_puncta_inside"], "iou": iou})
109
+ return out
110
+
111
+
112
+ def score(R, res: Result, gt_counts: dict, img: np.ndarray | None = None) -> dict:
113
+ ce = evaluate_cells(R.cell_inst, R.cell_labels, res.cell_inst, res.cell_labels)
114
+ ne = evaluate_nostrils(R.nostril_inst, R.nostril_labels, res.nostril_inst, res.nostril_labels)
115
+ return {
116
+ "cells": ce,
117
+ "nostrils": ne,
118
+ "counts": count_errors(gt_counts, res.counts),
119
+ "fp_analysis": fp_analysis(img, R, res) if img is not None else None,
120
+ "pairs": matched_pairs(R, res) if img is not None else None,
121
+ "summary": {
122
+ "cell_det_f1": ce["detection"]["f1"],
123
+ "cell_det_precision": ce["detection"]["precision"],
124
+ "cell_det_recall": ce["detection"]["recall"],
125
+ "cell_mean_iou": ce["detection"]["mean_iou_matched"],
126
+ "cell_det_f1_iou30": ce["detection"]["iou30"]["f1"],
127
+ "cell_det_f1_centroid": ce["detection"]["centroid"]["f1"],
128
+ "cell_attr_antibody": ce["attribute_accuracy"]["antibody"],
129
+ "cell_attr_nostril": ce["attribute_accuracy"]["nostril"],
130
+ "cell_attr_both": ce["attribute_accuracy"]["both"],
131
+ "nos_det_f1": ne["detection"]["f1"],
132
+ "nos_det_precision": ne["detection"]["precision"],
133
+ "nos_det_recall": ne["detection"]["recall"],
134
+ "nos_type_acc": ne["type_accuracy"],
135
+ **{f"f1[{c}]": ce["per_class"][c]["f1"] for c in CELL_LABELS},
136
+ **{f"f1[{c}]": ne["per_class"][c]["f1"] for c in NOSTRIL_LABELS},
137
+ },
138
+ }
139
+
140
+
141
+ def inst_hash(res: Result) -> str:
142
+ h = hashlib.sha256()
143
+ h.update(res.cell_inst.tobytes())
144
+ h.update(res.nostril_inst.tobytes())
145
+ h.update(json.dumps(res.counts, sort_keys=True).encode())
146
+ return h.hexdigest()[:16]
147
+
148
+
149
+ def test_image(weights: str, img: np.ndarray, R, gt_counts: dict, device: str, out_prefix: str | None = None) -> dict:
150
+ cfg = PostConfig()
151
+ out: dict = {"runs": {}}
152
+ p1 = Predictor(weights, device=device, tta=1)
153
+ # base + determinism
154
+ hashes = []
155
+ for i in range(3):
156
+ res, probs = p1.predict(img, cfg)
157
+ hashes.append(inst_hash(res))
158
+ base_res, base_probs = res, probs
159
+ out["deterministic"] = len(set(hashes)) == 1
160
+ out["hashes"] = hashes
161
+ out["runs"]["base"] = score(R, base_res, gt_counts, img)
162
+ out["base_counts"] = base_res.counts
163
+ if out_prefix:
164
+ Image.fromarray(render_overlay(img, base_res)).save(out_prefix + "_pred.png")
165
+ # TTA
166
+ for tta in (4, 8):
167
+ res, _ = Predictor(weights, device=device, tta=tta).predict(img, cfg)
168
+ out["runs"][f"tta{tta}"] = score(R, res, gt_counts)
169
+ # dihedral robustness
170
+ for k, f in DIHEDRAL:
171
+ timg = np.ascontiguousarray(fwd_np(np.moveaxis(img, -1, 0), k, f))
172
+ timg = np.moveaxis(timg, 0, -1)
173
+ probs = p1.probs(timg)
174
+ probs = {kk: inv_np(v, k, f) for kk, v in probs.items()}
175
+ res = analyze(probs, cfg)
176
+ out["runs"][f"dihedral_k{k}_f{int(f)}"] = score(R, res, gt_counts)
177
+ # photometric perturbations
178
+ for seed in range(6):
179
+ res, _ = p1.predict(photometric(img, 1000 + seed), cfg)
180
+ out["runs"][f"photometric_{seed}"] = score(R, res, gt_counts)
181
+ # post-processing rule variants (same probabilities as the base run)
182
+ for nr in ("head", "geometric"):
183
+ for cr in ("head", "contains", "either"):
184
+ res = analyze(base_probs, PostConfig(nostril_rule=nr, cellnos_rule=cr))
185
+ out["runs"][f"rule_nos={nr}_cell={cr}"] = score(R, res, gt_counts)
186
+ return out
187
+
188
+
189
+ def aggregate(per_key: dict[str, dict]) -> dict:
190
+ keys = sorted(per_key)
191
+ agg: dict = {"images": keys}
192
+ # micro-averaged per-class scores on the base run
193
+ cells = [per_key[k]["runs"]["base"]["cells"]["per_class"] for k in keys]
194
+ nos = [per_key[k]["runs"]["base"]["nostrils"]["per_class"] for k in keys]
195
+ agg["micro_base"] = {**micro_average(cells, CELL_LABELS), **micro_average(nos, NOSTRIL_LABELS)}
196
+ for variant in ("tta4", "tta8"):
197
+ cells = [per_key[k]["runs"][variant]["cells"]["per_class"] for k in keys]
198
+ nos = [per_key[k]["runs"][variant]["nostrils"]["per_class"] for k in keys]
199
+ agg[f"micro_{variant}"] = {**micro_average(cells, CELL_LABELS), **micro_average(nos, NOSTRIL_LABELS)}
200
+ # detection totals
201
+ for variant in ("base", "tta4", "tta8"):
202
+ tp = sum(per_key[k]["runs"][variant]["cells"]["detection"]["tp"] for k in keys)
203
+ fp = sum(per_key[k]["runs"][variant]["cells"]["detection"]["fp"] for k in keys)
204
+ fn = sum(per_key[k]["runs"][variant]["cells"]["detection"]["fn"] for k in keys)
205
+ ntp = sum(per_key[k]["runs"][variant]["nostrils"]["detection"]["tp"] for k in keys)
206
+ nfp = sum(per_key[k]["runs"][variant]["nostrils"]["detection"]["fp"] for k in keys)
207
+ nfn = sum(per_key[k]["runs"][variant]["nostrils"]["detection"]["fn"] for k in keys)
208
+ from cellquant.metrics import prf
209
+ agg[f"detection_{variant}"] = {"cells": prf(tp, fp, fn), "nostrils": prf(ntp, nfp, nfn)}
210
+ for loose in ("iou30", "centroid"):
211
+ ltp = sum(per_key[k]["runs"][variant]["cells"]["detection"][loose]["tp"] for k in keys)
212
+ lfp = sum(per_key[k]["runs"][variant]["cells"]["detection"][loose]["fp"] for k in keys)
213
+ lfn = sum(per_key[k]["runs"][variant]["cells"]["detection"][loose]["fn"] for k in keys)
214
+ agg[f"detection_{variant}"][f"cells_{loose}"] = prf(ltp, lfp, lfn)
215
+ # per-image summary table + robustness (mean/std over dihedral & photometric runs)
216
+ summ = {}
217
+ for k in keys:
218
+ runs = per_key[k]["runs"]
219
+ rob = [v["summary"] for n, v in runs.items() if n.startswith(("dihedral", "photometric"))]
220
+ rob_stats = {}
221
+ for m in ("cell_det_f1", "cell_attr_both", "nos_det_f1", "nos_type_acc"):
222
+ vals = np.array([r[m] for r in rob])
223
+ rob_stats[m] = {"mean": float(vals.mean()), "std": float(vals.std()), "min": float(vals.min()), "max": float(vals.max()), "n": int(len(vals))}
224
+ summ[k] = {"base": runs["base"]["summary"], "tta4": runs["tta4"]["summary"], "tta8": runs["tta8"]["summary"],
225
+ "robustness": rob_stats, "deterministic": per_key[k]["deterministic"],
226
+ "counts": runs["base"]["counts"], "fp_analysis": runs["base"].get("fp_analysis")}
227
+ agg["per_image"] = summ
228
+ # rule variants: mean cell_attr_both and nostril type acc across images
229
+ rules = {}
230
+ for n in per_key[keys[0]]["runs"]:
231
+ if n.startswith("rule_"):
232
+ nm = [per_key[k]["runs"][n]["cells"]["attribute_accuracy"]["n_matched"] for k in keys]
233
+ ok = [per_key[k]["runs"][n]["cells"]["attribute_accuracy"]["both"] * m for k, m in zip(keys, nm)]
234
+ ntp = [per_key[k]["runs"][n]["nostrils"]["detection"]["tp"] for k in keys]
235
+ tok = [per_key[k]["runs"][n]["nostrils"]["type_accuracy"] * t for k, t in zip(keys, ntp)]
236
+ rules[n] = {
237
+ "pooled_cell_attr_both": float(sum(ok) / max(1, sum(nm))),
238
+ "pooled_nos_type_acc": float(sum(tok) / max(1, sum(ntp))),
239
+ "micro_f1": {**micro_average([per_key[k]["runs"][n]["cells"]["per_class"] for k in keys], CELL_LABELS),
240
+ **micro_average([per_key[k]["runs"][n]["nostrils"]["per_class"] for k in keys], NOSTRIL_LABELS)},
241
+ }
242
+ agg["rule_variants"] = rules
243
+ agg["all_deterministic"] = all(per_key[k]["deterministic"] for k in keys)
244
+ # antibody threshold sweep on all held-out matched nuclei (base run)
245
+ pairs = [p for k in keys for p in (per_key[k]["runs"]["base"].get("pairs") or [])]
246
+ if pairs:
247
+ from cellquant.labels import cell_attributes
248
+ y = np.array([cell_attributes(p["gt"])[0] for p in pairs]) # True = antibody +
249
+ s_ = np.array([p["p_antibody"] for p in pairs])
250
+ sweep = []
251
+ for thr in np.round(np.arange(0.3, 0.91, 0.05), 2):
252
+ pred = s_ > thr
253
+ tp_neg = int((~pred & ~y).sum()); fp_neg = int((~pred & y).sum()); fn_neg = int((pred & ~y).sum())
254
+ from cellquant.metrics import prf
255
+ r = prf(tp_neg, fp_neg, fn_neg)
256
+ sweep.append({"thr": float(thr), "neg_precision": r["precision"], "neg_recall": r["recall"], "neg_f1": r["f1"],
257
+ "accuracy": float((pred == y).mean())})
258
+ agg["antibody_threshold_sweep"] = {"n_matched": int(len(y)), "n_negative": int((~y).sum()), "sweep": sweep}
259
+ return agg
260
+
261
+
262
+ def fmt_pct(x: float) -> str:
263
+ return f"{100*x:5.1f}%"
264
+
265
+
266
+ def write_report(agg: dict, path: str, title: str, note: str = "") -> None:
267
+ L = [f"# {title}", ""]
268
+ if note:
269
+ L += [note, ""]
270
+ L += ["## Micro-averaged per-class scores (base run, no TTA)", "",
271
+ "| Label | GT | Pred | TP | FP | FN | Precision | Recall | F1 |", "|---|---:|---:|---:|---:|---:|---:|---:|---:|"]
272
+ for lab in ALL_LABELS:
273
+ r = agg["micro_base"][lab]
274
+ L.append(f"| {lab} | {r['gt_count']} | {r['pred_count']} | {r['tp']} | {r['fp']} | {r['fn']} | {fmt_pct(r['precision'])} | {fmt_pct(r['recall'])} | {fmt_pct(r['f1'])} |")
275
+ L += ["", "Class-agnostic detection (IoU >= 0.5 for nuclei; centroid distance for puncta):", ""]
276
+ for v in ("base", "tta4", "tta8"):
277
+ c, n = agg[f"detection_{v}"]["cells"], agg[f"detection_{v}"]["nostrils"]
278
+ c3, cc = agg[f"detection_{v}"]["cells_iou30"], agg[f"detection_{v}"]["cells_centroid"]
279
+ L.append(f"- {v}: nuclei P {fmt_pct(c['precision'])} R {fmt_pct(c['recall'])} F1 {fmt_pct(c['f1'])} (IoU≥0.5); F1 {fmt_pct(c3['f1'])} (IoU≥0.3); F1 {fmt_pct(cc['f1'])} (centroid-in-nucleus) | puncta P {fmt_pct(n['precision'])} R {fmt_pct(n['recall'])} F1 {fmt_pct(n['f1'])}")
280
+ L += ["", "## Per-image results (each image held out of training)", "",
281
+ "| Image | Nuclei F1 (IoU≥0.5) | F1 (IoU≥0.3) | F1 (centroid) | mean IoU | Antibody acc | Nostril-attr acc | Puncta F1 | Puncta type acc | Deterministic |", "|---|---:|---:|---:|---:|---:|---:|---:|---:|---|"]
282
+ for k, s in agg["per_image"].items():
283
+ b = s["base"]
284
+ L.append(f"| {k} | {fmt_pct(b['cell_det_f1'])} | {fmt_pct(b['cell_det_f1_iou30'])} | {fmt_pct(b['cell_det_f1_centroid'])} | {b['cell_mean_iou']:.3f} | {fmt_pct(b['cell_attr_antibody'])} | {fmt_pct(b['cell_attr_nostril'])} | {fmt_pct(b['nos_det_f1'])} | {fmt_pct(b['nos_type_acc'])} | {'yes' if s['deterministic'] else 'NO'} |")
285
+ L += ["", "## Repeated tests per image (8 rotations/flips + 6 photometric perturbations = 14 runs)", "",
286
+ "| Image | Nuclei F1 mean ± sd [min, max] | Attr acc mean ± sd | Puncta F1 mean ± sd [min, max] |", "|---|---|---|---|"]
287
+ for k, s in agg["per_image"].items():
288
+ r = s["robustness"]
289
+ a, b, c = r["cell_det_f1"], r["cell_attr_both"], r["nos_det_f1"]
290
+ L.append(f"| {k} | {100*a['mean']:.1f} ± {100*a['std']:.1f} [{100*a['min']:.1f}, {100*a['max']:.1f}] | {100*b['mean']:.1f} ± {100*b['std']:.1f} | {100*c['mean']:.1f} ± {100*c['std']:.1f} [{100*c['min']:.1f}, {100*c['max']:.1f}] |")
291
+ L += ["", "## Why nuclei F1 is below 100 %: unmatched predictions and misses (base run)", "",
292
+ "| Image | FP total | FP overlapping a GT nucleus | FP with no GT overlap | FP / matched median blue | FP / matched median area | FN total | FN covered by a prediction |", "|---|---:|---:|---:|---|---|---:|---:|"]
293
+ for k, s in agg["per_image"].items():
294
+ f = s.get("fp_analysis") or {}
295
+ if not f or f.get("n_fp") is None:
296
+ continue
297
+ L.append(f"| {k} | {f['n_fp']} | {f.get('n_fp_overlapping_gt', 0)} | {f.get('n_fp_no_gt_overlap', 0)} | {f.get('fp_median_blue') or 0:.0f} / {f.get('matched_median_blue') or 0:.0f} | {f.get('fp_median_area') or 0:.0f} / {f.get('matched_median_area') or 0:.0f} | {f.get('n_fn', 0)} | {f.get('n_fn_covered_by_prediction', 0)} |")
298
+ L += ["", "## Counts per image (ground truth / predicted, base run)", "",
299
+ "| Image | " + " | ".join(ALL_LABELS) + " |", "|---|" + "---|" * len(ALL_LABELS)]
300
+ for k, s in agg["per_image"].items():
301
+ L.append(f"| {k} | " + " | ".join(f"{s['counts'][lab]['gt']} / {s['counts'][lab]['pred']}" for lab in ALL_LABELS) + " |")
302
+ L += ["", "## Post-processing rule variants (micro F1 across held-out images; accuracies pooled over matched objects)", "",
303
+ "| Variant | " + " | ".join(ALL_LABELS) + " | attr acc (matched nuclei) | puncta type acc (matched puncta) |", "|---|" + "---|" * (len(ALL_LABELS) + 2)]
304
+ for n, r in agg["rule_variants"].items():
305
+ L.append(f"| {n} | " + " | ".join(fmt_pct(r['micro_f1'][lab]['f1']) for lab in ALL_LABELS) + f" | {fmt_pct(r['pooled_cell_attr_both'])} | {fmt_pct(r['pooled_nos_type_acc'])} |")
306
+ if agg.get("antibody_threshold_sweep"):
307
+ sw = agg["antibody_threshold_sweep"]
308
+ L += ["", f"## Antibody threshold sweep on the {sw['n_matched']} matched held-out nuclei ({sw['n_negative']} antibody-negative)", "",
309
+ "| threshold | Antibody − precision | Antibody − recall | Antibody − F1 | overall accuracy |", "|---:|---:|---:|---:|---:|"]
310
+ for r in sw["sweep"]:
311
+ L.append(f"| {r['thr']:.2f} | {fmt_pct(r['neg_precision'])} | {fmt_pct(r['neg_recall'])} | {fmt_pct(r['neg_f1'])} | {fmt_pct(r['accuracy'])} |")
312
+ L.append("")
313
+ with open(path, "w") as f:
314
+ f.write("\n".join(L))
315
+
316
+
317
+ def cmd_cv(a):
318
+ samples, _ = discover(a.data_dir)
319
+ keys = a.keys.split(",") if a.keys else [s.key for s in samples]
320
+ os.makedirs(a.runs_dir, exist_ok=True)
321
+ per_key = {}
322
+ for s in samples:
323
+ if s.key not in keys:
324
+ continue
325
+ ck = os.path.join(a.runs_dir, f"fold_{s.key}.pt")
326
+ if not os.path.exists(ck) or a.retrain:
327
+ cmd = [sys.executable, "-I", os.path.join(os.path.dirname(__file__), "train.py"), "--data-dir", a.data_dir,
328
+ "--holdout", s.key, "--iters", str(a.iters), "--out", ck, "--seed", str(a.seed), "--device", a.device]
329
+ print("TRAIN:", " ".join(cmd), flush=True)
330
+ with open(os.path.join(a.runs_dir, f"train_{s.key}.log"), "w") as log:
331
+ subprocess.run(cmd, check=True, stdout=log, stderr=subprocess.STDOUT)
332
+ from cellquant.model import load_checkpoint
333
+ _, meta = load_checkpoint(ck)
334
+ if meta.get("holdout") != s.key or s.key in meta.get("train_keys", []):
335
+ raise SystemExit(f"{ck}: trained with holdout={meta.get('holdout')!r}, train_keys={meta.get('train_keys')}; "
336
+ f"refusing to score {s.key} with it (delete the file or use --retrain)")
337
+ eval_json = os.path.join(a.runs_dir, f"{s.key}_eval.json")
338
+ if a.reuse_eval and os.path.exists(eval_json) and os.path.getmtime(eval_json) > os.path.getmtime(ck):
339
+ with open(eval_json) as f:
340
+ per_key[s.key] = json.load(f)
341
+ b = per_key[s.key]["runs"]["base"]["summary"]
342
+ print(f"[{s.key}] reused {os.path.basename(eval_json)}: nuclei F1 {b['cell_det_f1']:.3f} attr {b['cell_attr_both']:.3f} puncta F1 {b['nos_det_f1']:.3f}", flush=True)
343
+ continue
344
+ img = load_image(s.image_path)
345
+ R = rasterize(s.annotation, img.shape[0], img.shape[1])
346
+ t = time.time()
347
+ res = test_image(ck, img, R, s.annotation.counts(), a.device, out_prefix=os.path.join(a.runs_dir, s.key))
348
+ res["train_keys"] = list(meta.get("train_keys", []))
349
+ res["checkpoint"] = os.path.basename(ck)
350
+ per_key[s.key] = res
351
+ b = res["runs"]["base"]["summary"]
352
+ print(f"[{s.key}] held-out: nuclei F1 {b['cell_det_f1']:.3f} attr {b['cell_attr_both']:.3f} puncta F1 {b['nos_det_f1']:.3f} type {b['nos_type_acc']:.3f} deterministic={res['deterministic']} ({time.time()-t:.0f}s)", flush=True)
353
+ with open(os.path.join(a.runs_dir, f"{s.key}_eval.json"), "w") as f:
354
+ json.dump(res, f, indent=1)
355
+ agg = aggregate(per_key)
356
+ totals = {lab: sum(s.annotation.count(lab) for s in samples) for lab in ALL_LABELS}
357
+ agg["dataset_totals"] = totals
358
+ with open(os.path.join(a.runs_dir, "cv_results.json"), "w") as f:
359
+ json.dump({"aggregate": agg, "per_image": per_key}, f, indent=1)
360
+ n_nuc = sum(totals[c] for c in CELL_LABELS)
361
+ n_pun = sum(totals[c] for c in NOSTRIL_LABELS)
362
+ write_report(agg, os.path.join(a.runs_dir, "cv_report.md"), "Leave-one-image-out cross-validation",
363
+ f"{len(per_key)} folds over {len(samples)} annotated images ({n_nuc} nucleus and {n_pun} punctum outlines); "
364
+ f"each image scored by a model whose checkpoint records that image as held out. Iterations per fold: {a.iters}.")
365
+ print("wrote", os.path.join(a.runs_dir, "cv_report.md"))
366
+
367
+
368
+ def cmd_final(a):
369
+ samples, unann = discover(a.data_dir)
370
+ os.makedirs(a.out_dir, exist_ok=True)
371
+ pred = Predictor(a.weights, device=a.device, tta=1)
372
+ rows = {}
373
+ for s in samples:
374
+ img = load_image(s.image_path)
375
+ R = rasterize(s.annotation, img.shape[0], img.shape[1])
376
+ res, _ = pred.predict(img, PostConfig())
377
+ rows[s.key] = score(R, res, s.annotation.counts())
378
+ Image.fromarray(render_overlay(img, res)).save(os.path.join(a.out_dir, f"{s.key}_pred.png"))
379
+ b = rows[s.key]["summary"]
380
+ print(f"[{s.key}] TRAINING-SET fit: nuclei F1 {b['cell_det_f1']:.3f} attr {b['cell_attr_both']:.3f} puncta F1 {b['nos_det_f1']:.3f}")
381
+ for p in unann:
382
+ img = load_image(p)
383
+ res, _ = pred.predict(img, PostConfig())
384
+ key = os.path.basename(p).split(" ")[0]
385
+ Image.fromarray(render_overlay(img, res)).save(os.path.join(a.out_dir, f"{key}_unlabeled_pred.png"))
386
+ rows[f"{key} (no annotation)"] = {"counts": res.counts}
387
+ print(f"[{key}] un-annotated image counts: {res.counts}")
388
+ with open(os.path.join(a.out_dir, "final_fit.json"), "w") as f:
389
+ json.dump(rows, f, indent=1)
390
+
391
+
392
+ def main(argv=None):
393
+ ap = argparse.ArgumentParser()
394
+ sub = ap.add_subparsers(dest="cmd", required=True)
395
+ c = sub.add_parser("cv")
396
+ c.add_argument("--data-dir", required=True)
397
+ c.add_argument("--runs-dir", default="runs/cv")
398
+ c.add_argument("--iters", type=int, default=3000)
399
+ c.add_argument("--keys", default="")
400
+ c.add_argument("--seed", type=int, default=0)
401
+ c.add_argument("--device", default="auto")
402
+ c.add_argument("--retrain", action="store_true")
403
+ c.add_argument("--reuse-eval", action="store_true", help="reuse <key>_eval.json files newer than their checkpoint")
404
+ f = sub.add_parser("final")
405
+ f.add_argument("--data-dir", required=True)
406
+ f.add_argument("--weights", required=True)
407
+ f.add_argument("--out-dir", default="reports/final")
408
+ f.add_argument("--device", default="auto")
409
+ a = ap.parse_args(argv)
410
+ {"cv": cmd_cv, "final": cmd_final}[a.cmd](a)
411
+
412
+
413
+ if __name__ == "__main__":
414
+ main()
examples/F100_crop_512.png ADDED

Git LFS Details

  • SHA256: 52a685e4f4602c8af7157bc6a1125a02a13912e5aa9f3fa0018961a58f767859
  • Pointer size: 131 Bytes
  • Size of remote file: 280 kB
reports/SUMMARY.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CellQuant: validation summary
2
+
3
+ Data: 9 annotated 1024×1024 images, 1569 nucleus outlines and 1057 punctum outlines.
4
+ Protocol: leave-one-image-out cross-validation (one model per image, 3000 iterations each, held-out image never seen in training), then a final model trained on all images with the same recipe. Each held-out image was tested 23 times: 3 identical runs (determinism), no-TTA / 4× / 8× test-time augmentation, 8 rotations and flips, 6 photometric perturbations, and 6 post-processing rule variants. The only choice made on held-out data is the rule that gives a nucleus the Nostril attribute: of three candidate rules, 'contains an in-nucleus punctum' (the annotation protocol) scored best and is the default; see the rule-variant table in cv_report.md.
5
+
6
+ ## Headline numbers (micro-averaged over all held-out images, no TTA)
7
+
8
+ | Measure | Precision | Recall | F1 |
9
+ |---|---:|---:|---:|
10
+ | Nucleus detection, IoU ≥ 0.5 | 77.5% | 78.4% | 77.9% |
11
+ | Nucleus detection, IoU ≥ 0.3 | 85.6% | 86.7% | 86.2% |
12
+ | Nucleus detection, centroid inside GT nucleus | 87.0% | 88.0% | 87.5% |
13
+ | Punctum detection (centroid ≤ 6 px) | 77.3% | 82.8% | 79.9% |
14
+ | Nucleus detection, IoU ≥ 0.5, 8× TTA | 78.1% | 78.9% | 78.5% |
15
+
16
+ Per-class F1 (a true positive needs a matched object **and** the right label):
17
+
18
+ | Label | GT | Pred | F1 |
19
+ |---|---:|---:|---:|
20
+ | Cell, Antibody + | 1061 | 1179 | 72.5% |
21
+ | Cell, Antibody - | 105 | 39 | 31.9% |
22
+ | Cell, Nostril, Antibody + | 398 | 370 | 71.6% |
23
+ | Cell, Nostril, Antibody - | 5 | 0 | 0.0% |
24
+ | Nostril in Nucleus | 525 | 529 | 78.9% |
25
+ | Nostril External of Nucleus | 532 | 603 | 70.3% |
26
+
27
+ ## Per held-out image
28
+
29
+ | Image | Nuclei F1 | Antibody acc | Nostril-attr acc | Puncta F1 | Nuclei F1 over 14 perturbed runs (mean ± sd) | Deterministic |
30
+ |---|---:|---:|---:|---:|---|---|
31
+ | C100 | 81.2% | 99.3% | 98.6% | 74.4% | 82.5 ± 1.0 | yes |
32
+ | C102 | 90.6% | 100.0% | 90.8% | 76.3% | 89.9 ± 1.8 | yes |
33
+ | C110 | 78.5% | 100.0% | 91.9% | 84.3% | 77.5 ± 1.1 | yes |
34
+ | C112 | 75.0% | 96.6% | 97.4% | 75.8% | 73.6 ± 1.5 | yes |
35
+ | F100 | 75.1% | 97.8% | 97.1% | 85.2% | 72.2 ± 1.3 | yes |
36
+ | F110 | 72.0% | 96.0% | 94.9% | 78.7% | 71.7 ± 1.9 | yes |
37
+ | F113 | 65.4% | 100.0% | 97.1% | 85.2% | 65.6 ± 2.1 | yes |
38
+ | F114 | 78.8% | 93.3% | 94.4% | 67.5% | 77.8 ± 1.7 | yes |
39
+ | F99 | 84.4% | 77.5% | 99.5% | 71.4% | 85.3 ± 1.2 | yes |
40
+
41
+ ## Final model (trained on all images) – fit on its own training images, NOT a generalisation estimate
42
+
43
+ | Image | Nuclei F1 | Attribute acc | Puncta F1 |
44
+ |---|---:|---:|---:|
45
+ | C100 | 86.7% | 98.7% | 82.1% |
46
+ | C102 | 93.3% | 94.4% | 84.6% |
47
+ | C110 | 88.6% | 95.7% | 85.0% |
48
+ | C112 | 83.8% | 99.2% | 86.2% |
49
+ | F100 | 87.2% | 96.2% | 90.7% |
50
+ | F110 | 91.7% | 94.8% | 87.2% |
51
+ | F113 | 83.7% | 97.7% | 87.1% |
52
+ | F114 | 88.3% | 93.4% | 83.1% |
53
+ | F99 | 89.7% | 91.2% | 75.7% |
54
+
55
+ ## Antibody − threshold (held-out matched nuclei: 1230, of which 87 negative)
56
+
57
+ Default threshold 0.5. Best Antibody − F1 in the sweep: 52.3% at threshold 0.85 (precision 62.9%, recall 44.8%); the full curve is in cv_report.md. The default was kept because the class is dominated by a single image (F99 holds 62 of the 102 negatives).
58
+
59
+ ## What limits the scores
60
+
61
+ * **Touching nuclei are sometimes merged** into one outline (worst on F113); this is the main source of IoU-0.5 misses. The marker/threshold sweep (`runs/cv/postprocess_sweep.json`) is flat, so it is a network-border issue, not a post-processing one.
62
+ * **Annotation completeness**: most unmatched predictions overlap an annotated nucleus (outline disagreement) and the rest are dimmer, smaller objects the annotator skipped; many faint red dots were likewise not annotated, which caps external-punctum precision.
63
+ * **Antibody − is rare** (102 of 1569 nuclei, 62 in one image), and **Cell, Nostril, Antibody −** has five examples; their scores are not reliable.
64
+ * Scores are from nine images of one experiment (35-day spinal cord, Iba1/TMEM119). New staining batches or magnifications need a check against a few annotated images.
65
+
66
+ ## Reproduce
67
+
68
+ ```bash
69
+ python evaluate.py cv --data-dir <folder with TIFs and CVAT zips> --runs-dir runs/cv --iters 3000
70
+ python evaluate.py final --data-dir <folder> --weights weights/cellquant.pt
71
+ python scripts/build_summary.py
72
+ ```
reports/cv/cv_report.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Leave-one-image-out cross-validation
2
+
3
+ 9 folds over 9 annotated images (1569 nucleus and 1057 punctum outlines); each image scored by a model whose checkpoint records that image as held out. Iterations per fold: 3000.
4
+
5
+ ## Micro-averaged per-class scores (base run, no TTA)
6
+
7
+ | Label | GT | Pred | TP | FP | FN | Precision | Recall | F1 |
8
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|
9
+ | Cell, Antibody + | 1061 | 1179 | 812 | 367 | 249 | 68.9% | 76.5% | 72.5% |
10
+ | Cell, Antibody - | 105 | 39 | 23 | 16 | 82 | 59.0% | 21.9% | 31.9% |
11
+ | Cell, Nostril, Antibody + | 398 | 370 | 275 | 95 | 123 | 74.3% | 69.1% | 71.6% |
12
+ | Cell, Nostril, Antibody - | 5 | 0 | 0 | 0 | 5 | 0.0% | 0.0% | 0.0% |
13
+ | Nostril in Nucleus | 525 | 529 | 416 | 113 | 109 | 78.6% | 79.2% | 78.9% |
14
+ | Nostril External of Nucleus | 532 | 603 | 399 | 204 | 133 | 66.2% | 75.0% | 70.3% |
15
+
16
+ Class-agnostic detection (IoU >= 0.5 for nuclei; centroid distance for puncta):
17
+
18
+ - base: nuclei P 77.5% R 78.4% F1 77.9% (IoU≥0.5); F1 86.2% (IoU≥0.3); F1 87.5% (centroid-in-nucleus) | puncta P 77.3% R 82.8% F1 79.9%
19
+ - tta4: nuclei P 78.0% R 78.3% F1 78.2% (IoU≥0.5); F1 86.7% (IoU≥0.3); F1 88.3% (centroid-in-nucleus) | puncta P 77.8% R 84.2% F1 80.9%
20
+ - tta8: nuclei P 78.1% R 78.9% F1 78.5% (IoU≥0.5); F1 86.8% (IoU≥0.3); F1 88.3% (centroid-in-nucleus) | puncta P 78.5% R 84.5% F1 81.4%
21
+
22
+ ## Per-image results (each image held out of training)
23
+
24
+ | Image | Nuclei F1 (IoU≥0.5) | F1 (IoU≥0.3) | F1 (centroid) | mean IoU | Antibody acc | Nostril-attr acc | Puncta F1 | Puncta type acc | Deterministic |
25
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|---|
26
+ | C100 | 81.2% | 88.4% | 89.5% | 0.783 | 99.3% | 98.6% | 74.4% | 100.0% | yes |
27
+ | C102 | 90.6% | 91.7% | 92.7% | 0.810 | 100.0% | 90.8% | 76.3% | 95.0% | yes |
28
+ | C110 | 78.5% | 85.4% | 88.1% | 0.752 | 100.0% | 91.9% | 84.3% | 92.4% | yes |
29
+ | C112 | 75.0% | 85.9% | 87.2% | 0.727 | 96.6% | 97.4% | 75.8% | 88.0% | yes |
30
+ | F100 | 75.1% | 84.9% | 84.9% | 0.761 | 97.8% | 97.1% | 85.2% | 97.0% | yes |
31
+ | F110 | 72.0% | 80.0% | 85.1% | 0.788 | 96.0% | 94.9% | 78.7% | 90.8% | yes |
32
+ | F113 | 65.4% | 80.1% | 83.3% | 0.732 | 100.0% | 97.1% | 85.2% | 80.8% | yes |
33
+ | F114 | 78.8% | 85.3% | 84.5% | 0.794 | 93.3% | 94.4% | 67.5% | 95.0% | yes |
34
+ | F99 | 84.4% | 91.9% | 92.3% | 0.802 | 77.5% | 99.5% | 71.4% | 93.3% | yes |
35
+
36
+ ## Repeated tests per image (8 rotations/flips + 6 photometric perturbations = 14 runs)
37
+
38
+ | Image | Nuclei F1 mean ± sd [min, max] | Attr acc mean ± sd | Puncta F1 mean ± sd [min, max] |
39
+ |---|---|---|---|
40
+ | C100 | 82.5 ± 1.0 [81.2, 84.9] | 98.2 ± 0.8 | 74.7 ± 4.3 [68.1, 81.1] |
41
+ | C102 | 89.9 ± 1.8 [87.0, 93.7] | 91.9 ± 2.3 | 77.4 ± 1.9 [74.4, 80.9] |
42
+ | C110 | 77.5 ± 1.1 [75.3, 79.3] | 91.1 ± 1.7 | 84.1 ± 0.6 [83.0, 85.5] |
43
+ | C112 | 73.6 ± 1.5 [70.5, 75.2] | 94.3 ± 0.8 | 77.2 ± 1.5 [75.8, 80.6] |
44
+ | F100 | 72.2 ± 1.3 [69.1, 75.1] | 94.3 ± 2.0 | 85.7 ± 1.6 [82.1, 88.2] |
45
+ | F110 | 71.7 ± 1.9 [69.1, 75.7] | 87.9 ± 1.6 | 78.8 ± 3.1 [73.2, 82.8] |
46
+ | F113 | 65.6 ± 2.1 [62.1, 69.9] | 96.2 ± 0.8 | 81.9 ± 3.1 [77.2, 87.1] |
47
+ | F114 | 77.8 ± 1.7 [74.7, 80.9] | 88.4 ± 1.0 | 68.9 ± 2.5 [64.8, 73.5] |
48
+ | F99 | 85.3 ± 1.2 [82.9, 87.5] | 76.9 ± 1.5 | 75.3 ± 7.2 [57.1, 85.7] |
49
+
50
+ ## Why nuclei F1 is below 100 %: unmatched predictions and misses (base run)
51
+
52
+ | Image | FP total | FP overlapping a GT nucleus | FP with no GT overlap | FP / matched median blue | FP / matched median area | FN total | FN covered by a prediction |
53
+ |---|---:|---:|---:|---|---|---:|---:|
54
+ | C100 | 37 | 27 | 10 | 48 / 76 | 264 / 642 | 31 | 28 |
55
+ | C102 | 14 | 3 | 11 | 21 / 52 | 332 / 807 | 4 | 1 |
56
+ | C110 | 45 | 30 | 15 | 21 / 43 | 282 / 634 | 36 | 25 |
57
+ | C112 | 49 | 40 | 9 | 24 / 28 | 354 / 664 | 29 | 24 |
58
+ | F100 | 42 | 24 | 18 | 17 / 33 | 601 / 744 | 49 | 46 |
59
+ | F110 | 48 | 41 | 7 | 46 / 61 | 166 / 676 | 29 | 17 |
60
+ | F113 | 47 | 31 | 16 | 10 / 24 | 713 / 702 | 61 | 55 |
61
+ | F114 | 35 | 24 | 11 | 73 / 89 | 702 / 652 | 62 | 53 |
62
+ | F99 | 41 | 34 | 7 | 36 / 56 | 435 / 595 | 38 | 35 |
63
+
64
+ ## Counts per image (ground truth / predicted, base run)
65
+
66
+ | Image | Cell, Antibody + | Cell, Antibody - | Cell, Nostril, Antibody + | Cell, Nostril, Antibody - | Nostril in Nucleus | Nostril External of Nucleus |
67
+ |---|---|---|---|---|---|---|
68
+ | C100 | 165 / 167 | 0 / 4 | 13 / 13 | 0 / 0 | 14 / 15 | 6 / 8 |
69
+ | C102 | 41 / 52 | 0 / 0 | 50 / 49 | 0 / 0 | 60 / 73 | 74 / 105 |
70
+ | C110 | 61 / 84 | 1 / 1 | 122 / 108 | 0 / 0 | 169 / 165 | 207 / 244 |
71
+ | C112 | 124 / 141 | 5 / 1 | 17 / 24 | 0 / 0 | 23 / 27 | 2 / 14 |
72
+ | F100 | 87 / 97 | 6 / 6 | 93 / 76 | 0 / 0 | 118 / 122 | 44 / 26 |
73
+ | F110 | 87 / 119 | 7 / 2 | 33 / 26 | 1 / 0 | 57 / 38 | 137 / 101 |
74
+ | F113 | 138 / 127 | 3 / 1 | 22 / 21 | 0 / 0 | 26 / 23 | 8 / 4 |
75
+ | F114 | 175 / 155 | 21 / 11 | 42 / 49 | 4 / 0 | 52 / 62 | 38 / 85 |
76
+ | F99 | 183 / 237 | 62 / 13 | 6 / 4 | 0 / 0 | 6 / 4 | 16 / 16 |
77
+
78
+ ## Post-processing rule variants (micro F1 across held-out images; accuracies pooled over matched objects)
79
+
80
+ | Variant | Cell, Antibody + | Cell, Antibody - | Cell, Nostril, Antibody + | Cell, Nostril, Antibody - | Nostril in Nucleus | Nostril External of Nucleus | attr acc (matched nuclei) | puncta type acc (matched puncta) |
81
+ |---|---|---|---|---|---|---|---|---|
82
+ | rule_nos=head_cell=head | 72.0% | 31.9% | 68.5% | 0.0% | 78.9% | 70.3% | 88.8% | 93.1% |
83
+ | rule_nos=head_cell=contains | 72.5% | 31.9% | 71.6% | 0.0% | 78.9% | 70.3% | 90.2% | 93.1% |
84
+ | rule_nos=head_cell=either | 72.4% | 31.9% | 69.3% | 0.0% | 78.9% | 70.3% | 89.4% | 93.1% |
85
+ | rule_nos=geometric_cell=head | 72.0% | 31.9% | 68.5% | 0.0% | 78.8% | 70.3% | 88.8% | 93.0% |
86
+ | rule_nos=geometric_cell=contains | 72.5% | 31.9% | 71.6% | 0.0% | 78.8% | 70.3% | 90.2% | 93.0% |
87
+ | rule_nos=geometric_cell=either | 72.4% | 31.9% | 69.3% | 0.0% | 78.8% | 70.3% | 89.4% | 93.0% |
88
+
89
+ ## Antibody threshold sweep on the 1230 matched held-out nuclei (87 antibody-negative)
90
+
91
+ | threshold | Antibody − precision | Antibody − recall | Antibody − F1 | overall accuracy |
92
+ |---:|---:|---:|---:|---:|
93
+ | 0.30 | 85.0% | 19.5% | 31.8% | 94.1% |
94
+ | 0.35 | 78.3% | 20.7% | 32.7% | 94.0% |
95
+ | 0.40 | 76.9% | 23.0% | 35.4% | 94.1% |
96
+ | 0.45 | 74.2% | 26.4% | 39.0% | 94.1% |
97
+ | 0.50 | 74.2% | 26.4% | 39.0% | 94.1% |
98
+ | 0.55 | 75.0% | 31.0% | 43.9% | 94.4% |
99
+ | 0.60 | 73.7% | 32.2% | 44.8% | 94.4% |
100
+ | 0.65 | 72.1% | 35.6% | 47.7% | 94.5% |
101
+ | 0.70 | 68.1% | 36.8% | 47.8% | 94.3% |
102
+ | 0.75 | 65.4% | 39.1% | 48.9% | 94.2% |
103
+ | 0.80 | 63.6% | 40.2% | 49.3% | 94.1% |
104
+ | 0.85 | 62.9% | 44.8% | 52.3% | 94.2% |
105
+ | 0.90 | 59.7% | 46.0% | 51.9% | 94.0% |
reports/cv/cv_results.json ADDED
The diff for this file is too large to render. See raw diff
 
reports/cv/postprocess_sweep.json ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "marker_thr": 0.7,
4
+ "cell_fg_thr": 0.4,
5
+ "min_marker_area": 30,
6
+ "nuc_f1_iou50": 0.7971642378889325,
7
+ "nuc_f1_centroid": 0.8853879480110279,
8
+ "f1_ab_pos": 0.7316276537833424,
9
+ "f1_ab_neg": 0.2903225806451613,
10
+ "f1_nos_pos": 0.7247386759581882,
11
+ "f1_pin": 0.7816411682892906,
12
+ "per_image": {
13
+ "C100": 0.83,
14
+ "C102": 0.912,
15
+ "C110": 0.796,
16
+ "C112": 0.768,
17
+ "F113": 0.654,
18
+ "F114": 0.797,
19
+ "F99": 0.838
20
+ }
21
+ },
22
+ {
23
+ "marker_thr": 0.8,
24
+ "cell_fg_thr": 0.4,
25
+ "min_marker_area": 30,
26
+ "nuc_f1_iou50": 0.7962456003128667,
27
+ "nuc_f1_centroid": 0.8838482596793116,
28
+ "f1_ab_pos": 0.7347379794705564,
29
+ "f1_ab_neg": 0.2903225806451613,
30
+ "f1_nos_pos": 0.7058823529411765,
31
+ "f1_pin": 0.7816411682892906,
32
+ "per_image": {
33
+ "C100": 0.845,
34
+ "C102": 0.892,
35
+ "C110": 0.777,
36
+ "C112": 0.748,
37
+ "F113": 0.665,
38
+ "F114": 0.797,
39
+ "F99": 0.849
40
+ }
41
+ },
42
+ {
43
+ "marker_thr": 0.8,
44
+ "cell_fg_thr": 0.5,
45
+ "min_marker_area": 30,
46
+ "nuc_f1_iou50": 0.7952941176470589,
47
+ "nuc_f1_centroid": 0.8847058823529411,
48
+ "f1_ab_pos": 0.7316017316017317,
49
+ "f1_ab_neg": 0.29268292682926833,
50
+ "f1_nos_pos": 0.7060869565217391,
51
+ "f1_pin": 0.7816411682892906,
52
+ "per_image": {
53
+ "C100": 0.833,
54
+ "C102": 0.887,
55
+ "C110": 0.779,
56
+ "C112": 0.726,
57
+ "F113": 0.673,
58
+ "F114": 0.799,
59
+ "F99": 0.86
60
+ }
61
+ },
62
+ {
63
+ "marker_thr": 0.7,
64
+ "cell_fg_thr": 0.5,
65
+ "min_marker_area": 30,
66
+ "nuc_f1_iou50": 0.7946287519747236,
67
+ "nuc_f1_centroid": 0.8862559241706163,
68
+ "f1_ab_pos": 0.7262813522355507,
69
+ "f1_ab_neg": 0.29268292682926833,
70
+ "f1_nos_pos": 0.7250437828371278,
71
+ "f1_pin": 0.7816411682892906,
72
+ "per_image": {
73
+ "C100": 0.818,
74
+ "C102": 0.906,
75
+ "C110": 0.798,
76
+ "C112": 0.739,
77
+ "F113": 0.662,
78
+ "F114": 0.799,
79
+ "F99": 0.845
80
+ }
81
+ },
82
+ {
83
+ "marker_thr": 0.8,
84
+ "cell_fg_thr": 0.4,
85
+ "min_marker_area": 12,
86
+ "nuc_f1_iou50": 0.7940717628705148,
87
+ "nuc_f1_centroid": 0.8814352574102964,
88
+ "f1_ab_pos": 0.7323640280021542,
89
+ "f1_ab_neg": 0.2903225806451613,
90
+ "f1_nos_pos": 0.7046632124352332,
91
+ "f1_pin": 0.7816411682892906,
92
+ "per_image": {
93
+ "C100": 0.845,
94
+ "C102": 0.892,
95
+ "C110": 0.773,
96
+ "C112": 0.744,
97
+ "F113": 0.658,
98
+ "F114": 0.797,
99
+ "F99": 0.849
100
+ }
101
+ },
102
+ {
103
+ "marker_thr": 0.7,
104
+ "cell_fg_thr": 0.4,
105
+ "min_marker_area": 12,
106
+ "nuc_f1_iou50": 0.7934143473147784,
107
+ "nuc_f1_centroid": 0.8812230497843984,
108
+ "f1_ab_pos": 0.7284552845528455,
109
+ "f1_ab_neg": 0.2903225806451613,
110
+ "f1_nos_pos": 0.7197231833910035,
111
+ "f1_pin": 0.7816411682892906,
112
+ "per_image": {
113
+ "C100": 0.821,
114
+ "C102": 0.912,
115
+ "C110": 0.792,
116
+ "C112": 0.761,
117
+ "F113": 0.654,
118
+ "F114": 0.796,
119
+ "F99": 0.834
120
+ }
121
+ },
122
+ {
123
+ "marker_thr": 0.8,
124
+ "cell_fg_thr": 0.5,
125
+ "min_marker_area": 12,
126
+ "nuc_f1_iou50": 0.7931169339069223,
127
+ "nuc_f1_centroid": 0.8822839264763395,
128
+ "f1_ab_pos": 0.7292340884573895,
129
+ "f1_ab_neg": 0.29268292682926833,
130
+ "f1_nos_pos": 0.704861111111111,
131
+ "f1_pin": 0.7816411682892906,
132
+ "per_image": {
133
+ "C100": 0.833,
134
+ "C102": 0.887,
135
+ "C110": 0.775,
136
+ "C112": 0.721,
137
+ "F113": 0.667,
138
+ "F114": 0.799,
139
+ "F99": 0.86
140
+ }
141
+ },
142
+ {
143
+ "marker_thr": 0.6,
144
+ "cell_fg_thr": 0.4,
145
+ "min_marker_area": 30,
146
+ "nuc_f1_iou50": 0.7930352196280174,
147
+ "nuc_f1_centroid": 0.8816778789077958,
148
+ "f1_ab_pos": 0.7275711159737418,
149
+ "f1_ab_neg": 0.2903225806451613,
150
+ "f1_nos_pos": 0.7180385288966724,
151
+ "f1_pin": 0.7816411682892906,
152
+ "per_image": {
153
+ "C100": 0.825,
154
+ "C102": 0.912,
155
+ "C110": 0.785,
156
+ "C112": 0.768,
157
+ "F113": 0.65,
158
+ "F114": 0.787,
159
+ "F99": 0.839
160
+ }
161
+ },
162
+ {
163
+ "marker_thr": 0.5,
164
+ "cell_fg_thr": 0.4,
165
+ "min_marker_area": 30,
166
+ "nuc_f1_iou50": 0.792063492063492,
167
+ "nuc_f1_centroid": 0.880952380952381,
168
+ "f1_ab_pos": 0.728171334431631,
169
+ "f1_ab_neg": 0.2903225806451613,
170
+ "f1_nos_pos": 0.7145359019264448,
171
+ "f1_pin": 0.7816411682892906,
172
+ "per_image": {
173
+ "C100": 0.823,
174
+ "C102": 0.912,
175
+ "C110": 0.785,
176
+ "C112": 0.782,
177
+ "F113": 0.647,
178
+ "F114": 0.78,
179
+ "F99": 0.836
180
+ }
181
+ },
182
+ {
183
+ "marker_thr": 0.6,
184
+ "cell_fg_thr": 0.5,
185
+ "min_marker_area": 30,
186
+ "nuc_f1_iou50": 0.792063492063492,
187
+ "nuc_f1_centroid": 0.8825396825396825,
188
+ "f1_ab_pos": 0.7243835616438358,
189
+ "f1_ab_neg": 0.29268292682926833,
190
+ "f1_nos_pos": 0.7183098591549296,
191
+ "f1_pin": 0.7816411682892906,
192
+ "per_image": {
193
+ "C100": 0.819,
194
+ "C102": 0.906,
195
+ "C110": 0.787,
196
+ "C112": 0.739,
197
+ "F113": 0.658,
198
+ "F114": 0.793,
199
+ "F99": 0.847
200
+ }
201
+ },
202
+ {
203
+ "marker_thr": 0.5,
204
+ "cell_fg_thr": 0.5,
205
+ "min_marker_area": 30,
206
+ "nuc_f1_iou50": 0.7914012738853503,
207
+ "nuc_f1_centroid": 0.8821656050955414,
208
+ "f1_ab_pos": 0.7253714914694551,
209
+ "f1_ab_neg": 0.29268292682926833,
210
+ "f1_nos_pos": 0.7147887323943662,
211
+ "f1_pin": 0.7816411682892906,
212
+ "per_image": {
213
+ "C100": 0.817,
214
+ "C102": 0.906,
215
+ "C110": 0.787,
216
+ "C112": 0.752,
217
+ "F113": 0.656,
218
+ "F114": 0.788,
219
+ "F99": 0.844
220
+ }
221
+ },
222
+ {
223
+ "marker_thr": 0.7,
224
+ "cell_fg_thr": 0.5,
225
+ "min_marker_area": 12,
226
+ "nuc_f1_iou50": 0.7908805031446541,
227
+ "nuc_f1_centroid": 0.8820754716981133,
228
+ "f1_ab_pos": 0.7231270358306189,
229
+ "f1_ab_neg": 0.29268292682926833,
230
+ "f1_nos_pos": 0.7200000000000002,
231
+ "f1_pin": 0.7816411682892906,
232
+ "per_image": {
233
+ "C100": 0.809,
234
+ "C102": 0.906,
235
+ "C110": 0.794,
236
+ "C112": 0.732,
237
+ "F113": 0.662,
238
+ "F114": 0.797,
239
+ "F99": 0.842
240
+ }
241
+ },
242
+ {
243
+ "marker_thr": 0.6,
244
+ "cell_fg_thr": 0.4,
245
+ "min_marker_area": 12,
246
+ "nuc_f1_iou50": 0.7899093417422152,
247
+ "nuc_f1_centroid": 0.8789909341742216,
248
+ "f1_ab_pos": 0.724006532389766,
249
+ "f1_ab_neg": 0.2903225806451613,
250
+ "f1_nos_pos": 0.7132867132867132,
251
+ "f1_pin": 0.7816411682892906,
252
+ "per_image": {
253
+ "C100": 0.816,
254
+ "C102": 0.912,
255
+ "C110": 0.783,
256
+ "C112": 0.761,
257
+ "F113": 0.645,
258
+ "F114": 0.787,
259
+ "F99": 0.839
260
+ }
261
+ },
262
+ {
263
+ "marker_thr": 0.5,
264
+ "cell_fg_thr": 0.4,
265
+ "min_marker_area": 12,
266
+ "nuc_f1_iou50": 0.7898694103680254,
267
+ "nuc_f1_centroid": 0.8793035219628017,
268
+ "f1_ab_pos": 0.726079825041006,
269
+ "f1_ab_neg": 0.2903225806451613,
270
+ "f1_nos_pos": 0.712280701754386,
271
+ "f1_pin": 0.7816411682892906,
272
+ "per_image": {
273
+ "C100": 0.819,
274
+ "C102": 0.912,
275
+ "C110": 0.783,
276
+ "C112": 0.78,
277
+ "F113": 0.643,
278
+ "F114": 0.78,
279
+ "F99": 0.834
280
+ }
281
+ },
282
+ {
283
+ "marker_thr": 0.5,
284
+ "cell_fg_thr": 0.5,
285
+ "min_marker_area": 12,
286
+ "nuc_f1_iou50": 0.7898291617004372,
287
+ "nuc_f1_centroid": 0.881207787048073,
288
+ "f1_ab_pos": 0.7240811848601207,
289
+ "f1_ab_neg": 0.29268292682926833,
290
+ "f1_nos_pos": 0.7125220458553791,
291
+ "f1_pin": 0.7816411682892906,
292
+ "per_image": {
293
+ "C100": 0.812,
294
+ "C102": 0.906,
295
+ "C110": 0.785,
296
+ "C112": 0.75,
297
+ "F113": 0.654,
298
+ "F114": 0.788,
299
+ "F99": 0.844
300
+ }
301
+ },
302
+ {
303
+ "marker_thr": 0.6,
304
+ "cell_fg_thr": 0.5,
305
+ "min_marker_area": 12,
306
+ "nuc_f1_iou50": 0.7892447607750098,
307
+ "nuc_f1_centroid": 0.8809806247528668,
308
+ "f1_ab_pos": 0.7212220403709765,
309
+ "f1_ab_neg": 0.29268292682926833,
310
+ "f1_nos_pos": 0.7135325131810192,
311
+ "f1_pin": 0.7816411682892906,
312
+ "per_image": {
313
+ "C100": 0.81,
314
+ "C102": 0.906,
315
+ "C110": 0.785,
316
+ "C112": 0.732,
317
+ "F113": 0.656,
318
+ "F114": 0.793,
319
+ "F99": 0.847
320
+ }
321
+ }
322
+ ]
reports/final/final_fit.json ADDED
@@ -0,0 +1,1820 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "C100": {
3
+ "cells": {
4
+ "detection": {
5
+ "tp": 153,
6
+ "fp": 22,
7
+ "fn": 25,
8
+ "precision": 0.8742857142857143,
9
+ "recall": 0.8595505617977528,
10
+ "f1": 0.8668555240793201,
11
+ "mean_iou_matched": 0.7974392864551416,
12
+ "iou30": {
13
+ "tp": 165,
14
+ "fp": 10,
15
+ "fn": 13,
16
+ "precision": 0.9428571428571428,
17
+ "recall": 0.9269662921348315,
18
+ "f1": 0.934844192634561
19
+ },
20
+ "centroid": {
21
+ "tp": 165,
22
+ "fp": 10,
23
+ "fn": 13,
24
+ "precision": 0.9428571428571428,
25
+ "recall": 0.9269662921348315,
26
+ "f1": 0.934844192634561
27
+ }
28
+ },
29
+ "attribute_accuracy": {
30
+ "antibody": 0.9934640522875817,
31
+ "nostril": 0.9934640522875817,
32
+ "both": 0.9869281045751634,
33
+ "n_matched": 153
34
+ },
35
+ "per_class": {
36
+ "Cell, Antibody +": {
37
+ "tp": 139,
38
+ "fp": 22,
39
+ "fn": 26,
40
+ "precision": 0.8633540372670807,
41
+ "recall": 0.8424242424242424,
42
+ "f1": 0.852760736196319,
43
+ "gt_count": 165,
44
+ "pred_count": 161
45
+ },
46
+ "Cell, Antibody -": {
47
+ "tp": 0,
48
+ "fp": 2,
49
+ "fn": 0,
50
+ "precision": 0.0,
51
+ "recall": 0.0,
52
+ "f1": 0.0,
53
+ "gt_count": 0,
54
+ "pred_count": 2
55
+ },
56
+ "Cell, Nostril, Antibody +": {
57
+ "tp": 12,
58
+ "fp": 0,
59
+ "fn": 1,
60
+ "precision": 1.0,
61
+ "recall": 0.9230769230769231,
62
+ "f1": 0.9600000000000001,
63
+ "gt_count": 13,
64
+ "pred_count": 12
65
+ },
66
+ "Cell, Nostril, Antibody -": {
67
+ "tp": 0,
68
+ "fp": 0,
69
+ "fn": 0,
70
+ "precision": 0.0,
71
+ "recall": 0.0,
72
+ "f1": 0.0,
73
+ "gt_count": 0,
74
+ "pred_count": 0
75
+ }
76
+ },
77
+ "confusion": {
78
+ "Cell, Antibody +": {
79
+ "Cell, Antibody +": 139,
80
+ "Cell, Antibody -": 1,
81
+ "Cell, Nostril, Antibody +": 0,
82
+ "Cell, Nostril, Antibody -": 0
83
+ },
84
+ "Cell, Antibody -": {
85
+ "Cell, Antibody +": 0,
86
+ "Cell, Antibody -": 0,
87
+ "Cell, Nostril, Antibody +": 0,
88
+ "Cell, Nostril, Antibody -": 0
89
+ },
90
+ "Cell, Nostril, Antibody +": {
91
+ "Cell, Antibody +": 1,
92
+ "Cell, Antibody -": 0,
93
+ "Cell, Nostril, Antibody +": 12,
94
+ "Cell, Nostril, Antibody -": 0
95
+ },
96
+ "Cell, Nostril, Antibody -": {
97
+ "Cell, Antibody +": 0,
98
+ "Cell, Antibody -": 0,
99
+ "Cell, Nostril, Antibody +": 0,
100
+ "Cell, Nostril, Antibody -": 0
101
+ }
102
+ },
103
+ "n_gt": 178,
104
+ "n_pred": 175
105
+ },
106
+ "nostrils": {
107
+ "detection": {
108
+ "tp": 16,
109
+ "fp": 3,
110
+ "fn": 4,
111
+ "precision": 0.8421052631578947,
112
+ "recall": 0.8,
113
+ "f1": 0.8205128205128205,
114
+ "mean_dist_matched": 1.157546253191996
115
+ },
116
+ "type_accuracy": 1.0,
117
+ "per_class": {
118
+ "Nostril in Nucleus": {
119
+ "tp": 12,
120
+ "fp": 0,
121
+ "fn": 2,
122
+ "precision": 1.0,
123
+ "recall": 0.8571428571428571,
124
+ "f1": 0.923076923076923,
125
+ "gt_count": 14,
126
+ "pred_count": 12
127
+ },
128
+ "Nostril External of Nucleus": {
129
+ "tp": 4,
130
+ "fp": 3,
131
+ "fn": 2,
132
+ "precision": 0.5714285714285714,
133
+ "recall": 0.6666666666666666,
134
+ "f1": 0.6153846153846153,
135
+ "gt_count": 6,
136
+ "pred_count": 7
137
+ }
138
+ },
139
+ "n_gt": 20,
140
+ "n_pred": 19
141
+ },
142
+ "counts": {
143
+ "Cell, Antibody +": {
144
+ "gt": 165,
145
+ "pred": 161,
146
+ "abs_err": 4,
147
+ "rel_err": 0.024242424242424242
148
+ },
149
+ "Cell, Antibody -": {
150
+ "gt": 0,
151
+ "pred": 2,
152
+ "abs_err": 2,
153
+ "rel_err": Infinity
154
+ },
155
+ "Cell, Nostril, Antibody +": {
156
+ "gt": 13,
157
+ "pred": 12,
158
+ "abs_err": 1,
159
+ "rel_err": 0.07692307692307693
160
+ },
161
+ "Cell, Nostril, Antibody -": {
162
+ "gt": 0,
163
+ "pred": 0,
164
+ "abs_err": 0,
165
+ "rel_err": 0.0
166
+ },
167
+ "Nostril in Nucleus": {
168
+ "gt": 14,
169
+ "pred": 12,
170
+ "abs_err": 2,
171
+ "rel_err": 0.14285714285714285
172
+ },
173
+ "Nostril External of Nucleus": {
174
+ "gt": 6,
175
+ "pred": 7,
176
+ "abs_err": 1,
177
+ "rel_err": 0.16666666666666666
178
+ }
179
+ },
180
+ "fp_analysis": null,
181
+ "pairs": null,
182
+ "summary": {
183
+ "cell_det_f1": 0.8668555240793201,
184
+ "cell_det_precision": 0.8742857142857143,
185
+ "cell_det_recall": 0.8595505617977528,
186
+ "cell_mean_iou": 0.7974392864551416,
187
+ "cell_det_f1_iou30": 0.934844192634561,
188
+ "cell_det_f1_centroid": 0.934844192634561,
189
+ "cell_attr_antibody": 0.9934640522875817,
190
+ "cell_attr_nostril": 0.9934640522875817,
191
+ "cell_attr_both": 0.9869281045751634,
192
+ "nos_det_f1": 0.8205128205128205,
193
+ "nos_det_precision": 0.8421052631578947,
194
+ "nos_det_recall": 0.8,
195
+ "nos_type_acc": 1.0,
196
+ "f1[Cell, Antibody +]": 0.852760736196319,
197
+ "f1[Cell, Antibody -]": 0.0,
198
+ "f1[Cell, Nostril, Antibody +]": 0.9600000000000001,
199
+ "f1[Cell, Nostril, Antibody -]": 0.0,
200
+ "f1[Nostril in Nucleus]": 0.923076923076923,
201
+ "f1[Nostril External of Nucleus]": 0.6153846153846153
202
+ }
203
+ },
204
+ "C102": {
205
+ "cells": {
206
+ "detection": {
207
+ "tp": 90,
208
+ "fp": 12,
209
+ "fn": 1,
210
+ "precision": 0.8823529411764706,
211
+ "recall": 0.989010989010989,
212
+ "f1": 0.9326424870466321,
213
+ "mean_iou_matched": 0.8122454665114849,
214
+ "iou30": {
215
+ "tp": 90,
216
+ "fp": 12,
217
+ "fn": 1,
218
+ "precision": 0.8823529411764706,
219
+ "recall": 0.989010989010989,
220
+ "f1": 0.9326424870466321
221
+ },
222
+ "centroid": {
223
+ "tp": 90,
224
+ "fp": 12,
225
+ "fn": 1,
226
+ "precision": 0.8823529411764706,
227
+ "recall": 0.989010989010989,
228
+ "f1": 0.9326424870466321
229
+ }
230
+ },
231
+ "attribute_accuracy": {
232
+ "antibody": 1.0,
233
+ "nostril": 0.9444444444444444,
234
+ "both": 0.9444444444444444,
235
+ "n_matched": 90
236
+ },
237
+ "per_class": {
238
+ "Cell, Antibody +": {
239
+ "tp": 41,
240
+ "fp": 15,
241
+ "fn": 0,
242
+ "precision": 0.7321428571428571,
243
+ "recall": 1.0,
244
+ "f1": 0.8453608247422679,
245
+ "gt_count": 41,
246
+ "pred_count": 56
247
+ },
248
+ "Cell, Antibody -": {
249
+ "tp": 0,
250
+ "fp": 0,
251
+ "fn": 0,
252
+ "precision": 0.0,
253
+ "recall": 0.0,
254
+ "f1": 0.0,
255
+ "gt_count": 0,
256
+ "pred_count": 0
257
+ },
258
+ "Cell, Nostril, Antibody +": {
259
+ "tp": 44,
260
+ "fp": 2,
261
+ "fn": 6,
262
+ "precision": 0.9565217391304348,
263
+ "recall": 0.88,
264
+ "f1": 0.9166666666666666,
265
+ "gt_count": 50,
266
+ "pred_count": 46
267
+ },
268
+ "Cell, Nostril, Antibody -": {
269
+ "tp": 0,
270
+ "fp": 0,
271
+ "fn": 0,
272
+ "precision": 0.0,
273
+ "recall": 0.0,
274
+ "f1": 0.0,
275
+ "gt_count": 0,
276
+ "pred_count": 0
277
+ }
278
+ },
279
+ "confusion": {
280
+ "Cell, Antibody +": {
281
+ "Cell, Antibody +": 41,
282
+ "Cell, Antibody -": 0,
283
+ "Cell, Nostril, Antibody +": 0,
284
+ "Cell, Nostril, Antibody -": 0
285
+ },
286
+ "Cell, Antibody -": {
287
+ "Cell, Antibody +": 0,
288
+ "Cell, Antibody -": 0,
289
+ "Cell, Nostril, Antibody +": 0,
290
+ "Cell, Nostril, Antibody -": 0
291
+ },
292
+ "Cell, Nostril, Antibody +": {
293
+ "Cell, Antibody +": 5,
294
+ "Cell, Antibody -": 0,
295
+ "Cell, Nostril, Antibody +": 44,
296
+ "Cell, Nostril, Antibody -": 0
297
+ },
298
+ "Cell, Nostril, Antibody -": {
299
+ "Cell, Antibody +": 0,
300
+ "Cell, Antibody -": 0,
301
+ "Cell, Nostril, Antibody +": 0,
302
+ "Cell, Nostril, Antibody -": 0
303
+ }
304
+ },
305
+ "n_gt": 91,
306
+ "n_pred": 102
307
+ },
308
+ "nostrils": {
309
+ "detection": {
310
+ "tp": 121,
311
+ "fp": 31,
312
+ "fn": 13,
313
+ "precision": 0.7960526315789473,
314
+ "recall": 0.9029850746268657,
315
+ "f1": 0.8461538461538461,
316
+ "mean_dist_matched": 1.0261222516695998
317
+ },
318
+ "type_accuracy": 1.0,
319
+ "per_class": {
320
+ "Nostril in Nucleus": {
321
+ "tp": 54,
322
+ "fp": 8,
323
+ "fn": 6,
324
+ "precision": 0.8709677419354839,
325
+ "recall": 0.9,
326
+ "f1": 0.8852459016393444,
327
+ "gt_count": 60,
328
+ "pred_count": 62
329
+ },
330
+ "Nostril External of Nucleus": {
331
+ "tp": 67,
332
+ "fp": 23,
333
+ "fn": 7,
334
+ "precision": 0.7444444444444445,
335
+ "recall": 0.9054054054054054,
336
+ "f1": 0.8170731707317074,
337
+ "gt_count": 74,
338
+ "pred_count": 90
339
+ }
340
+ },
341
+ "n_gt": 134,
342
+ "n_pred": 152
343
+ },
344
+ "counts": {
345
+ "Cell, Antibody +": {
346
+ "gt": 41,
347
+ "pred": 56,
348
+ "abs_err": 15,
349
+ "rel_err": 0.36585365853658536
350
+ },
351
+ "Cell, Antibody -": {
352
+ "gt": 0,
353
+ "pred": 0,
354
+ "abs_err": 0,
355
+ "rel_err": 0.0
356
+ },
357
+ "Cell, Nostril, Antibody +": {
358
+ "gt": 50,
359
+ "pred": 46,
360
+ "abs_err": 4,
361
+ "rel_err": 0.08
362
+ },
363
+ "Cell, Nostril, Antibody -": {
364
+ "gt": 0,
365
+ "pred": 0,
366
+ "abs_err": 0,
367
+ "rel_err": 0.0
368
+ },
369
+ "Nostril in Nucleus": {
370
+ "gt": 60,
371
+ "pred": 62,
372
+ "abs_err": 2,
373
+ "rel_err": 0.03333333333333333
374
+ },
375
+ "Nostril External of Nucleus": {
376
+ "gt": 74,
377
+ "pred": 90,
378
+ "abs_err": 16,
379
+ "rel_err": 0.21621621621621623
380
+ }
381
+ },
382
+ "fp_analysis": null,
383
+ "pairs": null,
384
+ "summary": {
385
+ "cell_det_f1": 0.9326424870466321,
386
+ "cell_det_precision": 0.8823529411764706,
387
+ "cell_det_recall": 0.989010989010989,
388
+ "cell_mean_iou": 0.8122454665114849,
389
+ "cell_det_f1_iou30": 0.9326424870466321,
390
+ "cell_det_f1_centroid": 0.9326424870466321,
391
+ "cell_attr_antibody": 1.0,
392
+ "cell_attr_nostril": 0.9444444444444444,
393
+ "cell_attr_both": 0.9444444444444444,
394
+ "nos_det_f1": 0.8461538461538461,
395
+ "nos_det_precision": 0.7960526315789473,
396
+ "nos_det_recall": 0.9029850746268657,
397
+ "nos_type_acc": 1.0,
398
+ "f1[Cell, Antibody +]": 0.8453608247422679,
399
+ "f1[Cell, Antibody -]": 0.0,
400
+ "f1[Cell, Nostril, Antibody +]": 0.9166666666666666,
401
+ "f1[Cell, Nostril, Antibody -]": 0.0,
402
+ "f1[Nostril in Nucleus]": 0.8852459016393444,
403
+ "f1[Nostril External of Nucleus]": 0.8170731707317074
404
+ }
405
+ },
406
+ "C110": {
407
+ "cells": {
408
+ "detection": {
409
+ "tp": 164,
410
+ "fp": 22,
411
+ "fn": 20,
412
+ "precision": 0.8817204301075269,
413
+ "recall": 0.8913043478260869,
414
+ "f1": 0.8864864864864864,
415
+ "mean_iou_matched": 0.800839882168248,
416
+ "iou30": {
417
+ "tp": 173,
418
+ "fp": 13,
419
+ "fn": 11,
420
+ "precision": 0.9301075268817204,
421
+ "recall": 0.9402173913043478,
422
+ "f1": 0.9351351351351351
423
+ },
424
+ "centroid": {
425
+ "tp": 172,
426
+ "fp": 14,
427
+ "fn": 12,
428
+ "precision": 0.9247311827956989,
429
+ "recall": 0.9347826086956522,
430
+ "f1": 0.9297297297297297
431
+ }
432
+ },
433
+ "attribute_accuracy": {
434
+ "antibody": 1.0,
435
+ "nostril": 0.9573170731707317,
436
+ "both": 0.9573170731707317,
437
+ "n_matched": 164
438
+ },
439
+ "per_class": {
440
+ "Cell, Antibody +": {
441
+ "tp": 54,
442
+ "fp": 19,
443
+ "fn": 7,
444
+ "precision": 0.7397260273972602,
445
+ "recall": 0.8852459016393442,
446
+ "f1": 0.8059701492537313,
447
+ "gt_count": 61,
448
+ "pred_count": 73
449
+ },
450
+ "Cell, Antibody -": {
451
+ "tp": 1,
452
+ "fp": 0,
453
+ "fn": 0,
454
+ "precision": 1.0,
455
+ "recall": 1.0,
456
+ "f1": 1.0,
457
+ "gt_count": 1,
458
+ "pred_count": 1
459
+ },
460
+ "Cell, Nostril, Antibody +": {
461
+ "tp": 102,
462
+ "fp": 10,
463
+ "fn": 20,
464
+ "precision": 0.9107142857142857,
465
+ "recall": 0.8360655737704918,
466
+ "f1": 0.8717948717948717,
467
+ "gt_count": 122,
468
+ "pred_count": 112
469
+ },
470
+ "Cell, Nostril, Antibody -": {
471
+ "tp": 0,
472
+ "fp": 0,
473
+ "fn": 0,
474
+ "precision": 0.0,
475
+ "recall": 0.0,
476
+ "f1": 0.0,
477
+ "gt_count": 0,
478
+ "pred_count": 0
479
+ }
480
+ },
481
+ "confusion": {
482
+ "Cell, Antibody +": {
483
+ "Cell, Antibody +": 54,
484
+ "Cell, Antibody -": 0,
485
+ "Cell, Nostril, Antibody +": 3,
486
+ "Cell, Nostril, Antibody -": 0
487
+ },
488
+ "Cell, Antibody -": {
489
+ "Cell, Antibody +": 0,
490
+ "Cell, Antibody -": 1,
491
+ "Cell, Nostril, Antibody +": 0,
492
+ "Cell, Nostril, Antibody -": 0
493
+ },
494
+ "Cell, Nostril, Antibody +": {
495
+ "Cell, Antibody +": 4,
496
+ "Cell, Antibody -": 0,
497
+ "Cell, Nostril, Antibody +": 102,
498
+ "Cell, Nostril, Antibody -": 0
499
+ },
500
+ "Cell, Nostril, Antibody -": {
501
+ "Cell, Antibody +": 0,
502
+ "Cell, Antibody -": 0,
503
+ "Cell, Nostril, Antibody +": 0,
504
+ "Cell, Nostril, Antibody -": 0
505
+ }
506
+ },
507
+ "n_gt": 184,
508
+ "n_pred": 186
509
+ },
510
+ "nostrils": {
511
+ "detection": {
512
+ "tp": 347,
513
+ "fp": 93,
514
+ "fn": 29,
515
+ "precision": 0.7886363636363637,
516
+ "recall": 0.9228723404255319,
517
+ "f1": 0.8504901960784313,
518
+ "mean_dist_matched": 0.8862671910950328
519
+ },
520
+ "type_accuracy": 0.9798270893371758,
521
+ "per_class": {
522
+ "Nostril in Nucleus": {
523
+ "tp": 148,
524
+ "fp": 34,
525
+ "fn": 21,
526
+ "precision": 0.8131868131868132,
527
+ "recall": 0.8757396449704142,
528
+ "f1": 0.8433048433048432,
529
+ "gt_count": 169,
530
+ "pred_count": 182
531
+ },
532
+ "Nostril External of Nucleus": {
533
+ "tp": 192,
534
+ "fp": 66,
535
+ "fn": 15,
536
+ "precision": 0.7441860465116279,
537
+ "recall": 0.927536231884058,
538
+ "f1": 0.8258064516129032,
539
+ "gt_count": 207,
540
+ "pred_count": 258
541
+ }
542
+ },
543
+ "n_gt": 376,
544
+ "n_pred": 440
545
+ },
546
+ "counts": {
547
+ "Cell, Antibody +": {
548
+ "gt": 61,
549
+ "pred": 73,
550
+ "abs_err": 12,
551
+ "rel_err": 0.19672131147540983
552
+ },
553
+ "Cell, Antibody -": {
554
+ "gt": 1,
555
+ "pred": 1,
556
+ "abs_err": 0,
557
+ "rel_err": 0.0
558
+ },
559
+ "Cell, Nostril, Antibody +": {
560
+ "gt": 122,
561
+ "pred": 112,
562
+ "abs_err": 10,
563
+ "rel_err": 0.08196721311475409
564
+ },
565
+ "Cell, Nostril, Antibody -": {
566
+ "gt": 0,
567
+ "pred": 0,
568
+ "abs_err": 0,
569
+ "rel_err": 0.0
570
+ },
571
+ "Nostril in Nucleus": {
572
+ "gt": 169,
573
+ "pred": 182,
574
+ "abs_err": 13,
575
+ "rel_err": 0.07692307692307693
576
+ },
577
+ "Nostril External of Nucleus": {
578
+ "gt": 207,
579
+ "pred": 258,
580
+ "abs_err": 51,
581
+ "rel_err": 0.2463768115942029
582
+ }
583
+ },
584
+ "fp_analysis": null,
585
+ "pairs": null,
586
+ "summary": {
587
+ "cell_det_f1": 0.8864864864864864,
588
+ "cell_det_precision": 0.8817204301075269,
589
+ "cell_det_recall": 0.8913043478260869,
590
+ "cell_mean_iou": 0.800839882168248,
591
+ "cell_det_f1_iou30": 0.9351351351351351,
592
+ "cell_det_f1_centroid": 0.9297297297297297,
593
+ "cell_attr_antibody": 1.0,
594
+ "cell_attr_nostril": 0.9573170731707317,
595
+ "cell_attr_both": 0.9573170731707317,
596
+ "nos_det_f1": 0.8504901960784313,
597
+ "nos_det_precision": 0.7886363636363637,
598
+ "nos_det_recall": 0.9228723404255319,
599
+ "nos_type_acc": 0.9798270893371758,
600
+ "f1[Cell, Antibody +]": 0.8059701492537313,
601
+ "f1[Cell, Antibody -]": 1.0,
602
+ "f1[Cell, Nostril, Antibody +]": 0.8717948717948717,
603
+ "f1[Cell, Nostril, Antibody -]": 0.0,
604
+ "f1[Nostril in Nucleus]": 0.8433048433048432,
605
+ "f1[Nostril External of Nucleus]": 0.8258064516129032
606
+ }
607
+ },
608
+ "C112": {
609
+ "cells": {
610
+ "detection": {
611
+ "tp": 124,
612
+ "fp": 26,
613
+ "fn": 22,
614
+ "precision": 0.8266666666666667,
615
+ "recall": 0.8493150684931506,
616
+ "f1": 0.8378378378378378,
617
+ "mean_iou_matched": 0.7526341825198912,
618
+ "iou30": {
619
+ "tp": 134,
620
+ "fp": 16,
621
+ "fn": 12,
622
+ "precision": 0.8933333333333333,
623
+ "recall": 0.9178082191780822,
624
+ "f1": 0.9054054054054055
625
+ },
626
+ "centroid": {
627
+ "tp": 133,
628
+ "fp": 17,
629
+ "fn": 13,
630
+ "precision": 0.8866666666666667,
631
+ "recall": 0.910958904109589,
632
+ "f1": 0.8986486486486487
633
+ }
634
+ },
635
+ "attribute_accuracy": {
636
+ "antibody": 0.9919354838709677,
637
+ "nostril": 1.0,
638
+ "both": 0.9919354838709677,
639
+ "n_matched": 124
640
+ },
641
+ "per_class": {
642
+ "Cell, Antibody +": {
643
+ "tp": 104,
644
+ "fp": 22,
645
+ "fn": 20,
646
+ "precision": 0.8253968253968254,
647
+ "recall": 0.8387096774193549,
648
+ "f1": 0.832,
649
+ "gt_count": 124,
650
+ "pred_count": 126
651
+ },
652
+ "Cell, Antibody -": {
653
+ "tp": 4,
654
+ "fp": 1,
655
+ "fn": 1,
656
+ "precision": 0.8,
657
+ "recall": 0.8,
658
+ "f1": 0.8000000000000002,
659
+ "gt_count": 5,
660
+ "pred_count": 5
661
+ },
662
+ "Cell, Nostril, Antibody +": {
663
+ "tp": 15,
664
+ "fp": 4,
665
+ "fn": 2,
666
+ "precision": 0.7894736842105263,
667
+ "recall": 0.8823529411764706,
668
+ "f1": 0.8333333333333333,
669
+ "gt_count": 17,
670
+ "pred_count": 19
671
+ },
672
+ "Cell, Nostril, Antibody -": {
673
+ "tp": 0,
674
+ "fp": 0,
675
+ "fn": 0,
676
+ "precision": 0.0,
677
+ "recall": 0.0,
678
+ "f1": 0.0,
679
+ "gt_count": 0,
680
+ "pred_count": 0
681
+ }
682
+ },
683
+ "confusion": {
684
+ "Cell, Antibody +": {
685
+ "Cell, Antibody +": 104,
686
+ "Cell, Antibody -": 0,
687
+ "Cell, Nostril, Antibody +": 0,
688
+ "Cell, Nostril, Antibody -": 0
689
+ },
690
+ "Cell, Antibody -": {
691
+ "Cell, Antibody +": 1,
692
+ "Cell, Antibody -": 4,
693
+ "Cell, Nostril, Antibody +": 0,
694
+ "Cell, Nostril, Antibody -": 0
695
+ },
696
+ "Cell, Nostril, Antibody +": {
697
+ "Cell, Antibody +": 0,
698
+ "Cell, Antibody -": 0,
699
+ "Cell, Nostril, Antibody +": 15,
700
+ "Cell, Nostril, Antibody -": 0
701
+ },
702
+ "Cell, Nostril, Antibody -": {
703
+ "Cell, Antibody +": 0,
704
+ "Cell, Antibody -": 0,
705
+ "Cell, Nostril, Antibody +": 0,
706
+ "Cell, Nostril, Antibody -": 0
707
+ }
708
+ },
709
+ "n_gt": 146,
710
+ "n_pred": 150
711
+ },
712
+ "nostrils": {
713
+ "detection": {
714
+ "tp": 25,
715
+ "fp": 8,
716
+ "fn": 0,
717
+ "precision": 0.7575757575757576,
718
+ "recall": 1.0,
719
+ "f1": 0.8620689655172413,
720
+ "mean_dist_matched": 0.934525036066851
721
+ },
722
+ "type_accuracy": 1.0,
723
+ "per_class": {
724
+ "Nostril in Nucleus": {
725
+ "tp": 23,
726
+ "fp": 2,
727
+ "fn": 0,
728
+ "precision": 0.92,
729
+ "recall": 1.0,
730
+ "f1": 0.9583333333333334,
731
+ "gt_count": 23,
732
+ "pred_count": 25
733
+ },
734
+ "Nostril External of Nucleus": {
735
+ "tp": 2,
736
+ "fp": 6,
737
+ "fn": 0,
738
+ "precision": 0.25,
739
+ "recall": 1.0,
740
+ "f1": 0.4,
741
+ "gt_count": 2,
742
+ "pred_count": 8
743
+ }
744
+ },
745
+ "n_gt": 25,
746
+ "n_pred": 33
747
+ },
748
+ "counts": {
749
+ "Cell, Antibody +": {
750
+ "gt": 124,
751
+ "pred": 126,
752
+ "abs_err": 2,
753
+ "rel_err": 0.016129032258064516
754
+ },
755
+ "Cell, Antibody -": {
756
+ "gt": 5,
757
+ "pred": 5,
758
+ "abs_err": 0,
759
+ "rel_err": 0.0
760
+ },
761
+ "Cell, Nostril, Antibody +": {
762
+ "gt": 17,
763
+ "pred": 19,
764
+ "abs_err": 2,
765
+ "rel_err": 0.11764705882352941
766
+ },
767
+ "Cell, Nostril, Antibody -": {
768
+ "gt": 0,
769
+ "pred": 0,
770
+ "abs_err": 0,
771
+ "rel_err": 0.0
772
+ },
773
+ "Nostril in Nucleus": {
774
+ "gt": 23,
775
+ "pred": 25,
776
+ "abs_err": 2,
777
+ "rel_err": 0.08695652173913043
778
+ },
779
+ "Nostril External of Nucleus": {
780
+ "gt": 2,
781
+ "pred": 8,
782
+ "abs_err": 6,
783
+ "rel_err": 3.0
784
+ }
785
+ },
786
+ "fp_analysis": null,
787
+ "pairs": null,
788
+ "summary": {
789
+ "cell_det_f1": 0.8378378378378378,
790
+ "cell_det_precision": 0.8266666666666667,
791
+ "cell_det_recall": 0.8493150684931506,
792
+ "cell_mean_iou": 0.7526341825198912,
793
+ "cell_det_f1_iou30": 0.9054054054054055,
794
+ "cell_det_f1_centroid": 0.8986486486486487,
795
+ "cell_attr_antibody": 0.9919354838709677,
796
+ "cell_attr_nostril": 1.0,
797
+ "cell_attr_both": 0.9919354838709677,
798
+ "nos_det_f1": 0.8620689655172413,
799
+ "nos_det_precision": 0.7575757575757576,
800
+ "nos_det_recall": 1.0,
801
+ "nos_type_acc": 1.0,
802
+ "f1[Cell, Antibody +]": 0.832,
803
+ "f1[Cell, Antibody -]": 0.8000000000000002,
804
+ "f1[Cell, Nostril, Antibody +]": 0.8333333333333333,
805
+ "f1[Cell, Nostril, Antibody -]": 0.0,
806
+ "f1[Nostril in Nucleus]": 0.9583333333333334,
807
+ "f1[Nostril External of Nucleus]": 0.4
808
+ }
809
+ },
810
+ "F100": {
811
+ "cells": {
812
+ "detection": {
813
+ "tp": 160,
814
+ "fp": 21,
815
+ "fn": 26,
816
+ "precision": 0.8839779005524862,
817
+ "recall": 0.8602150537634409,
818
+ "f1": 0.871934604904632,
819
+ "mean_iou_matched": 0.8049454720324757,
820
+ "iou30": {
821
+ "tp": 169,
822
+ "fp": 12,
823
+ "fn": 17,
824
+ "precision": 0.9337016574585635,
825
+ "recall": 0.9086021505376344,
826
+ "f1": 0.9209809264305177
827
+ },
828
+ "centroid": {
829
+ "tp": 168,
830
+ "fp": 13,
831
+ "fn": 18,
832
+ "precision": 0.9281767955801105,
833
+ "recall": 0.9032258064516129,
834
+ "f1": 0.9155313351498637
835
+ }
836
+ },
837
+ "attribute_accuracy": {
838
+ "antibody": 0.98125,
839
+ "nostril": 0.98125,
840
+ "both": 0.9625,
841
+ "n_matched": 160
842
+ },
843
+ "per_class": {
844
+ "Cell, Antibody +": {
845
+ "tp": 79,
846
+ "fp": 17,
847
+ "fn": 8,
848
+ "precision": 0.8229166666666666,
849
+ "recall": 0.9080459770114943,
850
+ "f1": 0.8633879781420765,
851
+ "gt_count": 87,
852
+ "pred_count": 96
853
+ },
854
+ "Cell, Antibody -": {
855
+ "tp": 4,
856
+ "fp": 2,
857
+ "fn": 2,
858
+ "precision": 0.6666666666666666,
859
+ "recall": 0.6666666666666666,
860
+ "f1": 0.6666666666666666,
861
+ "gt_count": 6,
862
+ "pred_count": 6
863
+ },
864
+ "Cell, Nostril, Antibody +": {
865
+ "tp": 71,
866
+ "fp": 8,
867
+ "fn": 22,
868
+ "precision": 0.8987341772151899,
869
+ "recall": 0.7634408602150538,
870
+ "f1": 0.8255813953488371,
871
+ "gt_count": 93,
872
+ "pred_count": 79
873
+ },
874
+ "Cell, Nostril, Antibody -": {
875
+ "tp": 0,
876
+ "fp": 0,
877
+ "fn": 0,
878
+ "precision": 0.0,
879
+ "recall": 0.0,
880
+ "f1": 0.0,
881
+ "gt_count": 0,
882
+ "pred_count": 0
883
+ }
884
+ },
885
+ "confusion": {
886
+ "Cell, Antibody +": {
887
+ "Cell, Antibody +": 79,
888
+ "Cell, Antibody -": 1,
889
+ "Cell, Nostril, Antibody +": 0,
890
+ "Cell, Nostril, Antibody -": 0
891
+ },
892
+ "Cell, Antibody -": {
893
+ "Cell, Antibody +": 2,
894
+ "Cell, Antibody -": 4,
895
+ "Cell, Nostril, Antibody +": 0,
896
+ "Cell, Nostril, Antibody -": 0
897
+ },
898
+ "Cell, Nostril, Antibody +": {
899
+ "Cell, Antibody +": 3,
900
+ "Cell, Antibody -": 0,
901
+ "Cell, Nostril, Antibody +": 71,
902
+ "Cell, Nostril, Antibody -": 0
903
+ },
904
+ "Cell, Nostril, Antibody -": {
905
+ "Cell, Antibody +": 0,
906
+ "Cell, Antibody -": 0,
907
+ "Cell, Nostril, Antibody +": 0,
908
+ "Cell, Nostril, Antibody -": 0
909
+ }
910
+ },
911
+ "n_gt": 186,
912
+ "n_pred": 181
913
+ },
914
+ "nostrils": {
915
+ "detection": {
916
+ "tp": 142,
917
+ "fp": 9,
918
+ "fn": 20,
919
+ "precision": 0.9403973509933775,
920
+ "recall": 0.8765432098765432,
921
+ "f1": 0.9073482428115016,
922
+ "mean_dist_matched": 1.2296582069626907
923
+ },
924
+ "type_accuracy": 0.9929577464788732,
925
+ "per_class": {
926
+ "Nostril in Nucleus": {
927
+ "tp": 102,
928
+ "fp": 8,
929
+ "fn": 16,
930
+ "precision": 0.9272727272727272,
931
+ "recall": 0.864406779661017,
932
+ "f1": 0.8947368421052632,
933
+ "gt_count": 118,
934
+ "pred_count": 110
935
+ },
936
+ "Nostril External of Nucleus": {
937
+ "tp": 39,
938
+ "fp": 2,
939
+ "fn": 5,
940
+ "precision": 0.9512195121951219,
941
+ "recall": 0.8863636363636364,
942
+ "f1": 0.9176470588235294,
943
+ "gt_count": 44,
944
+ "pred_count": 41
945
+ }
946
+ },
947
+ "n_gt": 162,
948
+ "n_pred": 151
949
+ },
950
+ "counts": {
951
+ "Cell, Antibody +": {
952
+ "gt": 87,
953
+ "pred": 96,
954
+ "abs_err": 9,
955
+ "rel_err": 0.10344827586206896
956
+ },
957
+ "Cell, Antibody -": {
958
+ "gt": 6,
959
+ "pred": 6,
960
+ "abs_err": 0,
961
+ "rel_err": 0.0
962
+ },
963
+ "Cell, Nostril, Antibody +": {
964
+ "gt": 93,
965
+ "pred": 79,
966
+ "abs_err": 14,
967
+ "rel_err": 0.15053763440860216
968
+ },
969
+ "Cell, Nostril, Antibody -": {
970
+ "gt": 0,
971
+ "pred": 0,
972
+ "abs_err": 0,
973
+ "rel_err": 0.0
974
+ },
975
+ "Nostril in Nucleus": {
976
+ "gt": 118,
977
+ "pred": 110,
978
+ "abs_err": 8,
979
+ "rel_err": 0.06779661016949153
980
+ },
981
+ "Nostril External of Nucleus": {
982
+ "gt": 44,
983
+ "pred": 41,
984
+ "abs_err": 3,
985
+ "rel_err": 0.06818181818181818
986
+ }
987
+ },
988
+ "fp_analysis": null,
989
+ "pairs": null,
990
+ "summary": {
991
+ "cell_det_f1": 0.871934604904632,
992
+ "cell_det_precision": 0.8839779005524862,
993
+ "cell_det_recall": 0.8602150537634409,
994
+ "cell_mean_iou": 0.8049454720324757,
995
+ "cell_det_f1_iou30": 0.9209809264305177,
996
+ "cell_det_f1_centroid": 0.9155313351498637,
997
+ "cell_attr_antibody": 0.98125,
998
+ "cell_attr_nostril": 0.98125,
999
+ "cell_attr_both": 0.9625,
1000
+ "nos_det_f1": 0.9073482428115016,
1001
+ "nos_det_precision": 0.9403973509933775,
1002
+ "nos_det_recall": 0.8765432098765432,
1003
+ "nos_type_acc": 0.9929577464788732,
1004
+ "f1[Cell, Antibody +]": 0.8633879781420765,
1005
+ "f1[Cell, Antibody -]": 0.6666666666666666,
1006
+ "f1[Cell, Nostril, Antibody +]": 0.8255813953488371,
1007
+ "f1[Cell, Nostril, Antibody -]": 0.0,
1008
+ "f1[Nostril in Nucleus]": 0.8947368421052632,
1009
+ "f1[Nostril External of Nucleus]": 0.9176470588235294
1010
+ }
1011
+ },
1012
+ "F110": {
1013
+ "cells": {
1014
+ "detection": {
1015
+ "tp": 116,
1016
+ "fp": 9,
1017
+ "fn": 12,
1018
+ "precision": 0.928,
1019
+ "recall": 0.90625,
1020
+ "f1": 0.9169960474308302,
1021
+ "mean_iou_matched": 0.8161455695675424,
1022
+ "iou30": {
1023
+ "tp": 120,
1024
+ "fp": 5,
1025
+ "fn": 8,
1026
+ "precision": 0.96,
1027
+ "recall": 0.9375,
1028
+ "f1": 0.9486166007905138
1029
+ },
1030
+ "centroid": {
1031
+ "tp": 121,
1032
+ "fp": 4,
1033
+ "fn": 7,
1034
+ "precision": 0.968,
1035
+ "recall": 0.9453125,
1036
+ "f1": 0.9565217391304348
1037
+ }
1038
+ },
1039
+ "attribute_accuracy": {
1040
+ "antibody": 0.9827586206896551,
1041
+ "nostril": 0.9655172413793104,
1042
+ "both": 0.9482758620689655,
1043
+ "n_matched": 116
1044
+ },
1045
+ "per_class": {
1046
+ "Cell, Antibody +": {
1047
+ "tp": 82,
1048
+ "fp": 10,
1049
+ "fn": 5,
1050
+ "precision": 0.8913043478260869,
1051
+ "recall": 0.9425287356321839,
1052
+ "f1": 0.9162011173184358,
1053
+ "gt_count": 87,
1054
+ "pred_count": 92
1055
+ },
1056
+ "Cell, Antibody -": {
1057
+ "tp": 5,
1058
+ "fp": 0,
1059
+ "fn": 2,
1060
+ "precision": 1.0,
1061
+ "recall": 0.7142857142857143,
1062
+ "f1": 0.8333333333333333,
1063
+ "gt_count": 7,
1064
+ "pred_count": 5
1065
+ },
1066
+ "Cell, Nostril, Antibody +": {
1067
+ "tp": 23,
1068
+ "fp": 5,
1069
+ "fn": 10,
1070
+ "precision": 0.8214285714285714,
1071
+ "recall": 0.696969696969697,
1072
+ "f1": 0.7540983606557378,
1073
+ "gt_count": 33,
1074
+ "pred_count": 28
1075
+ },
1076
+ "Cell, Nostril, Antibody -": {
1077
+ "tp": 0,
1078
+ "fp": 0,
1079
+ "fn": 1,
1080
+ "precision": 0.0,
1081
+ "recall": 0.0,
1082
+ "f1": 0.0,
1083
+ "gt_count": 1,
1084
+ "pred_count": 0
1085
+ }
1086
+ },
1087
+ "confusion": {
1088
+ "Cell, Antibody +": {
1089
+ "Cell, Antibody +": 82,
1090
+ "Cell, Antibody -": 0,
1091
+ "Cell, Nostril, Antibody +": 0,
1092
+ "Cell, Nostril, Antibody -": 0
1093
+ },
1094
+ "Cell, Antibody -": {
1095
+ "Cell, Antibody +": 2,
1096
+ "Cell, Antibody -": 5,
1097
+ "Cell, Nostril, Antibody +": 0,
1098
+ "Cell, Nostril, Antibody -": 0
1099
+ },
1100
+ "Cell, Nostril, Antibody +": {
1101
+ "Cell, Antibody +": 4,
1102
+ "Cell, Antibody -": 0,
1103
+ "Cell, Nostril, Antibody +": 23,
1104
+ "Cell, Nostril, Antibody -": 0
1105
+ },
1106
+ "Cell, Nostril, Antibody -": {
1107
+ "Cell, Antibody +": 0,
1108
+ "Cell, Antibody -": 0,
1109
+ "Cell, Nostril, Antibody +": 0,
1110
+ "Cell, Nostril, Antibody -": 0
1111
+ }
1112
+ },
1113
+ "n_gt": 128,
1114
+ "n_pred": 125
1115
+ },
1116
+ "nostrils": {
1117
+ "detection": {
1118
+ "tp": 157,
1119
+ "fp": 9,
1120
+ "fn": 37,
1121
+ "precision": 0.9457831325301205,
1122
+ "recall": 0.8092783505154639,
1123
+ "f1": 0.8722222222222222,
1124
+ "mean_dist_matched": 0.7899268230101212
1125
+ },
1126
+ "type_accuracy": 0.9936305732484076,
1127
+ "per_class": {
1128
+ "Nostril in Nucleus": {
1129
+ "tp": 48,
1130
+ "fp": 4,
1131
+ "fn": 9,
1132
+ "precision": 0.9230769230769231,
1133
+ "recall": 0.8421052631578947,
1134
+ "f1": 0.8807339449541285,
1135
+ "gt_count": 57,
1136
+ "pred_count": 52
1137
+ },
1138
+ "Nostril External of Nucleus": {
1139
+ "tp": 108,
1140
+ "fp": 6,
1141
+ "fn": 29,
1142
+ "precision": 0.9473684210526315,
1143
+ "recall": 0.7883211678832117,
1144
+ "f1": 0.8605577689243028,
1145
+ "gt_count": 137,
1146
+ "pred_count": 114
1147
+ }
1148
+ },
1149
+ "n_gt": 194,
1150
+ "n_pred": 166
1151
+ },
1152
+ "counts": {
1153
+ "Cell, Antibody +": {
1154
+ "gt": 87,
1155
+ "pred": 92,
1156
+ "abs_err": 5,
1157
+ "rel_err": 0.05747126436781609
1158
+ },
1159
+ "Cell, Antibody -": {
1160
+ "gt": 7,
1161
+ "pred": 5,
1162
+ "abs_err": 2,
1163
+ "rel_err": 0.2857142857142857
1164
+ },
1165
+ "Cell, Nostril, Antibody +": {
1166
+ "gt": 33,
1167
+ "pred": 28,
1168
+ "abs_err": 5,
1169
+ "rel_err": 0.15151515151515152
1170
+ },
1171
+ "Cell, Nostril, Antibody -": {
1172
+ "gt": 1,
1173
+ "pred": 0,
1174
+ "abs_err": 1,
1175
+ "rel_err": 1.0
1176
+ },
1177
+ "Nostril in Nucleus": {
1178
+ "gt": 57,
1179
+ "pred": 52,
1180
+ "abs_err": 5,
1181
+ "rel_err": 0.08771929824561403
1182
+ },
1183
+ "Nostril External of Nucleus": {
1184
+ "gt": 137,
1185
+ "pred": 114,
1186
+ "abs_err": 23,
1187
+ "rel_err": 0.1678832116788321
1188
+ }
1189
+ },
1190
+ "fp_analysis": null,
1191
+ "pairs": null,
1192
+ "summary": {
1193
+ "cell_det_f1": 0.9169960474308302,
1194
+ "cell_det_precision": 0.928,
1195
+ "cell_det_recall": 0.90625,
1196
+ "cell_mean_iou": 0.8161455695675424,
1197
+ "cell_det_f1_iou30": 0.9486166007905138,
1198
+ "cell_det_f1_centroid": 0.9565217391304348,
1199
+ "cell_attr_antibody": 0.9827586206896551,
1200
+ "cell_attr_nostril": 0.9655172413793104,
1201
+ "cell_attr_both": 0.9482758620689655,
1202
+ "nos_det_f1": 0.8722222222222222,
1203
+ "nos_det_precision": 0.9457831325301205,
1204
+ "nos_det_recall": 0.8092783505154639,
1205
+ "nos_type_acc": 0.9936305732484076,
1206
+ "f1[Cell, Antibody +]": 0.9162011173184358,
1207
+ "f1[Cell, Antibody -]": 0.8333333333333333,
1208
+ "f1[Cell, Nostril, Antibody +]": 0.7540983606557378,
1209
+ "f1[Cell, Nostril, Antibody -]": 0.0,
1210
+ "f1[Nostril in Nucleus]": 0.8807339449541285,
1211
+ "f1[Nostril External of Nucleus]": 0.8605577689243028
1212
+ }
1213
+ },
1214
+ "F113": {
1215
+ "cells": {
1216
+ "detection": {
1217
+ "tp": 131,
1218
+ "fp": 19,
1219
+ "fn": 32,
1220
+ "precision": 0.8733333333333333,
1221
+ "recall": 0.803680981595092,
1222
+ "f1": 0.8370607028753994,
1223
+ "mean_iou_matched": 0.7799368783421308,
1224
+ "iou30": {
1225
+ "tp": 146,
1226
+ "fp": 4,
1227
+ "fn": 17,
1228
+ "precision": 0.9733333333333334,
1229
+ "recall": 0.8957055214723927,
1230
+ "f1": 0.9329073482428116
1231
+ },
1232
+ "centroid": {
1233
+ "tp": 144,
1234
+ "fp": 6,
1235
+ "fn": 19,
1236
+ "precision": 0.96,
1237
+ "recall": 0.8834355828220859,
1238
+ "f1": 0.9201277955271565
1239
+ }
1240
+ },
1241
+ "attribute_accuracy": {
1242
+ "antibody": 1.0,
1243
+ "nostril": 0.9770992366412213,
1244
+ "both": 0.9770992366412213,
1245
+ "n_matched": 131
1246
+ },
1247
+ "per_class": {
1248
+ "Cell, Antibody +": {
1249
+ "tp": 108,
1250
+ "fp": 21,
1251
+ "fn": 30,
1252
+ "precision": 0.8372093023255814,
1253
+ "recall": 0.782608695652174,
1254
+ "f1": 0.8089887640449438,
1255
+ "gt_count": 138,
1256
+ "pred_count": 129
1257
+ },
1258
+ "Cell, Antibody -": {
1259
+ "tp": 2,
1260
+ "fp": 0,
1261
+ "fn": 1,
1262
+ "precision": 1.0,
1263
+ "recall": 0.6666666666666666,
1264
+ "f1": 0.8,
1265
+ "gt_count": 3,
1266
+ "pred_count": 2
1267
+ },
1268
+ "Cell, Nostril, Antibody +": {
1269
+ "tp": 18,
1270
+ "fp": 1,
1271
+ "fn": 4,
1272
+ "precision": 0.9473684210526315,
1273
+ "recall": 0.8181818181818182,
1274
+ "f1": 0.8780487804878049,
1275
+ "gt_count": 22,
1276
+ "pred_count": 19
1277
+ },
1278
+ "Cell, Nostril, Antibody -": {
1279
+ "tp": 0,
1280
+ "fp": 0,
1281
+ "fn": 0,
1282
+ "precision": 0.0,
1283
+ "recall": 0.0,
1284
+ "f1": 0.0,
1285
+ "gt_count": 0,
1286
+ "pred_count": 0
1287
+ }
1288
+ },
1289
+ "confusion": {
1290
+ "Cell, Antibody +": {
1291
+ "Cell, Antibody +": 108,
1292
+ "Cell, Antibody -": 0,
1293
+ "Cell, Nostril, Antibody +": 0,
1294
+ "Cell, Nostril, Antibody -": 0
1295
+ },
1296
+ "Cell, Antibody -": {
1297
+ "Cell, Antibody +": 0,
1298
+ "Cell, Antibody -": 2,
1299
+ "Cell, Nostril, Antibody +": 0,
1300
+ "Cell, Nostril, Antibody -": 0
1301
+ },
1302
+ "Cell, Nostril, Antibody +": {
1303
+ "Cell, Antibody +": 3,
1304
+ "Cell, Antibody -": 0,
1305
+ "Cell, Nostril, Antibody +": 18,
1306
+ "Cell, Nostril, Antibody -": 0
1307
+ },
1308
+ "Cell, Nostril, Antibody -": {
1309
+ "Cell, Antibody +": 0,
1310
+ "Cell, Antibody -": 0,
1311
+ "Cell, Nostril, Antibody +": 0,
1312
+ "Cell, Nostril, Antibody -": 0
1313
+ }
1314
+ },
1315
+ "n_gt": 163,
1316
+ "n_pred": 150
1317
+ },
1318
+ "nostrils": {
1319
+ "detection": {
1320
+ "tp": 27,
1321
+ "fp": 1,
1322
+ "fn": 7,
1323
+ "precision": 0.9642857142857143,
1324
+ "recall": 0.7941176470588235,
1325
+ "f1": 0.8709677419354839,
1326
+ "mean_dist_matched": 0.8145690279889126
1327
+ },
1328
+ "type_accuracy": 0.9259259259259259,
1329
+ "per_class": {
1330
+ "Nostril in Nucleus": {
1331
+ "tp": 21,
1332
+ "fp": 2,
1333
+ "fn": 5,
1334
+ "precision": 0.9130434782608695,
1335
+ "recall": 0.8076923076923077,
1336
+ "f1": 0.8571428571428572,
1337
+ "gt_count": 26,
1338
+ "pred_count": 23
1339
+ },
1340
+ "Nostril External of Nucleus": {
1341
+ "tp": 4,
1342
+ "fp": 1,
1343
+ "fn": 4,
1344
+ "precision": 0.8,
1345
+ "recall": 0.5,
1346
+ "f1": 0.6153846153846154,
1347
+ "gt_count": 8,
1348
+ "pred_count": 5
1349
+ }
1350
+ },
1351
+ "n_gt": 34,
1352
+ "n_pred": 28
1353
+ },
1354
+ "counts": {
1355
+ "Cell, Antibody +": {
1356
+ "gt": 138,
1357
+ "pred": 129,
1358
+ "abs_err": 9,
1359
+ "rel_err": 0.06521739130434782
1360
+ },
1361
+ "Cell, Antibody -": {
1362
+ "gt": 3,
1363
+ "pred": 2,
1364
+ "abs_err": 1,
1365
+ "rel_err": 0.3333333333333333
1366
+ },
1367
+ "Cell, Nostril, Antibody +": {
1368
+ "gt": 22,
1369
+ "pred": 19,
1370
+ "abs_err": 3,
1371
+ "rel_err": 0.13636363636363635
1372
+ },
1373
+ "Cell, Nostril, Antibody -": {
1374
+ "gt": 0,
1375
+ "pred": 0,
1376
+ "abs_err": 0,
1377
+ "rel_err": 0.0
1378
+ },
1379
+ "Nostril in Nucleus": {
1380
+ "gt": 26,
1381
+ "pred": 23,
1382
+ "abs_err": 3,
1383
+ "rel_err": 0.11538461538461539
1384
+ },
1385
+ "Nostril External of Nucleus": {
1386
+ "gt": 8,
1387
+ "pred": 5,
1388
+ "abs_err": 3,
1389
+ "rel_err": 0.375
1390
+ }
1391
+ },
1392
+ "fp_analysis": null,
1393
+ "pairs": null,
1394
+ "summary": {
1395
+ "cell_det_f1": 0.8370607028753994,
1396
+ "cell_det_precision": 0.8733333333333333,
1397
+ "cell_det_recall": 0.803680981595092,
1398
+ "cell_mean_iou": 0.7799368783421308,
1399
+ "cell_det_f1_iou30": 0.9329073482428116,
1400
+ "cell_det_f1_centroid": 0.9201277955271565,
1401
+ "cell_attr_antibody": 1.0,
1402
+ "cell_attr_nostril": 0.9770992366412213,
1403
+ "cell_attr_both": 0.9770992366412213,
1404
+ "nos_det_f1": 0.8709677419354839,
1405
+ "nos_det_precision": 0.9642857142857143,
1406
+ "nos_det_recall": 0.7941176470588235,
1407
+ "nos_type_acc": 0.9259259259259259,
1408
+ "f1[Cell, Antibody +]": 0.8089887640449438,
1409
+ "f1[Cell, Antibody -]": 0.8,
1410
+ "f1[Cell, Nostril, Antibody +]": 0.8780487804878049,
1411
+ "f1[Cell, Nostril, Antibody -]": 0.0,
1412
+ "f1[Nostril in Nucleus]": 0.8571428571428572,
1413
+ "f1[Nostril External of Nucleus]": 0.6153846153846154
1414
+ }
1415
+ },
1416
+ "F114": {
1417
+ "cells": {
1418
+ "detection": {
1419
+ "tp": 211,
1420
+ "fp": 25,
1421
+ "fn": 31,
1422
+ "precision": 0.8940677966101694,
1423
+ "recall": 0.871900826446281,
1424
+ "f1": 0.8828451882845189,
1425
+ "mean_iou_matched": 0.8168843572713155,
1426
+ "iou30": {
1427
+ "tp": 222,
1428
+ "fp": 14,
1429
+ "fn": 20,
1430
+ "precision": 0.940677966101695,
1431
+ "recall": 0.9173553719008265,
1432
+ "f1": 0.9288702928870294
1433
+ },
1434
+ "centroid": {
1435
+ "tp": 221,
1436
+ "fp": 15,
1437
+ "fn": 21,
1438
+ "precision": 0.9364406779661016,
1439
+ "recall": 0.9132231404958677,
1440
+ "f1": 0.9246861924686192
1441
+ }
1442
+ },
1443
+ "attribute_accuracy": {
1444
+ "antibody": 0.95260663507109,
1445
+ "nostril": 0.981042654028436,
1446
+ "both": 0.933649289099526,
1447
+ "n_matched": 211
1448
+ },
1449
+ "per_class": {
1450
+ "Cell, Antibody +": {
1451
+ "tp": 148,
1452
+ "fp": 28,
1453
+ "fn": 27,
1454
+ "precision": 0.8409090909090909,
1455
+ "recall": 0.8457142857142858,
1456
+ "f1": 0.8433048433048433,
1457
+ "gt_count": 175,
1458
+ "pred_count": 176
1459
+ },
1460
+ "Cell, Antibody -": {
1461
+ "tp": 12,
1462
+ "fp": 2,
1463
+ "fn": 9,
1464
+ "precision": 0.8571428571428571,
1465
+ "recall": 0.5714285714285714,
1466
+ "f1": 0.6857142857142857,
1467
+ "gt_count": 21,
1468
+ "pred_count": 14
1469
+ },
1470
+ "Cell, Nostril, Antibody +": {
1471
+ "tp": 37,
1472
+ "fp": 9,
1473
+ "fn": 5,
1474
+ "precision": 0.8043478260869565,
1475
+ "recall": 0.8809523809523809,
1476
+ "f1": 0.8409090909090908,
1477
+ "gt_count": 42,
1478
+ "pred_count": 46
1479
+ },
1480
+ "Cell, Nostril, Antibody -": {
1481
+ "tp": 0,
1482
+ "fp": 0,
1483
+ "fn": 4,
1484
+ "precision": 0.0,
1485
+ "recall": 0.0,
1486
+ "f1": 0.0,
1487
+ "gt_count": 4,
1488
+ "pred_count": 0
1489
+ }
1490
+ },
1491
+ "confusion": {
1492
+ "Cell, Antibody +": {
1493
+ "Cell, Antibody +": 148,
1494
+ "Cell, Antibody -": 1,
1495
+ "Cell, Nostril, Antibody +": 4,
1496
+ "Cell, Nostril, Antibody -": 0
1497
+ },
1498
+ "Cell, Antibody -": {
1499
+ "Cell, Antibody +": 9,
1500
+ "Cell, Antibody -": 12,
1501
+ "Cell, Nostril, Antibody +": 0,
1502
+ "Cell, Nostril, Antibody -": 0
1503
+ },
1504
+ "Cell, Nostril, Antibody +": {
1505
+ "Cell, Antibody +": 0,
1506
+ "Cell, Antibody -": 0,
1507
+ "Cell, Nostril, Antibody +": 37,
1508
+ "Cell, Nostril, Antibody -": 0
1509
+ },
1510
+ "Cell, Nostril, Antibody -": {
1511
+ "Cell, Antibody +": 0,
1512
+ "Cell, Antibody -": 0,
1513
+ "Cell, Nostril, Antibody +": 0,
1514
+ "Cell, Nostril, Antibody -": 0
1515
+ }
1516
+ },
1517
+ "n_gt": 242,
1518
+ "n_pred": 236
1519
+ },
1520
+ "nostrils": {
1521
+ "detection": {
1522
+ "tp": 81,
1523
+ "fp": 24,
1524
+ "fn": 9,
1525
+ "precision": 0.7714285714285715,
1526
+ "recall": 0.9,
1527
+ "f1": 0.8307692307692307,
1528
+ "mean_dist_matched": 0.9417483459046142
1529
+ },
1530
+ "type_accuracy": 1.0,
1531
+ "per_class": {
1532
+ "Nostril in Nucleus": {
1533
+ "tp": 46,
1534
+ "fp": 8,
1535
+ "fn": 6,
1536
+ "precision": 0.8518518518518519,
1537
+ "recall": 0.8846153846153846,
1538
+ "f1": 0.8679245283018868,
1539
+ "gt_count": 52,
1540
+ "pred_count": 54
1541
+ },
1542
+ "Nostril External of Nucleus": {
1543
+ "tp": 35,
1544
+ "fp": 16,
1545
+ "fn": 3,
1546
+ "precision": 0.6862745098039216,
1547
+ "recall": 0.9210526315789473,
1548
+ "f1": 0.7865168539325842,
1549
+ "gt_count": 38,
1550
+ "pred_count": 51
1551
+ }
1552
+ },
1553
+ "n_gt": 90,
1554
+ "n_pred": 105
1555
+ },
1556
+ "counts": {
1557
+ "Cell, Antibody +": {
1558
+ "gt": 175,
1559
+ "pred": 176,
1560
+ "abs_err": 1,
1561
+ "rel_err": 0.005714285714285714
1562
+ },
1563
+ "Cell, Antibody -": {
1564
+ "gt": 21,
1565
+ "pred": 14,
1566
+ "abs_err": 7,
1567
+ "rel_err": 0.3333333333333333
1568
+ },
1569
+ "Cell, Nostril, Antibody +": {
1570
+ "gt": 42,
1571
+ "pred": 46,
1572
+ "abs_err": 4,
1573
+ "rel_err": 0.09523809523809523
1574
+ },
1575
+ "Cell, Nostril, Antibody -": {
1576
+ "gt": 4,
1577
+ "pred": 0,
1578
+ "abs_err": 4,
1579
+ "rel_err": 1.0
1580
+ },
1581
+ "Nostril in Nucleus": {
1582
+ "gt": 52,
1583
+ "pred": 54,
1584
+ "abs_err": 2,
1585
+ "rel_err": 0.038461538461538464
1586
+ },
1587
+ "Nostril External of Nucleus": {
1588
+ "gt": 38,
1589
+ "pred": 51,
1590
+ "abs_err": 13,
1591
+ "rel_err": 0.34210526315789475
1592
+ }
1593
+ },
1594
+ "fp_analysis": null,
1595
+ "pairs": null,
1596
+ "summary": {
1597
+ "cell_det_f1": 0.8828451882845189,
1598
+ "cell_det_precision": 0.8940677966101694,
1599
+ "cell_det_recall": 0.871900826446281,
1600
+ "cell_mean_iou": 0.8168843572713155,
1601
+ "cell_det_f1_iou30": 0.9288702928870294,
1602
+ "cell_det_f1_centroid": 0.9246861924686192,
1603
+ "cell_attr_antibody": 0.95260663507109,
1604
+ "cell_attr_nostril": 0.981042654028436,
1605
+ "cell_attr_both": 0.933649289099526,
1606
+ "nos_det_f1": 0.8307692307692307,
1607
+ "nos_det_precision": 0.7714285714285715,
1608
+ "nos_det_recall": 0.9,
1609
+ "nos_type_acc": 1.0,
1610
+ "f1[Cell, Antibody +]": 0.8433048433048433,
1611
+ "f1[Cell, Antibody -]": 0.6857142857142857,
1612
+ "f1[Cell, Nostril, Antibody +]": 0.8409090909090908,
1613
+ "f1[Cell, Nostril, Antibody -]": 0.0,
1614
+ "f1[Nostril in Nucleus]": 0.8679245283018868,
1615
+ "f1[Nostril External of Nucleus]": 0.7865168539325842
1616
+ }
1617
+ },
1618
+ "F99": {
1619
+ "cells": {
1620
+ "detection": {
1621
+ "tp": 227,
1622
+ "fp": 28,
1623
+ "fn": 24,
1624
+ "precision": 0.8901960784313725,
1625
+ "recall": 0.9043824701195219,
1626
+ "f1": 0.8972332015810275,
1627
+ "mean_iou_matched": 0.8280746396024866,
1628
+ "iou30": {
1629
+ "tp": 238,
1630
+ "fp": 17,
1631
+ "fn": 13,
1632
+ "precision": 0.9333333333333333,
1633
+ "recall": 0.9482071713147411,
1634
+ "f1": 0.9407114624505929
1635
+ },
1636
+ "centroid": {
1637
+ "tp": 238,
1638
+ "fp": 17,
1639
+ "fn": 13,
1640
+ "precision": 0.9333333333333333,
1641
+ "recall": 0.9482071713147411,
1642
+ "f1": 0.9407114624505929
1643
+ }
1644
+ },
1645
+ "attribute_accuracy": {
1646
+ "antibody": 0.920704845814978,
1647
+ "nostril": 0.9911894273127754,
1648
+ "both": 0.9118942731277533,
1649
+ "n_matched": 227
1650
+ },
1651
+ "per_class": {
1652
+ "Cell, Antibody +": {
1653
+ "tp": 162,
1654
+ "fp": 41,
1655
+ "fn": 21,
1656
+ "precision": 0.7980295566502463,
1657
+ "recall": 0.8852459016393442,
1658
+ "f1": 0.839378238341969,
1659
+ "gt_count": 183,
1660
+ "pred_count": 203
1661
+ },
1662
+ "Cell, Antibody -": {
1663
+ "tp": 42,
1664
+ "fp": 6,
1665
+ "fn": 20,
1666
+ "precision": 0.875,
1667
+ "recall": 0.6774193548387096,
1668
+ "f1": 0.7636363636363636,
1669
+ "gt_count": 62,
1670
+ "pred_count": 48
1671
+ },
1672
+ "Cell, Nostril, Antibody +": {
1673
+ "tp": 3,
1674
+ "fp": 1,
1675
+ "fn": 3,
1676
+ "precision": 0.75,
1677
+ "recall": 0.5,
1678
+ "f1": 0.6,
1679
+ "gt_count": 6,
1680
+ "pred_count": 4
1681
+ },
1682
+ "Cell, Nostril, Antibody -": {
1683
+ "tp": 0,
1684
+ "fp": 0,
1685
+ "fn": 0,
1686
+ "precision": 0.0,
1687
+ "recall": 0.0,
1688
+ "f1": 0.0,
1689
+ "gt_count": 0,
1690
+ "pred_count": 0
1691
+ }
1692
+ },
1693
+ "confusion": {
1694
+ "Cell, Antibody +": {
1695
+ "Cell, Antibody +": 162,
1696
+ "Cell, Antibody -": 4,
1697
+ "Cell, Nostril, Antibody +": 0,
1698
+ "Cell, Nostril, Antibody -": 0
1699
+ },
1700
+ "Cell, Antibody -": {
1701
+ "Cell, Antibody +": 14,
1702
+ "Cell, Antibody -": 42,
1703
+ "Cell, Nostril, Antibody +": 0,
1704
+ "Cell, Nostril, Antibody -": 0
1705
+ },
1706
+ "Cell, Nostril, Antibody +": {
1707
+ "Cell, Antibody +": 2,
1708
+ "Cell, Antibody -": 0,
1709
+ "Cell, Nostril, Antibody +": 3,
1710
+ "Cell, Nostril, Antibody -": 0
1711
+ },
1712
+ "Cell, Nostril, Antibody -": {
1713
+ "Cell, Antibody +": 0,
1714
+ "Cell, Antibody -": 0,
1715
+ "Cell, Nostril, Antibody +": 0,
1716
+ "Cell, Nostril, Antibody -": 0
1717
+ }
1718
+ },
1719
+ "n_gt": 251,
1720
+ "n_pred": 255
1721
+ },
1722
+ "nostrils": {
1723
+ "detection": {
1724
+ "tp": 14,
1725
+ "fp": 1,
1726
+ "fn": 8,
1727
+ "precision": 0.9333333333333333,
1728
+ "recall": 0.6363636363636364,
1729
+ "f1": 0.7567567567567568,
1730
+ "mean_dist_matched": 0.6631874114708707
1731
+ },
1732
+ "type_accuracy": 1.0,
1733
+ "per_class": {
1734
+ "Nostril in Nucleus": {
1735
+ "tp": 4,
1736
+ "fp": 0,
1737
+ "fn": 2,
1738
+ "precision": 1.0,
1739
+ "recall": 0.6666666666666666,
1740
+ "f1": 0.8,
1741
+ "gt_count": 6,
1742
+ "pred_count": 4
1743
+ },
1744
+ "Nostril External of Nucleus": {
1745
+ "tp": 10,
1746
+ "fp": 1,
1747
+ "fn": 6,
1748
+ "precision": 0.9090909090909091,
1749
+ "recall": 0.625,
1750
+ "f1": 0.7407407407407406,
1751
+ "gt_count": 16,
1752
+ "pred_count": 11
1753
+ }
1754
+ },
1755
+ "n_gt": 22,
1756
+ "n_pred": 15
1757
+ },
1758
+ "counts": {
1759
+ "Cell, Antibody +": {
1760
+ "gt": 183,
1761
+ "pred": 203,
1762
+ "abs_err": 20,
1763
+ "rel_err": 0.1092896174863388
1764
+ },
1765
+ "Cell, Antibody -": {
1766
+ "gt": 62,
1767
+ "pred": 48,
1768
+ "abs_err": 14,
1769
+ "rel_err": 0.22580645161290322
1770
+ },
1771
+ "Cell, Nostril, Antibody +": {
1772
+ "gt": 6,
1773
+ "pred": 4,
1774
+ "abs_err": 2,
1775
+ "rel_err": 0.3333333333333333
1776
+ },
1777
+ "Cell, Nostril, Antibody -": {
1778
+ "gt": 0,
1779
+ "pred": 0,
1780
+ "abs_err": 0,
1781
+ "rel_err": 0.0
1782
+ },
1783
+ "Nostril in Nucleus": {
1784
+ "gt": 6,
1785
+ "pred": 4,
1786
+ "abs_err": 2,
1787
+ "rel_err": 0.3333333333333333
1788
+ },
1789
+ "Nostril External of Nucleus": {
1790
+ "gt": 16,
1791
+ "pred": 11,
1792
+ "abs_err": 5,
1793
+ "rel_err": 0.3125
1794
+ }
1795
+ },
1796
+ "fp_analysis": null,
1797
+ "pairs": null,
1798
+ "summary": {
1799
+ "cell_det_f1": 0.8972332015810275,
1800
+ "cell_det_precision": 0.8901960784313725,
1801
+ "cell_det_recall": 0.9043824701195219,
1802
+ "cell_mean_iou": 0.8280746396024866,
1803
+ "cell_det_f1_iou30": 0.9407114624505929,
1804
+ "cell_det_f1_centroid": 0.9407114624505929,
1805
+ "cell_attr_antibody": 0.920704845814978,
1806
+ "cell_attr_nostril": 0.9911894273127754,
1807
+ "cell_attr_both": 0.9118942731277533,
1808
+ "nos_det_f1": 0.7567567567567568,
1809
+ "nos_det_precision": 0.9333333333333333,
1810
+ "nos_det_recall": 0.6363636363636364,
1811
+ "nos_type_acc": 1.0,
1812
+ "f1[Cell, Antibody +]": 0.839378238341969,
1813
+ "f1[Cell, Antibody -]": 0.7636363636363636,
1814
+ "f1[Cell, Nostril, Antibody +]": 0.6,
1815
+ "f1[Cell, Nostril, Antibody -]": 0.0,
1816
+ "f1[Nostril in Nucleus]": 0.8,
1817
+ "f1[Nostril External of Nucleus]": 0.7407407407407406
1818
+ }
1819
+ }
1820
+ }
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
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
7
+ scikit-image>=0.22
8
+ tifffile>=2023.7
9
+ imagecodecs>=2023.7
10
+ Pillow>=10
11
+ gradio>=5.0
12
+ google-genai>=1.0
scripts/build_summary.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Write reports/SUMMARY.md from runs/cv/cv_results.json and reports/final/final_fit.json."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10
+ sys.path.insert(0, ROOT)
11
+ from cellquant.labels import ALL_LABELS, CELL_LABELS, NOSTRIL_LABELS # noqa: E402
12
+
13
+
14
+ def pct(x: float) -> str:
15
+ return f"{100 * x:.1f}%"
16
+
17
+
18
+ def main():
19
+ cv = json.load(open(os.path.join(ROOT, "runs", "cv", "cv_results.json")))
20
+ agg = cv["aggregate"]
21
+ per = agg["per_image"]
22
+ fit = json.load(open(os.path.join(ROOT, "reports", "final", "final_fit.json")))
23
+ tot = agg["dataset_totals"]
24
+ n_nuc = sum(tot[c] for c in CELL_LABELS)
25
+ n_pun = sum(tot[c] for c in NOSTRIL_LABELS)
26
+ det = agg["detection_base"]
27
+ det8 = agg["detection_tta8"]
28
+ L = ["# CellQuant: validation summary", "",
29
+ f"Data: {len(per)} annotated 1024×1024 images, {n_nuc} nucleus outlines and {n_pun} punctum outlines.",
30
+ "Protocol: leave-one-image-out cross-validation (one model per image, 3000 iterations each, held-out image never seen in training), "
31
+ "then a final model trained on all images with the same recipe. Each held-out image was tested 23 times: 3 identical runs "
32
+ "(determinism), no-TTA / 4× / 8× test-time augmentation, 8 rotations and flips, 6 photometric perturbations, and 6 post-processing rule variants. "
33
+ "The only choice made on held-out data is the rule that gives a nucleus the Nostril attribute: of three candidate rules, "
34
+ "'contains an in-nucleus punctum' (the annotation protocol) scored best and is the default; see the rule-variant table in cv_report.md.", "",
35
+ "## Headline numbers (micro-averaged over all held-out images, no TTA)", "",
36
+ "| Measure | Precision | Recall | F1 |", "|---|---:|---:|---:|",
37
+ f"| Nucleus detection, IoU ≥ 0.5 | {pct(det['cells']['precision'])} | {pct(det['cells']['recall'])} | {pct(det['cells']['f1'])} |",
38
+ f"| Nucleus detection, IoU ≥ 0.3 | {pct(det['cells_iou30']['precision'])} | {pct(det['cells_iou30']['recall'])} | {pct(det['cells_iou30']['f1'])} |",
39
+ f"| Nucleus detection, centroid inside GT nucleus | {pct(det['cells_centroid']['precision'])} | {pct(det['cells_centroid']['recall'])} | {pct(det['cells_centroid']['f1'])} |",
40
+ f"| Punctum detection (centroid ≤ 6 px) | {pct(det['nostrils']['precision'])} | {pct(det['nostrils']['recall'])} | {pct(det['nostrils']['f1'])} |",
41
+ f"| Nucleus detection, IoU ≥ 0.5, 8× TTA | {pct(det8['cells']['precision'])} | {pct(det8['cells']['recall'])} | {pct(det8['cells']['f1'])} |",
42
+ "", "Per-class F1 (a true positive needs a matched object **and** the right label):", "",
43
+ "| Label | GT | Pred | F1 |", "|---|---:|---:|---:|"]
44
+ for lab in ALL_LABELS:
45
+ r = agg["micro_base"][lab]
46
+ L.append(f"| {lab} | {r['gt_count']} | {r['pred_count']} | {pct(r['f1'])} |")
47
+ L += ["", "## Per held-out image", "",
48
+ "| Image | Nuclei F1 | Antibody acc | Nostril-attr acc | Puncta F1 | Nuclei F1 over 14 perturbed runs (mean ± sd) | Deterministic |",
49
+ "|---|---:|---:|---:|---:|---|---|"]
50
+ for k, s in per.items():
51
+ b = s["base"]; r = s["robustness"]["cell_det_f1"]
52
+ L.append(f"| {k} | {pct(b['cell_det_f1'])} | {pct(b['cell_attr_antibody'])} | {pct(b['cell_attr_nostril'])} | {pct(b['nos_det_f1'])} | "
53
+ f"{100*r['mean']:.1f} ± {100*r['std']:.1f} | {'yes' if s['deterministic'] else 'no'} |")
54
+ L += ["", "## Final model (trained on all images) – fit on its own training images, NOT a generalisation estimate", "",
55
+ "| Image | Nuclei F1 | Attribute acc | Puncta F1 |", "|---|---:|---:|---:|"]
56
+ for k, v in fit.items():
57
+ if "summary" in v:
58
+ s = v["summary"]
59
+ L.append(f"| {k} | {pct(s['cell_det_f1'])} | {pct(s['cell_attr_both'])} | {pct(s['nos_det_f1'])} |")
60
+ sw = agg.get("antibody_threshold_sweep")
61
+ if sw:
62
+ best = max(sw["sweep"], key=lambda r: r["neg_f1"])
63
+ L += ["", f"## Antibody − threshold (held-out matched nuclei: {sw['n_matched']}, of which {sw['n_negative']} negative)", "",
64
+ f"Default threshold 0.5. Best Antibody − F1 in the sweep: {pct(best['neg_f1'])} at threshold {best['thr']:.2f} "
65
+ f"(precision {pct(best['neg_precision'])}, recall {pct(best['neg_recall'])}); the full curve is in cv_report.md. "
66
+ "The default was kept because the class is dominated by a single image (F99 holds 62 of the 102 negatives)."]
67
+ L += ["", "## What limits the scores", "",
68
+ "* **Touching nuclei are sometimes merged** into one outline (worst on F113); this is the main source of IoU-0.5 misses. The marker/threshold sweep (`runs/cv/postprocess_sweep.json`) is flat, so it is a network-border issue, not a post-processing one.",
69
+ "* **Annotation completeness**: most unmatched predictions overlap an annotated nucleus (outline disagreement) and the rest are dimmer, smaller objects the annotator skipped; many faint red dots were likewise not annotated, which caps external-punctum precision.",
70
+ "* **Antibody − is rare** (102 of 1569 nuclei, 62 in one image), and **Cell, Nostril, Antibody −** has five examples; their scores are not reliable.",
71
+ "* Scores are from nine images of one experiment (35-day spinal cord, Iba1/TMEM119). New staining batches or magnifications need a check against a few annotated images.",
72
+ "", "## Reproduce", "",
73
+ "```bash", "python evaluate.py cv --data-dir <folder with TIFs and CVAT zips> --runs-dir runs/cv --iters 3000",
74
+ "python evaluate.py final --data-dir <folder> --weights weights/cellquant.pt", "python scripts/build_summary.py", "```", ""]
75
+ out = os.path.join(ROOT, "reports", "SUMMARY.md")
76
+ with open(out, "w") as f:
77
+ f.write("\n".join(L))
78
+ print("wrote", out)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
scripts/deploy_hf.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create / update the CellQuant Space on Hugging Face and wait until it runs.
3
+
4
+ python scripts/deploy_hf.py --repo <user>/cellquant [--public] [--no-wait]
5
+
6
+ Uses the token from `hf auth login` (or HF_TOKEN). Large files (the .pt checkpoint)
7
+ are uploaded through LFS automatically. Then polls the Space runtime and, once it is
8
+ RUNNING, calls its API with the bundled example image to prove it works.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ import time
16
+
17
+ from huggingface_hub import HfApi, get_token, whoami
18
+
19
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20
+ IGNORE = ["runs/**", "runs/*", ".pytest_cache/**", "**/__pycache__/**", "__pycache__/**", "*.pyc",
21
+ "reports/final/*.png", "reports/cv/*.png", "*.log", ".gitignore", ".DS_Store"]
22
+
23
+
24
+ def main(argv=None):
25
+ ap = argparse.ArgumentParser()
26
+ ap.add_argument("--repo", default=None, help="<user>/<space-name>; default <me>/cellquant")
27
+ ap.add_argument("--public", action="store_true")
28
+ ap.add_argument("--no-wait", action="store_true")
29
+ ap.add_argument("--timeout", type=int, default=1800)
30
+ a = ap.parse_args(argv)
31
+ api = HfApi()
32
+ me = whoami()["name"]
33
+ repo = a.repo or f"{me}/cellquant"
34
+ if not os.path.exists(os.path.join(ROOT, "weights", "cellquant.pt")):
35
+ raise SystemExit("weights/cellquant.pt missing: train first")
36
+ url = api.create_repo(repo, repo_type="space", space_sdk="gradio", private=not a.public, exist_ok=True)
37
+ print("space:", url)
38
+ api.upload_folder(folder_path=ROOT, repo_id=repo, repo_type="space", ignore_patterns=IGNORE,
39
+ commit_message="Deploy CellQuant")
40
+ print("uploaded")
41
+ if a.no_wait:
42
+ return
43
+ t0 = time.time()
44
+ last = None
45
+ while time.time() - t0 < a.timeout:
46
+ rt = api.get_space_runtime(repo)
47
+ stage = str(rt.stage)
48
+ if stage != last:
49
+ print(f"[{time.time()-t0:5.0f}s] stage {stage} hardware {rt.hardware}", flush=True)
50
+ last = stage
51
+ if stage.endswith("RUNNING"):
52
+ break
53
+ if "ERROR" in stage or stage.endswith("STOPPED"):
54
+ print("build failed; last log lines:")
55
+ try:
56
+ print(api.get_space_runtime(repo))
57
+ except Exception as e: # noqa: BLE001
58
+ print(e)
59
+ sys.exit(1)
60
+ time.sleep(15)
61
+ else:
62
+ raise SystemExit("timed out waiting for the Space")
63
+ # smoke test through the Gradio API
64
+ from gradio_client import Client, handle_file
65
+ client = Client(repo, hf_token=get_token())
66
+ ex = os.path.join(ROOT, "examples", "F100_crop_512.png")
67
+ t = time.time()
68
+ out = client.predict([handle_file(ex)], "none (fastest)", 0.5, 0.5, 50, "head", "contains", False, api_name="/analyze_files")
69
+ 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]}")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
scripts/postprocess_sweep.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Sweep post-processing thresholds on held-out fold predictions (no retraining).
3
+
4
+ python scripts/postprocess_sweep.py --data-dir DATA --runs-dir runs/cv [--keys A,B] [--device mps]
5
+
6
+ For every fold checkpoint the held-out image is predicted once; every PostConfig in
7
+ the grid is then scored on the same probabilities. Prints micro-averaged nucleus
8
+ detection F1 (IoU >= 0.5 and centroid rule) and per-class F1 per setting.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import itertools
14
+ import json
15
+ import os
16
+ import sys
17
+
18
+ import numpy as np
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+ from cellquant.annotations import discover, load_image, rasterize # noqa: E402
22
+ from cellquant.infer import Predictor # noqa: E402
23
+ from cellquant.labels import CELL_LABELS, NOSTRIL_LABELS # noqa: E402
24
+ from cellquant.metrics import evaluate_cells, evaluate_nostrils, micro_average, prf # noqa: E402
25
+ from cellquant.postprocess import PostConfig, analyze # noqa: E402
26
+
27
+
28
+ def main(argv=None):
29
+ ap = argparse.ArgumentParser()
30
+ ap.add_argument("--data-dir", required=True)
31
+ ap.add_argument("--runs-dir", default="runs/cv")
32
+ ap.add_argument("--keys", default="")
33
+ ap.add_argument("--device", default="auto")
34
+ ap.add_argument("--out", default="")
35
+ a = ap.parse_args(argv)
36
+ samples, _ = discover(a.data_dir, verbose=False)
37
+ keys = a.keys.split(",") if a.keys else [s.key for s in samples]
38
+ grid = [PostConfig(marker_thr=m, cell_fg_thr=f, min_marker_area=ma)
39
+ for m, f, ma in itertools.product((0.5, 0.6, 0.7, 0.8), (0.4, 0.5), (12, 30))]
40
+ per_cfg = {i: [] for i in range(len(grid))}
41
+ for s in samples:
42
+ if s.key not in keys:
43
+ continue
44
+ ck = os.path.join(a.runs_dir, f"fold_{s.key}.pt")
45
+ if not os.path.exists(ck):
46
+ print("missing", ck)
47
+ continue
48
+ img = load_image(s.image_path)
49
+ R = rasterize(s.annotation, img.shape[0], img.shape[1])
50
+ probs = Predictor(ck, device=a.device, tta=1).probs(img)
51
+ for i, cfg in enumerate(grid):
52
+ res = analyze(probs, cfg)
53
+ ce = evaluate_cells(R.cell_inst, R.cell_labels, res.cell_inst, res.cell_labels)
54
+ ne = evaluate_nostrils(R.nostril_inst, R.nostril_labels, res.nostril_inst, res.nostril_labels)
55
+ per_cfg[i].append({"key": s.key, "det": ce["detection"], "cells": ce["per_class"], "nos": ne["per_class"],
56
+ "attr": ce["attribute_accuracy"]})
57
+ print("done", s.key, flush=True)
58
+ rows = []
59
+ for i, cfg in enumerate(grid):
60
+ rs = per_cfg[i]
61
+ if not rs:
62
+ continue
63
+ tp = sum(r["det"]["tp"] for r in rs); fp = sum(r["det"]["fp"] for r in rs); fn = sum(r["det"]["fn"] for r in rs)
64
+ ctp = sum(r["det"]["centroid"]["tp"] for r in rs); cfp = sum(r["det"]["centroid"]["fp"] for r in rs); cfn = sum(r["det"]["centroid"]["fn"] for r in rs)
65
+ mc = micro_average([r["cells"] for r in rs], CELL_LABELS)
66
+ mn = micro_average([r["nos"] for r in rs], NOSTRIL_LABELS)
67
+ per_img = {r["key"]: round(r["det"]["f1"], 3) for r in rs}
68
+ rows.append({"marker_thr": cfg.marker_thr, "cell_fg_thr": cfg.cell_fg_thr, "min_marker_area": cfg.min_marker_area,
69
+ "nuc_f1_iou50": prf(tp, fp, fn)["f1"], "nuc_f1_centroid": prf(ctp, cfp, cfn)["f1"],
70
+ "f1_ab_pos": mc["Cell, Antibody +"]["f1"], "f1_ab_neg": mc["Cell, Antibody -"]["f1"],
71
+ "f1_nos_pos": mc["Cell, Nostril, Antibody +"]["f1"], "f1_pin": mn["Nostril in Nucleus"]["f1"],
72
+ "per_image": per_img})
73
+ rows.sort(key=lambda r: -r["nuc_f1_iou50"])
74
+ print(f"{'marker':>6} {'fg':>4} {'minA':>4} | {'nucF1@.5':>8} {'nucF1cen':>8} | {'Ab+':>6} {'Ab-':>6} {'NosAb+':>6} {'PinNuc':>6} | per image")
75
+ for r in rows:
76
+ print(f"{r['marker_thr']:6.2f} {r['cell_fg_thr']:4.1f} {r['min_marker_area']:4d} | {100*r['nuc_f1_iou50']:8.1f} {100*r['nuc_f1_centroid']:8.1f} | "
77
+ f"{100*r['f1_ab_pos']:6.1f} {100*r['f1_ab_neg']:6.1f} {100*r['f1_nos_pos']:6.1f} {100*r['f1_pin']:6.1f} | "
78
+ + " ".join(f"{k}:{100*v:.0f}" for k, v in r['per_image'].items()))
79
+ if a.out:
80
+ with open(a.out, "w") as f:
81
+ json.dump(rows, f, indent=1)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
tests/conftest.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
tests/test_annotations.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import zipfile
3
+
4
+ import numpy as np
5
+ import pytest
6
+
7
+ from cellquant.annotations import (
8
+ ImageAnnotation,
9
+ Shape,
10
+ discover,
11
+ image_key,
12
+ load_annotation_file,
13
+ parse_cvat_xml,
14
+ rasterize,
15
+ to_rgb_uint8,
16
+ )
17
+
18
+ XML = """<?xml version="1.0" encoding="utf-8"?>
19
+ <annotations>
20
+ <version>1.1</version>
21
+ <meta><task><size>1</size></task></meta>
22
+ <image id="0" name="K1 demo - RGB copy.tif" width="100" height="80">
23
+ <polyline label="Cell, Antibody +" source="manual" occluded="0" points="10.0,10.0;30.0,10.0;30.0,30.0;10.0,30.0;10.0,10.0" z_order="0"></polyline>
24
+ <polygon label="Cell, Nostril, Antibody -" source="manual" occluded="0" points="50,50;70,50;70,70;50,70" z_order="0"></polygon>
25
+ <ellipse label="Nostril in Nucleus" source="manual" occluded="0" cx="60" cy="60" rx="3" ry="2" z_order="0"></ellipse>
26
+ <polyline label="Nostril External of Nucleus" source="manual" occluded="0" points="90.2,5.1;90.4,5.3;90.6,5.1" z_order="0"></polyline>
27
+ <box label="Cell, Antibody -" source="manual" occluded="0" xtl="80" ytl="60" xbr="95" ybr="75" z_order="0"></box>
28
+ </image>
29
+ </annotations>
30
+ """
31
+
32
+
33
+ def test_parse_xml_shapes_and_counts():
34
+ anns = parse_cvat_xml(XML.encode())
35
+ assert len(anns) == 1
36
+ a = anns[0]
37
+ assert (a.width, a.height) == (100, 80)
38
+ assert a.key == "K1"
39
+ c = a.counts()
40
+ assert c["Cell, Antibody +"] == 1 and c["Cell, Nostril, Antibody -"] == 1 and c["Cell, Antibody -"] == 1
41
+ assert c["Nostril in Nucleus"] == 1 and c["Nostril External of Nucleus"] == 1
42
+
43
+
44
+ def test_rasterize_areas_and_attributes():
45
+ a = parse_cvat_xml(XML.encode())[0]
46
+ R = rasterize(a)
47
+ assert R.cell_inst.shape == (80, 100)
48
+ assert R.n_cells == 3 and R.n_nostrils == 2
49
+ areas = np.bincount(R.cell_inst.ravel())[1:]
50
+ # 20x20 squares rasterise to ~21x21 pixels (inclusive edges) -> 400..441
51
+ assert all(380 <= x <= 450 for x in areas[:2]) or all(380 <= x <= 450 for x in sorted(areas)[-2:])
52
+ # tiny polyline (sub-pixel) keeps its centroid pixel
53
+ nareas = np.bincount(R.nostril_inst.ravel())[1:]
54
+ assert nareas.min() >= 1
55
+ # ellipse rx=3, ry=2 -> pi*6 ~ 19 px
56
+ assert 10 <= nareas.max() <= 30
57
+ ab = R.cell_antibody()
58
+ hn = R.cell_has_nostril()
59
+ labels = R.cell_labels
60
+ for i, lab in enumerate(labels):
61
+ if i == 0:
62
+ continue
63
+ assert ab[i] == lab.endswith("+")
64
+ assert hn[i] == ("Nostril" in lab)
65
+ assert R.nostril_inside().sum() == 1
66
+
67
+
68
+ def test_image_key_handles_copies_and_double_spaces():
69
+ assert image_key("F114 F 35D SC Iba W002 - Focused EDF Document_RGB.tif") == "F114"
70
+ assert image_key("C110 F 35D SC Iba W001 - Focused EDF Document_RGB copy (1).tif") == "C110"
71
+
72
+
73
+ def test_discover_pairs_by_internal_name(tmp_path):
74
+ from PIL import Image
75
+ Image.fromarray(np.zeros((80, 100, 3), np.uint8)).save(tmp_path / "K1 demo - RGB copy (1).png")
76
+ Image.fromarray(np.zeros((80, 100, 3), np.uint8)).save(tmp_path / "K2 other.png") # unannotated
77
+ with zipfile.ZipFile(tmp_path / "WRONGNAME_Annotated.zip", "w") as zf:
78
+ zf.writestr("annotations.xml", XML)
79
+ samples, unann = discover(str(tmp_path), verbose=False)
80
+ assert [s.key for s in samples] == ["K1"]
81
+ assert len(unann) == 1 and unann[0].endswith("K2 other.png")
82
+ assert samples[0].annotation.count("Cell, Antibody +") == 1
83
+
84
+
85
+ def test_load_annotation_file_zip_and_xml(tmp_path):
86
+ (tmp_path / "a.xml").write_text(XML)
87
+ with zipfile.ZipFile(tmp_path / "a.zip", "w") as zf:
88
+ zf.writestr("annotations.xml", XML)
89
+ assert len(load_annotation_file(str(tmp_path / "a.xml"))[0].shapes) == 5
90
+ assert len(load_annotation_file(str(tmp_path / "a.zip"))[0].shapes) == 5
91
+ with pytest.raises(ValueError):
92
+ load_annotation_file(str(tmp_path / "nothing.txt"))
93
+
94
+
95
+ def test_to_rgb_uint8_variants():
96
+ g = np.full((4, 5), 7, np.uint8)
97
+ assert to_rgb_uint8(g).shape == (4, 5, 3)
98
+ chw = np.zeros((3, 4, 5), np.uint8)
99
+ assert to_rgb_uint8(chw).shape == (4, 5, 3)
100
+ rgba = np.zeros((4, 5, 4), np.uint8)
101
+ assert to_rgb_uint8(rgba).shape == (4, 5, 3)
102
+ u16 = np.full((4, 5, 3), 4000, np.uint16)
103
+ out = to_rgb_uint8(u16)
104
+ assert out.dtype == np.uint8 and out.max() == 255
105
+ f01 = np.full((4, 5, 3), 0.5, np.float32)
106
+ assert to_rgb_uint8(f01).max() in (127, 128)
107
+ with pytest.raises(ValueError):
108
+ to_rgb_uint8(np.zeros((40, 50, 2), np.uint8))
109
+ with pytest.raises(ValueError):
110
+ to_rgb_uint8(np.zeros((4, 50, 2), np.uint8)) # 4 rows is not "RGBA channel-first"
111
+
112
+
113
+ def test_shape_area_shoelace():
114
+ s = Shape("Cell, Antibody +", "polygon", np.array([[0, 0], [10, 0], [10, 10], [0, 10]], np.float32))
115
+ assert abs(s.area - 100) < 1e-6
tests/test_app.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import numpy as np
4
+ import pytest
5
+ from PIL import Image
6
+
7
+ WEIGHTS = os.environ.get("CELLQUANT_WEIGHTS", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "weights", "cellquant.pt"))
8
+
9
+
10
+ @pytest.mark.skipif(not os.path.exists(WEIGHTS), reason="trained weights not present")
11
+ def test_analyze_files_end_to_end(tmp_path):
12
+ import app
13
+ rng = np.random.default_rng(0)
14
+ img = (rng.random((200, 240, 3)) * 50).astype(np.uint8)
15
+ p1 = tmp_path / "a.png"
16
+ Image.fromarray(img).save(p1)
17
+ import tifffile
18
+ p2 = tmp_path / "b.tif"
19
+ tifffile.imwrite(str(p2), img)
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()
27
+ assert "counts.csv" in names and "objects.csv" in names and "results.json" in names
28
+
29
+
30
+ def test_build_demo_constructs():
31
+ pytest.importorskip("gradio")
32
+ import app
33
+ demo = app.build_demo()
34
+ assert demo is not None
tests/test_data.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ from cellquant.annotations import RasterAnnotation
4
+ from cellquant.data import AugConfig, PatchSampler, make_targets
5
+
6
+
7
+ def _raster():
8
+ inst = np.zeros((64, 64), np.int32)
9
+ inst[10:30, 10:30] = 1
10
+ inst[30:50, 30:50] = 2 # touches instance 1 at a corner
11
+ nos = np.zeros((64, 64), np.int32)
12
+ nos[20:23, 20:23] = 1
13
+ nos[5:7, 50:52] = 2
14
+ return RasterAnnotation(inst, ["", "Cell, Nostril, Antibody +", "Cell, Antibody -"], nos,
15
+ ["", "Nostril in Nucleus", "Nostril External of Nucleus"])
16
+
17
+
18
+ def test_make_targets_layout():
19
+ T = make_targets(_raster())
20
+ assert set(np.unique(T["cell3"])) == {0, 1, 2}
21
+ assert ((T["cell3"] > 0) == (T["cellmask"] > 0)).all()
22
+ # border ring is 2 px wide: a 20x20 square has 20*20 - 16*16 = 144 border px
23
+ assert int((T["cell3"][10:30, 10:30] == 2).sum()) == 144
24
+ assert T["antibody"][15, 15] == 1 and T["antibody"][40, 40] == 0
25
+ assert T["cellnos"][15, 15] == 1 and T["cellnos"][40, 40] == 0
26
+ assert T["nos3"][21, 21] == 1 and T["nos3"][5, 50] == 2 and T["nos3"][0, 0] == 0
27
+
28
+
29
+ def test_sampler_batch_shapes_and_reproducibility():
30
+ img = (np.random.default_rng(0).random((64, 64, 3)) * 255).astype(np.uint8)
31
+ T = make_targets(_raster())
32
+ cfg = AugConfig(crop=32)
33
+ a = PatchSampler([img], [T], cfg, seed=3).batch(2)
34
+ b = PatchSampler([img], [T], cfg, seed=3).batch(2)
35
+ x, t = a
36
+ assert x.shape == (2, 3, 32, 32) and x.min() >= 0 and x.max() <= 1
37
+ assert t["cell3"].shape == (2, 32, 32) and t["cell3"].dtype.is_floating_point is False
38
+ assert np.allclose(x.numpy(), b[0].numpy())
39
+ assert all(np.array_equal(t[k].numpy(), b[1][k].numpy()) for k in t)
tests/test_evaluate.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ import evaluate as ev
4
+
5
+
6
+ def test_dihedral_transforms_invert_for_2d_and_3d():
7
+ rng = np.random.default_rng(0)
8
+ a2 = rng.random((5, 7))
9
+ a3 = rng.random((3, 5, 7))
10
+ for k, f in ev.DIHEDRAL:
11
+ assert np.array_equal(ev.inv_np(ev.fwd_np(a2, k, f), k, f), a2)
12
+ assert np.array_equal(ev.inv_np(ev.fwd_np(a3, k, f), k, f), a3)
13
+
14
+
15
+ def test_dihedral_transforms_are_distinct():
16
+ a = np.arange(12).reshape(3, 4).astype(float)
17
+ outs = [ev.fwd_np(a, k, f).tobytes() + bytes(ev.fwd_np(a, k, f).shape) for k, f in ev.DIHEDRAL]
18
+ assert len(set(outs)) == 8
19
+
20
+
21
+ def test_photometric_is_deterministic_and_valid():
22
+ img = (np.random.default_rng(1).random((16, 16, 3)) * 255).astype(np.uint8)
23
+ a = ev.photometric(img, 5)
24
+ b = ev.photometric(img, 5)
25
+ assert np.array_equal(a, b) and a.dtype == np.uint8 and a.shape == img.shape
26
+ assert not np.array_equal(ev.photometric(img, 6), a)
tests/test_labels.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cellquant.labels import ALL_LABELS, CELL_LABELS, NOSTRIL_LABELS, cell_attributes, cell_label, nostril_in_nucleus, nostril_label
2
+
3
+
4
+ def test_cell_label_roundtrip():
5
+ for lab in CELL_LABELS:
6
+ a, n = cell_attributes(lab)
7
+ assert cell_label(a, n) == lab
8
+
9
+
10
+ def test_nostril_label_roundtrip():
11
+ for lab in NOSTRIL_LABELS:
12
+ assert nostril_label(nostril_in_nucleus(lab)) == lab
13
+
14
+
15
+ def test_label_sets_disjoint():
16
+ assert len(ALL_LABELS) == 6 and set(CELL_LABELS).isdisjoint(NOSTRIL_LABELS)
tests/test_metrics.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ from cellquant.metrics import count_errors, evaluate_cells, evaluate_nostrils, match_by_centroid, match_by_iou, micro_average
4
+
5
+
6
+ def _two_squares():
7
+ inst = np.zeros((50, 50), np.int32)
8
+ inst[5:15, 5:15] = 1
9
+ inst[30:45, 30:45] = 2
10
+ return inst
11
+
12
+
13
+ def test_perfect_prediction_scores_one():
14
+ inst = _two_squares()
15
+ labels = ["", "Cell, Antibody +", "Cell, Antibody -"]
16
+ r = evaluate_cells(inst, labels, inst.copy(), list(labels))
17
+ assert r["detection"]["f1"] == 1.0 and r["attribute_accuracy"]["both"] == 1.0
18
+ assert r["per_class"]["Cell, Antibody +"]["f1"] == 1.0
19
+
20
+
21
+ def test_wrong_label_counts_as_fp_and_fn_for_classes_but_detected():
22
+ inst = _two_squares()
23
+ gt = ["", "Cell, Antibody +", "Cell, Antibody -"]
24
+ pred = ["", "Cell, Antibody +", "Cell, Antibody +"]
25
+ r = evaluate_cells(inst, gt, inst.copy(), pred)
26
+ assert r["detection"]["tp"] == 2
27
+ assert r["per_class"]["Cell, Antibody -"]["fn"] == 1 and r["per_class"]["Cell, Antibody +"]["fp"] == 1
28
+ assert r["attribute_accuracy"]["antibody"] == 0.5
29
+
30
+
31
+ def test_shifted_prediction_fails_iou_but_passes_centroid():
32
+ gt = _two_squares()
33
+ pred = np.zeros_like(gt)
34
+ pred[9:19, 9:19] = 1 # IoU with square 1 = 36/164 < 0.5, centroid (14,14) is inside GT square 1
35
+ pred[30:45, 30:45] = 2
36
+ assert len(match_by_iou(gt, pred, 0.5)) == 1
37
+ r = evaluate_cells(gt, ["", "Cell, Antibody +", "Cell, Antibody +"], pred, ["", "Cell, Antibody +", "Cell, Antibody +"])
38
+ assert r["detection"]["tp"] == 1 and r["detection"]["centroid"]["tp"] == 2
39
+
40
+
41
+ def test_nostril_matching_by_distance():
42
+ gt = np.zeros((40, 40), np.int32)
43
+ gt[10:13, 10:13] = 1
44
+ gt[30:33, 30:33] = 2
45
+ pred = np.zeros_like(gt)
46
+ pred[12:15, 12:15] = 1 # 2 px away -> match
47
+ pred[5:8, 30:33] = 2 # 25 px away -> no match
48
+ m = match_by_centroid(gt, pred, 6.0)
49
+ assert len(m) == 1 and m[0][:2] == (1, 1)
50
+ r = evaluate_nostrils(gt, ["", "Nostril in Nucleus", "Nostril External of Nucleus"], pred, ["", "Nostril in Nucleus", "Nostril in Nucleus"])
51
+ assert r["detection"]["tp"] == 1 and r["detection"]["fp"] == 1 and r["detection"]["fn"] == 1
52
+ assert r["type_accuracy"] == 1.0
53
+
54
+
55
+ def test_count_errors_and_micro_average():
56
+ ce = count_errors({"Cell, Antibody +": 10}, {"Cell, Antibody +": 8})
57
+ assert ce["Cell, Antibody +"]["abs_err"] == 2 and abs(ce["Cell, Antibody +"]["rel_err"] - 0.2) < 1e-9
58
+ per = [{"A": {"tp": 1, "fp": 1, "fn": 0, "gt_count": 1, "pred_count": 2}}, {"A": {"tp": 3, "fp": 0, "fn": 1, "gt_count": 4, "pred_count": 3}}]
59
+ m = micro_average(per, ["A"])
60
+ assert m["A"]["tp"] == 4 and m["A"]["fp"] == 1 and m["A"]["fn"] == 1 and abs(m["A"]["f1"] - 0.8) < 1e-9
tests/test_model_infer.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import numpy as np
4
+ import pytest
5
+ import torch
6
+
7
+ from cellquant.model import CellUNet, N_OUT, load_checkpoint, pad_to_multiple, save_checkpoint
8
+
9
+ WEIGHTS = os.environ.get("CELLQUANT_WEIGHTS", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "weights", "cellquant.pt"))
10
+
11
+
12
+ def test_pad_to_multiple():
13
+ x = torch.zeros(1, 3, 70, 100)
14
+ xp, (ph, pw) = pad_to_multiple(x, 32)
15
+ assert xp.shape[-2:] == (96, 128) and (ph, pw) == (26, 28)
16
+ x2, (ph2, pw2) = pad_to_multiple(torch.zeros(1, 3, 64, 64), 32)
17
+ assert x2.shape[-2:] == (64, 64) and (ph2, pw2) == (0, 0)
18
+
19
+
20
+ def test_model_forward_and_checkpoint_roundtrip(tmp_path):
21
+ m = CellUNet("resnet34", pretrained=False).eval()
22
+ x = torch.rand(1, 3, 64, 96)
23
+ with torch.no_grad():
24
+ y = m(x)
25
+ assert y.shape == (1, N_OUT, 64, 96)
26
+ p = tmp_path / "m.pt"
27
+ save_checkpoint(m, str(p), {"note": "test"})
28
+ m2, meta = load_checkpoint(str(p))
29
+ assert meta["note"] == "test" and meta["encoder"] == "resnet34"
30
+ with torch.no_grad():
31
+ assert torch.allclose(m2(x), y, atol=1e-5)
32
+
33
+
34
+ @pytest.mark.skipif(not os.path.exists(WEIGHTS), reason="trained weights not present")
35
+ def test_predictor_deterministic_and_tiling_consistent():
36
+ from cellquant.infer import Predictor
37
+ rng = np.random.default_rng(0)
38
+ img = (rng.random((300, 420, 3)) * 60).astype(np.uint8)
39
+ p = Predictor(WEIGHTS, device="cpu", tta=1)
40
+ a = p.probs(img)
41
+ b = p.probs(img)
42
+ assert all(np.array_equal(a[k], b[k]) for k in a)
43
+ assert a["cell"].shape == (3, 300, 420)
44
+ # force tiling and compare with the whole-image pass
45
+ pt = Predictor(WEIGHTS, device="cpu", tta=1, tile=256, overlap=64, max_whole=128)
46
+ c = pt.probs(img)
47
+ diff = np.abs(c["cell"] - a["cell"]).mean()
48
+ assert diff < 0.05, diff
49
+
50
+
51
+ @pytest.mark.skipif(not os.path.exists(WEIGHTS), reason="trained weights not present")
52
+ def test_tta_outputs_valid_probabilities():
53
+ from cellquant.infer import Predictor
54
+ img = (np.random.default_rng(1).random((128, 160, 3)) * 60).astype(np.uint8)
55
+ for tta in (4, 8):
56
+ pr = Predictor(WEIGHTS, device="cpu", tta=tta).probs(img)
57
+ assert np.allclose(pr["cell"].sum(0), 1, atol=1e-4) and np.allclose(pr["nos"].sum(0), 1, atol=1e-4)
tests/test_postprocess.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ from cellquant.labels import ALL_LABELS
4
+ from cellquant.postprocess import PostConfig, analyze, logits_to_probs, render_overlay, segment_cells
5
+
6
+
7
+ def _disc(h, w, cy, cx, r):
8
+ yy, xx = np.mgrid[:h, :w]
9
+ return (yy - cy) ** 2 + (xx - cx) ** 2 <= r * r
10
+
11
+
12
+ def _probs(h=96, w=96):
13
+ """Two touching discs; the left one antibody+ with a punctum inside; one external punctum."""
14
+ d1, d2 = _disc(h, w, 48, 30, 14), _disc(h, w, 48, 56, 14)
15
+ fg = d1 | d2
16
+ inner = _disc(h, w, 48, 30, 11) | _disc(h, w, 48, 56, 11)
17
+ cell = np.zeros((3, h, w), np.float32)
18
+ cell[1][inner] = 0.9
19
+ cell[2][fg & ~inner] = 0.9
20
+ cell[0] = 1 - cell[1] - cell[2]
21
+ antibody = np.where(d1, 0.95, 0.05).astype(np.float32)
22
+ cellnos = np.full((h, w), 0.1, np.float32)
23
+ nos = np.zeros((3, h, w), np.float32)
24
+ p_in = _disc(h, w, 48, 30, 2)
25
+ p_ext = _disc(h, w, 10, 80, 2)
26
+ nos[1][p_in] = 0.9
27
+ nos[2][p_ext] = 0.9
28
+ nos[0] = 1 - nos[1] - nos[2]
29
+ return {"cell": cell, "antibody": antibody, "cellnos": cellnos, "nos": nos}
30
+
31
+
32
+ def test_touching_discs_split_into_two_instances():
33
+ inst = segment_cells(_probs()["cell"], PostConfig())
34
+ assert inst.max() == 2
35
+ areas = np.bincount(inst.ravel())[1:]
36
+ assert all(500 <= a <= 700 for a in areas) # pi*14^2 ~ 616
37
+
38
+
39
+ def test_analyze_labels_and_counts():
40
+ res = analyze(_probs(), PostConfig())
41
+ assert len(res.cells) == 2 and len(res.nostrils) == 2
42
+ labels = sorted(c["label"] for c in res.cells)
43
+ assert labels == ["Cell, Antibody -", "Cell, Nostril, Antibody +"]
44
+ assert sorted(n["label"] for n in res.nostrils) == ["Nostril External of Nucleus", "Nostril in Nucleus"]
45
+ assert sum(res.counts.values()) == 4 and set(res.counts) == set(ALL_LABELS)
46
+ inside = [n for n in res.nostrils if n["label"] == "Nostril in Nucleus"][0]
47
+ assert inside["cell_id"] == [c for c in res.cells if c["label"].startswith("Cell, Nostril")][0]["id"]
48
+
49
+
50
+ def test_cellnos_rules():
51
+ p = _probs()
52
+ head_only = analyze(p, PostConfig(cellnos_rule="head"))
53
+ assert all(not c["label"].startswith("Cell, Nostril") for c in head_only.cells)
54
+ contains = analyze(p, PostConfig(cellnos_rule="contains"))
55
+ assert sum(c["label"].startswith("Cell, Nostril") for c in contains.cells) == 1
56
+
57
+
58
+ def test_geometric_nostril_rule_overrides_head():
59
+ p = _probs()
60
+ # make the network call the inside punctum "external"; geometric rule should still say inside
61
+ p["nos"][1], p["nos"][2] = p["nos"][2].copy(), p["nos"][1].copy()
62
+ head = analyze(p, PostConfig(nostril_rule="head"))
63
+ geo = analyze(p, PostConfig(nostril_rule="geometric"))
64
+ assert head.counts["Nostril in Nucleus"] == 1 and geo.counts["Nostril in Nucleus"] == 1
65
+ # with head rule the punctum inside the disc is now labelled external
66
+ inside_disc = [n for n in head.nostrils if n["cell_id"] > 0][0]
67
+ assert inside_disc["label"] == "Nostril External of Nucleus"
68
+ assert [n for n in geo.nostrils if n["cell_id"] > 0][0]["label"] == "Nostril in Nucleus"
69
+
70
+
71
+ def test_min_area_filters_small_instances():
72
+ p = _probs()
73
+ assert len(analyze(p, PostConfig(min_cell_area=1000)).cells) == 0
74
+
75
+
76
+ def test_logits_to_probs_and_overlay():
77
+ logits = np.random.default_rng(0).normal(size=(8, 40, 40)).astype(np.float32)
78
+ pr = logits_to_probs(logits)
79
+ assert np.allclose(pr["cell"].sum(0), 1, atol=1e-5) and np.allclose(pr["nos"].sum(0), 1, atol=1e-5)
80
+ assert pr["antibody"].min() >= 0 and pr["antibody"].max() <= 1
81
+ res = analyze(_probs(), PostConfig())
82
+ img = np.zeros((96, 96, 3), np.uint8)
83
+ ov = render_overlay(img, res)
84
+ assert ov.shape == img.shape and ov.any()
85
+
86
+
87
+ def test_empty_image_gives_no_objects():
88
+ h = w = 64
89
+ p = {"cell": np.stack([np.ones((h, w)), np.zeros((h, w)), np.zeros((h, w))]).astype(np.float32),
90
+ "antibody": np.zeros((h, w), np.float32), "cellnos": np.zeros((h, w), np.float32),
91
+ "nos": np.stack([np.ones((h, w)), np.zeros((h, w)), np.zeros((h, w))]).astype(np.float32)}
92
+ res = analyze(p, PostConfig())
93
+ assert res.cells == [] and res.nostrils == [] and sum(res.counts.values()) == 0
tests/test_review_fixes.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for the defects found in the adversarial review."""
2
+ import os
3
+ import zipfile
4
+
5
+ import numpy as np
6
+ import pytest
7
+ import tifffile
8
+ from PIL import Image
9
+
10
+ from cellquant.annotations import discover, load_image, normalized_name, short_keys, to_rgb_uint8
11
+ from cellquant.data import AugConfig, Prefetcher, make_targets
12
+ from cellquant.annotations import RasterAnnotation
13
+ from cellquant.metrics import match_by_iou
14
+ from cellquant.postprocess import PostConfig, _fill_and_filter, analyze
15
+
16
+ XML = """<?xml version="1.0" encoding="utf-8"?>
17
+ <annotations><version>1.1</version><meta><task><size>1</size></task></meta>
18
+ <image id="0" name="{name}" width="{w}" height="{h}">
19
+ <polyline label="Cell, Antibody +" source="manual" occluded="0" points="10,10;30,10;30,30;10,30;10,10" z_order="0"></polyline>
20
+ </image></annotations>"""
21
+
22
+
23
+ def _rgb(h=64, w=64):
24
+ a = np.zeros((h, w, 3), np.uint8)
25
+ a[..., 0], a[..., 1], a[..., 2] = 200, 100, 50
26
+ return a
27
+
28
+
29
+ def test_channel_per_page_tif_loads_three_channels(tmp_path):
30
+ rgb = _rgb()
31
+ p1 = tmp_path / "ij.tif"
32
+ tifffile.imwrite(str(p1), np.moveaxis(rgb, -1, 0), imagej=True, metadata={"axes": "CYX"})
33
+ p2 = tmp_path / "ome.ome.tif"
34
+ tifffile.imwrite(str(p2), np.moveaxis(rgb, -1, 0), ome=True, metadata={"axes": "CYX"})
35
+ for p in (p1, p2):
36
+ a = load_image(str(p))
37
+ assert a.shape == (64, 64, 3)
38
+ assert a[1, 1].tolist() == [200, 100, 50], str(p)
39
+
40
+
41
+ def test_plain_tif_still_loads_and_large_stack_refused(tmp_path):
42
+ p = tmp_path / "plain.tif"
43
+ tifffile.imwrite(str(p), _rgb())
44
+ assert load_image(str(p))[0, 0].tolist() == [200, 100, 50]
45
+ stack = tmp_path / "stack.tif"
46
+ tifffile.imwrite(str(stack), np.zeros((7, 32, 32), np.uint8), imagej=True, metadata={"axes": "ZYX"})
47
+ with pytest.raises(ValueError):
48
+ load_image(str(stack))
49
+
50
+
51
+ def test_lzw_tif_via_pillow_fallback_or_codecs(tmp_path):
52
+ p = tmp_path / "lzw.tif"
53
+ Image.fromarray(_rgb()).save(str(p), compression="tiff_lzw")
54
+ assert load_image(str(p))[2, 2].tolist() == [200, 100, 50]
55
+
56
+
57
+ def test_palette_png_converted_to_rgb(tmp_path):
58
+ p = tmp_path / "pal.png"
59
+ Image.fromarray(_rgb()).convert("P", palette=Image.ADAPTIVE, colors=4).save(str(p))
60
+ a = load_image(str(p))
61
+ assert a.shape == (64, 64, 3)
62
+ assert not (np.array_equal(a[..., 0], a[..., 1]) and np.array_equal(a[..., 1], a[..., 2]))
63
+
64
+
65
+ def test_16bit_scaling_ignores_single_saturated_pixel():
66
+ a = np.full((64, 64, 3), 1000, np.uint16)
67
+ b = a.copy()
68
+ b[0, 0, :] = 65535
69
+ ua, ub = to_rgb_uint8(a), to_rgb_uint8(b)
70
+ assert ua[10, 10, 0] == 255 and ub[10, 10, 0] == 255 # the one hot pixel does not change the gain
71
+ assert to_rgb_uint8(np.full((8, 8, 3), 0.5, np.float32))[0, 0, 0] in (127, 128)
72
+
73
+
74
+ def test_enclosed_instance_survives_and_ids_are_contiguous():
75
+ inst = np.zeros((40, 40), np.int32)
76
+ yy, xx = np.mgrid[:40, :40]
77
+ ring = ((yy - 20) ** 2 + (xx - 20) ** 2 <= 15 ** 2) & ((yy - 20) ** 2 + (xx - 20) ** 2 > 8 ** 2)
78
+ inner = (yy - 20) ** 2 + (xx - 20) ** 2 <= 6 ** 2
79
+ inst[ring] = 1
80
+ inst[inner] = 2
81
+ out = _fill_and_filter(inst, min_area=5)
82
+ ids = np.unique(out)
83
+ assert ids.tolist() == [0, 1, 2]
84
+ assert out[20, 20] == 2 and out[20, 7] == 1
85
+ # the 2-px moat between ring and inner disc is background in the input and gets filled into the ring
86
+ assert out[20, 13] == 1
87
+ # analyze() must not crash on such a map either
88
+ h = w = 40
89
+ p_cell = np.zeros((3, h, w), np.float32)
90
+ p_cell[1][inner] = 0.9
91
+ p_cell[1][ring] = 0.9
92
+ p_cell[2] = 0
93
+ p_cell[0] = 1 - p_cell[1]
94
+ probs = {"cell": p_cell, "antibody": np.zeros((h, w), np.float32), "cellnos": np.zeros((h, w), np.float32),
95
+ "nos": np.stack([np.ones((h, w)), np.zeros((h, w)), np.zeros((h, w))]).astype(np.float32)}
96
+ res = analyze(probs, PostConfig(min_cell_area=5))
97
+ assert len(res.cells) == int(res.cell_inst.max())
98
+
99
+
100
+ def test_match_by_iou_prefers_number_of_valid_matches():
101
+ # GT A overlaps pred X with IoU 0.6 and pred Y with IoU 0.45; GT B overlaps X with 0.45.
102
+ # Summed-IoU assignment would pick (A,Y)+(B,X)=0.9 < (A,X)=0.6 ... make the sums favour the wrong choice:
103
+ gt = np.zeros((30, 60), np.int32)
104
+ pred = np.zeros((30, 60), np.int32)
105
+ gt[0:10, 0:20] = 1 # A: 200 px
106
+ gt[10:20, 0:20] = 2 # B: 200 px
107
+ pred[0:10, 0:20] = 1 # X == A exactly (IoU 1.0 with A)
108
+ pred[5:15, 0:20] = 2 # Y straddles A and B (IoU 100/300 = 0.33 with each)
109
+ pred[0:10, 0:20] = 1 # X overwrites the top of Y
110
+ m = match_by_iou(gt, pred, 0.5)
111
+ assert (1, 1) in [(g, p) for g, p, _ in m]
112
+
113
+
114
+ def test_short_keys_disambiguate_wells():
115
+ names = ["C110 F 35D SC Iba W001 - Focused EDF Document_RGB copy.tif", "C110 F 35D SC Iba W003 - Focused EDF Document_RGB.tif", "F99 M 35D SC Iba W004 - Focused EDF Document_RGB.tif"]
116
+ assert short_keys(names) == ["C110_W001", "C110_W003", "F99"]
117
+ assert normalized_name("F114 F 35D SC Iba W002 - Focused EDF Document_RGB copy (1).tif") == "f114 f 35d sc iba w002 - focused edf document_rgb"
118
+
119
+
120
+ def test_discover_two_sections_same_animal_and_size_check(tmp_path):
121
+ for n in ("C110 F 35D SC Iba W001 - RGB.png", "C110 F 35D SC Iba W003 - RGB.png"):
122
+ Image.fromarray(np.zeros((40, 50, 3), np.uint8)).save(tmp_path / n)
123
+ for i, n in enumerate(("C110 F 35D SC Iba W001 - RGB copy.tif", "C110 F 35D SC Iba W003 - RGB.tif")):
124
+ with zipfile.ZipFile(tmp_path / f"ann{i}.zip", "w") as zf:
125
+ zf.writestr("annotations.xml", XML.format(name=n, w=50, h=40))
126
+ samples, unann = discover(str(tmp_path), verbose=False)
127
+ assert [s.key for s in samples] == ["C110_W001", "C110_W003"] and unann == []
128
+ # a mismatching canvas is refused
129
+ with zipfile.ZipFile(tmp_path / "ann0.zip", "w") as zf:
130
+ zf.writestr("annotations.xml", XML.format(name="C110 F 35D SC Iba W001 - RGB.tif", w=500, h=400))
131
+ with pytest.raises(ValueError):
132
+ discover(str(tmp_path), verbose=False)
133
+
134
+
135
+ def test_discover_refuses_two_different_images_with_same_name(tmp_path):
136
+ Image.fromarray(np.zeros((40, 50, 3), np.uint8)).save(tmp_path / "K1 a - RGB.png")
137
+ Image.fromarray(np.ones((40, 50, 3), np.uint8)).save(tmp_path / "K1 a - RGB copy.png")
138
+ with zipfile.ZipFile(tmp_path / "ann.zip", "w") as zf:
139
+ zf.writestr("annotations.xml", XML.format(name="K1 a - RGB.png", w=50, h=40))
140
+ with pytest.raises(ValueError):
141
+ discover(str(tmp_path), verbose=False)
142
+
143
+
144
+ def test_prefetcher_is_deterministic():
145
+ inst = np.zeros((64, 64), np.int32)
146
+ inst[10:30, 10:30] = 1
147
+ R = RasterAnnotation(inst, ["", "Cell, Antibody +"], np.zeros((64, 64), np.int32), [""])
148
+ img = (np.random.default_rng(0).random((64, 64, 3)) * 255).astype(np.uint8)
149
+ T = make_targets(R)
150
+ def stream(seed):
151
+ pf = Prefetcher([img], [T], AugConfig(crop=32), batch=2, seed=seed, workers=3, depth=6)
152
+ out = [pf.next()[0].numpy().copy() for _ in range(9)]
153
+ pf.close()
154
+ return out
155
+ a, b = stream(4), stream(4)
156
+ assert all(np.array_equal(x, y) for x, y in zip(a, b))
157
+ assert not all(np.array_equal(x, y) for x, y in zip(a, stream(5)))
train.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train CellQuant on CVAT-annotated images.
3
+
4
+ Examples
5
+ --------
6
+ Final model on every annotated image:
7
+ python train.py --data-dir /path/to/images_and_zips --out weights/cellquant.pt
8
+ Leave-one-image-out fold (held-out image is only used for monitoring):
9
+ python train.py --data-dir ... --holdout C110 --out runs/fold_C110.pt
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import math
16
+ import os
17
+ import sys
18
+ import time
19
+
20
+ import numpy as np
21
+ import torch
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
24
+ from cellquant.annotations import discover, load_image, rasterize # noqa: E402
25
+ from cellquant.data import AugConfig, Prefetcher, make_targets # noqa: E402
26
+ from cellquant.infer import Predictor, pick_device # noqa: E402
27
+ from cellquant.losses import multitask_loss # noqa: E402
28
+ from cellquant.metrics import evaluate_cells, evaluate_nostrils # noqa: E402
29
+ from cellquant.model import CellUNet, save_checkpoint # noqa: E402
30
+ from cellquant.postprocess import PostConfig, analyze # noqa: E402
31
+
32
+
33
+ def parse_args(argv=None):
34
+ ap = argparse.ArgumentParser()
35
+ ap.add_argument("--data-dir", required=True)
36
+ ap.add_argument("--out", required=True)
37
+ ap.add_argument("--holdout", default="", help="image key to leave out (e.g. C110); empty = train on all")
38
+ ap.add_argument("--iters", type=int, default=3000)
39
+ ap.add_argument("--batch", type=int, default=4)
40
+ ap.add_argument("--crop", type=int, default=512)
41
+ ap.add_argument("--lr", type=float, default=3e-4)
42
+ ap.add_argument("--weight-decay", type=float, default=1e-4)
43
+ ap.add_argument("--warmup", type=int, default=100)
44
+ ap.add_argument("--encoder", default="resnet34")
45
+ ap.add_argument("--no-pretrained", action="store_true")
46
+ ap.add_argument("--seed", type=int, default=0)
47
+ ap.add_argument("--device", default="auto")
48
+ ap.add_argument("--log-every", type=int, default=50)
49
+ ap.add_argument("--val-every", type=int, default=500)
50
+ ap.add_argument("--workers", type=int, default=3, help="augmentation threads")
51
+ return ap.parse_args(argv)
52
+
53
+
54
+ def lr_at(it: int, total: int, base: float, warmup: int) -> float:
55
+ if it < warmup:
56
+ return base * (it + 1) / warmup
57
+ t = (it - warmup) / max(1, total - warmup)
58
+ return base * (0.02 + 0.98 * 0.5 * (1 + math.cos(math.pi * t)))
59
+
60
+
61
+ def quick_val(model: CellUNet, img: np.ndarray, R, device) -> dict:
62
+ pred = Predictor(model, device=str(device), tta=1)
63
+ res, _ = pred.predict(img, PostConfig())
64
+ ce = evaluate_cells(R.cell_inst, R.cell_labels, res.cell_inst, res.cell_labels)
65
+ ne = evaluate_nostrils(R.nostril_inst, R.nostril_labels, res.nostril_inst, res.nostril_labels)
66
+ return {
67
+ "cell_det_f1": ce["detection"]["f1"],
68
+ "cell_attr_both": ce["attribute_accuracy"]["both"],
69
+ "nos_det_f1": ne["detection"]["f1"],
70
+ "nos_type_acc": ne["type_accuracy"],
71
+ "n_pred_cells": ce["n_pred"], "n_gt_cells": ce["n_gt"],
72
+ }
73
+
74
+
75
+ def main(argv=None):
76
+ a = parse_args(argv)
77
+ torch.manual_seed(a.seed)
78
+ np.random.seed(a.seed)
79
+ device = pick_device(a.device)
80
+ samples, _ = discover(a.data_dir)
81
+ if not samples:
82
+ raise SystemExit("no annotated images found")
83
+ train = [s for s in samples if s.key != a.holdout]
84
+ held = [s for s in samples if s.key == a.holdout]
85
+ if a.holdout and not held:
86
+ raise SystemExit(f"holdout {a.holdout!r} not among {[s.key for s in samples]}")
87
+ print(f"device={device} train={[s.key for s in train]} holdout={[s.key for s in held]}")
88
+ images, targets = [], []
89
+ for s in train:
90
+ img = load_image(s.image_path)
91
+ R = rasterize(s.annotation, img.shape[0], img.shape[1])
92
+ images.append(img)
93
+ targets.append(make_targets(R))
94
+ sampler = Prefetcher(images, targets, AugConfig(crop=a.crop), batch=a.batch, seed=a.seed, workers=a.workers)
95
+ held_img = held_R = None
96
+ if held:
97
+ held_img = load_image(held[0].image_path)
98
+ held_R = rasterize(held[0].annotation, held_img.shape[0], held_img.shape[1])
99
+
100
+ model = CellUNet(a.encoder, pretrained=not a.no_pretrained).to(device)
101
+ opt = torch.optim.AdamW(model.parameters(), lr=a.lr, weight_decay=a.weight_decay)
102
+ history = {"train": [], "val": []}
103
+ t0 = time.time()
104
+ run = {}
105
+ for it in range(a.iters):
106
+ lr = lr_at(it, a.iters, a.lr, a.warmup)
107
+ for g in opt.param_groups:
108
+ g["lr"] = lr
109
+ model.train()
110
+ x, t = sampler.next()
111
+ out = model(x.to(device))
112
+ loss, parts = multitask_loss(out, t)
113
+ opt.zero_grad(set_to_none=True)
114
+ loss.backward()
115
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
116
+ opt.step()
117
+ for k, v in parts.items():
118
+ run[k] = run.get(k, 0.0) + v
119
+ if (it + 1) % a.log_every == 0:
120
+ avg = {k: v / a.log_every for k, v in run.items()}
121
+ run = {}
122
+ avg["iter"] = it + 1
123
+ avg["lr"] = lr
124
+ avg["elapsed_s"] = time.time() - t0
125
+ history["train"].append(avg)
126
+ print(f"it {it+1:5d} loss {avg['total']:.3f} cell {avg['ce_cell']:.3f}/{avg['dice_cell']:.3f} "
127
+ f"nos {avg['ce_nos']:.3f}/{avg['dice_nos']:.3f} ab {avg['bce_ab']:.3f} cn {avg['bce_cn']:.3f} "
128
+ f"lr {lr:.2e} {avg['elapsed_s']:.0f}s", flush=True)
129
+ if held and ((it + 1) % a.val_every == 0 or it + 1 == a.iters):
130
+ model.eval()
131
+ v = quick_val(model, held_img, held_R, device)
132
+ v["iter"] = it + 1
133
+ history["val"].append(v)
134
+ print(f" val[{a.holdout}] cell F1 {v['cell_det_f1']:.3f} attr {v['cell_attr_both']:.3f} "
135
+ f"nos F1 {v['nos_det_f1']:.3f} type {v['nos_type_acc']:.3f} (pred {v['n_pred_cells']} / gt {v['n_gt_cells']} cells)", flush=True)
136
+ sampler.close()
137
+ model.eval().cpu()
138
+ os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
139
+ save_checkpoint(model, a.out, {
140
+ "config": vars(a),
141
+ "train_keys": [s.key for s in train],
142
+ "holdout": a.holdout,
143
+ "history": history,
144
+ "labels": list(__import__("cellquant.labels", fromlist=["ALL_LABELS"]).ALL_LABELS),
145
+ })
146
+ with open(os.path.splitext(a.out)[0] + "_history.json", "w") as f:
147
+ json.dump(history, f, indent=1)
148
+ print(f"saved {a.out} ({time.time()-t0:.0f}s)")
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
weights/cellquant.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b6d252d9f4c03aedcf717649cc766fb47a503acc7cca8135b8d43bc684838959
3
+ size 98290507
weights/cellquant_history.json ADDED
@@ -0,0 +1,725 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "train": [
3
+ {
4
+ "ce_cell": 0.9070329773426056,
5
+ "dice_cell": 0.8730328452587127,
6
+ "ce_nos": 1.0830040109157562,
7
+ "dice_nos": 0.9898272252082825,
8
+ "bce_ab": 0.524188295006752,
9
+ "bce_cn": 0.6183760815858841,
10
+ "total": 4.9954613590240475,
11
+ "iter": 50,
12
+ "lr": 0.00015,
13
+ "elapsed_s": 59.566773891448975
14
+ },
15
+ {
16
+ "ce_cell": 0.7300815117359162,
17
+ "dice_cell": 0.8212458372116089,
18
+ "ce_nos": 0.7789127671718598,
19
+ "dice_nos": 0.9821323096752167,
20
+ "bce_ab": 0.2914739960432053,
21
+ "bce_cn": 0.3936499571800232,
22
+ "total": 3.9974963092803955,
23
+ "iter": 100,
24
+ "lr": 0.0003,
25
+ "elapsed_s": 109.73091292381287
26
+ },
27
+ {
28
+ "ce_cell": 0.5734736859798432,
29
+ "dice_cell": 0.7798113775253296,
30
+ "ce_nos": 0.574396123290062,
31
+ "dice_nos": 0.9748807692527771,
32
+ "bce_ab": 0.23322827845811844,
33
+ "bce_cn": 0.33575250566005704,
34
+ "total": 3.4715427684783937,
35
+ "iter": 150,
36
+ "lr": 0.00029979294712266676,
37
+ "elapsed_s": 160.73545289039612
38
+ },
39
+ {
40
+ "ce_cell": 0.44446682393550874,
41
+ "dice_cell": 0.7253605389595031,
42
+ "ce_nos": 0.4329249274730682,
43
+ "dice_nos": 0.9701189231872559,
44
+ "bce_ab": 0.20956841707229615,
45
+ "bce_cn": 0.32494749456644056,
46
+ "total": 3.107387113571167,
47
+ "iter": 200,
48
+ "lr": 0.0002991554114956718,
49
+ "elapsed_s": 211.49982213974
50
+ },
51
+ {
52
+ "ce_cell": 0.35687846958637237,
53
+ "dice_cell": 0.6721045792102813,
54
+ "ce_nos": 0.32840390503406525,
55
+ "dice_nos": 0.9639902281761169,
56
+ "bce_ab": 0.17408027544617652,
57
+ "bce_cn": 0.3147788044810295,
58
+ "total": 2.810236248970032,
59
+ "iter": 250,
60
+ "lr": 0.00029808917686451123,
61
+ "elapsed_s": 255.0301797389984
62
+ },
63
+ {
64
+ "ce_cell": 0.30268543839454654,
65
+ "dice_cell": 0.6246984755992889,
66
+ "ce_nos": 0.24472756624221803,
67
+ "dice_nos": 0.9488238263130188,
68
+ "bce_ab": 0.1679477596282959,
69
+ "bce_cn": 0.264477858543396,
70
+ "total": 2.5533609533309938,
71
+ "iter": 300,
72
+ "lr": 0.00029659737067906796,
73
+ "elapsed_s": 305.7051999568939
74
+ },
75
+ {
76
+ "ce_cell": 0.2692413151264191,
77
+ "dice_cell": 0.5903848171234131,
78
+ "ce_nos": 0.18806805282831193,
79
+ "dice_nos": 0.9169448506832123,
80
+ "bce_ab": 0.1362369504570961,
81
+ "bce_cn": 0.2647552129626274,
82
+ "total": 2.365631227493286,
83
+ "iter": 350,
84
+ "lr": 0.00029468436866391876,
85
+ "elapsed_s": 356.85323691368103
86
+ },
87
+ {
88
+ "ce_cell": 0.24572547733783723,
89
+ "dice_cell": 0.5541446590423584,
90
+ "ce_nos": 0.14375351637601852,
91
+ "dice_nos": 0.8989665198326111,
92
+ "bce_ab": 0.1442735657095909,
93
+ "bce_cn": 0.25428884506225585,
94
+ "total": 2.241152620315552,
95
+ "iter": 400,
96
+ "lr": 0.0002923557819835801,
97
+ "elapsed_s": 408.5768549442291
98
+ },
99
+ {
100
+ "ce_cell": 0.23625801086425782,
101
+ "dice_cell": 0.5364066225290298,
102
+ "ce_nos": 0.11115477636456489,
103
+ "dice_nos": 0.87745032787323,
104
+ "bce_ab": 0.1183611673116684,
105
+ "bce_cn": 0.22456467717885972,
106
+ "total": 2.1041955590248107,
107
+ "iter": 450,
108
+ "lr": 0.00028961844078399557,
109
+ "elapsed_s": 459.0741550922394
110
+ },
111
+ {
112
+ "ce_cell": 0.21822448045015336,
113
+ "dice_cell": 0.5127934575080871,
114
+ "ce_nos": 0.0872543377429247,
115
+ "dice_nos": 0.8334435498714448,
116
+ "bce_ab": 0.1045101822167635,
117
+ "bce_cn": 0.2290149673819542,
118
+ "total": 1.9852409744262696,
119
+ "iter": 500,
120
+ "lr": 0.0002864803741585413,
121
+ "elapsed_s": 509.7258110046387
122
+ },
123
+ {
124
+ "ce_cell": 0.21543064922094346,
125
+ "dice_cell": 0.49275926291942596,
126
+ "ce_nos": 0.07010223433375358,
127
+ "dice_nos": 0.795907906293869,
128
+ "bce_ab": 0.10423918575048446,
129
+ "bce_cn": 0.21024613380432128,
130
+ "total": 1.8886853814125062,
131
+ "iter": 550,
132
+ "lr": 0.0002829507865973116,
133
+ "elapsed_s": 560.3340859413147
134
+ },
135
+ {
136
+ "ce_cell": 0.21174423784017563,
137
+ "dice_cell": 0.4891924291849136,
138
+ "ce_nos": 0.06100128136575222,
139
+ "dice_nos": 0.7548426115512847,
140
+ "bce_ab": 0.09695948652923107,
141
+ "bce_cn": 0.20541400745511054,
142
+ "total": 1.8191540384292602,
143
+ "iter": 600,
144
+ "lr": 0.0002790400309887635,
145
+ "elapsed_s": 610.7946240901947
146
+ },
147
+ {
148
+ "ce_cell": 0.20390785247087478,
149
+ "dice_cell": 0.4746441453695297,
150
+ "ce_nos": 0.04720834020525217,
151
+ "dice_nos": 0.709533805847168,
152
+ "bce_ab": 0.09941868580877782,
153
+ "bce_cn": 0.19302350237965585,
154
+ "total": 1.727736325263977,
155
+ "iter": 650,
156
+ "lr": 0.0002747595782529114,
157
+ "elapsed_s": 660.7677178382874
158
+ },
159
+ {
160
+ "ce_cell": 0.2039007693529129,
161
+ "dice_cell": 0.46679477393627167,
162
+ "ce_nos": 0.048075792603194716,
163
+ "dice_nos": 0.6227267402410507,
164
+ "bce_ab": 0.08658930584788323,
165
+ "bce_cn": 0.18947327509522438,
166
+ "total": 1.617560646533966,
167
+ "iter": 700,
168
+ "lr": 0.0002701219836951418,
169
+ "elapsed_s": 711.0392649173737
170
+ },
171
+ {
172
+ "ce_cell": 0.200768101811409,
173
+ "dice_cell": 0.45868332326412203,
174
+ "ce_nos": 0.03979761004447937,
175
+ "dice_nos": 0.5989265680313111,
176
+ "bce_ab": 0.07302961058914662,
177
+ "bce_cn": 0.1712094095349312,
178
+ "total": 1.5424146223068238,
179
+ "iter": 750,
180
+ "lr": 0.0002651408501793399,
181
+ "elapsed_s": 760.5930259227753
182
+ },
183
+ {
184
+ "ce_cell": 0.19177152156829835,
185
+ "dice_cell": 0.4472357839345932,
186
+ "ce_nos": 0.03094205627217889,
187
+ "dice_nos": 0.5603820198774337,
188
+ "bce_ab": 0.08034474328160286,
189
+ "bce_cn": 0.14997318997979164,
190
+ "total": 1.4606493067741395,
191
+ "iter": 800,
192
+ "lr": 0.0002598307882283455,
193
+ "elapsed_s": 810.9303019046783
194
+ },
195
+ {
196
+ "ce_cell": 0.19938127607107162,
197
+ "dice_cell": 0.44567417323589326,
198
+ "ce_nos": 0.04125866884365678,
199
+ "dice_nos": 0.4803364199399948,
200
+ "bce_ab": 0.07717311266809702,
201
+ "bce_cn": 0.1591106753051281,
202
+ "total": 1.4029343223571777,
203
+ "iter": 850,
204
+ "lr": 0.00025420737316877263,
205
+ "elapsed_s": 857.9806337356567
206
+ },
207
+ {
208
+ "ce_cell": 0.1933874309062958,
209
+ "dice_cell": 0.4387037259340286,
210
+ "ce_nos": 0.036275244057178496,
211
+ "dice_nos": 0.48662609457969663,
212
+ "bce_ab": 0.06898861289024354,
213
+ "bce_cn": 0.15267845779657363,
214
+ "total": 1.3766595673561097,
215
+ "iter": 900,
216
+ "lr": 0.00024828709944589296,
217
+ "elapsed_s": 908.7008759975433
218
+ },
219
+ {
220
+ "ce_cell": 0.19506525486707688,
221
+ "dice_cell": 0.4412110126018524,
222
+ "ce_nos": 0.035951650235801935,
223
+ "dice_nos": 0.43454607129096984,
224
+ "bce_ab": 0.06932192835956812,
225
+ "bce_cn": 0.14733665704727172,
226
+ "total": 1.32343257188797,
227
+ "iter": 950,
228
+ "lr": 0.00024208733224258537,
229
+ "elapsed_s": 959.4800848960876
230
+ },
231
+ {
232
+ "ce_cell": 0.1945480227470398,
233
+ "dice_cell": 0.43224972307682036,
234
+ "ce_nos": 0.0335944464802742,
235
+ "dice_nos": 0.40378140449523925,
236
+ "bce_ab": 0.06522910814732313,
237
+ "bce_cn": 0.13741043150424959,
238
+ "total": 1.2668131160736085,
239
+ "iter": 1000,
240
+ "lr": 0.0002356262565442632,
241
+ "elapsed_s": 1009.7932658195496
242
+ },
243
+ {
244
+ "ce_cell": 0.18734178960323333,
245
+ "dice_cell": 0.4267615270614624,
246
+ "ce_nos": 0.036363077107816937,
247
+ "dice_nos": 0.4053969383239746,
248
+ "bce_ab": 0.0643582896143198,
249
+ "bce_cn": 0.12727220386266708,
250
+ "total": 1.2474938321113587,
251
+ "iter": 1050,
252
+ "lr": 0.0002289228237991784,
253
+ "elapsed_s": 1059.9658198356628
254
+ },
255
+ {
256
+ "ce_cell": 0.18910773277282714,
257
+ "dice_cell": 0.42362256467342374,
258
+ "ce_nos": 0.0346462229359895,
259
+ "dice_nos": 0.3729756760597229,
260
+ "bce_ab": 0.06174593850970268,
261
+ "bce_cn": 0.13085844799876212,
262
+ "total": 1.2129565787315368,
263
+ "iter": 1100,
264
+ "lr": 0.0002219966963305587,
265
+ "elapsed_s": 1110.0916857719421
266
+ },
267
+ {
268
+ "ce_cell": 0.1822684535384178,
269
+ "dice_cell": 0.4143069595098495,
270
+ "ce_nos": 0.03322938298806548,
271
+ "dice_nos": 0.3389162081480026,
272
+ "bce_ab": 0.05988596722483635,
273
+ "bce_cn": 0.11884681917726994,
274
+ "total": 1.1474537920951844,
275
+ "iter": 1150,
276
+ "lr": 0.00021486818966362585,
277
+ "elapsed_s": 1160.7585599422455
278
+ },
279
+ {
280
+ "ce_cell": 0.18991790741682052,
281
+ "dice_cell": 0.41873910784721374,
282
+ "ce_nos": 0.036337544079869985,
283
+ "dice_nos": 0.34970403611660006,
284
+ "bce_ab": 0.05970212183892727,
285
+ "bce_cn": 0.1334019273519516,
286
+ "total": 1.1878026413917542,
287
+ "iter": 1200,
288
+ "lr": 0.00020755821293665946,
289
+ "elapsed_s": 1211.4072179794312
290
+ },
291
+ {
292
+ "ce_cell": 0.17697676301002502,
293
+ "dice_cell": 0.40928772032260896,
294
+ "ce_nos": 0.03304771558381617,
295
+ "dice_nos": 0.3399574244022369,
296
+ "bce_ab": 0.04348419822752476,
297
+ "bce_cn": 0.1121597220748663,
298
+ "total": 1.1149135494232179,
299
+ "iter": 1250,
300
+ "lr": 0.00020008820757089248,
301
+ "elapsed_s": 1262.1290378570557
302
+ },
303
+ {
304
+ "ce_cell": 0.17598221957683563,
305
+ "dice_cell": 0.40642625391483306,
306
+ "ce_nos": 0.033407045025378466,
307
+ "dice_nos": 0.310367751121521,
308
+ "bce_ab": 0.0478444230183959,
309
+ "bce_cn": 0.09985558047890664,
310
+ "total": 1.073883285522461,
311
+ "iter": 1300,
312
+ "lr": 0.0001924800843791277,
313
+ "elapsed_s": 1311.735403060913
314
+ },
315
+ {
316
+ "ce_cell": 0.18522560983896255,
317
+ "dice_cell": 0.403508597612381,
318
+ "ce_nos": 0.036090935450047254,
319
+ "dice_nos": 0.3127438998222351,
320
+ "bce_ab": 0.04713532615453005,
321
+ "bce_cn": 0.11126945935189723,
322
+ "total": 1.0959738206863403,
323
+ "iter": 1350,
324
+ "lr": 0.00018475615929754922,
325
+ "elapsed_s": 1362.2759177684784
326
+ },
327
+ {
328
+ "ce_cell": 0.18232185065746306,
329
+ "dice_cell": 0.40336430847644805,
330
+ "ce_nos": 0.0384017390338704,
331
+ "dice_nos": 0.30273705661296846,
332
+ "bce_ab": 0.04570051861926913,
333
+ "bce_cn": 0.11299202486872673,
334
+ "total": 1.0855175077915191,
335
+ "iter": 1400,
336
+ "lr": 0.0001769390879292361,
337
+ "elapsed_s": 1412.6486489772797
338
+ },
339
+ {
340
+ "ce_cell": 0.18443019971251487,
341
+ "dice_cell": 0.41006422102451323,
342
+ "ce_nos": 0.038777465261518956,
343
+ "dice_nos": 0.293556307554245,
344
+ "bce_ab": 0.040375117510557175,
345
+ "bce_cn": 0.0971392347663641,
346
+ "total": 1.0643425357341767,
347
+ "iter": 1450,
348
+ "lr": 0.00016905179909137546,
349
+ "elapsed_s": 1462.6795949935913
350
+ },
351
+ {
352
+ "ce_cell": 0.17869871109724045,
353
+ "dice_cell": 0.40553332567214967,
354
+ "ce_nos": 0.03795233623124659,
355
+ "dice_nos": 0.2916260594129562,
356
+ "bce_ab": 0.04099308693781495,
357
+ "bce_cn": 0.0865385101735592,
358
+ "total": 1.0413420403003693,
359
+ "iter": 1500,
360
+ "lr": 0.00016111742756109098,
361
+ "elapsed_s": 1512.4294548034668
362
+ },
363
+ {
364
+ "ce_cell": 0.1764592757821083,
365
+ "dice_cell": 0.3958728986978531,
366
+ "ce_nos": 0.035169387739151715,
367
+ "dice_nos": 0.27471464276313784,
368
+ "bce_ab": 0.035233155209571126,
369
+ "bce_cn": 0.08008314736187458,
370
+ "total": 0.9975325107574463,
371
+ "iter": 1550,
372
+ "lr": 0.00015315924621715527,
373
+ "elapsed_s": 1562.1074118614197
374
+ },
375
+ {
376
+ "ce_cell": 0.17475848585367204,
377
+ "dice_cell": 0.3929943698644638,
378
+ "ce_nos": 0.03353961421176791,
379
+ "dice_nos": 0.3009433788061142,
380
+ "bce_ab": 0.03854949928820133,
381
+ "bce_cn": 0.08339719414710998,
382
+ "total": 1.0241825413703918,
383
+ "iter": 1600,
384
+ "lr": 0.00014520059777662684,
385
+ "elapsed_s": 1612.9562838077545
386
+ },
387
+ {
388
+ "ce_cell": 0.18072757810354234,
389
+ "dice_cell": 0.39913213849067686,
390
+ "ce_nos": 0.039328129449859264,
391
+ "dice_nos": 0.2857843828201294,
392
+ "bce_ab": 0.03886710917577148,
393
+ "bce_cn": 0.09502572849392892,
394
+ "total": 1.0388650584220886,
395
+ "iter": 1650,
396
+ "lr": 0.00013726482632663912,
397
+ "elapsed_s": 1663.1288919448853
398
+ },
399
+ {
400
+ "ce_cell": 0.1759936460852623,
401
+ "dice_cell": 0.39650719165802,
402
+ "ce_nos": 0.032182310023345054,
403
+ "dice_nos": 0.2933516526222229,
404
+ "bce_ab": 0.03653882872313261,
405
+ "bce_cn": 0.08788134880363942,
406
+ "total": 1.0224549758434296,
407
+ "iter": 1700,
408
+ "lr": 0.00012937520885217038,
409
+ "elapsed_s": 1712.6260061264038
410
+ },
411
+ {
412
+ "ce_cell": 0.17498762905597687,
413
+ "dice_cell": 0.38849363803863524,
414
+ "ce_nos": 0.03151271438226104,
415
+ "dice_nos": 0.27745749831199645,
416
+ "bce_ab": 0.038630726784467696,
417
+ "bce_cn": 0.08467736341059208,
418
+ "total": 0.995759572982788,
419
+ "iter": 1750,
420
+ "lr": 0.0001215548869606366,
421
+ "elapsed_s": 1763.162414073944
422
+ },
423
+ {
424
+ "ce_cell": 0.16943098440766335,
425
+ "dice_cell": 0.38974578082561495,
426
+ "ce_nos": 0.030594841404818,
427
+ "dice_nos": 0.2775198197364807,
428
+ "bce_ab": 0.03063900684937835,
429
+ "bce_cn": 0.07809612043201923,
430
+ "total": 0.9760265576839448,
431
+ "iter": 1800,
432
+ "lr": 0.00011382679900356957,
433
+ "elapsed_s": 1812.9093608856201
434
+ },
435
+ {
436
+ "ce_cell": 0.17392412394285203,
437
+ "dice_cell": 0.39087122797966,
438
+ "ce_nos": 0.03287358325906098,
439
+ "dice_nos": 0.27309031009674073,
440
+ "bce_ab": 0.03879237989895046,
441
+ "bce_cn": 0.07375255063176155,
442
+ "total": 0.9833041846752166,
443
+ "iter": 1850,
444
+ "lr": 0.0001062136127944796,
445
+ "elapsed_s": 1862.8471250534058
446
+ },
447
+ {
448
+ "ce_cell": 0.1721265098452568,
449
+ "dice_cell": 0.3918482345342636,
450
+ "ce_nos": 0.03052111998666078,
451
+ "dice_nos": 0.26571854174137116,
452
+ "bce_ab": 0.028863372541964055,
453
+ "bce_cn": 0.07009109571576118,
454
+ "total": 0.9591688740253449,
455
+ "iter": 1900,
456
+ "lr": 9.873765912025276e-05,
457
+ "elapsed_s": 1913.0803048610687
458
+ },
459
+ {
460
+ "ce_cell": 0.17321533888578414,
461
+ "dice_cell": 0.3879319250583649,
462
+ "ce_nos": 0.03197065497748554,
463
+ "dice_nos": 0.2754987716674805,
464
+ "bce_ab": 0.03000295827165246,
465
+ "bce_cn": 0.0681322829797864,
466
+ "total": 0.966751925945282,
467
+ "iter": 1950,
468
+ "lr": 9.142086624110557e-05,
469
+ "elapsed_s": 1962.4520688056946
470
+ },
471
+ {
472
+ "ce_cell": 0.16933307886123658,
473
+ "dice_cell": 0.3823584771156311,
474
+ "ce_nos": 0.03379744639620185,
475
+ "dice_nos": 0.2684609520435333,
476
+ "bce_ab": 0.03405721362680197,
477
+ "bce_cn": 0.07046777069568634,
478
+ "total": 0.9584749519824982,
479
+ "iter": 2000,
480
+ "lr": 8.428469557121958e-05,
481
+ "elapsed_s": 2011.636960029602
482
+ },
483
+ {
484
+ "ce_cell": 0.17340840995311738,
485
+ "dice_cell": 0.3878352814912796,
486
+ "ce_nos": 0.03318483327515423,
487
+ "dice_nos": 0.2580646830797195,
488
+ "bce_ab": 0.029467624574899674,
489
+ "bce_cn": 0.07082617726176978,
490
+ "total": 0.9527869963645935,
491
+ "iter": 2050,
492
+ "lr": 7.735007872871564e-05,
493
+ "elapsed_s": 2061.974331855774
494
+ },
495
+ {
496
+ "ce_cell": 0.17320298582315444,
497
+ "dice_cell": 0.3853736054897308,
498
+ "ce_nos": 0.028748751198872925,
499
+ "dice_nos": 0.26544315218925474,
500
+ "bce_ab": 0.033497555926442144,
501
+ "bce_cn": 0.06046421557664871,
502
+ "total": 0.9467302620410919,
503
+ "iter": 2100,
504
+ "lr": 7.063735613961179e-05,
505
+ "elapsed_s": 2112.548271894455
506
+ },
507
+ {
508
+ "ce_cell": 0.1658191041648388,
509
+ "dice_cell": 0.3796718597412109,
510
+ "ce_nos": 0.03187917632050812,
511
+ "dice_nos": 0.26365615785121915,
512
+ "bce_ab": 0.030561734307557344,
513
+ "bce_cn": 0.05981527805328369,
514
+ "total": 0.9314033150672912,
515
+ "iter": 2150,
516
+ "lr": 6.416621737584896e-05,
517
+ "elapsed_s": 2162.667312860489
518
+ },
519
+ {
520
+ "ce_cell": 0.1701272311806679,
521
+ "dice_cell": 0.37863297820091246,
522
+ "ce_nos": 0.027262243572622538,
523
+ "dice_nos": 0.26668123960494994,
524
+ "bce_ab": 0.04351606283336878,
525
+ "bce_cn": 0.06579859033226967,
526
+ "total": 0.9520183408260345,
527
+ "iter": 2200,
528
+ "lr": 5.795564340238374e-05,
529
+ "elapsed_s": 2213.4271240234375
530
+ },
531
+ {
532
+ "ce_cell": 0.1703978794813156,
533
+ "dice_cell": 0.38433976292610167,
534
+ "ce_nos": 0.03619127416983247,
535
+ "dice_nos": 0.24586948692798616,
536
+ "bce_ab": 0.02381640898063779,
537
+ "bce_cn": 0.06208158168941736,
538
+ "total": 0.922696396112442,
539
+ "iter": 2250,
540
+ "lr": 5.202385090274833e-05,
541
+ "elapsed_s": 2263.827299118042
542
+ },
543
+ {
544
+ "ce_cell": 0.16755644142627715,
545
+ "dice_cell": 0.3778986144065857,
546
+ "ce_nos": 0.03260435258969665,
547
+ "dice_nos": 0.25375066459178924,
548
+ "bce_ab": 0.029958658767864108,
549
+ "bce_cn": 0.05888926636427641,
550
+ "total": 0.9206579995155334,
551
+ "iter": 2300,
552
+ "lr": 4.638823884637875e-05,
553
+ "elapsed_s": 2313.582683801651
554
+ },
555
+ {
556
+ "ce_cell": 0.1692182320356369,
557
+ "dice_cell": 0.3797942841053009,
558
+ "ce_nos": 0.031096484493464232,
559
+ "dice_nos": 0.25585519909858706,
560
+ "bce_ab": 0.025209803096950055,
561
+ "bce_cn": 0.06410345062613487,
562
+ "total": 0.925277452468872,
563
+ "iter": 2350,
564
+ "lr": 4.106533745443955e-05,
565
+ "elapsed_s": 2366.780472755432
566
+ },
567
+ {
568
+ "ce_cell": 0.1647430169582367,
569
+ "dice_cell": 0.37565741121768953,
570
+ "ce_nos": 0.029058436192572118,
571
+ "dice_nos": 0.2536770069599152,
572
+ "bce_ab": 0.026647686092182994,
573
+ "bce_cn": 0.05636850781738758,
574
+ "total": 0.9061520624160767,
575
+ "iter": 2400,
576
+ "lr": 3.607075971383718e-05,
577
+ "elapsed_s": 2420.7595388889313
578
+ },
579
+ {
580
+ "ce_cell": 0.1587481266260147,
581
+ "dice_cell": 0.37386213421821596,
582
+ "ce_nos": 0.027811604756861924,
583
+ "dice_nos": 0.2525290942192078,
584
+ "bce_ab": 0.027055934872478246,
585
+ "bce_cn": 0.05388368744403124,
586
+ "total": 0.8938905835151673,
587
+ "iter": 2450,
588
+ "lr": 3.1419155581639394e-05,
589
+ "elapsed_s": 2472.9100120067596
590
+ },
591
+ {
592
+ "ce_cell": 0.16981444805860518,
593
+ "dice_cell": 0.3800900149345398,
594
+ "ce_nos": 0.02935561618767679,
595
+ "dice_nos": 0.2700779044628143,
596
+ "bce_ab": 0.026528375521302223,
597
+ "bce_cn": 0.05692515041679144,
598
+ "total": 0.932791496515274,
599
+ "iter": 2500,
600
+ "lr": 2.7124169014226923e-05,
601
+ "elapsed_s": 2525.758083820343
602
+ },
603
+ {
604
+ "ce_cell": 0.16598297476768495,
605
+ "dice_cell": 0.37718391716480254,
606
+ "ce_nos": 0.030217354064807297,
607
+ "dice_nos": 0.2526162987947464,
608
+ "bce_ab": 0.027398303812369705,
609
+ "bce_cn": 0.05613136574625969,
610
+ "total": 0.9095302104949952,
611
+ "iter": 2550,
612
+ "lr": 2.319839794721846e-05,
613
+ "elapsed_s": 2578.604254961014
614
+ },
615
+ {
616
+ "ce_cell": 0.16501545295119285,
617
+ "dice_cell": 0.37883709609508515,
618
+ "ce_nos": 0.029584614327177405,
619
+ "dice_nos": 0.24774192690849303,
620
+ "bce_ab": 0.022402014611288906,
621
+ "bce_cn": 0.057471454478800296,
622
+ "total": 0.9010525631904602,
623
+ "iter": 2600,
624
+ "lr": 1.9653357343555055e-05,
625
+ "elapsed_s": 2630.874783039093
626
+ },
627
+ {
628
+ "ce_cell": 0.16610188841819762,
629
+ "dice_cell": 0.3750225841999054,
630
+ "ce_nos": 0.03136977587360889,
631
+ "dice_nos": 0.24075563967227936,
632
+ "bce_ab": 0.023018423346802593,
633
+ "bce_cn": 0.054641781151294706,
634
+ "total": 0.8909100878238678,
635
+ "iter": 2650,
636
+ "lr": 1.6499445418129828e-05,
637
+ "elapsed_s": 2683.160297870636
638
+ },
639
+ {
640
+ "ce_cell": 0.16699187859892844,
641
+ "dice_cell": 0.37973670542240145,
642
+ "ce_nos": 0.031007613111287354,
643
+ "dice_nos": 0.25119431257247926,
644
+ "bce_ab": 0.02384809180162847,
645
+ "bce_cn": 0.05100016433745622,
646
+ "total": 0.9037787771224975,
647
+ "iter": 2700,
648
+ "lr": 1.3745913138031944e-05,
649
+ "elapsed_s": 2734.7909269332886
650
+ },
651
+ {
652
+ "ce_cell": 0.1654239809513092,
653
+ "dice_cell": 0.373527038693428,
654
+ "ce_nos": 0.026881940369494258,
655
+ "dice_nos": 0.25084660708904266,
656
+ "bce_ab": 0.03065326821990311,
657
+ "bce_cn": 0.05875104777514935,
658
+ "total": 0.9060838842391967,
659
+ "iter": 2750,
660
+ "lr": 1.1400837087866401e-05,
661
+ "elapsed_s": 2785.7226259708405
662
+ },
663
+ {
664
+ "ce_cell": 0.164136121571064,
665
+ "dice_cell": 0.37796851992607117,
666
+ "ce_nos": 0.03165758168324828,
667
+ "dice_nos": 0.2464037835597992,
668
+ "bce_ab": 0.02406527513638139,
669
+ "bce_cn": 0.05659090880304575,
670
+ "total": 0.9008221983909607,
671
+ "iter": 2800,
672
+ "lr": 9.471095779739621e-06,
673
+ "elapsed_s": 2837.11731505394
674
+ },
675
+ {
676
+ "ce_cell": 0.16365474253892898,
677
+ "dice_cell": 0.3711686569452286,
678
+ "ce_nos": 0.03501185901463032,
679
+ "dice_nos": 0.26115318775177004,
680
+ "bce_ab": 0.020862439805641772,
681
+ "bce_cn": 0.06006271541118622,
682
+ "total": 0.9119135963916779,
683
+ "iter": 2850,
684
+ "lr": 7.96234947739845e-06,
685
+ "elapsed_s": 2888.5047039985657
686
+ },
687
+ {
688
+ "ce_cell": 0.1650400999188423,
689
+ "dice_cell": 0.37380745112895963,
690
+ "ce_nos": 0.027719355495646594,
691
+ "dice_nos": 0.24648674428462983,
692
+ "bce_ab": 0.030483954632654787,
693
+ "bce_cn": 0.05518790137022734,
694
+ "total": 0.898725507259369,
695
+ "iter": 2900,
696
+ "lr": 6.8790235937012375e-06,
697
+ "elapsed_s": 2939.583750963211
698
+ },
699
+ {
700
+ "ce_cell": 0.1652056121826172,
701
+ "dice_cell": 0.37707124650478363,
702
+ "ce_nos": 0.027215274684131145,
703
+ "dice_nos": 0.24094445288181304,
704
+ "bce_ab": 0.02379746310412884,
705
+ "bce_cn": 0.05311750005930662,
706
+ "total": 0.8873515450954437,
707
+ "iter": 2950,
708
+ "lr": 6.224295710119515e-06,
709
+ "elapsed_s": 2990.7978727817535
710
+ },
711
+ {
712
+ "ce_cell": 0.15959388136863709,
713
+ "dice_cell": 0.3714492267370224,
714
+ "ce_nos": 0.030986426128074528,
715
+ "dice_nos": 0.2347310698032379,
716
+ "bce_ab": 0.018123713415116073,
717
+ "bce_cn": 0.05247432488948107,
718
+ "total": 0.8673586428165436,
719
+ "iter": 3000,
720
+ "lr": 6.000086256343946e-06,
721
+ "elapsed_s": 3042.6654930114746
722
+ }
723
+ ],
724
+ "val": []
725
+ }