mirror of
https://github.com/slsdetectorgroup/aare.git
synced 2026-09-03 16:30:41 +02:00
- Diagnostic twin of ClusterFinder: identical decisions, but the pedestal is frozen per frame (snapshot at frame start, updates deferred to frame end), matching the CUDA kernel's update model. - Isolates pedestal-update timing as the sole remaining CPU/GPU mismatch. - Adds class + bindings + factory, validation notebook, and helper utilities.
405 lines
18 KiB
Python
405 lines
18 KiB
Python
"""Helpers for the CPU-vs-CUDA ClusterFinder mismatch analysis.
|
||
|
||
Pure, parameterised versions of the functions that used to live inline in the
|
||
notebook's forensic-view cell, plus the reusable scan + plot routines so the
|
||
notebook cells stay thin. Import in the notebook with::
|
||
|
||
from helper import (centers, only_sets, footprint_mask, shift_dist,
|
||
train_pedestal, scan_mismatches, plot_masked_mismatch)
|
||
|
||
None of these rely on notebook globals — cluster geometry (rx, ry) and the
|
||
frame shape (rows, cols) are passed in explicitly.
|
||
"""
|
||
|
||
import numpy as np
|
||
import matplotlib.pyplot as plt
|
||
import boost_histogram as bh
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Pure helpers
|
||
# --------------------------------------------------------------------------- #
|
||
def print_pinning_budget(rows, cols, dtype=np.uint16, headroom_gb=4.0):
|
||
"""
|
||
Print system RAM stats and estimate the maximum number of frames
|
||
that can be safely registered with cudaHostRegister / register_input_buffer.
|
||
|
||
Parameters
|
||
----------
|
||
rows, cols : int Detector frame dimensions.
|
||
dtype : np.dtype Frame element type (default uint16 = 2 bytes/pixel).
|
||
headroom_gb: float RAM to keep free for OS + CUDA context (default 4 GB).
|
||
"""
|
||
# /proc/meminfo is always available on Linux — no extra dependency needed
|
||
meminfo = {}
|
||
with open('/proc/meminfo') as f:
|
||
for line in f:
|
||
key, val = line.split(':')
|
||
meminfo[key.strip()] = int(val.split()[0]) * 1024 # kB → bytes
|
||
|
||
total_bytes = meminfo['MemTotal']
|
||
available_bytes = meminfo['MemAvailable'] # free + reclaimable cache
|
||
safe_bytes = max(0, available_bytes - int(headroom_gb * 1024**3))
|
||
|
||
frame_bytes = rows * cols * np.dtype(dtype).itemsize
|
||
max_frames = safe_bytes // frame_bytes
|
||
|
||
GiB = 1024**3
|
||
print("── System RAM ──────────────────────────────────────────")
|
||
print(f" Total RAM : {total_bytes/GiB:.1f} GiB")
|
||
print(f" Currently available : {available_bytes/GiB:.1f} GiB "
|
||
f"(free + reclaimable cache)")
|
||
print(f" Reserved headroom : {headroom_gb:.1f} GiB "
|
||
f"(OS + CUDA context)")
|
||
print(f" Safe pinning budget : {safe_bytes/GiB:.1f} GiB")
|
||
print()
|
||
print("── Frame layout ────────────────────────────────────────")
|
||
print(f" Frame size : {rows} × {cols} × "
|
||
f"{np.dtype(dtype).itemsize} B = {frame_bytes/1024:.1f} kB")
|
||
print()
|
||
print("── Pinning estimate ────────────────────────────────────")
|
||
print(f" Max frames pinnable : {max_frames:,} "
|
||
f"({max_frames * frame_bytes / GiB:.1f} GiB)")
|
||
print()
|
||
print(" Note: no swap on this machine — exceeding available RAM")
|
||
print(" will trigger the OOM killer. Stay within the budget.")
|
||
|
||
def centers(cv):
|
||
"""Set of ``(x, y)`` integer cluster centres from a ClusterVector."""
|
||
if cv.size == 0:
|
||
return set()
|
||
a = np.asarray(cv)
|
||
return {(int(x), int(y)) for x, y in zip(a["x"], a["y"])}
|
||
|
||
|
||
def only_sets(cpu_c, cu_c, tol=1):
|
||
"""CPU-only / CUDA-only centres, ignoring ``<=tol`` px 'shifted' matches.
|
||
|
||
``tol=0`` returns the exact set difference (shifted twins stay counted as
|
||
mismatches); ``tol=1`` drops any mismatch that has a counterpart in the
|
||
other finder's 8-neighbourhood.
|
||
"""
|
||
def near(p, other):
|
||
x, y = p
|
||
return any((x + dx, y + dy) in other
|
||
for dx in range(-tol, tol + 1)
|
||
for dy in range(-tol, tol + 1))
|
||
|
||
cpu_only = {p for p in cpu_c - cu_c if not near(p, cu_c)}
|
||
cu_only = {p for p in cu_c - cpu_c if not near(p, cpu_c)}
|
||
return cpu_only, cu_only
|
||
|
||
|
||
def footprint_mask(cs, shape, rx, ry):
|
||
"""Boolean map: True where a pixel lies in some cluster's footprint.
|
||
|
||
Each centre paints a ``(2*ry+1) x (2*rx+1)`` box, clipped at the borders.
|
||
"""
|
||
m = np.zeros(shape, bool)
|
||
for (cx, cy) in cs:
|
||
m[max(0, cy - ry):cy + ry + 1, max(0, cx - rx):cx + rx + 1] = True
|
||
return m
|
||
|
||
|
||
def shift_dist(p, other, R=4):
|
||
"""Chebyshev distance from ``p`` to the nearest member of ``other``.
|
||
|
||
Rings are searched inner-to-outer so the first hit is the nearest.
|
||
Returns ``-1`` if nothing lies within ``R`` px.
|
||
"""
|
||
x, y = p
|
||
for r in range(1, R + 1):
|
||
for dx in range(-r, r + 1):
|
||
for dy in range(-r, r + 1):
|
||
if max(abs(dx), abs(dy)) == r and (x + dx, y + dy) in other:
|
||
return r
|
||
return -1
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Reusable scan + plot
|
||
# --------------------------------------------------------------------------- #
|
||
def train_pedestal(finders, f, n_frames, seek=0):
|
||
"""Push the first ``n_frames`` frames of ``f`` into every finder given."""
|
||
f.seek(seek)
|
||
for _ in range(n_frames):
|
||
img = f.read_frame().copy()
|
||
for cf in finders:
|
||
cf.push_pedestal_frame(img)
|
||
|
||
|
||
def scan_mismatches(cf_cpu, cf_cuda, data, rx, ry,
|
||
scan_count=1000, n_show=8, tol=0):
|
||
"""Run both finders over ``scan_count`` frames sampled across ``data``.
|
||
|
||
Before every frame it snapshots the pedestal each finder will DECIDE with
|
||
(CPU: host mean/rms; CUDA: device mean/rms on stream 0), so a later
|
||
recompute uses the exact decision-time baseline.
|
||
|
||
``tol`` is forwarded to :func:`only_sets` when scoring a frame; ``tol=0``
|
||
keeps shifted twins as mismatches. Returns ``(show, totals)`` where
|
||
``show`` is the ``n_show`` frames with the most mismatches, each a dict
|
||
with ``fid``, ``score``, ``cpu_c``, ``cu_c``, ``mism`` and the four
|
||
pedestal arrays ``ped_cpu/noise_cpu/ped_cu/noise_cu``.
|
||
"""
|
||
show = []
|
||
tot_cpu_only = tot_cu_only = 0
|
||
for fid in np.linspace(0, len(data) - 1, scan_count, dtype=int):
|
||
snap = dict(ped_cpu=np.asarray(cf_cpu.pedestal).copy(),
|
||
noise_cpu=np.asarray(cf_cpu.noise).copy(),
|
||
ped_cu=np.asarray(cf_cuda.device_pedestal(0)).copy(),
|
||
noise_cu=np.asarray(cf_cuda.device_noise(0)).copy())
|
||
cf_cpu.find_clusters(data[fid])
|
||
cpu_c = centers(cf_cpu.steal_clusters(realloc_same_capacity=True))
|
||
cf_cuda.find_clusters(data[fid])
|
||
cu_c = centers(cf_cuda.steal_clusters(realloc_same_capacity=True))
|
||
|
||
cpu_only, cu_only = only_sets(cpu_c, cu_c, tol=tol)
|
||
tot_cpu_only += len(cpu_only)
|
||
tot_cu_only += len(cu_only)
|
||
mism = cpu_only | cu_only
|
||
if not mism:
|
||
continue
|
||
if len(show) < n_show or len(mism) > show[-1]["score"]:
|
||
show.append(dict(score=len(mism), fid=int(fid),
|
||
cpu_c=cpu_c, cu_c=cu_c, mism=mism, **snap))
|
||
show.sort(key=lambda e: -e["score"])
|
||
del show[n_show:]
|
||
return show, dict(cpu_only=tot_cpu_only, cu_only=tot_cu_only)
|
||
|
||
|
||
def compare_finders(finders, data, scan_count=1000, n_bins=200, e_range=(-2, 4000)):
|
||
"""Run several finders over the same frames and score pairwise agreement.
|
||
|
||
``finders`` is a dict ``{name: finder}``; every finder must already be
|
||
trained on the SAME pedestal frames. Each is run over the same
|
||
``scan_count`` frames sampled across ``data`` (identical ``find_clusters``/
|
||
``steal_clusters`` API for CPU, frozen-CPU and CUDA). In the same pass it
|
||
accumulates a per-finder cluster-energy histogram (from ``cv.sum()``), so no
|
||
extra scan is needed to draw the spectra.
|
||
|
||
Returns ``(totals, pairs, frames_scanned, hists)``:
|
||
* ``totals[name]`` total clusters found by that finder
|
||
* ``pairs[(a, b)]`` dict with ``a_only``/``b_only``/``mismatch``
|
||
summed over frames (exact, tol=0)
|
||
* ``hists[name]`` boost ``Histogram`` of cluster energies
|
||
Companions :func:`print_comparison` and :func:`plot_spectra` render these.
|
||
"""
|
||
names = list(finders)
|
||
totals = {n: 0 for n in names}
|
||
hists = {n: bh.Histogram(bh.axis.Regular(n_bins, *e_range)) for n in names}
|
||
pairs = {}
|
||
for i, a in enumerate(names):
|
||
for b in names[i + 1:]:
|
||
pairs[(a, b)] = dict(a_only=0, b_only=0, mismatch=0)
|
||
|
||
fids = np.linspace(0, len(data) - 1, scan_count, dtype=int)
|
||
for fid in fids:
|
||
cs = {}
|
||
for n, cf in finders.items():
|
||
cf.find_clusters(data[fid])
|
||
cv = cf.steal_clusters(realloc_same_capacity=True)
|
||
cs[n] = centers(cv)
|
||
totals[n] += len(cs[n])
|
||
if cv.size:
|
||
hists[n].fill(np.asarray(cv.sum()).ravel())
|
||
for (a, b), acc in pairs.items():
|
||
a_only, b_only = only_sets(cs[a], cs[b], tol=0)
|
||
acc["a_only"] += len(a_only)
|
||
acc["b_only"] += len(b_only)
|
||
acc["mismatch"] += len(a_only) + len(b_only)
|
||
return totals, pairs, len(fids), hists
|
||
|
||
|
||
def plot_spectra(hists, totals=None, title="Cluster energy spectrum"):
|
||
"""Overlay per-finder cluster-energy spectra with a ratio panel.
|
||
|
||
``hists`` is the ``{name: Histogram}`` returned by :func:`compare_finders`;
|
||
the first finder is the reference for the ratio panel. Returns the Figure.
|
||
"""
|
||
names = list(hists)
|
||
ref = names[0]
|
||
edges = hists[ref].axes[0].edges
|
||
vals = {n: hists[n].values() for n in names}
|
||
|
||
fig, (ax_spec, ax_ratio) = plt.subplots(
|
||
2, 1, figsize=(8, 6), sharex=True,
|
||
gridspec_kw={"height_ratios": [3, 1]})
|
||
|
||
styles = ["-", "--", "-.", ":"]
|
||
for i, n in enumerate(names):
|
||
lbl = n if totals is None else f"{n} ({totals[n]:,} clusters)"
|
||
ax_spec.stairs(vals[n], edges, label=lbl, linestyle=styles[i % len(styles)])
|
||
ax_spec.set_ylabel("Counts")
|
||
ax_spec.set_title(title)
|
||
ax_spec.legend()
|
||
ax_spec.grid(alpha=0.2)
|
||
|
||
with np.errstate(divide="ignore", invalid="ignore"):
|
||
for i, n in enumerate(names[1:], start=1):
|
||
ratio = np.where(vals[ref] > 0, vals[n] / vals[ref], np.nan)
|
||
ax_ratio.stairs(ratio, edges, label=f"{n} / {ref}",
|
||
color=f"C{i}", linestyle=styles[i % len(styles)])
|
||
ax_ratio.axhline(1.0, color="gray", linewidth=0.5)
|
||
ax_ratio.set_ylabel(f"/ {ref}")
|
||
ax_ratio.set_xlabel("Energy [ADU]")
|
||
ax_ratio.set_ylim(0.5, 2.0)
|
||
ax_ratio.legend(fontsize=8)
|
||
ax_ratio.grid(alpha=0.3)
|
||
|
||
plt.tight_layout()
|
||
plt.show()
|
||
return fig
|
||
|
||
|
||
def print_comparison(totals, pairs, frames_scanned):
|
||
"""Pretty-print the output of :func:`compare_finders`."""
|
||
print(f"Scanned {frames_scanned} frames\n")
|
||
print("Total clusters per finder:")
|
||
for n, t in totals.items():
|
||
print(f" {n:<16} {t:>12,}")
|
||
print("\nPairwise exact mismatches (tol=0):")
|
||
print(f" {'pair':<28} {'A-only':>10} {'B-only':>10} {'total':>10}")
|
||
for (a, b), acc in pairs.items():
|
||
ref = max(totals[a], totals[b], 1)
|
||
pct = 100.0 * acc["mismatch"] / ref
|
||
print(f" {a+' vs '+b:<28} {acc['a_only']:>10,} "
|
||
f"{acc['b_only']:>10,} {acc['mismatch']:>10,} ({pct:.4f}%)")
|
||
|
||
|
||
def walkthrough(show, data, rx, ry, rows, cols, n_sigma, pick=0,
|
||
labels=('A', 'B')):
|
||
"""Manual Test1/Test3 recompute of the strongest residual mismatch.
|
||
|
||
Dissects ``show[pick]`` (from :func:`scan_mismatches`, run as ``(a, b)``)
|
||
under each finder's decision-time snapshot pedestal — ``a`` = ``ped_cpu`` /
|
||
``noise_cpu``, ``b`` = ``ped_cu`` / ``noise_cu`` — and prints the raw and
|
||
pedestal-subtracted window plus the accept/reject each finder reaches. With
|
||
the double/double build the recompute reproduces the kernel exactly, so a
|
||
lone surviving mismatch (e.g. the single 7x7 residual) is fully explained:
|
||
the test that flips (Test1/Test3) and the pedestal gap name the cause.
|
||
"""
|
||
if not show:
|
||
print("No residual mismatches to walk through.")
|
||
return
|
||
e = show[pick]
|
||
sx, sy = 2 * rx + 1, 2 * ry + 1
|
||
c3 = np.sqrt(sx * sy)
|
||
frame = data[e['fid']].astype(np.float64)
|
||
sub_a = frame - e['ped_cpu']
|
||
sub_b = frame - e['ped_cu']
|
||
|
||
# strongest mismatch pixel whose full window stays inside the frame
|
||
cand = [p for p in e['mism']
|
||
if rx <= p[0] < cols - rx and ry <= p[1] < rows - ry]
|
||
if not cand:
|
||
print(f"frame {e['fid']}: all mismatches on the border — pick another.")
|
||
return
|
||
X0, Y0 = max(cand, key=lambda p: max(sub_a[p[1], p[0]], sub_b[p[1], p[0]]))
|
||
|
||
def evaluate(mean, rms_img):
|
||
sig = (frame[Y0 - ry:Y0 + ry + 1, X0 - rx:X0 + rx + 1]
|
||
- mean[Y0 - ry:Y0 + ry + 1, X0 - rx:X0 + rx + 1])
|
||
value = frame[Y0, X0] - mean[Y0, X0]
|
||
rms = rms_img[Y0, X0]
|
||
thr1, thr3 = n_sigma * rms, c3 * n_sigma * rms
|
||
mx, total = sig.max(), sig.sum()
|
||
localmax = bool(value >= mx)
|
||
t1, t3 = bool(mx > thr1), bool(total > thr3)
|
||
accept = bool(value >= -thr1) and localmax and (t1 or t3)
|
||
return dict(sig=sig, value=value, rms=rms, thr1=thr1, thr3=thr3,
|
||
mx=mx, total=total, localmax=localmax, t1=t1, t3=t3,
|
||
accept=accept)
|
||
|
||
la, lb = labels
|
||
owner = la if (X0, Y0) in e['cpu_c'] else lb
|
||
print(f"frame {e['fid']} centre (x={X0}, y={Y0}) accepted only by {owner}")
|
||
print("raw window (ADU):")
|
||
print(np.array2string(frame[Y0 - ry:Y0 + ry + 1,
|
||
X0 - rx:X0 + rx + 1].astype(int)))
|
||
print()
|
||
|
||
ra = evaluate(e['ped_cpu'], e['noise_cpu'])
|
||
rb = evaluate(e['ped_cu'], e['noise_cu'])
|
||
|
||
def detail(tag, r):
|
||
print(f"--- {tag} ---")
|
||
print(" subtracted window:")
|
||
print(" ", np.array2string(r['sig'], precision=1, prefix=' '))
|
||
print(f" centre value = {float(r['value']):8.2f} "
|
||
f"(local max? {r['localmax']})")
|
||
print(f" max = {float(r['mx']):8.2f} "
|
||
f"total = {float(r['total']):8.2f}")
|
||
print(f" rms(centre) = {float(r['rms']):8.3f}")
|
||
print(f" Test1: max > {n_sigma}*rms = {float(r['thr1']):8.2f} -> {r['t1']}")
|
||
print(f" Test3: total > {c3:.0f}*{n_sigma}*rms = {float(r['thr3']):8.2f} -> {r['t3']}")
|
||
print(f" ACCEPT = {r['accept']}")
|
||
print()
|
||
|
||
detail(la, ra)
|
||
detail(lb, rb)
|
||
print(f"RESULT: {la} = {'ACCEPT' if ra['accept'] else 'reject'} , "
|
||
f"{lb} = {'ACCEPT' if rb['accept'] else 'reject'}")
|
||
gap = abs(float(e['ped_cpu'][Y0, X0] - e['ped_cu'][Y0, X0]))
|
||
rgap = abs(float(e['noise_cpu'][Y0, X0] - e['noise_cu'][Y0, X0]))
|
||
print(f"pedestal mean gap @centre = {gap:.4f} ADU; rms gap = {rgap:.4f}")
|
||
if ra['accept'] == rb['accept']:
|
||
print("NOTE: recompute agrees for this centre pixel — the split is FP "
|
||
"rounding right at threshold or a window-neighbour effect; "
|
||
"try another pick.")
|
||
|
||
|
||
def plot_masked_mismatch(show, data, rx, ry, rows, cols,
|
||
zoom=30, show_vals=True):
|
||
"""Side-by-side masked view of each frame in ``show``.
|
||
|
||
Cluster pixels are coloured by pedestal-subtracted value (value printed
|
||
when ``show_vals``); non-cluster pixels are white; a red dot marks every
|
||
cluster centre. The cut is centred on the strongest mismatch pixel of the
|
||
frame. No box, no tolerance discarding — a shifted twin in the other
|
||
finder stays visible. Returns the Figure.
|
||
"""
|
||
cmap = plt.cm.viridis.copy()
|
||
cmap.set_bad("white")
|
||
fig, axes = plt.subplots(len(show), 2, figsize=(11, 5.3 * len(show)),
|
||
squeeze=False)
|
||
for r, e in enumerate(show):
|
||
fid = e["fid"]
|
||
sub_cpu = data[fid].astype(np.float64) - e["ped_cpu"]
|
||
sub_cu = data[fid].astype(np.float64) - e["ped_cu"]
|
||
X0, Y0 = max(e["mism"],
|
||
key=lambda p: max(sub_cpu[p[1], p[0]], sub_cu[p[1], p[0]]))
|
||
half = zoom // 2
|
||
r0, r1 = max(0, Y0 - half), min(rows, Y0 + half)
|
||
c0, c1 = max(0, X0 - half), min(cols, X0 + half)
|
||
win_cpu, win_cu = sub_cpu[r0:r1, c0:c1], sub_cu[r0:r1, c0:c1]
|
||
mask_cpu = footprint_mask(e["cpu_c"], sub_cpu.shape, rx, ry)[r0:r1, c0:c1]
|
||
mask_cu = footprint_mask(e["cu_c"], sub_cu.shape, rx, ry)[r0:r1, c0:c1]
|
||
union = mask_cpu | mask_cu
|
||
vmax = max(np.percentile(np.concatenate([win_cpu[union], win_cu[union]]),
|
||
99) if union.any() else 50.0, 50.0)
|
||
|
||
for ax, win, mask, cs, name in [
|
||
(axes[r][0], win_cpu, mask_cpu, e["cpu_c"], "CPU"),
|
||
(axes[r][1], win_cu, mask_cu, e["cu_c"], "CUDA")]:
|
||
ax.imshow(np.ma.masked_where(~mask, win), cmap=cmap, vmin=0,
|
||
vmax=vmax, interpolation="nearest")
|
||
for (cx, cy) in cs:
|
||
if c0 <= cx < c1 and r0 <= cy < r1:
|
||
ax.plot(cx - c0, cy - r0, ".", color="red", ms=7)
|
||
if show_vals:
|
||
for i in range(win.shape[0]):
|
||
for j in range(win.shape[1]):
|
||
if mask[i, j]:
|
||
ax.text(j, i, f"{win[i, j]:.0f}", ha="center",
|
||
va="center", fontsize=6,
|
||
color="white" if win[i, j] < 0.55 * vmax
|
||
else "black")
|
||
ax.set_title(f"frame {fid} — {name}: {len(cs)} clusters", fontsize=10)
|
||
ax.set_xticks([])
|
||
ax.set_yticks([])
|
||
axes[r][0].set_ylabel(f"{e['score']} mismatches\nzoom @ ({X0},{Y0})",
|
||
fontsize=9)
|
||
plt.tight_layout()
|
||
plt.show()
|
||
return fig
|