Add min peak frequency floor (CACH v4) and Batch Export View as Images
Two strands of in-progress work, committed together because they overlap in sras_workers.py and main_window.py. Min peak frequency floor: - CACH tail bumped to version 4, adding u32 min_freq_khz provenance in fixed-point kHz (a float32 20.1 reads back as 20.10000038 and would report a spurious mismatch forever). v1-v3 tails read as no floor. - Stored FFT caches are accepted when the reader's floor is at or above the stored one, since a higher floor is re-applicable by masking. - Floor plumbed through compute_rf_image, BatchCacheWorker and the viewer. Batch Export View as Images: - New sras_render.py holds draw_view_image, shared by the Qt canvas and the headless exporter so a PNG cannot drift from what the GUI shows. Deliberately Qt-free so it is importable in a pool subprocess. - BatchExportImagesWorker renders the current view settings across many files, process-pooled with an inline fallback, reporting per-file output names so the caller can flag same-stem collisions. - _axes_extent extracted into sras_format for both render paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+116
-4
@@ -18,6 +18,7 @@ import sras_compute as compute
|
||||
from sras_align_export import write_aligned_sras
|
||||
from sras_compute import cache_file, compute_rf_image, dc_image_mv
|
||||
from sras_format import CH3_IDX, CH4_IDX, SrasFile
|
||||
from sras_render import export_view_image
|
||||
|
||||
# Concurrency caps. Batch conversion runs one process per file, and each of
|
||||
# those processes threads internally, so the two must be divided rather than
|
||||
@@ -197,7 +198,9 @@ class BatchCacheWorker(QObject):
|
||||
|
||||
Both FFT modes cache at *pad_factor*, which the caller sets from the
|
||||
viewer's own padding — a cache stored at a pad the user is not viewing
|
||||
at is one the display can never use.
|
||||
at is one the display can never use. *min_freq_mhz* travels the same
|
||||
way: the viewer's live min-peak-freq floor, recorded in the store as
|
||||
provenance so a reader knows which bins the peak search considered.
|
||||
|
||||
Files are processed one per subprocess: they are fully independent, each
|
||||
opens its own memmap and writes only its own bytes, and only path strings
|
||||
@@ -211,7 +214,7 @@ class BatchCacheWorker(QObject):
|
||||
|
||||
def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool,
|
||||
dc_threshold_mv: float | None = None, row_avg_n: int = 0,
|
||||
pad_factor: int = 1):
|
||||
pad_factor: int = 1, min_freq_mhz: float = 0.0):
|
||||
super().__init__()
|
||||
self._paths = paths
|
||||
self._mode = mode
|
||||
@@ -219,6 +222,7 @@ class BatchCacheWorker(QObject):
|
||||
self._dc_threshold = dc_threshold_mv
|
||||
self._row_avg_n = row_avg_n
|
||||
self._pad_factor = pad_factor
|
||||
self._min_freq_mhz = min_freq_mhz
|
||||
|
||||
def _report(self, path: str, err: str, done: int, total: int):
|
||||
self.file_done.emit(path, err)
|
||||
@@ -246,7 +250,8 @@ class BatchCacheWorker(QObject):
|
||||
per_proc_workers,
|
||||
pad_factor=self._pad_factor,
|
||||
dc_threshold_mv=self._dc_threshold,
|
||||
row_avg_n=self._row_avg_n): p
|
||||
row_avg_n=self._row_avg_n,
|
||||
min_freq_mhz=self._min_freq_mhz): p
|
||||
for p in paths
|
||||
}
|
||||
for fut in as_completed(futures):
|
||||
@@ -272,7 +277,8 @@ class BatchCacheWorker(QObject):
|
||||
compute.default_max_workers(),
|
||||
pad_factor=self._pad_factor,
|
||||
dc_threshold_mv=self._dc_threshold,
|
||||
row_avg_n=self._row_avg_n)
|
||||
row_avg_n=self._row_avg_n,
|
||||
min_freq_mhz=self._min_freq_mhz)
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
done += 1
|
||||
@@ -310,6 +316,112 @@ class BatchCacheWorker(QObject):
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class BatchExportImagesWorker(QObject):
|
||||
"""Renders the view settings captured by the caller at trigger time
|
||||
(angle/channel/threshold/etc.) to one PNG per file in *paths*, via
|
||||
sras_render.export_view_image.
|
||||
|
||||
Same process-pool-with-inline-fallback strategy as BatchCacheWorker
|
||||
above (same _BATCH_MAX_PROCS / _BATCH_POOL_MIN_BYTES thresholds): an
|
||||
FFT-derived (CH1/Velocity) view is exactly the same expensive per-file
|
||||
compute Batch Compute FFT already parallelizes this way. Never mutates
|
||||
*paths* — each file is opened read-only — so unlike BatchCacheWorker
|
||||
there is no version gate.
|
||||
|
||||
Emits progress(int) (0-100 by files completed), file_done(str, str, str)
|
||||
(path, error message or "", output filename this file targeted — set
|
||||
even on most failures so the caller can flag same-stem collisions across
|
||||
the batch without any cross-process bookkeeping), and finished().
|
||||
"""
|
||||
progress = pyqtSignal(int)
|
||||
file_done = pyqtSignal(str, str, str)
|
||||
finished = pyqtSignal()
|
||||
|
||||
def __init__(self, paths: list[str], **render_kwargs):
|
||||
"""*render_kwargs* is exactly export_view_image's keyword-only
|
||||
settings (out_dir, angle_idx, ch_idx, is_fft_mode, is_velocity,
|
||||
dc_threshold_mv, apply_bg_sub, pad_factor, min_freq_mhz, grating_um,
|
||||
cmap, auto_scale, vmin, vmax, highlight_masked, mode_str,
|
||||
colorbar_label, mask_color) — bundled rather than repeated as
|
||||
positional params across __init__/_run_pooled/_run_inline."""
|
||||
super().__init__()
|
||||
self._paths = paths
|
||||
self._kw = render_kwargs
|
||||
|
||||
def _report(self, path: str, err: str, out_name: str, done: int, total: int):
|
||||
self.file_done.emit(path, err, out_name)
|
||||
self.progress.emit(int(done / max(1, total) * 100))
|
||||
|
||||
def _run_pooled(self, paths: list[str], n_procs: int) -> list[str]:
|
||||
"""Same contract as BatchCacheWorker._run_pooled: returns the paths
|
||||
that never got a real answer because the pool itself died, so the
|
||||
caller can retry them in-process."""
|
||||
per_proc_workers = max(1, (os.cpu_count() or 4) // n_procs)
|
||||
unresolved: list[str] = []
|
||||
done = 0
|
||||
|
||||
with ProcessPoolExecutor(max_workers=n_procs) as executor:
|
||||
futures = {
|
||||
executor.submit(export_view_image, p,
|
||||
max_workers=per_proc_workers, **self._kw): p
|
||||
for p in paths
|
||||
}
|
||||
for fut in as_completed(futures):
|
||||
path = futures[fut]
|
||||
try:
|
||||
err, out_name = fut.result()
|
||||
except BrokenProcessPool:
|
||||
unresolved.append(path)
|
||||
continue
|
||||
except Exception as exc:
|
||||
err, out_name = str(exc), ""
|
||||
done += 1
|
||||
self._report(path, err, out_name, done, len(paths))
|
||||
|
||||
return unresolved
|
||||
|
||||
def _run_inline(self, paths: list[str], done: int, total: int):
|
||||
for path in paths:
|
||||
try:
|
||||
err, out_name = export_view_image(
|
||||
path, max_workers=compute.default_max_workers(), **self._kw)
|
||||
except Exception as exc:
|
||||
err, out_name = str(exc), ""
|
||||
done += 1
|
||||
self._report(path, err, out_name, done, total)
|
||||
|
||||
def _worth_pooling(self, paths: list[str]) -> bool:
|
||||
if len(paths) < 2:
|
||||
return False
|
||||
total = 0
|
||||
for p in paths:
|
||||
try:
|
||||
total += os.path.getsize(p)
|
||||
except OSError:
|
||||
pass # unreadable files are reported by export_view_image
|
||||
return total >= _BATCH_POOL_MIN_BYTES
|
||||
|
||||
def run(self):
|
||||
paths = self._paths
|
||||
n_procs = max(1, min(_BATCH_MAX_PROCS, len(paths)))
|
||||
|
||||
if not self._worth_pooling(paths):
|
||||
self._run_inline(paths, 0, len(paths))
|
||||
self.finished.emit()
|
||||
return
|
||||
|
||||
try:
|
||||
unresolved = self._run_pooled(paths, n_procs)
|
||||
except Exception:
|
||||
# The pool could not be created or collapsed wholesale.
|
||||
unresolved = list(paths)
|
||||
|
||||
if unresolved:
|
||||
self._run_inline(unresolved, len(paths) - len(unresolved), len(paths))
|
||||
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class Ch4MaskWorker(_PooledWorker):
|
||||
"""Fetches each requested angle's CH4 (Bias B) DC image in mV, for the
|
||||
alignment wizard's initial threshold-mask stack.
|
||||
|
||||
Reference in New Issue
Block a user