Spaces:
Sleeping
Sleeping
File size: 8,786 Bytes
bb112f6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | """Post-processing on the outer tissue layers (epidermis, exodermis, cortex).
Two rules, both applied after the classifier and the radial-order prior:
1. ``drop_exodermis``: for species that have no exodermis the classifier still
draws a partial ring, because it has learned "second layer from the surface
is exodermis". With the flag set, the exodermis probability is removed and
every cell takes its next most likely class. Applied BEFORE the radial prior
so that the prior's ordinal fit never sees exodermis mass.
2. ``smooth_along_layer``: a cell whose neighbours on both sides ALONG the layer
(clockwise and counter-clockwise around the section centre) agree on
another outer-layer class takes that class. This removes single cells of
cortex sitting inside the exodermis layer and single exodermis cells inside
the cortex, which are visually obvious errors. Measured on the 26 held-out
sections of the manuscript: every single-cell mix removed, accuracy
unchanged (0.8811 -> 0.8806). It never touches the vascular classes or the
root cap, and it does not fill longer gaps in a layer -- those come from
the classifier being confidently wrong, and no geometric rule fixes them
without breaking more correct cells than it repairs.
"""
import numpy as np
CORE = ("endodermis", "pericycle", "stele", "xylem", "phloem")
OUTER = ("epidermis", "exodermis", "cortex")
def drop_exodermis(P, classes):
"""Zero the exodermis column and renormalise each row."""
P = np.array(P, dtype=np.float64, copy=True)
j = list(classes).index("exodermis")
P[:, j] = 0.0
s = P.sum(1, keepdims=True)
# a row that was all exodermis has nothing left: fall back to uniform over
# the other classes rather than dividing by zero
empty = s[:, 0] <= 0
if empty.any():
P[empty] = 1.0
P[empty, j] = 0.0
s = P.sum(1, keepdims=True)
return P / s
def section_centre(cy, cx, names):
"""Same rule as the radial prior: mean centroid of the cells predicted to be
in the vascular core; all cells if fewer than five."""
core = np.isin(names, CORE)
if core.sum() >= 5:
return float(cy[core].mean()), float(cx[core].mean())
return float(cy.mean()), float(cx.mean())
def _tangential_sides(i, cy, cx, nbrs, c0, cos_max=0.5):
"""Indices of the neighbours of cell i that lie along the layer (displacement
within 60 degrees of the tangent), split by side."""
dy0, dx0 = cy[i] - c0[0], cx[i] - c0[1]
r0 = np.hypot(dy0, dx0)
if r0 == 0:
return [], []
uy, ux = dy0 / r0, dx0 / r0
cw, ccw = [], []
for k in nbrs:
dy, dx = cy[k] - cy[i], cx[k] - cx[i]
d = np.hypot(dy, dx)
if d == 0:
continue
if abs((dy * uy + dx * ux) / d) < cos_max:
(cw if uy * dx - ux * dy > 0 else ccw).append(k)
return cw, ccw
def _majority(labels, min_frac):
if not labels:
return None
vals, cnt = np.unique(labels, return_counts=True)
k = cnt.argmax()
if (cnt == cnt[k]).sum() > 1:
# A tie (e.g. one cortex and one exodermis neighbour) is no evidence
# for either class. np.unique orders alphabetically, so without this
# guard "cortex" won every tie and correct exodermis cells were lost.
return None
return vals[k] if cnt[k] / len(labels) >= min_frac else None
def smooth_along_layer(names, cy, cx, cell_ids, adjacency, classes=OUTER,
passes=3, min_frac=0.5, centre=None):
"""Return (new_names, changed_mask). ``adjacency`` maps cell_id -> iterable of
neighbouring cell_ids (as built by ``build_cell_adjacency``)."""
names = np.asarray(names, dtype=object).copy()
cy = np.asarray(cy, dtype=float)
cx = np.asarray(cx, dtype=float)
pos = {int(c): i for i, c in enumerate(cell_ids)}
nbr = [[pos[int(b)] for b in adjacency.get(int(c), ()) if int(b) in pos]
for c in cell_ids]
c0 = section_centre(cy, cx, names) if centre is None else centre
sides = [_tangential_sides(i, cy, cx, nbr[i], c0) for i in range(len(names))]
changed = np.zeros(len(names), dtype=bool)
classes = set(classes)
for _ in range(passes):
flips = []
for i in range(len(names)):
if names[i] not in classes:
continue
cw, ccw = sides[i]
a = _majority([names[k] for k in cw], min_frac)
b = _majority([names[k] for k in ccw], min_frac)
if a is not None and a == b and a != names[i] and a in classes:
flips.append((i, a))
if not flips:
break
for i, lab in flips: # simultaneous update within a pass
names[i] = lab
changed[i] = True
return names, changed
# ---------------------------------------------------------------------------
# Does this section have an exodermis at all?
#
# Some species never form the layer (in the training data: Pisum sativum,
# Vigna mungo, Echinochloa crus-galli) and every species lacks it in the
# meristem. The classifier still tends to draw a partial layer. Three
# properties of the PREDICTED exodermis separate the two cases: how many
# exodermis cells there are relative to epidermis cells, how much of the
# circumference they cover, and how confident the classifier is on them.
# A logistic model on these three, fitted on all 126 annotated sections (20
# without exodermis) and evaluated leaving one species out at a time: AUC
# 0.95; with the thresholds below 16 of the 20 sections without exodermis are
# called absent, 2 uncertain, 2 present; of 106 sections with exodermis 104
# are called present, 1 uncertain, 1 absent.
_EXO_COEF = {"ratio_ex_ep": 1.5188, "coverage": 2.6856, "p_ex_mean": 1.9851}
_EXO_INTERCEPT = -3.4758
EXO_ABSENT_BELOW = 0.15
EXO_PRESENT_FROM = 0.60
def exodermis_evidence(names, P, cy, cx, classes):
"""Section-level evidence that an exodermis is present. Returns a dict with
the three features and ``score`` in [0, 1] (1 = present)."""
names = np.asarray(names, dtype=object)
ex, ep = names == "exodermis", names == "epidermis"
n_ex, n_ep = int(ex.sum()), int(ep.sum())
ratio = n_ex / max(n_ep, 1)
if n_ex:
c0 = section_centre(np.asarray(cy, float), np.asarray(cx, float), names)
th = np.arctan2(np.asarray(cy, float) - c0[0], np.asarray(cx, float) - c0[1])
nb = max(8, min(36, n_ep // 2)) if n_ep else 12
cov = len(set(((th[ex] + np.pi) / (2 * np.pi) * nb).astype(int) % nb)) / nb
p_mean = float(P[ex, list(classes).index("exodermis")].mean())
else:
cov, p_mean = 0.0, 0.0
z = (_EXO_INTERCEPT + _EXO_COEF["ratio_ex_ep"] * ratio
+ _EXO_COEF["coverage"] * cov + _EXO_COEF["p_ex_mean"] * p_mean)
return dict(n_exodermis=n_ex, n_epidermis=n_ep, ratio_ex_ep=round(ratio, 3),
coverage=round(cov, 3), p_ex_mean=round(p_mean, 3),
score=round(float(1.0 / (1.0 + np.exp(-z))), 3))
def decide_exodermis(evidence, species_row=None, user_flag=None):
"""Combine the image evidence, the species table and an explicit user flag.
Returns (status, drop, reason): status in {"present", "absent",
"uncertain"}; drop=True means predicted exodermis is converted to each
cell's next best class. Precedence: user flag > training annotations of
the named species > image evidence. Literature-only rows and genus-level
matches never change labels; they are reported."""
if user_flag is True:
return "absent", True, "set by the user"
if user_flag is False:
return "present", False, "set by the user"
if (species_row and species_row.get("in_training") == "yes"
and species_row.get("match") == "species"):
if species_row["exodermis_in_training"] == "no":
return ("absent", True,
f"{species_row['binomial']}: no exodermis in the training annotations")
return ("present", False,
f"{species_row['binomial']}: exodermis annotated in the training sections")
s = evidence["score"]
if s < EXO_ABSENT_BELOW:
return "absent", True, (f"image evidence (score {s:.2f}): "
f"{evidence['n_exodermis']} exodermis cells predicted")
if s >= EXO_PRESENT_FROM:
return "present", False, f"image evidence (score {s:.2f})"
return "uncertain", False, (
f"image evidence is ambiguous (score {s:.2f}): the predicted exodermis covers "
f"{100 * evidence['coverage']:.0f}% of the circumference with mean confidence "
f"{evidence['p_ex_mean']:.2f}. If you know the species has no exodermis, "
f"give the species name or set no_exodermis.")
|