File size: 17,137 Bytes
c368438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
"""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)