Spaces:
Sleeping
Sleeping
RootScope v4: manuscript model (LightGBM per round, 3 seeds, layer + soft-neighbor context, radial prior)
Browse files- README.md +9 -2
- app.py +17 -14
- classify_worker.py +20 -0
- requirements.txt +33 -21
- rootscope/__init__.py +2 -2
- rootscope/__pycache__/__init__.cpython-310.pyc +0 -0
- rootscope/__pycache__/api.cpython-310.pyc +0 -0
- rootscope/__pycache__/cli.cpython-310.pyc +0 -0
- rootscope/__pycache__/extract_features.cpython-310.pyc +0 -0
- rootscope/__pycache__/overlay.cpython-310.pyc +0 -0
- rootscope/__pycache__/predict.cpython-310.pyc +0 -0
- rootscope/__pycache__/weights.cpython-310.pyc +0 -0
- rootscope/_eval_honest.py +491 -0
- rootscope/api.py +53 -23
- rootscope/classify_v4.py +106 -0
- rootscope/cli.py +24 -6
- rootscope/context.py +119 -0
- rootscope/extract_features.py +4 -4
- rootscope/predict.py +143 -10
- rootscope/radial_order.py +108 -0
- rootscope/weights.py +38 -11
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
|
| 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(
|
| 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=
|
| 622 |
-
info="
|
| 623 |
-
|
|
|
|
|
|
|
| 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,
|
| 633 |
-
|
| 634 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 2 |
-
#
|
| 3 |
-
#
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
scikit-
|
| 10 |
-
#
|
| 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 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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.
|
| 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=
|
| 34 |
model_dir=None,
|
| 35 |
cnn_weights=None,
|
| 36 |
-
um_per_px=
|
| 37 |
-
max_rounds=
|
| 38 |
label_cells=False,
|
| 39 |
):
|
| 40 |
-
"""Segment + predict cell types for a single TIFF. Returns a DataFrame.
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
|
| 50 |
|
| 51 |
def predict_folder(
|
| 52 |
tif_dir,
|
| 53 |
out_dir="results",
|
| 54 |
-
gpu=
|
| 55 |
model_dir=None,
|
| 56 |
cnn_weights=None,
|
| 57 |
-
um_per_px=
|
| 58 |
-
max_rounds=
|
| 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 |
-
|
|
|
|
| 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 =
|
| 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=
|
| 44 |
-
help="Microns per pixel
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 580 |
-
# that actually touch the GPU
|
| 581 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 582 |
|
| 583 |
def stage_segment(img_rgb, gpu=True, cellpose_model=None):
|
| 584 |
-
"""GPU stage 1
|
| 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 |
-
|
| 590 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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())})
|
| 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 ->
|
| 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 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
| 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
|
| 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,
|
| 225 |
-
_download_set(base_url,
|
| 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(
|
| 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 |
|