ClusterFinderFrozen: CPU finder for CUDA correctness study

- 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.
This commit is contained in:
kferjaoui
2026-08-03 11:59:27 +02:00
parent a42d71cf42
commit 3504d96336
7 changed files with 1047 additions and 2 deletions
+15 -1
View File
@@ -39,7 +39,21 @@ def ClusterFinder(image_size, cluster_size=(3,3), n_sigma=5, dtype = np.int32, c
def ClusterFinderMT(image_size, cluster_size = (3,3), dtype=np.int32, n_sigma=5, capacity = 1024, n_threads = 3):
def ClusterFinderFrozen(image_size, cluster_size=(3,3), n_sigma=5, dtype=np.int32, capacity=1024):
"""
Factory function to create a ClusterFinderFrozen object.
Diagnostic twin of ClusterFinder: identical decision logic, but the pedestal
is frozen per frame (every decision reads a start-of-frame snapshot, and all
pedestal updates are deferred to the end of the frame). This mirrors the CUDA
kernel's per-frame update model, so running it against ClusterFinderCUDA
isolates pedestal-update timing as the sole variable.
"""
cls = _get_class("ClusterFinderFrozen", cluster_size, dtype)
return cls(image_size, n_sigma=n_sigma, capacity=capacity)
def ClusterFinderMT(image_size, cluster_size = (3,3), dtype=np.int32, n_sigma=5, capacity = 1024, n_threads = 3):
"""
Factory function to create a ClusterFinderMT object. Provides a cleaner syntax for
the templated ClusterFinderMT in C++.
+1 -1
View File
@@ -31,7 +31,7 @@ from ._aare import corner
# from ._aare import ClusterFinderMT, ClusterCollector, ClusterFileSink, ClusterVector_i
from ._version import __version__
from .ClusterFinder import ClusterFinder, ClusterCollector, ClusterFinderMT, ClusterFileSink, ClusterFile
from .ClusterFinder import ClusterFinder, ClusterFinderFrozen, ClusterCollector, ClusterFinderMT, ClusterFileSink, ClusterFile
from .ClusterFinder import ClusterFinderCUDA, ClusterFinderCUDAGraph, _cuda_available
from .ClusterVector import ClusterVector
from .Cluster import Cluster
+81
View File
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: MPL-2.0
#include "aare/ClusterFinderFrozen.hpp"
#include "aare/ClusterVector.hpp"
#include "aare/NDView.hpp"
#include "aare/Pedestal.hpp"
#include "np_helper.hpp"
#include <cstdint>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <pybind11/stl_bind.h>
namespace py = pybind11;
using pd_type = double;
using namespace aare;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-parameter"
template <typename T, uint8_t ClusterSizeX, uint8_t ClusterSizeY,
typename CoordType = uint16_t>
void define_ClusterFinderFrozen(py::module &m, const std::string &typestr) {
auto class_name = fmt::format("ClusterFinderFrozen_{}", typestr);
using ClusterType = Cluster<T, ClusterSizeX, ClusterSizeY, CoordType>;
py::class_<ClusterFinderFrozen<ClusterType, uint16_t, pd_type>>(
m, class_name.c_str())
.def(py::init<Shape<2>, pd_type, size_t>(), py::arg("image_size"),
py::arg("n_sigma") = 5.0, py::arg("capacity") = 1'000'000)
.def_property(
"nSigma",
&ClusterFinderFrozen<ClusterType, uint16_t, pd_type>::get_nSigma,
&ClusterFinderFrozen<ClusterType, uint16_t, pd_type>::set_nSigma,
R"(number of sigma above the pedestal to consider a photon during cluster finding.)")
.def("push_pedestal_frame",
[](ClusterFinderFrozen<ClusterType, uint16_t, pd_type> &self,
py::array_t<uint16_t> frame) {
auto view = make_view_2d(frame);
self.push_pedestal_frame(view);
})
.def("clear_pedestal", &ClusterFinderFrozen<ClusterType, uint16_t,
pd_type>::clear_pedestal)
.def_property_readonly(
"pedestal",
[](ClusterFinderFrozen<ClusterType, uint16_t, pd_type> &self) {
auto pd = new NDArray<pd_type, 2>{};
*pd = self.pedestal();
return return_image_data(pd);
})
.def_property_readonly(
"noise",
[](ClusterFinderFrozen<ClusterType, uint16_t, pd_type> &self) {
auto arr = new NDArray<pd_type, 2>{};
*arr = self.noise();
return return_image_data(arr);
})
.def(
"steal_clusters",
[](ClusterFinderFrozen<ClusterType, uint16_t, pd_type> &self,
bool realloc_same_capacity) {
ClusterVector<ClusterType> clusters =
self.steal_clusters(realloc_same_capacity);
return clusters;
},
py::arg("realloc_same_capacity") = false)
.def(
"find_clusters",
[](ClusterFinderFrozen<ClusterType, uint16_t, pd_type> &self,
py::array_t<uint16_t> frame, uint64_t frame_number) {
auto view = make_view_2d(frame);
self.find_clusters(view, frame_number);
return;
},
py::arg(), py::arg("frame_number") = 0);
}
#pragma GCC diagnostic pop
+2
View File
@@ -7,6 +7,7 @@
#include "bind_ClusterFile.hpp"
#include "bind_ClusterFileSink.hpp"
#include "bind_ClusterFinder.hpp"
#include "bind_ClusterFinderFrozen.hpp"
#include "bind_ClusterFinderMT.hpp"
#include "bind_ClusterVector.hpp"
#include "bind_Defs.hpp"
@@ -54,6 +55,7 @@ double, 'f' for float)
#define DEFINE_BINDINGS_CLUSTERFINDER(T, N, M, U, TYPE_CODE) \
define_ClusterFinder<T, N, M, U>(m, "Cluster" #N "x" #M #TYPE_CODE); \
define_ClusterFinderFrozen<T, N, M, U>(m, "Cluster" #N "x" #M #TYPE_CODE); \
define_ClusterFinderMT<T, N, M, U>(m, "Cluster" #N "x" #M #TYPE_CODE); \
define_ClusterFileSink<T, N, M, U>(m, "Cluster" #N "x" #M #TYPE_CODE); \
define_ClusterCollector<T, N, M, U>(m, "Cluster" #N "x" #M #TYPE_CODE);
File diff suppressed because one or more lines are too long
+404
View File
@@ -0,0 +1,404 @@
"""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