Initial commit: grid-based multi-photon reconstruction

Code, configs, charge LUTs, best.pth and evaluation records for each
training run, the knife-edge / Siemens-star / XRF notebooks with
outputs, and the overnight run reports. Epoch checkpoints, inference
results and HTML exports stay local (see .gitignore).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xie_x1andClaude Opus 5.5 committed 2026-10-20 07:36:49 +02:00
commit 0f9a3f496f
222 files changed
+53227

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Self-tests for the grid formulation. Run after touching src/.
python scripts/00_selftest.py
1. encode -> decode round trip: the coordinate convention is self-consistent.
2. D4 augmentation keeps frame and truth coordinates aligned.
3. Translation equivariance: a model trained on SxS frames gives *identical*
predictions when the same content is embedded in a larger frame -- the claim
that D1/D2 rest on. If this test fails, train-small/infer-large is invalid.
4. Position-space merging removes duplicates but keeps genuinely distinct
photons in adjacent cells -- the property the 3x3 max-pool NMS lacked.
"""
import sys
from pathlib import Path
import numpy as np
import torch
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "src"))
from datasets import apply_d4, encode_targets # noqa: E402
from decode import decode # noqa: E402
from models import GridNet, valid_mask # noqa: E402
all_passed = True
def check(name, condition, detail=""):
global all_passed
all_passed &= bool(condition)
print(f" [{'PASS' if condition else 'FAIL'}] {name}"
f"{' ' + detail if detail else ''}")
print("1. encode -> decode round trip")
frame_size, energy_scale = 32, 12.0
rng = np.random.default_rng(0)
n_photons = 40
photons = np.zeros((n_photons, 4), np.float32)
photons[:, 0] = rng.uniform(0, frame_size, n_photons)
photons[:, 1] = rng.uniform(0, frame_size, n_photons)
photons[:, 2] = rng.uniform(5, 30, n_photons)
cells = (photons[:, 1].astype(int) * frame_size + photons[:, 0].astype(int))
_, unique_index = np.unique(cells, return_index=True) # drop same-cell duplicates
photons = photons[np.sort(unique_index)]
objectness, offset, energy = encode_targets(photons, frame_size, energy_scale)
prediction = torch.zeros(1, 4, frame_size, frame_size)
prediction[0, 0] = torch.from_numpy(
np.where(objectness > 0, 10.0, -10.0).astype(np.float32))
prediction[0, 1:3] = torch.from_numpy(offset)
prediction[0, 3:4] = torch.from_numpy(energy)
points = decode(prediction, threshold=0.5, merge_distance=0.0, halo=0,
energy_scale=energy_scale)[0].numpy()
check("photon count", len(points) == len(photons), f"{len(points)} vs {len(photons)}")
order_points = np.lexsort((points[:, 0], points[:, 1]))
order_truth = np.lexsort((photons[:, 0], photons[:, 1]))
position_error = np.abs(points[order_points, :2] - photons[order_truth, :2]).max()
energy_error = np.abs(points[order_points, 2] - photons[order_truth, 2]).max()
check("position round trip", position_error < 1e-4,
f"max |dx| = {position_error:.2e} px")
check("energy round trip", energy_error < 1e-3,
f"max |dE| = {energy_error:.2e} keV")
print("2. D4 augmentation consistency")
frame = np.zeros((frame_size, frame_size), np.float32)
for x, y, photon_energy, _ in photons: # put a delta at each photon
frame[int(y), int(x)] += photon_energy
worst_mismatch = 0.0
for operation in range(8):
transformed_frame, transformed_positions = apply_d4(frame, photons[:, :2], operation)
for (x, y), (x0, y0) in zip(transformed_positions, photons[:, :2]):
original = frame[int(y0), int(x0)]
worst_mismatch = max(worst_mismatch,
abs(transformed_frame[int(y), int(x)] - original))
check("charge follows the truth under all 8 ops", worst_mismatch < 1e-5,
f"max mismatch {worst_mismatch:.2e}")
print("3. translation equivariance (train small -> infer large)")
torch.manual_seed(0)
network = GridNet(channels=(32, 64, 64, 64)).eval()
radius = network.rf_radius
small_frame = torch.randn(1, 1, frame_size, frame_size) * 0.05
small_frame[0, 0, 12:15, 9:12] += torch.tensor([[.1, .3, .1],
[.4, 3.0, .5],
[.1, .4, .1]])
large_frame = torch.randn(1, 1, 3 * frame_size, 3 * frame_size) * 0.05
row_offset, column_offset = 40, 27
large_frame[0, 0, row_offset:row_offset + frame_size,
column_offset:column_offset + frame_size] = small_frame[0, 0]
with torch.no_grad():
small_prediction = network(small_frame)
large_prediction = network(large_frame)
embedded = large_prediction[:, :, row_offset:row_offset + frame_size,
column_offset:column_offset + frame_size]
mask = valid_mask(frame_size, frame_size, radius)
inside_difference = (small_prediction - embedded)[:, :, mask].abs().max().item()
check(f"identical inside the halo (R={radius})", inside_difference < 1e-4,
f"max |diff| = {inside_difference:.2e}")
# R must also be tight: the ring one pixel *inside* the halo must already differ
inner_ring = valid_mask(frame_size, frame_size, radius - 1) & ~mask
ring_difference = (small_prediction - embedded)[:, :, inner_ring].abs().max().item()
check(f"ring at distance R-1={radius-1} already differs "
f"(R is tight, not over-conservative)",
ring_difference > 1e-6, f"|diff| = {ring_difference:.2e}")
print("4. position-space merging")
# two adjacent cells pointing at the same physical place -> one photon
duplicate = torch.full((1, 4, 8, 8), -10.0)
duplicate[0, 1:3] = 0.0
duplicate[0, 3] = 1.0
duplicate[0, 0, 4, 4] = 6.0
duplicate[0, 1, 4, 4] = 0.45 # x = 4.95
duplicate[0, 0, 4, 5] = 5.0
duplicate[0, 1, 4, 5] = -0.45 # x = 5.05, same point
merged = decode(duplicate, threshold=0.5, merge_distance=0.3, halo=0,
energy_scale=12.0)[0]
check("duplicate in adjacent cells merged to one", len(merged) == 1,
f"got {len(merged)} points")
# two adjacent cells pointing at genuinely different places -> two photons
distinct = torch.full((1, 4, 8, 8), -10.0)
distinct[0, 1:3] = 0.0
distinct[0, 3] = 1.0
distinct[0, 0, 4, 4] = 6.0
distinct[0, 1, 4, 4] = -0.3 # x = 4.2
distinct[0, 0, 4, 5] = 5.0
distinct[0, 1, 4, 5] = 0.3 # x = 5.8, 1.6 px away
kept = decode(distinct, threshold=0.5, merge_distance=0.3, halo=0,
energy_scale=12.0)[0]
check("distinct photons in adjacent cells both kept", len(kept) == 2,
f"got {len(kept)} points (3x3 max-pool NMS would have given 1)")
print("\n" + ("ALL TESTS PASSED" if all_passed else "SOME TESTS FAILED"))
sys.exit(0 if all_passed else 1)
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
WP1.1 -- build the alpha(E, t) / beta(E, t) look-up table for continuous-energy
charge-cloud sampling.
python scripts/01_build_charge_lut.py --config Configs/charge_lut_Moench014.yaml
For every energy the charge-transport simulation gives the transverse carrier
distribution in 512 depth bins. Each bin is fitted with a generalised normal
distribution exactly as ``McGeneration/McGenerator.py::parameterization`` does,
so the shapes stay identical to the ones validated against measurement.
What is new is what happens next. ``parameterization`` compresses each energy
into 9 numbers by fitting alpha(t) with a 4-parameter polynomial and beta(t)
with a 5-parameter form; those 9 numbers are strongly correlated and
individually unstable, so interpolating them in energy produces unphysical
curves in between. Here the fitted *curves* are tabulated instead: the drift
time depends only on the sensor, bias and temperature, so the depth grid is the
same at every energy and (energy, drift time) is already a regular grid --
alpha and beta can simply be looked up and interpolated bilinearly, with no
functional form in between.
Output ``data/charge_lut_<sensor>_<hv>V.npz``:
energies (nE,) keV
z0 (nZ,) um, conversion depth measured from the entrance
drift_time (nZ,) ns
alpha, beta (nE, nZ) per-depth GGD fit
alpha_err, beta_err fit errors
n_carriers (nE, nZ) entries behind each fit
"""
import argparse
import os
import sys
import time
from multiprocessing import Pool
import numpy as np
from array import array
from omegaconf import OmegaConf
from ROOT import TF1, TH1F, TH1, gROOT
gROOT.SetBatch(True)
TH1.AddDirectory(False)
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "src"))
from charge_model import drift_time_allpix2 # noqa: E402
_CONF = None
def fit_generalised_normal(coords, index):
"""The per-depth GGD fit of McGenerator.parameterization, unchanged."""
rms = np.std(coords)
if not np.isfinite(rms) or rms <= 0:
return np.nan, np.nan, np.nan, np.nan
bin_width = rms / 20
n_bins = int(100 / bin_width)
histogram = TH1F(f"_h1_{index}", "", n_bins, -rms * 5, rms * 5)
histogram.FillN(len(coords), array("d", coords), array("d", [1] * len(coords)))
function = TF1(
f"f1_{index}",
"[0]/(2*[1]*TMath::Gamma(1/[0])) * exp(-pow(TMath::Abs(x)/[1], [0]))",
-5 * rms, 5 * rms) # par[0]: beta, par[1]: alpha
function.SetParLimits(0, 2, 5)
function.SetParLimits(1, .5, 30)
function.SetParameters(2., rms * 1.5)
histogram.Scale(histogram.GetNbinsX() / (10 * rms) / histogram.Integral())
histogram.Fit(function, "Q")
beta, alpha = function.GetParameter(0), function.GetParameter(1)
beta_err, alpha_err = function.GetParError(0), function.GetParError(1)
del histogram, function
return alpha, beta, alpha_err, beta_err
def process_energy(energy_keV):
conf = _CONF
folder = conf.simulation.folder.format(sensor=conf.sensor.code,
hv=conf.sensor.hv, energy=energy_keV)
n_bins = int(conf.simulation.z_bins)
method = conf.simulation.method
started = time.time()
alpha = np.full(n_bins, np.nan)
beta = np.full(n_bins, np.nan)
alpha_err = np.full(n_bins, np.nan)
beta_err = np.full(n_bins, np.nan)
n_carriers = np.zeros(n_bins, np.int64)
for index in range(n_bins):
stem = (f"{conf.sensor.code}_{conf.sensor.hv}V_{{axis}}_{energy_keV}keV_"
f"{index}_of_{n_bins}_{method}.npy")
try:
xs = np.load(os.path.join(folder, stem.format(axis="xs")))
ys = np.load(os.path.join(folder, stem.format(axis="ys")))
except FileNotFoundError:
continue
coords = np.concatenate((xs, ys))
n_carriers[index] = len(coords)
(alpha[index], beta[index],
alpha_err[index], beta_err[index]) = fit_generalised_normal(coords, index)
print(f" {energy_keV:2d} keV: {np.isfinite(alpha).sum()}/{n_bins} depth bins fitted "
f"({time.time()-started:.0f}s)", flush=True)
return energy_keV, alpha, beta, alpha_err, beta_err, n_carriers
def initialise(conf):
global _CONF
_CONF = conf
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--workers", type=int, default=8)
args = parser.parse_args()
conf = OmegaConf.load(args.config)
energies = list(range(int(conf.energies.first), int(conf.energies.last) + 1,
int(conf.energies.step)))
n_bins = int(conf.simulation.z_bins)
thickness = float(conf.sensor.thickness_um)
edges = np.linspace(0, thickness, n_bins + 1)
z0 = 0.5 * (edges[:-1] + edges[1:])
drift_time = np.array([drift_time_allpix2(
depth, thickness_um=thickness, hv=float(conf.sensor.hv),
depletion_voltage=float(conf.sensor.depletion_voltage),
temperature=float(conf.sensor.temperature)) for depth in z0])
print(f"[lut] {conf.sensor.code} @{conf.sensor.hv} V, {len(energies)} energies "
f"{energies[0]}-{energies[-1]} keV, {n_bins} depth bins")
print(f"[lut] drift time {drift_time.min():.2f} - {drift_time.max():.2f} ns")
with Pool(processes=args.workers, initializer=initialise, initargs=(conf,)) as pool:
results = pool.map(process_energy, energies)
order = {e: i for i, e in enumerate(energies)}
shape = (len(energies), n_bins)
alpha, beta = np.full(shape, np.nan), np.full(shape, np.nan)
alpha_err, beta_err = np.full(shape, np.nan), np.full(shape, np.nan)
n_carriers = np.zeros(shape, np.int64)
for energy, a, b, ae, be, nc in results:
i = order[energy]
alpha[i], beta[i], alpha_err[i], beta_err[i], n_carriers[i] = a, b, ae, be, nc
# Depth bins the simulation never produced (e.g. the deepest bins of an
# energy whose run was cut short) can be filled from the neighbouring
# energies at the *same* depth bin, i.e. the same drift time. The cloud size
# is set mainly by the drift time, so this is far closer than
# ChargeCloudLUT._fill_gaps, which holds the last fitted depth constant.
filled = ~np.isfinite(alpha) | ~np.isfinite(beta)
if conf.get("fill_missing", None) == "energy" and filled.any():
grid = np.array(energies, float)
for j in np.flatnonzero(filled.any(axis=0)):
good = ~filled[:, j]
if good.sum() < 2:
continue
for surface in (alpha, beta):
surface[filled[:, j], j] = np.interp(grid[filled[:, j]],
grid[good], surface[good, j])
for i in np.flatnonzero(filled.any(axis=1)):
bins = np.flatnonzero(filled[i])
print(f"[lut] {energies[i]:2d} keV: {len(bins)} depth bins filled from "
f"neighbouring energies (bins {bins.min()}-{bins.max()})")
os.makedirs(os.path.dirname(conf.output), exist_ok=True)
np.savez(conf.output, energies=np.array(energies, float), z0=z0,
drift_time=drift_time, alpha=alpha, beta=beta,
alpha_err=alpha_err, beta_err=beta_err, n_carriers=n_carriers,
filled_from_energy=filled if conf.get("fill_missing", None) == "energy"
else np.zeros(shape, bool),
sensor=conf.sensor.code, hv=float(conf.sensor.hv),
thickness_um=thickness)
print(f"[lut] wrote {conf.output} "
f"alpha {np.nanmin(alpha):.2f}-{np.nanmax(alpha):.2f} um, "
f"beta {np.nanmin(beta):.2f}-{np.nanmax(beta):.2f}")
if __name__ == "__main__":
main()
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
WP1.1 validation -- is the alpha(E,t) / beta(E,t) table good enough to sample
continuous energies from?
Four questions:
1. how noisy are the tabulated surfaces, and are they monotone in depth?
2. leave-one-out: drop an energy, interpolate it back from its neighbours,
and see how far off the interpolation is. With +-1 keV neighbours this
tests a 2 keV gap, twice the spacing the table actually uses.
3. does the table reproduce the 9-parameter fits that McGenerator uses at the
energies where both exist?
4. what happens if the 9 fit parameters are interpolated in energy instead
of the curves -- the thing this table exists to avoid.
"""
import argparse
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "src"))
from charge_model import ChargeCloudLUT # noqa: E402
GGD_PAR_DIR = Path('/home/xie_x1/MLXID/McGeneration/Notebooks')
def parametric_alpha(pars, t):
return pars[0] + pars[1] * np.sqrt(t) + pars[2] * t + pars[3] * t ** 2
def parametric_beta(pars, t):
return pars[4] * (t - pars[5]) ** pars[6] + pars[7] * np.exp(pars[8] * t) + 2
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--lut", default="data/charge_lut_Moench014_150V.npz")
parser.add_argument("--sensor", default="Moench014")
parser.add_argument("--hv", type=int, default=150)
parser.add_argument("--outdir", default="Results/charge_lut")
args = parser.parse_args()
lut = ChargeCloudLUT(args.lut)
energies, time = lut.energies, lut._time
alpha, beta = lut._alpha, lut._beta
outdir = Path(args.outdir); outdir.mkdir(parents=True, exist_ok=True)
# ---- 1. noise and monotonicity ------------------------------------
print("1. surfaces")
print(f" grid {alpha.shape[0]} energies x {alpha.shape[1]} depth bins, "
f"drift time {time.min():.2f}-{time.max():.2f} ns")
# bin-to-bin scatter after removing the smooth trend
from scipy.signal import savgol_filter
jitter_a, jitter_b = [], []
for row in range(alpha.shape[0]):
jitter_a.append(np.std(alpha[row] - savgol_filter(alpha[row], 21, 3)) /
np.mean(alpha[row]))
jitter_b.append(np.std(beta[row] - savgol_filter(beta[row], 21, 3)) /
np.mean(beta[row]))
print(f" bin-to-bin scatter about a local fit: alpha {np.mean(jitter_a)*100:.2f} %, "
f"beta {np.mean(jitter_b)*100:.2f} %")
rising = np.mean([np.all(np.diff(alpha[r]) > -0.02) for r in range(alpha.shape[0])])
print(f" alpha monotone in drift time for {rising*100:.0f} % of energies")
at_limit = ((alpha <= 0.501).mean(), (beta <= 2.001).mean())
print(f" fits sitting on the parameter limits: alpha {at_limit[0]*100:.1f} %, "
f"beta {at_limit[1]*100:.1f} % (shortest drifts, cloud smaller than a micron)")
# ---- 2. leave-one-out in energy -----------------------------------
print("\n2. leave-one-out in energy (interpolating across a 2 keV gap)")
rows = []
for i in range(1, len(energies) - 1):
a_hat = 0.5 * (alpha[i - 1] + alpha[i + 1])
b_hat = 0.5 * (beta[i - 1] + beta[i + 1])
rows.append((energies[i],
np.max(np.abs(a_hat - alpha[i]) / alpha[i]) * 100,
np.mean(np.abs(a_hat - alpha[i]) / alpha[i]) * 100,
np.max(np.abs(b_hat - beta[i]) / beta[i]) * 100))
rows = np.array(rows)
print(f" alpha: mean |error| {rows[:,2].mean():.3f} %, worst energy "
f"{rows[rows[:,1].argmax(),0]:.0f} keV at {rows[:,1].max():.2f} % (max over depth)")
print(f" beta : worst {rows[:,3].max():.2f} %")
print(" per energy (keV, mean%, max%):",
", ".join(f"{r[0]:.0f}:{r[2]:.2f}/{r[1]:.2f}" for r in rows[::5]))
# ---- 3. against the 9-parameter fits ------------------------------
print("\n3. table vs the 9-parameter fits McGenerator uses")
valid = (time > 0.3) & (time < time.max() - 0.2)
comparison = []
for i, energy in enumerate(energies):
path = GGD_PAR_DIR / f'ggdPar_{args.sensor}_{args.hv}V_{int(energy)}keV.npy'
if not path.exists():
continue
pars = np.load(path)
da = (parametric_alpha(pars, time[valid]) - alpha[i][valid]) / alpha[i][valid]
db = (parametric_beta(pars, time[valid]) - beta[i][valid]) / beta[i][valid]
comparison.append((energy, np.mean(np.abs(da)) * 100, np.mean(np.abs(db)) * 100))
comparison = np.array(comparison)
print(f" {len(comparison)} energies compared over {valid.sum()} depth bins "
f"inside the parametric fit's validity range")
print(f" mean |alpha difference| {comparison[:,1].mean():.2f} % "
f"(worst {comparison[:,1].max():.2f} % at {comparison[comparison[:,1].argmax(),0]:.0f} keV)")
print(f" mean |beta difference| {comparison[:,2].mean():.2f} %")
# ---- 4. interpolating the 9 parameters instead of the curves -------
print("\n4. interpolating the 9 fit parameters instead (what this table avoids)")
worst = 0.0
for target in range(6, 30):
low = GGD_PAR_DIR / f'ggdPar_{args.sensor}_{args.hv}V_{target-1}keV.npy'
high = GGD_PAR_DIR / f'ggdPar_{args.sensor}_{args.hv}V_{target+1}keV.npy'
if not (low.exists() and high.exists()):
continue
blended = 0.5 * (np.load(low) + np.load(high))
i = int(np.argmin(np.abs(energies - target)))
da = np.abs(parametric_alpha(blended, time[valid]) - alpha[i][valid]) / alpha[i][valid]
worst = max(worst, da.max() * 100)
if target in (8, 12, 20, 28):
print(f" {target:2d} keV: parameter blend is off by up to {da.max()*100:6.2f} %"
f" (curve interpolation: {rows[rows[:,0]==target][0,1]:.2f} %)")
print(f" worst case over all energies: {worst:.1f} %")
# ---- figure --------------------------------------------------------
fig, ax = plt.subplots(2, 3, figsize=(16, 8))
for i in range(0, len(energies), 5):
ax[0, 0].plot(time, alpha[i], lw=1, label=f'{energies[i]:.0f} keV')
ax[0, 1].plot(time, beta[i], lw=1)
ax[0, 2].plot(time, lut.cloud_sigma(energies[i], time), lw=1)
ax[0, 0].set_ylabel(r'$\alpha$ [$\mu$m]'); ax[0, 0].legend(fontsize=8)
ax[0, 1].set_ylabel(r'$\beta$')
ax[0, 2].set_ylabel(r'cloud RMS [$\mu$m]')
for a in ax[0]:
a.set_xlabel('drift time [ns]'); a.grid(alpha=.3)
im = ax[1, 0].imshow(alpha, aspect='auto', origin='lower', cmap='viridis',
extent=[time.min(), time.max(), energies[0], energies[-1]])
ax[1, 0].set_title(r'$\alpha(E, t)$ [$\mu$m]'); plt.colorbar(im, ax=ax[1, 0])
im = ax[1, 1].imshow(beta, aspect='auto', origin='lower', cmap='viridis',
extent=[time.min(), time.max(), energies[0], energies[-1]])
ax[1, 1].set_title(r'$\beta(E, t)$'); plt.colorbar(im, ax=ax[1, 1])
for a in ax[1, :2]:
a.set_xlabel('drift time [ns]'); a.set_ylabel('energy [keV]')
ax[1, 2].plot(rows[:, 0], rows[:, 1], 'o-', label=r'curve interp, max over depth')
ax[1, 2].plot(rows[:, 0], rows[:, 2], 's-', label=r'curve interp, mean')
ax[1, 2].set_xlabel('energy left out [keV]'); ax[1, 2].set_ylabel(r'$\alpha$ error [%]')
ax[1, 2].set_yscale('log'); ax[1, 2].legend(fontsize=8); ax[1, 2].grid(alpha=.3)
ax[1, 2].set_title('leave-one-out across a 2 keV gap')
fig.tight_layout()
fig.savefig(outdir / 'charge_lut_validation.png', dpi=130)
print(f"\nwrote {outdir / 'charge_lut_validation.png'}")
if __name__ == "__main__":
main()
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
WP1.2 -- generate grid-based MC frames from the existing single-photon stamps.
python scripts/02_generate_frames.py --config Configs/generate_frames_12keV.yaml
python scripts/02_generate_frames.py --config ... --splits train val
python scripts/02_generate_frames.py --config ... --splits eval_scan
python scripts/02_generate_frames.py --config ... --smoke # tiny, fast
Each shard is one HDF5 file:
frames (N, S, S) float32 keV
photons (M, 4) float32 x, y, E[keV], z[um] (frame coordinates)
frame_ptr (N+1,) int64 photons of frame i = photons[ptr[i]:ptr[i+1]]
occupancy (N,) float32 the occupancy each frame was thrown at
"""
import argparse
import os
import sys
import time
from multiprocessing import Pool
import h5py
import numpy as np
from omegaconf import OmegaConf
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
from frame_generator import generate_shard # noqa: E402
SMOKE_FRAMES = 400
def stamp_path(conf, index):
return os.path.join(conf.stamps.folder, conf.stamps.pattern.format(index))
def run_job(job):
started = time.time()
frames, photons, frame_pointer, occupancy, stats = generate_shard(
stamp_files=job["stamp_files"],
n_frames=job["n_frames"],
frame_size=job["frame_size"],
occupancy_spec=job["occupancy"],
seed=job["seed"],
margin=job["margin"],
noise_keV=job["noise_keV"],
cm_row_keV=job["cm_row_keV"],
cm_col_keV=job["cm_col_keV"],
edge_fraction=job["edge_fraction"],
mode=job["mode"], lut_path=job.get("lut_path"),
energy_spec=job.get("energy_spec"),
pixel_size_um=job["pixel_size_um"],
depletion_voltage=job.get("depletion_voltage"),
temperature=job.get("temperature"),
)
os.makedirs(os.path.dirname(job["output"]), exist_ok=True)
with h5py.File(job["output"], "w") as handle:
handle.create_dataset("frames", data=frames, dtype="f4")
handle.create_dataset("photons", data=photons, dtype="f4")
handle.create_dataset("frame_ptr", data=frame_pointer, dtype="i8")
handle.create_dataset("occupancy", data=occupancy, dtype="f4")
for key, value in job["attrs"].items():
handle.attrs[key] = value
for key, value in stats.items():
handle.attrs[key] = value
return dict(output=job["output"], seconds=time.time() - started, **stats)
def build_jobs(conf, splits, smoke=False):
jobs = []
base_seed = int(conf.runtime.seed)
generation = conf.get("generation", {})
mode = str(generation.get("mode", "stamps"))
common = dict(
frame_size=int(conf.frame.size),
margin=int(conf.frame.margin),
noise_keV=float(conf.noise.pixel_keV),
cm_row_keV=float(conf.noise.common_mode_row_keV),
cm_col_keV=float(conf.noise.common_mode_col_keV),
mode=mode,
pixel_size_um=float(conf.meta.pixel_size_um),
)
if mode == "lut":
common.update(lut_path=str(generation.lut),
depletion_voltage=float(conf.meta.depletion_voltage),
temperature=float(conf.meta.temperature))
attrs = dict(energy_keV=float(conf.meta.get("energy", 0.0)),
sensor=str(conf.meta.sensor),
hv=int(conf.meta.hv), pixel_size_um=float(conf.meta.pixel_size_um),
frame_size=int(conf.frame.size), margin=int(conf.frame.margin),
noise_keV=float(conf.noise.pixel_keV))
for split in splits:
if split == "eval_scan":
scan = conf.eval_scan
stamp_files = ([] if mode == "lut"
else [stamp_path(conf, index) for index in scan.stamp_files])
n_frames = SMOKE_FRAMES if smoke else int(scan.frames_each)
for point_index, occupancy in enumerate(scan.occupancies):
tag = f"occ{float(occupancy) * 100:g}pct".replace(".", "p")
jobs.append(dict(
output=os.path.join(conf.output.folder, "eval_scan", f"{tag}.h5"),
stamp_files=([] if mode == "lut"
else [stamp_files[point_index % len(stamp_files)]]),
energy_spec=(OmegaConf.to_container(conf.spectrum["eval"],
resolve=True)
if mode == "lut" else None),
n_frames=n_frames,
occupancy={"mode": "fixed", "value": float(occupancy)},
seed=base_seed + 9000 + point_index,
edge_fraction=float(scan.edge_fraction),
attrs=dict(attrs, split="eval_scan",
occupancy_nominal=float(occupancy)),
**common))
continue
split_conf = conf.splits[split]
if mode == "lut":
first_file, last_file = 0, int(split_conf.n_shards) - 1
else:
first_file, last_file = conf.stamps[f"{split}_files"]
n_frames = SMOKE_FRAMES if smoke else int(split_conf.frames_per_shard)
for shard_index, file_index in enumerate(range(int(first_file),
int(last_file) + 1)):
jobs.append(dict(
output=os.path.join(conf.output.folder, split,
f"shard_{shard_index:02d}.h5"),
stamp_files=([] if mode == "lut"
else [stamp_path(conf, file_index)]),
energy_spec=(OmegaConf.to_container(conf.spectrum[split], resolve=True)
if mode == "lut" else None),
n_frames=n_frames,
occupancy=OmegaConf.to_container(split_conf.occupancy, resolve=True),
seed=base_seed + 1000 * (splits.index(split) + 1) + shard_index,
edge_fraction=float(conf.edge_augmentation.fraction),
attrs=dict(attrs, split=split, mode=mode),
**common))
return jobs
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--splits", nargs="+",
default=["train", "val", "test", "eval_scan"])
parser.add_argument("--smoke", action="store_true",
help=f"{SMOKE_FRAMES} frames per shard, for a quick check")
parser.add_argument("--workers", type=int, default=None)
args = parser.parse_args()
conf = OmegaConf.load(args.config)
jobs = build_jobs(conf, list(args.splits), smoke=args.smoke)
n_workers = args.workers or int(conf.runtime.n_workers)
print(f"[gen] {len(jobs)} shards, {n_workers} workers, "
f"frame {conf.frame.size}x{conf.frame.size}")
print(f"[gen] output -> {conf.output.folder}")
started = time.time()
with Pool(processes=min(n_workers, len(jobs))) as pool:
results = pool.map(run_job, jobs)
total_photons = sum(r["n_photons"] for r in results)
total_frames = sum(r["n_frames"] for r in results)
total_same_cell = sum(r["n_same_cell"] for r in results)
for result in sorted(results, key=lambda r: r["output"]):
print(f" {os.path.relpath(result['output'], conf.output.folder):32s} "
f"{result['n_frames']:7d} frames {result['n_photons']:9d} photons "
f"<occ>={result['mean_occupancy']*100:5.2f}% "
f"same-cell={result['n_same_cell']:6d} ({result['seconds']:.1f}s)")
print(f"[gen] total {total_frames} frames, {total_photons} photons, "
f"same-cell loss {total_same_cell} "
f"({100.0*total_same_cell/max(total_photons,1):.2f}%), "
f"{time.time()-started:.1f}s")
if __name__ == "__main__":
main()
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""
WP3 -- train the grid-based model.
python scripts/03_train.py --config Configs/train_grid_12keV.yaml
python scripts/03_train.py --config ... --epochs 3 --tag smoke
Every epoch the validation pass reports both the loss and the *detection*
metrics (efficiency / purity / position RMS), because the loss alone says
nothing about the working point.
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from omegaconf import OmegaConf
from torch.utils.data import DataLoader
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "src"))
from datasets import GridFrameDataset, collate_with_photons # noqa: E402
from decode import decode # noqa: E402
from losses import GridLoss # noqa: E402
from metrics import DetectionStats # noqa: E402
from models import build_model, valid_mask # noqa: E402
LOSS_PARTS = ("objectness", "offset", "energy")
def shard_files(folder, split, shard_range):
first, last = shard_range
return [os.path.join(folder, split, f"shard_{index:02d}.h5")
for index in range(int(first), int(last) + 1)]
def prepare_output_dir(conf, tag=None):
date = datetime.now().strftime("%y%m%d")
base = Path(conf.experiment.results_dir)
run_index = 0
while True:
name = (f"{date}_{conf.experiment.name}"
+ (f"_{tag}" if tag else "") + f"_{run_index:02d}")
output_dir = base / name
if not output_dir.exists():
break
run_index += 1
(output_dir / "Models").mkdir(parents=True)
(output_dir / "Plots").mkdir(parents=True)
OmegaConf.save(conf, output_dir / "config.yaml")
return output_dir
def run_epoch(model, loader, loss_function, mask, optimizer=None,
grad_scaler=None, use_amp=False):
is_training = optimizer is not None
model.train(is_training)
totals = dict(loss=0.0, objectness=0.0, offset=0.0, energy=0.0)
n_samples = 0
for batch in loader:
frames, objectness, offset, energy = [t.cuda(non_blocking=True)
for t in batch[:4]]
with torch.autocast("cuda", enabled=use_amp):
prediction = model(frames)
loss, parts = loss_function(prediction.float(), objectness, offset,
energy, mask)
if is_training:
optimizer.zero_grad(set_to_none=True)
if grad_scaler is not None:
grad_scaler.scale(loss).backward()
grad_scaler.step(optimizer)
grad_scaler.update()
else:
loss.backward()
optimizer.step()
batch_size = frames.shape[0]
totals["loss"] += loss.item() * batch_size
for part in LOSS_PARTS:
totals[part] += parts[part].item() * batch_size
n_samples += batch_size
return {key: value / max(n_samples, 1) for key, value in totals.items()}
@torch.no_grad()
def run_detection_metrics(model, loader, conf, halo, frame_size, use_amp=False):
model.eval()
stats = DetectionStats(pixel_size_um=float(conf.eval.pixel_size_um),
match_radius=float(conf.eval.match_radius),
halo=halo, frame_size=frame_size)
for frames, objectness, offset, energy, photons in loader:
frames = frames.cuda(non_blocking=True)
with torch.autocast("cuda", enabled=use_amp):
prediction = model(frames)
points = decode(prediction.float(),
threshold=float(conf.eval.threshold),
merge_distance=float(conf.eval.merge_distance),
halo=halo,
predict_energy=bool(conf.model.predict_energy),
energy_scale=float(conf.data.energy_scale))
stats.update([frame_points.numpy() for frame_points in points],
[frame_truth.numpy() for frame_truth in photons])
return stats.summary()
def plot_curves(history, output_dir):
epochs = np.arange(1, len(history["train_loss"]) + 1)
figure, axes = plt.subplots(1, 3, figsize=(15, 4.2))
axes[0].plot(epochs, history["train_loss"], label="train")
axes[0].plot(epochs, history["val_loss"], label="val")
axes[0].set_yscale("log")
axes[0].set_xlabel("epoch")
axes[0].set_ylabel("total loss")
axes[0].legend()
axes[0].grid(alpha=.3)
axes[1].plot(epochs, history["efficiency"], label="efficiency")
axes[1].plot(epochs, history["purity"], label="purity")
axes[1].set_xlabel("epoch")
axes[1].set_ylim(0, 1.02)
axes[1].legend()
axes[1].grid(alpha=.3)
axes[2].plot(epochs, history["rms_1d_um"], label="RMS 1D")
axes[2].plot(epochs, history["sigma68_1d_um"], label=r"$\sigma_{68}$")
axes[2].set_xlabel("epoch")
axes[2].set_ylabel(r"residual [$\mu$m]")
axes[2].set_yscale("log")
axes[2].legend()
axes[2].grid(alpha=.3)
figure.tight_layout()
figure.savefig(output_dir / "Plots" / "training_curves.png", dpi=130)
plt.close(figure)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--epochs", type=int, default=None)
parser.add_argument("--tag", default=None)
args = parser.parse_args()
conf = OmegaConf.load(args.config)
if args.epochs:
conf.training.epochs = args.epochs
torch.manual_seed(conf.training.seed)
np.random.seed(conf.training.seed)
output_dir = prepare_output_dir(conf, args.tag)
print(f"[train] output -> {output_dir}")
dataset_kwargs = dict(input_scale=float(conf.data.input_scale),
energy_scale=float(conf.data.energy_scale))
train_dataset = GridFrameDataset(
shard_files(conf.data.folder, "train", conf.data.train_shards),
augment=bool(conf.data.augment), name="train",
max_frames=conf.data.max_train_frames, **dataset_kwargs)
val_dataset = GridFrameDataset(
shard_files(conf.data.folder, "val", conf.data.val_shards),
augment=False, name="val", return_photons=True,
max_frames=conf.data.max_val_frames, **dataset_kwargs)
n_workers = int(conf.data.num_workers)
train_loader = DataLoader(train_dataset, batch_size=int(conf.data.batch_size_train),
shuffle=True, num_workers=n_workers, pin_memory=True,
drop_last=True, persistent_workers=n_workers > 0)
val_loader = DataLoader(val_dataset, batch_size=int(conf.data.batch_size_val),
shuffle=False, num_workers=n_workers, pin_memory=True,
collate_fn=collate_with_photons,
persistent_workers=n_workers > 0)
model = build_model(conf).cuda()
halo = model.rf_radius
frame_size = train_dataset.frame_size
mask = valid_mask(frame_size, frame_size, halo, device="cuda")
n_parameters = sum(p.numel() for p in model.parameters())
print(f"[train] GridNet: {n_parameters} parameters, receptive-field radius "
f"R={halo} px (halo), valid region "
f"{frame_size-2*halo}x{frame_size-2*halo} of {frame_size}x{frame_size}")
loss_function = GridLoss(conf).cuda()
optimizer = torch.optim.Adam(model.parameters(), lr=float(conf.training.lr),
weight_decay=float(conf.training.weight_decay))
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, "min", factor=float(conf.training.scheduler_factor),
patience=int(conf.training.scheduler_patience))
use_amp = bool(conf.training.amp)
grad_scaler = torch.amp.GradScaler("cuda") if use_amp else None
history = {key: [] for key in ("train_loss", "val_loss", "efficiency",
"purity", "rms_1d_um", "sigma68_1d_um")}
best_loss = float("inf")
for epoch in range(1, int(conf.training.epochs) + 1):
started = time.time()
train_metrics = run_epoch(model, train_loader, loss_function, mask,
optimizer, grad_scaler, use_amp)
val_metrics = run_epoch(model, val_loader, loss_function, mask,
None, None, use_amp)
detection = run_detection_metrics(model, val_loader, conf, halo,
frame_size, use_amp)
scheduler.step(val_metrics["loss"])
history["train_loss"].append(train_metrics["loss"])
history["val_loss"].append(val_metrics["loss"])
history["efficiency"].append(detection["efficiency"])
history["purity"].append(detection["purity"])
history["rms_1d_um"].append(detection["rms_1d_um"])
history["sigma68_1d_um"].append(detection["sigma68_1d_um"])
print(f"[{epoch:3d}/{conf.training.epochs}] "
f"train {train_metrics['loss']:.4f} "
f"(obj {train_metrics['objectness']:.4f} "
f"off {train_metrics['offset']:.5f} "
f"ene {train_metrics['energy']:.5f}) "
f"val {val_metrics['loss']:.4f} | "
f"eff {detection['efficiency']*100:5.2f}% "
f"pur {detection['purity']*100:5.2f}% "
f"| RMS {detection['rms_1d_um']:.3f} um "
f"s68 {detection['sigma68_1d_um']:.3f} um "
f"| lr {optimizer.param_groups[0]['lr']:.1e} "
f"| {time.time()-started:.0f}s", flush=True)
checkpoint = dict(model=model.state_dict(),
conf=OmegaConf.to_container(conf),
epoch=epoch, metrics=detection)
if val_metrics["loss"] < best_loss:
best_loss = val_metrics["loss"]
torch.save(checkpoint, output_dir / "Models" / "best.pth")
if epoch in list(conf.training.checkpoint_epochs) or epoch == conf.training.epochs:
torch.save(checkpoint, output_dir / "Models" / f"E{epoch}.pth")
torch.save(checkpoint, output_dir / "Models" / "last.pth")
plot_curves(history, output_dir)
with open(output_dir / "history.json", "w") as handle:
json.dump(history, handle, indent=1)
print(f"[train] done. best val loss {best_loss:.4f}. "
f"models in {output_dir/'Models'}")
if __name__ == "__main__":
main()
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""
WP4 -- evaluate a grid model on the fixed-occupancy MC scan.
python scripts/04_evaluate.py --checkpoint Results/<exp>/Models/best.pth
python scripts/04_evaluate.py --checkpoint ... --data <folder>/eval_scan
Produces, per occupancy point:
efficiency / purity at the working threshold, position RMS and sigma68,
energy bias/resolution, sub-pixel non-uniformity (risk R1),
plus the threshold ROC, the pair-resolution curve (efficiency vs distance to the
nearest other photon -- the figure of merit that replaces "efficiency vs
occupancy"), and residual vs conversion depth z.
"""
import argparse
import glob
import json
import os
import re
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from omegaconf import OmegaConf
from torch.utils.data import DataLoader
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "src"))
from datasets import GridFrameDataset, collate_with_photons # noqa: E402
from decode import decode # noqa: E402
from metrics import (DetectionStats, greedy_match, # noqa: E402
nearest_neighbour_distance)
from models import build_model # noqa: E402
THRESHOLDS = np.array([.05, .1, .2, .3, .4, .5, .6, .7, .8, .9, .95, .98])
NEIGHBOUR_BIN_EDGES = np.array([0, .5, 1, 1.5, 2, 2.5, 3, 4, 6, 1e9])
ENERGY_BIN_WIDTH = 2.5 # keV; one bin only for a
# monochromatic set, which is fine
def energy_bin_edges(energies, width=ENERGY_BIN_WIDTH):
"""2.5 keV bins on a fixed grid, spanning the energies of the set."""
low = np.floor(energies.min() / width) * width
high = np.ceil(energies.max() / width) * width
return np.arange(low, high + width / 2, width)
def occupancy_of_filename(path):
match = re.search(r"occ([0-9p]+)pct", os.path.basename(path))
return float(match.group(1).replace("p", ".")) / 100 if match else float("nan")
@torch.no_grad()
def run_file(model, conf, path, halo, batch_size=512, workers=8, max_frames=None,
roc_frames=2000):
dataset = GridFrameDataset([path], augment=False, return_photons=True,
input_scale=float(conf.data.input_scale),
energy_scale=float(conf.data.energy_scale),
max_frames=max_frames, name=os.path.basename(path))
loader = DataLoader(dataset, batch_size=batch_size, shuffle=False,
num_workers=workers, collate_fn=collate_with_photons,
pin_memory=True)
frame_size = dataset.frame_size
working_threshold = float(conf.eval.threshold)
lowest_threshold = float(THRESHOLDS.min())
stats = DetectionStats(pixel_size_um=float(conf.eval.pixel_size_um),
match_radius=float(conf.eval.match_radius),
halo=halo, frame_size=frame_size)
roc_predicted, roc_true = [], []
for frames, objectness, offset, energy, photons in loader:
frames = frames.cuda(non_blocking=True)
prediction = model(frames).float()
points = decode(prediction, threshold=lowest_threshold,
merge_distance=float(conf.eval.merge_distance), halo=halo,
predict_energy=bool(conf.model.predict_energy),
energy_scale=float(conf.data.energy_scale))
points = [frame_points.numpy() for frame_points in points]
truth = [frame_truth.numpy() for frame_truth in photons]
stats.update([p[p[:, 3] > working_threshold] for p in points], truth)
if len(roc_predicted) < roc_frames:
roc_predicted.extend(points)
roc_true.extend(truth)
summary = stats.summary()
differential = stats.differential()
# ---- threshold sweep on a subset -------------------------------------
roc = []
for threshold in THRESHOLDS:
subset_stats = DetectionStats(pixel_size_um=float(conf.eval.pixel_size_um),
match_radius=float(conf.eval.match_radius),
halo=halo, frame_size=frame_size)
subset_stats.update([p[p[:, 3] > threshold] for p in roc_predicted[:roc_frames]],
roc_true[:roc_frames])
subset_summary = subset_stats.summary()
roc.append((float(threshold), subset_summary["efficiency"],
subset_summary["purity"], subset_summary["rms_1d_um"]))
# ---- pair resolution: efficiency vs truth nearest-neighbour distance --
n_bins = len(NEIGHBOUR_BIN_EDGES) - 1
n_true_binned = np.zeros(n_bins)
n_matched_binned = np.zeros(n_bins)
for predicted, truth in zip(roc_predicted[:roc_frames], roc_true[:roc_frames]):
predicted = predicted[predicted[:, 3] > working_threshold]
if len(truth) == 0:
continue
inside = ((truth[:, 0] >= halo) & (truth[:, 0] < frame_size - halo) &
(truth[:, 1] >= halo) & (truth[:, 1] < frame_size - halo))
truth = truth[inside]
if len(truth) == 0:
continue
neighbour_distance = nearest_neighbour_distance(truth[:, :2])
bin_index = np.digitize(neighbour_distance, NEIGHBOUR_BIN_EDGES) - 1
for index in range(n_bins):
n_true_binned[index] += (bin_index == index).sum()
if len(predicted):
_, index_true = greedy_match(predicted[:, :2], truth[:, :2],
float(conf.eval.match_radius))
for index in range(n_bins):
n_matched_binned[index] += (bin_index[index_true] == index).sum()
pair_resolution = dict(
edges=NEIGHBOUR_BIN_EDGES[:-1].tolist(),
n_true=n_true_binned.tolist(),
n_match=n_matched_binned.tolist(),
efficiency=(n_matched_binned / np.maximum(n_true_binned, 1)).tolist())
return (summary, differential, roc, pair_resolution,
stats.versus_energy(energy_bin_edges(dataset.photons[:, 2])))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--checkpoint", required=True)
parser.add_argument("--data", default=None,
help="folder with the eval_scan h5 files")
parser.add_argument("--max-frames", type=int, default=None)
parser.add_argument("--workers", type=int, default=8)
parser.add_argument("--outdir", default=None)
args = parser.parse_args()
checkpoint = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
conf = OmegaConf.create(checkpoint["conf"])
model = build_model(conf).cuda().eval()
model.load_state_dict(checkpoint["model"])
halo = model.rf_radius
print(f"[eval] checkpoint epoch {checkpoint['epoch']}, R={halo}, "
f"{sum(p.numel() for p in model.parameters())} params, "
f"threshold {conf.eval.threshold}, merge {conf.eval.merge_distance} px")
data_folder = args.data or os.path.join(conf.data.folder, "eval_scan")
files = sorted(glob.glob(os.path.join(data_folder, "*.h5")),
key=occupancy_of_filename)
if not files:
sys.exit(f"no eval files in {data_folder}")
output_dir = Path(args.outdir or Path(args.checkpoint).parent.parent)
(output_dir / "Plots").mkdir(parents=True, exist_ok=True)
results = {}
for path in files:
occupancy = occupancy_of_filename(path)
summary, differential, roc, pair_resolution, per_energy = run_file(
model, conf, path, halo, workers=args.workers, max_frames=args.max_frames)
results[f"{occupancy*100:g}%"] = dict(occupancy=occupancy, summary=summary,
roc=roc, pair=pair_resolution,
per_energy=per_energy,
_differential=differential)
print(f" occ {occupancy*100:5.2f}% eff {summary['efficiency']*100:6.2f}% "
f"pur {summary['purity']*100:6.2f}% RMS {summary['rms_1d_um']:.3f} um "
f"s68 {summary['sigma68_1d_um']:.3f} um "
f"dE {summary.get('energy_rms_keV', float('nan')):.3f} keV "
f"subpix-nonunif {summary['subpixel_nonuniformity']*100:.1f}%",
flush=True)
populated = [row for row in list(results.values())[len(results)//2]["per_energy"]
if row["n_true"] > 100]
if len(populated) > 1:
print(f"\n per energy bin at occupancy "
f"{list(results.values())[len(results)//2]['occupancy']*100:g}%:")
print(f" {'keV':>11} {'n':>9} {'eff%':>7} {'RMS um':>8} {'s68 um':>8} "
f"{'dE bias':>8} {'dE rms':>8}")
for row in populated:
print(f" {row['energy_low']:4.1f}-{row['energy_high']:4.1f} "
f"{row['n_true']:9d} {row['efficiency']*100:7.2f} "
f"{row['rms_1d_um']:8.3f} {row['sigma68_1d_um']:8.3f} "
f"{row['energy_bias_keV']:8.3f} {row['energy_rms_keV']:8.3f}")
plot_versus_energy(results, output_dir)
plot_all(results, output_dir, float(conf.eval.threshold))
serialisable = {label: {key: value for key, value in result.items()
if key != "_differential"}
for label, result in results.items()}
with open(output_dir / "eval_summary.json", "w") as handle:
json.dump(serialisable, handle, indent=1)
print(f"[eval] wrote {output_dir/'eval_summary.json'} and {output_dir/'Plots'}")
def plot_versus_energy(results, output_dir):
"""Efficiency, position resolution and energy resolution against photon energy."""
figure, axes = plt.subplots(1, 3, figsize=(15, 4.4))
for label, result in results.items():
rows = [row for row in result["per_energy"] if row["n_true"] > 100]
if len(rows) < 2:
continue
centre = np.array([0.5 * (r["energy_low"] + r["energy_high"]) for r in rows])
axes[0].plot(centre, [r["efficiency"] * 100 for r in rows], "o-", label=f"occ {label}")
axes[1].plot(centre, [r["sigma68_1d_um"] for r in rows], "o-", label=f"occ {label}")
axes[2].plot(centre, [r["energy_rms_keV"] for r in rows], "o-", label=f"occ {label}")
for axis, ylabel in zip(axes, ("efficiency [%]", r"position $\sigma_{68}$ [$\mu$m]",
r"energy RMS [keV]")):
axis.set_xlabel("photon energy [keV]")
axis.set_ylabel(ylabel)
axis.grid(alpha=.3)
axis.legend(fontsize=8)
figure.tight_layout()
figure.savefig(output_dir / "Plots" / "eval_vs_energy.png", dpi=130)
plt.close(figure)
def plot_all(results, output_dir, working_threshold):
occupancy = np.array([r["occupancy"] for r in results.values()]) * 100
efficiency = np.array([r["summary"]["efficiency"] for r in results.values()]) * 100
purity = np.array([r["summary"]["purity"] for r in results.values()]) * 100
rms = np.array([r["summary"]["rms_1d_um"] for r in results.values()])
sigma68 = np.array([r["summary"]["sigma68_1d_um"] for r in results.values()])
figure, axes = plt.subplots(2, 3, figsize=(16, 9))
axes[0, 0].plot(occupancy, efficiency, "o-", label="efficiency")
axes[0, 0].plot(occupancy, purity, "s-", label="purity")
axes[0, 0].set_xlabel("occupancy [%]")
axes[0, 0].set_ylabel("[%]")
axes[0, 0].set_title(f"working point p>{working_threshold}")
axes[0, 0].legend()
axes[0, 0].grid(alpha=.3)
axes[0, 1].plot(occupancy, rms, "o-", label="RMS")
axes[0, 1].plot(occupancy, sigma68, "s-", label=r"$\sigma_{68}$")
axes[0, 1].set_xlabel("occupancy [%]")
axes[0, 1].set_ylabel(r"residual [$\mu$m]")
axes[0, 1].legend()
axes[0, 1].grid(alpha=.3)
for label, result in results.items():
roc = np.array(result["roc"])
axes[0, 2].plot(roc[:, 2] * 100, roc[:, 1] * 100, ".-", label=f"occ {label}")
axes[0, 2].set_xlabel("purity [%]")
axes[0, 2].set_ylabel("efficiency [%]")
axes[0, 2].set_title("threshold sweep")
axes[0, 2].legend(fontsize=8)
axes[0, 2].grid(alpha=.3)
for label, result in results.items():
residual = result["_differential"]["residual_um"]
if len(residual):
axes[1, 0].hist(residual.ravel(), bins=200, range=(-6, 6),
histtype="step", density=True, label=f"occ {label}")
axes[1, 0].set_xlabel(r"residual [$\mu$m]")
axes[1, 0].set_yscale("log")
axes[1, 0].legend(fontsize=8)
axes[1, 0].grid(alpha=.3)
for label, result in results.items():
pair = result["pair"]
edges = np.array(pair["edges"])
pair_efficiency = np.array(pair["efficiency"]) * 100
populated = np.array(pair["n_true"]) > 50
axes[1, 1].plot(edges[populated], pair_efficiency[populated], "o-",
label=f"occ {label}")
axes[1, 1].set_xlabel("distance to nearest other photon [px]")
axes[1, 1].set_ylabel("efficiency [%]")
axes[1, 1].set_xlim(0, 6)
axes[1, 1].set_title("pair resolution")
axes[1, 1].legend(fontsize=8)
axes[1, 1].grid(alpha=.3)
middle = list(results.values())[len(results) // 2]
subpixel = middle["_differential"]["subpixel"]
if len(subpixel):
histogram, _, _ = np.histogram2d(subpixel[:, 0], subpixel[:, 1], bins=25,
range=[[0, 1], [0, 1]])
image = axes[1, 2].imshow(histogram.T / histogram.mean(), origin="lower",
extent=[0, 1, 0, 1], vmin=0.5, vmax=1.5,
cmap="viridis")
plt.colorbar(image, ax=axes[1, 2])
axes[1, 2].set_title(f"sub-pixel uniformity (occ {middle['occupancy']*100:g}%)\n"
"flat = no grid artefact (risk R1)")
axes[1, 2].set_xlabel("sub-pixel x")
axes[1, 2].set_ylabel("sub-pixel y")
figure.tight_layout()
figure.savefig(output_dir / "Plots" / "eval_scan.png", dpi=130)
plt.close(figure)
# ---- residual vs depth, vs isolation, and energy ---------------------
figure, axes = plt.subplots(1, 3, figsize=(16, 4.4))
depth_edges = np.linspace(0, 650, 14)
neighbour_edges = np.array([0, .5, 1, 1.5, 2, 2.5, 3, 4, 6, 10])
for label, result in results.items():
differential = result["_differential"]
if not len(differential["z_um"]):
continue
depth_bin = np.digitize(differential["z_um"], depth_edges) - 1
depth_rms = [differential["residual_um"][depth_bin == index].std()
if (depth_bin == index).sum() > 50 else np.nan
for index in range(len(depth_edges) - 1)]
axes[0].plot(0.5 * (depth_edges[:-1] + depth_edges[1:]), depth_rms, "o-",
label=f"occ {label}")
neighbour_bin = np.digitize(differential["nn_px"], neighbour_edges) - 1
neighbour_rms = [differential["residual_um"][neighbour_bin == index].std()
if (neighbour_bin == index).sum() > 50 else np.nan
for index in range(len(neighbour_edges) - 1)]
axes[1].plot(neighbour_edges[:-1], neighbour_rms, "o-", label=f"occ {label}")
if np.isfinite(differential["e_pred"]).any():
axes[2].hist(differential["e_pred"] - differential["e_true"], bins=150,
range=(-4, 4), histtype="step", density=True,
label=f"occ {label}")
axes[0].set_xlabel(r"conversion depth z [$\mu$m]")
axes[0].set_ylabel(r"RMS [$\mu$m]")
axes[0].set_title("resolution vs charge-cloud size")
axes[0].legend(fontsize=8)
axes[0].grid(alpha=.3)
axes[1].set_xlabel("distance to nearest other photon [px]")
axes[1].set_ylabel(r"RMS [$\mu$m]")
axes[1].legend(fontsize=8)
axes[1].grid(alpha=.3)
axes[2].set_xlabel(r"$E_{pred}-E_{true}$ [keV]")
axes[2].set_yscale("log")
axes[2].legend(fontsize=8)
axes[2].grid(alpha=.3)
figure.tight_layout()
figure.savefig(output_dir / "Plots" / "eval_differential.png", dpi=130)
plt.close(figure)
if __name__ == "__main__":
main()
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""
WP5.2 -- run the grid model over a measured run, straight from the raw files.
python scripts/05_infer_measurement.py --config Configs/infer_measurement_12keV.yaml \
--run Edge_3filters_pos0_12keV
python scripts/05_infer_measurement.py --config ... --run ... --n-frames 200000
python scripts/05_infer_measurement.py --config ... --run ... --save-photons
raw ADU -> pedestal -> common mode -> 3D-LUT -> GridNet -> position-space merge
-> sub-pixel binned image + energy spectrum + per-frame photon count.
Only the valid region (the frame minus the halo R) is kept: near the ROI border
the network sees zero padding instead of the neighbouring pixels that exist on
the chip, so those cells are not trustworthy.
With `inference.precision: fp16` the network runs under FP16 autocast in
channels-last layout, 2.3x faster for the poly model. The offsets and energy of
the cells above threshold are then recomputed in FP32 from the same FP16
features (`predict_fp16`), so energies are not rounded to the FP16 grid. The models are trained
with AMP, and on MC and measured frames FP16 reproduces the FP32 photon list
(efficiency, purity, position and energy resolution unchanged; 99.997 % of the
photons identical, positions within ~20 nm).
Images written per run, each as .npy (analysis) and .png (looking at it):
binned_image reconstructed photons at pixel_size / binning_factor
native_image the same photons rebinned back to the 25 um pitch
analog_image mean calibrated energy per frame -- the classical "native
readout" picture
plus comparison.png, the two side by side.
With `inference.self_flattening` the sub-pixel phase of the reconstructed
positions is flattened with the Rosenblatt transform of
`DeepLearning/src/etaInterpolationFromPoints.py`: the (frac_x, frac_y) histogram
of the whole run becomes a look-up table that maps every position onto the one a
uniform illumination would have put it at. A per-bin flat-field division cannot
do this -- it corrects intensities, while a grid artefact is a displacement --
so the flattened image is produced here alongside the raw one and both are kept.
"""
import argparse
import json
import math
import os
import queue
import sys
import threading
import time
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from omegaconf import OmegaConf
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "src"))
from decode import decode # noqa: E402
from measurement import (GpuCalibrator, ImageAccumulator, # noqa: E402
RawFrameReader, estimate_pedestal)
from models import build_model # noqa: E402
from etaInterpolationFromPoints import (NEtaBins, # noqa: E402
interpolate_eta_from_points)
from flattening import StreamingFlattener # noqa: E402
@torch.no_grad()
def predict_fp16(model, inputs, threshold):
"""FP16 backbone and head; offsets and energy of candidate cells in FP32.
An FP16 head rounds every energy to the FP16 grid (7.3 meV below 15 keV,
14.6 meV above), which shows up as a comb in a 50 eV spectrum. Running the
whole head in FP32 means converting the full feature map and costs ~15 %,
so only the cells above the objectness threshold -- the only ones decode can
report -- get channels 1-3 recomputed in FP32 from the same FP16 features.
"""
with torch.autocast("cuda", dtype=torch.float16):
features = model.backbone(inputs.contiguous(memory_format=torch.channels_last))
prediction = model.head(features)
prediction = prediction.float()
logit_threshold = math.log(threshold / (1.0 - threshold))
batch_of, row_of, column_of = torch.nonzero(prediction[:, 0] > logit_threshold,
as_tuple=True)
if len(batch_of):
weight = model.head.weight[1:, :, 0, 0].float() # (3, channels)
bias = model.head.bias[1:].float()
cells = features[batch_of, :, row_of, column_of].float() # (n, channels)
values = cells @ weight.T + bias # (n, 3)
for channel in range(values.shape[1]):
prediction[batch_of, channel + 1, row_of, column_of] = values[:, channel]
return prediction
def load_model(checkpoint_path):
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
train_conf = OmegaConf.create(checkpoint["conf"])
model = build_model(train_conf).cuda().eval()
model.load_state_dict(checkpoint["model"])
return model, train_conf, checkpoint
def prefetch(iterator, depth=4):
"""Overlap file reading with GPU work."""
output = queue.Queue(maxsize=depth)
def worker():
try:
for item in iterator:
output.put(item)
finally:
output.put(None)
threading.Thread(target=worker, daemon=True).start()
while True:
item = output.get()
if item is None:
return
yield item
def exposed_occupancy(density, fraction=0.5):
"""Occupancy inside the illuminated area only.
A knife-edge or slit run leaves most of the ROI in shadow, so an average
over the whole frame badly understates the flux the model actually sees:
on Edge_3filters_pos0_12keV it gives 0.16 % against the 0.67 % the
illuminated pixels really see.
"""
if density.size == 0 or density.max() <= 0:
return float("nan"), 0
reference = np.percentile(density, 99)
exposed = density > fraction * reference
if not exposed.any():
return float("nan"), 0
return float(density[exposed].mean()), int(exposed.sum())
def subpixel_histogram(x, y, bins=NEtaBins, chunk=50_000_000):
"""2D histogram of the sub-pixel phase of the reconstructed positions.
In slices: histogram2d stacks its input as float64 and adds int64 bin
indices, ~40 bytes per position, which ran out of memory on the 1e9-photon
flat fields. The histogram is additive, so the result is the same.
"""
histogram = np.zeros((bins, bins))
for start in range(0, len(x), chunk):
xs, ys = x[start:start + chunk], y[start:start + chunk]
histogram += np.histogram2d(xs - np.floor(xs), ys - np.floor(ys),
bins=bins, range=[[0, 1], [0, 1]])[0]
return histogram
def subpixel_nonuniformity(histogram):
"""Excess rms/mean over the Poisson floor; 0 means a flat sub-pixel phase."""
mean = histogram.mean()
if mean <= 0:
return float("nan")
relative = histogram.std() / mean
return float(np.sqrt(max(relative ** 2 - 1.0 / mean, 0.0)))
def save_subpixel_figure(output_dir, before, after, run_name):
panels = [("before flattening", before)]
if after is not None:
panels.append(("after flattening", after))
figure, axes = plt.subplots(1, len(panels) + 1,
figsize=(5.2 * (len(panels) + 1), 4.2))
for axis, (title, histogram) in zip(axes, panels):
image = axis.imshow(histogram.T / histogram.mean(), origin="lower",
extent=[0, 1, 0, 1], vmin=0.9, vmax=1.1, cmap="viridis")
axis.set_title(f"{title}\nexcess rms "
f"{subpixel_nonuniformity(histogram)*100:.2f} %", fontsize=10)
axis.set_xlabel("sub-pixel x")
axis.set_ylabel("sub-pixel y")
plt.colorbar(image, ax=axis, fraction=0.046)
axis = axes[-1]
for title, histogram in panels:
profile = histogram.sum(axis=1)
axis.plot(np.linspace(0, 1, len(profile)), profile / profile.mean(),
lw=1, label=title)
axis.axhline(1.0, color="k", lw=.6)
axis.set_xlabel("sub-pixel x")
axis.set_ylabel("relative density")
axis.legend(fontsize=8)
axis.grid(alpha=.3)
figure.suptitle(run_name)
figure.tight_layout()
figure.savefig(output_dir / "subpixel_phase.png", dpi=150)
plt.close(figure)
def save_images(output_dir, binned, native, analog, run_name, binning_factor,
pixel_size_um, halo):
"""Write the reconstruction and the analog picture as .npy and .png."""
np.save(output_dir / "binned_image.npy", binned)
np.save(output_dir / "native_image.npy", native)
np.save(output_dir / "analog_image.npy", analog)
bin_um = pixel_size_um / binning_factor
extent_binned = [0, binned.shape[1] * bin_um, 0, binned.shape[0] * bin_um]
extent_native = [0, native.shape[1] * pixel_size_um,
0, native.shape[0] * pixel_size_um]
def draw(axis, image, extent, title, unit):
finite = image[np.isfinite(image)]
high = np.percentile(finite, 99.5) if finite.size else 1.0
handle = axis.imshow(image, origin="lower", extent=extent, cmap="gray",
vmin=0, vmax=high if high > 0 else 1.0,
interpolation="nearest")
axis.set_title(title, fontsize=10)
axis.set_xlabel(r"x [$\mu$m]")
axis.set_ylabel(r"y [$\mu$m]")
plt.colorbar(handle, ax=axis, label=unit, fraction=0.046)
panels = (
(binned, extent_binned, "binned_image",
f"reconstruction, {bin_um:g} " + r"$\mu$m bins", "photons"),
(native, extent_native, "native_image",
f"reconstructed photons, {pixel_size_um:g} " + r"$\mu$m pitch", "photons"),
(analog, extent_native, "analog_image",
"native readout (mean energy per frame)", "keV/frame"),
)
for image, extent, name, title, unit in panels:
figure, axis = plt.subplots(figsize=(6.4, 5.4))
draw(axis, image, extent, f"{run_name}\n{title}", unit)
figure.tight_layout()
figure.savefig(output_dir / f"{name}.png", dpi=150)
plt.close(figure)
figure, axes = plt.subplots(1, 2, figsize=(12.4, 5.4))
draw(axes[0], analog, extent_native,
"native readout (mean energy per frame)", "keV/frame")
draw(axes[1], binned, extent_binned,
f"GridBased reconstruction, {bin_um:g} " + r"$\mu$m bins", "photons")
figure.suptitle(f"{run_name} (valid region = frame minus halo R={halo})")
figure.tight_layout()
figure.savefig(output_dir / "comparison.png", dpi=150)
plt.close(figure)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--run", required=True)
parser.add_argument("--n-frames", type=int, default=None)
parser.add_argument("--checkpoint", default=None)
parser.add_argument("--tag", default=None)
parser.add_argument("--save-photons", action="store_true",
help="write photons.npy (x, y, E, p) even if the config says no")
args = parser.parse_args()
conf = OmegaConf.load(args.config)
if args.run not in conf.runs:
sys.exit(f"unknown run {args.run!r}; have {list(conf.runs.keys())}")
run_conf = conf.runs[args.run]
# the stored frame is n_x x n_y; detector.crop = [x0, x1, y0, y1] reads and
# reconstructs only that window of it (the halo R is lost at its border, so
# the crop should reach R pixels past the region of interest)
frame_n_x, frame_n_y = int(conf.detector.n_x), int(conf.detector.n_y)
crop = conf.detector.get("crop", None)
crop = None if crop is None else [int(v) for v in crop]
if crop is None:
n_x, n_y, crop_x0, crop_y0 = frame_n_x, frame_n_y, 0, 0
else:
n_x, n_y, crop_x0, crop_y0 = crop[1] - crop[0], crop[3] - crop[2], crop[0], crop[2]
pixel_size_um = float(conf.detector.pixel_size_um)
checkpoint_path = args.checkpoint or conf.model.checkpoint
model, train_conf, checkpoint = load_model(checkpoint_path)
precision = str(conf.inference.get("precision", "fp32"))
if precision not in ("fp32", "fp16"):
sys.exit(f"unknown inference.precision {precision!r}")
if precision == "fp16":
model = model.to(memory_format=torch.channels_last)
torch.backends.cudnn.benchmark = True
halo = model.rf_radius
input_scale = float(train_conf.data.input_scale)
energy_scale = float(train_conf.data.energy_scale)
print(f"[infer] run {args.run}")
print(f"[infer] model {checkpoint_path} (epoch {checkpoint['epoch']}), "
f"R={halo} -> valid region {n_x-2*halo}x{n_y-2*halo} of {n_x}x{n_y}")
first_file, last_file = run_conf.signal_files
file_step = int(run_conf.get("signal_file_step", 1))
signal_files = [run_conf.signal_pattern.format(i)
for i in range(int(first_file), int(last_file) + 1, file_step)]
reader = RawFrameReader(signal_files, frame_n_x, frame_n_y,
header_words=int(conf.detector.header_words), crop=crop)
n_frames = args.n_frames or conf.inference.n_frames or reader.n_frames_available
n_frames = min(int(n_frames), reader.n_frames_available)
corner = (int(conf.detector.corner[0]) + crop_x0, int(conf.detector.corner[1]) + crop_y0)
calibrator = GpuCalibrator(conf.calibration.file, n_x, n_y, corner=corner,
common_mode=conf.calibration.common_mode)
if conf.calibration.pedestal_source == "pedestal_run":
pedestal_reader = RawFrameReader([run_conf.pedestal], frame_n_x, frame_n_y,
header_words=int(conf.detector.header_words),
crop=crop)
pedestal = estimate_pedestal(pedestal_reader,
n_frames=int(conf.calibration.pedestal_frames))
elif conf.calibration.pedestal_source == "per_file":
pedestal = estimate_pedestal(reader,
n_frames=int(conf.calibration.pedestal_frames))
else:
sys.exit(f"unknown pedestal_source {conf.calibration.pedestal_source!r}")
calibrator.set_pedestal(pedestal)
print(f"[infer] pedestal from {conf.calibration.pedestal_source}, "
f"common mode '{conf.calibration.common_mode}', "
f"{n_frames} frames of {reader.n_frames_available} "
f"({len(signal_files)} files, step {file_step})")
if crop is not None:
print(f"[infer] crop x {crop[0]}-{crop[1]}, y {crop[2]}-{crop[3]} of the "
f"{frame_n_x}x{frame_n_y} frame -> {n_x}x{n_y}, valid region "
f"x {crop[0]+halo}-{crop[1]-halo}, y {crop[2]+halo}-{crop[3]-halo}")
binning_factor = int(conf.inference.binning_factor)
accumulator = ImageAccumulator(n_x, n_y, binning_factor=binning_factor, halo=halo)
energy_sum = torch.zeros((n_y, n_x), dtype=torch.float64, device="cuda")
energy_low, energy_high, energy_n = conf.inference.energy_bins
energy_edges = np.linspace(float(energy_low), float(energy_high), int(energy_n) + 1)
energy_histogram = np.zeros(int(energy_n), np.int64)
photons_per_frame = []
save_photons = args.save_photons or bool(conf.inference.save_photons)
photon_dump = [] if save_photons else None
# self_flattening: true keeps every position and remaps it (the published
# runs); "streaming" histograms them on the GPU instead (src/flattening.py),
# for runs whose photon list would not fit in memory
flattening_mode = conf.inference.get("self_flattening", False)
streaming = str(flattening_mode).lower() == "streaming"
self_flattening = bool(flattening_mode) and not streaming
position_dump = [] if self_flattening else None
occupancy_edges = conf.inference.get("flattening_occupancy_edges", None)
flattener = (StreamingFlattener(
n_x, n_y, binning_factor, halo=halo,
lut_photons=int(conf.inference.get("flattening_lut_photons", 100_000_000)),
occupancy_edges=(None if occupancy_edges is None
else [float(e) for e in occupancy_edges]))
if streaming else None)
output_dir = Path(conf.inference.output_folder) / (
args.run + (f"_{args.tag}" if args.tag else ""))
output_dir.mkdir(parents=True, exist_ok=True)
batch_size = int(conf.inference.batch_size)
threshold = float(conf.model.threshold)
merge_distance = float(conf.model.merge_distance)
started = time.time()
n_done = 0
batches = reader.batches(batch_size, n_frames=n_frames)
with torch.no_grad():
for file_index, frame_index, adu_frames in prefetch(batches):
if len(adu_frames) == 0:
continue
energy_frames = calibrator.to_energy(torch.from_numpy(adu_frames).cuda())
energy_sum += energy_frames.sum(dim=0, dtype=torch.float64)
inputs = (energy_frames / input_scale).unsqueeze(1)
if precision == "fp16":
prediction = predict_fp16(model, inputs, threshold)
else:
prediction = model(inputs).float()
points_per_frame = decode(prediction, threshold=threshold,
merge_distance=merge_distance, halo=halo,
predict_energy=bool(train_conf.model.predict_energy),
energy_scale=energy_scale)
points_per_frame = [p.numpy() for p in points_per_frame]
accumulator.add(points_per_frame)
photons_per_frame.extend(len(p) for p in points_per_frame)
batch_energies = [p[:, 2] for p in points_per_frame if len(p)]
if batch_energies:
energy_histogram += np.histogram(np.concatenate(batch_energies),
bins=energy_edges)[0]
if photon_dump is not None:
photon_dump.extend(points_per_frame)
if position_dump is not None or flattener is not None:
# one array per batch, not per frame: 14 M small arrays would
# cost more in list overhead than the positions themselves
batch_points = [p[:, :2] for p in points_per_frame if len(p)]
if batch_points:
batch_points = np.concatenate(batch_points).astype(np.float32)
if flattener is not None:
flattener.add(batch_points, n_frames=len(adu_frames))
else:
position_dump.append(batch_points)
n_done += len(adu_frames)
if n_done % (batch_size * 200) < batch_size:
elapsed = time.time() - started
rate = n_done / elapsed
print(f" {n_done}/{n_frames} frames {rate:.0f} frame/s "
f"eta {(n_frames-n_done)/rate/60:.1f} min "
f"<photons/frame> {np.mean(photons_per_frame):.2f}", flush=True)
elapsed = time.time() - started
photons_per_frame = np.asarray(photons_per_frame)
binned = accumulator.image_2d
native = binned.reshape(n_y, binning_factor, n_x, binning_factor).sum(axis=(1, 3))
analog = (energy_sum / max(n_done, 1)).cpu().numpy()
phase_before = phase_after = None
flattening_classes = None
if self_flattening and position_dump:
positions = np.concatenate(position_dump)
position_dump.clear()
phase_before = subpixel_histogram(positions[:, 0], positions[:, 1])
print(f"[infer] self-flattening {len(positions)} positions, sub-pixel "
f"excess rms {subpixel_nonuniformity(phase_before)*100:.2f} %", flush=True)
corrected = interpolate_eta_from_points(positions)
phase_after = subpixel_histogram(corrected[:, 0], corrected[:, 1])
print(f"[infer] after flattening, sub-pixel excess rms "
f"{subpixel_nonuniformity(phase_after)*100:.2f} %", flush=True)
flattened_accumulator = ImageAccumulator(n_x, n_y,
binning_factor=binning_factor,
halo=halo)
# in slices: one call on 1e9 positions builds three int64 index arrays
# of the full length (~28 GB at the 0-filter flat field)
for start in range(0, len(corrected), 50_000_000):
flattened_accumulator.add([corrected[start:start + 50_000_000]])
np.save(output_dir / "binned_image_flattened.npy",
flattened_accumulator.image_2d)
np.save(output_dir / "subpixel_phase_before.npy", phase_before)
np.save(output_dir / "subpixel_phase_after.npy", phase_after)
save_subpixel_figure(output_dir, phase_before, phase_after, args.run)
del positions, corrected, flattened_accumulator
if flattener is not None and flattener.n_photons:
flattened, phase_before, phase_after = flattener.finalize()
print(f"[infer] streaming self-flattening of {flattener.n_photons} positions "
f"(table from the first {flattener.table_photons}): sub-pixel excess rms "
f"{subpixel_nonuniformity(phase_before)*100:.2f} % -> "
f"{subpixel_nonuniformity(phase_after)*100:.2f} %", flush=True)
for c in flattener.classes:
print(f"[infer] occupancy {c['occupancy_from']*100:5.2f}-{c['occupancy_to']*100:6.2f} %: "
f"{c['pixels']:6d} px, table from {c['table_photons']} photons", flush=True)
flattening_classes = flattener.classes
np.save(output_dir / "binned_image_flattened.npy", flattened)
np.save(output_dir / "subpixel_phase_before.npy", phase_before)
np.save(output_dir / "subpixel_phase_after.npy", phase_after)
save_subpixel_figure(output_dir, phase_before, phase_after, args.run)
del flattened, flattener
valid = (slice(halo, n_y - halo), slice(halo, n_x - halo))
density = native[valid] / max(n_done, 1) # photons per pixel per frame
occupancy_exposed, n_exposed = exposed_occupancy(density)
n_valid_pixels = (n_x - 2 * halo) * (n_y - 2 * halo)
save_images(output_dir, binned, native, analog, args.run, binning_factor,
pixel_size_um, halo)
np.save(output_dir / "energy_spectrum.npy",
np.stack([0.5 * (energy_edges[:-1] + energy_edges[1:]),
energy_histogram.astype(float)]))
np.save(output_dir / "photons_per_frame.npy", photons_per_frame)
if photon_dump is not None:
np.save(output_dir / "photons.npy", np.concatenate(photon_dump))
summary = dict(
run=args.run, checkpoint=str(checkpoint_path), epoch=int(checkpoint["epoch"]),
n_frames=int(n_done), n_photons=int(accumulator.n_photons),
photons_per_frame=float(photons_per_frame.mean()),
occupancy_valid_region=float(photons_per_frame.mean() / n_valid_pixels),
occupancy_exposed=occupancy_exposed,
exposed_pixels=n_exposed, valid_pixels=n_valid_pixels,
threshold=threshold, merge_distance=merge_distance, halo=halo,
common_mode=str(conf.calibration.common_mode),
pedestal_source=str(conf.calibration.pedestal_source),
binning_factor=binning_factor,
crop=crop, frame_size=[frame_n_x, frame_n_y],
signal_files=[int(first_file), int(last_file), file_step],
self_flattening="streaming" if streaming else self_flattening,
flattening_classes=flattening_classes,
precision=precision,
subpixel_nonuniformity_before=(subpixel_nonuniformity(phase_before)
if phase_before is not None else None),
subpixel_nonuniformity_after=(subpixel_nonuniformity(phase_after)
if phase_after is not None else None),
seconds=elapsed, frames_per_second=float(n_done / elapsed),
)
with open(output_dir / "summary.json", "w") as handle:
json.dump(summary, handle, indent=1)
print(f"[infer] {n_done} frames in {elapsed:.0f}s "
f"({n_done/elapsed:.0f} frame/s), {accumulator.n_photons} photons, "
f"{summary['photons_per_frame']:.2f}/frame")
print(f"[infer] occupancy: {summary['occupancy_valid_region']*100:.3f}% averaged "
f"over the valid region, {occupancy_exposed*100:.3f}% inside the "
f"illuminated area ({n_exposed} of {n_valid_pixels} px)")
print(f"[infer] wrote {output_dir}")
if __name__ == "__main__":
main()
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
WP4 -- baseline comparison on identical MC frames (gate G2).
conda activate mlxid_demo # this env has both torch and aare
python scripts/06_baseline_comparison.py \
--checkpoint Results/<exp>/Models/E100.pth --n-frames 4000
Three reconstruction chains are run over the *same* simulated frames and scored
against the same truth with the same matcher:
grid GridNet + position-space decode
cluster+dl aare VarClusterFinder -> cluster energy picks the multiplicity
-> the pretrained 1/2/3-photon CNNs on 3x3 / 6x6 / 9x9 crops
cluster+eta the same cluster finder, 1-photon clusters only, classical eta
interpolation with a Rosenblatt LUT fitted on the same events
Cluster cropping, energy windows and coordinate conventions follow
``DataProcess/UsefulFuncs.py::_processFrames``; the CNN input conventions follow
``DeepLearning/src/datasets.py``. Every chain is scored on the same valid
region (frame minus the grid model's halo), so the numbers are comparable.
"""
import argparse
import importlib.util
import json
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from omegaconf import OmegaConf
HERE = Path(__file__).resolve().parent
# GridBased/src and MLXIDDemo/src both define a `models` module. MLXIDDemo's
# model_zoo does `from models import ...`, so that name is left to the demo and
# the GridBased one is loaded by path under its own name.
sys.path.insert(0, str(HERE.parent / "src"))
sys.path.insert(0, str(HERE.parent.parent / "MLXIDDemo" / "src"))
sys.path.append("/home/xie_x1/software/aareBuild")
import aare # noqa: E402
from decode import decode # noqa: E402
from eta_interpolation_functions import (bilinear_xy_lookup, # noqa: E402
build_xy_lut_Rosenblatt)
from metrics import DetectionStats # noqa: E402
from model_zoo import load_pretrained # noqa: E402
def _load_by_path(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
build_model = _load_by_path("grid_models", HERE.parent / "src" / "models.py").build_model
CLUSTER_SIZE = {1: 3, 2: 6, 3: 9}
ETA_BINS = 201
ETA_POSITION_WEIGHTS = np.array([-1.0, 0.0, 1.0]) + 0.5
def coordinate_channels(size):
"""The two coordinate planes singlePhotonDataset appends to its input."""
axis = np.linspace(-0.5, 0.5, size)
x_grid, y_grid = np.meshgrid(axis, axis, indexing="ij")
return torch.from_numpy(np.stack([x_grid, y_grid])).float()
def find_clusters(frames, noise_keV, threshold_sigma=5, peripheral_factor=3,
n_neighbours=4):
"""Run the variable cluster finder frame by frame.
Returns a list per frame of dicts with the seed pixel, the charge-weighted
centre and the cluster energy.
"""
frame_size = frames.shape[-1]
noise_map = np.ones((frame_size, frame_size)) * noise_keV
def new_finder():
# A finder is built per frame. Reusing one across frames is what
# DataProcess/UsefulFuncs._processFrames does and is fine at its
# 101x101 ROI, but on these 32x32 frames the internal hit buffer
# overflows after a few frames and hits() starts returning stale
# entries (and can abort the process).
finder = aare.VarClusterFinder((frame_size, frame_size), threshold_sigma)
finder.set_peripheralThresholdFactor(peripheral_factor)
finder.set_noiseMap(noise_map)
finder.set_numberOfNeighbours(n_neighbours)
finder.set_empty_surroundingPixels(False)
return finder
per_frame = []
for frame in frames:
finder = new_finder()
working_copy = np.array(frame, dtype=np.float64) # the finder consumes it
finder.find_clusters_X(working_copy)
hits = finder.hits()
clusters = []
for index in range(len(hits)):
size = hits["size"][index]
energies = hits["enes"][index][:size]
total = energies.sum()
if total <= 0:
continue
clusters.append(dict(
energy=float(hits["energy"][index]),
seed_col=int(hits["col"][index]), seed_row=int(hits["row"][index]),
centre_col=float((hits["cols"][index][:size] * energies).sum() / total),
centre_row=float((hits["rows"][index][:size] * energies).sum() / total),
))
per_frame.append(clusters)
return per_frame
def collect_crops(frames, clusters_per_frame, beam_energy, selection_range):
"""Sort clusters into 1/2/3-photon crops, following UsefulFuncs._processFrames."""
frame_size = frames.shape[-1]
crops = {n: [] for n in CLUSTER_SIZE}
origins = {n: [] for n in CLUSTER_SIZE} # (frame_index, ref_col, ref_row)
for frame_index, clusters in enumerate(clusters_per_frame):
for cluster in clusters:
energy = cluster["energy"]
for n_photons, size in CLUSTER_SIZE.items():
window = n_photons * selection_range
if not (n_photons * beam_energy - window < energy
< n_photons * beam_energy + window):
continue
if n_photons == 1:
ref_col = cluster["seed_col"] - size // 2
ref_row = cluster["seed_row"] - size // 2
else:
ref_col = int(cluster["centre_col"] - size / 2) + 1
ref_row = int(cluster["centre_row"] - size / 2) + 1
if (ref_col < 0 or ref_col + size > frame_size
or ref_row < 0 or ref_row + size > frame_size):
continue
crops[n_photons].append(
frames[frame_index, ref_row:ref_row + size, ref_col:ref_col + size])
origins[n_photons].append((frame_index, ref_col, ref_row))
return ({n: (np.asarray(v, np.float32) if v else np.zeros((0, CLUSTER_SIZE[n],
CLUSTER_SIZE[n]), np.float32))
for n, v in crops.items()},
{n: (np.asarray(v, np.int64) if v else np.zeros((0, 3), np.int64))
for n, v in origins.items()})
@torch.no_grad()
def cluster_dl_points(crops, origins, n_frames, device="cuda", batch_size=8192):
"""Run the pretrained 1/2/3-photon CNNs -> per-frame photon lists."""
points = [[] for _ in range(n_frames)]
for n_photons, size in CLUSTER_SIZE.items():
sample = crops[n_photons]
if len(sample) == 0:
continue
model = load_pretrained(n_photons, device)
centre = size / 2.0
extra = coordinate_channels(size).to(device) if n_photons == 1 else None
for start in range(0, len(sample), batch_size):
batch = torch.from_numpy(sample[start:start + batch_size]).to(device)
batch = batch.unsqueeze(1)
if extra is not None:
batch = torch.cat([batch, extra.expand(batch.shape[0], -1, -1, -1)], dim=1)
output = model(batch).cpu().numpy().reshape(len(batch), n_photons, 2)
for row, (frame_index, ref_col, ref_row) in enumerate(
origins[n_photons][start:start + batch_size]):
for photon in range(n_photons):
points[frame_index].append((
ref_col + centre + output[row, photon, 0],
ref_row + centre + output[row, photon, 1],
float(n_photons and 12.0), 1.0))
del model
torch.cuda.empty_cache()
return [np.asarray(p, np.float64).reshape(-1, 4) for p in points]
def cluster_eta_points(crops, origins, n_frames):
"""Classical eta interpolation on the 1-photon clusters."""
sample = crops[1]
points = [[] for _ in range(n_frames)]
if len(sample) == 0:
return [np.zeros((0, 4)) for _ in range(n_frames)]
totals = sample.sum(axis=(1, 2))
totals[totals == 0] = 1.0
eta_x = np.clip((sample.sum(axis=1) * ETA_POSITION_WEIGHTS).sum(axis=-1) / totals, 0, 1)
eta_y = np.clip((sample.sum(axis=2) * ETA_POSITION_WEIGHTS).sum(axis=-1) / totals, 0, 1)
histogram, _, _ = np.histogram2d(eta_x, eta_y, bins=ETA_BINS, range=[[0, 1], [0, 1]])
x_table, y_table = build_xy_lut_Rosenblatt(histogram)
x_fraction, y_fraction = bilinear_xy_lookup(eta_x, eta_y, x_table, y_table)
for row, (frame_index, ref_col, ref_row) in enumerate(origins[1]):
points[frame_index].append((ref_col + 1.0 + x_fraction[row],
ref_row + 1.0 + y_fraction[row], 12.0, 1.0))
return [np.asarray(p, np.float64).reshape(-1, 4) for p in points]
@torch.no_grad()
def grid_points(model, frames, train_conf, halo, threshold, merge_distance,
batch_size=512):
input_scale = float(train_conf.data.input_scale)
energy_scale = float(train_conf.data.energy_scale)
points = []
for start in range(0, len(frames), batch_size):
batch = torch.from_numpy(frames[start:start + batch_size]).cuda().unsqueeze(1)
prediction = model(batch / input_scale).float()
points.extend(p.numpy() for p in decode(
prediction, threshold=threshold, merge_distance=merge_distance,
halo=halo, predict_energy=bool(train_conf.model.predict_energy),
energy_scale=energy_scale))
return points
def score(points_per_frame, truth_per_frame, halo, frame_size, match_radius,
pixel_size_um):
stats = DetectionStats(pixel_size_um=pixel_size_um, match_radius=match_radius,
halo=halo, frame_size=frame_size)
stats.update(points_per_frame, truth_per_frame)
return stats.summary()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--checkpoint", required=True)
parser.add_argument("--data", default=None, help="eval_scan folder")
parser.add_argument("--n-frames", type=int, default=4000)
parser.add_argument("--beam-energy", type=float, default=12.0)
parser.add_argument("--selection-range", type=float, default=3.0)
parser.add_argument("--outdir", default=None)
args = parser.parse_args()
import h5py
checkpoint = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
train_conf = OmegaConf.create(checkpoint["conf"])
model = build_model(train_conf).cuda().eval()
model.load_state_dict(checkpoint["model"])
halo = model.rf_radius
threshold = float(train_conf.eval.threshold)
merge_distance = float(train_conf.eval.merge_distance)
match_radius = float(train_conf.eval.match_radius)
pixel_size_um = float(train_conf.eval.pixel_size_um)
noise_keV = 0.13
print(f"[baseline] grid model {args.checkpoint} (epoch {checkpoint['epoch']}), "
f"R={halo}, threshold {threshold}, merge {merge_distance} px")
data_folder = Path(args.data or Path(train_conf.data.folder) / "eval_scan")
files = sorted(data_folder.glob("*.h5"),
key=lambda p: float(p.stem.replace("occ", "").replace("pct", "")
.replace("p", ".")))
output_dir = Path(args.outdir or Path(args.checkpoint).parent.parent)
(output_dir / "Plots").mkdir(parents=True, exist_ok=True)
results = {}
for path in files:
occupancy = float(path.stem.replace("occ", "").replace("pct", "")
.replace("p", ".")) / 100
with h5py.File(path, "r") as handle:
frames = handle["frames"][:args.n_frames].astype(np.float32)
pointer = handle["frame_ptr"][:args.n_frames + 1]
photons = handle["photons"][:pointer[-1]]
truth = [photons[pointer[i]:pointer[i + 1]] for i in range(len(frames))]
frame_size = frames.shape[-1]
clusters_per_frame = find_clusters(frames.astype(np.float64), noise_keV)
crops, origins = collect_crops(frames, clusters_per_frame,
args.beam_energy, args.selection_range)
chains = {
"grid": grid_points(model, frames, train_conf, halo, threshold,
merge_distance),
"cluster+dl": cluster_dl_points(crops, origins, len(frames)),
"cluster+eta": cluster_eta_points(crops, origins, len(frames)),
}
results[f"{occupancy*100:g}%"] = dict(
occupancy=occupancy,
n_clusters={str(n): int(len(crops[n])) for n in CLUSTER_SIZE},
**{name: score(pts, truth, halo, frame_size, match_radius, pixel_size_um)
for name, pts in chains.items()})
print(f"\n occupancy {occupancy*100:g}% ({len(frames)} frames, clusters "
f"1ph={len(crops[1])} 2ph={len(crops[2])} 3ph={len(crops[3])})")
print(f" {'chain':12s} {'eff%':>7} {'pur%':>7} {'RMS um':>8} {'s68 um':>8}")
for name in chains:
s = results[f"{occupancy*100:g}%"][name]
print(f" {name:12s} {s['efficiency']*100:7.2f} {s['purity']*100:7.2f} "
f"{s['rms_1d_um']:8.3f} {s['sigma68_1d_um']:8.3f}", flush=True)
with open(output_dir / "baseline_comparison.json", "w") as handle:
json.dump(results, handle, indent=1)
plot(results, output_dir, checkpoint["epoch"])
print(f"\n[baseline] wrote {output_dir/'baseline_comparison.json'}")
def plot(results, output_dir, epoch):
occupancy = np.array([r["occupancy"] for r in results.values()]) * 100
figure, axes = plt.subplots(1, 3, figsize=(15, 4.4))
styles = {"grid": ("o-", "#10695f"), "cluster+dl": ("s--", "#4b2a6b"),
"cluster+eta": ("^:", "#a0451c")}
for name, (style, colour) in styles.items():
efficiency = np.array([r[name]["efficiency"] for r in results.values()]) * 100
purity = np.array([r[name]["purity"] for r in results.values()]) * 100
rms = np.array([r[name]["rms_1d_um"] for r in results.values()])
axes[0].plot(occupancy, efficiency, style, color=colour, label=name)
axes[1].plot(occupancy, purity, style, color=colour, label=name)
axes[2].plot(occupancy, rms, style, color=colour, label=name)
for axis, label in zip(axes, ("efficiency [%]", "purity [%]",
r"position RMS [$\mu$m]")):
axis.set_xlabel("occupancy [%]")
axis.set_ylabel(label)
axis.set_xscale("log")
axis.grid(alpha=.3)
axis.legend()
figure.suptitle(f"same MC frames, same matcher (grid model at epoch {epoch})")
figure.tight_layout()
figure.savefig(output_dir / "Plots" / "baseline_comparison.png", dpi=130)
plt.close(figure)
if __name__ == "__main__":
main()
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
WP4/5.3 -- MC energy response of a polychromatic grid model.
python scripts/07_energy_response.py \
--checkpoint Results/260901_poly5to30keV_Moench014_00/Models/best.pth
The energy head is a regression trained on a flat 5-30 keV spectrum, so it
returns something close to the posterior mean: every prediction is pulled
towards the centre of the training range, most strongly within ~1 keV of the
edges where the prior is one-sided. A spectral line at E therefore shows up at
g(E) != E. This script measures g on MC in fine true-energy bins -- the
position of the Gaussian core of E_pred, i.e. what a line fit on measured data
returns -- together with the core width and the detection efficiency, and
writes
Results/<exp>/energy_response.npz per-bin table + all matched pairs
Results/<exp>/Plots/energy_response.png
The correction applied to measured photons is E_corr = g^-1(E_pred) (see
src/xrf_analysis.py: EnergyCorrection). Inverting the forward curve, rather
than regressing E_true on E_pred, is what puts a line back at its energy: the
reverse regression is the posterior mean again and would shrink a second time.
Only frames with occupancy <= --max-occupancy are used, so that the response
matches the low-flux XRF runs it is applied to (0.7-1.2 %).
"""
import argparse
import glob
import os
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from omegaconf import OmegaConf
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "src"))
from decode import decode # noqa: E402
from metrics import greedy_match # noqa: E402
from models import build_model # noqa: E402
from xrf_analysis import fit_gaussian_core # noqa: E402
DEFAULT_DATA = [
"{folder}/eval_scan/occ0p5pct.h5",
"{folder}/eval_scan/occ1pct.h5",
"{folder}/eval_scan/occ2pct.h5",
"{folder}/test/shard_00.h5",
"{folder}/test/shard_01.h5",
]
def load_frames(path, max_occupancy):
import h5py
with h5py.File(path, "r") as handle:
occupancy = handle["occupancy"][:]
keep = np.nonzero(occupancy <= max_occupancy)[0]
frames = handle["frames"][:][keep]
photons = handle["photons"][:]
pointer = handle["frame_ptr"][:]
truth = [photons[pointer[i]:pointer[i + 1]] for i in keep]
return frames, truth, occupancy[keep]
@torch.no_grad()
def collect(model, conf, files, halo, max_occupancy, batch_size=1024):
"""Matched (E_true, E_pred) pairs, and every valid truth photon with a flag."""
input_scale = float(conf.data.input_scale)
energy_scale = float(conf.data.energy_scale)
threshold = float(conf.eval.threshold)
merge_distance = float(conf.eval.merge_distance)
match_radius = float(conf.eval.match_radius)
e_true, e_pred, truth_energy, truth_found, fake_energy = [], [], [], [], []
n_frames = 0
for path in files:
frames, truth, occupancy = load_frames(path, max_occupancy)
size = frames.shape[-1]
print(f"[response] {os.path.basename(path)}: {len(frames)} frames "
f"with occupancy <= {max_occupancy*100:.1f} %", flush=True)
n_frames += len(frames)
for start in range(0, len(frames), batch_size):
batch = torch.from_numpy(frames[start:start + batch_size] / input_scale)
prediction = model(batch.float().unsqueeze(1).cuda()).float()
points = decode(prediction, threshold=threshold,
merge_distance=merge_distance, halo=halo,
predict_energy=True, energy_scale=energy_scale)
for predicted, true in zip(points, truth[start:start + batch_size]):
predicted = predicted.numpy().astype(np.float64)
inside = ((true[:, 0] >= halo) & (true[:, 0] < size - halo) &
(true[:, 1] >= halo) & (true[:, 1] < size - halo))
true = true[inside].astype(np.float64)
found = np.zeros(len(true), bool)
matched_prediction = np.zeros(len(predicted), bool)
if len(true) and len(predicted):
index_pred, index_true = greedy_match(predicted[:, :2], true[:, :2],
match_radius)
found[index_true] = True
matched_prediction[index_pred] = True
e_true.append(true[index_true, 2])
e_pred.append(predicted[index_pred, 2])
truth_energy.append(true[:, 2])
truth_found.append(found)
fake_energy.append(predicted[~matched_prediction, 2])
return (np.concatenate(e_true), np.concatenate(e_pred),
np.concatenate(truth_energy), np.concatenate(truth_found),
np.concatenate(fake_energy), n_frames)
def response_table(e_true, e_pred, truth_energy, truth_found, edges):
rows = {key: [] for key in ("centre", "n", "efficiency", "median", "mean",
"core_mu", "core_mu_err", "core_sigma",
"core_sigma_err", "sigma68", "rms")}
for low, high in zip(edges[:-1], edges[1:]):
selected = (e_true >= low) & (e_true < high)
in_bin = (truth_energy >= low) & (truth_energy < high)
residual_reference = e_true[selected]
predicted = e_pred[selected]
core = fit_gaussian_core(predicted)
rows["centre"].append(0.5 * (low + high))
rows["n"].append(int(selected.sum()))
rows["efficiency"].append(float(truth_found[in_bin].mean()) if in_bin.any()
else np.nan)
rows["median"].append(float(np.median(predicted)) if len(predicted) else np.nan)
rows["mean"].append(float(predicted.mean()) if len(predicted) else np.nan)
rows["core_mu"].append(core["mu"])
rows["core_mu_err"].append(core["mu_err"])
rows["core_sigma"].append(core["sigma"])
rows["core_sigma_err"].append(core["sigma_err"])
residual = predicted - residual_reference
rows["sigma68"].append(float(np.percentile(np.abs(residual - np.median(residual)),
68.27)) if len(residual) else np.nan)
rows["rms"].append(float(residual.std()) if len(residual) else np.nan)
return {key: np.asarray(value) for key, value in rows.items()}
def plot(table, e_true, e_pred, output_path, title):
centre = table["centre"]
low, high = float(np.floor(e_true.min())), float(np.ceil(e_true.max()))
figure, axes = plt.subplots(2, 2, figsize=(12, 8.5))
axis = axes[0, 0]
histogram, x_edges, y_edges = np.histogram2d(
e_true, e_pred, bins=[int(5 * (high - low)), int(5 * (high - low + 5))],
range=[[low, high], [low - 2.5, high + 2.5]])
axis.imshow(np.log10(histogram.T + 1), origin="lower", aspect="auto",
extent=[x_edges[0], x_edges[-1], y_edges[0], y_edges[-1]],
cmap="viridis")
axis.plot([low, high], [low, high], "w--", lw=.8)
axis.set_xlabel("E_true [keV]")
axis.set_ylabel("E_pred [keV]")
axis.set_title("matched photons, log10 counts")
axis = axes[0, 1]
axis.errorbar(centre, table["core_mu"] - centre, table["core_mu_err"], fmt=".",
ms=3, label="Gaussian core position")
axis.plot(centre, table["median"] - centre, lw=1, label="median")
axis.plot(centre, table["mean"] - centre, lw=1, label="mean")
axis.axhline(0, color="k", lw=.6)
axis.set_xlabel("E_true [keV]")
axis.set_ylabel("E_pred - E_true [keV]")
axis.set_title("g(E) - E: pull towards the centre")
axis.legend(fontsize=8)
axis.grid(alpha=.3)
axis = axes[1, 0]
axis.plot(centre, table["core_sigma"], ".", ms=4, label=r"core $\sigma$")
axis.plot(centre, table["sigma68"], lw=1, label=r"$\sigma_{68}$ of residual")
axis.plot(centre, table["rms"], lw=1, label="rms of residual")
axis.set_xlabel("E_true [keV]")
axis.set_ylabel("keV")
axis.set_ylim(0, 1.6)
axis.set_title("energy resolution")
axis.legend(fontsize=8)
axis.grid(alpha=.3)
axis = axes[1, 1]
axis.plot(centre, table["efficiency"] * 100, ".", ms=4)
axis.set_xlabel("E_true [keV]")
axis.set_ylabel("efficiency [%]")
axis.set_title("detection efficiency")
axis.grid(alpha=.3)
figure.suptitle(title)
figure.tight_layout()
figure.savefig(output_path, dpi=130)
plt.close(figure)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--checkpoint", required=True)
parser.add_argument("--data", nargs="*", default=None)
parser.add_argument("--max-occupancy", type=float, default=0.025)
parser.add_argument("--bin-width", type=float, default=0.25)
args = parser.parse_args()
checkpoint = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
conf = OmegaConf.create(checkpoint["conf"])
model = build_model(conf).cuda().eval()
model.load_state_dict(checkpoint["model"])
halo = model.rf_radius
files = args.data or [pattern.format(folder=conf.data.folder)
for pattern in DEFAULT_DATA]
files = [f for pattern in files for f in sorted(glob.glob(pattern))]
e_true, e_pred, truth_energy, truth_found, fake_energy, n_frames = collect(
model, conf, files, halo, args.max_occupancy)
# the true-energy range of the MC set: 5-30 keV or 3-30 keV
edges = np.arange(np.floor(truth_energy.min()), np.ceil(truth_energy.max()) + 1e-9,
args.bin_width)
table = response_table(e_true, e_pred, truth_energy, truth_found, edges)
experiment_dir = Path(args.checkpoint).resolve().parent.parent
output = experiment_dir / "energy_response.npz"
np.savez_compressed(
output, edges=edges, **{f"table_{k}": v for k, v in table.items()},
e_true=e_true.astype(np.float32), e_pred=e_pred.astype(np.float32),
truth_energy=truth_energy.astype(np.float32), truth_found=truth_found,
fake_energy=fake_energy.astype(np.float32),
checkpoint=str(Path(args.checkpoint).resolve()), epoch=int(checkpoint["epoch"]),
max_occupancy=args.max_occupancy, n_frames=n_frames,
files=np.array([str(f) for f in files]))
(experiment_dir / "Plots").mkdir(exist_ok=True)
plot(table, e_true, e_pred, experiment_dir / "Plots" / "energy_response.png",
f"{experiment_dir.name} (epoch {checkpoint['epoch']}), "
f"occupancy <= {args.max_occupancy*100:.1f} %, {len(e_true)} matched photons")
print(f"[response] {n_frames} frames, {len(truth_energy)} truth photons, "
f"{len(e_true)} matched, {len(fake_energy)} unmatched predictions")
print(f"[response] efficiency {truth_found.mean()*100:.2f} %")
for energy in [e for e in (3.5, 4.5, 5.5, 7.0, 8.0, 12.0, 17.5, 22.0, 26.0, 29.5)
if edges[0] < e < edges[-1]]:
i = np.searchsorted(edges, energy) - 1
print(f" E={table['centre'][i]:6.3f} g-E={table['core_mu'][i]-table['centre'][i]:+.3f}"
f" core sigma={table['core_sigma'][i]:.3f} eff={table['efficiency'][i]*100:.2f} %")
print(f"[response] wrote {output}")
if __name__ == "__main__":
main()
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""
Compare poly GridNet models: MC metrics, energy response, XRF lines, efficiency
against the classical 3x3 sum, fake rate and speed, in one report.
python scripts/08_compare_models.py --out Results/<dir>/report \\
--model "5-30 keV [32,64,128,128]" Results/260901_poly5to30keV_Moench014_00 - \\
--model "3-30 keV [32,64,96,96]" Results/<exp> c96
Each model is (label, experiment dir, XRF tag). The tag is the --tag its XRF
runs were reconstructed with (`-` for none), so the reconstructions are read
from <inference.output_folder>/<run>[_<tag>]. Expected inputs per model:
eval_summary.json and energy_response.npz in the experiment dir (04, 07), and
AgMo_1_factor_10, CuCrCo_1_factor_10 and Pedestal_CuCrCo_1 reconstructions (05).
Whatever is missing shows up as n/a, with the reason listed at the end.
Writes report.md, report.json and report_response.png.
"""
import argparse
import json
import sys
import time
import traceback
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from omegaconf import OmegaConf
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "src"))
from models import build_model # noqa: E402
from xrf_analysis import classical_and_model, fit_lines, line_centroid # noqa: E402
import importlib # noqa: E402
sys.path.insert(0, str(HERE))
predict_fp16 = importlib.import_module("05_infer_measurement").predict_fp16
RESPONSE_ENERGIES = (3.5, 4.5, 5.4, 6.9, 8.0, 12.0, 17.4, 22.1, 27.5, 29.4)
XRF_FITS = {
"AgMo_1_factor_10": [
dict(alpha=["Mo_Ka"], window=(16.0, 18.9)),
dict(alpha=["Mo_Kb"], window=(18.75, 20.7)),
dict(alpha=["Ag_Ka"], window=(20.6, 23.6)),
],
"CuCrCo_1_factor_10": [
dict(alpha=["Cr_Ka", "Co_Ka", "Cu_Ka"],
beta={"Cr_Kb": "Cr_Ka", "Co_Kb": "Co_Ka", "Cu_Kb": "Cu_Ka"},
ratio_fixed={"Co_Kb": 0.135}, fit_stretch=False, window=(4.85, 9.6)),
],
}
XRF_LINES = ["Cr_Ka", "Co_Ka", "Cu_Ka", "Mo_Ka", "Mo_Kb", "Ag_Ka"]
DARK_RUN = "Pedestal_CuCrCo_1"
def fmt(value, spec, missing="n/a"):
if value is None or (isinstance(value, float) and not np.isfinite(value)):
return missing
return format(value, spec)
def model_meta(experiment, errors):
out = {}
try:
checkpoint = torch.load(experiment / "Models" / "best.pth", map_location="cpu",
weights_only=False)
conf = OmegaConf.create(checkpoint["conf"])
model = build_model(conf)
out.update(channels=list(conf.model.channels), R=model.rf_radius,
params=sum(p.numel() for p in model.parameters()),
best_epoch=int(checkpoint["epoch"]))
history = json.load(open(experiment / "history.json"))
out.update(epochs=len(history["val_loss"]),
best_val_loss=float(min(history["val_loss"])))
out["speed_fp16"] = speed(model)
except Exception as error: # noqa: BLE001
errors.append(f"meta: {error!r}")
return out
@torch.no_grad()
def speed(model, size=400, batch=32, n_iter=20):
torch.backends.cudnn.benchmark = True
model = model.cuda().eval().to(memory_format=torch.channels_last)
x = (torch.randn(batch, 1, size, size, device="cuda") * 0.05).to(
memory_format=torch.channels_last)
def run(): # exactly the FP16 path of 05_infer_measurement
return predict_fp16(model, x, 0.3)
for _ in range(5):
run()
torch.cuda.synchronize()
started = time.perf_counter()
for _ in range(n_iter):
run()
torch.cuda.synchronize()
return batch * n_iter / (time.perf_counter() - started)
def mc_metrics(experiment, errors):
try:
data = json.load(open(experiment / "eval_summary.json"))
return {key: value["summary"] for key, value in data.items()}
except Exception as error: # noqa: BLE001
errors.append(f"eval_summary: {error!r}")
return {}
def response(experiment, errors):
out = {}
try:
data = np.load(experiment / "energy_response.npz")
centre = data["table_centre"]
e_true, e_pred = data["e_true"], data["e_pred"]
for energy in RESPONSE_ENERGIES:
if not centre[0] - 0.13 <= energy <= centre[-1] + 0.13:
continue
i = int(np.argmin(np.abs(centre - energy)))
near = np.abs(e_true - centre[i]) < 0.125
out[energy] = dict(
shift_meV=float((data["table_core_mu"][i] - centre[i]) * 1000),
core_sigma=float(data["table_core_sigma"][i]),
tail_percent=float((np.abs(e_pred[near] - e_true[near]) > 1).mean() * 100),
efficiency=float(data["table_efficiency"][i]))
out["_curve"] = dict(centre=centre.tolist(),
shift=(data["table_core_mu"] - centre).tolist(),
sigma=data["table_core_sigma"].tolist(),
efficiency=data["table_efficiency"].tolist())
except Exception as error: # noqa: BLE001
errors.append(f"energy_response: {error!r}")
return out
def xrf_lines(folder_of, errors):
out = {}
for run, groups in XRF_FITS.items():
try:
centres, counts = np.load(folder_of(run) / "energy_spectrum.npy")
for group in groups:
fit = fit_lines(centres, counts, group["alpha"], group.get("beta"),
window=group["window"],
beta_ratio_fixed=group.get("ratio_fixed"),
fit_stretch=group.get("fit_stretch", True))
for name in group["alpha"]:
line = fit["lines"][name]
out[name] = dict(shift_meV=line["shift"] * 1000, sigma=line["sigma"],
fwhm=line["fwhm"], chi2_ndf=fit["chi2_ndf"])
except Exception as error: # noqa: BLE001
errors.append(f"xrf {run}: {error!r}")
return out
def classical_lines(config, errors):
"""Line fits of the classical 3x3 spectra (model independent, cached)."""
out = {}
edges = np.arange(0.0, 40.025, 0.05)
centres = 0.5 * (edges[1:] + edges[:-1])
for run, groups in XRF_FITS.items():
try:
reference = classical_and_model(config, run, n_frames=10_000)
counts = np.histogram(reference["classical"][:, 3], edges)[0]
for group in groups:
fit = fit_lines(centres, counts, group["alpha"], group.get("beta"),
window=group["window"],
beta_ratio_fixed=group.get("ratio_fixed"),
fit_stretch=group.get("fit_stretch", True), tails=False)
for name in group["alpha"]:
line = fit["lines"][name]
out[name] = dict(shift_meV=line["shift"] * 1000, sigma=line["sigma"],
fwhm=line["fwhm"])
except Exception as error: # noqa: BLE001
errors.append(f"classical {run}: {error!r}")
return out
def efficiency(config, checkpoint, tag, errors):
out = {}
for run in XRF_FITS:
try:
reference = classical_and_model(config, run, n_frames=10_000,
checkpoint=checkpoint, tag=tag)
classical, grid = reference["classical"], reference["grid"]
found = reference["classical_match"] >= 0
isolated = reference["isolation_classical"] > 4.0
energy = classical[:, 3]
deposited = reference["balance"][:, 1].sum()
out[run] = dict(
isolated_above5=float(found[isolated & (energy >= 5)].mean()),
isolated_3to5=float(found[isolated & (energy >= 3) & (energy < 5)].mean()),
count_ratio=float(len(grid) / len(classical)),
energy_balance=float(grid[:, 3].sum() / deposited))
except Exception as error: # noqa: BLE001
errors.append(f"efficiency {run}: {error!r}")
return out
def run_summaries(folder_of, errors):
out = {}
for run in list(XRF_FITS) + [DARK_RUN]:
try:
summary = json.load(open(folder_of(run) / "summary.json"))
out[run] = dict(n_photons=summary["n_photons"], n_frames=summary["n_frames"],
subpixel=summary.get("subpixel_nonuniformity_before"),
fps=summary.get("frames_per_second"),
precision=summary.get("precision", "fp32"))
except Exception as error: # noqa: BLE001
errors.append(f"summary {run}: {error!r}")
return out
def write_report(models, classical, out_dir):
labels = list(models)
lines = [f"# Poly model comparison ({time.strftime('%Y-%m-%d %H:%M')})", ""]
lines += ["## Models", "",
"| model | channels | R | params | best epoch / epochs | FP16 frame/s (400×400, model only) |",
"|---|---|---|---|---|---|"]
for label in labels:
m = models[label]["meta"]
lines.append(f"| {label} | {m.get('channels', 'n/a')} | {m.get('R', 'n/a')} | "
f"{m.get('params', 'n/a')} | {m.get('best_epoch', 'n/a')} / "
f"{m.get('epochs', 'n/a')} | {fmt(m.get('speed_fp16'), '.0f')} |")
lines += ["", "## MC, fixed-occupancy scan (threshold 0.3)", "",
"| model | occupancy | efficiency | purity | RMS [µm] | σ68 [µm] | E σ68 [keV] | sub-pixel |",
"|---|---|---|---|---|---|---|---|"]
for label in labels:
for occupancy, s in models[label]["mc"].items():
if occupancy not in ("1%", "5%", "10%"):
continue
lines.append(f"| {label} | {occupancy} | {s['efficiency']*100:.2f} % | "
f"{s['purity']*100:.2f} % | {s['rms_1d_um']:.3f} | "
f"{s['sigma68_1d_um']:.3f} | {s.get('energy_sigma68_keV', float('nan')):.3f} | "
f"{s['subpixel_nonuniformity']*100:.1f} % |")
lines += ["", "## MC energy response (occupancy ≤ 2.5 %)", "",
"core σ [keV] / line shift g(E) − E [meV] / tails |ΔE| > 1 keV [%] / efficiency [%]", "",
"| E [keV] | " + " | ".join(labels) + " |", "|---|" + "---|" * len(labels)]
for energy in RESPONSE_ENERGIES:
cells = []
for label in labels:
r = models[label]["response"].get(energy)
cells.append("—" if r is None else
f"{r['core_sigma']:.3f} / {r['shift_meV']:+.0f} / "
f"{r['tail_percent']:.1f} / {r['efficiency']*100:.1f}")
lines.append(f"| {energy} | " + " | ".join(cells) + " |")
lines += ["", "## XRF lines (AgMo, CuCrCo slit factor 10)", "",
"fitted − tabulated [meV] / core σ [keV] / FWHM [keV]", "",
"| line | " + " | ".join(labels + ["classical 3×3"]) + " |",
"|---|" + "---|" * (len(labels) + 1)]
for name in XRF_LINES:
cells = []
for source in [models[label]["xrf"] for label in labels] + [classical]:
r = source.get(name)
cells.append("n/a" if r is None else
f"{r['shift_meV']:+.0f} / {r['sigma']:.3f} / {r['fwhm']:.2f}")
lines.append(f"| {name} ({line_centroid(name):.2f}) | " + " | ".join(cells) + " |")
lines += ["", "## Efficiency against the classical finder, fake rate, sub-pixel", "",
"| model | run | isolated ≥ 5 keV found | isolated 3–5 keV found | grid / classical | energy balance | sub-pixel excess rms |",
"|---|---|---|---|---|---|---|"]
for label in labels:
for run in XRF_FITS:
e = models[label]["efficiency"].get(run, {})
s = models[label]["runs"].get(run, {})
lines.append(f"| {label} | {run} | {fmt(e.get('isolated_above5', np.nan)*100, '.2f')} % | "
f"{fmt(e.get('isolated_3to5', np.nan)*100, '.1f')} % | "
f"{fmt(e.get('count_ratio'), '.3f')} | {fmt(e.get('energy_balance'), '.4f')} | "
f"{fmt((s.get('subpixel') or np.nan)*100, '.1f')} % |")
lines += ["", "| model | dark run photons | frames |", "|---|---|---|"]
for label in labels:
s = models[label]["runs"].get(DARK_RUN, {})
lines.append(f"| {label} | {s.get('n_photons', 'n/a')} | {s.get('n_frames', 'n/a')} |")
problems = [(label, e) for label in labels for e in models[label]["errors"]]
if problems:
lines += ["", "## Missing pieces", ""]
lines += [f"- {label}: {error}" for label, error in problems]
lines += ["", "Plots: `report_response.png`."]
(out_dir / "report.md").write_text("\n".join(lines) + "\n")
def plot_response(models, out_dir):
figure, axes = plt.subplots(1, 3, figsize=(17, 4.4))
for label, model in models.items():
curve = model["response"].get("_curve")
if not curve:
continue
centre = np.asarray(curve["centre"])
axes[0].plot(centre, np.asarray(curve["shift"]) * 1000, lw=1.2, label=label)
axes[1].plot(centre, curve["sigma"], lw=1.2, label=label)
axes[2].plot(centre, np.asarray(curve["efficiency"]) * 100, lw=1.2, label=label)
axes[0].set_ylim(-200, 200)
for axis, ylabel, title in zip(axes, ("g(E) − E [meV]", "core σ [keV]", "efficiency [%]"),
("line shift", "energy resolution", "detection efficiency")):
axis.set_xlabel("E_true [keV]"); axis.set_ylabel(ylabel); axis.set_title(title)
axis.grid(alpha=.3); axis.legend(fontsize=7)
figure.tight_layout()
figure.savefig(out_dir / "report_response.png", dpi=130)
plt.close(figure)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", nargs=3, action="append", required=True,
metavar=("LABEL", "EXPERIMENT", "TAG"))
parser.add_argument("--config", default=str(HERE.parent / "Configs" /
"infer_measurement_poly_Moench014.yaml"))
parser.add_argument("--out", required=True)
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
output_folder = Path(OmegaConf.load(args.config).inference.output_folder)
models = {}
for label, experiment, tag in args.model:
experiment = Path(experiment)
tag = None if tag in ("-", "") else tag
errors = []
print(f"[compare] {label}: {experiment} (tag {tag})", flush=True)
def folder_of(run, tag=tag):
return output_folder / (run + (f"_{tag}" if tag else ""))
models[label] = dict(
meta=model_meta(experiment, errors), mc=mc_metrics(experiment, errors),
response=response(experiment, errors), xrf=xrf_lines(folder_of, errors),
efficiency=efficiency(args.config, str(experiment / "Models" / "best.pth"),
tag, errors),
runs=run_summaries(folder_of, errors), errors=errors)
for error in errors:
print(f" missing: {error}", flush=True)
classical_errors = []
classical = classical_lines(args.config, classical_errors)
if classical_errors:
models[next(iter(models))]["errors"] += classical_errors
write_report(models, classical, out_dir)
plot_response(models, out_dir)
def clean(value):
if isinstance(value, dict):
return {str(k): clean(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [clean(v) for v in value]
if isinstance(value, (np.floating, np.integer)):
return value.item()
return value
with open(out_dir / "report.json", "w") as handle:
json.dump(clean(dict(models=models, classical=classical)), handle, indent=1)
print(f"[compare] wrote {out_dir/'report.md'}")
if __name__ == "__main__":
try:
main()
except Exception: # noqa: BLE001
traceback.print_exc()
sys.exit(1)
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Overnight run (2026-09-27/28): poly 2-20 keV model for MOENCH040 @150 V, then
# the 12 keV MaxIV knife edge at 0-3 filters and the 15 keV SOLEIL Siemens star.
#
# setsid nohup bash scripts/overnight_poly2to20keV_Moench040.sh \
# > Results/overnight_poly2to20keV_Moench040/driver.log 2>&1 < /dev/null &
# DRY_RUN=1 bash scripts/overnight_poly2to20keV_Moench040.sh # print the plan only
#
# Part A (this file): charge LUT -> MC frames -> training -> MC evaluation.
# Part B (overnight_poly2to20keV_Moench040_partB.sh): measured runs + notebooks.
# Part B is started at the end of part A once it has been marked ready
# (state/partB.ready), so it can be finished and tested while A trains.
#
# Every stage logs to $BASE/logs/<stage>.log and leaves state/<stage>.done when
# it succeeds; a rerun skips finished stages. Progress with times goes to
# $BASE/status.md.
set -u
cd /home/xie_x1/MLXID/GridBased
PY=/home/xie_x1/software/miniforge3/envs/pytorch_nightly/bin/python
BASE=Results/overnight_poly2to20keV_Moench040
LOG=$BASE/logs
STATE=$BASE/state
STATUS=$BASE/status.md
LUT_CONF=Configs/charge_lut_Moench040_2to20keV.yaml
LUT=data/charge_lut_Moench040_150V_2to20keV.npz
GEN=Configs/generate_frames_poly2to20keV_Moench040.yaml
TRAIN=Configs/train_grid_poly2to20keV_Moench040_c96.yaml
DATA=/mnt/sls_det_storage/moench_data/MLXID/Samples/GridBased/poly2to20keV_Moench040_150V
EPOCHS=200
DRY_RUN=${DRY_RUN:-0}
mkdir -p "$LOG" "$STATE"
note() {
[ "$DRY_RUN" = 1 ] || echo "- $(date '+%m-%d %H:%M:%S') $*" >> "$STATUS"
echo "$(date '+%m-%d %H:%M:%S') $*"
}
# run NAME COMMAND... -- skip if done, log, time, mark
run() {
local name=$1; shift
if [ -f "$STATE/$name.done" ]; then
note "$name: already done, skipped"; return 0
fi
if [ "$DRY_RUN" = 1 ]; then
echo "[dry] $name: $*"; return 0
fi
note "$name: started"
local started=$(date +%s)
"$@" > "$LOG/$name.log" 2>&1
local status=$?
local minutes=$(( ($(date +%s) - started) / 60 ))
if [ $status -eq 0 ]; then
touch "$STATE/$name.done"
note "$name: done (${minutes} min)"
else
note "$name: **FAILED** (exit $status after ${minutes} min), see logs/$name.log"
fi
return $status
}
check_frames() {
$PY - "$DATA" <<'EOF'
import glob, sys
import h5py
folder = sys.argv[1]
expected = {"train": (12, 100_000), "val": (2, 40_000), "test": (2, 40_000),
"eval_scan": (5, 8_000)}
bad = []
for split, (n_files, n_frames) in expected.items():
files = sorted(glob.glob(f"{folder}/{split}/*.h5"))
if len(files) != n_files:
bad.append(f"{split}: {len(files)} files, expected {n_files}")
for path in files:
with h5py.File(path) as f:
frames, energy = f["frames"].shape[0], f["photons"][:, 2]
if frames != n_frames:
bad.append(f"{path}: {frames} frames, expected {n_frames}")
if energy.min() < 1.999 or energy.max() > 20.001:
bad.append(f"{path}: energy {energy.min():.3f}-{energy.max():.3f} keV")
print("\n".join(bad) if bad else "all shards complete, energies within 2-20 keV")
sys.exit(1 if bad else 0)
EOF
}
check_training() { # experiment dir
[ -f "$1/Models/best.pth" ] && $PY -c "
import json, sys
h = json.load(open('$1/history.json'))
print(len(h['val_loss']), 'epochs, best val loss', min(h['val_loss']))
sys.exit(0 if len(h['val_loss']) == $EPOCHS else 1)"
}
note "=== driver started (pid $$) ==="
# --- 1. charge-cloud table ---------------------------------------------------
if ! run lut $PY scripts/01_build_charge_lut.py --config $LUT_CONF --workers 19; then
note "charge LUT failed: stopping"; exit 1
fi
# diagnostic only: its comparison with the 9-parameter fits needs MOENCH040
# ggdPar files that exist for a few energies only, so a failure does not stop
# the chain
run lut_validation $PY scripts/01b_validate_charge_lut.py --lut $LUT \
--sensor Moench040 --outdir Results/charge_lut_Moench040_2to20keV
# --- 2. MC frames, 2-20 keV (~35 min) ----------------------------------------
if ! run generate $PY scripts/02_generate_frames.py --config $GEN; then
note "frame generation failed: stopping"; exit 1
fi
if ! run check_frames check_frames; then
note "frame check failed: stopping"; exit 1
fi
# --- 3. training, [32, 64, 96, 96], 200 epochs (~1 h 40 min) -----------------
if ! run train $PY -u scripts/03_train.py --config $TRAIN; then
note "training failed: stopping"; exit 1
fi
if [ "$DRY_RUN" = 1 ]; then
experiment="Results/<date>_poly2to20keV_Moench040_c96_00"
else
[ -f "$STATE/experiment" ] || \
grep -m1 "\[train\] output ->" "$LOG/train.log" | awk '{print $NF}' > "$STATE/experiment"
experiment=$(cat "$STATE/experiment")
if ! run check_train check_training "$experiment"; then
note "training incomplete in $experiment: stopping"; exit 1
fi
fi
checkpoint=$experiment/Models/best.pth
# --- 4. MC evaluation --------------------------------------------------------
run evaluate $PY scripts/04_evaluate.py --checkpoint "$checkpoint"
run response $PY scripts/07_energy_response.py --checkpoint "$checkpoint"
note "=== part A finished: $experiment ==="
# --- 5. part B: measured runs and notebooks ----------------------------------
PARTB=scripts/overnight_poly2to20keV_Moench040_partB.sh
if [ "$DRY_RUN" = 1 ]; then
echo "[dry] part B: bash $PARTB $experiment"; exit 0
fi
waited=0
while [ ! -f "$STATE/partB.ready" ] && [ $waited -lt 7200 ]; do
[ $waited -eq 0 ] && note "waiting for state/partB.ready"
sleep 60; waited=$((waited + 60))
done
if [ ! -f "$STATE/partB.ready" ]; then
note "part B was never marked ready: stopping after part A"; exit 1
fi
bash "$PARTB" "$experiment"
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Overnight run (2026-09-27/28), part B: the measured runs and the notebooks.
# Started by overnight_poly2to20keV_Moench040.sh once part A (training) is done
# and state/partB.ready exists; can also be run by hand:
#
# setsid nohup bash scripts/overnight_poly2to20keV_Moench040_partB.sh \
# Results/<experiment> >> Results/overnight_poly2to20keV_Moench040/driver.log 2>&1 < /dev/null &
#
# Order: dark runs (fake rate, 1 min) -> knife edge 3/2/1/0 filters, edge + flat
# (63 M frames of 101x101) -> knife-edge notebook -> Siemens star, four parts of
# 500 files -> Siemens-star notebook -> HTML copies of both notebooks.
# A failed run is logged and skipped; the notebooks report what is missing.
set -u
cd /home/xie_x1/MLXID/GridBased
ENV_BIN=/home/xie_x1/software/miniforge3/envs/pytorch_nightly/bin
PY=$ENV_BIN/python
BASE=Results/overnight_poly2to20keV_Moench040
LOG=$BASE/logs
STATE=$BASE/state
STATUS=$BASE/status.md
REPORT=$BASE/report
KNIFE=Configs/infer_measurement_poly_Moench040_knife.yaml
STAR=Configs/infer_measurement_poly_Moench040_star.yaml
TAG=poly2to20
mkdir -p "$LOG" "$STATE" "$REPORT"
experiment=${1:-$(cat "$STATE/experiment" 2>/dev/null)}
checkpoint=$experiment/Models/best.pth
if [ ! -f "$checkpoint" ]; then
echo "- $(date '+%m-%d %H:%M:%S') part B: no checkpoint at '$checkpoint'" >> "$STATUS"
exit 1
fi
# the CPU side of the inference is small per-batch work; the default thread
# count (16) thrashes as soon as anything else runs (32 s instead of 0.1 s per
# decode batch next to the frame generator)
export OMP_NUM_THREADS=4 MKL_NUM_THREADS=4
export PATH=$ENV_BIN:$PATH # the notebooks' kernel runs `python`
export GRID_POLY_EXPERIMENT=$experiment GRID_POLY_TAG=$TAG
note() {
echo "- $(date '+%m-%d %H:%M:%S') $*" >> "$STATUS"
echo "$(date '+%m-%d %H:%M:%S') $*"
}
run() {
local name=$1; shift
if [ -f "$STATE/$name.done" ]; then
note "$name: already done, skipped"; return 0
fi
note "$name: started"
local started=$(date +%s)
"$@" > "$LOG/$name.log" 2>&1
local status=$?
local minutes=$(( ($(date +%s) - started) / 60 ))
if [ $status -eq 0 ]; then
touch "$STATE/$name.done"
note "$name: done (${minutes} min) $(grep -o '([0-9]* frame/s)' "$LOG/$name.log" | tail -1)"
else
note "$name: **FAILED** (exit $status after ${minutes} min), see logs/$name.log"
fi
return $status
}
notebook() { # notebook name -> executed in place + HTML copy in report/
local name=$1
jupyter nbconvert --to notebook --execute --inplace \
--ExecutePreprocessor.timeout=3600 --ExecutePreprocessor.kernel_name=python3 \
"Notebooks/$name.ipynb" && \
jupyter nbconvert --to html --output-dir "$REPORT" "Notebooks/$name.ipynb"
}
note "=== part B started (pid $$), model $checkpoint ==="
# Two lanes in parallel: the knife edge is bound by the GPU and the Python
# loop (101x101 frames, ~10^4 frame/s), the Siemens star by reading the NFS
# store and remapping ~820 photons per frame (~3000 frame/s), so they mostly
# use different resources.
knife_lane() {
# dark runs first: fake photons on 1000 frames each (a minute)
for run_name in Pedestal_1filters_12keV Pedestal_2filters_12keV Pedestal_3filters_pos0_12keV; do
run infer_$run_name $PY scripts/05_infer_measurement.py --config $KNIFE \
--run $run_name --checkpoint "$checkpoint" --tag $TAG
done
# the four flux points, edge and flat (63 M frames, ~1.5-2 h)
for filters in 3filters_pos0 2filters 1filters 0filters; do
for kind in Edge Flat; do
run infer_${kind}_${filters} $PY scripts/05_infer_measurement.py --config $KNIFE \
--run ${kind}_${filters}_12keV --checkpoint "$checkpoint" --tag $TAG
done
done
rm -f "$STATE/notebook_knife.done" # always re-run on what exists
run notebook_knife notebook KnifeEdge_poly2to20keV_Moench040
}
star_lane() {
# lower left + lower right in one crop, four parts of 500 files (~2 h)
for part in 0 1 2 3; do
run infer_star_part$part $PY scripts/05_infer_measurement.py --config $STAR \
--run SiemensStar_15keV_part$part --checkpoint "$checkpoint" --tag $TAG
done
rm -f "$STATE/notebook_star.done"
run notebook_star notebook SiemensStar_poly2to20keV_15keV
}
knife_lane &
star_lane &
wait
note "=== part B finished; notebooks in Notebooks/, HTML copies in $REPORT ==="
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Overnight run (2026-09-18): poly models trained on 3-30 keV, four network sizes.
#
# setsid nohup bash scripts/overnight_poly3to30keV.sh \
# > Results/overnight_poly3to30keV/driver.log 2>&1 < /dev/null &
# DRY_RUN=1 bash scripts/overnight_poly3to30keV.sh # print the plan only
#
# Every stage logs to Results/overnight_poly3to30keV/logs/<stage>.log and leaves
# state/<stage>.done when it succeeds; a rerun skips finished stages, so the
# script can simply be started again after a failure. Progress, with times, is
# appended to Results/overnight_poly3to30keV/status.md. A failure of the frame
# generation stops everything; a failure inside one model skips the rest of that
# model and goes on with the next. The last stage writes report/report.md.
set -u
cd /home/xie_x1/MLXID/GridBased
PY=/home/xie_x1/software/miniforge3/envs/pytorch_nightly/bin/python
BASE=Results/overnight_poly3to30keV
LOG=$BASE/logs
STATE=$BASE/state
STATUS=$BASE/status.md
INFER=Configs/infer_measurement_poly_Moench014.yaml
GEN=Configs/generate_frames_poly3to30keV_Moench014.yaml
DATA=/mnt/sls_det_storage/moench_data/MLXID/Samples/GridBased/poly3to30keV_Moench014_150V
REFERENCE=Results/260901_poly5to30keV_Moench014_00 # the current 5-30 keV model
MODELS="c128 c96 c64 r3"
declare -A CHANNELS=([c128]="[32,64,128,128]" [c96]="[32,64,96,96]"
[c64]="[32,64,64,64]" [r3]="[32,64,128]")
DRY_RUN=${DRY_RUN:-0}
mkdir -p "$LOG" "$STATE"
note() {
[ "$DRY_RUN" = 1 ] || echo "- $(date '+%m-%d %H:%M:%S') $*" >> "$STATUS"
echo "$(date '+%m-%d %H:%M:%S') $*"
}
# run NAME COMMAND... -- skip if done, log, time, mark
run() {
local name=$1; shift
if [ -f "$STATE/$name.done" ]; then
note "$name: already done, skipped"; return 0
fi
if [ "$DRY_RUN" = 1 ]; then
echo "[dry] $name: $*"; return 0
fi
note "$name: started"
local started=$(date +%s)
"$@" > "$LOG/$name.log" 2>&1
local status=$?
local minutes=$(( ($(date +%s) - started) / 60 ))
if [ $status -eq 0 ]; then
touch "$STATE/$name.done"
note "$name: done (${minutes} min)"
else
note "$name: **FAILED** (exit $status after ${minutes} min), see logs/$name.log"
fi
return $status
}
check_frames() {
$PY - "$DATA" <<'EOF'
import glob, sys
import h5py
folder = sys.argv[1]
expected = {"train": (12, 100_000), "val": (2, 40_000), "test": (2, 40_000),
"eval_scan": (5, 8_000)}
bad = []
for split, (n_files, n_frames) in expected.items():
files = sorted(glob.glob(f"{folder}/{split}/*.h5"))
if len(files) != n_files:
bad.append(f"{split}: {len(files)} files, expected {n_files}")
for path in files:
with h5py.File(path) as f:
frames, energy = f["frames"].shape[0], f["photons"][:, 2]
if frames != n_frames:
bad.append(f"{path}: {frames} frames, expected {n_frames}")
if energy.min() < 2.999 or energy.max() > 30.001:
bad.append(f"{path}: energy {energy.min():.3f}-{energy.max():.3f} keV")
print("\n".join(bad) if bad else "all shards complete, energies within 3-30 keV")
sys.exit(1 if bad else 0)
EOF
}
check_training() { # experiment dir
[ -f "$1/Models/best.pth" ] && $PY -c "
import json, sys
h = json.load(open('$1/history.json'))
print(len(h['val_loss']), 'epochs, best val loss', min(h['val_loss']))
sys.exit(0 if len(h['val_loss']) == 300 else 1)"
}
note "=== driver started (pid $$) ==="
# --- 1. charge-cloud table (done interactively before launch) ---------------
run lut $PY scripts/01_build_charge_lut.py --config Configs/charge_lut_Moench014_3to30keV.yaml --workers 14
run lut_validation $PY scripts/01b_validate_charge_lut.py \
--lut data/charge_lut_Moench014_150V_3to30keV.npz --outdir Results/charge_lut_3to30keV
# --- 2. MC frames, 3-30 keV (~1 h) ------------------------------------------
if ! run generate $PY scripts/02_generate_frames.py --config $GEN; then
note "frame generation failed: stopping"; exit 1
fi
if ! run check_frames check_frames; then
note "frame check failed: stopping"; exit 1
fi
# --- 3. four models, one after the other (~3 h each) ------------------------
# its XRF runs were moved to <run>_poly5to30 when the c96 model became the default
compare_args=(--model "5-30 keV [32,64,128,128] (previous default)" "$REFERENCE" poly5to30)
for model in $MODELS; do
if ! run train_$model $PY -u scripts/03_train.py \
--config Configs/train_grid_poly3to30keV_Moench014_$model.yaml; then
note "$model: training failed, skipping its evaluation"; continue
fi
if [ "$DRY_RUN" = 1 ]; then
experiment="Results/<date>_poly3to30keV_Moench014_${model}_00"
else
[ -f "$STATE/$model.experiment" ] || \
grep -m1 "\[train\] output ->" "$LOG/train_$model.log" | awk '{print $NF}' \
> "$STATE/$model.experiment"
experiment=$(cat "$STATE/$model.experiment")
if ! run check_train_$model check_training "$experiment"; then
note "$model: training incomplete in $experiment, skipping its evaluation"
continue
fi
fi
checkpoint=$experiment/Models/best.pth
run evaluate_$model $PY scripts/04_evaluate.py --checkpoint "$checkpoint"
run response_$model $PY scripts/07_energy_response.py --checkpoint "$checkpoint"
for xrf in AgMo_1_factor_10 CuCrCo_1_factor_10 Pedestal_CuCrCo_1; do
run xrf_${xrf}_$model $PY scripts/05_infer_measurement.py --config $INFER \
--run $xrf --checkpoint "$checkpoint" --tag $model
done
compare_args+=(--model "3-30 keV ${CHANNELS[$model]}" "$experiment" $model)
done
# --- 4. comparison report ----------------------------------------------------
rm -f "$STATE/compare.done" # always refresh the report
run compare $PY scripts/08_compare_models.py --out $BASE/report "${compare_args[@]}"
note "=== driver finished; report: $BASE/report/report.md ==="
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Siemens star again with the masked column common mode (2026-09-28).
# The 09-27 run used the plain column median, which the ~5 % occupancy of the
# star pulls up: the 15 keV line came out at 14.47 keV. Outputs carry the tag
# _poly2to20_cmmasked, next to the old _poly2to20 ones.
#
# setsid nohup bash scripts/rerun_star_column_masked.sh \
# >> Results/overnight_poly2to20keV_Moench040/driver.log 2>&1 < /dev/null &
#
# After parts 0-1 (half of the 20 M frames) the notebook is executed on them and
# kept as SiemensStar_poly2to20keV_15keV_50pct.ipynb (+ HTML, summary json), so
# a result exists even if the rest does not finish; after all four parts the
# main notebook is executed in place.
set -u
cd /home/xie_x1/MLXID/GridBased
ENV_BIN=/home/xie_x1/software/miniforge3/envs/pytorch_nightly/bin
PY=$ENV_BIN/python
BASE=Results/overnight_poly2to20keV_Moench040
LOG=$BASE/logs
STATE=$BASE/state
STATUS=$BASE/status.md
REPORT=$BASE/report
STAR=Configs/infer_measurement_poly_Moench040_star.yaml
TAG=poly2to20_cmmasked
experiment=$(cat "$STATE/experiment")
checkpoint=$experiment/Models/best.pth
export OMP_NUM_THREADS=4 MKL_NUM_THREADS=4
export PATH=$ENV_BIN:$PATH GRID_POLY_TAG=$TAG
NOTEBOOK=Notebooks/SiemensStar_poly2to20keV_15keV
note() {
echo "- $(date '+%m-%d %H:%M:%S') $*" >> "$STATUS"
echo "$(date '+%m-%d %H:%M:%S') $*"
}
run() {
local name=$1; shift
if [ -f "$STATE/$name.done" ]; then
note "$name: already done, skipped"; return 0
fi
note "$name: started"
local started=$(date +%s)
"$@" > "$LOG/$name.log" 2>&1
local status=$?
local minutes=$(( ($(date +%s) - started) / 60 ))
if [ $status -eq 0 ]; then
touch "$STATE/$name.done"
note "$name: done (${minutes} min) $(grep -o '([0-9]* frame/s)' "$LOG/$name.log" | tail -1)"
else
note "$name: **FAILED** (exit $status after ${minutes} min), see logs/$name.log"
fi
return $status
}
snapshot() { # executed copy of the notebook on the first two parts
GRID_STAR_RUNS=SiemensStar_15keV_part0,SiemensStar_15keV_part1 \
GRID_STAR_SUMMARY=siemens_star_poly2to20keV_summary_50pct.json \
jupyter nbconvert --to notebook --execute --ExecutePreprocessor.timeout=3600 \
--ExecutePreprocessor.kernel_name=python3 \
--output SiemensStar_poly2to20keV_15keV_50pct.ipynb "$NOTEBOOK.ipynb" && \
jupyter nbconvert --to html --output-dir "$REPORT" "${NOTEBOOK}_50pct.ipynb"
}
full() {
jupyter nbconvert --to notebook --execute --inplace --ExecutePreprocessor.timeout=3600 \
--ExecutePreprocessor.kernel_name=python3 "$NOTEBOOK.ipynb" && \
jupyter nbconvert --to html --output-dir "$REPORT" "$NOTEBOOK.ipynb"
}
note "=== Siemens star rerun, column_masked (pid $$), tag $TAG ==="
for part in 0 1; do
run infer_star_cmmasked_part$part $PY scripts/05_infer_measurement.py --config $STAR \
--run SiemensStar_15keV_part$part --checkpoint "$checkpoint" --tag $TAG
done
run notebook_star_cmmasked_50pct snapshot
for part in 2 3; do
run infer_star_cmmasked_part$part $PY scripts/05_infer_measurement.py --config $STAR \
--run SiemensStar_15keV_part$part --checkpoint "$checkpoint" --tag $TAG
done
rm -f "$STATE/notebook_star_cmmasked.done"
run notebook_star_cmmasked full
note "=== Siemens star rerun finished ==="