Download kit/auc.py from SharpMeow/ink-lens: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/spaces/SharpMeow/ink-lens/resolve/main/kit/auc.py
- Command line
-
hf download hf://spaces/SharpMeow/ink-lens/kit/auc.py
-
curl -L -o auc.py https://huggingface.co/spaces/SharpMeow/ink-lens/resolve/main/kit/auc.py
17.1 kB
| """Pixel AUC of an ink map against a segment's published ink labels. | |
| On a labeled segment no model was trained on (PHerc0841 for `ink_9um`, v8in, d9v2 and Reader | |
| v2, but not for Hecate, whose base model's trainer lists all three PHerc0841 segments; | |
| PHerc0139 w045 for v8in only, since ink_9um trained on its 2.4 um render), this is the generalization | |
| number: the chance that a labeled ink pixel scores above a | |
| labeled non-ink pixel. Only pixels inside the supervision mask count, because the | |
| labelers marked ink and non-ink there and nowhere else. Reference values (Community | |
| reports, not checked here): `ink_9um` on PHerc0841 team surfaces, 0.74 to 0.81 (Bullo27); | |
| on the PHerc0139 title, 0.81 to 0.95 depending on the checkpoint (Nieuwlaar). | |
| The labels sit on the 2.4 um scan's grid. Its pyramid level 2 is the 9 um surface grid up | |
| to a uniform scale (w045: 5,820 x 8,020 labels for 5,980 x 8,240 surface pixels), so labels | |
| are mapped onto the map by nearest neighbor, and a scale that differs between the axes is | |
| refused. A map that covers only a window of the surface gives that window with `crop` and | |
| the full surface shape with `surface_shape`. | |
| The reverse-direction map is the control: it reads the same CT, so it is shown the same | |
| ink, but in the wrong depth order. A forward AUC well above the reverse AUC says the model | |
| reads ink, not just brightness. | |
| Pixels where the map is exactly 0 are left out by default: villa and v8in write 0 where | |
| they made no prediction (outside the surface). The count is reported. | |
| Sampling noise: a segment's labels cover about 1 cm2 (PHerc0841: 0.9 to 1.4 cm2 each), and | |
| neighboring pixels are not independent, so an AUC carries more noise than its pixel count | |
| suggests. `bootstrap` resamples square blocks (default 107 px, 1 mm at 9.366 um) with | |
| replacement and reports a 95 % interval; `compare` scores a second map on the same pixels | |
| with the same draws and reports the difference, its interval, and the share of draws in | |
| which the first map is not ahead. On PHerc0841 a strong map's interval is +-0.01 to +-0.04, | |
| so a ranking by a smaller margin on one segment is noise (docs/logs/2026-10-07-overlap-and-baseline.md). | |
| """ | |
| from .layers import _zarr | |
| from .verify import VerifyError, _numpy, load_map | |
| DEFAULT_LEVEL = "2" | |
| MAX_AXIS_MISMATCH = 0.005 | |
| BLOCK_PX = 107 # 1 mm at 9.366 um | |
| BOOT_BINS = 1024 # score bins for the resampled AUCs (the point estimate stays exact) | |
| def _load(path): | |
| np = _numpy() | |
| if str(path).endswith(".npy"): | |
| return np.load(path) | |
| return load_map(path) | |
| def _same_file(a, b): | |
| import os | |
| try: | |
| return os.path.samefile(a, b) | |
| except OSError: | |
| return False | |
| def _levels(path, level): | |
| zarr = _zarr() | |
| try: | |
| node = zarr.open(str(path), mode="r") | |
| except Exception as exc: # zarr raises several types for a missing or malformed store | |
| raise VerifyError(f"cannot open {path} as zarr: {exc}") from exc | |
| if not hasattr(node, "shape"): | |
| if level not in node: | |
| raise VerifyError(f"{path}: no level {level!r}; levels: {sorted(node.keys())}") | |
| node = node[level] | |
| return node | |
| MIN_FLOAT_LEVELS = 256 # a float map spanning fewer of the 65,536 levels is rescaled min..max | |
| def _quantize(np, values): | |
| """Integer scores for exact histogram AUC: uint8 and uint16 as is, floats to 0..65535. | |
| A float map is read as 0..1 (or 0..255, or 0..65535 when its largest value says so), as | |
| before. A float map that does not fit that reading, with a value below 0 or above 65535 | |
| (logits, signed integers) or a range so narrow it falls on fewer than MIN_FLOAT_LEVELS | |
| levels, is instead stretched from its own minimum to its maximum, which keeps every pixel's | |
| rank. NaN and infinite values are refused: they have no rank.""" | |
| if values.dtype in (np.uint8, np.uint16): | |
| return values.astype(np.int64), 65536 | |
| v = values.astype(np.float64) | |
| if not np.isfinite(v).all(): | |
| bad = int((~np.isfinite(v)).sum()) | |
| raise VerifyError(f"the map holds {bad} NaN or infinite values; replace them (with 0 for no " | |
| "prediction) before scoring") | |
| if not v.size: | |
| return v.astype(np.int64), 65536 | |
| low, top = float(v.min()), float(v.max()) | |
| if low >= 0 and top <= 65535: | |
| s = v / (255.0 if top <= 255 else 65535.0) if top > 1.0 else v | |
| q = np.clip(np.round(s * 65535), 0, 65535).astype(np.int64) | |
| if top == low or int(q.max()) - int(q.min()) >= MIN_FLOAT_LEVELS: | |
| return q, 65536 | |
| if top == low: # a constant map outside 0..65535, such as a blank sham written as logits: every pixel ties | |
| return np.zeros(v.shape, dtype=np.int64), 65536 | |
| return np.round((v - low) / (top - low) * 65535).astype(np.int64), 65536 | |
| def auc_scores(ink_scores, background_scores): | |
| """Mann-Whitney AUC with ties counted half. | |
| The two arrays must be on one scale. score_array quantizes the whole map once before | |
| splitting it, so a float map is never scaled differently for ink and for background.""" | |
| np = _numpy() | |
| if len(ink_scores) == 0 or len(background_scores) == 0: | |
| return None | |
| if ink_scores.dtype != np.int64 or background_scores.dtype != np.int64: | |
| both, _ = _quantize(np, np.concatenate([ink_scores.ravel(), background_scores.ravel()])) | |
| ink_scores, background_scores = both[:ink_scores.size], both[ink_scores.size:] | |
| a, b, n = ink_scores, background_scores, 65536 | |
| ha = np.bincount(a, minlength=n).astype(np.float64) | |
| hb = np.bincount(b, minlength=n).astype(np.float64) | |
| below = np.concatenate(([0.0], np.cumsum(hb)[:-1])) # background strictly below each score | |
| wins = float((ha * below).sum() + 0.5 * (ha * hb).sum()) | |
| return wins / (float(ha.sum()) * float(hb.sum())) | |
| def label_grid(labels_shape, surface_shape, crop, map_shape): | |
| """Row and column indices into the labels for every map pixel (nearest neighbor).""" | |
| np = _numpy() | |
| sy = labels_shape[0] / surface_shape[0] | |
| sx = labels_shape[1] / surface_shape[1] | |
| if abs(sy - sx) > MAX_AXIS_MISMATCH * max(sy, sx): | |
| raise VerifyError(f"labels {labels_shape} and surface {surface_shape} differ in scale between axes " | |
| f"({sy:.4f} vs {sx:.4f}); they are not the same grid") | |
| y0, y1, x0, x1 = crop if crop is not None else (0, surface_shape[0], 0, surface_shape[1]) | |
| if not (0 <= y0 < y1 <= surface_shape[0] and 0 <= x0 < x1 <= surface_shape[1]): | |
| raise VerifyError(f"crop {[y0, y1, x0, x1]} is outside the {surface_shape[0]} x {surface_shape[1]} surface") | |
| if (y1 - y0, x1 - x0) != tuple(map_shape): | |
| raise VerifyError(f"map shape {tuple(map_shape)} does not match the window {y1 - y0} x {x1 - x0}") | |
| rows = np.minimum(((np.arange(y0, y1) + 0.5) * sy).astype(np.int64), labels_shape[0] - 1) | |
| cols = np.minimum(((np.arange(x0, x1) + 0.5) * sx).astype(np.int64), labels_shape[1] - 1) | |
| return rows, cols | |
| def labels_on_map(labels, mask, level, surface_shape, crop, map_shape): | |
| """Boolean ink and supervision arrays on the map's own grid.""" | |
| np = _numpy() | |
| lab = _levels(labels, level) | |
| msk = _levels(mask, level) | |
| if lab.shape != msk.shape: | |
| raise VerifyError(f"labels {lab.shape} and mask {msk.shape} differ in shape at level {level}") | |
| rows, cols = label_grid(lab.shape, surface_shape, crop, map_shape) | |
| r0, r1, c0, c1 = int(rows.min()), int(rows.max()) + 1, int(cols.min()), int(cols.max()) + 1 | |
| ink = np.asarray(lab[r0:r1, c0:c1])[np.ix_(rows - r0, cols - c0)] > 0 | |
| supervised = np.asarray(msk[r0:r1, c0:c1])[np.ix_(rows - r0, cols - c0)] > 0 | |
| return ink, supervised | |
| def score_array(prediction, ink, mask, keep_zero=False, inner=0): | |
| """AUC of a 2D map against same-shape boolean ink and mask arrays. | |
| inner > 0 leaves out that many pixels along every edge of the map, where a map inferred on | |
| a cropped input saw less context than the same pixels of a whole-segment map.""" | |
| np = _numpy() | |
| if inner: | |
| if 2 * inner >= min(prediction.shape): | |
| raise VerifyError(f"inner {inner} leaves nothing of a {prediction.shape} map") | |
| mask = mask.copy() | |
| mask[:inner] = mask[-inner:] = False | |
| mask[:, :inner] = mask[:, -inner:] = False | |
| q, _ = _quantize(np, prediction) # one scale for the whole map | |
| use = mask & (np.ones_like(mask) if keep_zero else prediction != 0) | |
| result = { | |
| "auc": auc_scores(q[use & ink], q[use & ~ink]), | |
| "ink_px": int((use & ink).sum()), | |
| "background_px": int((use & ~ink).sum()), | |
| "zero_px_in_mask": int((mask & (prediction == 0)).sum()), | |
| "zero_px_counted": bool(keep_zero), | |
| "inner": inner, | |
| } | |
| if result["auc"] is not None: | |
| result["auc"] = round(result["auc"], 4) | |
| return result | |
| def _edge_mask(np, mask, inner): | |
| if inner: | |
| mask = mask.copy() | |
| mask[:inner] = mask[-inner:] = False | |
| mask[:, :inner] = mask[:, -inner:] = False | |
| return mask | |
| def _auc_hist(np, a, b): | |
| """AUC from per-bin counts of ink (a) and background (b), ties half; works on stacks.""" | |
| below = np.cumsum(b, axis=-1) - b | |
| return ((a * below).sum(-1) + 0.5 * (a * b).sum(-1)) / (a.sum(-1) * b.sum(-1)) | |
| def _block_ids(np, keys): | |
| """Sorted occupied block IDs without sorting every pixel when the grid is dense. | |
| Keep the counting workspace bounded by pixel count; sparse, very large coordinate | |
| ranges retain the original unique/sort path rather than allocating by canvas area. | |
| """ | |
| slots = int(keys.max()) + 1 | |
| if slots > max(4096, 4 * len(keys)): | |
| return np.unique(keys, return_inverse=True) | |
| occupied = np.bincount(keys, minlength=slots) > 0 | |
| blocks = np.flatnonzero(occupied) | |
| inverse = np.cumsum(occupied, dtype=np.int64)[keys] - 1 | |
| return blocks, inverse | |
| def _compact_histograms(np, hists, weights): | |
| """Remove globally empty score columns when reductions remain integer-exact. | |
| At <=2**26 pixels, all nonnegative count products, cumulative sums and half ties | |
| are exactly representable in float64. Check both the original population and every | |
| draw. Larger populations keep the historical reduction layout unchanged. | |
| """ | |
| counts = (hists[0][0] + hists[0][1]).sum(1) | |
| if counts.sum() > 2 ** 26 or (weights @ counts).max() > 2 ** 26: | |
| return hists | |
| occupied = np.zeros(BOOT_BINS, dtype=bool) | |
| for hi, hb in hists: | |
| occupied |= (hi + hb).any(0) | |
| if occupied.all(): | |
| return hists | |
| return [(hi[:, occupied], hb[:, occupied]) for hi, hb in hists] | |
| def block_bootstrap(prediction, ink, mask, draws=300, block_px=BLOCK_PX, seed=0, other=None, | |
| keep_zero=False, inner=0): | |
| """Block-bootstrap interval of the AUC, and of the difference to `other` (same pixels, same draws).""" | |
| np = _numpy() | |
| if draws < 20: | |
| raise VerifyError("use at least 20 bootstrap draws") | |
| if block_px < 1: | |
| raise VerifyError("block size must be at least 1 px") | |
| mask = _edge_mask(np, mask, inner) | |
| use = mask & (np.ones_like(mask) if keep_zero else prediction != 0) | |
| maps = [prediction] | |
| if other is not None: | |
| if other.shape != prediction.shape: | |
| raise VerifyError(f"compared map shape {other.shape} differs from the map's {prediction.shape}") | |
| use &= (np.ones_like(mask) if keep_zero else other != 0) | |
| maps.append(other) | |
| ys, xs = np.nonzero(use) | |
| if len(ys) == 0: | |
| raise VerifyError("no supervised pixels to resample") | |
| keys = (ys // block_px) * (prediction.shape[1] // block_px + 1) + xs // block_px | |
| blocks, inv = _block_ids(np, keys) | |
| lab = ink[ys, xs] | |
| if lab.all() or not lab.any(): | |
| raise VerifyError("the mask holds only one class; no AUC to resample") | |
| hists = [] | |
| for m in maps: | |
| q, _ = _quantize(np, m) | |
| top = 255 if m.dtype == np.uint8 else 65535 # _quantize keeps uint8 as 0..255 | |
| qb = q[ys, xs] * (BOOT_BINS - 1) // top | |
| count = len(blocks) * BOOT_BINS | |
| indices = inv * BOOT_BINS + qb | |
| hi = np.bincount(indices[lab], minlength=count).reshape(len(blocks), BOOT_BINS).astype(np.float64) | |
| hb = np.bincount(indices[~lab], minlength=count).reshape(len(blocks), BOOT_BINS).astype(np.float64) | |
| hists.append((hi, hb)) | |
| rng = np.random.default_rng(seed) | |
| w = np.stack([np.bincount(rng.integers(0, len(blocks), len(blocks)), minlength=len(blocks)) | |
| for _ in range(draws)]).astype(np.float64) | |
| hists = _compact_histograms(np, hists, w) | |
| stats = [_auc_hist(np, w @ hi, w @ hb) for hi, hb in hists] | |
| lo, hi_ = np.percentile(stats[0], [2.5, 97.5]) | |
| result = {"draws": draws, "block_px": block_px, "blocks": int(len(blocks)), "seed": seed, | |
| "ci95": [round(float(lo), 4), round(float(hi_), 4)], "half_width": round(float(hi_ - lo) / 2, 4)} | |
| if other is not None: | |
| full = [float(_auc_hist(np, hi.sum(0), hb.sum(0))) for hi, hb in hists] | |
| diff = stats[0] - stats[1] | |
| dlo, dhi = np.percentile(diff, [2.5, 97.5]) | |
| result["compare"] = { | |
| "auc_map": round(full[0], 4), "auc_other": round(full[1], 4), "difference": round(full[0] - full[1], 4), | |
| "ci95": [round(float(dlo), 4), round(float(dhi), 4)], | |
| "share_draws_map_not_ahead": round(float((diff <= 0).mean()), 4), | |
| "pixels": int(len(ys)), | |
| } | |
| return result | |
| def score_files(prediction, labels, mask, control=None, level=DEFAULT_LEVEL, crop=None, | |
| surface_shape=None, keep_zero=False, inner=0, bootstrap=0, block_px=BLOCK_PX, | |
| compare=None, seed=0): | |
| np = _numpy() | |
| pred = np.squeeze(_load(prediction)) | |
| if pred.ndim != 2: | |
| raise VerifyError(f"{prediction}: expected a 2D map, got shape {pred.shape}") | |
| if surface_shape is None: | |
| if crop is not None: | |
| raise VerifyError("a cropped map needs the full surface shape (surface_shape)") | |
| surface_shape = pred.shape | |
| ink, supervised = labels_on_map(labels, mask, level, surface_shape, crop, pred.shape) | |
| result = {"forward": score_array(pred, ink, supervised, keep_zero, inner), | |
| "files": {"prediction": str(prediction), "labels": str(labels), "mask": str(mask), | |
| "control": None if control is None else str(control)}, | |
| "level": level, "crop": crop} | |
| if control is not None: | |
| ctrl = np.squeeze(_load(control)) | |
| if ctrl.shape != pred.shape: | |
| raise VerifyError(f"control shape {ctrl.shape} differs from the map's {pred.shape}") | |
| # the same file is refused; an identical copy still scores, and its zero gap fails the control honestly | |
| # (a constant known-bad map, such as a blank sham, equals its own reverse) | |
| if _same_file(control, prediction): | |
| raise VerifyError(f"control {control} is the map itself; give the reverse-depth map as the control") | |
| result["control"] = score_array(ctrl, ink, supervised, keep_zero, inner) | |
| if bootstrap or compare is not None: | |
| other = None | |
| if compare is not None: | |
| other = np.squeeze(_load(compare)) | |
| result["files"]["compare"] = str(compare) | |
| result["bootstrap"] = block_bootstrap(pred, ink, supervised, bootstrap or 300, block_px, seed, other, | |
| keep_zero, inner) | |
| return result | |
| def format_result(result): | |
| def line(label, r): | |
| if r["auc"] is None: | |
| return f"{label}: no AUC (ink {r['ink_px']} px, background {r['background_px']} px in the mask)" | |
| zeros = "" | |
| if r["zero_px_in_mask"]: | |
| zeros = (f" ({r['zero_px_in_mask']} zero px in the mask counted)" if r["zero_px_counted"] | |
| else f" ({r['zero_px_in_mask']} unpredicted px in the mask left out)") | |
| edge = f", {r['inner']} px edge left out" if r.get("inner") else "" | |
| return (f"{label}: AUC {r['auc']:.4f} on {r['ink_px']} ink and {r['background_px']} background px" | |
| + edge + zeros) | |
| rows = [line("map ", result["forward"])] | |
| if "control" in result: | |
| rows.append(line("control", result["control"])) | |
| f, c = result["forward"]["auc"], result["control"]["auc"] | |
| if f is not None and c is not None: | |
| rows.append(f"map minus control: {f - c:+.4f} (near 0 means the model is not reading ink in this direction)") | |
| b = result.get("bootstrap") | |
| if b: | |
| rows.append(f"map AUC 95 % interval {b['ci95'][0]:.4f} to {b['ci95'][1]:.4f} ({b['draws']} draws of " | |
| f"{b['blocks']} blocks of {b['block_px']} px)") | |
| c = b.get("compare") | |
| if c: | |
| verdict = ("ahead beyond sampling noise" if c["ci95"][0] > 0 else | |
| "behind beyond sampling noise" if c["ci95"][1] < 0 else "not distinguishable from noise") | |
| rows.append(f"map minus compared map: {c['difference']:+.4f}, 95 % interval {c['ci95'][0]:+.4f} to " | |
| f"{c['ci95'][1]:+.4f} on {c['pixels']} shared px: {verdict}") | |
| return "\n".join(rows) | |