ct-tranchau commited on
Commit
47c4bf8
Β·
verified Β·
1 Parent(s): a0d265c

RootScope v4: manuscript model (LightGBM per round, 3 seeds, layer + soft-neighbor context, radial prior)

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: 'RootScope: Cross-species Root Cell-Type Classification from Confocal Microscopy Images'
3
  emoji: 🌱
4
  colorFrom: green
5
  colorTo: blue
@@ -14,9 +14,16 @@ short_description: Classify root cell types across species from confocal TIFFs
14
  # RootScope web app
15
 
16
  Gradio front end for [RootScope](https://github.com/ct-tranchau/Rootscope):
17
- upload a confocal root-tip cross-section TIFF, get back a labeled overlay and a
18
  per-cell CSV. Runs on a Hugging Face Space with ZeroGPU.
19
 
 
 
 
 
 
 
 
20
  ## Do not load the classifiers in the main process
21
 
22
  Unpickling XGBoost builds a Booster that probes for CUDA devices. On ZeroGPU
 
1
  ---
2
+ title: 'RootScope v4: Cross-species Root Cell-Type Classification from Confocal Microscopy Images'
3
  emoji: 🌱
4
  colorFrom: green
5
  colorTo: blue
 
14
  # RootScope web app
15
 
16
  Gradio front end for [RootScope](https://github.com/ct-tranchau/Rootscope):
17
+ upload a confocal root cross-section TIFF, get back a labeled overlay and a
18
  per-cell CSV. Runs on a Hugging Face Space with ZeroGPU.
19
 
20
+ **Since 4.0.0 the app runs the v4 model** (the one in the manuscript):
21
+ LightGBM per refinement round, three seeds averaged, soft-neighbor and
22
+ layer-distance context rebuilt each round, radial-order prior at the end.
23
+ The model dropdown therefore shows a single entry. Set
24
+ `ROOTSCOPE_MODEL_VERSION=v2` in the Space's variables to run the old ensemble.
25
+ v4 keeps every segmented cell (no debris filter), as the manuscript did.
26
+
27
  ## Do not load the classifiers in the main process
28
 
29
  Unpickling XGBoost builds a Booster that probes for CUDA devices. On ZeroGPU
app.py CHANGED
@@ -31,7 +31,7 @@ from PIL import Image
31
  from rootscope import predict as rs
32
  from rootscope.cnn_embeddings import load_dinov2
33
  from rootscope.extract_features import load_cellpose_model
34
- from rootscope.weights import resolve_cnn_weights, resolve_model_dir
35
 
36
  # ── ZeroGPU shim ─────────────────────────────────────────────────────────────
37
  # `spaces` only exists on Hugging Face. Locally the decorator becomes a no-op
@@ -68,6 +68,8 @@ MAX_PIXELS = 40_000_000 # ~6300x6300; refuse anything larger
68
  # Settings β†’ Variables and restart; no code change needed.
69
  SHOW_DIAGNOSTICS = os.environ.get("ROOTSCOPE_DIAGNOSTICS") == "1"
70
  MODEL_CHOICES = ["Ensemble", "RandomForest", "LightGBM", "XGBoost"]
 
 
71
 
72
  # ── fetch weights at startup, load nothing ───────────────────────────────────
73
  # Downloading here means the first visitor does not wait for ~550 MB. Note that
@@ -161,13 +163,8 @@ def _segment_duration(img_rgb):
161
 
162
 
163
  def _embed_duration(masks, img_rgb, df_base):
164
- # v2's backbone is DINOv2 ViT-B/14 (86.6 M parameters) where v1 used
165
- # ViT-S/14 (22.1 M), about 3.5x the work per cell. Measured on the live
166
- # Space, v1 embedded 214 cells in ~2 s, so v2 should need ~7 s; the floor
167
- # and slope below are roughly 5x that, keeping the same deliberate margin
168
- # the v1 numbers had. Still capped under the signed-out quota.
169
  n = int(masks.max())
170
- return int(min(MAX_DURATION, max(30, 12 + 0.12 * n)))
171
 
172
 
173
  def _gpu_call(fn, *args):
@@ -509,7 +506,8 @@ def run_rootscope(tif_path, tif_file, um_per_px, which_model, label_cells,
509
  # ---- CPU: tissue layers + handcrafted features ----
510
  progress(0.35, desc=f"{n_cells} cells, layer index and features")
511
  masks, df_base, layer_lookup, adjacency, n_layers = rs.stage_features(
512
- masks, img_rgb, um_per_px=float(um_per_px))
 
513
  if df_base is None:
514
  raise gr.Error("Every detected object was filtered out as debris "
515
  "outside the tissue body.")
@@ -618,9 +616,11 @@ with gr.Blocks(title=TITLE, **_blocks_kwargs) as demo:
618
  "feature.",
619
  )
620
  model_pick = gr.Dropdown(
621
- MODEL_CHOICES, value="Ensemble", label="Model to display",
622
- info="All four are computed in one run; this only picks which "
623
- "one you see. Ensemble is the published result.",
 
 
624
  )
625
  with gr.Accordion("Advanced", open=False):
626
  label_cells = gr.Checkbox(
@@ -629,9 +629,12 @@ with gr.Blocks(title=TITLE, **_blocks_kwargs) as demo:
629
  "section the text overlaps and hides the image.",
630
  )
631
  max_rounds = gr.Slider(
632
- 1, 20, value=10, step=1, label="Max refinement rounds",
633
- info="The classifier re-predicts using neighbours' types "
634
- "until predictions stop changing.",
 
 
 
635
  )
636
  run_btn = gr.Button("Run RootScope", variant="primary")
637
  # Hugging Face meters free GPU per visitor, not per Space, so the
 
31
  from rootscope import predict as rs
32
  from rootscope.cnn_embeddings import load_dinov2
33
  from rootscope.extract_features import load_cellpose_model
34
+ from rootscope.weights import is_v4, model_version, resolve_cnn_weights, resolve_model_dir
35
 
36
  # ── ZeroGPU shim ─────────────────────────────────────────────────────────────
37
  # `spaces` only exists on Hugging Face. Locally the decorator becomes a no-op
 
68
  # Settings β†’ Variables and restart; no code change needed.
69
  SHOW_DIAGNOSTICS = os.environ.get("ROOTSCOPE_DIAGNOSTICS") == "1"
70
  MODEL_CHOICES = ["Ensemble", "RandomForest", "LightGBM", "XGBoost"]
71
+ if is_v4():
72
+ MODEL_CHOICES = ["LightGBM"] # v4 ships one classifier (3 seeds averaged inside)
73
 
74
  # ── fetch weights at startup, load nothing ───────────────────────────────────
75
  # Downloading here means the first visitor does not wait for ~550 MB. Note that
 
163
 
164
 
165
  def _embed_duration(masks, img_rgb, df_base):
 
 
 
 
 
166
  n = int(masks.max())
167
+ return int(min(MAX_DURATION, max(25, 10 + 0.06 * n)))
168
 
169
 
170
  def _gpu_call(fn, *args):
 
506
  # ---- CPU: tissue layers + handcrafted features ----
507
  progress(0.35, desc=f"{n_cells} cells, layer index and features")
508
  masks, df_base, layer_lookup, adjacency, n_layers = rs.stage_features(
509
+ masks, img_rgb, um_per_px=float(um_per_px),
510
+ remove_debris=not is_v4()) # v4 keeps every cell, as in the manuscript
511
  if df_base is None:
512
  raise gr.Error("Every detected object was filtered out as debris "
513
  "outside the tissue body.")
 
616
  "feature.",
617
  )
618
  model_pick = gr.Dropdown(
619
+ MODEL_CHOICES, value=MODEL_CHOICES[0], label="Model to display",
620
+ info=("v4: one LightGBM (three training seeds averaged inside); "
621
+ "this is the published result." if is_v4() else
622
+ "All four are computed in one run; this only picks which "
623
+ "one you see. Ensemble is the published result."),
624
  )
625
  with gr.Accordion("Advanced", open=False):
626
  label_cells = gr.Checkbox(
 
629
  "section the text overlaps and hides the image.",
630
  )
631
  max_rounds = gr.Slider(
632
+ 1, 20, value=(6 if is_v4() else 10), step=1,
633
+ label="Max refinement rounds",
634
+ info=("v4 was evaluated at 6; going to 10 changes accuracy "
635
+ "by under 0.1 pp." if is_v4() else
636
+ "The classifier re-predicts using neighbours' types "
637
+ "until predictions stop changing."),
638
  )
639
  run_btn = gr.Button("Run RootScope", variant="primary")
640
  # Hugging Face meters free GPU per visitor, not per Space, so the
classify_worker.py CHANGED
@@ -40,6 +40,26 @@ def run(job):
40
  # Imported here, not at module scope, so that even this process does no
41
  # model work until it has a job to do.
42
  from rootscope import predict as rs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  models, scalers, feature_cols, le = rs.load_models(job["model_dir"])
45
  if not models:
 
40
  # Imported here, not at module scope, so that even this process does no
41
  # model work until it has a job to do.
42
  from rootscope import predict as rs
43
+ from rootscope.weights import is_v4
44
+
45
+ if is_v4():
46
+ # v4: one LightGBM per round x 3 seeds, soft-neighbor + layer features
47
+ # rebuilt each round, radial-order prior at the end. No XGBoost anywhere,
48
+ # but the subprocess isolation is kept: it costs ~2 s and guards the
49
+ # parent's CUDA state against the same class of bug.
50
+ df = rs.stage_classify_v4(
51
+ job["df_base"], job["masks"], job["img_rgb"],
52
+ model_dir=job["model_dir"],
53
+ out_dir=job["out_dir"],
54
+ stem=job["stem"],
55
+ source_name=job["source_name"],
56
+ um_per_px=job["um_per_px"],
57
+ max_rounds=job["max_rounds"],
58
+ label_cells=job["label_cells"],
59
+ )
60
+ from rootscope.classify_v4 import CLASSES
61
+ return {"ok": True, "df": df, "classes": list(CLASSES),
62
+ "models": ["LightGBM"]}
63
 
64
  models, scalers, feature_cols, le = rs.load_models(job["model_dir"])
65
  if not models:
requirements.txt CHANGED
@@ -1,26 +1,38 @@
1
- # torch / torchvision are deliberately absent: ZeroGPU supplies its own CUDA
2
- # build (2.8+) and pinning them here would fight it. Same pattern the
3
- # Cellpose-SAM Space uses.
4
- gradio
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- numpy
7
- pandas
8
- scipy
9
- scikit-image
10
- # Hard pin: the released .joblib models were pickled with 1.7.2 and other
11
- # versions can fail to unpickle or silently change behaviour.
12
  scikit-learn==1.7.2
13
- opencv-python-headless
14
- Pillow
15
- tifffile
16
- imagecodecs
17
- matplotlib
18
- seaborn
19
- tqdm
20
- joblib
21
 
22
- xgboost
23
- lightgbm
 
 
 
 
 
 
 
 
 
24
  cellpose>=4.0
25
-
 
26
  huggingface_hub>=0.24
 
1
+ # Dependencies for the RootScope v4 Hugging Face Space.
2
+ #
3
+ # The Space vendors its own copy of rootscope/ (uploaded next to app.py), so
4
+ # RootScope is NOT installed from GitHub here. Doing both would put two
5
+ # packages of the same name on sys.path and whichever imports first wins --
6
+ # a bug that shows up only as wrong predictions.
7
+ #
8
+ # torch is NOT pinned, deliberately: ZeroGPU supports only torch >= 2.8 and
9
+ # picks the version. torch 2.6+ defaults torch.load(weights_only=True), which
10
+ # backbone.pt and the Cellpose-SAM checkpoint must load under -- test the two
11
+ # example images on the Space before announcing a change.
12
+ #
13
+ # v4 needs no xgboost: the classifier is LightGBM only. It is not listed on
14
+ # purpose (unpickling an XGBoost Booster is what poisons ZeroGPU's CUDA state;
15
+ # see classify_worker.py). Do not add it back for "completeness".
16
 
17
+ gradio>=5.0
18
+ spaces>=0.30
19
+
20
+ # scikit-learn is a hard pin: the LightGBM bundles carry StandardScalers pickled
21
+ # with 1.7.2, and other versions can fail to unpickle or change results.
 
22
  scikit-learn==1.7.2
 
 
 
 
 
 
 
 
23
 
24
+ numpy>=1.24
25
+ pandas>=2.0
26
+ scipy>=1.10
27
+ scikit-image>=0.22
28
+ opencv-python-headless>=4.8
29
+ Pillow>=10.0
30
+ tifffile>=2024.1.30
31
+ imagecodecs>=2024.1.1
32
+ joblib>=1.3
33
+ tqdm>=4.66
34
+ lightgbm>=4.0
35
  cellpose>=4.0
36
+ torch
37
+ torchvision
38
  huggingface_hub>=0.24
rootscope/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Rootscope β€” cell-type prediction for confocal root-tip cross-section images.
3
 
4
  Give it a TIFF, it segments the cells (Cellpose-SAM), extracts shape / size /
5
  intensity / layer features plus fine-tuned DINOv2 embeddings, and predicts the
@@ -16,7 +16,7 @@ Typical use (Python):
16
  df = predict_tif("my_image.tif", out_dir="results/", gpu=True)
17
  """
18
 
19
- __version__ = "0.1.0"
20
 
21
  from .api import predict_tif, predict_folder # noqa: E402,F401
22
 
 
1
  """
2
+ Rootscope: cell-type prediction for confocal root-tip cross-section images.
3
 
4
  Give it a TIFF, it segments the cells (Cellpose-SAM), extracts shape / size /
5
  intensity / layer features plus fine-tuned DINOv2 embeddings, and predicts the
 
16
  df = predict_tif("my_image.tif", out_dir="results/", gpu=True)
17
  """
18
 
19
+ __version__ = "4.0.0"
20
 
21
  from .api import predict_tif, predict_folder # noqa: E402,F401
22
 
rootscope/__pycache__/__init__.cpython-310.pyc DELETED
Binary file (936 Bytes)
 
rootscope/__pycache__/api.cpython-310.pyc DELETED
Binary file (2.62 kB)
 
rootscope/__pycache__/cli.cpython-310.pyc DELETED
Binary file (3.64 kB)
 
rootscope/__pycache__/extract_features.cpython-310.pyc DELETED
Binary file (41.9 kB)
 
rootscope/__pycache__/overlay.cpython-310.pyc DELETED
Binary file (9.19 kB)
 
rootscope/__pycache__/predict.cpython-310.pyc DELETED
Binary file (25.8 kB)
 
rootscope/__pycache__/weights.cpython-310.pyc DELETED
Binary file (7.47 kB)
 
rootscope/_eval_honest.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BUOC 5 -- danh gia trung thuc, khop dung cach tool chay tren anh moi.
3
+
4
+ Ba khac biet so voi train_iterative_cnn.py:
5
+
6
+ 1. DINO backbone chi hoc 100 section train -> cnn_emb_* cua 26 section test sach.
7
+
8
+ 2. Hang xom cua tap TEST khong lay tu BMP. Vong 1 = -1, vong sau lay tu chinh
9
+ PREDICTION, tra qua adjacency that (dung cell_id), y het
10
+ predict_cell_types_iterative.py. Khong dung
11
+ _fill_neighbor_celltypes_from_predictions() vi ham do doc GT tu CSV.
12
+
13
+ 3. Hinh hoc hang xom tinh tren TAT CA te bao segment duoc (df_geom), khong chi
14
+ te bao co nhan -- vi tren anh moi khong he co khai niem "khong nhan" hay
15
+ "do tin cay thap"; moi te bao deu co mat trong mo va deu duoc du doan.
16
+ Chi tap con co nhan + du tin cay (df_model) moi dung de train va cham diem.
17
+
18
+ Dong trung (source_file, cell_id) bi bo. Nhan BMP cua 26 section chi dung de cham diem.
19
+ """
20
+ import argparse, json, sys
21
+ from pathlib import Path
22
+
23
+ import joblib
24
+ import numpy as np
25
+ import pandas as pd
26
+ from sklearn.metrics import accuracy_score
27
+ from sklearn.model_selection import StratifiedKFold, StratifiedGroupKFold
28
+ from sklearn.preprocessing import LabelEncoder, StandardScaler
29
+ from sklearn.utils.class_weight import compute_sample_weight
30
+
31
+ ROOT = Path(__file__).resolve().parent # khong dung khi suy dien; giu cho tuong thich
32
+
33
+ # Copied verbatim from the research training module (train_iterative_cnn.py) so
34
+ # that inference does not import it: that module pulls in xgboost, matplotlib and
35
+ # seaborn at import time, none of which v4 inference uses. Only NEIGHBOR_CT_COLS
36
+ # is read on the inference path (in _blank); FEATURE_COLUMNS is kept for
37
+ # load_tables(), and the two training helpers are stubs that refuse to run.
38
+ FEATURE_COLUMNS = [
39
+ "layer_index", "normalized_radius", "edt_distance_px", "edt_normalized",
40
+ "layer_fraction", "is_boundary_cell", "dist_from_centroid_um",
41
+ "angular_position", "n_layers_total",
42
+ "area_um2", "perimeter_um", "eccentricity", "solidity", "aspect_ratio",
43
+ "compactness", "major_axis_px", "minor_axis_px", "orientation_rad",
44
+ "extent", "area_perimeter_ratio", "equivalent_diameter",
45
+ "mean_intensity", "std_intensity", "min_intensity", "max_intensity",
46
+ "median_intensity", "intensity_range", "intensity_cv",
47
+ "intensity_skewness", "intensity_kurtosis", "intensity_p10", "intensity_p90",
48
+ "neighbors_count", "mean_neighbor_layer", "std_neighbor_layer",
49
+ "mean_neighbor_area", "layer_diff_from_neighbors", "area_ratio_to_neighbors",
50
+ "touches_background", "cells_in_same_layer", "frac_neighbors_same_layer",
51
+ "frac_neighbors_inner", "frac_neighbors_outer", "inner_neighbor_count",
52
+ "outer_neighbor_count", "layer_from_inside", "radial_intensity_gradient",
53
+ "neighbor_layer_range", "neighbors_boundary_cell", "min_neighbor_layer",
54
+ "max_neighbor_layer", "n_neighbors_touching_bg",
55
+ "area_zscore", "area_ratio_to_global_median", "area_percentile_in_layer",
56
+ "area_ratio_to_inner", "area_ratio_to_outer", "hexagonality", "n_vertices",
57
+ "layer_cell_count_ratio", "inner_layer_cell_count", "outer_layer_cell_count",
58
+ "layer_count_gradient", "layer_count_asymmetry", "adjacent_layer_area_ratio",
59
+ "sin_angular_position", "cos_angular_position", "radial_x_sin", "radial_x_cos",
60
+ "radial_inward_neighbor_area", "radial_outward_neighbor_area",
61
+ "cw_neighbor_area", "ccw_neighbor_area",
62
+ "radial_inward_neighbor_intensity", "radial_outward_neighbor_intensity",
63
+ "cw_neighbor_intensity", "ccw_neighbor_intensity",
64
+ "radial_inward_neighbor_celltype", "radial_outward_neighbor_celltype",
65
+ "tangential_cw_neighbor_celltype", "tangential_ccw_neighbor_celltype",
66
+ "wall_thickness_proxy", "wall_to_lumen_ratio", "local_area_rank_in_stele",
67
+ "neighbor_area_std", "cell_wall_contrast",
68
+ "lumen_darkness", "wall_lumen_gap", "interior_intensity_std",
69
+ "frac_dark_interior", "ring1_intensity", "ring2_intensity",
70
+ "wall_interior_gradient", "mean_neighbor_wall_thickness",
71
+ "wall_thickness_vs_neighbors", "area_ratio_to_stele_mean",
72
+ ]
73
+
74
+ NEIGHBOR_CT_COLS = [
75
+ "radial_inward_neighbor_celltype", "radial_outward_neighbor_celltype",
76
+ "tangential_cw_neighbor_celltype", "tangential_ccw_neighbor_celltype",
77
+ ]
78
+
79
+
80
+ def _build_model(name, n_est, seed): # training-only; not shipped with the package
81
+ raise RuntimeError("_build_model is a training helper and is not part of the "
82
+ "RootScope inference package.")
83
+
84
+
85
+ def _compute_sample_weights(*a, **k): # training-only; not shipped with the package
86
+ raise RuntimeError("_compute_sample_weights is a training helper and is not part "
87
+ "of the RootScope inference package.")
88
+ from .extract_features import ( # noqa: E402
89
+ build_cell_adjacency, compute_neighbor_celltypes, CELL_CLASSES,
90
+ )
91
+
92
+ OUT = ROOT / "Figure_groundtruth_predicted"
93
+
94
+ _BASE_BUILD = _build_model # giu ban goc truoc khi thay
95
+ HP = {} # hyperparameter de de len tren, tu --hp
96
+
97
+
98
+ def _build_model_hp(name, n_est, seed):
99
+ """Nhu _build_model nhung (a) nhan them 'Ensemble' = soft-vote ca 3 model,
100
+ (b) cho phep de hyperparameter tu --hp len tren mac dinh hardcode."""
101
+ if name == "Ensemble":
102
+ from sklearn.ensemble import VotingClassifier
103
+ return VotingClassifier(
104
+ [(n, _build_model_hp(n, n_est, seed))
105
+ for n in ("RandomForest", "XGBoost", "LightGBM")],
106
+ voting="soft")
107
+ m = _BASE_BUILD(name, n_est, seed)
108
+ if HP:
109
+ ok = {k: v for k, v in HP.items() if k in m.get_params()}
110
+ if ok:
111
+ m.set_params(**ok)
112
+ return m
113
+
114
+
115
+ def dedup_best(df):
116
+ """
117
+ Bo dong trung (source_file, cell_id).
118
+
119
+ 11 section co nhieu hon 1 file BMP (139 BMP / 126 section). Hai ban nhan
120
+ khong phai luc nao cung giong: do duoc 8.2% cap mau thuan. Nen giu ban co
121
+ cell_type_confidence CAO NHAT thay vi lay bua dong dau tien.
122
+ """
123
+ n0 = len(df)
124
+ if "cell_type_confidence" in df.columns:
125
+ df = df.sort_values("cell_type_confidence", ascending=False,
126
+ na_position="last", kind="mergesort")
127
+ df = df.drop_duplicates(["source_file", "cell_id"], keep="first")
128
+ df = df.sort_values(["source_file", "cell_id"], kind="mergesort").reset_index(drop=True)
129
+ if len(df) != n0:
130
+ print(f" bo {n0-len(df):,} dong trung (giu ban confidence cao nhat)")
131
+ return df
132
+
133
+
134
+ def load_tables(csv_path, min_confidence):
135
+ df = pd.read_csv(csv_path, low_memory=False)
136
+ df = dedup_best(df)
137
+
138
+ available = [c for c in FEATURE_COLUMNS if c in df.columns]
139
+ if MT_KEEP is not None:
140
+ available = [c for c in available if c in MT_KEEP]
141
+ print(f" chi giu {len(available)} feature hinh thai")
142
+ if USE_NB_AGG:
143
+ for c in NB_AGG_COLS:
144
+ df[c] = -1.0
145
+ available += NB_AGG_COLS
146
+ print(f" THEM {len(NB_AGG_COLS)} feature thanh phan hang xom")
147
+ cnn = sorted([c for c in df.columns if c.startswith("cnn_emb_")],
148
+ key=lambda x: int(x.split("_")[-1]))
149
+ sec = sorted([c for c in df.columns if c.startswith("sec_")])
150
+ if sec:
151
+ available += sec
152
+ print(f" INCLUDING {len(sec)} feature muc section: {sec}")
153
+ if cnn:
154
+ available += cnn
155
+ print(f" INCLUDING {len(cnn)} CNN embedding features")
156
+ else:
157
+ print(" WARNING: khong thay cot cnn_emb_*")
158
+
159
+ geom = df.dropna(subset=available).reset_index(drop=True)
160
+ m = (geom["cell_type_label"] >= 0) & geom["cell_type"].notna()
161
+ if "cell_type_confidence" in geom.columns:
162
+ m &= geom["cell_type_confidence"] >= min_confidence
163
+ model = geom[m].reset_index(drop=True)
164
+ print(f" segment duoc: {len(geom):,} te bao; dung train/cham diem: {len(model):,}")
165
+ return geom, model, available
166
+
167
+
168
+ _ADJ = {}
169
+
170
+
171
+ def _adjacency_for(source_file, mask_dir):
172
+ if source_file not in _ADJ:
173
+ p = Path(mask_dir) / f"{Path(source_file).stem}_masks.npy"
174
+ if not p.exists():
175
+ raise FileNotFoundError(f"thieu mask: {p}")
176
+ if len(_ADJ) > 200: # du chua ca 126 section, khoi tinh lai moi vong
177
+ _ADJ.clear()
178
+ _ADJ[source_file] = build_cell_adjacency(np.load(p))
179
+ return _ADJ[source_file]
180
+
181
+
182
+ def fill_neighbors(df_geom, pred_names, mask_dir):
183
+ """Dien 4 cot hang xom tu NHAN DU DOAN cua MOI te bao, qua adjacency that."""
184
+ pred_names = np.asarray(pred_names)
185
+ parts = []
186
+ for sf, idx in df_geom.groupby("source_file").groups.items():
187
+ sub = df_geom.loc[idx].copy()
188
+ pos = df_geom.index.get_indexer(idx)
189
+ labels = {int(c): CELL_CLASSES.get(p, -1)
190
+ for c, p in zip(sub["cell_id"].values, pred_names[pos])}
191
+ adj = _adjacency_for(sf, mask_dir)
192
+ part = compute_neighbor_celltypes(sub, adj, cell_type_labels=labels)
193
+ if USE_NB_AGG:
194
+ names_by_cid = {int(c): pn for c, pn in zip(sub["cell_id"].values,
195
+ pred_names[pos])}
196
+ for k, v in _nb_aggregates(sub, adj, names_by_cid).items():
197
+ part[k] = v
198
+ parts.append(part)
199
+ return pd.concat(parts).loc[df_geom.index]
200
+
201
+
202
+ def _sample_weights(y, le, wp_boost):
203
+ """Nhu _compute_sample_weights goc, nhung he so cho xylem/phloem chinh duoc.
204
+
205
+ "balanced" da lo phan MAT CAN BANG (nghich dao tan suat). Moi he so nhan
206
+ them sau do la UU TIEN, khong phai chinh mat can bang. Ban goc hardcode
207
+ x15 cho xylem va phloem -- day la de do lai xem no con dang gia khong."""
208
+ w = compute_sample_weight("balanced", y)
209
+ for c in ("xylem", "phloem"):
210
+ if c in le.classes_:
211
+ w[y == le.transform([c])[0]] *= wp_boost
212
+ return w
213
+
214
+
215
+ VASCULAR = ("stele", "xylem", "phloem")
216
+ OUTER_TISSUE = ("epidermis", "exodermis", "root_cap")
217
+ NB_AGG_COLS = ["frac_nb_vascular", "n_nb_vascular", "frac_nb_cortex",
218
+ "frac_nb_same", "frac_nb_outer_tissue"]
219
+ ALL_CLASSES = ["root_cap", "epidermis", "exodermis", "cortex", "endodermis",
220
+ "pericycle", "stele", "xylem", "phloem"]
221
+ NB_PER_CLASS = [f"frac_nb_{c}" for c in ALL_CLASSES]
222
+ USE_NB_AGG = False
223
+ MT_KEEP = None
224
+
225
+
226
+ def _nb_aggregates(sub_df, adj, names_by_cid):
227
+ """Thanh phan lop cua TAT CA hang xom quanh moi cell.
228
+
229
+ 4 cot NEIGHBOR_CT_COLS chi lay DUNG MOT hang xom gan nhat moi huong, nen
230
+ cell pericycle nam o lop trong bi pericycle khac che se mat han tin hieu
231
+ stele (do duoc: 393/843 cell roi vao truong hop nay). Cac cot nay tong hop
232
+ tren MOI hang xom nen khong bi che.
233
+
234
+ Tinh du 5 dai luong roi chi tra ve nhung cot dang duoc yeu cau.
235
+ """
236
+ allv = {c: [] for c in ("frac_nb_vascular", "n_nb_vascular", "frac_nb_cortex",
237
+ "frac_nb_same", "frac_nb_outer_tissue")}
238
+ for cid in sub_df["cell_id"].values:
239
+ nb = [names_by_cid.get(b) for b in adj.get(int(cid), ())]
240
+ nb = [x for x in nb if x is not None and x != "__none__"]
241
+ n = len(nb)
242
+ me = names_by_cid.get(int(cid))
243
+ if n == 0:
244
+ for c in allv:
245
+ allv[c].append(-1.0)
246
+ continue
247
+ nv = sum(x in VASCULAR for x in nb)
248
+ allv["frac_nb_vascular"].append(nv / n)
249
+ allv["n_nb_vascular"].append(float(nv))
250
+ allv["frac_nb_cortex"].append(sum(x == "cortex" for x in nb) / n)
251
+ allv["frac_nb_same"].append(sum(x == me for x in nb) / n if me else -1.0)
252
+ allv["frac_nb_outer_tissue"].append(sum(x in OUTER_TISSUE for x in nb) / n)
253
+ for c in ALL_CLASSES:
254
+ allv[f"frac_nb_{c}"] = []
255
+ for cid in sub_df["cell_id"].values:
256
+ nb = [names_by_cid.get(b) for b in adj.get(int(cid), ())]
257
+ nb = [x for x in nb if x is not None and x != "__none__"]
258
+ n = len(nb)
259
+ for c in ALL_CLASSES:
260
+ allv[f"frac_nb_{c}"].append(sum(x == c for x in nb) / n if n else -1.0)
261
+ return {c: allv[c] for c in NB_AGG_COLS}
262
+
263
+
264
+ def _blank(df):
265
+ d = df.copy()
266
+ for c in list(NEIGHBOR_CT_COLS) + list(NB_AGG_COLS):
267
+ if c in d.columns:
268
+ d[c] = -1.0
269
+ return d
270
+
271
+
272
+ def _positions(df_geom, df_model):
273
+ """Vi tri cua tung dong df_model trong df_geom."""
274
+ g = pd.MultiIndex.from_frame(df_geom[["source_file", "cell_id"]])
275
+ m = pd.MultiIndex.from_frame(df_model[["source_file", "cell_id"]])
276
+ pos = g.get_indexer(m)
277
+ assert (pos >= 0).all(), "co dong model khong tim thay trong geom"
278
+ return pos
279
+
280
+
281
+ def iterative_fit(name, geom, model_df, pos, fcols, y, le, cv, sw,
282
+ mask_dir, n_est, seed, max_rounds, preset=False, groups=None):
283
+ df_cur, prev, hist = (geom if preset else _blank(geom)), None, []
284
+ mdl = scaler = None
285
+ for rnd in range(1, max_rounds + 1):
286
+ if rnd > 1:
287
+ df_cur = fill_neighbors(geom, prev, mask_dir)
288
+ Xg = df_cur[fcols].values.astype(np.float32)
289
+ Xm = Xg[pos]
290
+ scaler = StandardScaler().fit(Xm)
291
+ Xms = scaler.transform(Xm)
292
+
293
+ oof = np.zeros(len(y), dtype=int)
294
+ for tr, va in cv.split(Xms, y, groups):
295
+ f = _build_model(name, n_est, seed)
296
+ f.fit(Xms[tr], y[tr], sample_weight=sw[tr])
297
+ oof[va] = f.predict(Xms[va])
298
+ acc = accuracy_score(y, oof)
299
+ hist.append(round(float(acc), 4))
300
+
301
+ mdl = _build_model(name, n_est, seed)
302
+ mdl.fit(Xms, y, sample_weight=sw)
303
+
304
+ # nhan cho MOI te bao geom: co nhan -> oof (ngoai mau); con lai -> model vong nay
305
+ names = le.inverse_transform(mdl.predict(scaler.transform(Xg)))
306
+ names[pos] = le.inverse_transform(oof)
307
+
308
+ chg = -1 if prev is None else int((names != prev).sum())
309
+ print(f" Round {rnd}: CV acc = {acc:.4f}" +
310
+ ("" if chg < 0 else f", {chg}/{len(names)} doi ({100*chg/len(names):.2f}%)"),
311
+ flush=True)
312
+ if prev is not None and chg == 0:
313
+ print(" Hoi tu!", flush=True)
314
+ break
315
+ prev = names
316
+ return mdl, scaler, hist, oof
317
+
318
+
319
+ def iterative_predict(mdl, scaler, geom, fcols, le, mask_dir, max_rounds, preset=False):
320
+ df_cur, prev, hist = (geom if preset else _blank(geom)), None, []
321
+ names = None
322
+ for rnd in range(1, max_rounds + 1):
323
+ if rnd > 1:
324
+ df_cur = fill_neighbors(geom, prev, mask_dir)
325
+ names = le.inverse_transform(
326
+ mdl.predict(scaler.transform(df_cur[fcols].values.astype(np.float32))))
327
+ chg = -1 if prev is None else int((names != prev).sum())
328
+ hist.append(chg)
329
+ print(f" Test round {rnd}" + ("" if chg < 0 else f": {chg}/{len(names)} doi"),
330
+ flush=True)
331
+ if prev is not None and chg == 0:
332
+ break
333
+ prev = names
334
+ return names, hist
335
+
336
+
337
+ def main():
338
+ ap = argparse.ArgumentParser()
339
+ ap.add_argument("--features", required=True)
340
+ ap.add_argument("--mask-dir", required=True)
341
+ ap.add_argument("--tag", default="honest")
342
+ ap.add_argument("--model", default="LightGBM")
343
+ ap.add_argument("--n-estimators", type=int, default=500)
344
+ ap.add_argument("--max-rounds", type=int, default=10)
345
+ ap.add_argument("--seed", type=int, default=42)
346
+ ap.add_argument("--min-confidence", type=float, default=0.6)
347
+ ap.add_argument("--drop-neighbors", action="store_true")
348
+ ap.add_argument("--boost-outer", type=float, default=0.0,
349
+ help="Nhan trong so mau len N lan cho cac class VONG NGOAI "
350
+ "(epidermis, exodermis, endodermis, root_cap). Muc tieu: model "
351
+ "uu tien dung o may vong don lop thay vi toi da accuracy tong.")
352
+ ap.add_argument("--boost-classes", default="epidermis,exodermis,endodermis,root_cap",
353
+ help="danh sach class duoc boost, ngan cach bang dau phay")
354
+ ap.add_argument("--boost-spec", default="",
355
+ help="He so RIENG cho tung class, vd "
356
+ "'endodermis:20,exodermis:15,epidermis:10'. Neu dat thi "
357
+ "no de len tren --boost-outer/--boost-classes.")
358
+ ap.add_argument("--nb-agg", action="store_true",
359
+ help="Them feature tong hop tren TOAN BO hang xom.")
360
+ ap.add_argument("--nb-per-class", action="store_true",
361
+ help="Mot feature cho MOI class: ty le hang xom thuoc tung "
362
+ "loai trong 9 loai. Day du, khong gop nhom tuy tien.")
363
+ ap.add_argument("--nb-lean", action="store_true",
364
+ help="Nhu --nb-agg nhung chi giu 2 cot thuc su moi.")
365
+ ap.add_argument("--mt-list", default="",
366
+ help="File liet ke feature hinh thai duoc giu (1 ten/dong).")
367
+ ap.add_argument("--hp", default="",
368
+ help="JSON hyperparameter de len tren mac dinh, vd "
369
+ "'{\"num_leaves\": 127, \"learning_rate\": 0.03}'")
370
+ ap.add_argument("--wp-boost", type=float, default=15.0,
371
+ help="He so nhan trong so cho xylem va phloem. Ban goc "
372
+ "hardcode 15.0; dat 1.0 de bo han, chi giu 'balanced'.")
373
+ ap.add_argument("--group-cv", action="store_true",
374
+ help="Chia CV theo SECTION (StratifiedGroupKFold) thay vi theo te bao. "
375
+ "Quan trong: nhan hang xom bom vao vong lap se nhieu dung bang "
376
+ "luc test (~0.81) thay vi qua sach (~0.96), nen train va test "
377
+ "khop phan phoi.")
378
+ ap.add_argument("--neighbors-from-gt", action="store_true",
379
+ help="CO CHU Y RO RI: dien hang xom tu NHAN THAT (kieu pipeline cu). "
380
+ "Chi de do xem DINO dong gop bao nhieu phan ro ri, "
381
+ "KHONG duoc dung lam so cong bo.")
382
+ a = ap.parse_args()
383
+ globals()["USE_NB_AGG"] = a.nb_agg or a.nb_lean
384
+ if a.nb_lean:
385
+ globals()["NB_AGG_COLS"] = ["frac_nb_vascular", "frac_nb_cortex"]
386
+ if a.nb_per_class:
387
+ # 9 ty le theo class + frac_nb_same. Cai thu 10 KHONG suy ra duoc tu 9
388
+ # cai kia: model khong nhan class cua chinh no lam dau vao, nen no
389
+ # khong biet cot nao la "cua minh".
390
+ globals()["NB_AGG_COLS"] = list(NB_PER_CLASS) + ["frac_nb_same"]
391
+ globals()["USE_NB_AGG"] = True
392
+ if a.mt_list:
393
+ globals()["MT_KEEP"] = {l.strip() for l in open(a.mt_list) if l.strip()}
394
+ if a.hp:
395
+ globals()["HP"] = json.loads(a.hp)
396
+ print(f" hyperparameter de len tren: {HP}")
397
+ globals()["_build_model"] = _build_model_hp
398
+
399
+ geom, model_df, fcols = load_tables(a.features, a.min_confidence)
400
+ if a.drop_neighbors:
401
+ fcols = [c for c in fcols if c not in NEIGHBOR_CT_COLS]
402
+ a.max_rounds = 1
403
+ print(f" bo 4 cot hang xom -> {len(fcols)} features (1 vong, khong lap)")
404
+
405
+ tr_s = {l.strip() for l in open(ROOT / "split_train.txt") if l.strip()}
406
+ te_s = {l.strip() for l in open(ROOT / "split_test.txt") if l.strip()}
407
+ assert not (tr_s & te_s), "split train/test chong nhau!"
408
+
409
+ g_tr = geom[geom.source_file.isin(tr_s)].reset_index(drop=True)
410
+ g_te = geom[geom.source_file.isin(te_s)].reset_index(drop=True)
411
+ m_tr = model_df[model_df.source_file.isin(tr_s)].reset_index(drop=True)
412
+ m_te = model_df[model_df.source_file.isin(te_s)].reset_index(drop=True)
413
+ p_tr, p_te = _positions(g_tr, m_tr), _positions(g_te, m_te)
414
+ print(f" train {m_tr.source_file.nunique()} section: {len(g_tr):,} te bao "
415
+ f"({len(m_tr):,} co nhan)")
416
+ print(f" test {m_te.source_file.nunique()} section: {len(g_te):,} te bao "
417
+ f"({len(m_te):,} co nhan)", flush=True)
418
+
419
+ le = LabelEncoder().fit(sorted(model_df.cell_type.unique()))
420
+ y_tr = le.transform(m_tr.cell_type)
421
+ if a.group_cv:
422
+ cv = StratifiedGroupKFold(5, shuffle=True, random_state=a.seed)
423
+ groups = m_tr.source_file.values
424
+ print(f" CV chia theo SECTION: {m_tr.source_file.nunique()} section -> 5 fold")
425
+ else:
426
+ cv = StratifiedKFold(5, shuffle=True, random_state=a.seed)
427
+ groups = None
428
+ print(" CV chia theo TE BAO (mac dinh)")
429
+ sw = _sample_weights(y_tr, le, a.wp_boost)
430
+ print(f" trong so: balanced, xylem/phloem x{a.wp_boost}")
431
+ if a.boost_spec:
432
+ idx = {c: i for i, c in enumerate(le.classes_)}
433
+ sw = sw.copy()
434
+ for part in a.boost_spec.split(","):
435
+ cls, _, mult = part.partition(":")
436
+ cls, mult = cls.strip(), float(mult)
437
+ assert cls in idx, f"class khong co: {cls}"
438
+ m = y_tr == idx[cls]
439
+ sw[m] *= mult
440
+ print(f" boost x{mult:g} cho {cls} ({int(m.sum()):,} te bao)")
441
+ elif a.boost_outer > 0:
442
+ OUTER = [c.strip() for c in a.boost_classes.split(",") if c.strip()]
443
+ idx = {c: i for i, c in enumerate(le.classes_)}
444
+ m = np.isin(y_tr, [idx[c] for c in OUTER if c in idx])
445
+ sw = sw.copy(); sw[m] *= a.boost_outer
446
+ print(f" boost x{a.boost_outer} cho {OUTER} ({int(m.sum()):,} te bao)")
447
+
448
+ if a.neighbors_from_gt:
449
+ print("\n *** CHE DO RO RI CO CHU Y: hang xom lay tu NHAN THAT ***", flush=True)
450
+ g_tr = fill_neighbors(g_tr, g_tr.cell_type.fillna("__none__").values, a.mask_dir)
451
+ g_te = fill_neighbors(g_te, g_te.cell_type.fillna("__none__").values, a.mask_dir)
452
+ a.max_rounds = 1
453
+
454
+ print("\n --- train (hang xom = prediction, qua adjacency that) ---", flush=True)
455
+ mdl, scaler, hist, oof = iterative_fit(a.model, g_tr, m_tr, p_tr, fcols, y_tr, le,
456
+ cv, sw, a.mask_dir, a.n_estimators, a.seed,
457
+ a.max_rounds, preset=a.neighbors_from_gt,
458
+ groups=groups)
459
+
460
+ oof_df = m_tr[["source_file", "cell_id", "species", "stage", "cell_type"]].copy()
461
+ oof_df["predicted"] = le.inverse_transform(oof)
462
+ oof_df["correct"] = oof_df.cell_type.values == oof_df.predicted.values
463
+ oof_df.to_csv(OUT / f"oof_{a.tag}.csv", index=False)
464
+ print(f" OOF train da ghi -> oof_{a.tag}.csv (CV acc {oof_df.correct.mean():.4f})")
465
+
466
+ print("\n --- cham diem 26 section (KHONG dung BMP lam feature) ---", flush=True)
467
+ names_all, thist = iterative_predict(mdl, scaler, g_te, fcols, le,
468
+ a.mask_dir, a.max_rounds,
469
+ preset=a.neighbors_from_gt)
470
+ pred = names_all[p_te]
471
+ acc = accuracy_score(m_te.cell_type.values, pred)
472
+
473
+ print(f"\n >>> {a.tag}: TEST = {acc:.4f}", flush=True)
474
+ print(f" >>> vong TRAIN den hoi tu: {len(hist)} (CV moi vong: {hist})", flush=True)
475
+ print(f" >>> vong TEST den hoi tu: {len(thist)} (so cell doi: {thist})", flush=True)
476
+
477
+ joblib.dump(mdl, OUT / f"model_{a.tag}.joblib")
478
+ joblib.dump(scaler, OUT / f"scaler_{a.tag}.joblib")
479
+ joblib.dump(le, OUT / f"labelencoder_{a.tag}.joblib")
480
+ o = m_te[["source_file", "cell_id", "species", "stage", "cell_type"]].copy()
481
+ o["predicted"], o["correct"] = pred, m_te.cell_type.values == pred
482
+ o.to_csv(OUT / f"test_predictions_{a.tag}.csv", index=False)
483
+ (o.groupby("source_file").agg(n_cells=("correct", "size"), accuracy=("correct", "mean"))
484
+ .round(4).to_csv(OUT / f"per_image_accuracy_{a.tag}.csv"))
485
+ pc = o.groupby("cell_type")["correct"].agg(["size", "mean"]).round(4)
486
+ print("\n Accuracy tung loai:"); print(pc.to_string())
487
+ pc.to_csv(OUT / f"per_class_{a.tag}.csv")
488
+
489
+
490
+ if __name__ == "__main__":
491
+ main()
rootscope/api.py CHANGED
@@ -15,53 +15,88 @@ from pathlib import Path
15
  import pandas as pd
16
 
17
  from . import predict as _predict
18
- from .weights import resolve_cnn_weights, resolve_model_dir
19
 
20
 
21
  def _load(model_dir=None, cnn_weights=None):
 
 
 
 
 
 
22
  mdir = resolve_model_dir(model_dir)
23
  cnn = resolve_cnn_weights(cnn_weights)
 
 
24
  models_dict, scalers, feature_cols, le = _predict.load_models(str(mdir))
25
  if not models_dict:
26
  raise RuntimeError(f"No usable models found in {mdir}.")
27
  return models_dict, scalers, feature_cols, le, cnn
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  def predict_tif(
31
  tif,
32
  out_dir="results",
33
- gpu=True,
34
  model_dir=None,
35
  cnn_weights=None,
36
- um_per_px=1.0,
37
- max_rounds=10,
38
  label_cells=False,
39
  ):
40
- """Segment + predict cell types for a single TIFF. Returns a DataFrame."""
41
- models_dict, scalers, feature_cols, le, cnn = _load(model_dir, cnn_weights)
42
- df = _predict.predict_single_tif(
43
- str(tif), models_dict, scalers, feature_cols, le,
44
- um_per_px=um_per_px, gpu=gpu, out_dir=str(out_dir),
45
- max_rounds=max_rounds, cnn_weights=str(cnn) if cnn else None,
46
- label_cells=label_cells,
47
- )
48
- return df
49
 
50
 
51
  def predict_folder(
52
  tif_dir,
53
  out_dir="results",
54
- gpu=True,
55
  model_dir=None,
56
  cnn_weights=None,
57
- um_per_px=1.0,
58
- max_rounds=10,
59
  label_cells=False,
60
  pattern="*.tif",
61
  ):
62
  """Segment + predict for every TIFF in a folder. Returns a combined
63
  DataFrame and writes ``all_predictions.csv`` into ``out_dir``."""
64
- models_dict, scalers, feature_cols, le, cnn = _load(model_dir, cnn_weights)
 
65
  tif_paths = sorted(Path(tif_dir).glob(pattern))
66
  if not tif_paths:
67
  raise FileNotFoundError(f"No files matching {pattern} in {tif_dir}")
@@ -69,12 +104,7 @@ def predict_folder(
69
  tables = []
70
  for tp in tif_paths:
71
  try:
72
- df = _predict.predict_single_tif(
73
- str(tp), models_dict, scalers, feature_cols, le,
74
- um_per_px=um_per_px, gpu=gpu, out_dir=str(out_dir),
75
- max_rounds=max_rounds, cnn_weights=str(cnn) if cnn else None,
76
- label_cells=label_cells,
77
- )
78
  if df is not None:
79
  tables.append(df)
80
  except Exception as e: # noqa: BLE001
 
15
  import pandas as pd
16
 
17
  from . import predict as _predict
18
+ from .weights import is_v4, resolve_cnn_weights, resolve_model_dir
19
 
20
 
21
  def _load(model_dir=None, cnn_weights=None):
22
+ """Resolve weights for the selected model version.
23
+
24
+ v4 returns ``(model_dir, None, None, None, cnn)``: its classifiers are
25
+ loaded inside the v4 stage (one bundle per seed, each carrying its own
26
+ scaler and feature list), so there is nothing to pre-load here.
27
+ """
28
  mdir = resolve_model_dir(model_dir)
29
  cnn = resolve_cnn_weights(cnn_weights)
30
+ if is_v4():
31
+ return mdir, None, None, None, cnn
32
  models_dict, scalers, feature_cols, le = _predict.load_models(str(mdir))
33
  if not models_dict:
34
  raise RuntimeError(f"No usable models found in {mdir}.")
35
  return models_dict, scalers, feature_cols, le, cnn
36
 
37
 
38
+ def _gpu_available():
39
+ try:
40
+ import torch
41
+ return bool(torch.cuda.is_available())
42
+ except Exception: # noqa: BLE001
43
+ return False
44
+
45
+
46
+ def _run_one(tif, loaded, out_dir, gpu, um_per_px, max_rounds, label_cells):
47
+ if gpu is None: # library default: use a GPU if there is one
48
+ gpu = _gpu_available()
49
+ models_dict, scalers, feature_cols, le, cnn = loaded
50
+ if is_v4():
51
+ return _predict.predict_single_tif_v4(
52
+ str(tif), model_dir=str(models_dict), um_per_px=um_per_px, gpu=gpu,
53
+ out_dir=str(out_dir), max_rounds=max_rounds,
54
+ cnn_weights=str(cnn) if cnn else None, label_cells=label_cells,
55
+ )
56
+ return _predict.predict_single_tif(
57
+ str(tif), models_dict, scalers, feature_cols, le,
58
+ um_per_px=um_per_px, gpu=gpu, out_dir=str(out_dir),
59
+ max_rounds=max_rounds, cnn_weights=str(cnn) if cnn else None,
60
+ label_cells=label_cells,
61
+ )
62
+
63
+
64
  def predict_tif(
65
  tif,
66
  out_dir="results",
67
+ gpu=None,
68
  model_dir=None,
69
  cnn_weights=None,
70
+ um_per_px=None,
71
+ max_rounds=None,
72
  label_cells=False,
73
  ):
74
+ """Segment + predict cell types for a single TIFF. Returns a DataFrame.
75
+
76
+ ``um_per_px=None`` reads the pixel size from the TIFF's OME metadata and
77
+ fails loudly if it is absent -- a wrong scale distorts every size feature.
78
+ ``max_rounds=None`` uses the version's own setting (6 for v4, 10 for v2).
79
+ """
80
+ loaded = _load(model_dir, cnn_weights)
81
+ return _run_one(tif, loaded, out_dir, gpu, um_per_px,
82
+ _predict.default_rounds(max_rounds), label_cells)
83
 
84
 
85
  def predict_folder(
86
  tif_dir,
87
  out_dir="results",
88
+ gpu=None,
89
  model_dir=None,
90
  cnn_weights=None,
91
+ um_per_px=None,
92
+ max_rounds=None,
93
  label_cells=False,
94
  pattern="*.tif",
95
  ):
96
  """Segment + predict for every TIFF in a folder. Returns a combined
97
  DataFrame and writes ``all_predictions.csv`` into ``out_dir``."""
98
+ loaded = _load(model_dir, cnn_weights)
99
+ rounds = _predict.default_rounds(max_rounds)
100
  tif_paths = sorted(Path(tif_dir).glob(pattern))
101
  if not tif_paths:
102
  raise FileNotFoundError(f"No files matching {pattern} in {tif_dir}")
 
104
  tables = []
105
  for tp in tif_paths:
106
  try:
107
+ df = _run_one(tp, loaded, out_dir, gpu, um_per_px, rounds, label_cells)
 
 
 
 
 
108
  if df is not None:
109
  tables.append(df)
110
  except Exception as e: # noqa: BLE001
rootscope/classify_v4.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RootScope v4 -- tang phan loai.
2
+
3
+ Khac v2 o ba diem, va ca ba deu nam o day chu khong o phan trich dac trung:
4
+
5
+ 1. Hang xom dang MEM: thay vi chot nhan cua hang xom roi dua vao model, moi huong
6
+ dong gop ca vector xac suat 9 lop (36 cot) + 6 co dong thuan.
7
+ 2. Dac trung LOP: khoang cach tu moi te bao toi cac lop mo (epidermis, exodermis,
8
+ endodermis, pericycle) ma chinh model da du doan o vong truoc, do theo ban kinh
9
+ cuc bo va tinh bang duong kinh te bao.
10
+ 3. Radial-order prior: uu tien thu tu xuyen tam dung giai phau, ap sau vong cuoi.
11
+
12
+ Cac ham tinh dac trung nam o context.py va duoc CAT NGUYEN VAN tu code nghien cuu.
13
+ """
14
+ from pathlib import Path
15
+
16
+ import joblib
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from . import context as ctx
21
+ from . import _eval_honest as _eh
22
+ from .extract_features import build_cell_adjacency
23
+ from .radial_order import correct
24
+
25
+ CLASSES = ["cortex", "endodermis", "epidermis", "exodermis", "pericycle",
26
+ "phloem", "root_cap", "stele", "xylem"]
27
+ PRIOR = (0.3, 2.0) # (s, lambda) cua model san xuat
28
+
29
+
30
+ def load_models(model_dir, seeds=None):
31
+ """Nap cac goi LightGBM v4. Moi goi chua round_models = model cua tung vong."""
32
+ model_dir = Path(model_dir)
33
+ out = []
34
+ for p in sorted(model_dir.glob("lgbm_s*.joblib")):
35
+ s = int(p.stem.split("_s")[-1])
36
+ if seeds is None or s in seeds:
37
+ b = joblib.load(p)
38
+ if "round_models" not in b:
39
+ raise ValueError(
40
+ f"{p.name} khong co 'round_models'. Goi nay chi luu model vong cuoi "
41
+ f"nen khong chay lai duoc vong lap. Dung goi san xuat.")
42
+ out.append((s, b))
43
+ if not out:
44
+ raise FileNotFoundError(f"khong co lgbm_s*.joblib trong {model_dir}")
45
+ f0 = list(out[0][1]["fcols"])
46
+ for s, b in out:
47
+ if list(b["fcols"]) != f0:
48
+ raise ValueError(f"seed {s} co bo feature khac")
49
+ if list(b["classes"]) != CLASSES:
50
+ raise ValueError(f"seed {s} co thu tu lop khac")
51
+ return out
52
+
53
+
54
+ def classify(df_base, masks, source_file="image.tif", model_dir=None, seeds=None,
55
+ max_rounds=6, prior=PRIOR, verbose=True):
56
+ """Tra (nhan, xac suat, nhan vong 1) cho tung dong cua df_base."""
57
+ bundles = load_models(model_dir, seeds)
58
+ fcols = list(bundles[0][1]["fcols"])
59
+
60
+ geom = df_base.copy()
61
+ geom["source_file"] = source_file
62
+ geom = geom.reset_index(drop=True)
63
+
64
+ # Nap thang adjacency vao cache cua _eval_honest: khoi phai ghi mask ra dia
65
+ # roi doc lai nhu duong danh gia trong du an.
66
+ _eh._ADJ[source_file] = build_cell_adjacency(masks)
67
+ ctx.MASK = "<in-memory>"
68
+ ctx.CL = CLASSES
69
+
70
+ missing = [c for c in fcols if c not in geom.columns
71
+ and c not in ctx.RINGCOLS and not c.startswith(("nbp_", "agree_"))]
72
+ if missing:
73
+ raise ValueError(f"thieu {len(missing)} cot dac trung, vd {missing[:6]}")
74
+
75
+ mats = ctx.dir_mats(geom)
76
+ P1s, PFs = [], []
77
+ for s, b in bundles:
78
+ prev = prevP = None
79
+ for rnd in range(1, max_rounds + 1):
80
+ cur = ctx._blank(geom) if rnd == 1 else ctx.fill_neighbors(geom, prev, ctx.MASK)
81
+ cur = pd.concat([cur.reset_index(drop=True),
82
+ ctx.soft_feats(prevP, mats, CLASSES),
83
+ ctx.ring_feats(geom, prev, "q", prevP)], axis=1)
84
+ m_r, s_r = b["round_models"][min(rnd, len(b["round_models"])) - 1]
85
+ P = m_r.predict_proba(s_r.transform(cur[fcols].values.astype(np.float32)))
86
+ if rnd == 1:
87
+ P1s.append(P.astype(np.float64))
88
+ names = np.array(CLASSES)[P.argmax(1)]
89
+ chg = -1 if prev is None else int((names != prev).sum())
90
+ if verbose:
91
+ print(f" seed {s} round {rnd}: {chg} labels changed", flush=True)
92
+ prev, prevP = names, P
93
+ if chg == 0:
94
+ break
95
+ PFs.append(P.astype(np.float64))
96
+
97
+ P1 = np.mean(P1s, 0)
98
+ P = np.mean(PFs, 0)
99
+ if prior and prior[1]:
100
+ cy = geom.centroid_y.values.astype(float)
101
+ cx = geom.centroid_x.values.astype(float)
102
+ cid = geom.cell_id.values
103
+ look = {int(i): (y, x) for i, y, x in zip(cid, cy, cx)}
104
+ P = correct(P, np.full(len(geom), source_file), cid, CLASSES,
105
+ geom={source_file: (cy, cx, look)}, s=prior[0], lam=prior[1])
106
+ return np.array(CLASSES)[P.argmax(1)], P, np.array(CLASSES)[P1.argmax(1)]
rootscope/cli.py CHANGED
@@ -40,10 +40,15 @@ def build_parser() -> argparse.ArgumentParser:
40
 
41
  p.add_argument("--out", "--out-dir", dest="out_dir", default="results",
42
  help="Output folder for CSVs + overlays (default: results/).")
43
- p.add_argument("--um-per-px", type=float, default=1.0,
44
- help="Microns per pixel (default: 1.0).")
45
- p.add_argument("--max-rounds", type=int, default=10,
46
- help="Max iterative refinement rounds (default: 10).")
 
 
 
 
 
47
  p.add_argument("--label-cells", action="store_true",
48
  help="Write the cell-type name inside each cell on the "
49
  "overlay. Off by default (the text overlaps on dense "
@@ -55,6 +60,14 @@ def build_parser() -> argparse.ArgumentParser:
55
  g.add_argument("--cpu", dest="gpu", action="store_false",
56
  help="Force CPU (slow).")
57
 
 
 
 
 
 
 
 
 
58
  p.add_argument("--model-dir", default=None,
59
  help="Folder with trained model weights (default: "
60
  "auto-download/cache).")
@@ -69,7 +82,12 @@ def build_parser() -> argparse.ArgumentParser:
69
 
70
 
71
  def main(argv=None):
 
72
  args = build_parser().parse_args(argv)
 
 
 
 
73
  use_gpu = _gpu_available() if args.gpu is None else args.gpu
74
  if args.gpu is None:
75
  print(f"[rootscope] GPU {'detected' if use_gpu else 'not found (using CPU)'}.")
@@ -83,7 +101,7 @@ def main(argv=None):
83
  label_cells=args.label_cells,
84
  )
85
  n = 0 if df is None else len(df)
86
- print(f"\n[rootscope] Done β€” {n} cell predictions written to {args.out_dir}/")
87
  else:
88
  df = api.predict_folder(
89
  args.tif_dir, out_dir=args.out_dir, gpu=use_gpu,
@@ -92,7 +110,7 @@ def main(argv=None):
92
  label_cells=args.label_cells, pattern=args.pattern,
93
  )
94
  n = 0 if df is None else len(df)
95
- print(f"\n[rootscope] Done β€” {n} total predictions written to {args.out_dir}/")
96
  except (FileNotFoundError, RuntimeError) as e:
97
  print(f"\n[rootscope] ERROR: {e}", file=sys.stderr)
98
  sys.exit(1)
 
40
 
41
  p.add_argument("--out", "--out-dir", dest="out_dir", default="results",
42
  help="Output folder for CSVs + overlays (default: results/).")
43
+ p.add_argument("--um-per-px", type=float, default=None,
44
+ help="Microns per pixel. Default: read from the TIFF's OME "
45
+ "metadata (PhysicalSizeX); the run stops if it is "
46
+ "missing, because a wrong scale distorts every "
47
+ "size feature.")
48
+ p.add_argument("--max-rounds", type=int, default=None,
49
+ help="Max iterative refinement rounds (default: 6 for v4, "
50
+ "10 for v1/v2 -- the settings each model was "
51
+ "evaluated with).")
52
  p.add_argument("--label-cells", action="store_true",
53
  help="Write the cell-type name inside each cell on the "
54
  "overlay. Off by default (the text overlaps on dense "
 
60
  g.add_argument("--cpu", dest="gpu", action="store_false",
61
  help="Force CPU (slow).")
62
 
63
+ p.add_argument("--model-version", choices=("v1", "v2", "v4"), default=None,
64
+ help="Which published model to use. v4 (default): the "
65
+ "manuscript model -- 915 features (864 + 51 rebuilt "
66
+ "every refinement round), one LightGBM per round, "
67
+ "3 seeds averaged, radial-order prior; 0.881 accuracy "
68
+ "on the clean held-out split. v2: 864 features, "
69
+ "RF/XGB/LGBM ensemble, 10 rounds. v1: 480 features, "
70
+ "DINOv2 ViT-S/14.")
71
  p.add_argument("--model-dir", default=None,
72
  help="Folder with trained model weights (default: "
73
  "auto-download/cache).")
 
82
 
83
 
84
  def main(argv=None):
85
+ import os as _os
86
  args = build_parser().parse_args(argv)
87
+ # set before anything resolves weights, so the classifiers and the backbone
88
+ # are taken from the same version's folder
89
+ if args.model_version:
90
+ _os.environ["ROOTSCOPE_MODEL_VERSION"] = args.model_version
91
  use_gpu = _gpu_available() if args.gpu is None else args.gpu
92
  if args.gpu is None:
93
  print(f"[rootscope] GPU {'detected' if use_gpu else 'not found (using CPU)'}.")
 
101
  label_cells=args.label_cells,
102
  )
103
  n = 0 if df is None else len(df)
104
+ print(f"\n[rootscope] Done. {n} cell predictions written to {args.out_dir}/")
105
  else:
106
  df = api.predict_folder(
107
  args.tif_dir, out_dir=args.out_dir, gpu=use_gpu,
 
110
  label_cells=args.label_cells, pattern=args.pattern,
111
  )
112
  n = 0 if df is None else len(df)
113
+ print(f"\n[rootscope] Done. {n} total predictions written to {args.out_dir}/")
114
  except (FileNotFoundError, RuntimeError) as e:
115
  print(f"\n[rootscope] ERROR: {e}", file=sys.stderr)
116
  sys.exit(1)
rootscope/context.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dac trung ngu canh cua RootScope v4:
2
+ - hang xom bon huong, dang MEM (xac suat) thay vi nhan cung
3
+ - khoang cach toi cac LOP mo ma chinh model da du doan o vong truoc
4
+
5
+ CAT NGUYEN VAN tu Working/_figgrid/eval_grid.py (2026-09-21). Khong go lai bang tay:
6
+ mot sai lech nho o day cho ket qua sai ma khong bao loi nao.
7
+ """
8
+ import os
9
+ import numpy as np, pandas as pd, scipy.sparse as sp
10
+ from ._eval_honest import _adjacency_for, fill_neighbors, _blank # noqa: F401
11
+
12
+ MASK = None # thu muc chua <stem>_masks.npy; predict.py dat truoc khi goi
13
+ CL = None # danh sach ten lop; predict.py dat truoc khi goi
14
+
15
+ DIRS = ["inner", "outer", "cw", "ccw"]
16
+
17
+
18
+ def dir_mats(g):
19
+ """4 ma tran thua (n x n), chuan hoa theo hang: hang xom theo tung huong cuc."""
20
+ r, c = {k: [] for k in DIRS}, {k: [] for k in DIRS}
21
+ for sf, sub in g.groupby("source_file"):
22
+ adj = _adjacency_for(sf, MASK)
23
+ row = {int(i): p for i, p in zip(sub.cell_id.values, sub.index.values)}
24
+ cy0, cx0 = sub.centroid_y.mean(), sub.centroid_x.mean()
25
+ cen = {int(i): (y, x) for i, y, x in zip(sub.cell_id, sub.centroid_y, sub.centroid_x)}
26
+ for cid, (cy, cx) in cen.items():
27
+ md = np.hypot(cy - cy0, cx - cx0); ma = np.arctan2(cy - cy0, cx - cx0)
28
+ for n in adj.get(cid, ()):
29
+ if n not in cen:
30
+ continue
31
+ ny, nx = cen[n]
32
+ dr = np.hypot(ny - cy0, nx - cx0) - md
33
+ da = (np.arctan2(ny - cy0, nx - cx0) - ma + np.pi) % (2 * np.pi) - np.pi
34
+ arc = abs(da) * md if md > 1.0 else abs(da)
35
+ k = ("inner" if dr < 0 else "outer") if abs(dr) >= arc else ("cw" if da < 0 else "ccw")
36
+ r[k].append(row[cid]); c[k].append(row[n])
37
+ n = len(g); mats = {}
38
+ for k in DIRS:
39
+ A = sp.csr_matrix((np.ones(len(r[k])), (r[k], c[k])), shape=(n, n))
40
+ deg = np.asarray(A.sum(1)).ravel()
41
+ mats[k] = (sp.diags(1 / np.maximum(deg, 1)) @ A, deg > 0)
42
+ return mats
43
+
44
+
45
+ def soft_feats(P, mats, classes):
46
+ n = len(mats["cw"][1]); cols = {}
47
+ if P is None:
48
+ names = [f"nbp_{k}_{c}" for k in DIRS for c in classes] + [f"agree_{k}" for k in DIRS] + ["agree_cw_ccw", "agree_in_out"]
49
+ return pd.DataFrame(-1.0, index=range(n), columns=names)
50
+ M = {}
51
+ for k in DIRS:
52
+ A, has = mats[k]; M[k] = A @ P
53
+ for j, c in enumerate(classes):
54
+ cols[f"nbp_{k}_{c}"] = np.where(has, M[k][:, j], -1.0)
55
+ for k in DIRS:
56
+ cols[f"agree_{k}"] = np.where(mats[k][1], (P * M[k]).sum(1), -1.0)
57
+ cols["agree_cw_ccw"] = np.where(mats["cw"][1] & mats["ccw"][1], (M["cw"] * M["ccw"]).sum(1), -1.0)
58
+ cols["agree_in_out"] = np.where(mats["inner"][1] & mats["outer"][1], (M["inner"] * M["outer"]).sum(1), -1.0)
59
+ return pd.DataFrame(cols)
60
+
61
+
62
+
63
+ LAND = ["epidermis", "exodermis", "endodermis", "pericycle"]
64
+ RINGCOLS = ["depth_surf", "frac_r_surf"] + [f"d_{c}" for c in LAND] + ["d_vasc_own", "d_endo_own", "ratio_endo"]
65
+ RING = int(os.environ.get("RINGFEAT", "1"))
66
+ PERROUND = int(os.environ.get("PERROUND", "1")) # inference uses the model trained FOR each round
67
+ SOFTRING = int(os.environ.get("SOFTRING", "0")) # landmarks weighted by probability instead of argmax
68
+ DAMP = float(os.environ.get("DAMP", "0")) # P_used = DAMP*P_prev + (1-DAMP)*P_new
69
+ _STATIC = {}
70
+ def _adiff(a, b): return np.abs((a[:, None] - b[None, :] + np.pi) % (2 * np.pi) - np.pi)
71
+ def ring_feats(g, names, key, Pm=None):
72
+ """Where is each cell relative to landmarks of ITS OWN section? Static: the tissue surface. Dynamic (from the
73
+ previous round's predictions; out-of-fold during training): the predicted epidermis / exodermis / endodermis /
74
+ pericycle rings and the outer envelope of the vascular cylinder. Distances are in cell diameters, measured along
75
+ the local radius, so a non-circular root or an off-centre stele does not matter."""
76
+ out = np.full((len(g), len(RINGCOLS)), np.nan, dtype=np.float32)
77
+ if not RING: return pd.DataFrame(out, columns=RINGCOLS)
78
+ if key not in _STATIC:
79
+ st = np.full((len(g), 2), np.nan, dtype=np.float32)
80
+ for sf, sub in g.groupby("source_file"):
81
+ idx = sub.index.values; cy, cx = sub.centroid_y.mean(), sub.centroid_x.mean()
82
+ r = np.hypot(sub.centroid_y - cy, sub.centroid_x - cx).values; th = np.arctan2(sub.centroid_y - cy, sub.centroid_x - cx).values
83
+ near = _adiff(th, th) < np.deg2rad(15); rs = np.where(near, r[None, :], -1).max(1); dm = np.sqrt(np.median(sub.area_px))
84
+ st[idx, 0] = (rs - r) / dm; st[idx, 1] = r / np.maximum(rs, 1)
85
+ _STATIC[key] = st
86
+ out[:, :2] = _STATIC[key]
87
+ if names is None: return pd.DataFrame(out, columns=RINGCOLS)
88
+ names = np.asarray(names)
89
+ for sf, sub in g.groupby("source_file"):
90
+ idx = sub.index.values; nm = names[idx]; own = np.sqrt(sub.area_px.values)
91
+ core = np.isin(nm, ["endodermis", "pericycle", "stele", "xylem", "phloem"])
92
+ cy, cx = (sub.centroid_y[core].mean(), sub.centroid_x[core].mean()) if core.sum() >= 5 else (sub.centroid_y.mean(), sub.centroid_x.mean())
93
+ r = np.hypot(sub.centroid_y - cy, sub.centroid_x - cx).values; th = np.arctan2(sub.centroid_y - cy, sub.centroid_x - cx).values
94
+ def ring(sel, how="median", win=40):
95
+ m = _adiff(th, th[sel]) < np.deg2rad(win); A = np.where(m, r[sel][None, :], np.nan)
96
+ v = np.nanmedian(A, 1) if how == "median" else np.nanmax(A, 1)
97
+ fb = np.median(r[sel]) if how == "median" else np.percentile(r[sel], 90)
98
+ return np.where(m.sum(1) >= 2, v, fb)
99
+ import warnings
100
+ with warnings.catch_warnings():
101
+ warnings.simplefilter("ignore")
102
+ for j, c in enumerate(LAND):
103
+ if SOFTRING and Pm is not None:
104
+ w = Pm[idx, CL.index(c)]; sel = w > 0.2
105
+ if w[sel].sum() < 4: continue
106
+ ww = w[sel] ** 2; mwin = (_adiff(th, th[sel]) < np.deg2rad(40)) * ww[None, :]
107
+ tot = mwin.sum(1); glob = np.average(r[sel], weights=ww)
108
+ rc = np.where(tot > ww.max() * 1.5, (mwin * r[sel][None, :]).sum(1) / np.maximum(tot, 1e-9), glob)
109
+ dm = np.sqrt(np.average(sub.area_px.values[sel], weights=ww))
110
+ else:
111
+ sel = nm == c
112
+ if sel.sum() < 6: continue
113
+ rc = ring(sel); dm = np.sqrt(np.median(sub.area_px.values[sel]))
114
+ out[idx, 2 + j] = (r - rc) / dm
115
+ if c == "endodermis": out[idx, 7] = (r - rc) / own; out[idx, 8] = r / np.maximum(rc, 1)
116
+ sel = np.isin(nm, ["stele", "xylem", "phloem"])
117
+ if sel.sum() >= 5: out[idx, 6] = (r - ring(sel, "max", 30)) / own
118
+ return pd.DataFrame(out, columns=RINGCOLS)
119
+
rootscope/extract_features.py CHANGED
@@ -675,7 +675,7 @@ def extract_all_features(masks, img_rgb, um_per_px, layer_lookup, adjacency,
675
  # have fewer cells than thick cortex
676
  cells_in_same_layer = layer_cell_counts.get(my_layer, 0) if my_layer >= 0 else 0
677
 
678
- # 3. Neighbors inward vs outward β€” ring cells have neighbors
679
  # on both sides; cortex cells mostly have same-layer neighbors
680
  inner_neighbor_count = 0
681
  outer_neighbor_count = 0
@@ -725,7 +725,7 @@ def extract_all_features(masks, img_rgb, um_per_px, layer_lookup, adjacency,
725
  # Exodermis = YES (neighbors epidermis), cortex/endodermis = NO
726
  cell_neighbors_boundary = 1 if cid in neighbors_boundary else 0
727
 
728
- # 8. Min layer among neighbors β€” exodermis neighbors include
729
  # epidermis (layer 0-1), cortex neighbors are all mid-layers
730
  min_neighbor_layer = min(neighbor_layers) if neighbor_layers else -1
731
  max_neighbor_layer = max(neighbor_layers) if neighbor_layers else -1
@@ -851,7 +851,7 @@ def extract_all_features(masks, img_rgb, um_per_px, layer_lookup, adjacency,
851
  HEXAGON_COMPACTNESS = 0.9069
852
  hexagonality = round(1.0 - abs(compactness - HEXAGON_COMPACTNESS), 4)
853
 
854
- # 5. Convex hull vertex count β€” hexagons have ~6 vertices
855
  try:
856
  from skimage.measure import approximate_polygon
857
  coords = p.coords # (N, 2) array of pixel coordinates
@@ -1266,7 +1266,7 @@ def extract_cnn_embedding_features(masks, img_rgb, use_gpu=True, weights_path=No
1266
  weights_path=weights_path, model=model)
1267
  except ImportError:
1268
  import warnings
1269
- warnings.warn("cnn_embeddings module not found β€” skipping CNN features.")
1270
  return None
1271
  except Exception as e:
1272
  import warnings
 
675
  # have fewer cells than thick cortex
676
  cells_in_same_layer = layer_cell_counts.get(my_layer, 0) if my_layer >= 0 else 0
677
 
678
+ # 3. Neighbors inward vs outward, ring cells have neighbors
679
  # on both sides; cortex cells mostly have same-layer neighbors
680
  inner_neighbor_count = 0
681
  outer_neighbor_count = 0
 
725
  # Exodermis = YES (neighbors epidermis), cortex/endodermis = NO
726
  cell_neighbors_boundary = 1 if cid in neighbors_boundary else 0
727
 
728
+ # 8. Min layer among neighbors, exodermis neighbors include
729
  # epidermis (layer 0-1), cortex neighbors are all mid-layers
730
  min_neighbor_layer = min(neighbor_layers) if neighbor_layers else -1
731
  max_neighbor_layer = max(neighbor_layers) if neighbor_layers else -1
 
851
  HEXAGON_COMPACTNESS = 0.9069
852
  hexagonality = round(1.0 - abs(compactness - HEXAGON_COMPACTNESS), 4)
853
 
854
+ # 5. Convex hull vertex count, hexagons have ~6 vertices
855
  try:
856
  from skimage.measure import approximate_polygon
857
  coords = p.coords # (N, 2) array of pixel coordinates
 
1266
  weights_path=weights_path, model=model)
1267
  except ImportError:
1268
  import warnings
1269
+ warnings.warn("cnn_embeddings module not found, skipping CNN features.")
1270
  return None
1271
  except Exception as e:
1272
  import warnings
rootscope/predict.py CHANGED
@@ -576,18 +576,24 @@ def load_image(tif_path):
576
  #
577
  # predict_single_tif() below runs these in order. They are also exposed
578
  # separately so a caller that pays for GPU time by the second (a web app on
579
- # ZeroGPU, say) can wrap only stage_segment and stage_embed β€” the two steps
580
- # that actually touch the GPU β€” and run the rest on CPU.
581
  # ═══════════════════════════════════════════════════════════════
582
 
583
  def stage_segment(img_rgb, gpu=True, cellpose_model=None):
584
- """GPU stage 1 β€” Cellpose-SAM. Returns the int32 label mask."""
585
  return segment_cellpose_sam(img_rgb, use_gpu=gpu, model=cellpose_model)
586
 
587
 
588
- def stage_features(masks, img_rgb, um_per_px=1.0, verbose=True):
589
- """CPU stage β€” tissue mask, layer index, debris removal, handcrafted
590
- features.
 
 
 
 
 
 
591
 
592
  Debris removal edits ``masks`` in place, so the (possibly modified) mask is
593
  returned alongside the feature table.
@@ -605,7 +611,7 @@ def stage_features(masks, img_rgb, um_per_px=1.0, verbose=True):
605
  # Filter out debris cells outside the main tissue body
606
  n_cells = int(masks.max())
607
  debris_cells = set()
608
- for cid in range(1, n_cells + 1):
609
  cell_px = masks == cid
610
  n_total = cell_px.sum()
611
  if n_total == 0:
@@ -634,7 +640,7 @@ def stage_features(masks, img_rgb, um_per_px=1.0, verbose=True):
634
 
635
  def stage_embed(masks, img_rgb, df_base, gpu=True, cnn_weights=None,
636
  dinov2_model=None, verbose=True):
637
- """GPU stage 2 β€” fine-tuned DINOv2 per-cell embeddings, merged into
638
  ``df_base``. Returns the merged table (unchanged if embeddings are
639
  unavailable)."""
640
  if verbose:
@@ -653,7 +659,7 @@ def stage_classify(df_base, masks, img_rgb, layer_lookup, adjacency,
653
  models_dict, scalers, feature_cols, le,
654
  out_dir="predictions", stem="image", source_name=None,
655
  um_per_px=1.0, max_rounds=10, label_cells=False):
656
- """CPU stage β€” iterative prediction with each model plus the weighted
657
  ensemble, anatomical post-processing, and per-model CSV + overlay PNG.
658
 
659
  Returns the concatenated per-cell table across all models."""
@@ -828,6 +834,133 @@ def stage_classify(df_base, masks, img_rgb, layer_lookup, adjacency,
828
  return pd.concat(all_dfs, ignore_index=True)
829
 
830
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  def predict_single_tif(tif_path, models_dict, scalers, feature_cols, le,
832
  um_per_px=1.0, gpu=True, out_dir="predictions",
833
  max_rounds=10, cnn_weights=None, label_cells=False,
@@ -921,7 +1054,7 @@ def main():
921
  print(f"\n Found {len(tif_paths)} TIF files in {tif_dir}")
922
 
923
  print(f"\n[2] Predicting cell types with {len(models_dict)} models "
924
- f"({', '.join(models_dict.keys())}) β€” iterative, max {args.max_rounds} rounds...")
925
  all_tables = []
926
  for tif_path in tif_paths:
927
  try:
 
576
  #
577
  # predict_single_tif() below runs these in order. They are also exposed
578
  # separately so a caller that pays for GPU time by the second (a web app on
579
+ # ZeroGPU, say) can wrap only stage_segment and stage_embed, the two steps
580
+ # that actually touch the GPU, and run the rest on CPU.
581
  # ═══════════════════════════════════════════════════════════════
582
 
583
  def stage_segment(img_rgb, gpu=True, cellpose_model=None):
584
+ """GPU stage 1: Cellpose-SAM. Returns the int32 label mask."""
585
  return segment_cellpose_sam(img_rgb, use_gpu=gpu, model=cellpose_model)
586
 
587
 
588
+ def stage_features(masks, img_rgb, um_per_px=1.0, verbose=True,
589
+ remove_debris=True):
590
+ """CPU stage: tissue mask, layer index, (optional) debris removal,
591
+ handcrafted features.
592
+
593
+ ``remove_debris`` drops segmented objects that lie mostly outside the
594
+ tissue body. v1/v2 always did this. v4 passes False: the manuscript's
595
+ evaluation kept every Cellpose object, and the v4 package reproduces that
596
+ evaluation cell for cell only when the same cells are scored.
597
 
598
  Debris removal edits ``masks`` in place, so the (possibly modified) mask is
599
  returned alongside the feature table.
 
611
  # Filter out debris cells outside the main tissue body
612
  n_cells = int(masks.max())
613
  debris_cells = set()
614
+ for cid in (range(1, n_cells + 1) if remove_debris else ()):
615
  cell_px = masks == cid
616
  n_total = cell_px.sum()
617
  if n_total == 0:
 
640
 
641
  def stage_embed(masks, img_rgb, df_base, gpu=True, cnn_weights=None,
642
  dinov2_model=None, verbose=True):
643
+ """GPU stage 2: fine-tuned DINOv2 per-cell embeddings, merged into
644
  ``df_base``. Returns the merged table (unchanged if embeddings are
645
  unavailable)."""
646
  if verbose:
 
659
  models_dict, scalers, feature_cols, le,
660
  out_dir="predictions", stem="image", source_name=None,
661
  um_per_px=1.0, max_rounds=10, label_cells=False):
662
+ """CPU stage: iterative prediction with each model plus the weighted
663
  ensemble, anatomical post-processing, and per-model CSV + overlay PNG.
664
 
665
  Returns the concatenated per-cell table across all models."""
 
834
  return pd.concat(all_dfs, ignore_index=True)
835
 
836
 
837
+
838
+ # ═══════════════════════════════════════════════════════════════
839
+ # V4
840
+ #
841
+ # Same stage_segment / stage_features / stage_embed as above; only the
842
+ # classifier differs. The v4 loop lives in classify_v4.py and the context
843
+ # features it rebuilds every round in context.py (copied verbatim from the
844
+ # research code). These wrappers exist so api.py, cli.py and the web app can
845
+ # treat v4 like the other versions.
846
+ # ═══════════════════════════════════════════════════════════════
847
+
848
+ def default_rounds(user_value=None):
849
+ """The refinement-round cap each model version was evaluated with."""
850
+ if user_value is not None:
851
+ return int(user_value)
852
+ from .weights import is_v4
853
+ return 6 if is_v4() else 10
854
+
855
+
856
+ def read_um_per_px(tif_path):
857
+ """Pixel size in microns from the TIFF's OME metadata, or None."""
858
+ import re
859
+ try:
860
+ import tifffile
861
+ with tifffile.TiffFile(str(tif_path)) as t:
862
+ ome = t.ome_metadata or ""
863
+ except Exception: # noqa: BLE001
864
+ return None
865
+ m = re.search(r'PhysicalSizeX="([0-9.eE+-]+)"', ome)
866
+ if not m:
867
+ return None
868
+ v = float(m.group(1))
869
+ unit = re.search(r'PhysicalSizeXUnit="([^"]+)"', ome)
870
+ if unit and unit.group(1) not in ("Β΅m", "um", "\u00b5m", "micron", "microns"):
871
+ print(f" WARNING: PhysicalSizeXUnit is '{unit.group(1)}', assuming microns.")
872
+ return v
873
+
874
+
875
+ def stage_classify_v4(df_base, masks, img_rgb, model_dir, out_dir="predictions",
876
+ stem="image", source_name=None, um_per_px=1.0,
877
+ max_rounds=6, seeds=None, label_cells=False, verbose=True):
878
+ """CPU stage, v4: per-round LightGBM x 3 seeds, soft-neighbor + layer
879
+ context rebuilt each round, radial-order prior. Writes
880
+ ``<stem>_LightGBM_predictions.csv`` and ``<stem>_LightGBM_overlay.png``
881
+ and returns the per-cell table in the same shape as stage_classify."""
882
+ from sklearn.preprocessing import LabelEncoder
883
+ from .classify_v4 import CLASSES, classify
884
+
885
+ out_dir = Path(out_dir)
886
+ out_dir.mkdir(parents=True, exist_ok=True)
887
+ if source_name is None:
888
+ source_name = f"{stem}.tif"
889
+
890
+ print(f"\n --- LightGBM v4 (per-round models, max {max_rounds} rounds, "
891
+ f"{'all seeds' if seeds is None else seeds}) ---")
892
+ names, P, names_r1 = classify(df_base, masks, source_file=source_name,
893
+ model_dir=model_dir, seeds=seeds,
894
+ max_rounds=max_rounds, verbose=verbose)
895
+
896
+ df = df_base.copy().reset_index(drop=True)
897
+ df["predicted_cell_type"] = names
898
+ df["prediction_confidence"] = np.round(P.max(axis=1), 4)
899
+ df["predicted_round1"] = names_r1
900
+ for i, c in enumerate(CLASSES):
901
+ df[f"p_{c}"] = P[:, i]
902
+ df["source_file"] = source_name
903
+ df["um_per_px"] = um_per_px
904
+ df["model"] = "LightGBM"
905
+
906
+ print(" Final predictions:")
907
+ for ct, cnt in pd.Series(names).value_counts().items():
908
+ print(f" {ct}: {cnt}")
909
+ print(f" Labels changed by refinement: {int((names != names_r1).sum())} / {len(names)}")
910
+
911
+ csv_path = out_dir / f"{stem}_LightGBM_predictions.csv"
912
+ df.to_csv(csv_path, index=False)
913
+ print(f" Saved: {csv_path}")
914
+
915
+ le = LabelEncoder().fit(CLASSES) # only classes_ is used by the overlay
916
+ cell_predictions = {int(c): str(n) for c, n in zip(df["cell_id"].values, names)}
917
+ overlay_path = out_dir / f"{stem}_LightGBM_overlay.png"
918
+ save_prediction_overlay(img_rgb, masks, cell_predictions, overlay_path, le,
919
+ label_cells=label_cells)
920
+ print(f" Saved: {overlay_path}")
921
+ return df
922
+
923
+
924
+ def predict_single_tif_v4(tif_path, model_dir, um_per_px=None, gpu=True,
925
+ out_dir="predictions", max_rounds=6, cnn_weights=None,
926
+ label_cells=False, cellpose_model=None,
927
+ dinov2_model=None, seeds=None):
928
+ """Full v4 pipeline: segment -> features (no debris filter) -> embed ->
929
+ v4 classify -> overlay."""
930
+ out_dir = Path(out_dir)
931
+ out_dir.mkdir(parents=True, exist_ok=True)
932
+ stem = Path(tif_path).stem.replace(".aivia", "")
933
+ print(f"\n Processing: {tif_path}")
934
+
935
+ if um_per_px is None:
936
+ um_per_px = read_um_per_px(tif_path)
937
+ if um_per_px is None:
938
+ raise ValueError(
939
+ f"{Path(tif_path).name}: no PhysicalSizeX in the OME metadata. "
940
+ f"Pass um_per_px (CLI: --um-per-px). RootScope will not assume 1.0: "
941
+ f"a wrong scale distorts every size feature.")
942
+ print(f" Pixel size from OME metadata: {um_per_px} um/px")
943
+
944
+ img_rgb = load_image(tif_path)
945
+ print(f" Image shape: {img_rgb.shape}")
946
+ print(" Segmenting (Cellpose-SAM)...")
947
+ masks = stage_segment(img_rgb, gpu=gpu, cellpose_model=cellpose_model)
948
+ n_cells = int(masks.max())
949
+ print(f" {n_cells} cells found")
950
+ if n_cells == 0:
951
+ print(" No cells found, skipping.")
952
+ return None
953
+
954
+ masks, df_base, layer_lookup, adjacency, n_layers = stage_features(
955
+ masks, img_rgb, um_per_px=um_per_px, remove_debris=False)
956
+ df_base = stage_embed(masks, img_rgb, df_base, gpu=gpu,
957
+ cnn_weights=cnn_weights, dinov2_model=dinov2_model)
958
+ return stage_classify_v4(
959
+ df_base, masks, img_rgb, model_dir=model_dir, out_dir=out_dir, stem=stem,
960
+ source_name=Path(tif_path).name, um_per_px=um_per_px,
961
+ max_rounds=max_rounds, seeds=seeds, label_cells=label_cells)
962
+
963
+
964
  def predict_single_tif(tif_path, models_dict, scalers, feature_cols, le,
965
  um_per_px=1.0, gpu=True, out_dir="predictions",
966
  max_rounds=10, cnn_weights=None, label_cells=False,
 
1054
  print(f"\n Found {len(tif_paths)} TIF files in {tif_dir}")
1055
 
1056
  print(f"\n[2] Predicting cell types with {len(models_dict)} models "
1057
+ f"({', '.join(models_dict.keys())}), iterative, max {args.max_rounds} rounds...")
1058
  all_tables = []
1059
  for tif_path in tif_paths:
1060
  try:
rootscope/radial_order.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Soft radial-order constraint (fix for error family B: one-layer boundary shifts).
2
+
3
+ POST-HOC. Takes the model's probability matrix and the cell centroids; it never looks at
4
+ the 768 DINOv2 columns, so it can be re-applied unchanged after the re-fine-tune.
5
+
6
+ Method
7
+ 1. Per section, a monotone radial coordinate r_rel in [0,1]: r_rel = r / r_surface(theta),
8
+ centre = mean centroid of the cells PREDICTED vascular-core, r_surface = furthest cell
9
+ within +-15 deg of the cell's own angle (all cells of the section, labels not needed).
10
+ 2. Tissues get a radial RANK: epidermis/root_cap 0 < exodermis 1 < cortex 2 < endodermis 3
11
+ < pericycle 4 < {stele,xylem,phloem} 5. The trio shares ONE rank - there is no reliable
12
+ radial order inside it, so the constraint cannot touch stele/xylem/phloem against each other.
13
+ 3. Per section, an ordered-logit model of rank against -r_rel is fitted to the model's OWN
14
+ posterior (soft targets, no labels, nothing from the ground truth). Two passes: the second
15
+ fit uses the once-corrected posterior.
16
+ 4. P_final ~ P_model * P_order ** lambda, renormalised.
17
+
18
+ Hyperparameters (s = 0.3, lambda = 2.0, 2 passes) were chosen on the 100 out-of-fold training
19
+ sections only; the OOF optimum is a broad plateau (s 0.3-1.2, 2-4 passes, lambda 2).
20
+
21
+ Usage
22
+ from apply_radial_order import correct
23
+ P_final = correct(P, source_file, cell_id, classes) # P: (n_cells, n_classes)
24
+ """
25
+ import numpy as np
26
+ from scipy.optimize import minimize
27
+
28
+ CLASSES_DEFAULT = ['cortex','endodermis','epidermis','exodermis','pericycle','phloem','root_cap','stele','xylem']
29
+ RANK = {'epidermis':0,'root_cap':0,'exodermis':1,'cortex':2,'endodermis':3,'pericycle':4,
30
+ 'stele':5,'xylem':5,'phloem':5}
31
+ NRANK = 6
32
+ CORE = ('endodermis','pericycle','stele','xylem','phloem')
33
+ S_SCALE, LAMBDA, PASSES, FLOOR = 0.3, 2.0, 2, 1e-3
34
+
35
+
36
+ def radial_coordinate(cy, cx, cy_all, cx_all, pred_names, win_deg=15.0):
37
+ """r_rel for the cells (cy,cx); the surface envelope uses every cell of the section
38
+ (cy_all,cx_all), which may be a superset (unlabelled cells included)."""
39
+ core = np.isin(pred_names, CORE)
40
+ if core.sum() >= 5:
41
+ y0, x0 = cy[core].mean(), cx[core].mean()
42
+ else:
43
+ y0, x0 = cy_all.mean(), cx_all.mean()
44
+ r_all = np.hypot(cy_all - y0, cx_all - x0); th_all = np.arctan2(cy_all - y0, cx_all - x0)
45
+ r = np.hypot(cy - y0, cx - x0); th = np.arctan2(cy - y0, cx - x0)
46
+ da = np.abs((th[:, None] - th_all[None, :] + np.pi) % (2 * np.pi) - np.pi)
47
+ rs = np.maximum(np.where(da < np.deg2rad(win_deg), r_all[None, :], -1.0).max(1), np.maximum(r, 1.0))
48
+ return r / rs
49
+
50
+
51
+ def _nll(par, v, Q, s):
52
+ t = np.concatenate([[par[0]], par[0] + np.cumsum(np.logaddexp(0.0, par[1:]))])
53
+ z = np.clip((t[None, :] - v[:, None]) / s, -50, 50)
54
+ cum = np.concatenate([np.zeros((len(v), 1)), 1 / (1 + np.exp(-z)), np.ones((len(v), 1))], 1)
55
+ return -(Q * np.log(np.clip(np.diff(cum, axis=1), 1e-9, 1.0))).sum()
56
+
57
+
58
+ def _order_prior(v, Q, s):
59
+ cw = np.clip(np.cumsum(Q.sum(0) + 1e-9)[:-1] / (Q.sum() + 1e-9), 0.01, 0.99)
60
+ t0 = np.maximum.accumulate(np.quantile(v, cw))
61
+ par0 = np.concatenate([[t0[0]], np.log(np.expm1(np.maximum(np.diff(t0), 1e-3)))])
62
+ par = minimize(_nll, par0, args=(v, Q, s), method='L-BFGS-B', options=dict(maxiter=500)).x
63
+ t = np.concatenate([[par[0]], par[0] + np.cumsum(np.logaddexp(0.0, par[1:]))])
64
+ z = np.clip((t[None, :] - v[:, None]) / s, -50, 50)
65
+ cum = np.concatenate([np.zeros((len(v), 1)), 1 / (1 + np.exp(-z)), np.ones((len(v), 1))], 1)
66
+ return np.clip(np.diff(cum, axis=1), 1e-9, 1.0), t
67
+
68
+
69
+ def correct_section(P, cy, cx, classes, cy_all=None, cx_all=None,
70
+ s=S_SCALE, lam=LAMBDA, passes=PASSES):
71
+ cy_all = cy if cy_all is None else cy_all; cx_all = cx if cx_all is None else cx_all
72
+ pred = np.asarray(classes)[P.argmax(1)]
73
+ v = -radial_coordinate(cy, cx, cy_all, cx_all, pred)
74
+ Pc = P
75
+ for k in range(passes):
76
+ Q = np.zeros((len(P), NRANK))
77
+ for j, c in enumerate(classes):
78
+ Q[:, RANK[c]] += Pc[:, j]
79
+ Q /= np.maximum(Q.sum(1, keepdims=True), 1e-12)
80
+ Po, _ = _order_prior(v, Q, s)
81
+ Po = Po + FLOOR; Po /= Po.sum(1, keepdims=True)
82
+ pri = np.stack([Po[:, RANK[c]] for c in classes], 1)
83
+ F = P * pri ** lam; Pc = F / F.sum(1, keepdims=True)
84
+ return Pc
85
+
86
+
87
+ def correct(P, source_file, cell_id, classes=None, geom=None, **kw):
88
+ """P (n,k) posterior; source_file (n,) section of each row. geom: optional dict
89
+ section -> (cy_all, cx_all, cy, cx) if the centroids are not in geom.npz."""
90
+ classes = classes or CLASSES_DEFAULT
91
+ if geom is None:
92
+ raise ValueError(
93
+ "correct() needs cell centroids. Pass geom={source_file: (cy_all, cx_all, "
94
+ "{cell_id: (cy, cx)})}. The research build read these from a cached "
95
+ "geom.npz that is not part of this package.")
96
+ d = None # unreachable; kept so the original block below still parses
97
+ gsf = d['source_file'].astype(str); gid = d['cell_id'].astype(np.int64)
98
+ geom = {}
99
+ for s_ in np.unique(source_file):
100
+ m = gsf == s_
101
+ geom[s_] = (d['cy'][m], d['cx'][m], dict(zip(gid[m], zip(d['cy'][m], d['cx'][m]))))
102
+ out = P.copy()
103
+ for s_ in np.unique(source_file):
104
+ m = source_file == s_
105
+ cy_all, cx_all, look = geom[s_]
106
+ yx = np.array([look[int(c)] for c in np.asarray(cell_id)[m]])
107
+ out[m] = correct_section(P[m], yx[:, 0], yx[:, 1], classes, cy_all, cx_all, **kw)
108
+ return out
rootscope/weights.py CHANGED
@@ -37,7 +37,7 @@ from pathlib import Path
37
  DEFAULT_HF_REPO = "ct-tranchau/Rootscope"
38
 
39
  # Two published model versions live in one Hub repo:
40
- # v2 -> repo root 96 morphometric + 768 DINOv2 ViT-B/14 = 864 features
41
  # v1 -> "v1/" folder 96 morphometric + 384 DINOv2 ViT-S/14 = 480 features
42
  # v2 is the default: it is the model evaluated in the manuscript and the one the
43
  # hosted demo runs. Its backbone is heavier, but on a GPU the embedding stage is
@@ -45,8 +45,15 @@ DEFAULT_HF_REPO = "ct-tranchau/Rootscope"
45
  # each other, because Cellpose segmentation dominates and is identical.
46
  # v1 is kept for reproducing earlier results. Select with --model-version or
47
  # ROOTSCOPE_MODEL_VERSION.
48
- MODEL_VERSIONS = ("v1", "v2")
49
- DEFAULT_MODEL_VERSION = "v2"
 
 
 
 
 
 
 
50
 
51
 
52
  def model_version() -> str:
@@ -59,8 +66,14 @@ def model_version() -> str:
59
 
60
 
61
  def _hf_prefix() -> str:
62
- """Subfolder inside the Hub repo holding the selected version's weights."""
63
- return "" if model_version() == "v2" else f"{model_version()}/"
 
 
 
 
 
 
64
 
65
  # Optional escape hatch: a plain base URL under which each file name below is
66
  # directly downloadable, e.g. "https://zenodo.org/records/XXXXXXX/files".
@@ -90,6 +103,20 @@ MODEL_FILES_OPTIONAL = [
90
  ]
91
  CNN_WEIGHTS_FILE = "backbone.pt"
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  def _cache_dir() -> Path:
95
  root = os.environ.get("ROOTSCOPE_CACHE")
@@ -110,7 +137,7 @@ def _models_base_url() -> str:
110
 
111
 
112
  def _has_required_models(d: Path) -> bool:
113
- return d.is_dir() and all((d / f).exists() for f in MODEL_FILES_REQUIRED)
114
 
115
 
116
  # ── Hugging Face Hub ─────────────────────────────────────────────────────────
@@ -185,7 +212,7 @@ def resolve_model_dir(user_arg: str | None = None) -> Path:
185
  if not _has_required_models(d):
186
  raise FileNotFoundError(
187
  f"--model-dir '{d}' does not contain the required model files "
188
- f"({', '.join(MODEL_FILES_REQUIRED)})."
189
  )
190
  return d
191
 
@@ -212,7 +239,7 @@ def resolve_model_dir(user_arg: str | None = None) -> Path:
212
  # Name the files rather than globbing: "*.joblib" also matches
213
  # "v1/*.joblib", which would drag the other version's weights down too.
214
  snap = _hf_snapshot(
215
- repo, [f"{pref}{f}" for f in MODEL_FILES_REQUIRED + MODEL_FILES_OPTIONAL]
216
  )
217
  if snap is not None and _has_required_models(snap / pref if pref else snap):
218
  return snap / pref if pref else snap
@@ -221,8 +248,8 @@ def resolve_model_dir(user_arg: str | None = None) -> Path:
221
  base_url = _models_base_url()
222
  if base_url:
223
  print(f"[rootscope] Fetching model weights into {cache} (first run only)...")
224
- _download_set(base_url, MODEL_FILES_REQUIRED, cache, skip_missing=False)
225
- _download_set(base_url, MODEL_FILES_OPTIONAL, cache, skip_missing=True)
226
  return cache
227
 
228
  raise RuntimeError(
@@ -233,7 +260,7 @@ def resolve_model_dir(user_arg: str | None = None) -> Path:
233
  " β€’ Put the model files in a folder and pass --model-dir <folder>, or\n"
234
  " β€’ export ROOTSCOPE_MODEL_DIR=/path/to/models, or\n"
235
  " β€’ export ROOTSCOPE_MODELS_URL=<base url> to self-host them.\n"
236
- f"Required files: {', '.join(MODEL_FILES_REQUIRED)}"
237
  )
238
 
239
 
 
37
  DEFAULT_HF_REPO = "ct-tranchau/Rootscope"
38
 
39
  # Two published model versions live in one Hub repo:
40
+ # v2 -> "v2/" folder 96 morphometric + 768 DINOv2 ViT-B/14 = 864 features
41
  # v1 -> "v1/" folder 96 morphometric + 384 DINOv2 ViT-S/14 = 480 features
42
  # v2 is the default: it is the model evaluated in the manuscript and the one the
43
  # hosted demo runs. Its backbone is heavier, but on a GPU the embedding stage is
 
45
  # each other, because Cellpose segmentation dominates and is identical.
46
  # v1 is kept for reproducing earlier results. Select with --model-version or
47
  # ROOTSCOPE_MODEL_VERSION.
48
+ # v4 -> "v4/" folder 96 morphometric + 768 DINOv2 ViT-B/14, PLUS 51 columns the
49
+ # classifier rebuilds every refinement round (36 soft-neighbor
50
+ # probabilities, 6 agreement flags, 9 layer-distance features),
51
+ # = 915 features; one LightGBM per round, 3 seeds averaged,
52
+ # radial-order prior after the last round. This is the model
53
+ # in the manuscript (0.881 accuracy, 0.852 macro-F1 on the
54
+ # clean 26-section split). Default since 4.0.0.
55
+ MODEL_VERSIONS = ("v1", "v2", "v4")
56
+ DEFAULT_MODEL_VERSION = "v4"
57
 
58
 
59
  def model_version() -> str:
 
66
 
67
 
68
  def _hf_prefix() -> str:
69
+ """Subfolder inside the Hub repo holding the selected version's weights.
70
+ Every version has a folder named after it (v1/, v2/, v4/); the repo root
71
+ holds only the model card. v2 lived at the root until 2026-09-21."""
72
+ return f"{model_version()}/"
73
+
74
+
75
+ def is_v4() -> bool:
76
+ return model_version() == "v4"
77
 
78
  # Optional escape hatch: a plain base URL under which each file name below is
79
  # directly downloadable, e.g. "https://zenodo.org/records/XXXXXXX/files".
 
103
  ]
104
  CNN_WEIGHTS_FILE = "backbone.pt"
105
 
106
+ # v4 artifacts. One LightGBM bundle per training seed; each holds the per-round
107
+ # models, the scaler, the 915 feature names and the class order. Seed 42 alone
108
+ # gives a working predictor; all three reproduce the manuscript's 3-seed average.
109
+ V4_MODEL_FILES_REQUIRED = ["lgbm_s42.joblib"]
110
+ V4_MODEL_FILES_OPTIONAL = ["lgbm_s1.joblib", "lgbm_s7.joblib"]
111
+
112
+
113
+ def _required_files():
114
+ return V4_MODEL_FILES_REQUIRED if is_v4() else MODEL_FILES_REQUIRED
115
+
116
+
117
+ def _optional_files():
118
+ return V4_MODEL_FILES_OPTIONAL if is_v4() else MODEL_FILES_OPTIONAL
119
+
120
 
121
  def _cache_dir() -> Path:
122
  root = os.environ.get("ROOTSCOPE_CACHE")
 
137
 
138
 
139
  def _has_required_models(d: Path) -> bool:
140
+ return d.is_dir() and all((d / f).exists() for f in _required_files())
141
 
142
 
143
  # ── Hugging Face Hub ─────────────────────────────────────────────────────────
 
212
  if not _has_required_models(d):
213
  raise FileNotFoundError(
214
  f"--model-dir '{d}' does not contain the required model files "
215
+ f"for model version {model_version()} ({', '.join(_required_files())})."
216
  )
217
  return d
218
 
 
239
  # Name the files rather than globbing: "*.joblib" also matches
240
  # "v1/*.joblib", which would drag the other version's weights down too.
241
  snap = _hf_snapshot(
242
+ repo, [f"{pref}{f}" for f in _required_files() + _optional_files()]
243
  )
244
  if snap is not None and _has_required_models(snap / pref if pref else snap):
245
  return snap / pref if pref else snap
 
248
  base_url = _models_base_url()
249
  if base_url:
250
  print(f"[rootscope] Fetching model weights into {cache} (first run only)...")
251
+ _download_set(base_url, _required_files(), cache, skip_missing=False)
252
+ _download_set(base_url, _optional_files(), cache, skip_missing=True)
253
  return cache
254
 
255
  raise RuntimeError(
 
260
  " β€’ Put the model files in a folder and pass --model-dir <folder>, or\n"
261
  " β€’ export ROOTSCOPE_MODEL_DIR=/path/to/models, or\n"
262
  " β€’ export ROOTSCOPE_MODELS_URL=<base url> to self-host them.\n"
263
+ f"Required files ({model_version()}): {', '.join(_required_files())}"
264
  )
265
 
266