Spaces:
Sleeping
Sleeping
RootScope v4: manuscript model (LightGBM per round, 3 seeds, layer + soft-neighbor context, radial prior)
bb112f6 verified Download rootscope/layer_rules.py from ct-tranchau/Rootscope: direct link, hf CLI and curl.
- Browser
- Download file 8.79 kB
-
https://huggingface.co/spaces/ct-tranchau/Rootscope/resolve/main/rootscope/layer_rules.py
- Command line
-
hf download hf://spaces/ct-tranchau/Rootscope/rootscope/layer_rules.py
-
curl -L -o layer_rules.py https://huggingface.co/spaces/ct-tranchau/Rootscope/resolve/main/rootscope/layer_rules.py
8.79 kB
| """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.") | |