Add batch export of DC/RF/Velocity map images

Add Export -> Batch Export Images..., which renders and saves one PNG
per angle for whichever of CH1 (RF), CH3 (DC-A), CH4 (DC-B), and
Velocity the user selects, for the currently open file. Each channel
gets its own fixed min/max colorbar range, entered once in the dialog
and held constant across every exported angle, so the resulting images
are directly comparable to each other instead of each auto-scaling to
its own data (today's live-view default).

BatchExportDialog (sras_viewer.py) collects the output folder, file
prefix, and per-channel checkbox + range, seeded from whatever's
already cached (session DC/FFT caches, or the file's own v7 cache
blocks) so opening the dialog never triggers a fresh compute. Export
runs on a background thread via a new BatchExportWorker
(sras_workers.py), which computes each angle's channel image with the
existing dc_image_mv/compute_rf_image helpers (reusing one FFT per
angle for both RF and Velocity) and renders it with a headless
matplotlib Agg canvas.

Also includes several small correctness/robustness fixes that were
already staged in the working tree: an int16-vs-float32 accumulator
mismatch in sras_average.py's row averaging, a DC4-mask cache reuse and
atomic sidecar write in sras_compute.py, a dc3/dc4 pairing guard in
sras_format.py's v7 cache writer, and matching updates to the
tools/ test fixtures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales [M S E]
2026-08-10 18:30:00 -05:00
parent b0bfe6e8c6
commit 6976cbf767
7 changed files with 507 additions and 43 deletions
+15 -3
View File
@@ -266,13 +266,19 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
budget=budget)
background = sras.background if (apply_bg_sub and sras.background is not None) else None
cal4 = sras.cal(CH4_IDX)
# DC4 mask source, in the same priority order as the fast path above:
# already-cached DC block (skips reading CH4 raw waveforms entirely),
# then the caller-supplied image, else recompute per chunk below.
dc4_full = sras.cached_dc_mv(angle_idx, CH4_IDX) if dc_threshold_mv is not None else None
if dc4_full is None:
dc4_full = dc4_mv
def chunk(r0: int, r1: int):
if dc_threshold_mv is None:
valid = None
else:
if dc4_mv is not None:
dc4_chunk = dc4_mv[r0:r1]
if dc4_full is not None:
dc4_chunk = dc4_full[r0:r1]
else:
dc4_chunk = adc_to_mv(
data[r0:r1, CH4_IDX, :, :].astype(np.float32).mean(axis=-1), *cal4)
@@ -1007,7 +1013,13 @@ def save_manual_alignment(sras: SrasFile, ref_angle_idx: int,
for a, p in per_angle.items()
},
}
path.write_text(json.dumps(payload, indent=2))
# Write to a temp file in the same directory then rename over the target
# — os.replace() is atomic on both POSIX and Windows, so a crash mid-write
# leaves either the old sidecar or the new one, never a truncated/corrupt
# file that load_manual_alignment would silently treat as "no alignment".
tmp_path = path.with_name(path.name + f".tmp{os.getpid()}")
tmp_path.write_text(json.dumps(payload, indent=2))
os.replace(tmp_path, path)
return path