Merge branch 'feature/batch-export-images' into main
Adds Export -> Batch Export Images... (see 6976cbf for details), plus
the small correctness fixes bundled in that commit.
This commit is contained in:
+9
-4
@@ -54,8 +54,13 @@ def average_rows(block: np.ndarray, n: int, discard_remainder: bool) -> np.ndarr
|
|||||||
"""Average every N frames of one angle's (n_rows, n_ch, n_frames, spf)
|
"""Average every N frames of one angle's (n_rows, n_ch, n_frames, spf)
|
||||||
block. Returns int16 of shape (n_rows, n_ch, n_out, spf).
|
block. Returns int16 of shape (n_rows, n_ch, n_out, spf).
|
||||||
|
|
||||||
Averaging is done in float32 and rounded on cast, matching numpy's mean
|
Cast to native int16 (not float32) before calling .mean(): numpy's mean()
|
||||||
followed by an int16 cast in the original implementation.
|
uses a float64 accumulator by default for integer input, matching the
|
||||||
|
original implementation exactly (which kept the whole file as int16 and
|
||||||
|
called .mean() directly). A float32 cast here would use a float32
|
||||||
|
accumulator instead — for large group sizes that can round the sum
|
||||||
|
differently than float64 and, after the int16 cast below, occasionally
|
||||||
|
land on a value 1 ADC count away from the original tool's output.
|
||||||
"""
|
"""
|
||||||
n_frames = block.shape[2]
|
n_frames = block.shape[2]
|
||||||
n_full = n_frames // n
|
n_full = n_frames // n
|
||||||
@@ -63,11 +68,11 @@ def average_rows(block: np.ndarray, n: int, discard_remainder: bool) -> np.ndarr
|
|||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
if n_full:
|
if n_full:
|
||||||
full = block[:, :, :n_full * n, :].astype(np.float32)
|
full = block[:, :, :n_full * n, :].astype(np.int16)
|
||||||
full = full.reshape(block.shape[0], block.shape[1], n_full, n, block.shape[3])
|
full = full.reshape(block.shape[0], block.shape[1], n_full, n, block.shape[3])
|
||||||
parts.append(full.mean(axis=3).astype(np.int16))
|
parts.append(full.mean(axis=3).astype(np.int16))
|
||||||
if remainder and not discard_remainder:
|
if remainder and not discard_remainder:
|
||||||
tail = block[:, :, n_full * n:, :].astype(np.float32)
|
tail = block[:, :, n_full * n:, :].astype(np.int16)
|
||||||
parts.append(tail.mean(axis=2, keepdims=True).astype(np.int16))
|
parts.append(tail.mean(axis=2, keepdims=True).astype(np.int16))
|
||||||
|
|
||||||
if not parts:
|
if not parts:
|
||||||
|
|||||||
+15
-3
@@ -266,13 +266,19 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
|||||||
budget=budget)
|
budget=budget)
|
||||||
background = sras.background if (apply_bg_sub and sras.background is not None) else None
|
background = sras.background if (apply_bg_sub and sras.background is not None) else None
|
||||||
cal4 = sras.cal(CH4_IDX)
|
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):
|
def chunk(r0: int, r1: int):
|
||||||
if dc_threshold_mv is None:
|
if dc_threshold_mv is None:
|
||||||
valid = None
|
valid = None
|
||||||
else:
|
else:
|
||||||
if dc4_mv is not None:
|
if dc4_full is not None:
|
||||||
dc4_chunk = dc4_mv[r0:r1]
|
dc4_chunk = dc4_full[r0:r1]
|
||||||
else:
|
else:
|
||||||
dc4_chunk = adc_to_mv(
|
dc4_chunk = adc_to_mv(
|
||||||
data[r0:r1, CH4_IDX, :, :].astype(np.float32).mean(axis=-1), *cal4)
|
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()
|
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
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+24
-8
@@ -523,7 +523,13 @@ class SrasFile:
|
|||||||
final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz
|
final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz
|
||||||
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
|
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
|
||||||
|
|
||||||
dc_entries = [a for a in range(self.n_angles) if final_dc3[a] is not None]
|
# dc3/dc4 are always populated together by every current caller, but
|
||||||
|
# guard the per-angle pairing explicitly rather than assume it: an
|
||||||
|
# angle present in only one of the two arrays would otherwise crash
|
||||||
|
# below on final_dc4[a].astype(...) (or silently store the wrong
|
||||||
|
# dc3/dc4 pairing for that angle).
|
||||||
|
dc_entries = [a for a in range(self.n_angles)
|
||||||
|
if final_dc3[a] is not None and final_dc4[a] is not None]
|
||||||
fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
|
fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
|
||||||
|
|
||||||
block_flags = ((CACH_FLAG_DC if dc_entries else 0)
|
block_flags = ((CACH_FLAG_DC if dc_entries else 0)
|
||||||
@@ -552,13 +558,23 @@ class SrasFile:
|
|||||||
f.truncate()
|
f.truncate()
|
||||||
f.flush()
|
f.flush()
|
||||||
os.fsync(f.fileno())
|
os.fsync(f.fileno())
|
||||||
# Version-byte flip last: if the process dies before this point,
|
# Version-byte flip last: when converting a v6 source (self.version
|
||||||
# the file is still readable as plain v6 (v6 parsing only
|
# was 6 on entry), if the process dies before this point the file
|
||||||
# bounds-checks per-angle offset+nbytes <= file_size, it never
|
# is still readable as plain v6 (v6 parsing only bounds-checks
|
||||||
# asserts exactly how many bytes follow the last angle) — so an
|
# per-angle offset+nbytes <= file_size, it never asserts exactly
|
||||||
# interrupted write can never corrupt the file, only leave
|
# how many bytes follow the last angle) — so an interrupted write
|
||||||
# harmless trailing bytes that the next successful write
|
# can never corrupt the file, only leave harmless trailing bytes
|
||||||
# overwrites via this same deterministic cache offset.
|
# that the next successful write overwrites via this same
|
||||||
|
# deterministic cache offset.
|
||||||
|
#
|
||||||
|
# That guarantee does NOT extend to updating an already-v7 file:
|
||||||
|
# the version byte here is already 7 before this call, so a crash
|
||||||
|
# during the payload write above (before flush/fsync/truncate)
|
||||||
|
# can leave a cache tail that mixes a prefix of the new payload
|
||||||
|
# with a stale suffix of the old one, and this method has no
|
||||||
|
# protection against that case (no atomic rename — the tail is
|
||||||
|
# rewritten in place to avoid copying the, potentially huge,
|
||||||
|
# waveform data that precedes it).
|
||||||
f.seek(4)
|
f.seek(4)
|
||||||
f.write(struct.pack("B", 7))
|
f.write(struct.pack("B", 7))
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|||||||
+300
-15
@@ -29,9 +29,10 @@ from PyQt6.QtCore import QObject, Qt, QThread, pyqtSignal
|
|||||||
from PyQt6.QtGui import QAction, QKeyEvent
|
from PyQt6.QtGui import QAction, QKeyEvent
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
QApplication, QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||||
QDoubleSpinBox, QFileDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout,
|
QDoubleSpinBox, QFileDialog, QFormLayout, QFrame, QGridLayout, QGroupBox,
|
||||||
QLabel, QMainWindow, QMessageBox, QProgressDialog, QPushButton, QRadioButton,
|
QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QProgressDialog,
|
||||||
QScrollArea, QSizePolicy, QSpinBox, QSplitter, QVBoxLayout, QWidget,
|
QPushButton, QRadioButton, QScrollArea, QSizePolicy, QSpinBox, QSplitter,
|
||||||
|
QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
import sras_compute as compute
|
import sras_compute as compute
|
||||||
@@ -45,8 +46,9 @@ from sras_format import (
|
|||||||
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
|
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
|
||||||
)
|
)
|
||||||
from sras_workers import (
|
from sras_workers import (
|
||||||
AngleAlignmentWorker, BatchCacheWorker, Ch4MaskWorker, ComputeWorker,
|
AngleAlignmentWorker, BatchCacheWorker, BatchExportWorker, Ch4MaskWorker,
|
||||||
CrossCorrelateWorker, DcPrecomputeWorker, LoadWorker,
|
ComputeWorker, CrossCorrelateWorker, DcPrecomputeWorker, ExportChannel,
|
||||||
|
LoadWorker,
|
||||||
)
|
)
|
||||||
|
|
||||||
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
||||||
@@ -743,6 +745,137 @@ class FftOptionsDialog(QDialog):
|
|||||||
return max(1, self._spin_pad.value())
|
return max(1, self._spin_pad.value())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Batch export dialog (Export -> Batch Export Images...)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BatchExportDialog(QDialog):
|
||||||
|
"""Configure a batch PNG export of DC/RF/Velocity maps across every angle
|
||||||
|
of the currently open file.
|
||||||
|
|
||||||
|
Each channel's colorbar range is entered here and held fixed for every
|
||||||
|
exported angle (rather than auto-scaled per image, today's live-view
|
||||||
|
default) so the exported images are directly comparable to each other.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# (ch_idx, is_velocity, label, colorbar unit, filename tag)
|
||||||
|
_ROWS = [
|
||||||
|
(CH1_IDX, False, CH_LABELS[CH1_IDX], _CHANNEL_DISPLAY[CH1_IDX][2],
|
||||||
|
CH_NAMES[CH1_IDX]),
|
||||||
|
(CH3_IDX, False, CH_LABELS[CH3_IDX], _CHANNEL_DISPLAY[CH3_IDX][2],
|
||||||
|
CH_NAMES[CH3_IDX]),
|
||||||
|
(CH4_IDX, False, CH_LABELS[CH4_IDX], _CHANNEL_DISPLAY[CH4_IDX][2],
|
||||||
|
CH_NAMES[CH4_IDX]),
|
||||||
|
(CH1_IDX, True, CH_LABELS[VELOCITY_MODE_IDX],
|
||||||
|
_CHANNEL_DISPLAY[VELOCITY_MODE_IDX][2], CH_NAMES[VELOCITY_MODE_IDX]),
|
||||||
|
]
|
||||||
|
# DC is cheap and precomputed on load; CH1/Velocity need a per-pixel FFT
|
||||||
|
# that can take minutes, so don't default to exporting them (same
|
||||||
|
# rationale as SrasViewerWindow._on_load_done's channel default).
|
||||||
|
_DEFAULT_CHECKED = {CH3_IDX, CH4_IDX}
|
||||||
|
|
||||||
|
def __init__(self, parent, *, default_dir: str, default_prefix: str,
|
||||||
|
default_ranges: dict[tuple[int, bool], tuple[float, float]]):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Batch Export Images")
|
||||||
|
self.setModal(True)
|
||||||
|
self.setMinimumWidth(460)
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# ---- Output ------------------------------------------------------
|
||||||
|
grp_out, ol = _group("Output")
|
||||||
|
dir_row = QHBoxLayout()
|
||||||
|
self._edit_dir = QLineEdit(default_dir)
|
||||||
|
btn_browse = QPushButton("Browse…")
|
||||||
|
btn_browse.clicked.connect(self._on_browse)
|
||||||
|
dir_row.addWidget(self._edit_dir)
|
||||||
|
dir_row.addWidget(btn_browse)
|
||||||
|
out_form = _form()
|
||||||
|
out_form.addRow("Folder:", dir_row)
|
||||||
|
self._edit_prefix = QLineEdit(default_prefix)
|
||||||
|
out_form.addRow("File prefix:", self._edit_prefix)
|
||||||
|
ol.addLayout(out_form)
|
||||||
|
layout.addWidget(grp_out)
|
||||||
|
|
||||||
|
# ---- Channels ------------------------------------------------------
|
||||||
|
grp_ch, cl = _group("Channels (fixed range, applied to every angle)")
|
||||||
|
grid = QGridLayout()
|
||||||
|
grid.setHorizontalSpacing(8)
|
||||||
|
grid.setVerticalSpacing(6)
|
||||||
|
grid.addWidget(_wrap_label("min:", _CSS_HINT), 0, 1)
|
||||||
|
grid.addWidget(_wrap_label("max:", _CSS_HINT), 0, 2)
|
||||||
|
|
||||||
|
self._rows: list[tuple[QCheckBox, QDoubleSpinBox, QDoubleSpinBox]] = []
|
||||||
|
for i, (ch_idx, is_velocity, label, unit, _tag) in enumerate(self._ROWS, 1):
|
||||||
|
chk = QCheckBox(label + (f" [{unit}]" if unit else ""))
|
||||||
|
chk.setChecked(not is_velocity and ch_idx in self._DEFAULT_CHECKED)
|
||||||
|
vmin, vmax = default_ranges.get((ch_idx, is_velocity), (0.0, 1.0))
|
||||||
|
spin_min, spin_max = QDoubleSpinBox(), QDoubleSpinBox()
|
||||||
|
for spin, val in ((spin_min, vmin), (spin_max, vmax)):
|
||||||
|
spin.setRange(-1e9, 1e9)
|
||||||
|
spin.setDecimals(4)
|
||||||
|
spin.setMinimumWidth(_SPIN_MIN_W)
|
||||||
|
spin.setValue(val)
|
||||||
|
grid.addWidget(chk, i, 0)
|
||||||
|
grid.addWidget(spin_min, i, 1)
|
||||||
|
grid.addWidget(spin_max, i, 2)
|
||||||
|
self._rows.append((chk, spin_min, spin_max))
|
||||||
|
cl.addLayout(grid)
|
||||||
|
layout.addWidget(grp_ch)
|
||||||
|
|
||||||
|
# ---- Buttons --------------------------------------------------
|
||||||
|
buttons = QDialogButtonBox()
|
||||||
|
buttons.addButton("Export", QDialogButtonBox.ButtonRole.AcceptRole
|
||||||
|
).clicked.connect(self.accept)
|
||||||
|
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
|
||||||
|
).clicked.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
def _on_browse(self):
|
||||||
|
d = QFileDialog.getExistingDirectory(
|
||||||
|
self, "Select Output Folder", self._edit_dir.text())
|
||||||
|
if d:
|
||||||
|
self._edit_dir.setText(d)
|
||||||
|
|
||||||
|
def accept(self):
|
||||||
|
"""Validate before closing — Cancel bypasses this entirely."""
|
||||||
|
if not self.get_prefix():
|
||||||
|
QMessageBox.warning(self, "Batch Export", "Enter a file prefix.")
|
||||||
|
return
|
||||||
|
if not self.get_output_dir():
|
||||||
|
QMessageBox.warning(self, "Batch Export", "Choose an output folder.")
|
||||||
|
return
|
||||||
|
selected = self.get_selected_channels()
|
||||||
|
if not selected:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Batch Export", "Select at least one channel to export.")
|
||||||
|
return
|
||||||
|
for ch in selected:
|
||||||
|
if not ch.vmin < ch.vmax:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Batch Export", f"{ch.label}: min must be less than max.")
|
||||||
|
return
|
||||||
|
super().accept()
|
||||||
|
|
||||||
|
def get_output_dir(self) -> str:
|
||||||
|
return self._edit_dir.text().strip()
|
||||||
|
|
||||||
|
def get_prefix(self) -> str:
|
||||||
|
return self._edit_prefix.text().strip()
|
||||||
|
|
||||||
|
def get_selected_channels(self) -> list[ExportChannel]:
|
||||||
|
result = []
|
||||||
|
for (chk, spin_min, spin_max), (ch_idx, is_velocity, label, unit, tag) in zip(
|
||||||
|
self._rows, self._ROWS):
|
||||||
|
if chk.isChecked():
|
||||||
|
result.append(ExportChannel(
|
||||||
|
ch_idx=ch_idx, is_velocity=is_velocity,
|
||||||
|
vmin=spin_min.value(), vmax=spin_max.value(),
|
||||||
|
label=label, unit=unit, tag=tag))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Manual alignment dialog (Fusion -> Manual Alignment...)
|
# Manual alignment dialog (Fusion -> Manual Alignment...)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1399,6 +1532,10 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
# Convert menu: batch DC/FFT compute-and-store (v6 -> v7)
|
# Convert menu: batch DC/FFT compute-and-store (v6 -> v7)
|
||||||
self._batch_errors: list[str] = []
|
self._batch_errors: list[str] = []
|
||||||
|
|
||||||
|
# Export menu: batch image export
|
||||||
|
self._export_errors: list[str] = []
|
||||||
|
self._export_ok_count: int = 0
|
||||||
|
|
||||||
# Display-only settings (colormap, grating) never trigger a
|
# Display-only settings (colormap, grating) never trigger a
|
||||||
# recompute — they're applied to cached data on redraw. DC images
|
# recompute — they're applied to cached data on redraw. DC images
|
||||||
# (CH3/CH4) are cheap and precomputed for every angle in the
|
# (CH3/CH4) are cheap and precomputed for every angle in the
|
||||||
@@ -1412,6 +1549,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._fft_cache: dict[tuple[int, bool, int | None, float], np.ndarray] = {}
|
self._fft_cache: dict[tuple[int, bool, int | None, float], np.ndarray] = {}
|
||||||
self._dc_precompute_worker: DcPrecomputeWorker | None = None
|
self._dc_precompute_worker: DcPrecomputeWorker | None = None
|
||||||
self._dc_generation: int = 0
|
self._dc_generation: int = 0
|
||||||
|
self._compute_generation: int = 0
|
||||||
|
self._pending_compute_generation: int | None = None
|
||||||
|
|
||||||
# Angle alignment ("Fusion" menu)
|
# Angle alignment ("Fusion" menu)
|
||||||
self._alignment_result = None
|
self._alignment_result = None
|
||||||
@@ -1447,6 +1586,19 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
guarantee the OS thread has joined. Dropping the last reference to
|
guarantee the OS thread has joined. Dropping the last reference to
|
||||||
a QThread whose thread is still running logs "QThread: Destroyed
|
a QThread whose thread is still running logs "QThread: Destroyed
|
||||||
while thread is still running" and aborts, so wait() first.
|
while thread is still running" and aborts, so wait() first.
|
||||||
|
|
||||||
|
3. The thread must quit once worker.run() returns, regardless of
|
||||||
|
which signal (if any) run() emitted on its way out. quit_on exists
|
||||||
|
so a worker whose real completion is signalled asynchronously
|
||||||
|
from *within* run() (e.g. angle_done fired from pool threads
|
||||||
|
before run() itself returns) can still opt into quitting earlier,
|
||||||
|
but relying on quit_on alone is fragile: a worker whose run()
|
||||||
|
emits `error` instead of `finished` on an exception path, at a
|
||||||
|
call site that forgot to add "error" to quit_on, would otherwise
|
||||||
|
never quit — silently wedging that job in self._jobs forever.
|
||||||
|
Quitting unconditionally after run() returns closes that gap for
|
||||||
|
every worker, present and future, without requiring every call
|
||||||
|
site to enumerate every signal run() might emit.
|
||||||
"""
|
"""
|
||||||
if key in self._jobs:
|
if key in self._jobs:
|
||||||
return False
|
return False
|
||||||
@@ -1454,7 +1606,11 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
thread = QThread()
|
thread = QThread()
|
||||||
self._jobs[key] = (thread, worker, on_done) # claim before anything pumps
|
self._jobs[key] = (thread, worker, on_done) # claim before anything pumps
|
||||||
worker.moveToThread(thread)
|
worker.moveToThread(thread)
|
||||||
thread.started.connect(worker.run)
|
|
||||||
|
def _run_then_quit():
|
||||||
|
worker.run()
|
||||||
|
thread.quit()
|
||||||
|
thread.started.connect(_run_then_quit)
|
||||||
for signal_name, slot in connect:
|
for signal_name, slot in connect:
|
||||||
getattr(worker, signal_name).connect(slot)
|
getattr(worker, signal_name).connect(slot)
|
||||||
for signal_name in quit_on:
|
for signal_name in quit_on:
|
||||||
@@ -1792,6 +1948,15 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._batch_fft_act.triggered.connect(lambda: self._on_batch_compute("fft"))
|
self._batch_fft_act.triggered.connect(lambda: self._on_batch_compute("fft"))
|
||||||
convert_menu.addAction(self._batch_fft_act)
|
convert_menu.addAction(self._batch_fft_act)
|
||||||
|
|
||||||
|
export_menu = menubar.addMenu("&Export")
|
||||||
|
self._batch_export_act = QAction("&Batch Export Images…", self)
|
||||||
|
self._batch_export_act.setStatusTip(
|
||||||
|
"Export DC/RF/Velocity map PNGs for every angle of the open "
|
||||||
|
"file, with a fixed colorbar range per channel.")
|
||||||
|
self._batch_export_act.setEnabled(False)
|
||||||
|
self._batch_export_act.triggered.connect(self._on_batch_export)
|
||||||
|
export_menu.addAction(self._batch_export_act)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Drag-and-drop
|
# Drag-and-drop
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1826,10 +1991,10 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
self.btn_open.setEnabled(False)
|
self.btn_open.setEnabled(False)
|
||||||
self.statusBar().showMessage(f"Loading {Path(path).name}…")
|
self.statusBar().showMessage(f"Loading {Path(path).name}…")
|
||||||
self._show_progress("main", f"Loading {Path(path).name}…")
|
self._show_progress("load", f"Loading {Path(path).name}…")
|
||||||
|
|
||||||
def _on_load_done(self, sras):
|
def _on_load_done(self, sras):
|
||||||
self._close_progress("main")
|
self._close_progress("load")
|
||||||
self.btn_open.setEnabled(True)
|
self.btn_open.setEnabled(True)
|
||||||
if sras is None:
|
if sras is None:
|
||||||
return
|
return
|
||||||
@@ -1850,6 +2015,7 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._dc_cache = {}
|
self._dc_cache = {}
|
||||||
self._fft_cache = {}
|
self._fft_cache = {}
|
||||||
self._dc_generation += 1
|
self._dc_generation += 1
|
||||||
|
self._compute_generation += 1
|
||||||
self.lbl_dc_precompute.setText("")
|
self.lbl_dc_precompute.setText("")
|
||||||
|
|
||||||
self._alignment_result = None
|
self._alignment_result = None
|
||||||
@@ -1988,6 +2154,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._manual_align_act.setEnabled(
|
self._manual_align_act.setEnabled(
|
||||||
has_file and s.n_angles > 1 and not self._job_running("align"))
|
has_file and s.n_angles > 1 and not self._job_running("align"))
|
||||||
self.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None)
|
self.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None)
|
||||||
|
|
||||||
|
self._batch_export_act.setEnabled(has_file and not self._job_running("export"))
|
||||||
self._update_roi_ui()
|
self._update_roi_ui()
|
||||||
|
|
||||||
def _on_channel_changed(self):
|
def _on_channel_changed(self):
|
||||||
@@ -2322,6 +2490,11 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._pending_bg_sub = self.chk_bg_sub.isChecked()
|
self._pending_bg_sub = self.chk_bg_sub.isChecked()
|
||||||
self._pending_threshold = self.spin_threshold_mv.value()
|
self._pending_threshold = self.spin_threshold_mv.value()
|
||||||
self._pending_fft_pad_factor = self._fft_pad_factor
|
self._pending_fft_pad_factor = self._fft_pad_factor
|
||||||
|
# Snapshot which file this compute belongs to, so a result that lands
|
||||||
|
# after a *different* file has since been loaded (nothing blocks
|
||||||
|
# opening a new file while a compute is in flight) gets discarded
|
||||||
|
# instead of being cached/displayed against the wrong SrasFile.
|
||||||
|
self._pending_compute_generation = self._compute_generation
|
||||||
|
|
||||||
worker = ComputeWorker(
|
worker = ComputeWorker(
|
||||||
self._sras, angle_idx, ch_idx,
|
self._sras, angle_idx, ch_idx,
|
||||||
@@ -2347,13 +2520,13 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
if is_fft:
|
if is_fft:
|
||||||
self.statusBar().showMessage("Computing FFT…")
|
self.statusBar().showMessage("Computing FFT…")
|
||||||
self._show_progress(
|
self._show_progress(
|
||||||
"main",
|
"compute",
|
||||||
f"Computing FFT for angle {angle_idx}…\n"
|
f"Computing FFT for angle {angle_idx}…\n"
|
||||||
"This can take a while on a large scan — result is cached "
|
"This can take a while on a large scan — result is cached "
|
||||||
"so revisiting this angle/mode/threshold will be instant.")
|
"so revisiting this angle/mode/threshold will be instant.")
|
||||||
else:
|
else:
|
||||||
self.statusBar().showMessage("Computing DC image…")
|
self.statusBar().showMessage("Computing DC image…")
|
||||||
self._show_progress("main", f"Computing DC image for angle {angle_idx}…")
|
self._show_progress("compute", f"Computing DC image for angle {angle_idx}…")
|
||||||
|
|
||||||
def _after_compute(self):
|
def _after_compute(self):
|
||||||
"""If settings changed while the compute was running, re-dispatch
|
"""If settings changed while the compute was running, re-dispatch
|
||||||
@@ -2367,7 +2540,10 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._refresh_display()
|
self._refresh_display()
|
||||||
|
|
||||||
def _on_compute_done(self, result):
|
def _on_compute_done(self, result):
|
||||||
self._close_progress("main")
|
self._close_progress("compute")
|
||||||
|
generation, self._pending_compute_generation = self._pending_compute_generation, None
|
||||||
|
if generation != self._compute_generation:
|
||||||
|
return # a different file was loaded while this was computing — discard
|
||||||
if result is None:
|
if result is None:
|
||||||
return # cancelled mid-compute; the partial image must not cache
|
return # cancelled mid-compute; the partial image must not cache
|
||||||
angle_idx = self._pending_angle
|
angle_idx = self._pending_angle
|
||||||
@@ -2554,6 +2730,106 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._batch_dc_act.setEnabled(True)
|
self._batch_dc_act.setEnabled(True)
|
||||||
self._batch_fft_act.setEnabled(True)
|
self._batch_fft_act.setEnabled(True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Export menu: batch image export
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _export_default_ranges(self) -> dict[tuple[int, bool], tuple[float, float]]:
|
||||||
|
"""Best-effort default min/max per exportable channel, from whatever
|
||||||
|
is already cached — this must never trigger a fresh compute just to
|
||||||
|
seed the dialog (CH1/Velocity in particular can be expensive)."""
|
||||||
|
s = self._sras
|
||||||
|
ranges: dict[tuple[int, bool], tuple[float, float]] = {}
|
||||||
|
|
||||||
|
for ch_idx, precomputed in ((CH3_IDX, s.precomputed_dc3_mv),
|
||||||
|
(CH4_IDX, s.precomputed_dc4_mv)):
|
||||||
|
arrays = [self._dc_cache.get((a, ch_idx)) for a in range(s.n_angles)]
|
||||||
|
arrays = [a for a in arrays if a is not None]
|
||||||
|
if not arrays:
|
||||||
|
arrays = [a for a in precomputed if a is not None]
|
||||||
|
if arrays:
|
||||||
|
ranges[(ch_idx, False)] = (
|
||||||
|
float(min(a.min() for a in arrays)),
|
||||||
|
float(max(a.max() for a in arrays)))
|
||||||
|
|
||||||
|
freq_arrays = list(self._fft_cache.values())
|
||||||
|
if not freq_arrays:
|
||||||
|
freq_arrays = [a for a in s.precomputed_freq_mhz if a is not None]
|
||||||
|
if freq_arrays:
|
||||||
|
fmin = float(min(a.min() for a in freq_arrays))
|
||||||
|
fmax = float(max(a.max() for a in freq_arrays))
|
||||||
|
ranges[(CH1_IDX, False)] = (fmin, fmax)
|
||||||
|
grating = self.spin_grating_um.value()
|
||||||
|
ranges[(CH1_IDX, True)] = (fmin * grating, fmax * grating)
|
||||||
|
|
||||||
|
return ranges
|
||||||
|
|
||||||
|
def _on_batch_export(self):
|
||||||
|
if self._sras is None or self._job_running("export"):
|
||||||
|
return
|
||||||
|
s = self._sras
|
||||||
|
|
||||||
|
dlg = BatchExportDialog(
|
||||||
|
self, default_dir=str(s.path.parent), default_prefix=s.path.stem,
|
||||||
|
default_ranges=self._export_default_ranges())
|
||||||
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||||
|
return
|
||||||
|
|
||||||
|
output_dir = dlg.get_output_dir()
|
||||||
|
try:
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Batch Export", f"Could not create output folder:\n{exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
channels = dlg.get_selected_channels()
|
||||||
|
self._export_errors = []
|
||||||
|
self._export_ok_count = 0
|
||||||
|
worker = BatchExportWorker(
|
||||||
|
s, channels, output_dir, dlg.get_prefix(),
|
||||||
|
cmap=self.combo_cmap.currentText(),
|
||||||
|
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||||||
|
dc_threshold_mv=self.spin_threshold_mv.value(),
|
||||||
|
n_fft=self._current_n_fft(), grating_um=self.spin_grating_um.value())
|
||||||
|
started = self._run_worker(
|
||||||
|
"export", worker,
|
||||||
|
connect=(
|
||||||
|
("progress", lambda pct: self._set_progress("export", pct)),
|
||||||
|
("file_done", self._on_export_file_done),
|
||||||
|
("finished", self._on_export_finished),
|
||||||
|
),
|
||||||
|
on_done=self._after_export,
|
||||||
|
)
|
||||||
|
if not started:
|
||||||
|
return # a second trigger snuck in while the dialog was open
|
||||||
|
|
||||||
|
self._batch_export_act.setEnabled(False)
|
||||||
|
self._show_progress(
|
||||||
|
"export", f"Exporting images to {output_dir}…", maximum=100)
|
||||||
|
|
||||||
|
def _on_export_file_done(self, path: str, err: str):
|
||||||
|
if err:
|
||||||
|
self._export_errors.append(f"{Path(path).name if path else '?'} — {err}")
|
||||||
|
else:
|
||||||
|
self._export_ok_count += 1
|
||||||
|
self._show_progress("export", f"Wrote {Path(path).name}…")
|
||||||
|
|
||||||
|
def _on_export_finished(self):
|
||||||
|
self._close_progress("export")
|
||||||
|
n_ok = self._export_ok_count
|
||||||
|
n_failed = len(self._export_errors)
|
||||||
|
if n_failed:
|
||||||
|
summary = (f"Batch export: {n_ok} image(s) written, {n_failed} "
|
||||||
|
f"failed: {'; '.join(self._export_errors)}")
|
||||||
|
else:
|
||||||
|
summary = f"Batch export: {n_ok} image(s) written."
|
||||||
|
self.statusBar().showMessage(summary)
|
||||||
|
self._export_errors = []
|
||||||
|
|
||||||
|
def _after_export(self):
|
||||||
|
self._update_controls_enabled(self._sras is not None)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Fusion: angle alignment
|
# Fusion: angle alignment
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -2568,7 +2844,7 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
started = self._run_worker(
|
started = self._run_worker(
|
||||||
"align", AngleAlignmentWorker(self._sras, ref_idx, threshold_mv),
|
"align", AngleAlignmentWorker(self._sras, ref_idx, threshold_mv),
|
||||||
connect=(
|
connect=(
|
||||||
("progress", lambda pct: self._set_progress("main", pct)),
|
("progress", lambda pct: self._set_progress("align", pct)),
|
||||||
("finished", lambda result, err, g=generation:
|
("finished", lambda result, err, g=generation:
|
||||||
self._on_alignment_done(g, result, err)),
|
self._on_alignment_done(g, result, err)),
|
||||||
),
|
),
|
||||||
@@ -2579,13 +2855,13 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
|
|
||||||
self._alignment_act.setEnabled(False)
|
self._alignment_act.setEnabled(False)
|
||||||
self._show_progress(
|
self._show_progress(
|
||||||
"main",
|
"align",
|
||||||
f"Computing angle alignment ({self._sras.n_angles} angles, "
|
f"Computing angle alignment ({self._sras.n_angles} angles, "
|
||||||
f"ref=angle 0, CH4 mask ≥ {threshold_mv:.3f} mV)…",
|
f"ref=angle 0, CH4 mask ≥ {threshold_mv:.3f} mV)…",
|
||||||
maximum=100)
|
maximum=100)
|
||||||
|
|
||||||
def _on_alignment_done(self, generation: int, result, error_msg: str):
|
def _on_alignment_done(self, generation: int, result, error_msg: str):
|
||||||
self._close_progress("main")
|
self._close_progress("align")
|
||||||
if generation != self._alignment_generation:
|
if generation != self._alignment_generation:
|
||||||
return # a new file was loaded while this was computing — discard
|
return # a new file was loaded while this was computing — discard
|
||||||
if error_msg:
|
if error_msg:
|
||||||
@@ -2639,7 +2915,16 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
dlg.alignment_saved.connect(self._on_manual_alignment_saved)
|
dlg.alignment_saved.connect(self._on_manual_alignment_saved)
|
||||||
dlg.alignment_cleared.connect(self._on_manual_alignment_cleared)
|
dlg.alignment_cleared.connect(self._on_manual_alignment_cleared)
|
||||||
dlg.finished.connect(self._on_manual_align_dialog_closed)
|
dlg.finished.connect(self._on_manual_align_dialog_closed)
|
||||||
dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
# Deliberately NOT WA_DeleteOnClose: Ch4MaskWorker/CrossCorrelateWorker
|
||||||
|
# have no stop() and are owned by this window's _jobs (not the
|
||||||
|
# dialog), so a background mask-fetch/cross-correlate job can still be
|
||||||
|
# running after the dialog closes. Its signals stay connected to this
|
||||||
|
# dialog's own bound methods (_on_mask_angle_done, _finish_mask_prep,
|
||||||
|
# etc.) until it finishes — if Qt had already destroyed the dialog's
|
||||||
|
# C++ object, those late callbacks would raise "wrapped C/C++ object
|
||||||
|
# ... has been deleted". Leaving the object merely hidden (not
|
||||||
|
# destroyed) lets them land harmlessly; it is garbage-collected once
|
||||||
|
# the job's own references to it are released.
|
||||||
self._manual_align_dialog = dlg
|
self._manual_align_dialog = dlg
|
||||||
dlg.show()
|
dlg.show()
|
||||||
|
|
||||||
|
|||||||
+139
-1
@@ -10,15 +10,19 @@ everything they need through their constructor and hand results back by signal.
|
|||||||
import os
|
import os
|
||||||
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
||||||
from concurrent.futures.process import BrokenProcessPool
|
from concurrent.futures.process import BrokenProcessPool
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
||||||
|
from matplotlib.figure import Figure
|
||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
import sras_compute as compute
|
import sras_compute as compute
|
||||||
from sras_compute import (
|
from sras_compute import (
|
||||||
cache_file, compute_angle_alignment, compute_rf_image, dc_image_mv,
|
cache_file, compute_angle_alignment, compute_rf_image, dc_image_mv,
|
||||||
)
|
)
|
||||||
from sras_format import CH3_IDX, CH4_IDX, SrasFile
|
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
|
||||||
|
|
||||||
# Concurrency caps. Batch conversion runs one process per file, and each of
|
# Concurrency caps. Batch conversion runs one process per file, and each of
|
||||||
# those processes threads internally, so the two must be divided rather than
|
# those processes threads internally, so the two must be divided rather than
|
||||||
@@ -281,6 +285,140 @@ class BatchCacheWorker(QObject):
|
|||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExportChannel:
|
||||||
|
"""One row of a batch image export: which raw channel to read, whether
|
||||||
|
to apply the Velocity post-multiply, and the fixed display range/labels
|
||||||
|
to render it with.
|
||||||
|
|
||||||
|
Kept free of sras_viewer's display constants (CH_LABELS, VELOCITY_MODE_IDX,
|
||||||
|
etc.) so BatchExportWorker has no dependency on the GUI module — the
|
||||||
|
caller resolves labels/units/filename tags once, up front.
|
||||||
|
"""
|
||||||
|
ch_idx: int # CH1_IDX, CH3_IDX, or CH4_IDX -- which raw data to read
|
||||||
|
is_velocity: bool # True only for the derived Velocity map (post-multiply of CH1 freq)
|
||||||
|
vmin: float
|
||||||
|
vmax: float
|
||||||
|
label: str # e.g. "CH3 -- Bias A (DC mean)"
|
||||||
|
unit: str # colorbar units, e.g. "mV"
|
||||||
|
tag: str # filename tag: "CH1", "CH3", "CH4", "VEL"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_map_png(img: np.ndarray, extent: list[float], *, cmap: str,
|
||||||
|
vmin: float, vmax: float, title: str, colorbar_label: str,
|
||||||
|
out_path: str):
|
||||||
|
"""Render one map image to *out_path* with a fixed vmin/vmax, using a
|
||||||
|
headless Agg canvas so this never touches the GUI thread's interactive
|
||||||
|
matplotlib backend. Layout mirrors ImageCanvas.show_image."""
|
||||||
|
fig = Figure(figsize=(7, 5), tight_layout=True)
|
||||||
|
FigureCanvasAgg(fig)
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
im = ax.imshow(img, aspect="auto", origin="upper", extent=extent,
|
||||||
|
cmap=cmap, vmin=vmin, vmax=vmax, interpolation="nearest")
|
||||||
|
cb = fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
||||||
|
if colorbar_label:
|
||||||
|
cb.set_label(colorbar_label)
|
||||||
|
ax.set_xlabel("X (mm)")
|
||||||
|
ax.set_ylabel("Y (mm)")
|
||||||
|
ax.set_title(title)
|
||||||
|
fig.savefig(out_path, dpi=150)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchExportWorker(CancellableWorker):
|
||||||
|
"""Renders and saves one PNG per (angle, selected channel) for an
|
||||||
|
already-open SrasFile, with each channel's vmin/vmax held fixed across
|
||||||
|
every angle so the colorbar is directly comparable image to image.
|
||||||
|
|
||||||
|
Takes the SrasFile object directly (like ComputeWorker/DcPrecomputeWorker)
|
||||||
|
rather than a path -- this runs against the file already open in the GUI,
|
||||||
|
not an arbitrary batch of files, so there is no need to reopen it in a
|
||||||
|
subprocess the way BatchCacheWorker does.
|
||||||
|
|
||||||
|
Emits progress(int) (0-100 over all angle x channel pairs), file_done(str,
|
||||||
|
str) (output path, error message or ""), and finished() -- same shape as
|
||||||
|
BatchCacheWorker.
|
||||||
|
"""
|
||||||
|
progress = pyqtSignal(int)
|
||||||
|
file_done = pyqtSignal(str, str)
|
||||||
|
finished = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, sras: SrasFile, channels: list[ExportChannel],
|
||||||
|
output_dir: str, prefix: str, *, cmap: str,
|
||||||
|
apply_bg_sub: bool, dc_threshold_mv: float,
|
||||||
|
n_fft: int | None, grating_um: float):
|
||||||
|
super().__init__()
|
||||||
|
self._sras = sras
|
||||||
|
self._channels = channels
|
||||||
|
self._output_dir = Path(output_dir)
|
||||||
|
self._prefix = prefix
|
||||||
|
self._cmap = cmap
|
||||||
|
self._apply_bg_sub = apply_bg_sub
|
||||||
|
self._dc_threshold_mv = dc_threshold_mv
|
||||||
|
self._n_fft = n_fft
|
||||||
|
self._grating_um = grating_um
|
||||||
|
|
||||||
|
def _angle_extent(self, angle_idx: int) -> list[float]:
|
||||||
|
s = self._sras
|
||||||
|
x_axis = s.x_axis_mm(angle_idx)
|
||||||
|
y_axis = s.y_positions_mm(angle_idx)
|
||||||
|
dx = x_axis[1] - x_axis[0] if len(x_axis) > 1 else s.pixel_x_mm
|
||||||
|
dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0
|
||||||
|
return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
|
||||||
|
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
s = self._sras
|
||||||
|
n_angles = s.n_angles
|
||||||
|
total = n_angles * len(self._channels)
|
||||||
|
done = 0
|
||||||
|
needs_freq = any(c.ch_idx == CH1_IDX for c in self._channels)
|
||||||
|
|
||||||
|
for angle_idx in range(n_angles):
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
extent = self._angle_extent(angle_idx)
|
||||||
|
angle_deg = s.angles_deg[angle_idx]
|
||||||
|
|
||||||
|
freq_mhz = None
|
||||||
|
if needs_freq:
|
||||||
|
freq_mhz = compute_rf_image(
|
||||||
|
s, angle_idx, dc_threshold_mv=self._dc_threshold_mv,
|
||||||
|
apply_bg_sub=self._apply_bg_sub, n_fft=self._n_fft,
|
||||||
|
should_stop=self._stopped)
|
||||||
|
|
||||||
|
for channel in self._channels:
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
out_path = (self._output_dir
|
||||||
|
/ f"{self._prefix}_angle{angle_idx:02d}_{channel.tag}.png")
|
||||||
|
try:
|
||||||
|
if channel.ch_idx == CH1_IDX:
|
||||||
|
img = (freq_mhz * self._grating_um if channel.is_velocity
|
||||||
|
else freq_mhz)
|
||||||
|
else:
|
||||||
|
img = dc_image_mv(s, angle_idx, channel.ch_idx,
|
||||||
|
should_stop=self._stopped)
|
||||||
|
title = f"{channel.tag} | {angle_deg:.1f}°"
|
||||||
|
colorbar_label = (f"{channel.label} ({channel.unit})"
|
||||||
|
if channel.unit else channel.label)
|
||||||
|
_render_map_png(
|
||||||
|
img, extent, cmap=self._cmap,
|
||||||
|
vmin=channel.vmin, vmax=channel.vmax,
|
||||||
|
title=title, colorbar_label=colorbar_label,
|
||||||
|
out_path=str(out_path))
|
||||||
|
self.file_done.emit(str(out_path), "")
|
||||||
|
except Exception as exc:
|
||||||
|
self.file_done.emit(str(out_path), str(exc))
|
||||||
|
done += 1
|
||||||
|
self.progress.emit(int(done / max(1, total) * 100))
|
||||||
|
|
||||||
|
self.finished.emit()
|
||||||
|
except Exception as exc:
|
||||||
|
self.file_done.emit("", str(exc))
|
||||||
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
class AngleAlignmentWorker(QObject):
|
class AngleAlignmentWorker(QObject):
|
||||||
"""Computes rotation+translation alignment for every angle in *sras*,
|
"""Computes rotation+translation alignment for every angle in *sras*,
|
||||||
referenced to *ref_angle_idx*, from each angle's binarized CH4 mask.
|
referenced to *ref_angle_idx*, from each angle's binarized CH4 mask.
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ Usage:
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import struct
|
import struct
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
HDR_FMT_V6 = ">4sBHfffffffIdBB"
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
GEO_FMT_V6 = ">ffIH"
|
|
||||||
|
# Packed straight from sras_format.py's own constants (not a hand-copy) so
|
||||||
|
# this generator can never silently drift from what the real parser expects.
|
||||||
|
from sras_format import GEO_FMT_V6, HDR_FMT as HDR_FMT_LEGACY, HDR_FMT_V6 # noqa: E402
|
||||||
|
|
||||||
# Per-angle (n_rows, n_frames) — deliberately different per angle so ragged
|
# Per-angle (n_rows, n_frames) — deliberately different per angle so ragged
|
||||||
# geometry handling is actually exercised.
|
# geometry handling is actually exercised.
|
||||||
@@ -125,9 +129,6 @@ def write(path: Path, n_angles: int = 3, seed: int = 0,
|
|||||||
return meta
|
return meta
|
||||||
|
|
||||||
|
|
||||||
HDR_FMT_LEGACY = ">4sBHHffffIIdBB"
|
|
||||||
|
|
||||||
|
|
||||||
def write_legacy(path: Path, version: int = 4, n_angles: int = 2,
|
def write_legacy(path: Path, version: int = 4, n_angles: int = 2,
|
||||||
n_rows: int = 4, n_frames: int = 10,
|
n_rows: int = 4, n_frames: int = 10,
|
||||||
samples_per_frame: int = 32, seed: int = 0) -> dict:
|
samples_per_frame: int = 32, seed: int = 0) -> dict:
|
||||||
|
|||||||
+10
-3
@@ -288,23 +288,30 @@ def test_legacy_parse_and_average(scratch: Path):
|
|||||||
check("background preserved",
|
check("background preserved",
|
||||||
np.array_equal(avg.background, SrasFile(str(src)).background))
|
np.array_equal(avg.background, SrasFile(str(src)).background))
|
||||||
src_data = meta["data"]
|
src_data = meta["data"]
|
||||||
expect0 = src_data[0][:, :, 0:4, :].astype(np.float32).mean(axis=2).astype(np.int16)
|
# int16 (not float32) before .mean(): matches average_rows' own
|
||||||
|
# float64-accumulator behavior for integer input, so this doesn't
|
||||||
|
# drift from what average_rows actually guarantees.
|
||||||
|
expect0 = src_data[0][:, :, 0:4, :].astype(np.int16).mean(axis=2).astype(np.int16)
|
||||||
check("first averaged group equals the mean of its 4 source frames",
|
check("first averaged group equals the mean of its 4 source frames",
|
||||||
np.array_equal(np.asarray(avg.data[0])[:, :, 0, :], expect0))
|
np.array_equal(np.asarray(avg.data[0])[:, :, 0, :], expect0))
|
||||||
|
|
||||||
# Remainder handling: 12 frames / 5 -> 2 full groups + 1 partial.
|
# Remainder handling: 12 frames / 5 -> 2 full groups + 1 partial.
|
||||||
dst2 = scratch / "legacy_v4_avg5.sras"
|
dst2 = scratch / "legacy_v4_avg5.sras"
|
||||||
subprocess.run([sys.executable, str(repo / "sras_average.py"),
|
proc2 = subprocess.run([sys.executable, str(repo / "sras_average.py"),
|
||||||
str(src), str(dst2), "--n", "5"],
|
str(src), str(dst2), "--n", "5"],
|
||||||
capture_output=True, text=True, cwd=repo)
|
capture_output=True, text=True, cwd=repo)
|
||||||
|
check("sras_average ran (--n 5)", proc2.returncode == 0,
|
||||||
|
(proc2.stderr or proc2.stdout).strip()[-200:])
|
||||||
if dst2.exists():
|
if dst2.exists():
|
||||||
check("partial trailing group kept by default",
|
check("partial trailing group kept by default",
|
||||||
list(SrasFile(str(dst2)).n_frames) == [3, 3],
|
list(SrasFile(str(dst2)).n_frames) == [3, 3],
|
||||||
f"{list(SrasFile(str(dst2)).n_frames)}")
|
f"{list(SrasFile(str(dst2)).n_frames)}")
|
||||||
dst3 = scratch / "legacy_v4_avg5d.sras"
|
dst3 = scratch / "legacy_v4_avg5d.sras"
|
||||||
subprocess.run([sys.executable, str(repo / "sras_average.py"),
|
proc3 = subprocess.run([sys.executable, str(repo / "sras_average.py"),
|
||||||
str(src), str(dst3), "--n", "5", "--discard-remainder"],
|
str(src), str(dst3), "--n", "5", "--discard-remainder"],
|
||||||
capture_output=True, text=True, cwd=repo)
|
capture_output=True, text=True, cwd=repo)
|
||||||
|
check("sras_average ran (--n 5 --discard-remainder)", proc3.returncode == 0,
|
||||||
|
(proc3.stderr or proc3.stdout).strip()[-200:])
|
||||||
if dst3.exists():
|
if dst3.exists():
|
||||||
check("--discard-remainder drops the partial group",
|
check("--discard-remainder drops the partial group",
|
||||||
list(SrasFile(str(dst3)).n_frames) == [2, 2],
|
list(SrasFile(str(dst3)).n_frames) == [2, 2],
|
||||||
|
|||||||
Reference in New Issue
Block a user