Merge remote-tracking branch 'origin/main' into main

# Conflicts:
#	sras_compute.py
#	sras_viewer.py
#	sras_workers.py
#	tools/make_test_sras.py
#	tools/test_refactor.py
This commit is contained in:
Thomas Ales [M S E]
2026-08-10 18:58:21 -05:00
34 changed files with 10768 additions and 5738 deletions
+7
View File
@@ -1,2 +1,9 @@
__pycache__/
.venv/
.DS_Store
.pytest_cache/
*.png
*.sras
baseline*.txt
after*.txt
sras_viewer.egg-info/
-761
View File
@@ -1,761 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SRAS File Format Specification</title>
<style>
/* From extension vscode.github */
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.vscode-dark img[src$=\#gh-light-mode-only],
.vscode-light img[src$=\#gh-dark-mode-only],
.vscode-high-contrast:not(.vscode-high-contrast-light) img[src$=\#gh-light-mode-only],
.vscode-high-contrast-light img[src$=\#gh-dark-mode-only] {
display: none;
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/Microsoft/vscode/extensions/markdown-language-features/media/markdown.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/Microsoft/vscode/extensions/markdown-language-features/media/highlight.css">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe WPC', 'Segoe UI', system-ui, 'Ubuntu', 'Droid Sans', sans-serif;
font-size: 14px;
line-height: 1.6;
}
</style>
<style>
.task-list-item {
list-style-type: none;
}
.task-list-item-checkbox {
margin-left: -20px;
vertical-align: middle;
pointer-events: none;
}
</style>
<style>
:root {
--color-note: #0969da;
--color-tip: #1a7f37;
--color-warning: #9a6700;
--color-severe: #bc4c00;
--color-caution: #d1242f;
--color-important: #8250df;
}
</style>
<style>
@media (prefers-color-scheme: dark) {
:root {
--color-note: #2f81f7;
--color-tip: #3fb950;
--color-warning: #d29922;
--color-severe: #db6d28;
--color-caution: #f85149;
--color-important: #a371f7;
}
}
</style>
<style>
.markdown-alert {
padding: 0.5rem 1rem;
margin-bottom: 16px;
color: inherit;
border-left: .25em solid #888;
}
.markdown-alert>:first-child {
margin-top: 0
}
.markdown-alert>:last-child {
margin-bottom: 0
}
.markdown-alert .markdown-alert-title {
display: flex;
font-weight: 500;
align-items: center;
line-height: 1
}
.markdown-alert .markdown-alert-title .octicon {
margin-right: 0.5rem;
display: inline-block;
overflow: visible !important;
vertical-align: text-bottom;
fill: currentColor;
}
.markdown-alert.markdown-alert-note {
border-left-color: var(--color-note);
}
.markdown-alert.markdown-alert-note .markdown-alert-title {
color: var(--color-note);
}
.markdown-alert.markdown-alert-important {
border-left-color: var(--color-important);
}
.markdown-alert.markdown-alert-important .markdown-alert-title {
color: var(--color-important);
}
.markdown-alert.markdown-alert-warning {
border-left-color: var(--color-warning);
}
.markdown-alert.markdown-alert-warning .markdown-alert-title {
color: var(--color-warning);
}
.markdown-alert.markdown-alert-tip {
border-left-color: var(--color-tip);
}
.markdown-alert.markdown-alert-tip .markdown-alert-title {
color: var(--color-tip);
}
.markdown-alert.markdown-alert-caution {
border-left-color: var(--color-caution);
}
.markdown-alert.markdown-alert-caution .markdown-alert-title {
color: var(--color-caution);
}
</style>
</head>
<body class="vscode-body vscode-light">
<h1 id="sras-file-format-specification">SRAS File Format Specification</h1>
<p><strong>Format family:</strong> <code>.sras</code><br>
<strong>Byte order:</strong> Big-endian (network byte order) throughout, unless noted.<br>
<strong>Version history:</strong> v2 (baseline), v3 (scope calibration), v4 (background waveform), v5 (precomputed images + guaranteed frame count).</p>
<hr>
<h2 id="table-of-contents">Table of Contents</h2>
<ol>
<li><a href="#overview">Overview</a></li>
<li><a href="#type-notation">Type notation</a></li>
<li><a href="#version-history">Version history</a></li>
<li><a href="#file-structure">File structure</a>
<ul>
<li><a href="#1-fixed-header-43-bytes-all-versions">Fixed header (all versions)</a></li>
<li><a href="#2-angle-table-all-versions">Angle table (all versions)</a></li>
<li><a href="#3-row-position-table-all-versions">Row position table (all versions)</a></li>
<li><a href="#4-channel-preambles-v3">Channel preambles (v3+)</a></li>
<li><a href="#5-background-waveform-v4">Background waveform (v4+)</a></li>
<li><a href="#6-waveform-data-all-versions">Waveform data (all versions)</a></li>
<li><a href="#7-prec-section-v5">PREC section (v5)</a></li>
</ul>
</li>
<li><a href="#derived-quantities">Derived quantities</a></li>
<li><a href="#adc-calibration">ADC calibration</a></li>
<li><a href="#waveform-data-layout-detail">Waveform data layout detail</a></li>
<li><a href="#size-reference">Size reference</a></li>
<li><a href="#compatibility-notes">Compatibility notes</a></li>
</ol>
<hr>
<h2 id="overview">Overview</h2>
<p>An SRAS file stores the raw RF waveforms captured during a Surface-acoustic-wave Resonance And Spectroscopy (SRAS) scan, along with the scan geometry and scope calibration metadata needed to interpret them.</p>
<p>A scan consists of one or more <strong>angles</strong> (rotation positions of the sample), each containing a 2-D raster of <strong>rows</strong> × <strong>frames</strong>. At every pixel, <code>n_channels</code> waveforms of <code>samples_per_frame</code> ADC counts are stored. Channel order is fixed:</p>
<table>
<thead>
<tr>
<th>Index</th>
<th>Hardware channel</th>
<th>Signal</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>CH1</td>
<td>RF acoustic packet (AC-coupled)</td>
</tr>
<tr>
<td>1</td>
<td>CH3</td>
<td>Bias A — DC mean used for masking</td>
</tr>
<tr>
<td>2</td>
<td>CH4</td>
<td>Bias B — DC mean used for masking</td>
</tr>
</tbody>
</table>
<hr>
<h2 id="type-notation">Type notation</h2>
<table>
<thead>
<tr>
<th>Symbol</th>
<th>C type</th>
<th>Size</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>u8</code></td>
<td><code>uint8_t</code></td>
<td>1 byte</td>
<td>unsigned</td>
</tr>
<tr>
<td><code>u16</code></td>
<td><code>uint16_t</code></td>
<td>2 bytes</td>
<td>big-endian</td>
</tr>
<tr>
<td><code>u32</code></td>
<td><code>uint32_t</code></td>
<td>4 bytes</td>
<td>big-endian</td>
</tr>
<tr>
<td><code>i8</code></td>
<td><code>int8_t</code></td>
<td>1 byte</td>
<td>signed, used for ADC samples when <code>bytes_per_sample == 1</code></td>
</tr>
<tr>
<td><code>i16</code></td>
<td><code>int16_t</code></td>
<td>2 bytes</td>
<td>big-endian signed, used when <code>bytes_per_sample == 2</code></td>
</tr>
<tr>
<td><code>f32</code></td>
<td><code>float</code></td>
<td>4 bytes</td>
<td>big-endian IEEE 754 single</td>
</tr>
<tr>
<td><code>f64</code></td>
<td><code>double</code></td>
<td>8 bytes</td>
<td>big-endian IEEE 754 double</td>
</tr>
<tr>
<td><code>char[N]</code></td>
<td>—</td>
<td>N bytes</td>
<td>raw bytes, no null terminator unless noted</td>
</tr>
<tr>
<td><code>utf8[N]</code></td>
<td>—</td>
<td>N bytes</td>
<td>UTF-8 string, length-prefixed (see preamble section)</td>
</tr>
</tbody>
</table>
<hr>
<h2 id="version-history">Version history</h2>
<table>
<thead>
<tr>
<th>Version</th>
<th>Added</th>
</tr>
</thead>
<tbody>
<tr>
<td>2</td>
<td>Baseline: fixed header, angle table, row table, raw waveform data. No scope calibration (fallback constants used by readers).</td>
</tr>
<tr>
<td>3</td>
<td>Per-channel Tektronix WFMOutpre preamble strings carrying YMULT / YOFF / YZERO calibration.</td>
</tr>
<tr>
<td>4</td>
<td>Background waveform section: one CH1 reference shot subtracted from each CH1 frame before FFT.</td>
</tr>
<tr>
<td>5</td>
<td><strong>(this document)</strong> Version byte incremented to 5. <code>n_frames_hdr</code> is now the <em>actual</em> acquired frame count (authoritative). PREC section appended after waveform data with precomputed FFT-peak and DC images for instant re-display.</td>
</tr>
</tbody>
</table>
<blockquote>
<p><strong>v2 note:</strong> Version 1 is not defined; version 2 is the lowest observed in the field.</p>
</blockquote>
<hr>
<h2 id="file-structure">File structure</h2>
<pre><code>┌─────────────────────────────────────────────┐
│ 1. Fixed header (43 bytes) │ all versions
├─────────────────────────────────────────────┤
│ 2. Angle table (n_angles × 4 bytes)│ all versions
├─────────────────────────────────────────────┤
│ 3. Row position table (n_rows × 4 bytes)│ all versions
├─────────────────────────────────────────────┤
│ 4. Channel preambles (variable) │ v3+
├─────────────────────────────────────────────┤
│ 5. Background waveform (variable) │ v4+
├─────────────────────────────────────────────┤
│ 6. Waveform data (variable) │ all versions
├─────────────────────────────────────────────┤
│ 7. PREC section (variable) │ v5 only
└─────────────────────────────────────────────┘
</code></pre>
<hr>
<h3 id="1-fixed-header-43-bytes-all-versions">1. Fixed header (43 bytes, all versions)</h3>
<table>
<thead>
<tr>
<th>Offset</th>
<th>Size</th>
<th>Type</th>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>4</td>
<td><code>char[4]</code></td>
<td><code>magic</code></td>
<td><code>SRAS</code> (ASCII, no null terminator). Reject file if this does not match.</td>
</tr>
<tr>
<td>4</td>
<td>1</td>
<td><code>u8</code></td>
<td><code>version</code></td>
<td>Format version. This document describes version <strong>5</strong>.</td>
</tr>
<tr>
<td>5</td>
<td>2</td>
<td><code>u16</code></td>
<td><code>n_angles</code></td>
<td>Number of scan angles (rotation positions). ≥ 1.</td>
</tr>
<tr>
<td>7</td>
<td>2</td>
<td><code>u16</code></td>
<td><code>n_rows</code></td>
<td>Number of scan rows (Y positions). ≥ 1.</td>
</tr>
<tr>
<td>9</td>
<td>4</td>
<td><code>f32</code></td>
<td><code>x_start_mm</code></td>
<td>X position of the first frame in the first row, in millimetres.</td>
</tr>
<tr>
<td>13</td>
<td>4</td>
<td><code>f32</code></td>
<td><code>x_delta_mm</code></td>
<td>Pre-computed pixel pitch in mm (<code>velocity_mm_s / laser_freq_hz</code>). Provided for convenience; readers should prefer the derived value.</td>
</tr>
<tr>
<td>17</td>
<td>4</td>
<td><code>f32</code></td>
<td><code>velocity_mm_s</code></td>
<td>Scanner stage velocity, mm s⁻¹. Used together with <code>laser_freq_hz</code> to compute pixel pitch.</td>
</tr>
<tr>
<td>21</td>
<td>4</td>
<td><code>f32</code></td>
<td><code>laser_freq_hz</code></td>
<td>Laser repetition rate, Hz.</td>
</tr>
<tr>
<td>25</td>
<td>4</td>
<td><code>u32</code></td>
<td><code>n_frames_hdr</code></td>
<td><strong>v2–v4:</strong> the <em>configured</em> frame count written before acquisition; may exceed actual frames acquired (use file-size arithmetic to obtain the true count). <strong>v5:</strong> the <em>actual</em> acquired frame count — authoritative; readers must not re-derive it from file size.</td>
</tr>
<tr>
<td>29</td>
<td>4</td>
<td><code>u32</code></td>
<td><code>samples_per_frame</code></td>
<td>ADC samples per waveform (<code>spf</code>).</td>
</tr>
<tr>
<td>33</td>
<td>8</td>
<td><code>f64</code></td>
<td><code>sample_rate_hz</code></td>
<td>Oscilloscope sample rate, Hz (e.g. 5 × 10⁹ for 5 GS/s).</td>
</tr>
<tr>
<td>41</td>
<td>1</td>
<td><code>u8</code></td>
<td><code>bytes_per_sample</code></td>
<td>ADC word size: <code>1</code> → <code>i8</code>, <code>2</code> → <code>i16</code> (big-endian).</td>
</tr>
<tr>
<td>42</td>
<td>1</td>
<td><code>u8</code></td>
<td><code>n_channels</code></td>
<td>Number of channels per frame. Currently always <code>3</code>.</td>
</tr>
</tbody>
</table>
<hr>
<h3 id="2-angle-table-all-versions">2. Angle table (all versions)</h3>
<p>Immediately follows the fixed header.</p>
<pre><code>n_angles × f32 — scan angle in degrees
</code></pre>
<p>Each entry is a big-endian <code>f32</code> giving the sample rotation angle in degrees at which that angle index was acquired.</p>
<hr>
<h3 id="3-row-position-table-all-versions">3. Row position table (all versions)</h3>
<p>Immediately follows the angle table.</p>
<pre><code>n_rows × f32 — Y position of each row, in millimetres
</code></pre>
<hr>
<h3 id="4-channel-preambles-v3">4. Channel preambles (v3+)</h3>
<p>One entry per channel, in channel-index order (CH1 first).</p>
<pre><code>for each channel:
u16 preamble_length — byte count of the UTF-8 string that follows
utf8[N] preamble — Tektronix WFMOutpre string
</code></pre>
<p>The preamble is the oscilloscope's <code>WFMOutpre</code> response string. Readers extract the following keys (case-insensitive, space-separated value):</p>
<table>
<thead>
<tr>
<th>Key</th>
<th>Stored unit</th>
<th>Conversion to mV</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>YMULT</code></td>
<td>V count⁻¹</td>
<td>multiply by 1000</td>
</tr>
<tr>
<td><code>YOFF</code></td>
<td>ADC counts</td>
<td>used directly</td>
</tr>
<tr>
<td><code>YZERO</code></td>
<td>V</td>
<td>multiply by 1000</td>
</tr>
</tbody>
</table>
<p><strong>v2 fallback:</strong> when preambles are absent, readers use:</p>
<ul>
<li><code>YMULT</code> = 1.5625 mV count⁻¹ (50 mV/div, 8 div, 8-bit ADC)</li>
<li><code>YOFF</code> = −87.04 ADC counts (scope position = −2.72 div)</li>
<li><code>YZERO</code> = 0 mV</li>
</ul>
<hr>
<h3 id="5-background-waveform-v4">5. Background waveform (v4+)</h3>
<pre><code>u32 n_bg_samples — number of i8 ADC samples that follow
i8[n_bg] background — one representative CH1 background shot
</code></pre>
<p>The background waveform has the same <code>samples_per_frame</code> length as a normal CH1 waveform. It is subtracted from each CH1 waveform before FFT processing when background subtraction is enabled. When <code>n_bg_samples == 0</code> the section is present but empty.</p>
<hr>
<h3 id="6-waveform-data-all-versions">6. Waveform data (all versions)</h3>
<p>Begins immediately after the fixed header (v2), preambles (v3), or background waveform (v4+). The waveform data is a flat, contiguous array with the following logical shape, stored in row-major (C) order:</p>
<pre><code>waveform_data[n_angles][n_rows][n_channels][n_frames][samples_per_frame]
</code></pre>
<p>Each element is a signed ADC count of size <code>bytes_per_sample</code>:</p>
<ul>
<li><code>bytes_per_sample == 1</code> → <code>i8</code></li>
<li><code>bytes_per_sample == 2</code> → <code>i16</code> big-endian</li>
</ul>
<p><strong>Total byte count:</strong></p>
<pre><code>waveform_bytes = n_angles × n_rows × n_channels × n_frames × samples_per_frame × bytes_per_sample
</code></pre>
<h4 id="index-semantics">Index semantics</h4>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>Range</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>[a]</code></td>
<td>0 … n_angles−1</td>
<td>Scan angle (rotation position)</td>
</tr>
<tr>
<td><code>[r]</code></td>
<td>0 … n_rows−1</td>
<td>Row (Y position); row 0 is the first acquired</td>
</tr>
<tr>
<td><code>[c]</code></td>
<td>0 … n_channels−1</td>
<td>Channel (0=CH1 RF, 1=CH3 Bias A, 2=CH4 Bias B)</td>
</tr>
<tr>
<td><code>[f]</code></td>
<td>0 … n_frames−1</td>
<td>Frame (X position) within the row</td>
</tr>
<tr>
<td><code>[s]</code></td>
<td>0 … spf−1</td>
<td>Sample index within the waveform</td>
</tr>
</tbody>
</table>
<h4 id="frame-count-determination">Frame-count determination</h4>
<ul>
<li><strong>v5:</strong> use <code>n_frames_hdr</code> directly; do not use file-size arithmetic.</li>
<li><strong>v2–v4:</strong> <code>n_frames = floor((file_bytes_after_header_sections) / (bytes_per_sample × n_angles × n_rows × n_channels × samples_per_frame))</code>. Any remainder bytes are a partial trailing row and are discarded.</li>
</ul>
<hr>
<h3 id="7-prec-section-v5">7. PREC section (v5)</h3>
<p>The PREC section is appended immediately after the waveform data and is present if and only if <code>version == 5</code> and the file size exceeds <code>waveform_end_offset</code>.</p>
<pre><code>waveform_end_offset = data_offset + waveform_bytes
</code></pre>
<p>where <code>data_offset</code> is the file offset of the first waveform byte (the byte immediately after the background waveform, or after the angle/row tables for v2 files).</p>
<h4 id="prec-header-8-bytes">PREC header (8 bytes)</h4>
<table>
<thead>
<tr>
<th>Offset (relative)</th>
<th>Size</th>
<th>Type</th>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>4</td>
<td><code>char[4]</code></td>
<td><code>prec_magic</code></td>
<td><code>PREC</code> (ASCII). Absent or wrong magic → ignore section.</td>
</tr>
<tr>
<td>4</td>
<td>1</td>
<td><code>u8</code></td>
<td><code>prec_version</code></td>
<td>PREC format version. Currently <code>1</code>.</td>
</tr>
<tr>
<td>5</td>
<td>1</td>
<td><code>u8</code></td>
<td><code>flags</code></td>
<td>Bitmask (see below).</td>
</tr>
<tr>
<td>6</td>
<td>2</td>
<td><code>u16</code></td>
<td><code>n_stored</code></td>
<td>Number of angle entries that follow. 0 ≤ <code>n_stored</code> ≤ <code>n_angles</code>.</td>
</tr>
</tbody>
</table>
<h5 id="flags-byte">Flags byte</h5>
<table>
<thead>
<tr>
<th>Bit</th>
<th>Mask</th>
<th>Meaning when set</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td><code>0x01</code></td>
<td><code>bg_sub_applied</code> — background waveform was subtracted from CH1 before the FFT when these images were computed.</td>
</tr>
<tr>
<td>1–7</td>
<td>—</td>
<td>Reserved, must be zero on write; readers must ignore.</td>
</tr>
</tbody>
</table>
<h4 id="prec-angle-entries">PREC angle entries</h4>
<p>Repeated <code>n_stored</code> times, in arbitrary angle-index order:</p>
<pre><code>for each stored angle:
u16 angle_idx — index into the angle table (0-based)
f32[n_rows×n_frames] peak_freq_mhz — CH1 FFT peak frequency, MHz, row-major
f32[n_rows×n_frames] dc4_mv — CH4 waveform mean, mV, row-major
f32[n_rows×n_frames] dc3_mv — CH3 waveform mean, mV, row-major
</code></pre>
<p>All image arrays are <code>f32</code> big-endian, stored in row-major order: element <code>[r][f]</code> is at offset <code>(r × n_frames + f) × 4</code> bytes within the array.</p>
<p><strong><code>peak_freq_mhz</code></strong> is computed without any DC-threshold masking (i.e. the FFT is run on every pixel unconditionally). Readers apply the <code>dc4_mv</code> threshold at display time:</p>
<pre><code>pixel is valid ⟺ dc4_mv[r][f] ≥ threshold_mv
display_value = peak_freq_mhz[r][f] if valid, else 0
</code></pre>
<p><strong><code>dc4_mv</code> / <code>dc3_mv</code></strong> are the mean of all ADC samples in the respective channel waveform, converted to millivolts using the channel calibration:</p>
<pre><code>dc_mv = (adc_mean − YOFF) × YMULT + YZERO
</code></pre>
<h4 id="when-readers-must-bypass-the-prec-fast-path">When readers must bypass the PREC fast path</h4>
<p>Readers must fall back to real-time FFT computation (ignoring stored <code>peak_freq_mhz</code>) when any of the following are true:</p>
<ul>
<li>Time-domain gating is active (zeroing samples outside a time window changes peak frequency).</li>
<li>Zero-padding (<code>n_fft ≠ samples_per_frame</code>) is requested (changes bin spacing).</li>
<li>The reader's background-subtraction setting does not match <code>flags.bg_sub_applied</code>.</li>
</ul>
<hr>
<h2 id="derived-quantities">Derived quantities</h2>
<pre><code>pixel_pitch_mm = velocity_mm_s / laser_freq_hz
x_axis_mm[f] = x_start_mm + f × pixel_pitch_mm (f = 0 … n_frames−1)
time_axis_ns[s] = s / sample_rate_hz × 1e9 (s = 0 … spf−1)
freq_axis_mhz[k] = k × sample_rate_hz / (n_fft × 1e6) (k = 0 … n_fft/2)
where n_fft = samples_per_frame unless zero-padding is active
velocity_ms[r][f] = peak_freq_mhz[r][f] × grating_um (grating_um user-supplied)
</code></pre>
<hr>
<h2 id="adc-calibration">ADC calibration</h2>
<p>Convert raw ADC counts to millivolts:</p>
<pre><code>voltage_mv = (adc_count − YOFF) × YMULT_mv + YZERO_mv
</code></pre>
<p>Invert (mV → ADC count):</p>
<pre><code>adc_count = (voltage_mv − YZERO_mv) / YMULT_mv + YOFF
</code></pre>
<p>where <code>YMULT_mv</code> is YMULT in mV count⁻¹ (= scope YMULT in V count⁻¹ × 1000).</p>
<hr>
<h2 id="waveform-data-layout-detail">Waveform data layout detail</h2>
<p>For a scan with <code>n_angles=2</code>, <code>n_rows=3</code>, <code>n_channels=3</code>, <code>n_frames=4</code>, <code>spf=5</code> the layout is:</p>
<pre><code>angle 0
row 0
CH1: [s0 s1 s2 s3 s4] [s0 s1 s2 s3 s4] [s0 s1 s2 s3 s4] [s0 s1 s2 s3 s4]
frame 0 frame 1 frame 2 frame 3
CH3: …(same layout)…
CH4: …(same layout)…
row 1
…
row 2
…
angle 1
…
</code></pre>
<p>The flat byte offset of sample <code>s</code> of frame <code>f</code>, channel <code>c</code>, row <code>r</code>, angle <code>a</code> is:</p>
<pre><code>offset = data_offset
+ (a × n_rows × n_channels × n_frames × spf
+ r × n_channels × n_frames × spf
+ c × n_frames × spf
+ f × spf
+ s)
× bytes_per_sample
</code></pre>
<hr>
<h2 id="size-reference">Size reference</h2>
<p>Approximate sizes for representative scans (<code>bytes_per_sample = 1</code>, <code>n_channels = 3</code>).</p>
<table>
<thead>
<tr>
<th>n_angles</th>
<th>n_rows</th>
<th>n_frames</th>
<th>spf</th>
<th>Waveform data</th>
<th>PREC section</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>500</td>
<td>500</td>
<td>400</td>
<td>300 MB</td>
<td>12 MB</td>
</tr>
<tr>
<td>4</td>
<td>500</td>
<td>500</td>
<td>400</td>
<td>1.2 GB</td>
<td>48 MB</td>
</tr>
<tr>
<td>1</td>
<td>2000</td>
<td>2000</td>
<td>400</td>
<td>4.8 GB</td>
<td>48 MB</td>
</tr>
<tr>
<td>4</td>
<td>2000</td>
<td>2000</td>
<td>400</td>
<td>19.2 GB</td>
<td>192 MB</td>
</tr>
<tr>
<td>8</td>
<td>2000</td>
<td>2000</td>
<td>400</td>
<td>38.4 GB</td>
<td>384 MB</td>
</tr>
<tr>
<td>16</td>
<td>2000</td>
<td>2000</td>
<td>400</td>
<td>76.8 GB</td>
<td>768 MB</td>
</tr>
</tbody>
</table>
<p><strong>PREC section size formula:</strong></p>
<pre><code>prec_bytes = 8 + n_stored × (2 + 3 × n_rows × n_frames × 4)
</code></pre>
<hr>
<h2 id="compatibility-notes">Compatibility notes</h2>
<h3 id="reading-v5-files-with-a-v4-reader">Reading v5 files with a v4 reader</h3>
<p>A v4 reader that only accepts versions <code>{2, 3, 4}</code> will reject a v5 file with an &quot;unsupported version&quot; error. This is intentional: a v4 reader would derive <code>n_frames</code> from the file size, incorrectly including the PREC bytes in the sample count, producing a silently wrong reshape.</p>
<h3 id="producing-v5-files">Producing v5 files</h3>
<p>v5 files are produced by the SRAS viewer's <strong>&quot;Pre-process and Save as v5&quot;</strong> action. The procedure is:</p>
<ol>
<li>Copy the source file (any version) verbatim.</li>
<li>Set <code>version = 5</code> at byte offset 4.</li>
<li>Set <code>n_frames_hdr</code> at byte offset 25 to the actual acquired frame count.</li>
<li>Truncate the copy to <code>data_offset + waveform_bytes</code> (removes any pre-existing stale PREC tail).</li>
<li>Compute <code>peak_freq_mhz</code>, <code>dc4_mv</code>, and <code>dc3_mv</code> for every angle using chunked FFT.</li>
<li>Append the PREC section.</li>
</ol>
<h3 id="partially-written-prec-sections">Partially-written PREC sections</h3>
<p>If <code>n_stored &lt; n_angles</code> (e.g. pre-processing was interrupted), the file is still valid. Readers use stored images for the angles present in the PREC section and fall back to real-time FFT for the remainder. Readers must check <code>angle_idx</code> bounds on each entry and stop parsing on an out-of-range value.</p>
</body>
</html>
-379
View File
@@ -1,379 +0,0 @@
# SRAS File Format Specification
**Format family:** `.sras`
**Byte order:** Big-endian (network byte order) throughout, unless noted.
**Version history:** v2 (baseline), v3 (scope calibration), v4 (background waveform), v5 (precomputed images + guaranteed frame count).
---
## Table of Contents
1. [Overview](#overview)
2. [Type notation](#type-notation)
3. [Version history](#version-history)
4. [File structure](#file-structure)
- [Fixed header (all versions)](#1-fixed-header-43-bytes-all-versions)
- [Angle table (all versions)](#2-angle-table-all-versions)
- [Row position table (all versions)](#3-row-position-table-all-versions)
- [Channel preambles (v3+)](#4-channel-preambles-v3)
- [Background waveform (v4+)](#5-background-waveform-v4)
- [Waveform data (all versions)](#6-waveform-data-all-versions)
- [PREC section (v5)](#7-prec-section-v5)
5. [Derived quantities](#derived-quantities)
6. [ADC calibration](#adc-calibration)
7. [Waveform data layout detail](#waveform-data-layout-detail)
8. [Size reference](#size-reference)
9. [Compatibility notes](#compatibility-notes)
---
## Overview
An SRAS file stores the raw RF waveforms captured during a Surface-acoustic-wave Resonance And Spectroscopy (SRAS) scan, along with the scan geometry and scope calibration metadata needed to interpret them.
A scan consists of one or more **angles** (rotation positions of the sample), each containing a 2-D raster of **rows** × **frames**. At every pixel, `n_channels` waveforms of `samples_per_frame` ADC counts are stored. Channel order is fixed:
| Index | Hardware channel | Signal |
|-------|-----------------|--------|
| 0 | CH1 | RF acoustic packet (AC-coupled) |
| 1 | CH3 | Bias A — DC mean used for masking |
| 2 | CH4 | Bias B — DC mean used for masking |
---
## Type notation
| Symbol | C type | Size | Notes |
|--------|--------|------|-------|
| `u8` | `uint8_t` | 1 byte | unsigned |
| `u16` | `uint16_t` | 2 bytes | big-endian |
| `u32` | `uint32_t` | 4 bytes | big-endian |
| `i8` | `int8_t` | 1 byte | signed, used for ADC samples when `bytes_per_sample == 1` |
| `i16` | `int16_t` | 2 bytes | big-endian signed, used when `bytes_per_sample == 2` |
| `f32` | `float` | 4 bytes | big-endian IEEE 754 single |
| `f64` | `double` | 8 bytes | big-endian IEEE 754 double |
| `char[N]` | — | N bytes | raw bytes, no null terminator unless noted |
| `utf8[N]` | — | N bytes | UTF-8 string, length-prefixed (see preamble section) |
---
## Version history
| Version | Added |
|---------|-------|
| 2 | Baseline: fixed header, angle table, row table, raw waveform data. No scope calibration (fallback constants used by readers). |
| 3 | Per-channel Tektronix WFMOutpre preamble strings carrying YMULT / YOFF / YZERO calibration. |
| 4 | Background waveform section: one CH1 reference shot subtracted from each CH1 frame before FFT. |
| 5 | **(this document)** Version byte incremented to 5. `n_frames_hdr` is now the *actual* acquired frame count (authoritative). PREC section appended after waveform data with precomputed FFT-peak and DC images for instant re-display. |
> **v2 note:** Version 1 is not defined; version 2 is the lowest observed in the field.
---
## File structure
```
┌─────────────────────────────────────────────┐
│ 1. Fixed header (43 bytes) │ all versions
├─────────────────────────────────────────────┤
│ 2. Angle table (n_angles × 4 bytes)│ all versions
├─────────────────────────────────────────────┤
│ 3. Row position table (n_rows × 4 bytes)│ all versions
├─────────────────────────────────────────────┤
│ 4. Channel preambles (variable) │ v3+
├─────────────────────────────────────────────┤
│ 5. Background waveform (variable) │ v4+
├─────────────────────────────────────────────┤
│ 6. Waveform data (variable) │ all versions
├─────────────────────────────────────────────┤
│ 7. PREC section (variable) │ v5 only
└─────────────────────────────────────────────┘
```
---
### 1. Fixed header (43 bytes, all versions)
| Offset | Size | Type | Field | Description |
|--------|------|------|-------|-------------|
| 0 | 4 | `char[4]` | `magic` | `SRAS` (ASCII, no null terminator). Reject file if this does not match. |
| 4 | 1 | `u8` | `version` | Format version. This document describes version **5**. |
| 5 | 2 | `u16` | `n_angles` | Number of scan angles (rotation positions). ≥ 1. |
| 7 | 2 | `u16` | `n_rows` | Number of scan rows (Y positions). ≥ 1. |
| 9 | 4 | `f32` | `x_start_mm` | X position of the first frame in the first row, in millimetres. |
| 13 | 4 | `f32` | `x_delta_mm` | Pre-computed pixel pitch in mm (`velocity_mm_s / laser_freq_hz`). Provided for convenience; readers should prefer the derived value. |
| 17 | 4 | `f32` | `velocity_mm_s` | Scanner stage velocity, mm s⁻¹. Used together with `laser_freq_hz` to compute pixel pitch. |
| 21 | 4 | `f32` | `laser_freq_hz` | Laser repetition rate, Hz. |
| 25 | 4 | `u32` | `n_frames_hdr` | **v2–v4:** the *configured* frame count written before acquisition; may exceed actual frames acquired (use file-size arithmetic to obtain the true count). **v5:** the *actual* acquired frame count — authoritative; readers must not re-derive it from file size. |
| 29 | 4 | `u32` | `samples_per_frame` | ADC samples per waveform (`spf`). |
| 33 | 8 | `f64` | `sample_rate_hz` | Oscilloscope sample rate, Hz (e.g. 5 × 10⁹ for 5 GS/s). |
| 41 | 1 | `u8` | `bytes_per_sample` | ADC word size: `1` → `i8`, `2` → `i16` (big-endian). |
| 42 | 1 | `u8` | `n_channels` | Number of channels per frame. Currently always `3`. |
---
### 2. Angle table (all versions)
Immediately follows the fixed header.
```
n_angles × f32 — scan angle in degrees
```
Each entry is a big-endian `f32` giving the sample rotation angle in degrees at which that angle index was acquired.
---
### 3. Row position table (all versions)
Immediately follows the angle table.
```
n_rows × f32 — Y position of each row, in millimetres
```
---
### 4. Channel preambles (v3+)
One entry per channel, in channel-index order (CH1 first).
```
for each channel:
u16 preamble_length — byte count of the UTF-8 string that follows
utf8[N] preamble — Tektronix WFMOutpre string
```
The preamble is the oscilloscope's `WFMOutpre` response string. Readers extract the following keys (case-insensitive, space-separated value):
| Key | Stored unit | Conversion to mV |
|-----|-------------|-----------------|
| `YMULT` | V count⁻¹ | multiply by 1000 |
| `YOFF` | ADC counts | used directly |
| `YZERO` | V | multiply by 1000 |
**v2 fallback:** when preambles are absent, readers use:
- `YMULT` = 1.5625 mV count⁻¹ (50 mV/div, 8 div, 8-bit ADC)
- `YOFF` = −87.04 ADC counts (scope position = −2.72 div)
- `YZERO` = 0 mV
---
### 5. Background waveform (v4+)
```
u32 n_bg_samples — number of i8 ADC samples that follow
i8[n_bg] background — one representative CH1 background shot
```
The background waveform has the same `samples_per_frame` length as a normal CH1 waveform. It is subtracted from each CH1 waveform before FFT processing when background subtraction is enabled. When `n_bg_samples == 0` the section is present but empty.
---
### 6. Waveform data (all versions)
Begins immediately after the fixed header (v2), preambles (v3), or background waveform (v4+). The waveform data is a flat, contiguous array with the following logical shape, stored in row-major (C) order:
```
waveform_data[n_angles][n_rows][n_channels][n_frames][samples_per_frame]
```
Each element is a signed ADC count of size `bytes_per_sample`:
- `bytes_per_sample == 1` → `i8`
- `bytes_per_sample == 2` → `i16` big-endian
**Total byte count:**
```
waveform_bytes = n_angles × n_rows × n_channels × n_frames × samples_per_frame × bytes_per_sample
```
#### Index semantics
| Dimension | Range | Meaning |
|-----------|-------|---------|
| `[a]` | 0 … n_angles−1 | Scan angle (rotation position) |
| `[r]` | 0 … n_rows−1 | Row (Y position); row 0 is the first acquired |
| `[c]` | 0 … n_channels−1 | Channel (0=CH1 RF, 1=CH3 Bias A, 2=CH4 Bias B) |
| `[f]` | 0 … n_frames−1 | Frame (X position) within the row |
| `[s]` | 0 … spf−1 | Sample index within the waveform |
#### Frame-count determination
- **v5:** use `n_frames_hdr` directly; do not use file-size arithmetic.
- **v2–v4:** `n_frames = floor((file_bytes_after_header_sections) / (bytes_per_sample × n_angles × n_rows × n_channels × samples_per_frame))`. Any remainder bytes are a partial trailing row and are discarded.
---
### 7. PREC section (v5)
The PREC section is appended immediately after the waveform data and is present if and only if `version == 5` and the file size exceeds `waveform_end_offset`.
```
waveform_end_offset = data_offset + waveform_bytes
```
where `data_offset` is the file offset of the first waveform byte (the byte immediately after the background waveform, or after the angle/row tables for v2 files).
#### PREC header (8 bytes)
| Offset (relative) | Size | Type | Field | Description |
|-------------------|------|------|-------|-------------|
| 0 | 4 | `char[4]` | `prec_magic` | `PREC` (ASCII). Absent or wrong magic → ignore section. |
| 4 | 1 | `u8` | `prec_version` | PREC format version. Currently `1`. |
| 5 | 1 | `u8` | `flags` | Bitmask (see below). |
| 6 | 2 | `u16` | `n_stored` | Number of angle entries that follow. 0 ≤ `n_stored` ≤ `n_angles`. |
##### Flags byte
| Bit | Mask | Meaning when set |
|-----|------|-----------------|
| 0 | `0x01` | `bg_sub_applied` — background waveform was subtracted from CH1 before the FFT when these images were computed. |
| 1–7 | — | Reserved, must be zero on write; readers must ignore. |
#### PREC angle entries
Repeated `n_stored` times, in arbitrary angle-index order:
```
for each stored angle:
u16 angle_idx — index into the angle table (0-based)
f32[n_rows×n_frames] peak_freq_mhz — CH1 FFT peak frequency, MHz, row-major
f32[n_rows×n_frames] dc4_mv — CH4 waveform mean, mV, row-major
f32[n_rows×n_frames] dc3_mv — CH3 waveform mean, mV, row-major
```
All image arrays are `f32` big-endian, stored in row-major order: element `[r][f]` is at offset `(r × n_frames + f) × 4` bytes within the array.
**`peak_freq_mhz`** is computed without any DC-threshold masking (i.e. the FFT is run on every pixel unconditionally). Readers apply the `dc4_mv` threshold at display time:
```
pixel is valid ⟺ dc4_mv[r][f] ≥ threshold_mv
display_value = peak_freq_mhz[r][f] if valid, else 0
```
**`dc4_mv` / `dc3_mv`** are the mean of all ADC samples in the respective channel waveform, converted to millivolts using the channel calibration:
```
dc_mv = (adc_mean − YOFF) × YMULT + YZERO
```
#### When readers must bypass the PREC fast path
Readers must fall back to real-time FFT computation (ignoring stored `peak_freq_mhz`) when any of the following are true:
- Time-domain gating is active (zeroing samples outside a time window changes peak frequency).
- Zero-padding (`n_fft ≠ samples_per_frame`) is requested (changes bin spacing).
- The reader's background-subtraction setting does not match `flags.bg_sub_applied`.
---
## Derived quantities
```
pixel_pitch_mm = velocity_mm_s / laser_freq_hz
x_axis_mm[f] = x_start_mm + f × pixel_pitch_mm (f = 0 … n_frames−1)
time_axis_ns[s] = s / sample_rate_hz × 1e9 (s = 0 … spf−1)
freq_axis_mhz[k] = k × sample_rate_hz / (n_fft × 1e6) (k = 0 … n_fft/2)
where n_fft = samples_per_frame unless zero-padding is active
velocity_ms[r][f] = peak_freq_mhz[r][f] × grating_um (grating_um user-supplied)
```
---
## ADC calibration
Convert raw ADC counts to millivolts:
```
voltage_mv = (adc_count − YOFF) × YMULT_mv + YZERO_mv
```
Invert (mV → ADC count):
```
adc_count = (voltage_mv − YZERO_mv) / YMULT_mv + YOFF
```
where `YMULT_mv` is YMULT in mV count⁻¹ (= scope YMULT in V count⁻¹ × 1000).
---
## Waveform data layout detail
For a scan with `n_angles=2`, `n_rows=3`, `n_channels=3`, `n_frames=4`, `spf=5` the layout is:
```
angle 0
row 0
CH1: [s0 s1 s2 s3 s4] [s0 s1 s2 s3 s4] [s0 s1 s2 s3 s4] [s0 s1 s2 s3 s4]
frame 0 frame 1 frame 2 frame 3
CH3: …(same layout)…
CH4: …(same layout)…
row 1
…
row 2
…
angle 1
…
```
The flat byte offset of sample `s` of frame `f`, channel `c`, row `r`, angle `a` is:
```
offset = data_offset
+ (a × n_rows × n_channels × n_frames × spf
+ r × n_channels × n_frames × spf
+ c × n_frames × spf
+ f × spf
+ s)
× bytes_per_sample
```
---
## Size reference
Approximate sizes for representative scans (`bytes_per_sample = 1`, `n_channels = 3`).
| n_angles | n_rows | n_frames | spf | Waveform data | PREC section |
|----------|--------|----------|-----|---------------|-------------|
| 1 | 500 | 500 | 400 | 300 MB | 12 MB |
| 4 | 500 | 500 | 400 | 1.2 GB | 48 MB |
| 1 | 2000 | 2000 | 400 | 4.8 GB | 48 MB |
| 4 | 2000 | 2000 | 400 | 19.2 GB | 192 MB |
| 8 | 2000 | 2000 | 400 | 38.4 GB | 384 MB |
| 16 | 2000 | 2000 | 400 | 76.8 GB | 768 MB |
**PREC section size formula:**
```
prec_bytes = 8 + n_stored × (2 + 3 × n_rows × n_frames × 4)
```
---
## Compatibility notes
### Reading v5 files with a v4 reader
A v4 reader that only accepts versions `{2, 3, 4}` will reject a v5 file with an "unsupported version" error. This is intentional: a v4 reader would derive `n_frames` from the file size, incorrectly including the PREC bytes in the sample count, producing a silently wrong reshape.
### Producing v5 files
v5 files are produced by the SRAS viewer's **"Pre-process and Save as v5"** action. The procedure is:
1. Copy the source file (any version) verbatim.
2. Set `version = 5` at byte offset 4.
3. Set `n_frames_hdr` at byte offset 25 to the actual acquired frame count.
4. Truncate the copy to `data_offset + waveform_bytes` (removes any pre-existing stale PREC tail).
5. Compute `peak_freq_mhz`, `dc4_mv`, and `dc3_mv` for every angle using chunked FFT.
6. Append the PREC section.
### Partially-written PREC sections
If `n_stored < n_angles` (e.g. pre-processing was interrupted), the file is still valid. Readers use stored images for the angles present in the PREC section and fall back to real-time FFT for the remainder. Readers must check `angle_idx` bounds on each entry and stop parsing on an out-of-range value.
+398
View File
@@ -0,0 +1,398 @@
# sras-viewer design notes
Rationale that outgrew code comments. Each section is referenced by a short
pointer comment at the relevant definition, so the code stays scannable and
the reasoning stays findable.
## Memory budget and row chunking (`sras_compute.py`)
DC images are computed over row chunks so the float32 working buffers for one
chunk stay under a memory budget. A fixed row count (the original design)
works fine for small legacy scans but is catastrophic for a v6 scan with a
large per-angle frame/sample count — e.g. a 7500-frame × 2500-sample angle
needs ~2.4 GB for a single 32-row chunk.
With chunks running concurrently the budget has to cover *all* live chunks at
once. On a large scan `chunk_rows` is already clamped to its floor of one row
(one row alone is ~75 MB of float32 at 7507×2500), so shrinking the per-chunk
size cannot buy more concurrency — the worker count must be derived from the
budget instead: `_plan_chunks` picks the worker count *first* and sizes the
chunk to it. Sizing the chunk first is the trap: a single chunk would always
consume the whole budget and leave room for exactly one worker, precisely on
the large scans that need concurrency most.
The 1024 MB default (`SRAS_MEM_BUDGET_MB`) is the measured knee on a 16-core
machine against a 7507-frame × 2500-sample angle: 512 MB left ~20% of the
speedup on the table, and 1536+ MB cost ~0.4 GB more resident memory for no
further gain.
A caller that itself runs several computations concurrently (angle-level
parallelism, `plan_angle_level`) must pass *both* `max_workers=1` and its
share of the budget. Capping the workers alone is not enough: the chunk would
still be sized against the whole budget, and N concurrent callers would each
allocate all of it.
## FFT peak search: block-parallel zoom refinement (`sras_compute.py`)
The displayed RF value per pixel is the argmax of the zero-padded power
spectrum of that pixel's CH1 waveform. At the pad factor of 40 needed for
mapping resolution, materialising padded spectra is hopeless: ~9 GB per scan
row, which is what used to collapse the old row-chunk planner to one worker
and make synthesis single-threaded.
`_peak_bins_zoom` never materialises the padded spectrum:
1. a coarse rfft at `next_fast_len(2*spf)` — 2× oversampled, so the padded
power spectrum (a trig polynomial of degree spf−1) cannot hide its global
max between coarse samples;
2. every coarse bin within `_ZOOM_CAND_RATIO` (0.7) of its row's coarse max
becomes a refinement candidate. Quarter-natural-bin scalloping at the 2×
grid can understate a peak's power by at most ~19%, so 0.7 keeps a wide
margin. The DC-adjacent window is always refined too: the coarse DC bin
is zeroed for suppression, which would otherwise blind the scan to fine
bins closer to DC than the first coarse sample (where the leakage skirt
of an un-subtracted offset peaks);
3. each candidate window (±`_ZOOM_HALFWIDTH` = 0.75 coarse spacings; every
fine bin lies within 0.5 spacings of its nearest coarse bin) is evaluated
on the exact `n_fft` grid by one small complex gemm, with np.argmax's
lowest-bin tie-break preserved across windows.
The selected bin is bit-identical to the full padded argmax — enforced by
`tests/test_compute.py::test_zoom_identity`, a fuzz test over adversarial
spectra, and the golden-hash harness (`tools/check_equivalence.py`), whose
baseline was captured on the old full-padded path.
Work fans out over a persistent thread pool in `_FFT_BLOCK` = 512-waveform
tasks: smaller blocks serialise on GIL-held numpy dispatch, larger ones lose
cache residency and task granularity (measured on a 16-core machine, where
this path runs ~35× faster than the old serial padded transform at pad 40).
pyFFTW runs through per-thread `builders` plans (FFTW_MEASURE, wisdom
persisted under `~/.cache/sras-viewer/`), and `threadpoolctl` clamps BLAS to
one thread under the pool so the refinement gemm cannot oversubscribe.
`compute_rf_image(exact=True)` (or `SRAS_FFT_EXACT=1`) keeps the reference
full-padded path for audits.
## Row-averaged FFT: same-row, distance-weighted SNR cleanup (`sras_compute.py`)
`compute_rf_image`'s `row_avg_n` parameter averages each pixel's CH1
waveform with its up-to-n same-row neighbors before the FFT peak search, to
improve SNR on noisy scans. Never crosses rows: pixel pitch is strongly
anisotropic and varies by scan (5 µm × 50 µm on a typical scan, but as
stretched as 5 µm × 1 mm on others), so a physically meaningful "neighbor"
set can't be a fixed-shape 2-D window — but the X pitch *within one row* is
a single file-wide constant (`SrasFile.pixel_x_mm`), so restricting to the
row axis sidesteps the anisotropy question entirely rather than solving it
with an elliptical or physically-scaled 2-D kernel.
`_row_average_weights` is a Gaussian in pixel-index distance, not physical
mm distance — deliberately: within one row those are the same function up
to a fixed scale factor (`pixel_x_mm` is constant along a row), so the
kernel itself needs no pitch at all. `pixel_x_mm` is used for real exactly
once, in the GUI's options dialog, to show the window's physical width —
not in the kernel math, where it would only ever cancel out.
`_row_average_waveforms` is a masked/renormalized convolution (two
`correlate1d` calls, numerator and denominator, divided) rather than a
single fixed-normalized convolution, because a masked neighbor must
contribute *zero weight*, not a zero-amplitude sample at full weight — the
latter would bias every average near a masked run or a row's own edge
toward zero. The same two-correlation trick handles row-edge truncation for
free: `mode="constant", cval=0.0` zero-pads both the numerator and the
denominator beyond a row's own ends, so the output renormalizes by whatever
weight sum actually landed inside the row, no separate edge case.
Background subtraction stays exactly where it already was (subtracted once
from the fully-assembled `waves` buffer) rather than being threaded into the
per-neighbor gather. This is exact, not an approximation: because
`_row_average_waveforms`'s denominator is always the *actual* sum of
included, valid weights (never a fixed total), `Σwᵢ·(rawᵢ−bg) / Σwᵢ`
distributes to `avg − bg·(Σwᵢ/Σwᵢ) = avg − bg` regardless of which or how
many neighbors were included — subtracting background from the averaged
waveform is identical to subtracting it from every neighbor first, for any
window, at any row edge, with any number of masked-out neighbors.
No cross-row halo is needed: `compute_rf_image`'s chunk loop already splits
on rows only, and `read_row` already reads one row's complete
`(n_frames, spf)` slice at a time — averaging happens entirely inside that
one row's own frame axis, so a chunk boundary (which falls between rows)
can never truncate a window. Only a row's own start/end can, and that's the
same edge case the masked convolution already handles.
The averaging step doubles the live per-row scratch memory (a full-width
`(n_frames, spf)` buffer on top of the existing compacted `waves` buffer),
so `compute_rf_image` halves its byte budget when `row_avg_n > 0` before
`_plan_fft_rows`/the exact-path sizing runs — see "Memory budget and row
chunking" above. On the largest real scans `_plan_chunks` is already
clamped to its floor of one row regardless, so this costs no concurrency
where it matters most; it mainly protects moderate-sized scans from an
unexpected regression.
Persistence: `cached_rf_image` (the extracted fast-path check) requires
`sras.precomputed_row_avg_n == row_avg_n` exactly, so a raw request can
never be silently served a row-averaged cache or vice versa, and a request
at one window size can never be served a cache at another — see
`scan_format.md`'s Cache Tail / CACH tail version history sections for the
on-disk `row_avg_n` field this depends on.
## Serving a stored cache: provenance, not just presence
A stored `peak_freq_mhz` image is only interchangeable with a live compute
for the *exact* settings it was computed under. Three of them are baked
irreversibly into the numbers — background subtraction, row-averaging window,
and zero-padding — so all three are recorded in the `SFFT` block and checked
by `cached_rf_image` before it hands the image back. Getting this wrong is
not a slow display, it is a *wrong* display, which is why the check is a
single predicate in one place rather than spread across callers.
Padding is the subtlest of the three, because a padded FFT looks like it
should be a refinement of the unpadded one. It isn't: zero-padding
interpolates between the natural bins, so it resolves a different peak
frequency for the same waveform. `precomputed_pad_factor` exists so a padded
view can be served from a cache computed at *its* pad while still refusing
one computed at any other, including pad 1. Before it existed the store was
pad 1 by definition and any `n_fft` was rejected outright — correct, but it
meant a user working at a pad factor got nothing at all from batch-computing
a file, which is most of the point of the feature. `pad_factor_for` maps an
`n_fft` request onto the integer factor a store could have recorded, and
returns 0 for a request that is not a whole multiple of `samples_per_frame`
— unmatchable by construction, since only integer factors are representable.
The batch actions therefore have to cache at the *viewer's* current pad
factor, not a fixed one: a cache stored at a pad nobody is viewing at is
dead weight. When the two do diverge (the user changes the pad after
batching), `_cache_mismatch_notes` says so in the scan info panel, because
the symptom otherwise is just "the file I pre-computed got slow again" with
no visible cause.
That divergence note is informational only, not a warning of an impending
recompute. The *display* path (`_stored_fft_image`) never asks
`cached_rf_image` whether a stored image matches the window's live
bg-sub/pad/row-averaging controls — it asks whether the image matches its
*own* recorded settings (`sras.precomputed_bg_sub`/`precomputed_pad_factor`/
`precomputed_row_avg_n`), which is always true whenever a stored image
exists. So presence alone decides whether it's shown; the live controls
never gate it. They still matter for two things: a genuinely never-computed
angle's first live compute, and an explicit batch recompute — both of which
read the live controls and produce new stored data, at which point it's the
new data's *own* settings that get self-matched from then on. This is what
keeps a view switch (angle, channel, or flipping bg-sub/pad) from ever
discarding precomputed data — only an explicit batch recompute does, and it
already reloads the file afterward so the new data displays immediately.
Row-averaging has no live control to diverge from in the first place (it's
only ever set inside the batch dialog), so it never appears in the
divergence note — the "Cached images" line's own `row-averaged n=…` phrase
already covers it.
### Two caches, in cost order
`_refresh_display` consults this window's in-session `_fft_cache`/`_dc_cache`
dicts first, then the open file's stored v5/v7 blocks, and only then
dispatches a `ComputeWorker`. The second tier is what makes a batch-computed
file worth having; without it every angle change queued a worker and a
progress popup for an image already sitting on disk — precisely the cost the
batch was run to avoid. (The stored image *was* reachable before, but only
from inside `ComputeWorker`, i.e. after paying for the thread and the popup.)
The file tier asks with `allow_dc_recompute=False`. If applying the DC4 mask
would mean reading a whole CH4 channel, it declines rather than blocking the
GUI thread, and the fall-through worker reaches the same stored image via
`compute_rf_image` and pays for the mask off-thread. So the GUI thread never
does I/O, and the slow path is still a fast path.
## Angle alignment coordinate frames (`sras_compute.py`)
Alignment puts every angle's images onto one shared, zero-padded pixel grid
using a rigid transform only — rotation + translation, never scale.
Angle 0 (the reference) is the sole coordinate authority: it is the only
angle whose stage XY (`x_start_mm` / `y_positions_mm`) is ever read, and the
shared canvas is literally an extension of angle 0's own pixel grid, so the
aligned view carries angle 0's real X/Y axes. Every *other* angle is placed
purely by content — its rotation and translation come from cross-correlating
its CH4 image against angle 0's (`register_angle_to_reference`) — and its own
stage XY is deliberately never consulted. That is not an oversight: the
rotation stage moves the sample relative to the scan window, so where a
window sat in stage coordinates says nothing about where the sample is, and
an earlier design that pivoted each angle on a signal-weighted centroid of
its own window put every angle on a ~20 mm circle around the optical center
instead of stacking them into one shape.
Only two coordinate frames exist:
* **local mm** — one angle's own physical frame: origin at the *center of its
own pixel array*, x along +column, y along +row, scaled by that angle's own
pitches. Carries no stage position whatsoever.
* **ref mm** — the reference angle's local mm. A registration result
`(rotation_deg, shift_mm)` is exactly the rigid map from an angle's local
mm to ref mm: `q = R(rotation_deg) @ l + shift_mm`. Stage coordinates
re-enter once, at the very end, when the canvas origin is converted to
angle 0's stage mm (`AlignmentResult.canvas_origin_mm`).
Rotation is done in mm, never on raw pixel indices: the x pitch
(`SrasFile.pixel_x_mm`, 5 µm on a real scan) and the y/row pitch (50 µm)
differ by 10×, so rotating the raw index grid would shear the image — an
unwanted anisotropic scale. Registration runs on a resampled *isotropic* grid
for the same reason, and every affine maps shared-grid index → mm → undo
rotation/shift → that angle's own local mm → that angle's own raw index,
matching the output→input convention `scipy.ndimage.affine_transform` wants.
### Cropping the canvas is index translation, not a second transform
`crop_alignment_result` restricts an `AlignmentResult` to a rectangular window
of its canvas by folding the crop into each angle's existing affine rather than
composing a new one. From `_affine_out_to_src`, `matrix = D @ Rinv @ A_out`
depends only on the pitches and the rotation, and `A_out @ [row0, col0]` is
exactly the mm displacement of the new origin, so
```
matrix @ [r', c'] + (offset + matrix @ [row0, col0])
== matrix @ [r' + row0, c' + col0] + offset
```
identically. `matrix` is untouched and `offset` — which already absorbs the
origin — absorbs the crop too.
Two things follow, and both are relied on. `apply_alignment`, `reproject_mask`
and the aligned exporter all work on a cropped result with no special-casing:
resampling a cropped result is *exactly* a slice of resampling the full one
(`tests/test_align_export.py::test_crop_is_a_window_of_the_full_canvas` asserts
bit equality). And because the crop offset is a whole number of canvas pixels,
`canvas_for_params`' snap invariant — the reference angle lands on integer
canvas pixels — survives the crop, which is what keeps the reference exportable
as a verbatim block.
## Aligned export (`sras_align_export.py`)
`write_aligned_sras` bakes an alignment into a new v6 file: every angle
resampled onto the cropped shared canvas, so all of them end up with identical
geometry and the file opens already aligned. It is the only place in the
codebase that *resamples* waveform data — `sras_edit_scans` and `sras_average`
copy waveform bytes verbatim — which is why it is its own top-level module
rather than part of `sras_format` (scoped to the versioned binary spec, per the
sidecar section's own rule) or `sras_compute` (imported by every
multiprocessing child).
**Nearest neighbour, never interpolation.** Each output pixel gets exactly one
source pixel's three waveforms, verbatim. Averaging two neighbouring CH1
packets would synthesise a waveform the instrument never measured, whose FFT
peak is the peak of neither — meaningless for a technique whose entire output is
that peak frequency. The cost is that some source pixels are duplicated and
others dropped, which is the same trade `apply_alignment`'s `order=0` already
makes for the display.
**The rounding rule is `floor(x + 0.5)`, not `np.rint`.** `scipy.ndimage`'s
`order=0` rounds halves away from zero while `np.rint` rounds them to even. The
canvas is snapped to the reference's own pixel grid, so an angle whose row pitch
differs from the reference's lands on exact half-integers across whole rows —
this is the common case, not a corner case. Getting it wrong shifts those rows
by one source pixel relative to what the Aligned View drew.
**Out-of-bounds is tested on the fractional coordinate, not the rounded index.**
`scipy`'s `mode="constant"` writes `cval` wherever the coordinate leaves the
range of sample *centres*, `[0, n-1]` — a coordinate of −0.4 rounds to a
perfectly valid index 0 and is still padding. Testing the rounded index instead
puts a one-pixel rim of real data everywhere the preview shows padding.
**...but with a tolerance (`_EDGE_TOL`).** The affine is built from a chain of
mm-space multiplications, so an exactly-integer transform comes out a few times
1e-13 off: the reference angle's offset is `-20 - 7e-15`, not `-20`. A bare
`>= 0.0` therefore rejects that angle's entire first row, and `<= n-1` its last
column — for the *reference* angle, whose whole job is to pass through as an
exact integer crop. The tolerance is ~7 orders of magnitude above that noise and
~7 below the half-pixel scale at which a rounding decision means anything, so it
can only ever change pixels whose scipy answer was itself decided by noise.
**Padding is the per-channel ADC code nearest 0 mV, not 0.** Zero ADC decodes to
`(0 - yoff) * ymult + yzero`, which on real calibration is around +100 mV —
above any sensible CH4 mask threshold, so a zero fill would paint a solid
rectangle of "valid" pixels around the sample and corrupt every DC image and ROI
statistic downstream.
**Source rows are served from sliding in-RAM bands** (`_SourceReader`). A
rotated angle maps one output row to a *diagonal* across the source array, so
the pixels of a single output row come from hundreds of different source rows —
~1.4 MB each on a full-size scan. Indexing a memmap pixel-by-pixel in output
order re-faults nearly the whole angle per output row: terabytes of paging for a
gigabyte of data. Reading a contiguous band per output chunk, with the band
advancing monotonically, costs roughly 2× the source size in total reads.
**Writes go to `.part` and are `os.replace`d into position.** Not politeness: a
truncated .sras is not detectably broken, because `_parse_v6` drops incomplete
trailing angle blocks and opens what is left as an aborted scan. A half-written
export left in place would silently look like a real file with fewer angles.
**The Angle Table is carried over unchanged.** Alignment removes the *spatial*
rotation of the sample; it does not change which acoustic propagation direction
each angle measured, and that direction is the scientific content of a
multi-angle scan. Zeroing the table would make the export self-consistent for
re-registration and useless for anisotropy work. The consequence is that
re-registering an export needs `seed_deg=0.0` to put 0° inside the coarse sweep,
since `nominal_delta_deg` is still non-zero — which is exactly what the seed
parameter exists for.
## Alignment wizard (`sras_viewer/align_wizard.py`)
A `QWizard` rather than another dialog because the three steps are genuinely
sequential and the last one is destructive: correlate, choose a crop, write a
file. It replaces both former Fusion actions, so it also absorbs the old
`ManualAlignmentDialog`'s by-eye nudge editor — otherwise a scan the search
cannot fit would have no fallback at all.
Shared state lives on the wizard object, not in `registerField`: the pages pass
numpy arrays, `ManualAngleParams` and an `AlignmentResult` between them, none of
which are scalar widget properties.
`IndependentPages` is deliberately left **off**. With it set Qt never calls
`cleanupPage`, and `cleanupPage` is how the ROI page discards a crop when the
user goes back to re-correlate — a crop is indexed in canvas pixels, and a new
rotation means a different canvas, so stale indices would silently be
reinterpreted against the wrong grid. `geometry_generation` is the belt-and-
braces check for the same hazard.
The mask-stack preview shares the **final** canvas's origin and uses a pitch
that is an integer multiple of it, unlike the old manual dialog's padded,
unsnapped preview canvas. That is what lets the crop page convert a rectangle
drawn in millimetres into an exact integer window of the real canvas, with no
second coordinate frame to reconcile.
"Fit to full overlap" uses `largest_rect_at_least`, a largest-rectangle sweep,
not a bounding box of the fully-covered pixels. The full-overlap region of
several rotated scans is roughly a disc, and its bounding box has corners no
angle covers — offering that as the crop would hand the user the padding they
were trying to avoid.
Every background launch follows the two rules `_run_worker`'s docstring
establishes: disable the trigger *before* the call (so a re-entrant click cannot
start a second thread over the first), and never ignore the returned bool.
Progress is an inline `QProgressBar` on the page rather than a `QProgressDialog`
— a window-modal popup over a wizard both looks wrong and reintroduces the
event-loop pumping hazard that ordering exists to avoid. `reject()` refuses to
close while a job is in flight, since the running worker's signals are connected
to bound methods of the pages Qt would be deleting.
## Manual-alignment sidecar (`sras_compute.py`)
`<name>.sras.align.json` lives next to the scan file. The code lives in
`sras_compute`, not `sras_format`: `sras_format` is scoped to the versioned
binary .sras spec itself (see `scan_format.md`), while a manual alignment is
a viewer-computed *derived* artifact, analogous in kind to `AlignmentResult`
— so it belongs with the alignment math it serialises. json + pathlib are
stdlib, so this adds no dependency to a module whose load-bearing constraint
is staying free of Qt/matplotlib for cheap multiprocessing-child imports.
### Schema history
The stored `rotation_deg`/`shift_mm` are meaningless without the frame they
were measured in, so `_SIDECAR_SCHEMA_VERSION` is bumped whenever that frame
changes. Each bump makes older files describe a different (and, for the bugs
each bump fixed, actively wrong) transform than the same numbers would today;
loading one unchanged would silently reproduce the very "scans show up
everywhere" symptom the bump fixed — so older sidecars are treated as absent
rather than migrated.
* **1 → 2** — pivot moved from the scan-window bbox center to a
content-derived centroid, and the rotation sign convention was corrected.
* **2 → 3** — the content centroid was abandoned entirely: rotation is now
about each angle's own array center, mapped onto the reference's array
center, with `shift_mm` in the reference's local mm frame. No angle but the
reference contributes stage coordinates any more.
+41
View File
@@ -0,0 +1,41 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "sras-viewer"
version = "0.1.0"
description = "Viewer and processing tools for SRAS .sras scan files"
requires-python = ">=3.12"
dependencies = [
"PyQt6==6.10.2",
"numpy==2.4.1",
"matplotlib==3.10.8",
"scipy==1.18.0",
# Angle alignment only: masked FFT phase correlation (skimage.registration).
"scikit-image==0.26.0",
# Faster rfft backend; the viewer falls back to scipy.fft without it.
"pyFFTW==0.15.1",
# Clamps BLAS threading under the FFT worker pool.
"threadpoolctl==3.6.0",
]
[project.optional-dependencies]
dev = ["pytest"]
[project.scripts]
sras-viewer = "sras_viewer.main_window:main"
[tool.setuptools]
py-modules = [
"sras_format",
"sras_compute",
"sras_workers",
"sras_align_export",
"sras_average",
"sras_edit_scans",
]
packages = ["sras_viewer"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+95 -9
View File
@@ -173,6 +173,41 @@ sum over angles a of: n_rows[a] × 3 × n_frames[a] × samples_per_frame × byte
> Table and check `file_size` against the running total before reshaping —
> a fixed `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not
> work since row/frame counts are no longer uniform across angles.
>
> A consequence worth stating explicitly: because a short file opens
> *successfully* as a scan with fewer angles, a truncated file is not
> detectably broken. Anything that writes a .sras must therefore stage to a
> temporary name and rename on success — the viewer's aligned export writes
> `<name>.part` and `os.replace`s it — or an interrupted write leaves behind
> something that loads without complaint and silently has the wrong angle count.
---
## Files written by the viewer's Alignment Wizard
The acquisition app is not the only producer of this format. The viewer's
`Fusion → Alignment Wizard…` writes a **v6** file holding the aligned, cropped
stack, with these properties:
* Every angle shares one grid — the cropped alignment canvas — so the
Per-Angle Geometry Table is `n_angles` identical records and the ragged Row
Table is `n_angles` identical spans. The raggedness v6 exists for is still
*expressible*, just unused, so any v6 reader works unchanged.
* `x_delta` is the reference angle's own pitch, which is exactly
`velocity_mm_s / laser_freq_hz`, so the derived X axis stays consistent with
the header.
* The `*_nominal` header fields describe the crop. Uniquely for these files they
coincide with the actual per-angle geometry, since after alignment every angle
really does scan the same box.
* The **Angle Table is unchanged**. Alignment removes the sample's spatial
rotation, not the acoustic propagation direction each angle measured — that
direction is the point of a multi-angle scan, so it is preserved.
* Output pixels with no corresponding source pixel (the canvas corners a rotated
scan cannot reach) hold the per-channel ADC code nearest **0 mV**, not zero.
Zero ADC decodes to roughly +100 mV on real calibration and would read as
signal.
* No Cache Tail is written: any cached DC/FFT is indexed by the source's grid
and would be meaningless on the new one.
---
@@ -220,7 +255,7 @@ actions.
| Offset | Size | Type | Field | Description |
|--------|------|------|-------|-------------|
| 0 | 4 | `char[4]` | `cach_magic` | `CACH` (ASCII). Missing/wrong magic → treat file as having no cache. |
| 4 | 1 | `u8` | `cach_version` | Cache format version. Currently `1`. Readers must treat the file as uncached if this is not a version they understand (unlike v5's `PREC` section, which read but never validated its version byte). |
| 4 | 1 | `u8` | `cach_version` | Cache format version. Currently `3`; readers also accept `1` and `2` (each older tail simply lacks the fields added since — see the `SFFT` block below and [CACH tail version history](#cach-tail-version-history)). Any other value → treat the file as uncached (unlike v5's `PREC` section, which read but never validated its version byte). |
| 5 | 1 | `u8` | `block_flags` | Bit 0 = DC block (`SDCB`) follows. Bit 1 = FFT block (`SFFT`) follows, immediately after the DC block if both are present. Bits 2–7 reserved, must be zero on write. |
### DC block `SDCB` (present iff `block_flags & 0x01`)
@@ -249,13 +284,29 @@ value, same as v5's `PREC` section.
### FFT block `SFFT` (present iff `block_flags & 0x02`)
7-byte block header, format `">4sBH"`:
Block header layout depends on `cach_version`:
Each `cach_version` appended one trailing field, so the header grows but
never shifts an existing offset:
- **`cach_version` 1**: 7 bytes, format `">4sBH"` — magic, flags, n_stored.
- **`cach_version` 2**: 8 bytes, format `">4sBHB"` — + `row_avg_n`.
- **`cach_version` 3**: 10 bytes, format `">4sBHBH"` — + `pad_factor`.
Always written by current code.
An older tail is read with its absent fields taken as the only value such a
tail can describe: `row_avg_n = 0` for a `cach_version` 1 tail, which
predates row-averaged FFT caching, and `pad_factor = 1` for `cach_version`
1 or 2, which predate padded caching and are therefore natural-resolution.
Files cached before either change keep working with no recompute.
| Offset (rel) | Size | Type | Field | Description |
|--------------|------|------|-------|-------------|
| 0 | 4 | `char[4]` | `magic` | `SFFT` |
| 4 | 1 | `u8` | `flags` | Bit 0 = `bg_sub_applied` — background waveform was subtracted from CH1 before the FFT when these images were computed. Bits 1–7 reserved. |
| 4 | 1 | `u8` | `flags` | Bit 0 = `bg_sub_applied` — background waveform was subtracted from CH1 before the FFT when these images were computed. Bit 1 = `row_averaged` — `peak_freq_mhz` came from same-row, distance-weighted averaged CH1 waveforms rather than raw per-pixel ones; `row_avg_n` (below) is the neighbor half-width used. Bits 2–7 reserved. |
| 5 | 2 | `u16` | `n_stored` | Number of angle entries that follow |
| 7 | 1 | `u8` | `row_avg_n` | *`cach_version` ≥ 2 only.* Same-row neighbor half-width, in pixels, that `peak_freq_mhz` was averaged over before its FFT; `0` = raw (unaveraged). Meaningful only when `flags` bit 1 is set — a `cach_version` 1 tail has no such byte and is always `row_avg_n = 0`. |
| 8 | 2 | `u16` | `pad_factor` | *`cach_version` 3 only.* Zero-padding factor the stored `peak_freq_mhz` was resolved at: `n_fft = pad_factor × samples_per_frame`, so `1` = natural resolution. Never `0`; a `cach_version` 1 or 2 tail has no such field and is always `pad_factor = 1`. |
followed by `n_stored` entries, each:
@@ -264,9 +315,10 @@ u16 angle_idx — index into the angle table (0-ba
f32[n_rows[angle_idx] × n_frames[angle_idx]] peak_freq_mhz — CH1 FFT peak frequency, MHz, row-major
```
**`peak_freq_mhz`** is computed without any DC-threshold masking (i.e. the
FFT is run on every pixel unconditionally, same as v5's `PREC` convention).
Readers apply the DC4 threshold at display time:
**`peak_freq_mhz`** for a raw store (`row_avg_n == 0`) is computed without
any DC-threshold masking (i.e. the FFT is run on every pixel
unconditionally, same as v5's `PREC` convention). Readers apply the DC4
threshold at display time:
```
pixel is valid ⟺ dc4_mv[r][f] ≥ threshold_mv
@@ -276,10 +328,27 @@ display_value = peak_freq_mhz[r][f] if valid, else 0
using the DC4 image from the DC block if that angle is also cached there,
else computed on demand.
For a row-averaged store (`row_avg_n > 0`), the DC4 threshold is applied
*during* the store — a pixel below threshold is left at `0` and never
contributes to any neighbor's average — since neighbor validity can't be
deferred to display time the way plain masking can. The threshold value
itself is not recorded, only that averaging happened and at what window
size. Readers still apply their own live DC4 threshold at display time
exactly as for a raw store, using whatever mask they currently have.
Readers must fall back to real-time FFT computation (ignoring stored
`peak_freq_mhz`) under the same conditions as v5's PREC fast path: time-domain
gating is active, zero-padding (`n_fft ≠ samples_per_frame`) is requested, or
the reader's background-subtraction setting doesn't match `flags.bg_sub_applied`.
`peak_freq_mhz`) whenever the store's recorded provenance doesn't match what
the reader is asking for: time-domain gating is active, the reader's
requested `n_fft` doesn't equal `pad_factor × samples_per_frame`, the
reader's background-subtraction setting doesn't match
`flags.bg_sub_applied`, or the reader's requested `row_avg_n` doesn't match
the stored value exactly. A raw request must never be served a row-averaged
store, or vice versa; a request at one row-averaging window size must never
be served a store at another; and a request at one padding must never be
served a store at another, since a padded FFT interpolates between the
natural bins and so resolves genuinely different peak frequencies. An
`n_fft` that is not a whole multiple of `samples_per_frame` can never match
any store, because only an integer `pad_factor` is representable.
### In-place write ordering
@@ -294,6 +363,23 @@ interrupted write leaves harmless trailing bytes rather than a corrupt file,
and the next successful write overwrites them via the same deterministic
`cache_offset`.
### CACH tail version history
Distinct from the outer `.sras` file `version` byte (top of this document),
which has stayed `7` since the Cache Tail was introduced — this is the inner
`cach_version` byte inside the `CACH` header itself.
| cach_version | Change |
|--------------|--------|
| 1 | Initial Cache Tail: `SDCB` (DC) and `SFFT` (FFT, 7-byte header) blocks. |
| 2 | `SFFT` header grows one byte, `row_avg_n` — the same-row neighbor half-width the stored `peak_freq_mhz` was averaged over before its FFT, `0` = raw. Readers still accept a `cach_version` 1 tail, treated as `row_avg_n = 0` for every angle it stores, so files cached before this change keep working without a recompute. |
| 3 | `SFFT` header grows a `u16` `pad_factor` — the zero-padding factor the stored `peak_freq_mhz` was resolved at, `1` = natural resolution. Before this, a padded view could never use a stored cache at all (the store was pad 1 by definition and readers rejected any `n_fft ≠ samples_per_frame`), so a user working at a pad factor got no benefit from batch-computing a file. Recording the factor lets such a view be served, while still refusing a store resolved at a *different* pad. Readers accept `cach_version` 1 and 2 tails as `pad_factor = 1`. |
A reader that does not know a `cach_version` must treat the file as
uncached — not attempt a partial parse — and the file still reads as an
ordinary v7 (byte-identical to v6) scan, so a forward-dated tail costs a
recompute and never correctness.
---
## Acquisition Settings (fixed by sc3_aui_app.py)
+456
View File
@@ -0,0 +1,456 @@
#!/usr/bin/env python3
"""Write an aligned, cropped .sras file from an AlignmentResult.
The alignment machinery in sras_compute never modifies a scan: it produces an
AlignmentResult, and every consumer resamples on the fly (apply_alignment for
the display, reproject_mask for the overlay). That is right for a viewer, but it
means the aligned stack cannot leave the process — no other tool can read it,
and re-opening the scan re-does the registration.
This module bakes an alignment into a new file. Each angle is resampled onto the
shared canvas that AlignmentResult already defines, cropped to the caller's
window, so every output angle ends up with *identical* geometry: same rows, same
frames, same X/Y coordinates. Rotation and translation are gone, absorbed into
where each waveform sits. The result is an ordinary v6 file that opens already
aligned, and registering it against itself returns identity.
Two deliberate choices, both about not inventing data:
* The resample is a nearest-neighbour **gather** of whole waveforms, never an
interpolation. Averaging two neighbouring pixels' CH1 packets would produce
a waveform the instrument never measured, whose FFT peak is not the peak of
either — meaningless for a technique whose entire output is that peak
frequency. So each output pixel gets exactly one source pixel's three
waveforms, verbatim, and the cost is that some source pixels are duplicated
and others dropped. This matches apply_alignment's order=0 for the same
reason.
* Output pixels with no source pixel (the canvas corners a rotated scan cannot
reach, and anything outside the crop's coverage) are filled with the ADC
code for 0 mV, not with zero. See _fill_row.
Depends only on numpy/sras_format/sras_compute — no Qt — so it is directly
unit-testable and importable from a worker thread.
"""
import os
import struct
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import sras_compute as compute
from sras_compute import AlignmentResult
from sras_format import GEO_FMT_V6, HDR_FMT_V6, SrasFile, mv_to_adc
# GEO_FMT_V6 stores n_rows as ">H" and n_frames as ">I". A canvas that overflows
# either is not representable, and silently truncating would write a file whose
# geometry table disagrees with its waveform block.
_MAX_ROWS = 0xFFFF
_MAX_FRAMES = 0xFFFFFFFF
# Slack, in source pixels, on the in-bounds test at the very edge of a source
# array. Absorbs the ~1e-13 of float noise an exactly-integer affine picks up
# from being built in mm space; see _in_bounds.
_EDGE_TOL = 1e-6
# Output rows per write() call. One row is n_channels * n_cols * spf bytes —
# ~1.5 MB on a full-size scan — so a handful of rows keeps the peak buffer in
# the low tens of MB no matter how large the scan is.
_ROW_CHUNK = 8
@dataclass
class ExportPlan:
"""What write_aligned_sras would produce, without producing it.
Derived from the affine transforms alone — no waveform bytes are read — so
the wizard can call it on every ROI edit to keep a live size estimate and
per-angle coverage readout in front of the user *before* they commit to a
multi-gigabyte write.
"""
n_rows: int
n_frames: int
n_angles: int
bytes_per_angle: int
total_bytes: int
valid_px: dict[int, int] # output pixels with a source pixel
warnings: list[str] = field(default_factory=list)
def coverage_frac(self, angle_idx: int) -> float:
px = self.n_rows * self.n_frames
return (self.valid_px.get(angle_idx, 0) / px) if px else 0.0
def empty_angles(self) -> list[int]:
"""Angles that would be written as pure padding — no output pixel of
theirs has a source pixel."""
return [a for a in range(self.n_angles) if not self.valid_px.get(a, 0)]
def _src_coords(t, rows, n_cols: int) -> tuple[np.ndarray, np.ndarray]:
"""Fractional source (row, col) coordinates for whole output rows.
*rows* is an array of output row indices; both results are shaped
(len(rows), n_cols).
"""
cols = np.arange(n_cols, dtype=np.float64)
r = np.asarray(rows, dtype=np.float64)[:, None]
sr = t.matrix[0, 0] * r + t.matrix[0, 1] * cols + t.offset[0]
sc = t.matrix[1, 0] * r + t.matrix[1, 1] * cols + t.offset[1]
return sr, sc
def _round_idx(coord: np.ndarray) -> np.ndarray:
"""Nearest source index, rounding halves away from zero.
floor(x + 0.5), not np.rint: scipy.ndimage's order=0 rounds halves away
from zero while np.rint rounds them to even, and these have to be the same
source pixels the apply_alignment(order=0) preview drew. Exact halves are
not a corner case here — the canvas is snapped to the reference angle's own
pixel grid (see canvas_for_params), so an unrotated angle lands on
half-integers wherever its row pitch differs from the reference's.
"""
return np.floor(coord + 0.5).astype(np.int64)
def _in_bounds(sr: np.ndarray, sc: np.ndarray,
n_rows: int, n_frames: int) -> np.ndarray:
"""Which output pixels have a source pixel, by scipy's mode="constant" rule
plus a tolerance at the edge.
Tested on the *fractional* coordinate against the range of sample centres,
[0, n-1] inclusive — deliberately not on the rounded index. The two differ
around the whole rim: a coordinate of -0.4 rounds to a perfectly valid index
0, but scipy calls it out of bounds and writes cval there, so testing the
rounded index would put a one-pixel rim of real data everywhere the Aligned
View shows padding.
_EDGE_TOL is why this is not literally scipy's test. The affine is built
from a chain of mm-space multiplications, so an exactly-integer transform
comes out a few times 1e-13 off (the reference angle's offset lands on
-20 - 7e-15 rather than -20). Bare >= 0.0 then rejects that angle's entire
first row, and <= n-1 its last column — for the *reference* angle, whose
whole role is to pass through as an exact integer crop. The tolerance is
seven orders of magnitude above that noise and seven below the half-pixel
scale at which a rounding decision is ever meaningful, so it can only ever
change pixels whose scipy answer was itself decided by rounding noise.
"""
return ((sr >= -_EDGE_TOL) & (sr <= n_rows - 1 + _EDGE_TOL)
& (sc >= -_EDGE_TOL) & (sc <= n_frames - 1 + _EDGE_TOL))
# Output rows evaluated per numpy call when counting coverage. Counting row by
# row costs one small matmul per row (hundreds of milliseconds per angle on a
# full-size scan, on every ROI edit); counting the whole canvas at once needs
# hundreds of MB of index arrays. Blocking gets both: ~10 numpy calls per angle
# against ~30 MB of live index arrays.
_COUNT_BLOCK = 128
def _count_in_bounds(t, n_rows: int, n_cols: int,
src_rows: int, src_frames: int) -> int:
"""How many of the n_rows x n_cols output pixels have a source pixel."""
total = 0
for start in range(0, n_rows, _COUNT_BLOCK):
rows = np.arange(start, min(start + _COUNT_BLOCK, n_rows))
sr, sc = _src_coords(t, rows, n_cols)
total += int(np.count_nonzero(_in_bounds(sr, sc, src_rows, src_frames)))
return total
def plan_export(sras: SrasFile, result: AlignmentResult) -> ExportPlan:
"""Geometry, size and per-angle coverage of the file *result* would export.
Coverage is counted from the actual per-pixel index arrays rather than
approximated by the footprint parallelogram's area, because the two differ
exactly where it matters — a crop that clips one angle's scan window — and
this number is what tells the user an angle will come out mostly empty. No
waveform bytes are read, so it stays fast enough to call on every ROI edit.
"""
n_rows, n_cols = result.canvas_shape
n_angles = sras.n_angles
warnings: list[str] = []
valid_px: dict[int, int] = {}
for a in range(n_angles):
t = result.per_angle.get(a)
if t is None:
valid_px[a] = 0
warnings.append(f"Angle {a} has no transform and will be all padding.")
continue
src_rows, src_frames = sras.image_shape(a)
valid_px[a] = _count_in_bounds(t, n_rows, n_cols, src_rows, src_frames)
bytes_per_angle = (n_rows * sras.n_channels * n_cols
* sras.samples_per_frame * sras.bytes_per_sample)
# Built before the remaining warnings so they can be phrased with the
# plan's own coverage_frac rather than a second copy of the same division.
plan = ExportPlan(n_rows=n_rows, n_frames=n_cols, n_angles=n_angles,
bytes_per_angle=bytes_per_angle,
total_bytes=bytes_per_angle * n_angles,
valid_px=valid_px, warnings=warnings)
if n_rows > _MAX_ROWS:
warnings.append(
f"Crop is {n_rows} rows; the .sras geometry table caps rows at "
f"{_MAX_ROWS}. Narrow the ROI in Y.")
if n_cols > _MAX_FRAMES:
warnings.append(f"Crop is {n_cols} frames; the cap is {_MAX_FRAMES}.")
for a in range(n_angles):
frac = plan.coverage_frac(a)
if frac == 0.0:
warnings.append(
f"Angle {a} has no data inside this crop — it will be written "
f"as all padding.")
elif frac < 0.10:
warnings.append(
f"Angle {a} covers only {frac * 100:.1f}% of the crop.")
if sras.background is None:
warnings.append(
"Input has no background waveform (pre-v4 scan); a zero background "
"is written, which makes background subtraction a no-op.")
if sras.version != 6:
warnings.append(f"Input is v{sras.version}; the export is written as v6.")
if sras.scan_aborted:
warnings.append(
f"Input scan was aborted: only its {n_angles} complete angle(s) "
f"are exported.")
return plan
def _fill_row(sras: SrasFile, n_cols: int, dtype) -> np.ndarray:
"""One output row of pure padding, shape (n_channels, n_cols, spf).
Filled per channel with the ADC code for 0 mV, not with 0. Zero ADC decodes
to (0 - yoff) * ymult + yzero, which for a real scope preamble is a long way
from 0 mV — often far enough to sit above the CH4 mask threshold, which
would paint a solid rectangle of "valid" pixels around the sample and make
every DC image and every ROI statistic wrong. Rounding to the integer code
lands within half an ADC step of 0 mV, which is as close as the format can
represent.
"""
info = np.iinfo(dtype)
codes = [int(np.clip(round(mv_to_adc(0.0, *sras.cal(ch))), info.min, info.max))
for ch in range(sras.n_channels)]
row = np.empty((sras.n_channels, n_cols, sras.samples_per_frame), dtype=dtype)
for ch, code in enumerate(codes):
row[ch] = code
return row
class _SourceReader:
"""Gives the gather source rows without ever reading one twice.
This is the difference between a usable export and an unusable one, and it
is entirely about read amplification. A rotated angle maps one output row to
a *diagonal* line across the source array, so the pixels of a single output
row come from hundreds of different source rows — on a full-size scan, a
~1.4 MB source row each. Indexing a memmap pixel by pixel in output order
therefore re-faults nearly the whole angle for every output row: terabytes
of paging for a gigabyte of data.
So rows are served from a contiguous *band* held in RAM. The band for a
chunk of output rows is read in one sequential slice, and because output
rows advance monotonically through the source, consecutive chunks' bands
barely overlap: each source row is read about once, and the whole job costs
roughly 2x the source size in reads rather than a thousand times it.
Small angles skip the machinery — if the whole block fits the budget it is
materialized once and every band is a view of it.
One honest caveat: a chunk whose diagonal spans more source rows than the
budget allows still gets the band it asked for, so the budget can be
overshot. The overshoot is bounded by the span of _ROW_CHUNK output rows,
and in the worst case (an extreme rotation on a huge scan) that is the whole
angle — i.e. no worse than the in-RAM path above. Accepted deliberately:
correctness of the gather is not negotiable, and the alternative is the
memmap thrashing this class exists to avoid.
"""
def __init__(self, sras: SrasFile, angle_idx: int, budget: int):
self._src = sras.data[angle_idx]
self._n_rows = self._src.shape[0]
self._row_bytes = max(1, self._src[0].nbytes)
self._whole = np.asarray(self._src) if self._src.nbytes <= budget else None
# Leave room for the output buffer and the index arrays alongside.
self._max_band = max(1, int(budget * 0.5) // self._row_bytes)
self._band = None
self._lo = self._hi = 0
def band(self, lo: int, hi: int) -> tuple[np.ndarray, int]:
"""Rows [lo, hi) as an in-RAM array, plus the index its row 0 holds."""
lo = max(0, min(lo, self._n_rows))
hi = max(lo + 1, min(hi, self._n_rows))
if self._whole is not None:
return self._whole, 0
if self._band is None or lo < self._lo or hi > self._hi:
# Read a little more than asked so a chunk whose band creeps
# forward by a few rows does not re-read the whole span.
span = min(self._max_band, max(hi - lo, self._max_band // 2))
self._lo = lo
self._hi = min(self._n_rows, lo + span)
if self._hi < hi: # band cannot cover the ask
self._hi = hi
self._band = np.asarray(self._src[self._lo:self._hi])
return self._band, self._lo
def close(self):
self._whole = None
self._band = None
def write_aligned_sras(sras: SrasFile, result: AlignmentResult, out_path,
*, progress_cb=None, should_stop=None,
budget: int | None = None) -> Path:
"""Write *sras*, aligned per *result* and cropped to its canvas, to a new
v6 .sras file. Returns the path written.
*result* is used exactly as given: crop the canvas first with
compute.crop_alignment_result, whose offset shift makes the cropped result
resample precisely the window the user selected.
No cache tail is written. Any DC/FFT the input had cached is indexed by the
input's grid and is meaningless on the new one, so — like sras_edit_scans —
the export drops it and lets the viewer recompute.
Writes to a sibling ".part" file and os.replace()s it into position on
success, unlinking it on error or cancellation: a half-written .sras is not
detectably broken (the v6 parser treats a short file as an aborted scan and
opens it happily), so it must never be left where the user might load it.
*should_stop* is polled once per output row chunk; returning True aborts and
raises nothing — the partial file is removed and the returned path will not
exist, so callers must check.
"""
out_path = Path(out_path)
n_rows, n_cols = result.canvas_shape
n_ch, spf = sras.n_channels, sras.samples_per_frame
n_angles = sras.n_angles
if n_rows <= 0 or n_cols <= 0:
raise ValueError(f"empty canvas: {n_rows} x {n_cols}")
if n_rows > _MAX_ROWS:
raise ValueError(
f"{n_rows} rows exceeds the .sras per-angle geometry limit of "
f"{_MAX_ROWS}; crop further in Y")
if n_cols > _MAX_FRAMES:
raise ValueError(f"{n_cols} frames exceeds the limit of {_MAX_FRAMES}")
missing = [a for a in range(n_angles) if a not in result.per_angle]
if missing:
raise ValueError(f"alignment result has no transform for angle(s) {missing}")
# The source's waveform blocks are live read-only memmaps into sras.path,
# so writing over it would corrupt the very reads the gather is making.
if out_path.exists() and out_path.samefile(sras.path):
raise ValueError(
"refusing to export onto the source scan; choose another filename")
dtype = np.dtype(np.int8 if sras.bytes_per_sample == 1 else ">i2")
x0_mm, y0_mm = result.canvas_origin_mm
y_rows = (y0_mm + np.arange(n_rows) * result.canvas_dy_mm).astype(">f4")
# Reference-only header fields. v6/v7 inputs have real ones to carry over;
# for a legacy input describe the canvas we are actually writing.
if sras.x_start_nominal_mm is not None:
nominal = (sras.x_start_nominal_mm, sras.y_start_nominal_mm,
sras.x_delta_nominal_mm, sras.y_delta_nominal_mm,
sras.row_spacing_mm)
else:
nominal = (x0_mm, y0_mm,
n_cols * sras.pixel_x_mm, n_rows * result.canvas_dy_mm,
result.canvas_dy_mm)
header = struct.pack(
HDR_FMT_V6, b"SRAS", 6, n_angles,
float(nominal[0]), float(nominal[1]), float(nominal[2]),
float(nominal[3]), float(nominal[4]),
sras.velocity_mm_s, sras.laser_freq_hz, spf, sras.sample_rate_hz,
sras.bytes_per_sample, n_ch)
# Every angle now shares one grid, so the ragged v6 tables collapse to
# n_angles copies of the same record. x_delta is the reference angle's own
# pitch (the canvas is its grid extended), which is velocity/laser_freq
# exactly, so x_axis_mm() stays self-consistent on re-read.
geo = struct.pack(GEO_FMT_V6, float(x0_mm), float(sras.pixel_x_mm),
int(n_cols), int(n_rows)) * n_angles
budget = compute.memory_budget_bytes() if budget is None else max(1, budget)
total_chunks = max(1, n_angles * ((n_rows + _ROW_CHUNK - 1) // _ROW_CHUNK))
done_chunks = 0
cancelled = False
part_path = out_path.with_name(out_path.name + ".part")
try:
with open(part_path, "wb") as fout:
fout.write(header)
fout.write(sras.angles_deg.astype(">f4").tobytes())
fout.write(geo)
fout.write(y_rows.tobytes() * n_angles)
fout.write(sras.encoded_preambles())
fout.write(sras.encoded_background())
pad = _fill_row(sras, n_cols, dtype)
for a in range(n_angles):
t = result.per_angle[a]
reader = _SourceReader(sras, a, budget)
src_rows, src_frames = sras.image_shape(a)
try:
for chunk_start in range(0, n_rows, _ROW_CHUNK):
if should_stop is not None and should_stop():
cancelled = True
break
chunk = np.arange(chunk_start,
min(chunk_start + _ROW_CHUNK, n_rows))
sr, sc = _src_coords(t, chunk, n_cols)
ok = _in_bounds(sr, sc, src_rows, src_frames)
# Clip rather than trust: _EDGE_TOL admits coordinates a
# hair outside the array, and an index off the end here
# would silently read the wrong row of the band.
idx_r = np.clip(_round_idx(sr), 0, src_rows - 1)
idx_c = np.clip(_round_idx(sc), 0, src_frames - 1)
# One band read covers the whole chunk: every source row
# any of these output rows touches, in one sequential
# slice. See _SourceReader.
if ok.any():
band, base = reader.band(int(idx_r[ok].min()),
int(idx_r[ok].max()) + 1)
else:
band, base = None, 0
for i in range(len(chunk)):
out = pad.copy()
keep = ok[i]
if keep.any():
# The two advanced indices are separated by a
# slice, so numpy puts the gathered axis first:
# (n_sel, n_ch, spf). Move it behind channels.
out[:, keep, :] = band[
idx_r[i][keep] - base, :, idx_c[i][keep], :
].transpose(1, 0, 2)
# out is C-contiguous, so the buffer protocol
# writes it straight out — .tobytes() would
# copy a full row per row written.
fout.write(out)
done_chunks += 1
if progress_cb is not None:
progress_cb(int(done_chunks / total_chunks * 100))
finally:
reader.close()
if cancelled:
break
if not cancelled:
fout.flush()
os.fsync(fout.fileno())
if cancelled:
part_path.unlink(missing_ok=True)
return out_path
os.replace(part_path, out_path)
except BaseException:
part_path.unlink(missing_ok=True)
raise
if progress_cb is not None:
progress_cb(100)
return out_path
+1 -1
View File
@@ -47,7 +47,7 @@ def read_header_sections(sras: SrasFile) -> bytes:
lost in a re-encode."""
with open(sras.path, "rb") as f:
f.seek(HDR_SIZE)
return f.read(sras._data_offset - HDR_SIZE)
return f.read(sras.data_offset - HDR_SIZE)
def average_rows(block: np.ndarray, n: int, discard_remainder: bool) -> np.ndarray:
+1467 -533
View File
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""
sras_edit_scans.py — Remove one or more angle scans from a .sras file.
A .sras file holds one or more "angles" (rotation positions); the viewer
cross-correlates each non-reference angle against the reference to align
them. If one angle's acquisition went wrong (stage glitch, bad trigger,
laser dropout, ...) it throws off that alignment for the whole file. This
tool drops the bad angle(s) and renumbers the rest, writing a new .sras file
with everything else — waveform samples, calibration preambles, background
waveform, row/geometry tables — carried over byte-for-byte.
Handles v2-v7. Any precomputed FFT/DC cache (v5 PREC tail, v7 CACH tail) is
dropped on write, since it's indexed by angle and would be stale/misaligned
after renumbering; the viewer just recomputes it next time the file opens.
This tool only ever *drops* angles — every kept angle's waveform bytes and
geometry are carried across verbatim. To write a file whose angles have been
resampled onto one shared aligned grid and cropped, use the viewer's
Fusion -> Alignment Wizard (sras_align_export.py) instead.
Usage:
python sras_edit_scans.py input.sras --list
python sras_edit_scans.py input.sras output.sras --drop 2,5
python sras_edit_scans.py input.sras output.sras --keep 0,1,3,4,6
"""
import argparse
import struct
import sys
from pathlib import Path
from sras_format import GEO_FMT_V6, HDR_FMT, HDR_FMT_V6, HDR_SIZE, SrasFile
_LEGACY_VERSIONS = (2, 3, 4, 5)
_V6_VERSIONS = (6, 7)
def _die(msg: str):
print(f"Error: {msg}", file=sys.stderr)
sys.exit(1)
def parse_args():
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("input", help="Input .sras file")
p.add_argument("output", nargs="?", help="Output .sras file (omit with --list)")
p.add_argument("--list", action="store_true",
help="Print each angle's index/degrees/geometry and exit")
g = p.add_mutually_exclusive_group()
g.add_argument("--drop", metavar="I,J,...",
help="Comma-separated angle indices to remove")
g.add_argument("--keep", metavar="I,J,...",
help="Comma-separated angle indices to keep (all others dropped)")
return p.parse_args()
def _parse_index_list(s: str, n_angles: int) -> set[int]:
out = set()
for piece in s.split(","):
piece = piece.strip()
if not piece:
continue
i = int(piece)
if not (0 <= i < n_angles):
raise ValueError(f"angle index {i} out of range [0, {n_angles - 1}]")
out.add(i)
return out
def print_listing(sras: SrasFile):
print(f"\n{'idx':>4} {'angle_deg':>10} {'x_start_mm':>11} {'rows':>6} {'frames':>7}")
for a in range(sras.n_angles):
print(f"{a:>4} {sras.angles_deg[a]:>10.4f} {sras.x_start_mm[a]:>11.4f} "
f"{int(sras.n_rows[a]):>6} {int(sras.n_frames[a]):>7}")
def _copy_range(fin, fout, offset: int, nbytes: int, chunk: int = 64 * 1024 * 1024):
"""Stream *nbytes* raw bytes from *fin* at *offset* into *fout*, without
ever holding more than one chunk in memory (waveform blocks can be
hundreds of MB to low GB each)."""
fin.seek(offset)
remaining = nbytes
while remaining:
buf = fin.read(min(chunk, remaining))
if not buf:
raise IOError("unexpected EOF while copying waveform data")
fout.write(buf)
remaining -= len(buf)
# ---------------------------------------------------------------------------
# Legacy (v2-v5): uniform geometry across angles, one flat waveform block
# ---------------------------------------------------------------------------
def _write_legacy(sras: SrasFile, keep: list[int], out_path: Path):
n_rows = int(sras.n_rows[0])
n_frames = int(sras.n_frames[0]) # uniform across angles for v2-v5
n_ch = sras.n_channels
spf = sras.samples_per_frame
bps = sras.bytes_per_sample
header = struct.pack(
HDR_FMT, b"SRAS", sras.version, len(keep), n_rows,
float(sras.x_start_mm[0]), float(sras.x_delta_mm),
sras.velocity_mm_s, sras.laser_freq_hz,
n_frames, spf, sras.sample_rate_hz, bps, n_ch,
)
# Row table + preambles + background sit right after the angle table and
# don't vary per angle — copy that whole span through unmodified.
angle_table_size = sras.n_angles * 4
with open(sras.path, "rb") as f:
f.seek(HDR_SIZE + angle_table_size)
shared_mid = f.read(sras.data_offset - (HDR_SIZE + angle_table_size))
angle_bytes = n_rows * n_ch * n_frames * spf * bps
with open(sras.path, "rb") as fin, open(out_path, "wb") as fout:
fout.write(header)
fout.write(sras.angles_deg[keep].astype(">f4").tobytes())
fout.write(shared_mid)
for a in keep:
_copy_range(fin, fout, sras.data_offset + a * angle_bytes, angle_bytes)
# ---------------------------------------------------------------------------
# v6/v7: per-angle geometry, ragged waveform blocks
# ---------------------------------------------------------------------------
def _write_v6(sras: SrasFile, keep: list[int], out_path: Path):
header = struct.pack(
HDR_FMT_V6, b"SRAS", sras.version, len(keep),
sras.x_start_nominal_mm, sras.y_start_nominal_mm,
sras.x_delta_nominal_mm, sras.y_delta_nominal_mm,
sras.row_spacing_mm, sras.velocity_mm_s, sras.laser_freq_hz,
sras.samples_per_frame, sras.sample_rate_hz,
sras.bytes_per_sample, sras.n_channels,
)
blocks = {a: (offset, nbytes) for a, offset, nbytes in sras.iter_angle_blocks()}
with open(sras.path, "rb") as fin, open(out_path, "wb") as fout:
fout.write(header)
fout.write(sras.angles_deg[keep].astype(">f4").tobytes())
for i in keep:
fout.write(struct.pack(
GEO_FMT_V6, float(sras.x_start_mm[i]),
float(sras.x_delta_mm_per_angle[i]),
int(sras.n_frames[i]), int(sras.n_rows[i])))
for i in keep:
fout.write(sras.y_pos_per_angle[i].astype(">f4").tobytes())
fout.write(sras.preambles_raw)
fout.write(sras.background_raw)
for i in keep:
offset, nbytes = blocks[i]
_copy_range(fin, fout, offset, nbytes)
def main():
args = parse_args()
in_path = Path(args.input)
if not in_path.exists():
_die(f"input file not found: {in_path}")
print(f"Reading {in_path} ...", flush=True)
try:
sras = SrasFile(str(in_path))
except ValueError as e:
_die(str(e))
if sras.version not in (*_LEGACY_VERSIONS, *_V6_VERSIONS):
_die(f"unsupported .sras version: {sras.version}")
aborted_note = " (scan aborted; trailing angle(s) already excluded)" if sras.scan_aborted else ""
print(f" Version : v{sras.version}", flush=True)
print(f" Angles : {sras.n_angles}{aborted_note}", flush=True)
if args.list:
print_listing(sras)
return
if not args.output:
_die("output path required unless --list is given.")
if not (args.drop or args.keep):
_die("specify --drop or --keep (see --list for indices).")
out_path = Path(args.output)
if out_path.resolve() == in_path.resolve():
_die("output path must differ from input path.")
try:
if args.drop:
drop = _parse_index_list(args.drop, sras.n_angles)
keep = [a for a in range(sras.n_angles) if a not in drop]
else:
keep = sorted(_parse_index_list(args.keep, sras.n_angles))
except ValueError as e:
_die(str(e))
if not keep:
_die("at least one angle must remain.")
dropped = [a for a in range(sras.n_angles) if a not in keep]
print(f"\nDropping angle(s): {dropped}")
print(f"Keeping angle(s) : {keep} ({len(keep)} of {sras.n_angles})")
print(f"\nWriting {out_path} ...", flush=True)
if sras.version in _LEGACY_VERSIONS:
_write_legacy(sras, keep, out_path)
else:
_write_v6(sras, keep, out_path)
in_mb = in_path.stat().st_size / 1024**2
out_mb = out_path.stat().st_size / 1024**2
print(f" Input size : {in_mb:.1f} MB")
print(f" Output size: {out_mb:.1f} MB")
print("Done.")
print("Note: any precomputed FFT/DC cache was dropped (it's indexed by "
"angle); the viewer will recompute it next time this file opens.")
if __name__ == "__main__":
main()
+221 -18
View File
@@ -60,7 +60,15 @@ PREC_FLAG_BG_SUB = 0x01
CACH_MAGIC = b"CACH"
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
CACH_VERSION = 1
CACH_VERSION = 3 # written on every fresh write
CACH_VERSIONS_READABLE = (1, 2, 3) # accepted on read — see
# _read_sfft_block. Each bump only
# appended a field, and every older
# tail has a well-defined reading:
# v1 predates row-averaged FFT
# caching (row_avg_n=0) and v1/v2
# predate padded caching, so both
# are natural-resolution (pad 1).
CACH_FLAG_DC = 0x01
CACH_FLAG_FFT = 0x02
@@ -69,9 +77,19 @@ SDCB_HDR_FMT = ">4sBH" # magic, reserved, n_stored
SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
SFFT_MAGIC = b"SFFT"
SFFT_HDR_FMT = ">4sBH" # magic, flags, n_stored
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored (cach_version 1)
SFFT_HDR_FMT_V2 = ">4sBHB" # + row_avg_n (cach_version 2)
SFFT_HDR_FMT = ">4sBHBH" # + pad_factor (cach_version 3)
SFFT_HDR_SIZE_V1 = struct.calcsize(SFFT_HDR_FMT_V1)
SFFT_HDR_SIZE_V2 = struct.calcsize(SFFT_HDR_FMT_V2)
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
MAX_PAD_FACTOR = 0xFFFF # the H field above
SFFT_FLAG_BG_SUB = 0x01
SFFT_FLAG_ROW_AVG = 0x02 # peak_freq_mhz came from same-row,
# distance-weighted averaged CH1
# waveforms, not raw per-pixel ones;
# row_avg_n is the neighbor half-width
# (pixels) used. Bits 2-7 reserved.
# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC)
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
@@ -169,7 +187,10 @@ class SrasFile:
``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``,
always as ragged per-angle lists (``list[np.ndarray | None]``, one entry
per angle, ``None`` where that angle was never stored) regardless of
source version.
source version. The scalars ``precomputed_bg_sub`` /
``precomputed_row_avg_n`` / ``precomputed_pad_factor`` record the
settings the stored FFT images were computed under, so a reader can tell
whether they answer the question it is actually asking.
"""
def __init__(self, path: str):
@@ -226,6 +247,54 @@ class SrasFile:
self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles
self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles
self.precomputed_bg_sub: bool = False
self.precomputed_row_avg_n: int = 0
# Zero-padding factor the stored peak_freq_mhz images were resolved
# at: 1 = natural resolution (n_fft == samples_per_frame). A padded
# FFT resolves peaks a padded view would, and only such a view can
# be served from it — see sras_compute.cached_rf_image.
self.precomputed_pad_factor: int = 1
def encoded_preambles(self) -> bytes:
"""This file's Preamble Blocks section, as bytes a writer can emit.
v6/v7 files kept the on-disk span verbatim, which is both cheaper and
lossless; legacy files did not keep it, and a v2 file has no preambles
at all, so those are re-encoded from the parsed strings (empty ones for
v2). Empty is not a silent downgrade: _parse_preamble("") returns {} and
_set_calibration falls back to the hardcoded scope constants, which is
exactly the calibration a v2 file already gets, so mV values round-trip
unchanged.
Lives here rather than at each writer so the version fan-out sits next
to the parser that creates it, and no writer has to probe the object
to find out which shape it got.
"""
raw = getattr(self, "preambles_raw", None)
if raw is not None:
return raw
out = bytearray()
for s in self.preambles or [""] * self.n_channels:
encoded = s.encode("utf-8")
out += struct.pack(">H", len(encoded)) + encoded
return bytes(out)
def encoded_background(self) -> bytes:
"""This file's Background Block, as bytes a writer can emit.
When there is none (v2/v3), this is samples_per_frame zeros rather than
a zero-length block. Every consumer guards on `background is not None`
and then subtracts it from a (spf,)-shaped row, so a length-0 array
would broadcast-fail at the first background-subtracted FFT; zeros make
the subtraction a correct no-op instead.
"""
raw = getattr(self, "background_raw", None)
if raw is not None:
return raw
if self.background is None:
samples = np.zeros(self.samples_per_frame, dtype=np.int8)
else:
samples = np.rint(self.background).astype(np.int8)
return struct.pack(">I", samples.size) + samples.tobytes()
def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
"""A stored DC image (already in mV) for (angle, channel), or None."""
@@ -257,6 +326,15 @@ class SrasFile:
self.scan_aborted = False
self.n_angles_declared = n_angles
# Pre-v6 files carry no nominal ROI. Defined as None rather than
# left absent so the object's shape does not depend on its version
# and writers can ask instead of probing with hasattr.
self.x_start_nominal_mm = None
self.y_start_nominal_mm = None
self.x_delta_nominal_mm = None
self.y_delta_nominal_mm = None
self.row_spacing_mm = None
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32)
y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32)
@@ -375,21 +453,34 @@ class SrasFile:
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32)
x_start = np.empty(n_angles, dtype=np.float64)
x_delta = np.empty(n_angles, dtype=np.float64)
n_frames = np.empty(n_angles, dtype=np.int64)
n_rows = np.empty(n_angles, dtype=np.int64)
for a in range(n_angles):
xs, xd, nf, nr = _read_struct(f, GEO_FMT_V6)
x_start[a], n_frames[a], n_rows[a] = xs, nf, nr
x_start[a], x_delta[a], n_frames[a], n_rows[a] = xs, xd, nf, nr
y_pos_per_angle = [
np.frombuffer(f.read(int(n_rows[a]) * 4), dtype=">f4").astype(np.float32)
for a in range(n_angles)
]
# Verbatim on-disk spans of the preamble and background sections,
# kept so file-rewriting tools (sras_edit_scans) can carry them
# over byte-for-byte without re-parsing.
span_start = f.tell()
self._set_calibration(_read_preambles(f, n_ch), n_ch)
self.background = _read_background(f)
span_end = f.tell()
f.seek(span_start)
self.preambles_raw = f.read(span_end - span_start)
data_offset = f.tell()
span_start = span_end
self.background = _read_background(f)
span_end = f.tell()
f.seek(span_start)
self.background_raw = f.read(span_end - span_start)
data_offset = span_end
self._data_offset = data_offset
@@ -428,6 +519,7 @@ class SrasFile:
self.scan_aborted = n_complete < n_angles_declared
self.angles_deg = angles[:n_complete]
self.x_start_mm = x_start[:n_complete]
self.x_delta_mm_per_angle = x_delta[:n_complete]
self.n_frames = n_frames[:n_complete]
self.n_rows = n_rows[:n_complete]
self._y_pos_per_angle = y_pos_per_angle[:n_complete]
@@ -436,6 +528,37 @@ class SrasFile:
if self.version == 7 and offset < file_size:
self._parse_cach_section(offset)
# ------------------------------------------------------------------
# Byte-layout accessors (public: used by file-rewriting tools)
# ------------------------------------------------------------------
@property
def data_offset(self) -> int:
"""File offset where the waveform data begins (headers end)."""
return self._data_offset
@property
def y_pos_per_angle(self) -> list[np.ndarray]:
"""Per-angle Y row positions (mm). The list and its arrays are the
live parsed state — tools that reproject may replace entries."""
return self._y_pos_per_angle
@y_pos_per_angle.setter
def y_pos_per_angle(self, value: list[np.ndarray]):
self._y_pos_per_angle = value
def iter_angle_blocks(self):
"""Yields (angle_idx, byte_offset, byte_count) for each complete
angle's waveform block. Works for every version: legacy files have
uniform per-angle geometry, so the same walk applies."""
offset = self._data_offset
for a in range(self.n_angles):
nbytes = (int(self.n_rows[a]) * self.n_channels
* int(self.n_frames[a]) * self.samples_per_frame
* self.bytes_per_sample)
yield a, offset, nbytes
offset += nbytes
# ------------------------------------------------------------------
# v7 cache tail (CACH section: precomputed DC / FFT images)
# ------------------------------------------------------------------
@@ -445,12 +568,10 @@ class SrasFile:
start), derived purely from the header + Per-Angle Geometry Table —
independent of whether a cache tail is actually present. Used by
both the parser and the in-place writer."""
waveform_bytes = sum(
int(self.n_rows[a]) * self.n_channels * int(self.n_frames[a])
* self.samples_per_frame * self.bytes_per_sample
for a in range(self.n_angles)
)
return self._data_offset + int(waveform_bytes)
end = self._data_offset
for _, offset, nbytes in self.iter_angle_blocks():
end = offset + nbytes
return end
def _read_cache_block(self, f, hdr_fmt: str, magic: bytes,
stores: list[list]) -> int | None:
@@ -474,6 +595,40 @@ class SrasFile:
store[angle_idx] = _read_f32_image(f, shape)
return flags
def _read_sfft_block(self, f, cach_version: int) -> tuple[int, int, int] | None:
"""Read the SFFT block header — its layout depends on cach_version,
since each bump appended a trailing field (v2 row_avg_n, v3
pad_factor) — then n_stored per-angle peak_freq_mhz entries
(unchanged across versions).
Returns (flags, row_avg_n, pad_factor), or None if the block is
malformed. The absent fields of an older tail take the value that
describes what such a tail can only have been: row_avg_n=0 for v1,
which predates row-averaged FFT caching, and pad_factor=1 for v1/v2,
which predate padded caching and so are natural-resolution.
"""
hdr_fmt = {1: SFFT_HDR_FMT_V1, 2: SFFT_HDR_FMT_V2}.get(
cach_version, SFFT_HDR_FMT)
raw = f.read(struct.calcsize(hdr_fmt))
if len(raw) < struct.calcsize(hdr_fmt):
return None
row_avg_n, pad_factor = 0, 1
if cach_version == 1:
magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
elif cach_version == 2:
magic, flags, n_stored, row_avg_n = struct.unpack(hdr_fmt, raw)
else:
magic, flags, n_stored, row_avg_n, pad_factor = struct.unpack(hdr_fmt, raw)
if magic != SFFT_MAGIC:
return None
for _ in range(n_stored):
(angle_idx,) = _read_struct(f, ">H")
if angle_idx >= self.n_angles:
break
self.precomputed_freq_mhz[angle_idx] = _read_f32_image(
f, self.image_shape(angle_idx))
return flags, row_avg_n, pad_factor
def _parse_cach_section(self, offset: int):
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
with open(self.path, "rb") as f:
@@ -482,7 +637,7 @@ class SrasFile:
if len(header_raw) < CACH_HDR_SIZE:
return
magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw)
if magic != CACH_MAGIC or cach_version != CACH_VERSION:
if magic != CACH_MAGIC or cach_version not in CACH_VERSIONS_READABLE:
return
if block_flags & CACH_FLAG_DC:
@@ -492,17 +647,21 @@ class SrasFile:
return
if block_flags & CACH_FLAG_FFT:
flags = self._read_cache_block(
f, SFFT_HDR_FMT, SFFT_MAGIC, [self.precomputed_freq_mhz])
if flags is None:
result = self._read_sfft_block(f, cach_version)
if result is None:
return
flags, row_avg_n, pad_factor = result
self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB)
self.precomputed_row_avg_n = row_avg_n if (flags & SFFT_FLAG_ROW_AVG) else 0
self.precomputed_pad_factor = max(1, pad_factor)
def write_v7_cache(self, *,
new_dc3_mv: list[np.ndarray | None] | None = None,
new_dc4_mv: list[np.ndarray | None] | None = None,
new_freq_mhz: list[np.ndarray | None] | None = None,
new_bg_sub: bool | None = None):
new_bg_sub: bool | None = None,
new_row_avg_n: int | None = None,
new_pad_factor: int | None = None):
"""Store computed DC and/or FFT images into this file's CACH tail,
in place, converting a v6 source to v7 (or updating an existing v7
file). Only the block(s) passed in are recomputed; whichever block
@@ -510,6 +669,18 @@ class SrasFile:
``SrasFile`` already has in memory (from parsing, or a prior write
in this same session) — its bytes are never re-read from disk.
*new_row_avg_n* is the same-row neighbor half-width (pixels) the
passed *new_freq_mhz* was averaged over before its FFT, 0 for a raw
(unaveraged) compute — carried forward like *new_bg_sub* when None.
It describes the whole stored FFT block, not per-angle, mirroring
how bg-sub has never been tracked per-angle either.
*new_pad_factor* is the zero-padding factor the passed *new_freq_mhz*
was resolved at (1 = natural resolution), carried forward the same
way. Like row_avg_n it is provenance, not a hint: a view at a
different pad resolves different peaks, so recording it is what lets
a reader refuse the cache instead of showing the wrong numbers.
The waveform data itself is never touched: the cache tail always
starts at ``_cache_tail_offset()``, a fixed offset derived from the
header and geometry table alone.
@@ -522,6 +693,15 @@ class SrasFile:
final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv
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_row_avg_n = (new_row_avg_n if new_row_avg_n is not None
else self.precomputed_row_avg_n)
final_pad_factor = (new_pad_factor if new_pad_factor is not None
else self.precomputed_pad_factor)
if not (0 <= final_row_avg_n <= 255):
raise ValueError(f"row_avg_n must fit in a byte (0-255), got {final_row_avg_n}")
if not (1 <= final_pad_factor <= MAX_PAD_FACTOR):
raise ValueError(
f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {final_pad_factor}")
# dc3/dc4 are always populated together by every current caller, but
# guard the per-angle pairing explicitly rather than assume it: an
@@ -547,7 +727,10 @@ class SrasFile:
if fft_entries:
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries))
fft_flags |= SFFT_FLAG_ROW_AVG if final_row_avg_n else 0
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags,
len(fft_entries), final_row_avg_n,
final_pad_factor)
for a in fft_entries:
payload += struct.pack(">H", a)
payload += final_freq[a].astype(">f4").tobytes()
@@ -585,6 +768,8 @@ class SrasFile:
self.precomputed_dc4_mv = final_dc4
self.precomputed_freq_mhz = final_freq
self.precomputed_bg_sub = final_bg_sub
self.precomputed_row_avg_n = final_row_avg_n
self.precomputed_pad_factor = final_pad_factor
# ------------------------------------------------------------------
# Axes helpers
@@ -601,6 +786,24 @@ class SrasFile:
def y_positions_mm(self, angle_idx: int) -> np.ndarray:
return self._y_pos_per_angle[angle_idx]
def angles_share_raw_grid(self) -> bool:
"""True iff every angle's raw (x, y) pixel grid is literally the same
array as angle 0's -- the case for a .sras file the viewer's own
Alignment Wizard exported (see scan_format.md, "Files written by the
viewer's Alignment Wizard"): the writer packs one Per-Angle Geometry
record and one Row Table span and repeats those same bytes for every
angle, so re-parsed arrays are bit-identical copies rather than
independently re-derived numbers -- a bare np.array_equal is the
correct test here, no tolerance needed.
"""
if self.n_angles <= 1:
return True
x0 = self.x_axis_mm(0)
y0 = self.y_positions_mm(0)
return all(np.array_equal(self.x_axis_mm(a), x0)
and np.array_equal(self.y_positions_mm(a), y0)
for a in range(1, self.n_angles))
def time_axis_ns(self) -> np.ndarray:
return np.arange(self.samples_per_frame) / self.sample_rate_hz * 1e9
-3015
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
"""
SRAS Scan File Viewer
PyQt6 application for visualizing channel data from .sras binary scan files.
Channel semantics (fixed by sc3_aui_app.py acquisition settings):
CH1 — RF Acoustic Packet (AC-coupled, 100 mV/div): FFT → peak frequency
CH3 — Bias A (DC-coupled, 50 mV/div): waveform mean
CH4 — Bias B (DC-coupled, 50 mV/div): waveform mean
RF images are masked: pixels where CH4_dc < dc_threshold show 0.
File parsing lives in sras_format, image/alignment math in sras_compute, and
background workers in sras_workers — none of which import Qt or matplotlib,
so multiprocessing children can load them cheaply.
"""
import faulthandler
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
from .align_wizard import AlignmentWizard # noqa: E402,F401
from .canvases import ( # noqa: E402,F401
AlignOverlayCanvas, ImageCanvas, RoiQuad, WaveformCanvas,
)
from .common import CH_LABELS, CMAPS, VELOCITY_MODE_IDX # noqa: E402,F401
from .dialogs import ( # noqa: E402,F401
FftOptionsDialog, FusedRoiExportDialog, RowAverageFftOptionsDialog,
)
from .main_window import SrasViewerWindow, main # noqa: E402,F401
+4
View File
@@ -0,0 +1,4 @@
from .main_window import main
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+629
View File
@@ -0,0 +1,629 @@
"""Matplotlib canvases and the ROI primitive."""
import matplotlib as mpl
import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.colors import BoundaryNorm, ListedColormap
from matplotlib.figure import Figure
from matplotlib.patches import Polygon
from matplotlib.path import Path as MplPath
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QKeyEvent
from PyQt6.QtWidgets import QSizePolicy
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv
def count_colormap(n_angles: int):
"""(cmap, norm, ticks) for an integer "how many angles cover this pixel"
image, 0..n_angles.
Discrete, not continuous: the judgement the wizard's stack view exists for
is "is this a plateau at N, or a fan of partial overlaps", so a region
covered by one angle too few has to read as its own band rather than a
slightly darker shade. Count 0 is fully transparent so uncovered canvas
cannot be mistaken for a low count.
Shared by both wizard pages that draw this image — they use different canvas
classes, and the same number must not change colour between them.
"""
n = max(1, int(n_angles))
base = mpl.colormaps["viridis"].resampled(n)
colors = [(0.0, 0.0, 0.0, 0.0)] + [base(i) for i in range(n)]
return (ListedColormap(colors),
BoundaryNorm(np.arange(-0.5, n + 1), len(colors)),
np.arange(0, n + 1))
# ---------------------------------------------------------------------------
# ROI (free quadrilateral in data coordinates)
# ---------------------------------------------------------------------------
class RoiQuad:
"""Free quadrilateral defined in data coordinates (mm).
Stored as 4 corner points (shape (4, 2)) in CCW order: BL, BR, TR, TL.
Each corner can be positioned independently, allowing skewed /
non-orthogonal regions of interest. Because it lives in scan/data
coords it persists unchanged when the displayed channel/mode switches.
"""
def __init__(self, pts: np.ndarray):
"""pts : array-like, shape (4, 2)."""
self._pts = np.asarray(pts, dtype=np.float64).reshape(4, 2).copy()
@classmethod
def from_bbox(cls, x0: float, y0: float, x1: float, y1: float) -> "RoiQuad":
"""Create an axis-aligned rectangle from two opposite corners."""
lx, rx = min(x0, x1), max(x0, x1)
by, ty = min(y0, y1), max(y0, y1)
return cls(np.array([[lx, by], [rx, by], [rx, ty], [lx, ty]]))
def copy(self) -> "RoiQuad":
return RoiQuad(self._pts.copy())
def corners(self) -> np.ndarray:
"""World-coord corners, shape (4, 2), CCW: BL, BR, TR, TL."""
return self._pts.copy()
def centroid(self) -> np.ndarray:
return self._pts.mean(axis=0)
def bbox_size(self) -> np.ndarray:
"""Width and height of the axis-aligned bounding box, shape (2,)."""
return self._pts.max(axis=0) - self._pts.min(axis=0)
def contains(self, x: float, y: float) -> bool:
return bool(MplPath(self._pts).contains_point((x, y)))
def mask_for_grid(self, x_axis: np.ndarray,
y_axis: np.ndarray) -> np.ndarray:
"""Boolean mask (n_rows, n_frames) of pixels whose centres lie
inside the quadrilateral.
Only the quad's axis-aligned bounding box is tested — meshgrid and
contains_points over the *whole* grid would be tens of millions of
point-in-polygon tests (and hundreds of MB of float64 temporaries)
on a large scan, on every ROI edit.
"""
x = np.asarray(x_axis, dtype=np.float64)
y = np.asarray(y_axis, dtype=np.float64)
mask = np.zeros((y.size, x.size), dtype=bool)
(x0, y0), (x1, y1) = self._pts.min(axis=0), self._pts.max(axis=0)
cols = np.nonzero((x >= x0) & (x <= x1))[0]
rows = np.nonzero((y >= y0) & (y <= y1))[0]
if cols.size == 0 or rows.size == 0:
return mask
c0, c1 = int(cols[0]), int(cols[-1]) + 1
r0, r1 = int(rows[0]), int(rows[-1]) + 1
X, Y = np.meshgrid(x[c0:c1], y[r0:r1])
inside = MplPath(self._pts).contains_points(
np.column_stack([X.ravel(), Y.ravel()]))
mask[r0:r1, c0:c1] = inside.reshape(X.shape)
return mask
# ---------------------------------------------------------------------------
# Matplotlib canvases
# ---------------------------------------------------------------------------
class ImageCanvas(FigureCanvasQTAgg):
pixel_clicked = pyqtSignal(int, int) # row_idx, frame_idx
roi_changed = pyqtSignal() # ROI created / edited / cleared
draw_mode_changed = pyqtSignal(bool) # "draw new ROI" arm toggled
# Interaction state values
_IDLE = "idle"
_DRAW_NEW = "draw_new"
_MOVE = "move"
_DRAG_CORNER = "drag_corner"
# Hit tolerance (display pixels) for handles.
_HANDLE_PX = 12
_CLICK_THRESH_PX = 4 # releases within this of press count as a click
def __init__(self, parent=None, *, rect_only: bool = False):
"""*rect_only* constrains the ROI to an axis-aligned rectangle.
Used by the alignment wizard's crop page, where a free quadrilateral
would be actively misleading: v6 geometry can only express an
axis-aligned rectangle, so anything else the user drew would have to be
squared off behind their back. Default off, so the main window's
free-quad ROI is unaffected.
"""
fig = Figure(figsize=(7, 5), tight_layout=True)
self.ax = fig.add_subplot(111)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._extent = None
self._img_shape = None
self._rect_only = rect_only
# ROI state
self._roi: RoiQuad | None = None
self._roi_artists: list = []
self._state = self._IDLE
self._draw_mode = False
# Per-interaction snapshots / anchors
self._press_xy: tuple[float, float] | None = None
self._press_pixel: tuple[float, float] | None = None
self._press_button = None
self._snapshot: RoiQuad | None = None
self._drag_corner_idx: int = -1
self._move_anchor = None # press-point in world coords
self._draw_previous: RoiQuad | None = None
self.mpl_connect("button_press_event", self._on_press)
self.mpl_connect("motion_notify_event", self._on_motion)
self.mpl_connect("button_release_event", self._on_release)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def show_image(self, img: np.ndarray, extent: list[float], cmap,
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
colorbar_label: str = "", cb_ticks=None, norm=None):
"""*cmap* may be a name or a Colormap instance. *norm* (which overrides
vmin/vmax) and *cb_ticks* let a caller draw a discrete integer image —
the wizard's overlap-count view — with whole-number colorbar bands
instead of a continuous shade."""
self.figure.clf()
self.ax = self.figure.add_subplot(111)
# Patches and lines are destroyed by figure.clf(); drop stale refs.
self._roi_artists = []
self._extent = extent
self._img_shape = img.shape
kw = ({"norm": norm} if norm is not None
else {"vmin": vmin, "vmax": vmax})
im = self.ax.imshow(
img, aspect="auto", origin="upper",
extent=extent, cmap=cmap, interpolation="nearest", **kw,
)
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
ticks=cb_ticks)
if colorbar_label:
cb.set_label(colorbar_label)
self.ax.set_xlabel(xlabel)
self.ax.set_ylabel(ylabel)
self.ax.set_title(title)
# Re-draw the ROI (if any) on top of the fresh image so it persists
# unchanged across mode / angle / channel switches.
self._draw_roi()
self.draw()
def get_roi(self) -> RoiQuad | None:
return self._roi
def set_roi(self, roi: RoiQuad | None):
self._roi = roi.copy() if roi is not None else None
self._draw_roi()
self.draw_idle()
self.roi_changed.emit()
def clear_roi(self):
self._roi = None
self._remove_roi_artists()
self.draw_idle()
self.roi_changed.emit()
def start_drawing(self):
"""Arm the next click+drag on the image to create a new ROI,
replacing any existing one."""
self._draw_mode = True
self.setCursor(Qt.CursorShape.CrossCursor)
self.draw_mode_changed.emit(True)
def cancel_drawing(self):
if self._draw_mode:
self._draw_mode = False
self.setCursor(Qt.CursorShape.ArrowCursor)
self.draw_mode_changed.emit(False)
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _remove_roi_artists(self):
for a in self._roi_artists:
try:
a.remove()
except (ValueError, AttributeError, NotImplementedError):
pass
self._roi_artists = []
def _draw_roi(self):
self._remove_roi_artists()
if self._roi is None or self.ax is None:
return
corners = self._roi.corners()
# Filled quad, then a sharp unfilled edge for visibility over bright
# images, then draggable corner handles.
for kwargs in (
dict(fill=True, facecolor="#ffd93a", edgecolor="#e53935",
alpha=0.22, linewidth=2.0, zorder=10),
dict(fill=False, edgecolor="#e53935", linewidth=1.8, zorder=11),
):
patch = Polygon(corners, closed=True, **kwargs)
self.ax.add_patch(patch)
self._roi_artists.append(patch)
self._roi_artists.append(self.ax.scatter(
corners[:, 0], corners[:, 1], s=60, c="white",
edgecolors="#e53935", linewidths=1.6, zorder=13))
# ------------------------------------------------------------------
# Hit testing (display pixels for handles, data coords for "inside")
# ------------------------------------------------------------------
def _hit_test(self, event) -> tuple[str, int | None] | None:
if self._roi is None or self.ax is None:
return None
if event.x is None or event.y is None:
return None
corners_disp = self.ax.transData.transform(self._roi.corners())
click = np.array([event.x, event.y])
for i in range(4):
if np.hypot(*(corners_disp[i] - click)) <= self._HANDLE_PX:
return ("corner", i)
if event.xdata is not None and event.ydata is not None:
if self._roi.contains(event.xdata, event.ydata):
return ("inside", None)
return None
# ------------------------------------------------------------------
# Mouse event handlers
# ------------------------------------------------------------------
def _on_press(self, event):
if event.inaxes is not self.ax or self._extent is None:
return
if event.button != 1: # only left mouse button
return
# If the matplotlib toolbar is in pan / zoom mode, let it handle
# the interaction instead of starting a ROI manipulation.
tb = getattr(self, "toolbar", None)
if tb is not None and getattr(tb, "mode", ""):
return
self._press_xy = (event.xdata, event.ydata)
self._press_pixel = (event.x, event.y)
self._press_button = event.button
if self._draw_mode:
self._draw_previous = self._roi.copy() if self._roi else None
self._roi = RoiQuad.from_bbox(event.xdata, event.ydata,
event.xdata, event.ydata)
self._state = self._DRAW_NEW
self._draw_roi()
self.draw_idle()
return
hit = self._hit_test(event)
if hit is None:
self._state = self._IDLE
return
kind, idx = hit
self._snapshot = self._roi.copy()
if kind == "corner":
self._state = self._DRAG_CORNER
self._drag_corner_idx = idx
else:
self._state = self._MOVE
self._move_anchor = (event.xdata, event.ydata)
def _on_motion(self, event):
if self._state == self._IDLE:
return
if event.xdata is None or event.ydata is None:
return
if event.inaxes is not self.ax:
return
if self._state == self._DRAW_NEW:
x0, y0 = self._press_xy
self._roi = RoiQuad.from_bbox(x0, y0, event.xdata, event.ydata)
elif self._state == self._MOVE:
delta = np.array([event.xdata - self._move_anchor[0],
event.ydata - self._move_anchor[1]])
self._roi._pts = self._snapshot.corners() + delta
elif self._state == self._DRAG_CORNER:
self._roi._pts[self._drag_corner_idx] = [event.xdata, event.ydata]
if self._rect_only:
self._rectify_corner(self._drag_corner_idx)
self._draw_roi()
self.draw_idle()
def _rectify_corner(self, idx: int):
"""Re-square the quad after a corner drag, anchored on the *opposite*
corner.
Anchoring on the diagonal opposite (idx ^ 2, since corners run
BL, BR, TR, TL) rather than taking the bbox of all four points is what
lets the rectangle shrink: a bbox over the three stale corners plus the
new one is the union of the old rectangle and the new point, so dragging
inward would never make it smaller.
"""
pts = self._roi.corners()
ax_, ay = pts[idx ^ 2]
bx, by = pts[idx]
self._roi._pts = RoiQuad.from_bbox(min(ax_, bx), min(ay, by),
max(ax_, bx), max(ay, by)).corners()
def _on_release(self, event):
if event.button != 1 and self._press_button != 1:
return
prev_state = self._state
self._state = self._IDLE
try:
if prev_state == self._DRAW_NEW:
self._finish_draw()
elif prev_state in (self._MOVE, self._DRAG_CORNER):
self._draw_roi()
self.draw_idle()
self.roi_changed.emit()
else:
self._maybe_emit_pixel_click(event)
finally:
self._press_xy = self._press_pixel = None
self._press_button = None
def _finish_draw(self):
"""Commit (or reject) a freshly-dragged quad."""
if self._extent is not None:
x0, x1, y_bot, y_top = self._extent
min_w = abs(x1 - x0) * 0.01 # minimum: 1% of each axis range
min_h = abs(y_bot - y_top) * 0.01
else:
min_w = min_h = 1e-6
if self._roi is None:
too_small = True
else:
bbox = self._roi.bbox_size()
too_small = bbox[0] < min_w or bbox[1] < min_h
if too_small:
self._roi = self._draw_previous
self._draw_previous = None
self.cancel_drawing()
self._draw_roi()
self.draw_idle()
self.roi_changed.emit()
def _maybe_emit_pixel_click(self, event):
"""A release close enough to its press counts as a pixel click."""
if (self._press_pixel is None or event.x is None or event.y is None
or self._extent is None or event.inaxes is not self.ax
or event.xdata is None):
return
dx_px = event.x - self._press_pixel[0]
dy_px = event.y - self._press_pixel[1]
if dx_px * dx_px + dy_px * dy_px > self._CLICK_THRESH_PX ** 2:
return
x0, x1, y_bot, y_top = self._extent
n_rows, n_frames = self._img_shape
col = int((event.xdata - x0) / (x1 - x0) * n_frames)
row = int((event.ydata - y_top) / (y_bot - y_top) * n_rows)
self.pixel_clicked.emit(max(0, min(row, n_rows - 1)),
max(0, min(col, n_frames - 1)))
class WaveformCanvas(FigureCanvasQTAgg):
def __init__(self, parent=None):
fig = Figure(figsize=(8, 3), tight_layout=True)
self.ax_wave = fig.add_subplot(121)
self.ax_right = fig.add_subplot(122)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def show_rf_waveform(self, sras: SrasFile, angle_idx: int,
row_idx: int, frame_idx: int,
apply_bg_sub: bool = True):
"""CH1 RF: time-domain + FFT spectrum.
If apply_bg_sub is True and sras.background is not None, the background
waveform is overlaid on the time-domain plot and the FFT is computed
on the subtracted signal. The unsubtracted FFT is also shown faintly
for comparison.
"""
data = sras.data[angle_idx]
waveform = data[row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
t_ns = sras.time_axis_ns()
f_mhz = sras.freq_axis_mhz()
dc3_val = data[row_idx, CH3_IDX, frame_idx, :].astype(np.float32).mean()
dc4_val = data[row_idx, CH4_IDX, frame_idx, :].astype(np.float32).mean()
bg = sras.background if (apply_bg_sub and sras.background is not None) else None
waveform_plot = waveform - bg if bg is not None else waveform
self.ax_wave.cla()
self.ax_right.cla()
if bg is not None:
self.ax_wave.plot(t_ns, waveform, linewidth=0.5, color="#aaaaaa",
label="raw", zorder=1)
self.ax_wave.plot(t_ns, bg, linewidth=0.5, color="#e07030",
linestyle="--", label="background", zorder=2)
self.ax_wave.plot(t_ns, waveform_plot, linewidth=0.7, color="#4488cc",
label="subtracted", zorder=3)
self.ax_wave.legend(fontsize=7, loc="upper right")
else:
self.ax_wave.plot(t_ns, waveform, linewidth=0.7, color="#4488cc")
self.ax_wave.set_xlabel("Time (ns)")
self.ax_wave.set_ylabel("ADC counts")
bg_tag = " [bg sub]" if bg is not None else ""
dc3_mv = adc_to_mv(dc3_val, *sras.cal(CH3_IDX))
dc4_mv = adc_to_mv(dc4_val, *sras.cal(CH4_IDX))
self.ax_wave.set_title(
f"CH1 RF row={row_idx} frame={frame_idx}{bg_tag}\n"
f"CH3={dc3_val:.1f} CH4={dc4_val:.1f} "
f"({dc3_mv:.2f} / {dc4_mv:.2f} mV)",
fontsize=8,
)
# FFT of the (possibly subtracted) waveform
power_sub = np.abs(np.fft.rfft(waveform_plot)) ** 2
power_sub[0] = 0.0
peak_mhz = f_mhz[int(np.argmax(power_sub))]
if bg is not None:
# Also show the unsubtracted FFT for reference
power_raw = np.abs(np.fft.rfft(waveform)) ** 2
power_raw[0] = 0.0
self.ax_right.plot(f_mhz, power_raw, linewidth=0.5, color="#aaaaaa",
label="raw FFT", zorder=1)
self.ax_right.plot(f_mhz, power_sub, linewidth=0.7, color="#4488cc",
label="subtracted FFT" if bg is not None else None, zorder=2)
self.ax_right.axvline(peak_mhz, color="tomato", linestyle="--",
linewidth=1.2, label=f"peak = {peak_mhz:.1f} MHz")
self.ax_right.set_xlabel("Frequency (MHz)")
self.ax_right.set_ylabel("Power (arb.)")
self.ax_right.set_title("FFT Power Spectrum")
self.ax_right.set_xlim(0, 500)
self.ax_right.legend(fontsize=8)
self.draw()
def show_dc_waveform(self, sras: SrasFile, angle_idx: int, ch_idx: int,
row_idx: int, frame_idx: int):
"""CH3 or CH4 DC: time-domain + mean annotation."""
waveform = sras.data[angle_idx][row_idx, ch_idx, frame_idx, :].astype(np.float32)
mean_val = float(waveform.mean())
mean_mv = adc_to_mv(mean_val, *sras.cal(ch_idx))
self.ax_wave.cla()
self.ax_right.cla()
self.ax_wave.plot(sras.time_axis_ns(), waveform, linewidth=0.7, color="#4488cc")
self.ax_wave.axhline(mean_val, color="tomato", linestyle="--",
linewidth=1.2, label=f"mean = {mean_val:.2f} ADC")
self.ax_wave.set_xlabel("Time (ns)")
self.ax_wave.set_ylabel("ADC counts")
self.ax_wave.set_title(
f"{CH_NAMES[ch_idx]} DC row={row_idx} frame={frame_idx}")
self.ax_wave.legend(fontsize=8)
self.ax_right.text(
0.5, 0.5,
f"DC mode\n\nmean = {mean_val:.3f} ADC\n = {mean_mv:.3f} mV",
ha="center", va="center",
transform=self.ax_right.transAxes, fontsize=11,
)
self.ax_right.set_axis_off()
self.draw()
class AlignOverlayCanvas(FigureCanvasQTAgg):
"""Renders the alignment wizard's multi-angle mask views and turns keyboard
input into translate/rotate nudge requests for whichever angle is active.
Two views of the same reprojected masks, because they answer different
questions. show_counts colours each pixel by *how many* angles cover it,
which is the at-a-glance verdict on a correlation run: a good alignment is
one saturated plateau, a bad one is a fringe of low-count halos.
show_overlay gives each angle its own colour, which is what you need while
nudging a specific angle by hand.
A pure input+render widget — it holds no alignment state and never
touches SrasFile itself; the wizard page owns all of that and decides,
from these signals, whether a cheap single-layer refresh or a
full preview-canvas rebuild is needed.
FigureCanvasQTAgg is a real QWidget, so keyPressEvent works like on any
other widget, but Qt only ever delivers key events to whichever widget
currently has focus — StrongFocus, plus grabbing focus on click and once
right after the dialog is shown, are both required or arrow keys
silently do nothing.
Rotate keys are letters (Q/E), not punctuation (comma/period or
brackets): Shift+letter still reports the same Qt.Key on every platform,
whereas Shift+comma/bracket can report a different virtual key
(Key_Less / Key_BraceLeft) depending on platform and keyboard layout —
which would silently break the "Shift = coarse step" modifier for
rotation specifically. Arrow keys have no such hazard.
"""
nudge_translate = pyqtSignal(int, int, bool) # dir_x, dir_y in {-1,0,1}; coarse
nudge_rotate = pyqtSignal(int, bool) # dir in {-1,1} (CCW/CW); coarse
_TRANSLATE_KEYS = {
Qt.Key.Key_Left: (-1, 0),
Qt.Key.Key_Right: (1, 0),
Qt.Key.Key_Up: (0, -1),
Qt.Key.Key_Down: (0, 1),
}
_ROTATE_KEYS = {Qt.Key.Key_Q: 1, Qt.Key.Key_E: -1} # CCW, CW
def __init__(self, parent=None):
fig = Figure(figsize=(6, 6), tight_layout=True)
self.ax = fig.add_subplot(111)
super().__init__(fig)
self.setParent(parent)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.mpl_connect("button_press_event", lambda _e: self.setFocus())
def show_overlay(self, rgba: np.ndarray, extent: list[float], title: str):
self.figure.clf()
self.ax = self.figure.add_subplot(111)
self.ax.imshow(rgba, extent=extent, origin="upper", aspect="auto")
self._finish(title)
def show_counts(self, counts: np.ndarray, n_angles: int,
extent: list[float], title: str):
"""The mask stack coloured by how many angles cover each pixel.
A discrete colormap with integer-ticked colorbar rather than a
continuous one: the judgement being made is "is this a plateau at N, or
a fan of partial overlaps", and a region covered by one angle too few
has to read as its own band rather than a slightly darker shade.
Uncovered pixels are transparent so they cannot be mistaken for a low
count.
"""
self.figure.clf()
self.ax = self.figure.add_subplot(111)
cmap, norm, ticks = count_colormap(n_angles)
im = self.ax.imshow(
np.asarray(counts), extent=extent, origin="upper", aspect="auto",
interpolation="nearest", cmap=cmap, norm=norm)
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
ticks=ticks)
cb.set_label("angles overlapping")
self._finish(title)
def _finish(self, title: str):
self.ax.set_xlabel("X (mm)")
self.ax.set_ylabel("Y (mm)")
self.ax.set_title(title)
self.draw_idle() # coalesces rapid redraws — matters for key-repeat.
def keyPressEvent(self, event: QKeyEvent):
key = event.key()
coarse = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
if key in self._TRANSLATE_KEYS:
dx, dy = self._TRANSLATE_KEYS[key]
self.nudge_translate.emit(dx, dy, coarse)
event.accept()
elif key in self._ROTATE_KEYS:
self.nudge_rotate.emit(self._ROTATE_KEYS[key], coarse)
event.accept()
else:
super().keyPressEvent(event)
+178
View File
@@ -0,0 +1,178 @@
"""Shared constants and small layout helpers for the viewer widgets."""
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel,
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX
# ---------------------------------------------------------------------------
# Display constants
# ---------------------------------------------------------------------------
CH_LABELS = [
"CH1 — RF (FFT peak freq)",
"CH3 — Bias A (DC mean)",
"CH4 — Bias B (DC mean)",
"CH1 — Velocity (SRAS)",
]
# Combo index for the derived velocity mode (uses CH1_IDX data)
VELOCITY_MODE_IDX = 3
# All modes that operate on CH1 waveforms
CH1_DERIVED_MODES = (CH1_IDX, VELOCITY_MODE_IDX)
CMAPS = ["gray", "viridis", "plasma", "inferno", "hot", "jet", "RdBu_r", "seismic"]
# (mode_str, status-bar unit, colorbar label) per channel index
_CHANNEL_DISPLAY = {
CH1_IDX: ("RF", "Peak frequency (MHz)", "MHz"),
CH3_IDX: ("DC", "DC mean (mV)", "mV"),
CH4_IDX: ("DC", "DC mean (mV)", "mV"),
VELOCITY_MODE_IDX: ("Velocity", "Velocity (m/s)", "m/s"),
}
_CSS_HINT = "font-size: 11px; color: #aaa;"
_CSS_INFO = "font-size: 11px;"
_CSS_MUTED = "color: #888; font-size: 11px;"
_CSS_WARN = "color: #e07000; font-size: 11px;"
_CSS_BUSY = "color: #4a90d9; font-size: 11px;"
# Side-panel column widths (the scroll areas that hold the controls).
_LEFT_PANEL_W = 288
_RIGHT_PANEL_W = 272
# Minimum width for a spin box so its value + suffix are never clipped.
_SPIN_MIN_W = 96
# ---------------------------------------------------------------------------
# Small layout helpers
# ---------------------------------------------------------------------------
class Jobs:
"""Keys for SrasViewerWindow's background-job registry (_run_worker /
_job_running) and its progress dialogs — one place instead of string
literals scattered across window and dialogs."""
LOAD = "load"
COMPUTE = "compute"
DC_PRECOMPUTE = "dc_precompute"
BATCH = "batch"
# The alignment wizard's three background steps: fetching each angle's CH4
# image for the mask stack, registering the angles, and writing the aligned
# export. Separate keys because a retry of one must not be blocked by
# another having run, and _run_worker's busy check is per key.
ALIGN_MASKS = "align_masks"
ALIGN_CORRELATE = "align_correlate"
ALIGN_EXPORT = "align_export"
def _make_dspin(lo: float, hi: float, decimals: int, *, suffix: str = "",
value: float | None = None, step: float | None = None) -> QDoubleSpinBox:
"""A QDoubleSpinBox with the panel-standard construction."""
spin = QDoubleSpinBox()
spin.setRange(lo, hi)
spin.setDecimals(decimals)
if suffix:
spin.setSuffix(suffix)
if step is not None:
spin.setSingleStep(step)
if value is not None:
spin.setValue(value)
spin.setMinimumWidth(_SPIN_MIN_W)
return spin
def _combo(items=(), *, min_chars: int = 10) -> QComboBox:
"""A combo box whose size hint does not depend on its longest entry.
By default a QComboBox asks for enough width to show its widest item. These
hold descriptive phrases, and the side panels are fixed-width — in a scroll
area with the horizontal scrollbar off (`_scroll_panel`) an unconstrained
hint pushes the inner widget past the panel and everything on the right,
including the hint text, is silently clipped instead of scrolling.
*items* is a sequence of (label, data) pairs, or of plain labels.
"""
combo = QComboBox()
combo.setSizeAdjustPolicy(
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
combo.setMinimumContentsLength(min_chars)
for item in items:
if isinstance(item, tuple):
combo.addItem(item[0], item[1])
else:
combo.addItem(item)
return combo
def _axes_extent(x_axis, y_axis, dx: float, dy: float) -> list[float]:
"""Matplotlib imshow extent with half-pixel margins, Y flipped so row 0
renders at the top."""
return [x_axis[0] - dx / 2, x_axis[-1] + dx / 2,
y_axis[-1] + dy / 2, y_axis[0] - dy / 2]
def _wrap_label(text: str = "", css: str | None = None) -> QLabel:
"""A word-wrapped QLabel that reports its *wrapped* height to the layout.
A plain word-wrapped QLabel advertises a single-line minimum height, so in a
fixed-width column the layout happily shrinks it and the extra lines get
clipped. Enabling height-for-width makes the box layout ask for the real
height at the column's width instead.
"""
lbl = QLabel(text)
lbl.setWordWrap(True)
sp = lbl.sizePolicy()
sp.setVerticalPolicy(QSizePolicy.Policy.Minimum)
sp.setHeightForWidth(True)
lbl.setSizePolicy(sp)
if css:
lbl.setStyleSheet(css)
return lbl
def _group(title: str) -> tuple[QGroupBox, QVBoxLayout]:
"""A group box with consistent, non-cramped internal margins."""
grp = QGroupBox(title)
lay = QVBoxLayout(grp)
lay.setContentsMargins(10, 8, 10, 10)
lay.setSpacing(6)
return grp, lay
def _form() -> QFormLayout:
"""A label/field form layout for a narrow side panel."""
form = QFormLayout()
form.setContentsMargins(0, 0, 0, 0)
form.setHorizontalSpacing(8)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight
| Qt.AlignmentFlag.AlignVCenter)
form.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
form.setFieldGrowthPolicy(
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.DontWrapRows)
return form
def _scroll_panel(inner: QWidget, width: int) -> QScrollArea:
"""Put a side panel in a fixed-width scroll area.
Without this the panels are sized by the window: a short window squeezes the
controls past their minimum heights, which is what makes text overlap the
widget below it. Scrolling keeps every control at its natural size.
"""
area = QScrollArea()
area.setWidget(inner)
area.setWidgetResizable(True)
area.setFrameShape(QFrame.Shape.NoFrame)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
area.setFixedWidth(width)
area.viewport().setAutoFillBackground(False)
inner.setAutoFillBackground(False)
return area
+415
View File
@@ -0,0 +1,415 @@
"""FFT option dialogs.
Angle alignment used to live here too, as ManualAlignmentDialog; it is now the
alignment wizard's first page (see align_wizard.py), which needed the same
mask-overlay editor plus the crop and export steps.
"""
from pathlib import Path
from PyQt6.QtWidgets import (
QButtonGroup, QCheckBox, QDialog, QDialogButtonBox, QFileDialog,
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QRadioButton,
QScrollArea, QSpinBox, QVBoxLayout, QWidget,
)
from sras_compute import PYFFTW_AVAILABLE
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES
from .common import CH_LABELS, VELOCITY_MODE_IDX, _CSS_HINT, _CSS_WARN, _group, _make_dspin, _wrap_label
# ---------------------------------------------------------------------------
# FFT Options dialog
# ---------------------------------------------------------------------------
class FftOptionsDialog(QDialog):
"""Configure FFT backend and zero-padding.
Changes take effect only when the user clicks Apply. Cancel discards
all pending edits. The live 'frequency resolution' label updates as
the user adjusts the pad factor so they can see the trade-off before
committing.
"""
def __init__(self, parent=None, *,
current_backend: str,
current_pad_factor: int,
samples_per_frame: int | None,
sample_rate_hz: float | None,
grating_um: float):
super().__init__(parent)
self.setWindowTitle("FFT Options")
self.setModal(True)
self.setMinimumWidth(380)
self._samples_per_frame = samples_per_frame
self._sample_rate_hz = sample_rate_hz
self._grating_um = grating_um
layout = QVBoxLayout(self)
# ---- Backend ---------------------------------------------------
grp_backend = QGroupBox("FFT Backend")
bl = QVBoxLayout(grp_backend)
self._btn_scipy = QRadioButton("SciPy FFT (pocketfft) (always available)")
self._btn_pyfftw = QRadioButton(
"pyFFTW (faster for large arrays)" if PYFFTW_AVAILABLE
else "pyFFTW (not installed — run: pip install pyfftw)")
self._btn_pyfftw.setEnabled(PYFFTW_AVAILABLE)
self._backend_group = QButtonGroup(self)
self._backend_group.addButton(self._btn_scipy, id=0)
self._backend_group.addButton(self._btn_pyfftw, id=1)
if current_backend == "pyfftw" and PYFFTW_AVAILABLE:
self._btn_pyfftw.setChecked(True)
else:
self._btn_scipy.setChecked(True)
bl.addWidget(self._btn_scipy)
bl.addWidget(self._btn_pyfftw)
layout.addWidget(grp_backend)
# ---- Zero-padding ----------------------------------------------
grp_zp = QGroupBox("Zero-Padding")
zl = QVBoxLayout(grp_zp)
pad_row = QHBoxLayout()
pad_row.addWidget(QLabel("Pad factor:"))
self._spin_pad = QSpinBox()
self._spin_pad.setRange(1, 256)
self._spin_pad.setValue(max(1, current_pad_factor))
self._spin_pad.setToolTip(
"Multiply the waveform length by this factor via zero-padding\n"
"before computing the FFT.\n"
"1 = no padding (natural length).\n"
"Powers of 2 (2, 4, 8 …) give the best performance."
)
self._spin_pad.valueChanged.connect(self._update_info)
pad_row.addWidget(self._spin_pad)
zl.addLayout(pad_row)
self._lbl_nfft = QLabel()
self._lbl_freq_res = QLabel()
self._lbl_vel_res = QLabel()
for lbl in (self._lbl_nfft, self._lbl_freq_res, self._lbl_vel_res):
lbl.setStyleSheet(_CSS_HINT)
zl.addWidget(lbl)
layout.addWidget(grp_zp)
# ---- Buttons ---------------------------------------------------
buttons = QDialogButtonBox()
buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole
).clicked.connect(self.accept)
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
).clicked.connect(self.reject)
layout.addWidget(buttons)
self._update_info()
def _update_info(self):
spf = self._samples_per_frame
sr = self._sample_rate_hz
pad = self._spin_pad.value()
if spf is None or sr is None:
self._lbl_nfft.setText("Load a file to preview FFT parameters.")
self._lbl_freq_res.setText("")
self._lbl_vel_res.setText("")
return
n_fft = spf * pad
freq_res_hz = sr / n_fft
freq_res_mhz = freq_res_hz / 1e6
# v (m/s) = freq (MHz) × grating (µm)
vel_res_ms = freq_res_mhz * self._grating_um
self._lbl_nfft.setText(f"FFT points: {spf} × {pad} = {n_fft:,}")
self._lbl_freq_res.setText(
f"Frequency bin: {freq_res_mhz:.4f} MHz ({freq_res_hz / 1e3:.2f} kHz)")
self._lbl_vel_res.setText(
f"Velocity bin: {vel_res_ms:.3f} m/s "
f"(at grating = {self._grating_um:.2f} µm)")
def get_backend(self) -> str:
return "pyfftw" if self._btn_pyfftw.isChecked() and PYFFTW_AVAILABLE else "scipy"
def get_pad_factor(self) -> int:
return max(1, self._spin_pad.value())
# ---------------------------------------------------------------------------
# Row-Averaged FFT Options dialog
# ---------------------------------------------------------------------------
class RowAverageFftOptionsDialog(QDialog):
"""Configure the same-row, distance-weighted neighbor averaging applied
to each pixel's CH1 waveform before 'Batch Compute Row-Averaged FFT and
Store' re-runs the FFT peak search — a same-row SNR cleanup pass, never
mixing across rows/Y (see sras_compute._row_average_waveforms).
Unlike the plain FFT batch action (which stores unmasked and defers
masking to display time), the DC threshold here is required up front:
it decides which same-row neighbors are eligible to contribute to a
pixel's average, so it can't be deferred.
Changes take effect only when the user clicks Apply. Cancel discards
all pending edits.
"""
def __init__(self, parent=None, *,
current_n: int,
current_threshold_mv: float,
pixel_x_mm: float | None):
super().__init__(parent)
self.setWindowTitle("Row-Averaged FFT Options")
self.setModal(True)
self.setMinimumWidth(380)
self._pixel_x_mm = pixel_x_mm
layout = QVBoxLayout(self)
# ---- Neighbor window ---------------------------------------------
grp_window = QGroupBox("Same-Row Neighbor Window")
wl = QVBoxLayout(grp_window)
n_row = QHBoxLayout()
n_row.addWidget(QLabel("Neighbor half-width (n):"))
self._spin_n = QSpinBox()
self._spin_n.setRange(1, 50)
self._spin_n.setValue(max(1, current_n))
self._spin_n.setToolTip(
"Each pixel's CH1 waveform is averaged with up to n same-row\n"
"neighbors on each side, distance-weighted (Gaussian) and\n"
"counting only neighbors that already pass the DC threshold\n"
"below. Never mixes across rows/Y.")
self._spin_n.valueChanged.connect(self._update_info)
n_row.addWidget(self._spin_n)
wl.addLayout(n_row)
self._lbl_width = QLabel()
self._lbl_width.setStyleSheet(_CSS_HINT)
wl.addWidget(self._lbl_width)
layout.addWidget(grp_window)
# ---- DC threshold ------------------------------------------------
grp_thr = QGroupBox("Neighbor Validity")
tl = QVBoxLayout(grp_thr)
thr_row = QHBoxLayout()
thr_row.addWidget(QLabel("DC threshold:"))
self._spin_threshold = _make_dspin(-500.0, 500.0, 3, suffix=" mV",
value=current_threshold_mv, step=0.025)
self._spin_threshold.setToolTip(
"A same-row neighbor only contributes to a pixel's average if\n"
"its own CH4 signal is at or above this threshold -- the same\n"
"test used for RF mask display. A pixel below threshold stays\n"
"masked, exactly as today; it is never rescued by its neighbors.")
thr_row.addWidget(self._spin_threshold)
tl.addLayout(thr_row)
layout.addWidget(grp_thr)
# ---- Buttons -----------------------------------------------------
buttons = QDialogButtonBox()
buttons.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole
).clicked.connect(self.accept)
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
).clicked.connect(self.reject)
layout.addWidget(buttons)
self._update_info()
def _update_info(self):
n = self._spin_n.value()
if self._pixel_x_mm is None:
self._lbl_width.setText("Load a file to preview the window's physical width.")
return
width_um = 2 * n * self._pixel_x_mm * 1e3
self._lbl_width.setText(
f"Window: ±{n} px = {width_um:.2f} µm full width "
f"(pixel pitch {self._pixel_x_mm * 1e3:.3g} µm)")
def get_half_width(self) -> int:
return self._spin_n.value()
def get_threshold_mv(self) -> float:
return self._spin_threshold.value()
# ---------------------------------------------------------------------------
# Export Fused ROI dialog
# ---------------------------------------------------------------------------
class FusedRoiExportDialog(QDialog):
"""Choose a value type and which angles to fuse for Export Fused ROI.
Every angle in the file is listed (a live AlignmentResult's per_angle
always covers every angle, and the raw-shared-grid path needs no per-
angle transform at all). Each checkbox is disabled — and auto-unchecked
— whenever *availability_fn(angle_idx, ch_idx)* is False for the
currently selected value type; switching the value-type radio
re-evaluates every checkbox live, since availability is per (angle,
value type) rather than just per angle — e.g. DC may be ready
everywhere while FFT is ready nowhere.
"""
_VALUE_MODES = (CH1_IDX, CH3_IDX, CH4_IDX, VELOCITY_MODE_IDX)
def __init__(self, parent=None, *,
angles: list[tuple[int, float]],
availability_fn,
default_ch_idx: int,
out_dir: str,
stem: str,
grid_note: str):
super().__init__(parent)
self.setWindowTitle("Export Fused ROI")
self.setModal(True)
self.setMinimumWidth(420)
self._availability_fn = availability_fn
self._out_dir = out_dir
self._stem = stem
self._path_user_chosen = False
layout = QVBoxLayout(self)
layout.addWidget(_wrap_label(grid_note, _CSS_HINT))
# ---- Value type --------------------------------------------------
grp_val, vl = _group("Value to Export")
self._val_group = QButtonGroup(self)
self._val_buttons: dict[int, QRadioButton] = {}
for ch_idx, label in zip(self._VALUE_MODES, CH_LABELS):
rb = QRadioButton(label)
self._val_group.addButton(rb, id=ch_idx)
self._val_buttons[ch_idx] = rb
vl.addWidget(rb)
self._val_buttons[default_ch_idx].setChecked(True)
self._val_group.idClicked.connect(self._on_value_type_changed)
layout.addWidget(grp_val)
# ---- Angles --------------------------------------------------
grp_ang, al = _group("Angles to Include")
sel_row = QHBoxLayout()
btn_all = QPushButton("Select All Available")
btn_none = QPushButton("Select None")
btn_all.clicked.connect(self._on_select_all_available)
btn_none.clicked.connect(self._on_select_none)
sel_row.addWidget(btn_all)
sel_row.addWidget(btn_none)
al.addLayout(sel_row)
scroll_inner = QWidget()
scroll_layout = QVBoxLayout(scroll_inner)
self._angle_checks: dict[int, QCheckBox] = {}
for angle_idx, angle_deg in angles:
cb = QCheckBox(f"{angle_deg:.1f}° (angle {angle_idx})")
self._angle_checks[angle_idx] = cb
cb.toggled.connect(self._update_accept_enabled)
scroll_layout.addWidget(cb)
scroll = QScrollArea()
scroll.setWidget(scroll_inner)
scroll.setWidgetResizable(True)
scroll.setMaximumHeight(220)
al.addWidget(scroll)
self._lbl_none_available = _wrap_label("", _CSS_WARN)
al.addWidget(self._lbl_none_available)
layout.addWidget(grp_ang)
# ---- Output path --------------------------------------------------
grp_out, ol = _group("Output File")
path_row = QHBoxLayout()
self._edit_path = QLineEdit()
self._edit_path.setReadOnly(True)
path_row.addWidget(self._edit_path, 1)
btn_browse = QPushButton("Browse…")
btn_browse.clicked.connect(self._on_browse)
path_row.addWidget(btn_browse)
ol.addLayout(path_row)
layout.addWidget(grp_out)
# ---- Buttons -----------------------------------------------------
buttons = QDialogButtonBox()
self._btn_export = buttons.addButton(
"Export", QDialogButtonBox.ButtonRole.AcceptRole)
self._btn_export.clicked.connect(self.accept)
buttons.addButton("Cancel", QDialogButtonBox.ButtonRole.RejectRole
).clicked.connect(self.reject)
layout.addWidget(buttons)
self._refresh_default_path()
self._apply_availability()
# ---- internals -----------------------------------------------------
def _current_ch_idx(self) -> int:
return self._val_group.checkedId()
def _apply_availability(self):
ch_idx = self._current_ch_idx()
n_ok = 0
for angle_idx, cb in self._angle_checks.items():
ok = self._availability_fn(angle_idx, ch_idx)
cb.setEnabled(ok)
if ok:
n_ok += 1
cb.setToolTip("")
else:
cb.setChecked(False)
cb.setToolTip(
f"No cached/stored {CH_LABELS[ch_idx]} data for this "
"angle yet — view it in the main window (or run "
"Batch Compute) first.")
self._lbl_none_available.setText(
"" if n_ok else "No angle has this value type ready yet.")
self._update_accept_enabled()
def _on_value_type_changed(self, _id: int):
self._apply_availability()
if not self._path_user_chosen:
self._refresh_default_path()
def _on_select_all_available(self):
for cb in self._angle_checks.values():
if cb.isEnabled():
cb.setChecked(True)
def _on_select_none(self):
for cb in self._angle_checks.values():
cb.setChecked(False)
def _refresh_default_path(self):
ch_idx = self._current_ch_idx()
name = f"{self._stem}_fused_roi_{CH_NAMES[ch_idx]}.csv"
self._edit_path.setText(str(Path(self._out_dir) / name))
self._update_accept_enabled()
def _on_browse(self):
path, _ = QFileDialog.getSaveFileName(
self, "Export Fused ROI as CSV", self._edit_path.text(),
"CSV files (*.csv);;All files (*)")
if path:
self._edit_path.setText(path)
self._path_user_chosen = True
self._update_accept_enabled()
def _update_accept_enabled(self):
any_checked = any(cb.isChecked() for cb in self._angle_checks.values())
self._btn_export.setEnabled(any_checked and bool(self._edit_path.text()))
# ---- getters ---------------------------------------------------------
def get_ch_idx(self) -> int:
return self._current_ch_idx()
def get_selected_angles(self) -> list[int]:
return sorted(a for a, cb in self._angle_checks.items() if cb.isChecked())
def get_output_path(self) -> str:
return self._edit_path.text()
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
PyQt6==6.10.2
numpy==2.4.1
matplotlib==3.10.8
+149 -125
View File
@@ -19,9 +19,8 @@ from matplotlib.figure import Figure
from PyQt6.QtCore import QObject, pyqtSignal
import sras_compute as compute
from sras_compute import (
cache_file, compute_angle_alignment, compute_rf_image, dc_image_mv,
)
from sras_align_export import write_aligned_sras
from sras_compute import cache_file, compute_rf_image, dc_image_mv
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
# Concurrency caps. Batch conversion runs one process per file, and each of
@@ -58,6 +57,34 @@ class CancellableWorker(QObject):
return self._stop
class _PooledWorker(CancellableWorker):
"""Fans a per-item computation across a thread pool, emitting each result
from this worker's own thread as it lands (never from a pool thread).
Subclasses provide _plan() -> n_workers (stashing whatever per-run
context they need), _items(), _one(item) -> result, and _emit(result).
On stop(): queued items are dropped, in-flight ones are not waited for —
that is what keeps closing the window responsive on a large scan.
"""
finished = pyqtSignal()
error = pyqtSignal(str)
def run(self):
try:
pool = ThreadPoolExecutor(max_workers=max(1, self._plan()))
try:
futures = [pool.submit(self._one, it) for it in self._items()]
for fut in as_completed(futures):
if self._stop:
break
self._emit(fut.result())
finally:
pool.shutdown(wait=not self._stop, cancel_futures=True)
self.finished.emit()
except Exception as exc:
self.error.emit(str(exc))
class LoadWorker(QObject):
finished = pyqtSignal(object) # SrasFile | None
error = pyqtSignal(str)
@@ -121,29 +148,29 @@ class ComputeWorker(CancellableWorker):
self.error.emit(str(exc))
class DcPrecomputeWorker(CancellableWorker):
class DcPrecomputeWorker(_PooledWorker):
"""Computes CH3/CH4 DC images for every angle in the background.
DC images are cheap (a per-waveform mean, no FFT) compared to the
CH1/Velocity FFT, so precomputing them for the whole file right after load
makes switching angles instant while on a DC channel, and also means the
FFT masking step (which needs a DC4 image) rarely has to wait on anything.
Angles are computed on a thread pool — the work is a pure mean over the
waveform block, so it is I/O- and bandwidth-bound and embarrassingly
parallel. Results are emitted one at a time as they land (out of angle
order), and always from this worker's own thread: nothing emits a Qt
signal from a pool thread.
"""
angle_done = pyqtSignal(int, np.ndarray, np.ndarray) # angle_idx, dc3_mv, dc4_mv
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, sras: SrasFile):
super().__init__()
self._sras = sras
self._angle_budget = 0
def _one_angle(self, a: int) -> tuple[int, np.ndarray, np.ndarray]:
def _plan(self) -> int:
n_workers, self._angle_budget = compute.plan_angle_level(self._sras)
return n_workers
def _items(self):
return range(self._sras.n_angles)
def _one(self, a: int) -> tuple[int, np.ndarray, np.ndarray]:
# max_workers=1 *and* a budget share: this call is one of several
# concurrent angles, and both the thread count and the buffer size
# have to be divided (see compute.plan_angle_level).
@@ -153,26 +180,8 @@ class DcPrecomputeWorker(CancellableWorker):
dc_image_mv(self._sras, a, CH3_IDX, **kw),
dc_image_mv(self._sras, a, CH4_IDX, **kw))
def run(self):
try:
n = self._sras.n_angles
n_workers, self._angle_budget = compute.plan_angle_level(self._sras)
pool = ThreadPoolExecutor(max_workers=n_workers)
try:
futures = {pool.submit(self._one_angle, a): a for a in range(n)}
for fut in as_completed(futures):
if self._stop:
break
a, dc3, dc4 = fut.result()
self.angle_done.emit(a, dc3, dc4)
finally:
# cancel_futures drops the queued angles; should_stop lets the
# in-flight ones bail within a chunk. Not waiting here is what
# keeps closing the window responsive on a large scan.
pool.shutdown(wait=not self._stop, cancel_futures=True)
self.finished.emit()
except Exception as exc:
self.error.emit(str(exc))
def _emit(self, result):
self.angle_done.emit(*result)
class BatchCacheWorker(QObject):
@@ -181,9 +190,15 @@ class BatchCacheWorker(QObject):
an existing v7 file's cache blocks without disturbing whatever the other
block already holds.
*mode* is ``"dc"`` (CH3/CH4 mean images) or ``"fft"`` (CH1 peak-frequency
*mode* is ``"dc"`` (CH3/CH4 mean images), ``"fft"`` (CH1 peak-frequency
images, unmasked — masking is applied at display time, same as v5's PREC
convention).
convention), or ``"fft_rowavg"`` (same-row, distance-weighted CH1
averaging before the FFT — needs *dc_threshold_mv* and a positive
*row_avg_n*; see ``sras_compute.cache_file``).
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.
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
@@ -195,11 +210,16 @@ class BatchCacheWorker(QObject):
file_done = pyqtSignal(str, str)
finished = pyqtSignal()
def __init__(self, paths: list[str], mode: str, apply_bg_sub: bool):
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):
super().__init__()
self._paths = paths
self._mode = mode
self._apply_bg_sub = apply_bg_sub
self._dc_threshold = dc_threshold_mv
self._row_avg_n = row_avg_n
self._pad_factor = pad_factor
def _report(self, path: str, err: str, done: int, total: int):
self.file_done.emit(path, err)
@@ -224,7 +244,10 @@ class BatchCacheWorker(QObject):
with ProcessPoolExecutor(max_workers=n_procs) as executor:
futures = {
executor.submit(cache_file, p, self._mode, self._apply_bg_sub,
compute.get_fft_backend(), per_proc_workers): p
compute.get_fft_backend(), per_proc_workers,
pad_factor=self._pad_factor,
dc_threshold_mv=self._dc_threshold,
row_avg_n=self._row_avg_n): p
for p in paths
}
for fut in as_completed(futures):
@@ -247,7 +270,11 @@ class BatchCacheWorker(QObject):
for path in paths:
try:
err = cache_file(path, self._mode, self._apply_bg_sub,
compute.get_fft_backend(), compute._MAX_WORKERS)
compute.get_fft_backend(),
compute.default_max_workers(),
pad_factor=self._pad_factor,
dc_threshold_mv=self._dc_threshold,
row_avg_n=self._row_avg_n)
except Exception as exc:
err = str(exc)
done += 1
@@ -419,34 +446,9 @@ class BatchExportWorker(CancellableWorker):
self.finished.emit()
class AngleAlignmentWorker(QObject):
"""Computes rotation+translation alignment for every angle in *sras*,
referenced to *ref_angle_idx*, from each angle's binarized CH4 mask.
Rotation is analytic (from sras.angles_deg); only translation is found by
phase correlation.
"""
progress = pyqtSignal(int) # 0–100
finished = pyqtSignal(object, str) # AlignmentResult|None, error ("" = success)
def __init__(self, sras: SrasFile, ref_angle_idx: int, dc_threshold_mv: float):
super().__init__()
self._sras = sras
self._ref = ref_angle_idx
self._threshold = dc_threshold_mv
def run(self):
try:
result = compute_angle_alignment(
self._sras, self._ref, self._threshold,
progress_cb=self.progress.emit)
self.finished.emit(result, "")
except Exception as exc:
self.finished.emit(None, str(exc))
class Ch4MaskWorker(QObject):
"""Fetches each requested angle's CH4 (Bias B) DC image in mV, for
ManualAlignmentDialog's initial threshold-mask overlay.
class Ch4MaskWorker(_PooledWorker):
"""Fetches each requested angle's CH4 (Bias B) DC image in mV, for the
alignment wizard's initial threshold-mask stack.
Reuses dc_image_mv, which prefers a stored v5/v7 cache over recomputing
from raw waveforms, so this only does real work for a file that hasn't
@@ -455,86 +457,108 @@ class Ch4MaskWorker(QObject):
every file load) hasn't reached yet. In the common case — the user opens
Fusion -> Manual Alignment after DC precompute has already finished —
*angle_indices* is empty and this worker is never even constructed (see
ManualAlignmentDialog._start_mask_prep).
CorrelatePage._start_mask_prep).
"""
angle_done = pyqtSignal(int, np.ndarray) # angle_idx, dc4_mv
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, sras: SrasFile, angle_indices: list[int]):
super().__init__()
self._sras = sras
self._angles = angle_indices
self._budget = 0
def run(self):
try:
n_workers, budget = compute.plan_angle_level(self._sras)
pool = ThreadPoolExecutor(max_workers=n_workers)
try:
futures = {
pool.submit(dc_image_mv, self._sras, a, CH4_IDX,
max_workers=1, budget=budget): a
for a in self._angles
}
for fut in as_completed(futures):
a = futures[fut]
self.angle_done.emit(a, fut.result())
finally:
pool.shutdown(wait=True)
self.finished.emit()
except Exception as exc:
self.error.emit(str(exc))
def _plan(self) -> int:
n_workers, self._budget = compute.plan_angle_level(self._sras)
return n_workers
def _items(self):
return self._angles
def _one(self, a: int) -> tuple[int, np.ndarray]:
return a, dc_image_mv(self._sras, a, CH4_IDX,
max_workers=1, budget=self._budget)
def _emit(self, result):
self.angle_done.emit(*result)
class CrossCorrelateWorker(QObject):
"""FFT phase-correlation translation for each of *angle_indices* against
*ref_angle_idx*, for ManualAlignmentDialog's Auto Cross-Correlate button.
class CrossCorrelateWorker(_PooledWorker):
"""Rigid registration (rotation + translation, never scale) of each of
*angle_indices* against *ref_angle_idx*, for the alignment wizard's
Run/Re-run Correlation button.
Runs on a background thread — a real many-angle, high-resolution scan's
correlation (even at its downsampled working resolution) can take long
enough that doing all of them on the GUI thread would visibly freeze the
dialog. Rotation is set to the same analytic scan-angle delta Auto
De-rotate uses alongside the correlated shift, since a translation
search is only meaningful once both angles' content is already oriented
the same way. dc4_mv/pivot_mm are the dialog's own already-in-memory
per-angle images/pivots — this worker does no fetching of its own.
Runs on a background thread — registering a real many-angle,
high-resolution scan takes long enough that doing it on the GUI thread
would visibly freeze the dialog. Rotation is *searched*, not taken from the
stage's reported angle: see compute.register_angle_to_reference, which
seeds from that angle but scores both of its signs and refines from there.
dc4_mv is the dialog's own already-in-memory per-angle CH4 image — this
worker does no fetching of its own.
"""
angle_done = pyqtSignal(int, float, float, float) # angle_idx, rotation_deg, shift_x_mm, shift_y_mm
finished = pyqtSignal()
error = pyqtSignal(str)
# angle_idx, rotation_deg, shift_x_mm, shift_y_mm, score, source
angle_done = pyqtSignal(int, float, float, float, float, str)
def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int],
dc4_mv: dict[int, np.ndarray], pivot_mm: dict[int, tuple[float, float]],
*, use_mask: bool, dc_threshold_mv: float, margin_frac: float):
dc4_mv: dict[int, np.ndarray], *, reg_kwargs: dict | None = None):
"""*reg_kwargs* is splatted into register_angle_to_reference — every
registration setting the wizard exposes (sources, threshold, search
width, seed, signs, refine, grid sizes) travels in it, so this class
holds no opinion about which knobs exist and exposing another needs no
change here."""
super().__init__()
self._sras = sras
self._ref = ref_angle_idx
self._angles = angle_indices
self._dc4_mv = dc4_mv
self._pivot_mm = pivot_mm
self._use_mask = use_mask
self._threshold = dc_threshold_mv
self._margin = margin_frac
self._reg_kwargs = dict(reg_kwargs or {})
def _one(self, a: int) -> tuple[int, float, float, float]:
theta = compute._theta_deg(self._sras, a, self._ref)
dx, dy = compute.correlate_translation_mm(
self._sras, a, self._ref, self._dc4_mv, self._pivot_mm,
use_mask=self._use_mask, dc_threshold_mv=self._threshold,
margin_frac=self._margin)
return a, theta, dx, dy
def _plan(self) -> int:
return compute.registration_workers(self._sras)
def _items(self):
return self._angles
def _one(self, a: int) -> tuple[int, compute.RigidFit]:
return a, compute.register_angle_to_reference(
self._sras, a, self._ref, self._dc4_mv, **self._reg_kwargs)
def _emit(self, result):
a, fit = result
self.angle_done.emit(a, fit.rotation_deg, fit.shift_mm[0],
fit.shift_mm[1], fit.score, fit.source)
class AlignedExportWorker(CancellableWorker):
"""Writes the aligned, cropped .sras on a background thread.
Unlike every other worker here this one produces a *file*, which changes
what cancellation has to mean: write_aligned_sras stages into a ".part"
sibling and removes it when should_stop() fires, so a cancelled or crashed
export leaves nothing behind. That matters more than it sounds — a
truncated .sras is not detectably broken, since the v6 parser reads a short
file as an aborted scan and opens it happily.
Cancellation is polled per output row chunk, the same granularity
CancellableWorker's docstring justifies, so closing the window never waits
on a multi-gigabyte write.
"""
progress = pyqtSignal(int) # 0-100
finished = pyqtSignal(str, str) # written path ("" = none), error
def __init__(self, sras: SrasFile, result, out_path: str):
super().__init__()
self._sras = sras
self._result = result
self._out_path = out_path
def run(self):
try:
n_workers, _budget = compute.plan_angle_level(self._sras)
pool = ThreadPoolExecutor(max_workers=max(1, n_workers))
try:
futures = [pool.submit(self._one, a) for a in self._angles]
for fut in as_completed(futures):
a, theta, dx, dy = fut.result()
self.angle_done.emit(a, theta, dx, dy)
finally:
pool.shutdown(wait=True)
self.finished.emit()
written = write_aligned_sras(
self._sras, self._result, self._out_path,
progress_cb=self.progress.emit, should_stop=self._stopped)
if self._stopped():
self.finished.emit("", "") # cancelled: no file, no error
else:
self.finished.emit(str(written), "")
except Exception as exc:
self.error.emit(str(exc))
self.finished.emit("", str(exc))
+16
View File
@@ -0,0 +1,16 @@
"""Shared test setup: repo-root imports, the offscreen Qt platform, and
hermetic QSettings (tests must not read or write the user's real viewer
settings)."""
import os
import sys
import tempfile
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from PyQt6.QtCore import QSettings # noqa: E402
QSettings.setPath(QSettings.Format.IniFormat, QSettings.Scope.UserScope,
tempfile.mkdtemp(prefix="sras_qsettings_"))
+646
View File
@@ -0,0 +1,646 @@
"""Aligned/cropped .sras export: does the written file actually hold the
alignment the viewer showed?
The export is the one place an alignment stops being a transform applied on the
fly and becomes bytes on disk, so these tests care about two things above all:
the file's geometry describes what was written, and the pixels in it are the
same pixels apply_alignment would have drawn. The strongest check is the
round-trip — register the exported file against itself and demand identity,
which no amount of self-consistent-but-wrong index math can fake.
No Qt: this exercises sras_align_export and sras_compute directly.
"""
import struct
import numpy as np
import pytest
import sras_align_export as export
import sras_compute as compute
from sras_format import CH3_IDX, CH4_IDX, HDR_SIZE_V6, SrasFile, adc_to_mv, mv_to_adc
import tools.make_test_sras as gen
_THRESHOLD_MV = 80.0
# Same reasoning as tests/test_alignment.py: a quarter degree is already
# sub-pixel for this sample at the registration pitch.
_ROT_TOL_DEG = 0.5
_SHIFT_TOL_MM = 0.02
def dc_mv(sras: SrasFile, angle_idx: int, ch: int = CH4_IDX) -> np.ndarray:
return adc_to_mv(compute.compute_dc_image(sras, angle_idx, ch), *sras.cal(ch))
@pytest.fixture(scope="module")
def rig(tmp_path_factory):
"""The rotating-sample scan, its truth alignment, and its export."""
tmpdir = tmp_path_factory.mktemp("sras_export")
src_path = tmpdir / "rotating.sras"
meta = gen.write_rotating(src_path, n_angles=4)
sras = SrasFile(str(src_path))
params = {a: compute.ManualAngleParams(rot, shift)
for a, (rot, shift) in meta["truth"].items()}
result = compute.build_manual_alignment(sras, 0, _THRESHOLD_MV, params)
out_path = tmpdir / "rotating_aligned.sras"
export.write_aligned_sras(sras, result, out_path)
return type("Rig", (), dict(
tmpdir=tmpdir, src_path=src_path, sras=sras, meta=meta,
result=result, out_path=out_path, out=SrasFile(str(out_path))))
# ---------------------------------------------------------------------------
# Geometry and file structure
# ---------------------------------------------------------------------------
def test_output_is_v6_with_uniform_geometry(rig):
out, result = rig.out, rig.result
n_rows, n_cols = result.canvas_shape
assert out.version == 6
assert out.n_angles == rig.sras.n_angles
assert set(out.n_rows) == {n_rows}, "every angle must share the canvas rows"
assert set(out.n_frames) == {n_cols}, "every angle must share the canvas frames"
assert np.allclose(out.x_start_mm, result.canvas_origin_mm[0])
# x_delta must stay velocity/laser_freq or x_axis_mm() contradicts the
# geometry table; the canvas pitch is the reference angle's own pitch, so
# this is exact rather than approximate.
assert np.allclose(out.x_delta_mm_per_angle, rig.sras.pixel_x_mm)
assert out.pixel_x_mm == pytest.approx(rig.sras.pixel_x_mm)
def test_row_table_matches_the_canvas(rig):
expected = (rig.result.canvas_origin_mm[1]
+ np.arange(rig.result.canvas_shape[0]) * rig.result.canvas_dy_mm)
for a in range(rig.out.n_angles):
assert rig.out.y_positions_mm(a) == pytest.approx(expected, abs=1e-4)
def test_angle_table_and_calibration_round_trip(rig):
assert rig.out.angles_deg == pytest.approx(rig.sras.angles_deg)
for ch in range(rig.sras.n_channels):
assert rig.out.cal(ch) == pytest.approx(rig.sras.cal(ch))
assert rig.out.samples_per_frame == rig.sras.samples_per_frame
assert rig.out.bytes_per_sample == rig.sras.bytes_per_sample
assert rig.out.n_channels == rig.sras.n_channels
assert rig.out.background == pytest.approx(rig.sras.background)
def test_no_cache_tail(rig):
"""File ends exactly at the waveform data — nothing trailing.
A stale cache tail would be indexed by the *input's* grid, so the export
must not carry one; asserting on the exact file size is what proves it,
since a v7 tail would simply be ignored by a v6 parser.
"""
end = max(off + n for _, off, n in rig.out.iter_angle_blocks())
assert rig.out_path.stat().st_size == end
assert all(img is None for img in rig.out.precomputed_dc4_mv)
def test_declared_header_size_is_v6(rig):
raw = rig.out_path.read_bytes()[:HDR_SIZE_V6]
magic, version, n_angles = struct.unpack(">4sBH", raw[:7])
assert (magic, version, n_angles) == (b"SRAS", 6, rig.sras.n_angles)
# ---------------------------------------------------------------------------
# The pixels themselves
# ---------------------------------------------------------------------------
def test_export_matches_apply_alignment(rig):
"""The exported waveforms decode to the same DC image the viewer drew —
over the *whole* canvas, padding included.
Two rules have to be exactly right for this, and each fails differently:
* rounding must be floor(x + 0.5), not np.rint, or pixels on exact
half-integer boundaries pick the neighbouring source pixel;
* out-of-bounds must be tested on the fractional coordinate against
[0, n-1], not on the rounded index, or a one-pixel rim gets real data
where the preview shows padding.
Comparing every pixel rather than only the interior is what catches the
second one, since a rim discrepancy hides inside a `preview != 0` mask.
"""
for a in range(rig.sras.n_angles):
preview = compute.apply_alignment(rig.result, a, dc_mv(rig.sras, a))
actual = dc_mv(rig.out, a)
assert actual.shape == preview.shape
# Padding matches to within half an ADC step: apply_alignment pads with
# literal 0.0 mV, the export with the nearest integer ADC code to 0 mV.
tol = abs(rig.sras.cal(CH4_IDX)[0]) / 2.0 + 1e-4
# Exclude the epsilon rim the export deliberately keeps and scipy drops
# (see test_edge_tolerance_only_affects_the_epsilon_rim).
sr, sc = export._src_coords(rig.result.per_angle[a],
np.arange(preview.shape[0]), preview.shape[1])
rim = export._in_bounds(sr, sc, *rig.sras.image_shape(a)) & (preview == 0.0)
cmp = ~rim
assert actual[cmp] == pytest.approx(preview[cmp], abs=tol), \
f"angle {a}: exported pixels differ from the aligned preview"
# And exactly, wherever there is real data.
inside = (preview != 0.0)
assert inside.any(), f"angle {a}: preview is entirely padding"
assert actual[inside] == pytest.approx(preview[inside], abs=1e-6), \
f"angle {a}: exported data pixels are not bit-equal to the preview"
def test_reference_angle_is_exported_whole(rig):
"""The reference angle must survive as a complete, exact integer crop.
It is the coordinate authority — its transform is the identity with an
integer offset by construction — so every one of its source pixels has to
appear in the export. This is what _EDGE_TOL exists for: that offset comes
out of the mm-space affine chain as -20 - 7e-15, and a bare `>= 0` bounds
test silently drops the angle's entire first row and last column.
"""
ref = rig.result.ref_angle_idx
src_rows, src_frames = rig.sras.image_shape(ref)
plan = export.plan_export(rig.sras, rig.result)
assert plan.valid_px[ref] == src_rows * src_frames, \
"reference angle lost pixels to the in-bounds test"
# And the values themselves land as an exact, unrotated block.
src_img = dc_mv(rig.sras, ref)
out_img = dc_mv(rig.out, ref)
t = rig.result.per_angle[ref]
row0, col0 = (int(round(-t.offset[0])), int(round(-t.offset[1])))
assert np.array_equal(out_img[row0:row0 + src_rows, col0:col0 + src_frames],
src_img), \
"reference angle is not a verbatim block in the export"
def test_edge_tolerance_only_affects_the_epsilon_rim(rig):
"""Where the export's bounds test and scipy's disagree, the coordinate must
be within _EDGE_TOL of the boundary — i.e. only pixels whose scipy answer
was itself decided by float noise, never a real half-pixel decision."""
for a in range(rig.sras.n_angles):
t = rig.result.per_angle[a]
src_rows, src_frames = rig.sras.image_shape(a)
n_rows, n_cols = rig.result.canvas_shape
ones = np.ones((src_rows, src_frames), dtype=np.float32)
scipy_valid = compute.apply_alignment(rig.result, a, ones) > 0.5
sr, sc = export._src_coords(t, np.arange(n_rows), n_cols)
ours = export._in_bounds(sr, sc, src_rows, src_frames)
differ = ours != scipy_valid
assert not (scipy_valid & ~ours).any(), \
f"angle {a}: export drops pixels scipy keeps"
if differ.any():
# Every disagreement sits within the tolerance of an edge.
near = (np.abs(sr) <= export._EDGE_TOL)
near |= (np.abs(sr - (src_rows - 1)) <= export._EDGE_TOL)
near |= (np.abs(sc) <= export._EDGE_TOL)
near |= (np.abs(sc - (src_frames - 1)) <= export._EDGE_TOL)
assert near[differ].all(), \
f"angle {a}: bounds differ away from the epsilon rim"
def test_export_matches_apply_alignment_on_ch3(rig):
"""Channel-agnostic: the gather moves whole pixels, not per-channel images."""
for a in range(rig.sras.n_angles):
preview = compute.apply_alignment(rig.result, a,
dc_mv(rig.sras, a, CH3_IDX))
actual = dc_mv(rig.out, a, CH3_IDX)
inside = preview != 0.0
assert actual[inside] == pytest.approx(preview[inside], abs=1e-3)
def test_padding_is_zero_mv_not_zero_adc(rig):
"""Unreachable canvas pixels must read as ~0 mV on every channel.
Filling with literal zero ADC would decode to (0 - yoff) * ymult + yzero —
for this fixture's CH4 calibration that is +100 mV, well above any sensible
mask threshold, so the padding would masquerade as valid sample everywhere.
"""
a = rig.sras.n_angles - 1
t = rig.result.per_angle[a]
n_rows, n_cols = rig.result.canvas_shape
src_rows, src_frames = rig.sras.image_shape(a)
sr, sc = export._src_coords(t, np.arange(n_rows), n_cols)
outside = ~export._in_bounds(sr, sc, src_rows, src_frames)
assert outside.any(), "rotated angle should leave unreachable canvas corners"
for ch in (CH3_IDX, CH4_IDX):
img = dc_mv(rig.out, a, ch)
half_step = abs(rig.sras.cal(ch)[0]) / 2.0
assert np.abs(img[outside]).max() <= half_step + 1e-6, \
f"CH{ch} padding is not within half an ADC step of 0 mV"
# And the sanity check that makes the above meaningful: zero ADC would not
# have passed it.
assert abs(adc_to_mv(0, *rig.sras.cal(CH4_IDX))) > 10.0
def test_reregistering_the_export_is_identity(rig):
"""The export really is aligned: registering it against its own angle 0
recovers no rotation and no shift.
The end-to-end check — it fails for any index error, sign flip, wrong pivot
or origin mistake anywhere in crop/affine/gather, in a way the
self-consistency tests above cannot.
"""
dc4 = {a: dc_mv(rig.out, a) for a in range(rig.out.n_angles)}
for a in range(1, rig.out.n_angles):
fit = compute.register_angle_to_reference(
rig.out, a, 0, dc4, dc_threshold_mv=_THRESHOLD_MV,
seed_deg=0.0, seed_signs=(1,))
assert abs(fit.rotation_deg) <= _ROT_TOL_DEG, \
f"angle {a} still rotated by {fit.rotation_deg:.3f}° after export"
assert float(np.hypot(*fit.shift_mm)) <= _SHIFT_TOL_MM, \
f"angle {a} still shifted by {fit.shift_mm} mm after export"
def test_export_of_int16_input(rig, tmp_path):
"""bps=2 inputs keep their big-endian int16 dtype through the gather."""
src_path = tmp_path / "i16.sras"
gen.write(src_path, n_angles=2, samples_per_frame=16, bps=2)
sras = SrasFile(str(src_path))
result = compute.build_manual_alignment(sras, 0, 0.0, {})
out_path = tmp_path / "i16_aligned.sras"
export.write_aligned_sras(sras, result, out_path)
out = SrasFile(str(out_path))
assert out.bytes_per_sample == 2
assert out.data[0].dtype == np.dtype(">i2")
for a in range(sras.n_angles):
preview = compute.apply_alignment(result, a, dc_mv(sras, a))
actual = dc_mv(out, a)
inside = preview != 0.0
assert actual[inside] == pytest.approx(preview[inside], abs=1e-3)
# ---------------------------------------------------------------------------
# Cropping
# ---------------------------------------------------------------------------
def test_crop_is_a_window_of_the_full_canvas(rig):
"""crop_alignment_result must resample exactly the sub-rectangle it names.
Asserted as bit-exact equality, not approximately: the crop composes into
the affine's offset by an integer number of canvas pixels, so anything but
an exact match means the composition is wrong.
"""
n_rows, n_cols = rig.result.canvas_shape
row0, col0 = n_rows // 5, n_cols // 4
nr, nc = n_rows // 2, n_cols // 3
cropped = compute.crop_alignment_result(rig.result, row0, col0, nr, nc)
assert cropped.canvas_shape == (nr, nc)
assert cropped.canvas_origin_mm[0] == pytest.approx(
rig.result.canvas_origin_mm[0] + col0 * rig.result.canvas_dx_mm)
assert cropped.canvas_origin_mm[1] == pytest.approx(
rig.result.canvas_origin_mm[1] + row0 * rig.result.canvas_dy_mm)
for a in range(rig.sras.n_angles):
img = dc_mv(rig.sras, a)
full = compute.apply_alignment(rig.result, a, img)
assert np.array_equal(
compute.apply_alignment(cropped, a, img),
full[row0:row0 + nr, col0:col0 + nc]), \
f"angle {a}: cropped resample is not the same window"
# Rotation/shift are properties of the angle, not of the canvas.
assert cropped.per_angle[a].rotation_deg == rig.result.per_angle[a].rotation_deg
assert cropped.per_angle[a].shift_mm == rig.result.per_angle[a].shift_mm
def test_cropped_export_round_trips(rig, tmp_path):
n_rows, n_cols = rig.result.canvas_shape
row0, col0, nr, nc = n_rows // 4, n_cols // 4, n_rows // 2, n_cols // 2
cropped = compute.crop_alignment_result(rig.result, row0, col0, nr, nc)
out_path = tmp_path / "cropped.sras"
export.write_aligned_sras(rig.sras, cropped, out_path)
out = SrasFile(str(out_path))
assert set(out.n_rows) == {nr} and set(out.n_frames) == {nc}
assert out.x_start_mm[0] == pytest.approx(cropped.canvas_origin_mm[0], abs=1e-4)
for a in range(rig.sras.n_angles):
preview = compute.apply_alignment(cropped, a, dc_mv(rig.sras, a))
actual = dc_mv(out, a)
inside = preview != 0.0
if inside.any():
assert actual[inside] == pytest.approx(preview[inside], abs=1e-3)
def test_crop_rejects_empty_window(rig):
with pytest.raises(ValueError, match="empty crop"):
compute.crop_alignment_result(rig.result, 0, 0, 0, 10)
with pytest.raises(ValueError, match="empty crop"):
compute.crop_alignment_result(rig.result, 0, 0, 10, -1)
# ---------------------------------------------------------------------------
# plan_export and overlap_stats
# ---------------------------------------------------------------------------
def test_plan_export_matches_what_was_written(rig):
plan = export.plan_export(rig.sras, rig.result)
n_rows, n_cols = rig.result.canvas_shape
assert (plan.n_rows, plan.n_frames) == (n_rows, n_cols)
assert plan.n_angles == rig.sras.n_angles
data_bytes = sum(n for _, _, n in rig.out.iter_angle_blocks())
assert plan.total_bytes == data_bytes
assert plan.bytes_per_angle * plan.n_angles == plan.total_bytes
# Coverage must agree with the pixels that actually carry data. The
# reference angle is unrotated, so its whole footprint lands inside.
ref_px = np.prod(rig.sras.image_shape(0))
assert plan.valid_px[0] == ref_px
for a in range(1, rig.sras.n_angles):
assert 0 < plan.valid_px[a] <= n_rows * n_cols
assert 0.0 < plan.coverage_frac(a) < 1.0
def test_plan_export_flags_a_crop_that_misses_an_angle(rig):
"""A crop over a corner the rotated angles cannot reach must warn, and the
export must still succeed by writing that angle as padding."""
n_rows, n_cols = rig.result.canvas_shape
corner = compute.crop_alignment_result(rig.result, 0, 0,
max(1, n_rows // 12),
max(1, n_cols // 12))
plan = export.plan_export(rig.sras, corner)
empty = [a for a in range(rig.sras.n_angles) if plan.valid_px[a] == 0]
assert empty, "top-left canvas corner should be unreachable for some angle"
assert any("all padding" in w for w in plan.warnings)
def test_overlap_stats():
counts = np.array([[0, 1, 2], [3, 3, 0], [0, 2, 3]])
stats = compute.overlap_stats(counts, 3)
assert stats["union_px"] == 6
assert stats["full_px"] == 3
assert stats["full_frac"] == pytest.approx(0.5)
assert stats["max_count"] == 3
assert stats["mean_count"] == pytest.approx((1 + 2 + 3 + 3 + 2 + 3) / 6)
assert stats["empty"] is False
empty = compute.overlap_stats(np.zeros((4, 4), dtype=int), 3)
assert empty["empty"] is True
assert empty["full_frac"] == 0.0 and empty["mean_count"] == 0.0
def test_largest_rect_at_least():
# A 2x3 block of 3s with a notch that a bounding box would swallow.
counts = np.array([
[0, 0, 0, 0, 0],
[0, 3, 3, 3, 0],
[0, 3, 3, 3, 0],
[0, 3, 0, 3, 0],
])
row0, col0, nr, nc = compute.largest_rect_at_least(counts, 3)
assert (nr * nc) == 6 and (row0, col0, nr, nc) == (1, 1, 2, 3)
assert (counts[row0:row0 + nr, col0:col0 + nc] >= 3).all()
# A column taller than the wide block is the better rectangle.
tall = np.array([[3, 3], [3, 0], [3, 0], [3, 0]])
r0, c0, nr2, nc2 = compute.largest_rect_at_least(tall, 3)
assert (r0, c0, nr2, nc2) == (0, 0, 4, 1)
assert compute.largest_rect_at_least(np.zeros((3, 3), dtype=int), 1) is None
# Whole-array case: no notch, so the answer is the array itself.
assert compute.largest_rect_at_least(np.full((3, 4), 2), 2) == (0, 0, 3, 4)
def test_largest_rect_matches_brute_force():
"""Randomized check against an O(n^4) reference.
The histogram sweep is short and easy to get subtly wrong — an off-by-one in
the stack unwind yields rectangles that are merely large, and "large but not
maximal" is invisible by eye on real data.
"""
def brute(good):
n_rows, n_cols = good.shape
best = 0
for r0 in range(n_rows):
for r1 in range(r0 + 1, n_rows + 1):
run = 0
for g in good[r0:r1].all(axis=0):
run = run + 1 if g else 0
best = max(best, run * (r1 - r0))
return best
rng = np.random.default_rng(0)
for _ in range(200):
counts = rng.integers(0, 3, size=(int(rng.integers(1, 9)),
int(rng.integers(1, 9))))
got = compute.largest_rect_at_least(counts, 2)
expected = brute(counts >= 2)
if got is None:
assert expected == 0
continue
row0, col0, nr, nc = got
assert (counts[row0:row0 + nr, col0:col0 + nc] >= 2).all(), \
f"rectangle is not pure:\n{counts}\n{got}"
assert nr * nc == expected, \
f"not maximal ({nr * nc} < {expected}):\n{counts}\n{got}"
def test_largest_rect_is_pure_on_the_real_fixture(rig):
"""On real overlap counts the returned rectangle must contain only
full-overlap pixels — the property a bounding box would violate."""
n = rig.sras.n_angles
masks = {a: (dc_mv(rig.sras, a) >= _THRESHOLD_MV).astype(np.float32)
for a in range(n)}
counts = sum(compute.apply_alignment(rig.result, a, masks[a]) > 0.5
for a in range(n)).astype(int)
assert counts.max() == n, "fixture alignment should have a full-overlap region"
rect = compute.largest_rect_at_least(counts, n)
assert rect is not None
row0, col0, nr, nc = rect
assert (counts[row0:row0 + nr, col0:col0 + nc] == n).all(), \
"convenience crop must not include pixels some angle misses"
# And it must beat the naive bounding box, which here is impure.
rr, cc = np.nonzero(counts == n)
bbox_pure = (counts[rr.min():rr.max() + 1, cc.min():cc.max() + 1] == n).all()
assert not bbox_pure, "fixture no longer exercises the bounding-box hazard"
# ---------------------------------------------------------------------------
# Legacy inputs, validation and durability
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("version", [2, 4])
def test_legacy_input_exports_as_v6(version, tmp_path):
"""v2-v5 inputs keep no verbatim preamble/background spans, so those
sections have to be re-encoded. v2 additionally has neither."""
src_path = tmp_path / f"legacy_v{version}.sras"
gen.write_legacy(src_path, version=version, n_angles=2)
sras = SrasFile(str(src_path))
result = compute.build_manual_alignment(sras, 0, 0.0, {})
out_path = tmp_path / f"legacy_v{version}_aligned.sras"
export.write_aligned_sras(sras, result, out_path)
out = SrasFile(str(out_path))
assert out.version == 6
assert out.n_angles == sras.n_angles
# A zero background rather than a zero-length one: consumers subtract it
# from a (spf,)-shaped row, which a length-0 array cannot broadcast against.
assert out.background is not None
assert out.background.size == sras.samples_per_frame
if sras.background is None:
assert np.all(out.background == 0)
assert any("no background" in w for w in
export.plan_export(sras, result).warnings)
# Calibration must survive: v2 has no preambles and falls back to the
# hardcoded scope constants, and the re-encoded empty preambles must land on
# exactly the same fallback.
for ch in range(sras.n_channels):
assert out.cal(ch) == pytest.approx(sras.cal(ch))
for a in range(sras.n_angles):
preview = compute.apply_alignment(result, a, dc_mv(sras, a))
actual = dc_mv(out, a)
inside = preview != 0.0
assert actual[inside] == pytest.approx(preview[inside], abs=1e-3)
def test_too_many_rows_is_rejected_before_writing(rig, tmp_path):
"""The geometry table stores n_rows as a u16; silently truncating would
write a file whose header disagrees with its own waveform block."""
huge = compute.crop_alignment_result(rig.result, 0, 0, 70000, 4)
out_path = tmp_path / "huge.sras"
with pytest.raises(ValueError, match="exceeds the .sras per-angle geometry"):
export.write_aligned_sras(rig.sras, huge, out_path)
assert not out_path.exists()
assert not out_path.with_name(out_path.name + ".part").exists()
def test_missing_transform_is_rejected(rig, tmp_path):
broken = compute.crop_alignment_result(rig.result, 0, 0,
*rig.result.canvas_shape)
del broken.per_angle[1]
with pytest.raises(ValueError, match="no transform for angle"):
export.write_aligned_sras(rig.sras, broken, tmp_path / "broken.sras")
def test_cancelled_export_leaves_nothing_behind(rig, tmp_path):
out_path = tmp_path / "cancelled.sras"
written = export.write_aligned_sras(rig.sras, rig.result, out_path,
should_stop=lambda: True)
assert written == out_path
assert not out_path.exists(), "cancelled export must not leave an output file"
assert not out_path.with_name(out_path.name + ".part").exists()
def test_failed_write_leaves_nothing_behind(rig, tmp_path):
"""An exception mid-write must remove the partial file: a short .sras is
not detectably broken — the v6 parser reads it as an aborted scan."""
out_path = tmp_path / "boom.sras"
def explode(_pct):
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
export.write_aligned_sras(rig.sras, rig.result, out_path,
progress_cb=explode)
assert not out_path.exists()
assert not out_path.with_name(out_path.name + ".part").exists()
def test_progress_is_monotonic_and_completes(rig, tmp_path):
seen: list[int] = []
export.write_aligned_sras(rig.sras, rig.result, tmp_path / "prog.sras",
progress_cb=seen.append)
assert seen and seen[-1] == 100
assert seen == sorted(seen)
assert all(0 <= p <= 100 for p in seen)
def test_band_reader_path_is_byte_identical(rig, tmp_path, monkeypatch):
"""A source block too large to hold in RAM is served from sliding bands
instead. That path only runs on multi-gigabyte scans, so force it with a
tiny budget and demand the same bytes — otherwise the one code path that
matters on real data is the one never tested."""
whole = tmp_path / "whole.sras"
export.write_aligned_sras(rig.sras, rig.result, whole)
monkeypatch.setattr(compute, "_TOTAL_BYTES_BUDGET", 4096)
banded = tmp_path / "banded.sras"
export.write_aligned_sras(rig.sras, rig.result, banded)
assert banded.read_bytes() == whole.read_bytes()
def test_row_chunking_is_invariant(rig, tmp_path, monkeypatch):
"""Output must not depend on how many rows are buffered per write."""
base = tmp_path / "base.sras"
export.write_aligned_sras(rig.sras, rig.result, base)
monkeypatch.setattr(export, "_ROW_CHUNK", 1)
one = tmp_path / "one.sras"
export.write_aligned_sras(rig.sras, rig.result, one)
assert one.read_bytes() == base.read_bytes()
def test_refuses_to_overwrite_the_source(rig):
"""The source's waveform blocks are live read-only memmaps; writing over
the file would corrupt the reads the gather is making from it."""
with pytest.raises(ValueError, match="refusing to export onto the source"):
export.write_aligned_sras(rig.sras, rig.result, rig.src_path)
assert SrasFile(str(rig.src_path)).n_angles == rig.sras.n_angles
def test_overwrites_an_existing_file(rig, tmp_path):
out_path = tmp_path / "existing.sras"
out_path.write_bytes(b"not a scan")
export.write_aligned_sras(rig.sras, rig.result, out_path)
assert SrasFile(str(out_path)).version == 6
# ---------------------------------------------------------------------------
# The registration knobs the wizard exposes
# ---------------------------------------------------------------------------
def test_locked_rotation_returns_exactly_the_seed(rig):
"""search_deg=0 + one sign + refine=False pins rotation to the seed, which
is what "lock rotation to the stage angle" means on the wizard's first
page. Only the translation may be searched."""
dc4 = {a: dc_mv(rig.sras, a) for a in range(rig.sras.n_angles)}
for a in range(1, rig.sras.n_angles):
nominal = compute.nominal_delta_deg(rig.sras, a, 0)
fit = compute.register_angle_to_reference(
rig.sras, a, 0, dc4, dc_threshold_mv=_THRESHOLD_MV,
search_deg=0.0, coarse_step_deg=2.0, seed_signs=(-1,), refine=False)
assert fit.rotation_deg == pytest.approx(-nominal)
def test_seed_deg_overrides_the_stage_angle(rig):
"""seed_deg=0.0 searches around no rotation at all, so a scan whose angles
are genuinely ~37° apart must fail to find them within a ±2° window —
proving the seed is what positions the search."""
dc4 = {a: dc_mv(rig.sras, a) for a in range(rig.sras.n_angles)}
fit = compute.register_angle_to_reference(
rig.sras, 1, 0, dc4, dc_threshold_mv=_THRESHOLD_MV,
search_deg=2.0, seed_deg=0.0, seed_signs=(1,), refine=False)
truth_rot = rig.meta["truth"][1][0]
assert abs(fit.rotation_deg) <= 2.0
assert abs(fit.rotation_deg - truth_rot) > 10.0
def test_rotation_candidates_signs():
both = compute._rotation_candidates(10.0, 2.0, 2.0)
assert both == compute._rotation_candidates(10.0, 2.0, 2.0, (-1, 1)), \
"default must stay the both-signs sweep"
assert compute._rotation_candidates(10.0, 0.0, 2.0, (1,)) == [10.0]
assert compute._rotation_candidates(10.0, 0.0, 2.0, (-1,)) == [-10.0]
# A zero seed collapses the two windows; the dedupe must keep one copy.
assert compute._rotation_candidates(0.0, 2.0, 2.0) == [-2.0, 0.0, 2.0]
def test_zero_mv_fill_code_is_clipped_to_dtype():
"""mv_to_adc is unclamped, so the fill code must be clipped or the int8
cast wraps around to a large-magnitude value."""
fake = type("S", (), dict(
n_channels=1, samples_per_frame=2,
cal=lambda self, ch: (1e-6, 0.0, 5000.0)))()
row = export._fill_row(fake, 3, np.dtype(np.int8))
assert row.shape == (1, 3, 2)
assert row.min() == row.max() == np.iinfo(np.int8).min
assert mv_to_adc(0.0, 1e-6, 0.0, 5000.0) < np.iinfo(np.int8).min
+212
View File
@@ -0,0 +1,212 @@
"""Angle-alignment tests: does registration actually stack the scans?
Builds a synthetic scan in which one sample is imaged at several *known*
rotations and offsets (tools/make_test_sras.write_rotating) and checks that the
alignment path recovers them, that the shared canvas is angle 0's own pixel
grid extended, and that nothing in the result depends on any other angle's
stage coordinates.
No Qt — this exercises sras_compute directly. See tests/test_gui.py for the
dialog and Aligned-View plumbing.
"""
from types import SimpleNamespace
import numpy as np
import pytest
import sras_compute as compute
from sras_format import CH4_IDX, SrasFile, adc_to_mv
import tools.make_test_sras as gen
# Registration is limited by how far a feature moves per degree: with this
# sample's ~1 mm radius and a ~16 µm registration pitch, a quarter degree is
# already sub-pixel, so it is the floor of what any metric can resolve here.
_ROT_TOL_DEG = 0.5
_SHIFT_TOL_MM = 0.02
_STACK_IOU_MIN = 0.90
_THRESHOLD_MV = 80.0
def dc4_images(sras: SrasFile) -> dict[int, np.ndarray]:
return {a: adc_to_mv(compute.compute_dc_image(sras, a, CH4_IDX), *sras.cal(CH4_IDX))
for a in range(sras.n_angles)}
def mm_transform(sras: SrasFile, result, angle_idx: int) -> np.ndarray:
"""Recover the pure mm-space rotation from a canvas->raw affine.
matrix == D @ R^T @ A_out, where A_out and D only carry the canvas and
per-angle pixel pitches; undoing both must leave something orthonormal, or
the transform is smuggling in a scale or a shear.
"""
dx_a, dy_a = compute.pixel_pitch_mm(sras, angle_idx)
A_out = np.array([[0.0, result.canvas_dx_mm], [result.canvas_dy_mm, 0.0]])
D = np.array([[0.0, 1.0 / dy_a], [1.0 / dx_a, 0.0]])
return np.linalg.inv(D) @ result.per_angle[angle_idx].matrix @ np.linalg.inv(A_out)
@pytest.fixture(scope="module")
def rig(tmp_path_factory):
"""The rotating-sample scan plus everything computed from it once."""
tmpdir = tmp_path_factory.mktemp("sras_align")
path = tmpdir / "rotating.sras"
meta = gen.write_rotating(path, n_angles=5)
sras = SrasFile(str(path))
dc4 = dc4_images(sras)
fits = {a: compute.register_angle_to_reference(
sras, a, 0, dc4, dc_threshold_mv=_THRESHOLD_MV)
for a in range(sras.n_angles)}
result = compute.compute_angle_alignment(sras, 0, _THRESHOLD_MV)
return SimpleNamespace(path=path, sras=sras, truth=meta["truth"],
dc4=dc4, fits=fits, result=result)
def test_registration_recovers_truth(rig):
"""Per-angle rigid registration (rotation + translation, no scale)."""
for a, fit in rig.fits.items():
t_rot, t_shift = rig.truth[a]
rot_err = abs(fit.rotation_deg - t_rot)
shift_err = float(np.hypot(fit.shift_mm[0] - t_shift[0],
fit.shift_mm[1] - t_shift[1]))
assert rot_err <= _ROT_TOL_DEG, \
(f"angle {a}: got {fit.rotation_deg:.3f}°, truth {t_rot:.3f}° "
f"(err {rot_err:.3f}°)")
assert shift_err <= _SHIFT_TOL_MM, f"angle {a}: err {shift_err:.4f} mm"
assert rig.fits[0] == compute.RigidFit(0.0, (0.0, 0.0), 1.0, "reference"), \
"reference angle registers as exact identity"
def test_stage_angle_sign_is_not_trusted(rig):
# The stage's rotational sense relative to this module's math-positive
# convention is not knowable from the file, and the old code hardcoded a
# guess. Flipping every reported angle must therefore change nothing: the
# search scores both signs and the images decide.
flipped = SrasFile(str(rig.path))
flipped.angles_deg = -flipped.angles_deg
flipped_fits = {a: compute.register_angle_to_reference(
flipped, a, 0, rig.dc4, dc_threshold_mv=_THRESHOLD_MV)
for a in range(1, flipped.n_angles)}
mismatches = {a: (flipped_fits[a].rotation_deg, rig.fits[a].rotation_deg)
for a in flipped_fits if flipped_fits[a] != rig.fits[a]}
assert not mismatches, \
f"negating every reported stage angle changed fits: {mismatches}"
def test_stage_coordinates_are_not_consulted(rig):
# Move every non-reference angle's scan window somewhere else entirely.
# Only angle 0's coordinates may matter, so every fit must be untouched.
moved = SrasFile(str(rig.path))
for a in range(1, moved.n_angles):
moved.x_start_mm[a] += 13.5 * a
moved.y_pos_per_angle[a] = moved.y_pos_per_angle[a] - 9.25 * a
moved_dc4 = dc4_images(moved)
moved_fits = {a: compute.register_angle_to_reference(
moved, a, 0, moved_dc4, dc_threshold_mv=_THRESHOLD_MV)
for a in range(1, moved.n_angles)}
mismatches = {a: (round(moved_fits[a].rotation_deg, 4), rig.fits[a].rotation_deg)
for a in moved_fits if moved_fits[a] != rig.fits[a]}
assert not mismatches, \
f"relocating every other angle's scan window changed fits: {mismatches}"
def test_canvas_is_reference_grid_extended(rig):
sras, result = rig.sras, rig.result
t0 = result.per_angle[0]
assert np.allclose(t0.matrix, np.eye(2)), \
f"angle 0's transform has rotation/scale/shear: {t0.matrix}"
assert np.allclose(t0.offset, np.round(t0.offset)), \
f"angle 0 does not land on whole canvas pixels: {t0.offset}"
assert ((result.canvas_dx_mm, result.canvas_dy_mm)
== compute.pixel_pitch_mm(sras, 0)), \
"canvas pitch is angle 0's own pitch"
n_rows, n_cols = result.canvas_shape
x_axis = result.canvas_origin_mm[0] + np.arange(n_cols) * result.canvas_dx_mm
y_axis = result.canvas_origin_mm[1] + np.arange(n_rows) * result.canvas_dy_mm
row0, col0 = int(round(-t0.offset[0])), int(round(-t0.offset[1]))
a0_rows, a0_cols = sras.image_shape(0)
assert np.allclose(x_axis[col0:col0 + a0_cols], sras.x_axis_mm(0)), \
"canvas X axis reproduces angle 0's own X coordinates"
assert np.allclose(y_axis[row0:row0 + a0_rows], sras.y_positions_mm(0)), \
"canvas Y axis reproduces angle 0's own Y coordinates"
assert (n_rows >= max(int(sras.n_rows[a]) for a in range(sras.n_angles))
and n_cols >= max(int(sras.n_frames[a]) for a in range(sras.n_angles))), \
f"canvas does not cover every angle's footprint: {result.canvas_shape}"
def test_transforms_are_pure_rotations(rig):
"""No scaling anywhere in the per-angle transforms."""
for a in range(rig.sras.n_angles):
R = mm_transform(rig.sras, rig.result, a)
assert (np.allclose(R @ R.T, np.eye(2), atol=1e-9)
and abs(abs(np.linalg.det(R)) - 1.0) < 1e-9), \
f"angle {a}: det={np.linalg.det(R):.6f}"
def test_all_angles_stack(rig):
aligned = {a: compute.apply_alignment(rig.result, a, rig.dc4[a])
for a in range(rig.sras.n_angles)}
base = aligned[0] >= _THRESHOLD_MV
for a in range(1, rig.sras.n_angles):
other = aligned[a] >= _THRESHOLD_MV
iou = float((base & other).sum()) / max(1, int((base | other).sum()))
assert iou >= _STACK_IOU_MIN, f"angle {a}: IoU {iou:.4f}"
def test_downsampled_preview_lands_with_full_res(rig):
# The wizard reprojects block-mean-downsampled masks, so the
# affine has to account for the factor. When it did not, every preview
# layer came out magnified by that factor and offset — the overlay showed a
# blown-up crop of each mask, which is not something you can align by eye.
sras, result = rig.sras, rig.result
pitch = (result.canvas_dx_mm, result.canvas_dy_mm)
a = sras.n_angles - 1
p = result.per_angle[a]
full_mask = (rig.dc4[a] >= _THRESHOLD_MV).astype(np.float32)
full = compute.reproject_mask(
sras, a, 0, full_mask, p.rotation_deg, p.shift_mm, pitch,
result.canvas_origin_mm, result.canvas_shape)
fy, fx = 4, 16
small = compute.reproject_mask(
sras, a, 0, compute.block_mean_2d(full_mask, fy, fx),
p.rotation_deg, p.shift_mm, (pitch[0] * fx, pitch[1] * fy),
result.canvas_origin_mm,
(result.canvas_shape[0] // fy, result.canvas_shape[1] // fx),
src_downsample=(fy, fx))
# Compare in mm, via each layer's own center of mass.
def com_mm(layer, px, py):
rows, cols = np.nonzero(layer > 0.5)
return np.array([cols.mean() * px, rows.mean() * py])
d = com_mm(small, pitch[0] * fx, pitch[1] * fy) - com_mm(full, *pitch)
assert (abs(d[0]) <= abs(pitch[0] * fx) and abs(d[1]) <= abs(pitch[1] * fy)), \
f"downsampled preview offset {d[0]:+.4f}, {d[1]:+.4f} mm"
def test_manual_path_reproduces_geometry(rig):
sras, result = rig.sras, rig.result
params = {a: compute.ManualAngleParams(t.rotation_deg, t.shift_mm)
for a, t in result.per_angle.items()}
manual = compute.build_manual_alignment(sras, 0, _THRESHOLD_MV, params)
assert (manual.canvas_shape == result.canvas_shape
and np.allclose(manual.canvas_origin_mm, result.canvas_origin_mm)
and all(np.allclose(manual.per_angle[a].matrix, result.per_angle[a].matrix)
and np.allclose(manual.per_angle[a].offset, result.per_angle[a].offset)
for a in range(sras.n_angles))), \
"build_manual_alignment matches compute_angle_alignment for the same params"
def test_sidecar_roundtrip(rig):
sras, result = rig.sras, rig.result
params = {a: compute.ManualAngleParams(t.rotation_deg, t.shift_mm)
for a, t in result.per_angle.items()}
compute.save_manual_alignment(sras, 0, _THRESHOLD_MV, params)
loaded = compute.load_manual_alignment(sras)
assert (loaded is not None
and all(np.isclose(loaded.per_angle[a].rotation_deg, params[a].rotation_deg)
and np.allclose(loaded.per_angle[a].shift_mm, params[a].shift_mm)
for a in range(sras.n_angles))), \
"sidecar reloads every angle's params"
assert compute.delete_manual_alignment(sras), "sidecar deletes cleanly"
+356
View File
@@ -0,0 +1,356 @@
"""Behavioural tests for the compute/format layer.
Covers what the golden-hash harness can't: the v6->v7 cache round-trip
(including block carry-forward), parallel-vs-serial identity, the no-mask
fast path, and the ROI bounding-box mask optimisation.
"""
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
import sras_compute as compute
from sras_compute import (
cache_file, compute_dc_image, compute_rf_image, dc_image_mv,
)
from sras_format import CH3_IDX, CH4_IDX, SrasFile, adc_to_mv
import tools.make_test_sras as gen
REPO = Path(__file__).resolve().parent.parent
def test_cache_roundtrip(tmp_path):
"""v6 -> v7 for DC, then FFT, asserting the first block survives the
second write (the carry-forward path in write_v7_cache)."""
path = tmp_path / "roundtrip.sras"
gen.write(path, n_angles=3, seed=1, samples_per_frame=64)
src = SrasFile(str(path))
assert src.version == 6, f"got v{src.version}"
expect_dc3 = [dc_image_mv(src, a, CH3_IDX) for a in range(src.n_angles)]
expect_dc4 = [dc_image_mv(src, a, CH4_IDX) for a in range(src.n_angles)]
expect_fft = [compute_rf_image(src, a, dc_threshold_mv=None, apply_bg_sub=True)
for a in range(src.n_angles)]
err = cache_file(str(path), "dc", True)
assert err == "", err
after_dc = SrasFile(str(path))
assert after_dc.version == 7, f"got v{after_dc.version}"
assert all(x is not None for x in after_dc.precomputed_dc3_mv)
assert all(np.allclose(after_dc.precomputed_dc3_mv[a], expect_dc3[a], atol=1e-4)
for a in range(after_dc.n_angles))
assert all(np.allclose(after_dc.precomputed_dc4_mv[a], expect_dc4[a], atol=1e-4)
for a in range(after_dc.n_angles))
assert all(x is None for x in after_dc.precomputed_freq_mhz), "no fft block yet"
assert (after_dc.precomputed_dc3_mv[0].dtype == np.float32
and after_dc.precomputed_dc3_mv[0].dtype.byteorder in ("=", "|")), \
"cached images are native float32"
assert after_dc.precomputed_dc3_mv[0].flags.writeable
err = cache_file(str(path), "fft", True)
assert err == "", err
both = SrasFile(str(path))
assert all(x is not None for x in both.precomputed_freq_mhz)
assert all(np.allclose(both.precomputed_freq_mhz[a], expect_fft[a], atol=1e-3)
for a in range(both.n_angles))
assert all(np.allclose(both.precomputed_dc3_mv[a], expect_dc3[a], atol=1e-4)
for a in range(both.n_angles)), \
"DC block carried forward through the FFT write"
assert both.precomputed_bg_sub is True
# The fast path must reproduce a fresh compute, and masking must still
# apply on top of a cached (unmasked) image.
fresh = SrasFile(str(path))
fresh.precomputed_freq_mhz = [None] * fresh.n_angles
dc4 = dc_image_mv(both, 0, CH4_IDX)
thr = float(np.median(dc4))
assert np.allclose(
compute_rf_image(both, 0, dc_threshold_mv=None, apply_bg_sub=True),
compute_rf_image(fresh, 0, dc_threshold_mv=None, apply_bg_sub=True),
atol=1e-3), "cached fast path == fresh compute (unmasked)"
assert np.allclose(
compute_rf_image(both, 0, dc_threshold_mv=thr, apply_bg_sub=True),
compute_rf_image(fresh, 0, dc_threshold_mv=thr, apply_bg_sub=True),
atol=1e-3), "cached fast path == fresh compute (masked)"
# Waveform data must be byte-identical to the pre-cache file.
orig = tmp_path / "roundtrip_orig.sras"
gen.write(orig, n_angles=3, seed=1, samples_per_frame=64)
o, n = SrasFile(str(orig)), SrasFile(str(path))
assert all(np.array_equal(np.asarray(o.data[a]), np.asarray(n.data[a]))
for a in range(o.n_angles)), \
"waveform data untouched by the cache write"
def test_partial_v7_cache(tmp_path):
"""Only some angles cached: uncached angles must compute, not read zeros.
This is the v5 bug the ragged normalisation fixed, checked via v7."""
path = tmp_path / "partial.sras"
gen.write(path, n_angles=3, seed=2, samples_per_frame=64)
src = SrasFile(str(path))
expected = [compute_rf_image(src, a, dc_threshold_mv=None, apply_bg_sub=True)
for a in range(src.n_angles)]
partial = [expected[0], None, expected[2]] # angle 1 deliberately absent
src.write_v7_cache(new_freq_mhz=partial, new_bg_sub=True)
reread = SrasFile(str(path))
assert reread.precomputed_freq_mhz[1] is None
assert (reread.precomputed_freq_mhz[0] is not None
and reread.precomputed_freq_mhz[2] is not None)
img1 = compute_rf_image(reread, 1, dc_threshold_mv=None, apply_bg_sub=True)
assert np.any(img1 != 0) and np.allclose(img1, expected[1], atol=1e-3), \
"uncached angle computes rather than returning zeros"
def test_parallel_identity(tmp_path, monkeypatch):
"""Forcing 1 worker vs many must give identical output — catches
chunk-boundary and race bugs."""
path = tmp_path / "parallel.sras"
# Many rows, so the row loop actually splits into several chunks.
n_rows, n_frames, spf = 48, 9, 256
gen.write(path, n_angles=1, seed=3, samples_per_frame=spf,
geometry=[(n_rows, n_frames)])
sras = SrasFile(str(path))
# Shrink the budget so the outer row loop splits into many chunks, and
# the block size so every chunk splits into many FFT tasks — the worst
# case for boundary bugs.
monkeypatch.setattr(compute, "_TOTAL_BYTES_BUDGET", 8 * n_frames * spf * 4)
monkeypatch.setattr(compute, "_FFT_BLOCK", 4)
fft_rows = compute._plan_fft_rows(n_frames, spf, compute._TOTAL_BYTES_BUDGET)
assert fft_rows < n_rows, \
f"FFT work actually splits into multiple chunks ({fft_rows} of {n_rows})"
dc_rows = compute._chunk_rows_for(n_frames, spf, compute._TOTAL_BYTES_BUDGET)
assert dc_rows < n_rows, \
f"DC work actually splits into multiple chunks ({dc_rows} of {n_rows})"
monkeypatch.setattr(compute, "_MAX_WORKERS", 1)
dc_serial = compute_dc_image(sras, 0, CH4_IDX)
rf_serial = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
dc4 = adc_to_mv(dc_serial, *sras.cal(CH4_IDX))
thr = float(np.median(dc4))
rf_masked_serial = compute_rf_image(sras, 0, dc_threshold_mv=thr,
apply_bg_sub=True)
rf_pad_serial = compute_rf_image(sras, 0, dc_threshold_mv=thr,
apply_bg_sub=True, n_fft=spf * 8)
monkeypatch.setattr(compute, "_MAX_WORKERS", 8)
dc_par = compute_dc_image(sras, 0, CH4_IDX)
rf_par = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
rf_masked_par = compute_rf_image(sras, 0, dc_threshold_mv=thr, apply_bg_sub=True)
rf_pad_par = compute_rf_image(sras, 0, dc_threshold_mv=thr,
apply_bg_sub=True, n_fft=spf * 8)
assert np.array_equal(dc_serial, dc_par), "dc image identical"
assert np.array_equal(rf_serial, rf_par), "rf image identical (unmasked)"
assert np.array_equal(rf_masked_serial, rf_masked_par), \
"rf image identical (masked)"
assert np.array_equal(rf_pad_serial, rf_pad_par), \
"rf image identical (masked, padded/zoom)"
@pytest.mark.parametrize("spf,bps", [(64, 2), (37, 1)])
def test_zoom_identity(tmp_path, monkeypatch, spf, bps):
"""The zoom peak search must reproduce the full padded-rfft argmax
bit-for-bit, across pad factors, masking, bg-sub, dtype, and backend."""
path = tmp_path / f"zoom_{spf}.sras"
gen.write(path, n_angles=2, seed=6, samples_per_frame=spf, bps=bps)
sras = SrasFile(str(path))
dc4 = dc_image_mv(sras, 0, CH4_IDX)
thr = float(np.median(dc4))
backends = ["scipy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
for backend in backends:
monkeypatch.setattr(compute, "_fft_backend", backend)
for pad in (4, 8, 40):
n_fft = spf * pad
for thr_v in (None, thr):
for bg in (False, True):
ref = compute_rf_image(sras, 0, dc_threshold_mv=thr_v,
apply_bg_sub=bg, n_fft=n_fft,
exact=True)
zoom = compute_rf_image(sras, 0, dc_threshold_mv=thr_v,
apply_bg_sub=bg, n_fft=n_fft)
diff = int((ref != zoom).sum())
assert diff == 0, \
(f"{diff} px differ: backend={backend} pad={pad} "
f"thr={thr_v} bg={bg} spf={spf}")
# A threshold above every pixel masks everything: both paths must agree
# on an all-zero image.
all_masked = compute_rf_image(sras, 0, dc_threshold_mv=1e9, n_fft=spf * 8)
assert not all_masked.any()
def test_zoom_identity_fuzz():
"""Hammer _peak_bins_zoom directly with adversarial spectra: noise,
un-subtracted DC offsets, on-bin and off-bin tones, near-tie tone pairs,
and all-zero rows."""
import scipy.fft as scipy_fft
rng = np.random.default_rng(42)
for _ in range(25):
spf = int(rng.integers(16, 220))
pad = int(rng.choice([4, 5, 8, 16, 40]))
n_fft = spf * pad
n_wf = 24
w = rng.normal(scale=20.0, size=(n_wf, spf))
t = np.arange(spf)
# rows 0-5: pure/noisy tones (some off-bin), row 6-7: near-tie pair,
# row 8: big DC offset, row 9: all zeros, rest: plain noise.
for r in range(6):
f = rng.uniform(1.0, spf / 2 - 1)
w[r] = 60 * np.sin(2 * np.pi * f * t / spf) + w[r] * (r % 2)
f1, f2 = rng.uniform(2.0, spf / 2 - 2, size=2)
w[6] = 50 * np.sin(2 * np.pi * f1 * t / spf) \
+ 49.9 * np.sin(2 * np.pi * f2 * t / spf)
w[7] = 50 * np.sin(2 * np.pi * f1 * t / spf) \
+ 50 * np.cos(2 * np.pi * f2 * t / spf)
w[8] = 90 + rng.normal(scale=5.0, size=spf)
w[9] = 0.0
w = w.astype(np.float32)
S = scipy_fft.rfft(w, n=n_fft, axis=-1, workers=1)
P = S.real ** 2
P += S.imag ** 2
P[:, 0] = 0.0
ref = np.argmax(P, axis=1)
zp = compute._zoom_plan(spf, n_fft)
got = compute._peak_bins_zoom(w, zp)
bad = np.nonzero(ref != got)[0]
assert not len(bad), \
(f"spf={spf} pad={pad}: rows {bad.tolist()} picked "
f"{got[bad].tolist()} instead of {ref[bad].tolist()}")
def test_nomask_equals_low_threshold(tmp_path):
"""dc_threshold_mv=None must equal a threshold below every pixel, while
skipping the CH4 read."""
path = tmp_path / "nomask.sras"
gen.write(path, n_angles=2, seed=4, samples_per_frame=128)
sras = SrasFile(str(path))
for a in range(sras.n_angles):
none_img = compute_rf_image(sras, a, dc_threshold_mv=None, apply_bg_sub=True)
low_img = compute_rf_image(sras, a, dc_threshold_mv=-1e9, apply_bg_sub=True)
assert np.array_equal(none_img, low_img), \
f"angle {a}: None == -1e9 threshold"
assert len(np.unique(none_img)) > 1, \
f"angle {a}: image is degenerate ({len(np.unique(none_img))} unique)"
def test_roi_mask():
"""The bbox-restricted mask must equal a full-grid point-in-polygon test."""
from matplotlib.path import Path as MplPath
from sras_viewer import RoiQuad
rng = np.random.default_rng(0)
x = np.linspace(-2.0, 3.0, 137)
y = np.linspace(1.0, 4.0, 91)
cases = {
"axis-aligned rect": np.array([[0.0, 1.5], [1.0, 1.5], [1.0, 3.0], [0.0, 3.0]]),
"skewed quad": np.array([[-0.5, 1.2], [1.7, 1.9], [1.2, 3.4], [-1.0, 2.6]]),
"entirely outside": np.array([[8.0, 8.0], [9.0, 8.0], [9.0, 9.0], [8.0, 9.0]]),
"covers whole grid": np.array([[-9.0, -9.0], [9.0, -9.0], [9.0, 9.0], [-9.0, 9.0]]),
"straddles left edge": np.array([[-4.0, 2.0], [0.5, 2.0], [0.5, 3.0], [-4.0, 3.0]]),
}
for _ in range(5):
cases[f"random {_}"] = rng.uniform([-2.5, 0.5], [3.5, 4.5], size=(4, 2))
for name, pts in cases.items():
roi = RoiQuad(pts)
fast = roi.mask_for_grid(x, y)
X, Y = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
slow = MplPath(pts).contains_points(
np.column_stack([X.ravel(), Y.ravel()])).reshape(X.shape)
assert np.array_equal(fast, slow), f"{name} ({int(slow.sum())} px inside)"
# Descending y axis (images are stored top-down in some scans).
roi = RoiQuad(cases["skewed quad"])
y_desc = y[::-1]
fast = roi.mask_for_grid(x, y_desc)
X, Y = np.meshgrid(x.astype(np.float64), y_desc.astype(np.float64))
slow = MplPath(cases["skewed quad"]).contains_points(
np.column_stack([X.ravel(), Y.ravel()])).reshape(X.shape)
assert np.array_equal(fast, slow), "descending y axis"
def test_legacy_parse(tmp_path):
"""v2-v4 parsing against known written data."""
for version in (2, 3, 4):
path = tmp_path / f"legacy_v{version}.sras"
meta = gen.write_legacy(path, version=version, n_angles=2, n_rows=4,
n_frames=12, samples_per_frame=32, seed=version)
s = SrasFile(str(path))
assert s.version == version, f"got v{s.version}"
assert list(s.n_rows) == [4, 4] and list(s.n_frames) == [12, 12], \
f"rows={list(s.n_rows)} frames={list(s.n_frames)}"
assert all(np.array_equal(np.asarray(s.data[a]), meta["data"][a])
for a in range(s.n_angles)), \
f"v{version} waveform data matches what was written"
assert (s.background is not None) == (version >= 4), \
f"v{version} background {'present' if version >= 4 else 'absent'}"
assert (isinstance(s.precomputed_freq_mhz, list)
and len(s.precomputed_freq_mhz) == s.n_angles), \
f"v{version} precomputed stores are ragged lists"
# DC image must equal a direct mean of the known input.
expect = meta["data"][0][:, CH3_IDX, :, :].astype(np.float64).mean(axis=-1)
assert np.allclose(compute_dc_image(s, 0, CH3_IDX), expect, atol=1e-3), \
f"v{version} DC image equals a direct mean"
def test_sras_average(tmp_path):
"""The sras_average.py CLI: frame averaging with remainder handling."""
src = tmp_path / "legacy_v4.sras"
meta = gen.write_legacy(src, version=4, n_angles=2, n_rows=4, n_frames=12,
samples_per_frame=32, seed=4)
dst = tmp_path / "legacy_v4_avg.sras"
proc = subprocess.run(
[sys.executable, str(REPO / "sras_average.py"), str(src), str(dst), "--n", "4"],
capture_output=True, text=True, cwd=REPO)
assert proc.returncode == 0, (proc.stderr or proc.stdout).strip()[-200:]
avg = SrasFile(str(dst))
assert avg.version == 4
assert list(avg.n_frames) == [3, 3], f"{list(avg.n_frames)}"
assert (avg.n_angles == 2 and list(avg.n_rows) == [4, 4]
and avg.n_channels == meta["n_channels"])
assert np.allclose(avg.ch_ymult_mv, SrasFile(str(src)).ch_ymult_mv), \
"calibration preserved"
assert np.array_equal(avg.background, SrasFile(str(src)).background), \
"background preserved"
src_data = meta["data"]
expect0 = src_data[0][:, :, 0:4, :].astype(np.float32).mean(axis=2).astype(np.int16)
assert np.array_equal(np.asarray(avg.data[0])[:, :, 0, :], expect0), \
"first averaged group equals the mean of its 4 source frames"
# Remainder handling: 12 frames / 5 -> 2 full groups + 1 partial.
dst2 = tmp_path / "legacy_v4_avg5.sras"
subprocess.run([sys.executable, str(REPO / "sras_average.py"),
str(src), str(dst2), "--n", "5"],
capture_output=True, text=True, cwd=REPO)
assert list(SrasFile(str(dst2)).n_frames) == [3, 3], \
"partial trailing group kept by default"
dst3 = tmp_path / "legacy_v4_avg5d.sras"
subprocess.run([sys.executable, str(REPO / "sras_average.py"),
str(src), str(dst3), "--n", "5", "--discard-remainder"],
capture_output=True, text=True, cwd=REPO)
assert list(SrasFile(str(dst3)).n_frames) == [2, 2], \
"--discard-remainder drops the partial group"
def test_unsupported_version_reported(tmp_path):
"""cache_file must report, not raise, for a file it can't handle."""
bogus = tmp_path / "bogus.sras"
bogus.write_bytes(b"SRAS" + bytes([99]) + b"\x00" * 200)
err = cache_file(str(bogus), "dc", True)
assert err, "bad version returns an error string"
missing = cache_file(str(tmp_path / "does_not_exist.sras"), "dc", True)
assert missing, "missing file returns an error string"
+75
View File
@@ -0,0 +1,75 @@
"""SrasFile.angles_share_raw_grid(): the no-alignment-needed gating path for
Export Fused ROI.
A plain multi-angle scan gives each angle its own bounding box and stage
x_start (scan_format.md's whole reason v6 geometry is per-angle), so it must
read as "not shareable" without a live alignment. A file the viewer's own
Alignment Wizard exported repeats one Per-Angle Geometry record and one Row
Table span for every angle (scan_format.md, "Files written by the viewer's
Alignment Wizard"), so it must read as "shareable" with no alignment needed
at all.
No Qt: this exercises sras_format/sras_compute/sras_align_export directly,
mirroring tests/test_align_export.py.
"""
import sras_align_export as export
import sras_compute as compute
from sras_format import SrasFile
import tools.make_test_sras as gen
_THRESHOLD_MV = 80.0
def test_single_angle_file_always_shares_its_grid(tmp_path):
path = tmp_path / "one_angle.sras"
gen.write(path, n_angles=1)
sras = SrasFile(str(path))
assert sras.angles_share_raw_grid()
def test_plain_multi_angle_file_does_not_share_its_grid(tmp_path):
"""tools.make_test_sras.write gives every angle its own geometry and
stage x_start (build()'s `x_start = -0.5 + 0.1 * a`), matching how real
v6 scans vary per angle — so this must read as "not shareable"."""
path = tmp_path / "plain.sras"
gen.write(path, n_angles=3)
sras = SrasFile(str(path))
assert not sras.angles_share_raw_grid()
def test_wizard_exported_file_shares_its_grid(tmp_path):
src_path = tmp_path / "rotating.sras"
meta = gen.write_rotating(src_path, n_angles=4)
sras = SrasFile(str(src_path))
params = {a: compute.ManualAngleParams(rot, shift)
for a, (rot, shift) in meta["truth"].items()}
result = compute.build_manual_alignment(sras, 0, _THRESHOLD_MV, params)
out_path = tmp_path / "rotating_aligned.sras"
export.write_aligned_sras(sras, result, out_path)
out = SrasFile(str(out_path))
assert not sras.angles_share_raw_grid(), (
"sanity check: the *source* rotating scan must NOT already share a "
"grid, or this test would not actually exercise the wizard export")
assert out.angles_share_raw_grid()
def test_mutated_angle_breaks_the_shared_grid(tmp_path):
"""A loaded SrasFile's x_start_mm is a public per-angle array (as
tests/test_gui.py::test_alignment_geometry_is_stage_independent also
relies on) -- mutating one angle's start must be visible here too."""
src_path = tmp_path / "rotating.sras"
meta = gen.write_rotating(src_path, n_angles=3)
sras = SrasFile(str(src_path))
params = {a: compute.ManualAngleParams(rot, shift)
for a, (rot, shift) in meta["truth"].items()}
result = compute.build_manual_alignment(sras, 0, _THRESHOLD_MV, params)
out_path = tmp_path / "rotating_aligned.sras"
export.write_aligned_sras(sras, result, out_path)
out = SrasFile(str(out_path))
assert out.angles_share_raw_grid()
out.x_start_mm[1] += 1.0
assert not out.angles_share_raw_grid()
+881
View File
@@ -0,0 +1,881 @@
"""Headless GUI test: drives SrasViewerWindow through the real Qt widgets,
signals and worker threads under the offscreen platform plugin.
Covers the interactions a manual smoke test would: load, switch angles and
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
changes, the alignment wizard end to end (pre-rotation, correlation, manual
nudging, crop, export), aligned view, ROI draw/move, and CSV export.
NOTE: this module is one ordered integration sequence over a single shared
window — the tests build on each other's state and must run in definition
order (pytest's default within a module). Run the whole module, not single
tests.
"""
import json
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from PyQt6.QtCore import QEventLoop, Qt, QTimer
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QDialog, QMessageBox
import sras_compute as compute
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
from sras_viewer import (
FusedRoiExportDialog, RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX,
)
import tools.make_test_sras as gen
def pump(ms: int = 250):
"""Run the event loop for a while so queued signals and worker threads
make progress."""
loop = QEventLoop()
QTimer.singleShot(ms, loop.quit)
loop.exec()
def wait_until(pred, timeout_ms: int = 20000, step: int = 100) -> bool:
waited = 0
while waited < timeout_ms:
if pred():
return True
pump(step)
waited += step
return pred()
@pytest.fixture(scope="module")
def ctx(tmp_path_factory):
"""The shared window, test file, and cross-test state for the sequence."""
app = QApplication.instance() or QApplication([])
tmpdir = tmp_path_factory.mktemp("sras_gui")
path = tmpdir / "gui.sras"
gen.write(path, n_angles=4, seed=11, samples_per_frame=256)
win = SrasViewerWindow()
win.show()
errors: list[str] = []
# Capture anything the app reports as an error via the status bar.
win.statusBar().messageChanged.connect(
lambda m: errors.append(m) if m and "error" in m.lower() else None)
c = SimpleNamespace(app=app, win=win, path=path, tmpdir=tmpdir,
errors=errors, s=None)
yield c
if win.isVisible():
win.close()
pump(400)
def test_load(ctx):
win = ctx.win
win._load_file(str(ctx.path))
assert wait_until(lambda: win._sras is not None), "file loaded"
ctx.s = s = win._sras
assert s.version == 6, f"v{s.version}"
assert win.combo_channel.currentIndex() == CH4_IDX, "defaults to CH4"
assert win._current_image is not None, "image displayed"
assert win.spin_angle.maximum() == s.n_angles - 1, \
"angle spinbox ranges over all angles"
assert win._info["Angles"].text() == f"Angles: {s.n_angles}", \
win._info["Angles"].text()
def test_dc_precompute_all_angles(ctx):
win, s = ctx.win, ctx.s
ok = wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
and (a, CH3_IDX) in win._dc_cache
for a in range(s.n_angles)))
assert ok, f"every angle cached for CH3 and CH4 ({len(win._dc_cache)} entries)"
assert "ready for all angles" in win.lbl_dc_precompute.text(), \
win.lbl_dc_precompute.text()
def test_angle_switching_from_cache(ctx):
win, s = ctx.win, ctx.s
for a in range(s.n_angles):
win.spin_angle.setValue(a)
win._on_view_changed()
pump(60)
expected = win._sras.image_shape(a)
assert win._current_image.shape == expected, \
f"angle {a} shows its own geometry {expected}, got {win._current_image.shape}"
assert not win._job_running("compute"), \
"no compute job needed for cached DC angles"
def test_stepping_the_angle_spinbox_redraws(ctx):
"""Clicking the angle spinbox's arrows (or pressing Up/Down in it) must
move the display, not just the number.
This is the ordinary way to walk a scan, and it used to do nothing: the
spinbox was wired on editingFinished, which QAbstractSpinBox emits only
on Return or focus-out — never on a step. Every other test in this module
called _on_view_changed() by hand and so could not have caught it.
"""
win, s = ctx.win, ctx.s
assert s.n_angles >= 3, "need room to step in both directions"
win.spin_angle.setValue(0)
assert wait_until(lambda: win._current_angle == 0), "settled on angle 0"
for expected in range(1, s.n_angles):
win.spin_angle.stepUp()
assert wait_until(lambda e=expected: win._current_angle == e), \
f"stepping up to angle {expected} redrew the display"
win.spin_angle.stepDown()
assert wait_until(lambda: win._current_angle == s.n_angles - 2), \
"stepping down redraws too"
# Keyboard stepping goes through the same signal, so it must work as well.
QTest.keyClick(win.spin_angle, Qt.Key.Key_Down)
assert wait_until(lambda: win._current_angle == s.n_angles - 3), \
"Key_Down redraws"
win.spin_angle.setValue(0)
assert wait_until(lambda: win._current_angle == 0), "back to angle 0"
def test_typing_an_angle_does_not_compute_intermediate_angles(ctx):
"""Keyboard tracking must stay off: with it on, valueChanged fires per
keystroke, so typing "12" would dispatch a compute for angle 1 first —
on a real scan, a whole wasted FFT for an angle the user never asked for.
"""
win, s = ctx.win, ctx.s
assert not win.spin_angle.keyboardTracking(), \
"keyboard tracking off is what makes valueChanged safe to connect"
target = s.n_angles - 1
assert target >= 2, "need a multi-digit-ish range to make the point"
win.spin_angle.setValue(0)
wait_until(lambda: win._current_angle == 0)
seen = []
win.spin_angle.valueChanged.connect(seen.append)
try:
win.spin_angle.lineEdit().selectAll()
QTest.keyClicks(win.spin_angle, str(target))
pump(60)
assert seen == [], f"no signal while typing, got {seen}"
QTest.keyClick(win.spin_angle, Qt.Key.Key_Return)
pump(60)
assert seen == [target], f"one signal on commit, got {seen}"
finally:
win.spin_angle.valueChanged.disconnect(seen.append)
assert wait_until(lambda: win._current_angle == target), "committed angle shown"
win.spin_angle.setValue(0)
assert wait_until(lambda: win._current_angle == 0), "back to angle 0"
def test_channel_switching(ctx):
win = ctx.win
win.spin_angle.setValue(0)
win._on_view_changed()
pump(60)
win.combo_channel.setCurrentIndex(CH3_IDX)
assert wait_until(lambda: win._current_ch == CH3_IDX), "CH3 displayed"
win.combo_channel.setCurrentIndex(CH1_IDX)
assert wait_until(
lambda: win._current_ch == CH1_IDX and not win._job_running("compute")), \
"CH1 (FFT) computed"
assert len(win._fft_cache) > 0, "FFT result cached"
ctx.rf_img = win._current_image
assert len(np.unique(ctx.rf_img)) > 1, \
f"FFT image is degenerate ({len(np.unique(ctx.rf_img))} unique values)"
def test_velocity_mode(ctx):
"""Velocity mode is a pure post-multiply, no recompute."""
win = ctx.win
ctx.n_fft_before = len(win._fft_cache)
win.combo_channel.setCurrentIndex(VELOCITY_MODE_IDX)
assert wait_until(
lambda: win._current_ch == VELOCITY_MODE_IDX
and not win._job_running("compute")), "velocity displayed"
grating = win.spin_grating_um.value()
assert np.allclose(win._current_image, ctx.rf_img * grating, atol=1e-3), \
"velocity == freq x grating"
assert len(win._fft_cache) == ctx.n_fft_before, \
f"velocity reused the cached FFT ({ctx.n_fft_before} -> {len(win._fft_cache)})"
assert win.grp_velocity.isVisible(), "grating spinbox visible in velocity mode"
def test_threshold_change_recomputes(ctx):
"""A threshold change is a genuine cache-key change."""
win = ctx.win
win.combo_channel.setCurrentIndex(CH1_IDX)
wait_until(lambda: not win._job_running("compute"))
dc4 = win._dc_cache[(0, CH4_IDX)]
win.spin_threshold_mv.setValue(float(np.median(dc4)))
win._on_threshold_changed()
assert wait_until(
lambda: not win._job_running("compute")
and len(win._fft_cache) > ctx.n_fft_before), "recomputed at new threshold"
n_zero = int((win._current_image == 0).sum())
assert n_zero > 0, \
f"masking zeroed some pixels ({n_zero} of {win._current_image.size})"
def test_bg_sub_toggle(ctx):
"""bg-sub no longer gates the display: it only affects a future live
compute for an angle with nothing cached yet, or an explicit batch
recompute. Toggling it on an angle that already has an FFT image must
leave that image on screen, untouched."""
win = ctx.win
n_before = len(win._fft_cache)
img_before = win._current_image
win.chk_bg_sub.setChecked(False)
pump(200)
assert not win._job_running("compute"), \
"toggling bg-sub alone must not dispatch a recompute"
assert len(win._fft_cache) == n_before, "no new cache entry from the toggle"
assert np.array_equal(win._current_image, img_before), \
"displayed image unchanged by the bg-sub toggle"
win.chk_bg_sub.setChecked(True)
pump(200)
assert not win._job_running("compute")
assert len(win._fft_cache) == n_before
assert np.array_equal(win._current_image, img_before)
def test_roi_and_csv_export(ctx):
win, s = ctx.win, ctx.s
x = s.x_axis_mm(0)
y = s.y_positions_mm(0)
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]),
float(x[-2]), float(y[-2]))
win.image_canvas.set_roi(roi)
pump(120)
assert win.image_canvas.get_roi() is not None, "ROI registered"
assert ("pixels inside" in win.lbl_roi_npix.text()
and win.lbl_roi_npix.text() != "pixels inside: —"), \
win.lbl_roi_npix.text()
npix = int(win.lbl_roi_npix.text().split(":")[1])
assert 0 < npix <= win._current_image.size, f"{npix}"
assert win.btn_export_roi.isEnabled(), "Export ROI enabled"
csv_path = ctx.tmpdir / "roi.csv"
with patch("sras_viewer.main_window.QFileDialog.getSaveFileName",
return_value=(str(csv_path), "")):
win._on_export_roi_csv()
assert csv_path.exists(), "ROI CSV written"
body = [l for l in csv_path.read_text().splitlines() if not l.startswith("#")]
assert len(body) == npix + 1, \
f"ROI CSV has {len(body)} lines for {npix} pixels (want header + one per pixel)"
img_csv = ctx.tmpdir / "img.csv"
with patch("sras_viewer.main_window.QFileDialog.getSaveFileName",
return_value=(str(img_csv), "")):
win._on_export_csv()
assert img_csv.exists(), "image CSV written"
arr = np.loadtxt(img_csv, delimiter=",")
assert (arr.shape == win._current_image.shape
and np.allclose(arr, win._current_image, rtol=1e-5, atol=1e-4)), \
"image CSV round-trips the displayed image"
def test_roi_survives_switches(ctx):
win = ctx.win
win.spin_angle.setValue(1)
win._on_view_changed()
wait_until(lambda: not win._job_running("compute"))
assert win.image_canvas.get_roi() is not None, \
"ROI still present after angle switch"
win.combo_channel.setCurrentIndex(CH4_IDX)
wait_until(lambda: win._current_ch == CH4_IDX)
assert win.image_canvas.get_roi() is not None, \
"ROI still present after channel switch"
def test_alignment_geometry_is_stage_independent(ctx):
"""Local mm is anchored on each angle's array center, not its stage
position: that is what makes a scan's placement independent of where its
window happened to sit. (Registration accuracy itself is covered by
tests/test_alignment.py, which has a synthetic sample to register.)"""
win, s = ctx.win, ctx.s
win.spin_angle.setValue(0)
win._on_view_changed()
wait_until(lambda: not win._job_running("compute"))
assert win._wizard_act.isEnabled(), "alignment wizard action enabled"
n_rows, n_frames = s.image_shape(0)
assert np.allclose(compute._center_idx(s, 0),
[(n_rows - 1) / 2, (n_frames - 1) / 2]), \
"array center is the geometric center of the pixel grid"
dx0, dy0 = compute.pixel_pitch_mm(s, 0)
assert np.allclose(compute._local_half_extent_mm(s, 0),
[(n_frames - 1) / 2 * abs(dx0), (n_rows - 1) / 2 * abs(dy0)]), \
"local half-extent is derived from shape and pitch alone"
identity = {a: compute.ManualAngleParams() for a in range(s.n_angles)}
origin_a, shape_a = compute.canvas_for_params(s, 0, (dx0, dy0), identity)
moved = SrasFile(str(ctx.path))
for a in range(1, moved.n_angles):
moved.x_start_mm[a] += 7.5
moved.y_pos_per_angle[a] = moved.y_pos_per_angle[a] + 3.25
origin_b, shape_b = compute.canvas_for_params(moved, 0, (dx0, dy0), identity)
assert shape_a == shape_b and np.allclose(origin_a, origin_b), \
("moving every non-reference angle's scan window must leave the canvas "
f"unchanged: {origin_a} {shape_a} vs {origin_b} {shape_b}")
# Both signs of the stage's reported angle are searched by default.
cands = compute._rotation_candidates(30.0, 6.0, 2.0)
assert min(cands) < -29.0 and max(cands) > 29.0, f"{min(cands)}..{max(cands)}"
# Whole-pixel translation must not wrap content around the edge.
arr = np.zeros((6, 6), dtype=np.float32)
arr[0, 0] = 1.0
assert compute._shift_into(arr, -1, -1).sum() == 0.0, \
"_shift_into zero-fills rather than wrapping"
assert compute._shift_into(arr, 2, 3)[2, 3] == 1.0, \
"_shift_into moves content by exactly the requested offset"
def test_wizard_opens_prerotated(ctx):
"""The wizard shows a mask stack before any correlation has run, built from
the stage angles in the file — the "pre-rotate" step. Nothing may seed from
the still-live automatic result; only a saved sidecar."""
win, s = ctx.win, ctx.s
win._on_alignment_wizard()
assert win._align_wizard is not None, "wizard opened"
ctx.wiz = wiz = win._align_wizard
ctx.p1 = p1 = wiz.page(wiz.PAGE_CORRELATE)
assert not win._job_running("align_masks"), \
"mask prep needed no background worker (already DC-cached)"
assert wait_until(lambda: p1.isComplete()), "masks ready, Next enabled"
assert not win._wizard_act.isEnabled(), \
"wizard action disabled while a wizard is open"
st = wiz.state
assert st.result is not None, "an AlignmentResult exists from pre-rotation alone"
assert st.counts is not None and st.counts.shape == st.preview_shape
assert 0 <= st.counts.max() <= s.n_angles
assert not st.fits, "no fits before a correlation has run"
for a in range(s.n_angles):
nominal = compute.nominal_delta_deg(s, a, st.ref_angle_idx)
expected = 0.0 if a == st.ref_angle_idx else nominal
assert abs(st.params[a].rotation_deg - expected) < 1e-9, \
f"angle {a} not pre-rotated to its stage angle"
assert st.params[a].shift_mm == (0.0, 0.0), \
"pre-rotation must not invent a translation"
def test_wizard_reference_angle_is_locked(ctx):
p1, wiz = ctx.p1, ctx.wiz
p1.combo_active.setCurrentIndex(wiz.state.ref_angle_idx)
pump(30)
before = wiz.state.params[wiz.state.ref_angle_idx]
p1._on_nudge_translate(1, 0, False)
p1._on_nudge_rotate(1, False)
assert wiz.state.params[wiz.state.ref_angle_idx] == before, \
"reference angle untouched by nudge attempts"
def test_wizard_nudges(ctx):
"""Manual correction, which the wizard absorbed from the old dialog."""
p1, wiz, s = ctx.p1, ctx.wiz, ctx.s
ctx.active = active = 1 if s.n_angles > 1 else 0
p1.combo_active.setCurrentIndex(active)
pump(30)
before = wiz.state.params[active].shift_mm
p1._on_nudge_translate(1, 0, False)
fine = p1.spin_step_translate.value()
assert abs(wiz.state.params[active].shift_mm[0] - (before[0] + fine)) < 1e-9, \
"fine translate nudge moved shift_x by exactly one fine step"
before = wiz.state.params[active].shift_mm
p1._on_nudge_translate(0, -1, True)
coarse = fine * p1.spin_step_mult.value()
assert abs(wiz.state.params[active].shift_mm[1] - (before[1] - coarse)) < 1e-9, \
"coarse translate nudge uses the multiplier"
before_rot = wiz.state.params[active].rotation_deg
p1._on_nudge_rotate(1, False)
assert wiz.state.params[active].rotation_deg != before_rot
assert len(wiz.state.layers) == s.n_angles, \
"stack rebuilt for every angle after a rotation nudge"
# A nudge only reprojects the angle that moved, patching the overlap counts
# in place. That shortcut is only sound if it lands on exactly what a full
# rebuild would have produced.
incremental = wiz.state.counts.copy()
wiz.rebuild_stack()
assert np.array_equal(wiz.state.counts, incremental), \
"incremental nudge update matches a full stack rebuild"
# Real key-event wiring (keyPressEvent -> signal -> slot).
before = wiz.state.params[active].shift_mm
QTest.keyClick(p1.canvas, Qt.Key.Key_Right)
assert wiz.state.params[active].shift_mm[0] > before[0], \
"a real Right-arrow key event nudged shift_x"
# Both views render from the same reprojected layers.
p1.combo_view.setCurrentIndex(1)
pump(50)
p1.combo_view.setCurrentIndex(0)
pump(50)
def test_wizard_correlate(ctx):
"""Cross-correlation, for every source option, retryable."""
win, p1, wiz, s = ctx.win, ctx.p1, ctx.wiz, ctx.s
from sras_viewer.align_wizard import _CORRELATE_SOURCES
for idx, (label, _sources) in enumerate(_CORRELATE_SOURCES):
p1.combo_source.setCurrentIndex(idx)
p1.btn_correlate.click()
assert not p1.isComplete(), \
f"Next must be disabled while correlating ({label})"
assert wait_until(lambda: not win._job_running("align_correlate"),
timeout_ms=60000), f"correlation finished ({label})"
assert p1.isComplete(), f"Next re-enabled ({label})"
assert all(a in wiz.state.fits for a in range(s.n_angles)
if a != wiz.state.ref_angle_idx), \
f"every non-reference angle got a fit ({label})"
assert wiz.state.params[wiz.state.ref_angle_idx] == compute.ManualAngleParams(), \
"reference angle stays identity after correlation"
assert p1.btn_correlate.isEnabled(), "controls re-enabled when done"
assert p1.table.rowCount() == s.n_angles and p1.table.item(0, 0) is not None, \
"per-angle fit table populated"
assert p1.lbl_overlap.text(), "overlap summary reported"
r = wiz.state.result
assert len(r.per_angle) == s.n_angles, "transform for every angle"
assert r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0), \
"reference angle has zero shift"
def test_wizard_retry_changes_geometry(ctx):
"""Editing a parameter and re-running is the retry path, and it must
invalidate anything indexed against the old canvas."""
p1, wiz = ctx.p1, ctx.wiz
gen_before = wiz.state.geometry_generation
p1.spin_threshold.setValue(p1.spin_threshold.value() + 5.0)
p1.spin_threshold.editingFinished.emit()
pump(60)
assert wiz.state.geometry_generation > gen_before, \
"a threshold change rebuilt the geometry"
# Reset drops the fits and returns to pre-rotation only.
p1.btn_reset.click()
pump(60)
assert not wiz.state.fits, "reset cleared the fits"
nominal = compute.nominal_delta_deg(ctx.s, ctx.active, wiz.state.ref_angle_idx)
assert abs(wiz.state.params[ctx.active].rotation_deg - nominal) < 1e-9
assert wiz.state.params[ctx.active].shift_mm == (0.0, 0.0), \
"reset also drops nudged translation"
# Put a real correlation back for the pages that follow.
p1.btn_correlate.click()
assert wait_until(lambda: not ctx.win._job_running("align_correlate"),
timeout_ms=60000)
def test_wizard_roi_page(ctx):
"""The crop page: presets, and two-way sync between the drawn rectangle and
the numeric canvas-pixel boxes."""
wiz = ctx.wiz
wiz.next()
pump(150)
assert wiz.currentId() == wiz.PAGE_ROI, "advanced to the ROI page"
ctx.p2 = p2 = wiz.page(wiz.PAGE_ROI)
st = wiz.state
assert st.crop is not None and p2.isComplete(), \
"a default crop is offered on entry"
n_rows, n_cols = st.result.canvas_shape
assert st.crop[2] > 1 or n_rows == 1, \
f"default crop must not collapse to a single row: {st.crop}"
p2.btn_whole.click()
pump(50)
assert st.crop == (0, 0, n_rows, n_cols), "whole-canvas preset"
p2.btn_fit_union.click()
pump(50)
assert st.counts[st.crop[0]:st.crop[0] + st.crop[2],
st.crop[1]:st.crop[1] + st.crop[3]].sum() == st.counts.sum(), \
"fit-to-union must keep every covered pixel"
if p2.btn_fit_overlap.isEnabled():
p2.btn_fit_overlap.click()
pump(50)
row0, col0, nr, nc = st.crop
assert (st.counts[row0:row0 + nr, col0:col0 + nc] >= 1).all(), \
"full-overlap crop must not include uncovered pixels"
# Numeric -> drawn rectangle.
p2.btn_whole.click()
pump(50)
target = (0, 0, max(1, n_rows // 2), max(1, n_cols // 2))
p2.spin_rows.setValue(target[2])
p2.spin_cols.setValue(target[3])
pump(50)
assert st.crop == target, f"spin boxes drive the crop: {st.crop} vs {target}"
# Drawn rectangle -> numeric, round-tripping exactly.
x0, y0 = wiz.canvas_to_mm(target[1] - 0.5, target[0] - 0.5)
x1, y1 = wiz.canvas_to_mm(target[1] + target[3] - 0.5,
target[0] + target[2] - 0.5)
p2.canvas.set_roi(RoiQuad.from_bbox(min(x0, x1), min(y0, y1),
max(x0, x1), max(y0, y1)))
pump(80)
assert st.crop == target, \
f"drawn rectangle round-trips to the same crop: {st.crop} vs {target}"
# A degenerate crop blocks Next.
st.crop = None
p2.completeChanged.emit()
assert not p2.isComplete(), "an absent crop blocks Next"
p2._set_crop(*target)
assert p2.isComplete()
ctx.crop = target
def test_wizard_crop_dropped_when_going_back(ctx):
"""A crop is canvas-pixel indexed, so it cannot survive a re-correlation."""
wiz, p2 = ctx.wiz, ctx.p2
wiz.back()
pump(120)
assert wiz.currentId() == wiz.PAGE_CORRELATE
assert wiz.state.crop is None, "cleanupPage discarded the stale crop"
assert wiz.cropped_plan() == (None, None), \
"nothing derived from the dropped crop survives either"
wiz.next()
pump(150)
assert wiz.state.crop is not None, "a fresh default crop is offered again"
p2._set_crop(*ctx.crop)
def test_wizard_export(ctx):
"""Writing the file: Finish stays unavailable until a write succeeds."""
win, wiz = ctx.win, ctx.wiz
out = ctx.tmpdir / "wizard_aligned.sras"
with patch("sras_viewer.align_wizard.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes):
wiz.next()
pump(150)
assert wiz.currentId() == wiz.PAGE_SAVE, "advanced to the save page"
ctx.p3 = p3 = wiz.page(wiz.PAGE_SAVE)
cropped, _ = wiz.cropped_plan()
assert cropped is not None, "crop applied on leaving page 2"
assert cropped.canvas_shape == ctx.crop[2:], \
"cropped result carries the chosen shape"
assert not p3.isComplete(), "Finish unavailable before anything is written"
assert p3.lbl_summary.text(), "a summary of what will be written is shown"
with patch("sras_viewer.align_wizard.QFileDialog.getSaveFileName",
return_value=(str(out), "")):
p3.btn_browse.click()
assert wiz.state.out_path == str(out)
p3.btn_export.click()
assert wait_until(lambda: not win._job_running("align_export"),
timeout_ms=60000), "export finished"
assert wiz.state.exported_path == str(out), p3.lbl_status.text()
assert p3.isComplete(), "Finish available once the file exists"
assert out.exists()
written = SrasFile(str(out))
ctx.written = written
assert written.version == 6, "export is a v6 file"
assert written.n_angles == ctx.s.n_angles
assert all(written.image_shape(a) == ctx.crop[2:]
for a in range(written.n_angles)), \
"every angle shares the cropped grid"
assert not out.with_name(out.name + ".part").exists(), \
"no staging file left behind"
def test_wizard_finish_applies_and_persists(ctx):
"""Finish makes the session match the file: Aligned View shows the exported
extent, and the sidecar records it for the input scan."""
win, wiz = ctx.win, ctx.wiz
wiz.accept()
pump(250)
assert win._align_wizard is None, "wizard reference released"
assert win._wizard_act.isEnabled(), "wizard action available again"
assert win._alignment_result is not None
assert win._alignment_result.canvas_shape == ctx.crop[2:], \
"the *cropped* result is what the view now uses"
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked()
pump(200)
assert win.image_canvas._img_shape == ctx.crop[2:], \
f"canvas shows the cropped extent: {win.image_canvas._img_shape}"
sidecar = compute.sidecar_path(ctx.s.path)
assert sidecar.exists(), "sidecar written for the input scan"
ctx.sidecar = sidecar
ctx.sidecar_raw = raw = json.loads(sidecar.read_text())
assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION
assert all(raw["per_angle"][str(a)]["rotation_deg"]
== win._alignment_result.per_angle[a].rotation_deg
for a in range(ctx.s.n_angles)), \
"sidecar round-trips the applied rotations"
win.chk_aligned_view.setChecked(False)
pump(200)
assert win.image_canvas._img_shape == ctx.s.image_shape(0), \
f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}"
def test_stale_schema_sidecar_ignored(ctx):
"""An old-schema sidecar (pre-pivot/sign fix) is treated as absent."""
s, raw, sidecar = ctx.s, ctx.sidecar_raw, ctx.sidecar
stale = dict(raw)
stale["schema_version"] = compute._SIDECAR_SCHEMA_VERSION - 1
sidecar.write_text(json.dumps(stale))
assert compute.load_manual_alignment(s) is None, \
"a sidecar with an old schema_version is not loaded"
sidecar.write_text(json.dumps(raw)) # restore for the rest of the sequence
def test_sidecar_restored_on_reload(ctx):
win, active = ctx.win, ctx.active
saved = json.loads(ctx.sidecar.read_text())["per_angle"][str(active)]
old_sras_id = id(win._sras)
win._load_file(str(ctx.path)) # reload the same file fresh
assert wait_until(
lambda: win._sras is not None and id(win._sras) != old_sras_id), \
"file reloaded"
ctx.s = win._sras
assert win._align_wizard is None, "no wizard left open across a reload"
assert win._alignment_result is not None, \
"reload restores the saved alignment automatically"
assert abs(win._alignment_result.per_angle[active].rotation_deg
- saved["rotation_deg"]) < 1e-9, \
"restored rotation matches what was saved"
assert win.chk_aligned_view.isChecked(), \
"Aligned View auto-checked after restoring a saved alignment"
def test_wizard_closes_with_a_reload(ctx):
"""An open wizard belongs to the file it was opened on."""
win = ctx.win
win._on_alignment_wizard()
assert win._align_wizard is not None
assert wait_until(
lambda: win._align_wizard.page(win._align_wizard.PAGE_CORRELATE).isComplete())
win._load_file(str(ctx.path))
assert wait_until(lambda: not win._job_running("load"))
pump(200)
assert win._align_wizard is None, "wizard force-closed by a reload"
ctx.s = win._sras
# ---------------------------------------------------------------------------
# Export Fused ROI
# ---------------------------------------------------------------------------
#
# Two independent ways angles can end up sharing one (x, y) grid to fuse
# onto: a live alignment result (case a, exercised on ctx.win -- the reload
# above restored one from the sidecar), or a file that is itself a previous
# Alignment Wizard export, whose angles already share a grid on disk with no
# alignment result needed at all (case b, exercised on a second window
# opened on ctx.written from test_wizard_export).
def test_fused_export_gating_case_a(ctx):
"""A live alignment result bridges the raw scan's per-angle grids --
angles_share_raw_grid() alone would be False here."""
win, s = ctx.win, ctx.s
assert win._alignment_result is not None, "alignment restored from sidecar"
assert not s.angles_share_raw_grid(), \
"sanity check: the raw (un-aligned) scan must not already share a grid"
x, y = win._aligned_canvas_axes()
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]), float(x[-2]), float(y[-2]))
win.image_canvas.set_roi(roi)
pump(120)
assert win._fused_grid_ready()
assert win.btn_export_fused_roi.isEnabled()
def test_write_fused_roi_csv_case_a_content(ctx):
win, s = ctx.win, ctx.s
roi = win.image_canvas.get_roi()
assert roi is not None, "ROI drawn by test_fused_export_gating_case_a"
csv_path = ctx.tmpdir / "fused_roi_case_a.csv"
win._write_fused_roi_csv(roi, CH4_IDX, [0, 1], str(csv_path))
assert csv_path.exists()
lines = csv_path.read_text().splitlines()
body = [l for l in lines if not l.startswith("#")]
header, *data_lines = body
assert header == (
f"x_mm,y_mm,v_{s.angles_deg[0]:.4g}deg,v_{s.angles_deg[1]:.4g}deg")
x, y = win._fused_export_axes()
mask = roi.mask_for_grid(x, y)
assert len(data_lines) == int(mask.sum())
data = np.array([[float(v) for v in line.split(",")] for line in data_lines])
expect0 = win._fused_value_image(0, CH4_IDX)[mask]
expect1 = win._fused_value_image(1, CH4_IDX)[mask]
assert np.allclose(data[:, 2], expect0, rtol=1e-5, atol=1e-4)
assert np.allclose(data[:, 3], expect1, rtol=1e-5, atol=1e-4)
def test_fused_roi_dialog_availability_live_updates(ctx):
"""Switching the value-type radio re-evaluates every angle checkbox,
disabling/auto-unchecking whichever ones are no longer available --
independent of what actually backs availability_fn, so a synthetic
stand-in keeps this a fast, deterministic test of the dialog itself."""
win, s = ctx.win, ctx.s
angles = [(a, float(s.angles_deg[a])) for a in range(s.n_angles)]
only_angle0_has_ch1 = lambda a, c: (a == 0) if c == CH1_IDX else True
dlg = FusedRoiExportDialog(
win, angles=angles, availability_fn=only_angle0_has_ch1,
default_ch_idx=CH1_IDX, out_dir=str(s.path.parent), stem=s.path.stem,
grid_note="test")
try:
assert dlg._angle_checks[0].isEnabled()
assert all(not dlg._angle_checks[a].isEnabled()
for a in range(1, s.n_angles))
dlg._angle_checks[0].setChecked(True)
dlg._val_buttons[CH4_IDX].click()
assert all(dlg._angle_checks[a].isEnabled() for a in range(s.n_angles)), \
"CH4 is available for every angle"
assert dlg._angle_checks[0].isChecked(), \
"stays checked -- still available under CH4"
if s.n_angles > 1:
dlg._angle_checks[1].setChecked(True)
dlg._val_buttons[CH1_IDX].click()
assert dlg._angle_checks[0].isChecked()
if s.n_angles > 1:
assert not dlg._angle_checks[1].isEnabled()
assert not dlg._angle_checks[1].isChecked(), \
"auto-unchecked: angle 1 has no data under CH1"
finally:
dlg.close()
def test_fused_roi_dialog_select_all_none(ctx):
win, s = ctx.win, ctx.s
angles = [(a, float(s.angles_deg[a])) for a in range(s.n_angles)]
dlg = FusedRoiExportDialog(
win, angles=angles, availability_fn=lambda a, c: c == CH4_IDX,
default_ch_idx=CH4_IDX, out_dir=str(s.path.parent), stem=s.path.stem,
grid_note="test")
try:
assert not dlg._btn_export.isEnabled(), "nothing checked yet"
dlg._on_select_all_available()
assert all(cb.isChecked() for cb in dlg._angle_checks.values())
assert dlg._btn_export.isEnabled()
dlg._on_select_none()
assert not any(cb.isChecked() for cb in dlg._angle_checks.values())
assert not dlg._btn_export.isEnabled()
finally:
dlg.close()
def test_on_export_fused_roi_csv_end_to_end(ctx):
win, s = ctx.win, ctx.s
roi = win.image_canvas.get_roi()
assert roi is not None, "ROI from the earlier fused-export tests is still set"
csv_path = ctx.tmpdir / "fused_roi_e2e.csv"
with patch("sras_viewer.main_window.FusedRoiExportDialog") as MockDlg:
inst = MockDlg.return_value
inst.exec.return_value = QDialog.DialogCode.Accepted
inst.get_ch_idx.return_value = CH4_IDX
inst.get_selected_angles.return_value = [0, 1]
inst.get_output_path.return_value = str(csv_path)
win.btn_export_fused_roi.click()
assert csv_path.exists()
kwargs = MockDlg.call_args.kwargs
assert kwargs["default_ch_idx"] == win.combo_channel.currentIndex()
assert kwargs["out_dir"] == str(s.path.parent)
assert kwargs["stem"] == s.path.stem
def test_fused_export_no_alignment_shared_grid_path(ctx):
"""ctx.written (from test_wizard_export) is itself a previous Alignment
Wizard export: opened fresh with no sidecar for its own path, so no
alignment result is ever restored -- but its angles already share one
grid on disk, so the export must work through the no-resample path."""
win2 = SrasViewerWindow()
try:
win2._load_file(str(ctx.written.path))
assert wait_until(lambda: win2._sras is not None)
s2 = win2._sras
assert win2._alignment_result is None, \
"no sidecar exists for this path -- nothing auto-restored"
assert s2.angles_share_raw_grid(), \
"a wizard export already shares one grid across angles"
assert wait_until(lambda: all((a, CH4_IDX) in win2._dc_cache
for a in range(s2.n_angles))), \
"DC precomputed for every angle"
x, y = s2.x_axis_mm(0), s2.y_positions_mm(0)
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]), float(x[-2]), float(y[-2]))
win2.image_canvas.set_roi(roi)
pump(120)
assert win2._fused_grid_ready()
assert win2.btn_export_fused_roi.isEnabled()
aligned_cache_before = len(win2._aligned_cache)
angle_idxs = list(range(min(2, s2.n_angles)))
csv_path = ctx.tmpdir / "fused_roi_case_b.csv"
win2._write_fused_roi_csv(roi, CH4_IDX, angle_idxs, str(csv_path))
assert csv_path.exists()
assert len(win2._aligned_cache) == aligned_cache_before, \
"no-resample path must never touch apply_alignment"
header = next(l for l in csv_path.read_text().splitlines()
if not l.startswith("#"))
expected_header = "x_mm,y_mm," + ",".join(
f"v_{s2.angles_deg[a]:.4g}deg" for a in angle_idxs)
assert header == expected_header
# Break the shared grid and confirm gating flips off.
mutate_idx = 1 if s2.n_angles > 1 else 0
s2.x_start_mm[mutate_idx] += 1.0
assert not s2.angles_share_raw_grid()
win2._update_fused_export_enabled()
assert not win2._fused_grid_ready()
assert not win2.btn_export_fused_roi.isEnabled()
assert "Alignment Wizard" in win2.btn_export_fused_roi.toolTip()
finally:
win2.close()
pump(200)
def test_pixel_inspector(ctx):
win = ctx.win
win.chk_aligned_view.setChecked(False)
pump(100)
win._on_pixel_clicked(0, 0)
pump(150)
assert win.lbl_wave_hint.isHidden(), "waveform hint hidden after a click"
win.combo_channel.setCurrentIndex(CH1_IDX)
wait_until(lambda: not win._job_running("compute"))
win._on_pixel_clicked(1, 1)
pump(150)
assert len(win.wave_canvas.ax_wave.lines) > 0, \
f"RF waveform panel rendered ({len(win.wave_canvas.ax_wave.lines)} lines)"
def test_shutdown(ctx):
win = ctx.win
win.close()
pump(400)
assert len(win._jobs) == 0, f"all background jobs released: {list(win._jobs)}"
def test_no_status_bar_errors(ctx):
unexpected = [e for e in ctx.errors if e]
assert not unexpected, f"status-bar errors seen: {unexpected}"
+278
View File
@@ -0,0 +1,278 @@
"""Row-averaged FFT: same-row, distance-weighted CH1 waveform smoothing.
Covers the properties the design depends on: the kernel is symmetric and
n=0 is a true no-op; the masked/renormalized convolution matches an
independent brute-force reference and gives masked neighbors exactly zero
weight regardless of their content; background subtraction after averaging
is algebraically identical to subtracting before; chunking/worker count
never changes the result; and a pixel that's itself masked is never
"rescued" by averaging.
"""
import numpy as np
import pytest
import sras_compute as compute
from sras_compute import compute_rf_image, dc_image_mv
from sras_format import CH4_IDX, SrasFile
import tools.make_test_sras as gen
def _reference_row_average(masked_waves: np.ndarray, valid: np.ndarray,
weights: np.ndarray) -> np.ndarray:
"""Independent, unvectorized reference for _row_average_waveforms: for
each row position, sum weighted valid neighbors within the kernel's
radius and normalize by the actual included weight sum. Same
definition, computed by brute-force nested loops instead of
correlate1d, so it can't share a bug with the implementation."""
n_frames, spf = masked_waves.shape
n = len(weights) // 2
out = np.zeros_like(masked_waves)
for i in range(n_frames):
num = np.zeros(spf, dtype=np.float64)
den = 0.0
for d in range(-n, n + 1):
j = i + d
if 0 <= j < n_frames and valid[j]:
w = float(weights[d + n])
num += w * masked_waves[j].astype(np.float64)
den += w
out[i] = num / den if den > 0 else 0.0
return out
# ---------------------------------------------------------------------------
# _row_average_weights
# ---------------------------------------------------------------------------
def test_row_average_weights_shape_and_symmetry():
w0 = compute._row_average_weights(0)
assert w0.shape == (1,) and w0[0] == 1.0
for n in (1, 2, 5):
w = compute._row_average_weights(n)
assert w.shape == (2 * n + 1,)
assert w[n] == pytest.approx(1.0), "center tap is the peak weight"
assert np.allclose(w, w[::-1]), "symmetric about the center"
half = w[n:]
assert np.all(np.diff(half) < 0), "strictly decreasing away from center"
# ---------------------------------------------------------------------------
# _row_average_waveforms
# ---------------------------------------------------------------------------
def test_row_average_matches_hand_rolled_reference():
rng = np.random.default_rng(0)
n_frames, spf = 15, 6
raw = rng.integers(-50, 51, size=(n_frames, spf)).astype(np.float32)
valid = np.ones(n_frames, dtype=bool)
valid[[2, 3, 9]] = False # a run of two invalid, plus a lone invalid
masked = raw.copy()
masked[~valid] = 0.0
weights = compute._row_average_weights(3)
got = compute._row_average_waveforms(masked, valid, weights)
ref = _reference_row_average(masked, valid, weights)
assert np.allclose(got[valid], ref[valid], atol=1e-4)
def test_row_average_edge_of_row():
"""A window wider than the row itself must still renormalize correctly
at both ends -- mode='constant', cval=0.0 zero-pads both the numerator
and denominator, so this is not a special case, but it's the one most
likely to break if that padding were ever mismatched between the two."""
n_frames, spf = 6, 3
raw = np.arange(n_frames * spf, dtype=np.float32).reshape(n_frames, spf)
valid = np.ones(n_frames, dtype=bool)
weights = compute._row_average_weights(4) # window (9 taps) > n_frames (6)
got = compute._row_average_waveforms(raw, valid, weights)
ref = _reference_row_average(raw, valid, weights)
assert np.allclose(got, ref, atol=1e-4)
def test_row_average_excludes_masked_neighbor_from_normalization():
"""A masked neighbor must contribute zero *weight* to the normalization,
not participate as a legitimate zero-valued sample at full weight --
the two give different answers, and only the former is correct. (Note:
masked_waves must already be 0 at invalid positions per
_row_average_waveforms's contract -- that's what read_row's zero-filled
scratch buffer guarantees in production -- so the only way to vary
"what a masked position looks like" while respecting that contract is
whether its weight is excluded from the denominator at all.)"""
n_frames, spf = 9, 4
weights = compute._row_average_weights(2)
# A: position 4 is masked -- excluded from the weight sum entirely.
valid_a = np.ones(n_frames, dtype=bool)
valid_a[4] = False
masked_a = np.zeros((n_frames, spf), dtype=np.float32)
masked_a[valid_a] = 1.0
got_a = compute._row_average_waveforms(masked_a, valid_a, weights)
# B: position 4 is valid but genuinely zero-valued -- included in the
# weight sum, diluting neighbors' averages.
valid_b = np.ones(n_frames, dtype=bool)
masked_b = np.ones((n_frames, spf), dtype=np.float32)
masked_b[4] = 0.0
got_b = compute._row_average_waveforms(masked_b, valid_b, weights)
# Every position whose window reaches index 4 must average *higher* in
# A (excluded from the denominator) than in B (included as a real zero).
affected = [2, 3, 5, 6]
assert np.all(got_a[affected] > got_b[affected]), \
"masking must exclude a neighbor from normalization, not just zero its value"
# Positions outside the window (radius 2) are unaffected either way.
assert np.allclose(got_a[[0, 1, 7, 8]], got_b[[0, 1, 7, 8]])
def test_background_subtracted_once_equals_subtract_then_average():
"""Algebraic identity the implementation relies on: subtracting a fixed
background from the already-averaged waveform equals subtracting it
from every valid neighbor first, because the denominator is always the
*actual* included weight sum (never a fixed total)."""
rng = np.random.default_rng(1)
n_frames, spf = 11, 8
raw = rng.integers(-40, 41, size=(n_frames, spf)).astype(np.float32)
valid = np.ones(n_frames, dtype=bool)
valid[[1, 7]] = False
masked = raw.copy()
masked[~valid] = 0.0
background = rng.integers(-5, 6, size=spf).astype(np.float32)
weights = compute._row_average_weights(3)
# Order A (what the code does): average first, subtract background once.
order_a = compute._row_average_waveforms(masked, valid, weights) - background
# Order B: subtract background from every valid neighbor first (restoring
# the "0 at invalid positions" contract afterward), then average.
bg_subbed = masked - background
bg_subbed[~valid] = 0.0
order_b = compute._row_average_waveforms(bg_subbed, valid, weights)
assert np.allclose(order_a[valid], order_b[valid], atol=1e-3)
# ---------------------------------------------------------------------------
# compute_rf_image(row_avg_n=...) integration
# ---------------------------------------------------------------------------
def test_row_average_zero_is_identity(tmp_path):
"""row_avg_n=0 must take the exact same code path as before this
feature existed (row_avg_weights stays None), not a single-tap kernel
that merely computes to the same answer."""
path = tmp_path / "zero.sras"
gen.write(path, n_angles=1, seed=10, samples_per_frame=64)
sras = SrasFile(str(path))
plain = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
explicit_zero = compute_rf_image(sras, 0, dc_threshold_mv=None,
apply_bg_sub=True, row_avg_n=0)
assert np.array_equal(plain, explicit_zero)
def test_row_average_respects_own_center_mask(tmp_path):
"""A pixel that's itself below threshold stays masked (0) after row
averaging -- averaging never rescues a masked pixel, matching the
'valid neighbors only' design (masked pixels are excluded from other
pixels' averages, and are never themselves smoothed)."""
path = tmp_path / "center_mask.sras"
gen.write(path, n_angles=1, seed=13, samples_per_frame=64)
sras = SrasFile(str(path))
dc4 = dc_image_mv(sras, 0, CH4_IDX)
thr = float(np.percentile(dc4, 50))
mask = dc4 >= thr
assert mask.any() and not mask.all(), "threshold actually splits the image"
img = compute_rf_image(sras, 0, dc_threshold_mv=thr, apply_bg_sub=True,
row_avg_n=4)
assert np.array_equal(img == 0, ~mask), \
"masked pixels stay exactly 0 after row averaging; valid ones don't"
def test_row_average_composes_with_padding(tmp_path):
"""row_avg_n and n_fft (zero-padding) are independent knobs: using them
together must not raise, and must still agree with the exact (non-zoom)
reference path at that pad factor -- i.e. row-averaging composes with
the zoom peak search correctly, not just with the direct one."""
path = tmp_path / "padded_rowavg.sras"
gen.write(path, n_angles=1, seed=12, samples_per_frame=64)
sras = SrasFile(str(path))
spf = sras.samples_per_frame
raw_natural = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
avg_natural = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True,
row_avg_n=3)
avg_padded_zoom = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True,
row_avg_n=3, n_fft=spf * 40)
avg_padded_exact = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True,
row_avg_n=3, n_fft=spf * 40, exact=True)
assert avg_natural.shape == raw_natural.shape == avg_padded_zoom.shape
assert np.all(np.isfinite(avg_padded_zoom))
assert np.array_equal(avg_padded_zoom, avg_padded_exact), \
"row-averaged waveforms feed the zoom and exact FFT paths identically"
def test_row_average_improves_snr_recovery():
"""The actual point of the feature: averaging same-row waveforms that
share a true underlying tone but carry independent noise recovers that
tone far more reliably than any single raw (unaveraged) waveform does."""
rng = np.random.default_rng(42)
n_frames, spf = 21, 128
true_bin = 9
t = np.arange(spf)
tone = 15.0 * np.sin(2 * np.pi * true_bin * t / spf) # same true signal
# at every position
noise_sigma = 40.0 # much larger than the tone -- deliberately poor SNR
raw = (tone[None, :] + rng.normal(scale=noise_sigma, size=(n_frames, spf))
).astype(np.float32)
valid = np.ones(n_frames, dtype=bool)
weights = compute._row_average_weights(8) # wide window: lots of averaging
averaged = compute._row_average_waveforms(raw, valid, weights)
raw_bins = compute._peak_bins_direct(raw, spf)
avg_bins = compute._peak_bins_direct(averaged, spf)
raw_hits = int(np.sum(raw_bins == true_bin))
avg_hits = int(np.sum(avg_bins == true_bin))
assert avg_hits > raw_hits, (
f"row averaging should recover the true bin ({true_bin}) more often "
f"than raw per-pixel estimates: raw {raw_hits}/{n_frames}, "
f"averaged {avg_hits}/{n_frames}")
assert avg_hits >= n_frames * 0.7, \
f"averaged recovery should be reliable, not just barely better: {avg_hits}/{n_frames}"
def test_row_average_parallel_identity(tmp_path, monkeypatch):
"""Forcing 1 worker vs many must give an identical row-averaged image --
catches chunk-boundary bugs (there should be none, since averaging never
crosses rows, but this is the empirical proof, not just inspection)."""
path = tmp_path / "parallel_rowavg.sras"
n_rows, n_frames, spf = 40, 13, 128
gen.write(path, n_angles=1, seed=11, samples_per_frame=spf,
geometry=[(n_rows, n_frames)])
sras = SrasFile(str(path))
monkeypatch.setattr(compute, "_TOTAL_BYTES_BUDGET", 8 * n_frames * spf * 4)
monkeypatch.setattr(compute, "_FFT_BLOCK", 4)
# row_avg_n > 0 halves the effective budget before chunk planning.
fft_rows = compute._plan_fft_rows(n_frames, spf, compute._TOTAL_BYTES_BUDGET // 2)
assert fft_rows < n_rows, \
f"row-averaged FFT work actually splits into multiple chunks ({fft_rows} of {n_rows})"
dc4 = dc_image_mv(sras, 0, CH4_IDX)
thr = float(np.median(dc4))
monkeypatch.setattr(compute, "_MAX_WORKERS", 1)
serial = compute_rf_image(sras, 0, dc_threshold_mv=thr, apply_bg_sub=True,
row_avg_n=4)
monkeypatch.setattr(compute, "_MAX_WORKERS", 8)
parallel = compute_rf_image(sras, 0, dc_threshold_mv=thr, apply_bg_sub=True,
row_avg_n=4)
assert np.array_equal(serial, parallel), \
"row-averaged rf image identical regardless of chunking/worker count"
+595
View File
@@ -0,0 +1,595 @@
"""Does a batch-computed FFT cache actually spare the viewer the FFT?
Storing a peak-frequency image per angle in the file is only worth doing if
displaying it is then free. The regression this module pins down is the
viewer's *dispatch* decision: it used to find the stored image only inside
ComputeWorker, so after a batch every angle change still queued a background
job behind a "Computing FFT…" popup for an image already on disk.
Both layers are covered — cached_rf_image's accept/reject rules, and the
window never reaching _start_compute for a batch-cached angle.
"""
import struct
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from PyQt6.QtCore import QEventLoop, QTimer
from PyQt6.QtWidgets import QApplication, QDialog
import sras_compute as compute
import sras_format as fmt
from sras_compute import cache_file, cached_rf_image, compute_rf_image, dc_image_mv
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
from sras_viewer import SrasViewerWindow, VELOCITY_MODE_IDX
import tools.make_test_sras as gen
_THRESHOLD_MV = 50.0 # the viewer's own default
def pump(ms: int = 200):
loop = QEventLoop()
QTimer.singleShot(ms, loop.quit)
loop.exec()
def wait_until(pred, timeout_ms: int = 20000, step: int = 100) -> bool:
waited = 0
while waited < timeout_ms:
if pred():
return True
pump(step)
waited += step
return pred()
@pytest.fixture(scope="module")
def rig(tmp_path_factory):
"""A v6 file, the FFT images a from-scratch compute gives for it, and the
same file after Batch Compute FFT has written them into its v7 cache."""
path = tmp_path_factory.mktemp("stored_cache") / "cached.sras"
gen.write(path, n_angles=4, seed=7, samples_per_frame=256)
src = SrasFile(str(path))
fresh = {a: compute_rf_image(src, a, dc_threshold_mv=_THRESHOLD_MV,
apply_bg_sub=True)
for a in range(src.n_angles)}
assert src.background is not None, "the fixture file must have a background"
err = cache_file(str(path), "fft", True)
assert err == "", err
cached = SrasFile(str(path))
assert all(x is not None for x in cached.precomputed_freq_mhz)
assert all(x is None for x in cached.precomputed_dc4_mv), \
"FFT-only batch: the mask has to come from the viewer, not the file"
return SimpleNamespace(path=path, fresh=fresh, sras=cached,
n_angles=cached.n_angles)
@pytest.fixture(scope="module")
def dc_rig(tmp_path_factory):
"""A file that has been through Batch Compute DC and Store."""
path = tmp_path_factory.mktemp("stored_dc") / "dc_cached.sras"
gen.write(path, n_angles=4, seed=9, samples_per_frame=128)
err = cache_file(str(path), "dc", True)
assert err == "", err
sras = SrasFile(str(path))
assert all(x is not None for x in sras.precomputed_dc3_mv)
assert all(x is not None for x in sras.precomputed_dc4_mv)
assert len(np.unique(sras.precomputed_dc4_mv[0])) > 1, \
"a degenerate DC image would make the comparisons below vacuous"
return SimpleNamespace(path=path, sras=sras, n_angles=sras.n_angles)
@pytest.fixture
def no_fft(monkeypatch):
"""Make any real FFT work loud: returns a list that stays empty unless a
peak search actually runs."""
calls = []
for name in ("_peak_bins_direct", "_peak_bins_zoom"):
original = getattr(compute, name)
def spy(*args, _f=original, **kwargs):
calls.append(_f.__name__)
return _f(*args, **kwargs)
monkeypatch.setattr(compute, name, spy)
return calls
# ---------------------------------------------------------------------------
# cached_rf_image: when may the stored image stand in for a compute?
# ---------------------------------------------------------------------------
def test_stored_image_matches_a_fresh_compute(rig, no_fft):
for a in range(rig.n_angles):
dc4 = dc_image_mv(SrasFile(str(rig.path)), a, CH4_IDX)
img = cached_rf_image(rig.sras, a, dc_threshold_mv=_THRESHOLD_MV,
apply_bg_sub=True, dc4_mv=dc4)
assert img is not None, f"angle {a} is cached in the file"
assert np.allclose(img, rig.fresh[a], atol=1e-3), \
f"angle {a} differs from a from-scratch compute"
assert not no_fft, f"the stored image was used, no FFT ran: {no_fft}"
def test_unmasked_when_no_threshold(rig):
img = cached_rf_image(rig.sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
assert img is not None and np.array_equal(img, rig.sras.precomputed_freq_mhz[0])
img[:] = -1.0
assert not np.any(rig.sras.precomputed_freq_mhz[0] == -1.0), \
"callers get a copy, never the file's own array"
def test_settings_the_stored_image_cannot_serve(rig):
"""A stored image carries one bg-sub state and one padding, so anything
else must fall through to a real compute rather than lie."""
spf = rig.sras.samples_per_frame
assert cached_rf_image(rig.sras, 0, _THRESHOLD_MV, apply_bg_sub=True,
n_fft=spf * 4) is None, \
"cache was written at pad 1, a pad-4 view resolves different peaks"
assert cached_rf_image(rig.sras, 0, _THRESHOLD_MV, apply_bg_sub=False) is None, \
"cache was written with bg-sub on"
uncached = SrasFile(str(rig.path))
uncached.precomputed_freq_mhz[1] = None
assert cached_rf_image(uncached, 1, _THRESHOLD_MV, apply_bg_sub=True) is None
@pytest.mark.parametrize("pad", [2, 10])
def test_cache_is_stored_at_the_configured_pad(tmp_path, pad):
"""Batching at a pad factor must produce a cache that view can read back —
a pad-1-only cache is one the padded viewer can never use."""
path = tmp_path / f"pad{pad}.sras"
gen.write(path, n_angles=2, seed=13, samples_per_frame=256)
spf = SrasFile(str(path)).samples_per_frame
assert cache_file(str(path), "fft", True, "scipy", 0, pad) == ""
sras = SrasFile(str(path))
assert sras.precomputed_pad_factor == pad, "pad factor survives the round trip"
assert cached_rf_image(sras, 0, None, apply_bg_sub=True,
n_fft=spf * pad) is not None, f"usable at pad {pad}"
assert cached_rf_image(sras, 0, None, apply_bg_sub=True) is None, \
"not usable unpadded"
assert cached_rf_image(sras, 0, None, apply_bg_sub=True,
n_fft=spf * (pad + 1)) is None, "not usable at another pad"
# The stored numbers must be the padded ones, not pad-1 relabelled.
fresh = SrasFile(str(path))
fresh.precomputed_freq_mhz = [None] * fresh.n_angles
for a in range(sras.n_angles):
assert np.allclose(
compute_rf_image(sras, a, dc_threshold_mv=None, apply_bg_sub=True,
n_fft=spf * pad),
compute_rf_image(fresh, a, dc_threshold_mv=None, apply_bg_sub=True,
n_fft=spf * pad), atol=1e-3), \
f"angle {a}: stored image is the pad-{pad} answer"
def test_cach_v1_reads_as_natural_resolution(tmp_path):
"""Files cached before the pad factor existed must keep working: a v1 tail
has no pad field and is pad 1 by construction."""
path = tmp_path / "v1.sras"
gen.write(path, n_angles=2, seed=14, samples_per_frame=256)
assert cache_file(str(path), "fft", True) == ""
# Rewrite the tail as a genuine CACH v1 block (old header, no pad field).
v2 = SrasFile(str(path))
freq, entries = v2.precomputed_freq_mhz, list(range(v2.n_angles))
payload = struct.pack(fmt.CACH_HDR_FMT, fmt.CACH_MAGIC, 1, fmt.CACH_FLAG_FFT)
payload += struct.pack(fmt.SFFT_HDR_FMT_V1, fmt.SFFT_MAGIC,
fmt.SFFT_FLAG_BG_SUB, len(entries))
for a in entries:
payload += struct.pack(">H", a) + freq[a].astype(">f4").tobytes()
head = path.read_bytes()[:v2._cache_tail_offset()]
path.write_bytes(head + payload)
v1 = SrasFile(str(path))
assert v1.precomputed_pad_factor == 1
assert v1.precomputed_bg_sub is True
assert all(np.array_equal(v1.precomputed_freq_mhz[a], freq[a]) for a in entries), \
"v1 images read back unchanged"
assert cached_rf_image(v1, 0, None, apply_bg_sub=True) is not None
assert cached_rf_image(v1, 0, None, apply_bg_sub=True,
n_fft=v1.samples_per_frame * 10) is None
def test_mask_read_can_be_refused(rig, no_fft):
"""With no DC4 in hand, building the mask means reading a whole channel —
the GUI thread asks for None instead."""
assert cached_rf_image(rig.sras, 0, _THRESHOLD_MV, apply_bg_sub=True,
allow_dc_recompute=False) is None
dc4 = dc_image_mv(SrasFile(str(rig.path)), 0, CH4_IDX)
img = cached_rf_image(rig.sras, 0, _THRESHOLD_MV, apply_bg_sub=True,
dc4_mv=dc4, allow_dc_recompute=False)
assert img is not None and np.allclose(img, rig.fresh[0], atol=1e-3)
assert not no_fft, f"no FFT on either branch: {no_fft}"
# ---------------------------------------------------------------------------
# The viewer: no compute job at all for a batch-cached angle
# ---------------------------------------------------------------------------
def test_viewer_shows_stored_angles_without_computing(rig, no_fft, monkeypatch):
app = QApplication.instance() or QApplication([]) # noqa: F841
win = SrasViewerWindow()
win.show()
dispatched = []
original_start = type(win)._start_compute
monkeypatch.setattr(type(win), "_start_compute",
lambda self: (dispatched.append(self.spin_angle.value()),
original_start(self))[1])
try:
win._load_file(str(rig.path))
assert wait_until(lambda: win._sras is not None), "file loaded"
# The file carries no DC block, so the mask comes from the window's own
# background precompute — the state a user is in by the time they click.
assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
for a in range(rig.n_angles))), \
"DC precompute finished"
dispatched.clear()
win.combo_channel.setCurrentIndex(CH1_IDX)
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
for a in list(range(rig.n_angles)) + [1, 0]:
win.spin_angle.setValue(a)
win._on_view_changed()
pump(60)
assert win._current_angle == a, f"angle {a} displayed"
assert np.allclose(win._current_image, rig.fresh[a], atol=1e-3), \
f"angle {a} shows the stored image"
assert dispatched == [], \
f"stored angles need no compute job, dispatched for {dispatched}"
assert not no_fft, f"no FFT ran for any stored angle: {no_fft}"
# Velocity is still a post-multiply of the same stored image.
win.combo_channel.setCurrentIndex(VELOCITY_MODE_IDX)
pump(120)
assert np.allclose(win._current_image,
rig.fresh[0] * win.spin_grating_um.value(), atol=1e-3)
assert dispatched == [] and not no_fft
# ...and a live control that no longer matches the stored image's own
# provenance must NOT force a recompute either — the stored image is
# shown as-is; only an explicit batch recompute changes what's shown.
win.combo_channel.setCurrentIndex(CH1_IDX)
pump(60)
win.chk_bg_sub.setChecked(False)
pump(200)
assert not win._job_running("compute") and not no_fft, \
"bg-sub off still shows the stored image, no real FFT"
assert np.allclose(win._current_image, rig.fresh[0], atol=1e-3)
finally:
win.close()
pump(300)
def test_batch_caches_at_the_viewers_pad_factor(tmp_path, no_fft, monkeypatch):
"""The bug a pad-10 user hits: Batch Compute FFT used to store pad-1
images regardless, so the padded view recomputed every angle forever.
"""
path = tmp_path / "padded_gui.sras"
gen.write(path, n_angles=3, seed=15, samples_per_frame=256)
app = QApplication.instance() or QApplication([]) # noqa: F841
win = SrasViewerWindow()
win.show()
dispatched = []
original_start = type(win)._start_compute
monkeypatch.setattr(type(win), "_start_compute",
lambda self: (dispatched.append(self.spin_angle.value()),
original_start(self))[1])
try:
win._fft_pad_factor = 10
win._load_file(str(path))
assert wait_until(lambda: win._sras is not None), "file loaded"
assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
for a in range(win._sras.n_angles))), \
"DC precompute finished"
# Convert -> Batch Compute FFT, on the open file, through the real slot.
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
return_value=([str(path)], "")):
win._on_batch_compute("fft")
assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran"
assert wait_until(lambda: win._sras is not None
and win._sras.version == 7), "file reloaded as v7"
pump(200)
assert win._sras.precomputed_pad_factor == 10, \
f"cached at the viewer's pad, got {win._sras.precomputed_pad_factor}"
expected = {a: compute.cached_rf_image(
win._sras, a, dc_threshold_mv=win.spin_threshold_mv.value(),
apply_bg_sub=win.chk_bg_sub.isChecked(),
n_fft=win._current_n_fft(),
dc4_mv=win._dc_cache.get((a, CH4_IDX)))
for a in range(win._sras.n_angles)}
assert all(v is not None for v in expected.values()), "cache is readable at pad 10"
no_fft.clear()
dispatched.clear()
win.combo_channel.setCurrentIndex(CH1_IDX)
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
for a in range(win._sras.n_angles):
win.spin_angle.setValue(a)
pump(60)
assert np.allclose(win._current_image, expected[a], atol=1e-3), \
f"angle {a} served from the pad-10 cache"
assert dispatched == [] and not no_fft, \
f"no recompute at pad 10 (jobs={dispatched}, fft={no_fft})"
assert "unusable" not in win.lbl_frame_warn.text()
# Change the live pad control so it no longer matches the stored
# image's own provenance — the info panel has to say so rather than
# leave it a mystery, but the stored pad-10 image keeps displaying;
# only an explicit batch recompute would ever produce a pad-4 one.
win._fft_pad_factor = 4
win._update_scan_info_labels()
assert "differ from the stored cache" in win.lbl_frame_warn.text(), \
win.lbl_frame_warn.text()
assert "pad 10x" in win.lbl_frame_warn.text()
last_angle = win._current_angle
win._refresh_display()
assert wait_until(lambda: not win._job_running("compute")), "settled"
assert not no_fft, "no real FFT ran — the pad-10 cache still served the view"
assert np.allclose(win._current_image, expected[last_angle], atol=1e-3), \
"pad-10 cache still shown after the live pad control diverged"
finally:
win.close()
pump(300)
def test_viewer_shows_stored_dc_without_computing(dc_rig, monkeypatch):
"""Same for the DC half, with the background precompute silenced so the
file's stored block is the only thing that can be carrying the display."""
app = QApplication.instance() or QApplication([]) # noqa: F841
win = SrasViewerWindow()
win.show()
dispatched = []
original_start = type(win)._start_compute
monkeypatch.setattr(type(win), "_start_compute",
lambda self: (dispatched.append(self.spin_angle.value()),
original_start(self))[1])
monkeypatch.setattr(type(win), "_start_dc_precompute", lambda self: None)
try:
win._load_file(str(dc_rig.path))
assert wait_until(lambda: win._sras is not None), "file loaded"
pump(120)
for ch in (CH3_IDX, CH4_IDX):
win.combo_channel.setCurrentIndex(ch)
for a in range(dc_rig.n_angles):
win.spin_angle.setValue(a)
pump(60)
assert (win._current_angle, win._current_ch) == (a, ch), \
f"angle {a} on channel {ch} displayed"
assert np.array_equal(win._current_image,
dc_rig.sras.cached_dc_mv(a, ch)), \
f"angle {a} channel {ch} shows the file's stored DC image"
assert dispatched == [], \
f"stored DC angles need no compute job, dispatched for {dispatched}"
finally:
win.close()
pump(300)
# ---------------------------------------------------------------------------
# Row-averaged FFT: cache_file("fft_rowavg", ...) and its on-disk provenance
# ---------------------------------------------------------------------------
def test_row_average_flag_and_n_round_trip(tmp_path):
path = tmp_path / "rowavg_roundtrip.sras"
gen.write(path, n_angles=2, seed=20, samples_per_frame=128)
err = cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=-1e9, row_avg_n=5)
assert err == "", err
sras = SrasFile(str(path))
assert sras.version == 7
assert sras.precomputed_row_avg_n == 5
assert all(x is not None for x in sras.precomputed_freq_mhz)
def test_fft_rowavg_mode_requires_positive_n_and_threshold(tmp_path):
path = tmp_path / "rowavg_bad_args.sras"
gen.write(path, n_angles=1, seed=27, samples_per_frame=64)
assert cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=0.0, row_avg_n=0)
assert cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=None, row_avg_n=5)
def test_raw_and_row_averaged_caches_never_cross_served(tmp_path):
"""The central regression this feature must never allow: a raw request
served a row-averaged image (or vice versa), or a request at one window
size served a cache stored at a different one."""
path = tmp_path / "cross_serve.sras"
gen.write(path, n_angles=1, seed=21, samples_per_frame=128)
err = cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=-1e9, row_avg_n=5)
assert err == "", err
sras = SrasFile(str(path))
assert cached_rf_image(sras, 0, None, apply_bg_sub=True, row_avg_n=0) is None, \
"a raw request must not be served a row-averaged cache"
assert cached_rf_image(sras, 0, None, apply_bg_sub=True, row_avg_n=3) is None, \
"a request at the wrong window size must not be served either"
served = cached_rf_image(sras, 0, None, apply_bg_sub=True, row_avg_n=5)
assert served is not None
assert np.array_equal(served, sras.precomputed_freq_mhz[0])
def test_write_v7_cache_row_avg_n_carries_forward(tmp_path):
"""A later DC-only write must leave a previously-written row-averaged
FFT block -- including its row_avg_n -- byte-for-byte unchanged."""
path = tmp_path / "carry_forward.sras"
gen.write(path, n_angles=2, seed=22, samples_per_frame=64)
err = cache_file(str(path), "fft_rowavg", True, dc_threshold_mv=-1e9, row_avg_n=7)
assert err == "", err
before = SrasFile(str(path))
assert before.precomputed_row_avg_n == 7
freq_before = [x.copy() for x in before.precomputed_freq_mhz]
err = cache_file(str(path), "dc", True)
assert err == "", err
after = SrasFile(str(path))
assert after.precomputed_row_avg_n == 7, "row_avg_n survives a DC-only write"
assert all(np.array_equal(after.precomputed_freq_mhz[a], freq_before[a])
for a in range(after.n_angles)), \
"the row-averaged FFT block itself is untouched by a DC-only write"
def test_row_average_never_touches_dc_images(tmp_path):
path = tmp_path / "dc_untouched.sras"
gen.write(path, n_angles=2, seed=23, samples_per_frame=64)
src = SrasFile(str(path))
expect_dc3 = [dc_image_mv(src, a, CH3_IDX) for a in range(src.n_angles)]
expect_dc4 = [dc_image_mv(src, a, CH4_IDX) for a in range(src.n_angles)]
assert cache_file(str(path), "dc", True) == ""
assert cache_file(str(path), "fft_rowavg", True,
dc_threshold_mv=-1e9, row_avg_n=6) == ""
after = SrasFile(str(path))
assert all(np.allclose(after.precomputed_dc3_mv[a], expect_dc3[a], atol=1e-4)
for a in range(after.n_angles))
assert all(np.allclose(after.precomputed_dc4_mv[a], expect_dc4[a], atol=1e-4)
for a in range(after.n_angles))
def test_row_average_never_modifies_raw_waveform_data(tmp_path):
path = tmp_path / "waveform_untouched.sras"
gen.write(path, n_angles=2, seed=24, samples_per_frame=64)
orig = tmp_path / "waveform_untouched_orig.sras"
gen.write(orig, n_angles=2, seed=24, samples_per_frame=64)
assert cache_file(str(path), "fft_rowavg", True,
dc_threshold_mv=-1e9, row_avg_n=5) == ""
o, n = SrasFile(str(orig)), SrasFile(str(path))
assert all(np.array_equal(np.asarray(o.data[a]), np.asarray(n.data[a]))
for a in range(o.n_angles)), \
"waveform data untouched by a row-averaged cache write"
def test_cach_v1_backward_compat_defaults_row_avg_n_zero(tmp_path):
"""A v1 CACH tail predates row-averaged FFT caching entirely (no
row_avg_n byte at all) -- readers must still parse it in full, treating
it as row_avg_n=0. This is what protects an existing real-world v7
file's already-stored FFT cache from silently becoming unusable after
this change ships."""
path = tmp_path / "v1_rowavg.sras"
gen.write(path, n_angles=2, seed=25, samples_per_frame=128)
assert cache_file(str(path), "fft", True) == ""
v2 = SrasFile(str(path))
freq, entries = v2.precomputed_freq_mhz, list(range(v2.n_angles))
payload = struct.pack(fmt.CACH_HDR_FMT, fmt.CACH_MAGIC, 1, fmt.CACH_FLAG_FFT)
payload += struct.pack(fmt.SFFT_HDR_FMT_V1, fmt.SFFT_MAGIC,
fmt.SFFT_FLAG_BG_SUB, len(entries))
for a in entries:
payload += struct.pack(">H", a) + freq[a].astype(">f4").tobytes()
head = path.read_bytes()[:v2._cache_tail_offset()]
path.write_bytes(head + payload)
v1 = SrasFile(str(path))
assert v1.precomputed_row_avg_n == 0
assert all(np.array_equal(v1.precomputed_freq_mhz[a], freq[a]) for a in entries), \
"v1 images read back unchanged"
assert cached_rf_image(v1, 0, None, apply_bg_sub=True, row_avg_n=0) is not None
assert cached_rf_image(v1, 0, None, apply_bg_sub=True, row_avg_n=5) is None, \
"a v1 tail (predating this feature) can never satisfy a row-averaged request"
def test_viewer_batch_row_average_dispatch(tmp_path, monkeypatch, no_fft):
"""Driving the new 'Batch Compute Row-Averaged FFT and Store' action
end-to-end through the real menu handler: dialog values reach the
worker, the worker reaches cache_file, and the written file is
self-describing afterward. Also the exact scenario the row-averaged-FFT
recompute bug reported: before the fix, the display always asked for
row_avg_n=0 regardless of what the file actually had stored, so viewing
an angle after this batch action saw a phantom mismatch and launched a
full raw recompute on every view switch. This asserts that no longer
happens -- the stored row-averaged image is shown directly, with no
dispatched compute job and no real FFT."""
path = tmp_path / "rowavg_gui.sras"
gen.write(path, n_angles=2, seed=26, samples_per_frame=128)
class _StubDialog:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.DialogCode.Accepted
def get_half_width(self):
return 6
def get_threshold_mv(self):
return -1e9 # mask nothing, keep the comparison simple
monkeypatch.setattr("sras_viewer.main_window.RowAverageFftOptionsDialog", _StubDialog)
app = QApplication.instance() or QApplication([]) # noqa: F841
win = SrasViewerWindow()
win.show()
dispatched = []
original_start = type(win)._start_compute
monkeypatch.setattr(type(win), "_start_compute",
lambda self: (dispatched.append(self.spin_angle.value()),
original_start(self))[1])
try:
win._load_file(str(path))
assert wait_until(lambda: win._sras is not None), "file loaded"
assert wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
for a in range(win._sras.n_angles))), \
"DC precompute finished"
with patch("sras_viewer.main_window.QFileDialog.getOpenFileNames",
return_value=([str(path)], "")):
win._on_batch_compute_row_avg()
assert wait_until(lambda: not win._job_running("batch"), 60000), "batch ran"
assert wait_until(lambda: win._sras is not None
and win._sras.version == 7), "file reloaded as v7"
pump(200)
assert win._sras.precomputed_row_avg_n == 6
assert "row-averaged n=6" in win.lbl_frame_warn.text(), win.lbl_frame_warn.text()
expected = compute.cached_rf_image(win._sras, 0, dc_threshold_mv=None,
apply_bg_sub=win.chk_bg_sub.isChecked(),
row_avg_n=6)
assert expected is not None, "the batch write left a readable row-averaged cache"
# The regression this batch action used to leave unfixed: viewing an
# angle afterward must show the stored row-averaged image directly,
# never fall through to a real (raw) recompute.
no_fft.clear()
dispatched.clear()
win.combo_channel.setCurrentIndex(CH1_IDX)
assert wait_until(lambda: win._current_ch == CH1_IDX), "CH1 displayed"
for a in range(win._sras.n_angles):
win.spin_angle.setValue(a)
pump(60)
assert np.allclose(win._current_image,
compute.cached_rf_image(
win._sras, a,
dc_threshold_mv=win.spin_threshold_mv.value(),
apply_bg_sub=win.chk_bg_sub.isChecked(),
row_avg_n=6), atol=1e-3), \
f"angle {a} shows the stored row-averaged image"
assert dispatched == [] and not no_fft, \
f"no recompute for row-averaged angles (jobs={dispatched}, fft={no_fft})"
finally:
win.close()
pump(300)
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Benchmark the FFT peak-search path: exact vs zoom, serial vs pooled.
Reports wall time, waveforms/s, CPU utilization (utime+stime over wall, in
cores), and verifies every variant against the exact reference image.
Usage:
python tools/bench_fft.py # synthetic, pads 1/8/40
python tools/bench_fft.py --pads 40 --spf 2500 --rows 8 --frames 1024
python tools/bench_fft.py --real /path/big.sras --real-rows 32 --pads 40
"""
import argparse
import resource
import sys
import tempfile
import time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import sras_compute as compute # noqa: E402
from sras_compute import compute_rf_image, set_fft_backend # noqa: E402
from sras_format import SrasFile # noqa: E402
import tools.make_test_sras as gen # noqa: E402
from tools.check_equivalence import row_slice # noqa: E402
def _timed(fn):
r0 = resource.getrusage(resource.RUSAGE_SELF)
t0 = time.perf_counter()
out = fn()
wall = time.perf_counter() - t0
r1 = resource.getrusage(resource.RUSAGE_SELF)
cpu = (r1.ru_utime - r0.ru_utime) + (r1.ru_stime - r0.ru_stime)
return out, wall, cpu / max(wall, 1e-9)
def bench(sras, pads, backends):
n_wf = sum(int(sras.n_rows[a]) * int(sras.n_frames[a])
for a in range(sras.n_angles))
spf = sras.samples_per_frame
print(f"{n_wf} waveforms x {spf} samples, {sras.n_angles} angle(s)")
print(f"{'pad':>4} {'backend':>8} {'variant':>16} {'wall':>9} "
f"{'wf/s':>10} {'util':>6} match")
for pad in pads:
n_fft = spf * pad if pad > 1 else None
for backend in backends:
set_fft_backend(backend)
def run(**kw):
imgs = [compute_rf_image(sras, a, dc_threshold_mv=None,
apply_bg_sub=True, n_fft=n_fft, **kw)
for a in range(sras.n_angles)]
return np.concatenate([i.ravel() for i in imgs])
ref, wall, util = _timed(lambda: run(exact=True))
rows = [("exact(serial)", ref, wall, util, True)]
for label, kw in (("zoom(serial)", dict(max_workers=1)),
("zoom(pool)", {})):
img, wall, util = _timed(lambda: run(**kw))
rows.append((label, img, wall, util, bool(np.array_equal(img, ref))))
for label, img, wall, util, ok in rows:
print(f"{pad:>4} {backend:>8} {label:>16} {wall:>8.2f}s "
f"{n_wf / wall:>10.0f} {util:>5.1f}x "
f"{'OK' if ok else 'MISMATCH'}")
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--pads", default="1,8,40",
help="comma-separated pad factors (default 1,8,40)")
p.add_argument("--spf", type=int, default=2500)
p.add_argument("--rows", type=int, default=8)
p.add_argument("--frames", type=int, default=1024)
p.add_argument("--backends", default=None,
help="comma-separated (default: scipy,pyfftw if available)")
p.add_argument("--real", help="path to a real .sras file")
p.add_argument("--real-rows", type=int, default=32,
help="rows of angle 0 to use from the real file")
args = p.parse_args()
pads = [int(x) for x in args.pads.split(",")]
if args.backends:
backends = args.backends.split(",")
else:
backends = ["scipy"] + (["pyfftw"] if compute.PYFFTW_AVAILABLE else [])
if args.real:
sras = row_slice(SrasFile(args.real), 0, args.real_rows)
sras.data = [sras.data[0]]
sras.n_angles = 1
bench(sras, pads, backends)
else:
with tempfile.TemporaryDirectory(prefix="sras_bench_") as tmp:
path = Path(tmp) / "bench.sras"
gen.write(path, n_angles=1, seed=0, samples_per_frame=args.spf,
geometry=[(args.rows, args.frames)])
bench(SrasFile(str(path)), pads, backends)
if __name__ == "__main__":
main()
+25 -28
View File
@@ -1,13 +1,9 @@
#!/usr/bin/env python3
"""Golden-output equivalence harness for the sras-viewer refactor.
"""Golden-output equivalence harness for compute-path refactors.
Computes a battery of DC / FFT / alignment outputs and prints a stable hash
for each. Run it on the pre-refactor commit to capture a baseline, then again
after the refactor and diff the two reports — every line must match.
Imports work against both the pre-refactor monolith (`sras_viewer`) and the
post-refactor split (`sras_format` + `sras_compute`), so the *same* script
produces both sides of the comparison.
for each. Run it before a refactor to capture a baseline, then again after
and diff the two reports — every line must match.
Hashes canonicalise to native little-endian float64 before hashing, so a
deliberate dtype/byte-order change that preserves values does not show up as
@@ -29,22 +25,10 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# --- Import shim: split modules if present, else the monolith --------------
try:
from sras_format import SrasFile, CH1_IDX, CH3_IDX, CH4_IDX, adc_to_mv
import sras_compute as C
_LAYOUT = "split"
except ImportError:
import sras_viewer as _V
from sras_viewer import SrasFile, CH1_IDX, CH3_IDX, CH4_IDX, adc_to_mv
C = _V
_LAYOUT = "monolith"
compute_dc_image = C.compute_dc_image
compute_rf_image = C.compute_rf_image
compute_alignment = C._compute_angle_alignment
apply_alignment = C.apply_alignment
from sras_format import SrasFile, CH3_IDX, CH4_IDX, adc_to_mv # noqa: E402
from sras_compute import ( # noqa: E402
apply_alignment, compute_angle_alignment, compute_dc_image, compute_rf_image,
)
import tools.make_test_sras as gen # noqa: E402
@@ -63,8 +47,8 @@ def row_slice(sras: SrasFile, angle_idx: int, n_rows: int) -> SrasFile:
view.n_rows[angle_idx] = n
view.data = list(sras.data)
view.data[angle_idx] = sras.data[angle_idx][:n]
view._y_pos_per_angle = list(sras._y_pos_per_angle)
view._y_pos_per_angle[angle_idx] = sras._y_pos_per_angle[angle_idx][:n]
view.y_pos_per_angle = list(sras.y_pos_per_angle)
view.y_pos_per_angle[angle_idx] = sras.y_pos_per_angle[angle_idx][:n]
return view
@@ -109,7 +93,7 @@ def check_file(path: Path, lines: list[str], tag: str,
for bg in (False, True):
if bg and s.background is None:
continue
for pad in (1, 2):
for pad in (1, 2, 4, 8, 40):
n_fft = s.samples_per_frame * pad if pad > 1 else None
for ti, thr in enumerate(thresholds):
img = compute_rf_image(s, a, dc_threshold_mv=thr,
@@ -135,7 +119,7 @@ def check_alignment(path: Path, lines: list[str], tag: str):
sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX],
sras.ch_yzero_mv[CH4_IDX])
thr = float(np.median(dc4))
res = compute_alignment(sras, 0, thr)
res = compute_angle_alignment(sras, 0, thr)
report(lines, f"[{tag}] align canvas_shape", str(res.canvas_shape))
report(lines, f"[{tag}] align canvas_origin",
f"{res.canvas_origin_mm[0]:.9g},{res.canvas_origin_mm[1]:.9g}")
@@ -165,7 +149,7 @@ def main():
help="directory for generated synthetic files")
args = p.parse_args()
lines = [f"# layout: {_LAYOUT}", f"# numpy: {np.__version__}"]
lines = [f"# numpy: {np.__version__}"]
scratch = Path(args.scratch)
synth = scratch / "equiv_synth.sras"
@@ -179,6 +163,19 @@ def main():
gen.write(synth_odd, n_angles=2, seed=7, samples_per_frame=37)
check_file(synth_odd, lines, "odd", angles=[0, 1], n_rows=None)
# A legacy v4 file exercises the uniform-geometry legacy layout through
# the same DC/FFT battery.
synth_v4 = scratch / "equiv_synth_v4.sras"
gen.write_legacy(synth_v4, version=4, n_angles=2, n_rows=6,
n_frames=14, samples_per_frame=48, seed=5)
check_file(synth_v4, lines, "v4", angles=[0, 1], n_rows=None)
# A big-endian int16 v6 file (real acquisitions are >i2; the other
# synthetics are int8).
synth_i16 = scratch / "equiv_synth_i16.sras"
gen.write(synth_i16, n_angles=2, seed=9, samples_per_frame=64, bps=2)
check_file(synth_i16, lines, "int16", angles=[0, 1], n_rows=None)
if args.real:
real = Path(args.real)
if real.exists():
+140 -8
View File
@@ -18,9 +18,12 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# 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
# Single source of truth for the byte layout: the reader's own constants.
# The byte *assembly* below stays independent, so a writer bug can't be
# masked by a matching reader bug.
from sras_format import HDR_FMT as HDR_FMT_LEGACY # noqa: E402
from sras_format import GEO_FMT_V6, HDR_FMT_V6 # noqa: E402
from sras_compute import _rotation_matrix as _rot # noqa: E402
# Per-angle (n_rows, n_frames) — deliberately different per angle so ragged
# geometry handling is actually exercised.
@@ -46,12 +49,12 @@ def _preamble(ymult_v: float, yoff_adc: float, yzero_v: float) -> bytes:
def build(n_angles: int, seed: int, samples_per_frame: int,
geometry: list[tuple[int, int]] | None = None) -> tuple[bytes, dict]:
geometry: list[tuple[int, int]] | None = None,
bps: int = 1) -> tuple[bytes, dict]:
rng = np.random.default_rng(seed)
src_geom = geometry or _GEOMETRY
geom = [src_geom[a % len(src_geom)] for a in range(n_angles)]
n_ch = 3
bps = 1
angles_deg = np.linspace(0.0, 60.0, n_angles, dtype=np.float32)
# Distinct calibration per channel so a swapped-channel bug is visible.
@@ -104,7 +107,9 @@ def build(n_angles: int, seed: int, samples_per_frame: int,
block[r, 1, f] = np.int8((a * 7 + r * 3 + f) % 100 - 50)
block[r, 2, f] = np.int8((a * 5 + r * 11 + f * 2) % 120 - 60)
waveforms.append(block)
out += block.tobytes()
# bps=2 stores the same values big-endian int16, exercising the
# reader's >i2 memmap path.
out += (block.astype(">i2") if bps == 2 else block).tobytes()
meta = {
"n_angles": n_angles,
@@ -123,12 +128,139 @@ def build(n_angles: int, seed: int, samples_per_frame: int,
def write(path: Path, n_angles: int = 3, seed: int = 0,
samples_per_frame: int = 64,
geometry: list[tuple[int, int]] | None = None) -> dict:
payload, meta = build(n_angles, seed, samples_per_frame, geometry)
geometry: list[tuple[int, int]] | None = None,
bps: int = 1) -> dict:
payload, meta = build(n_angles, seed, samples_per_frame, geometry, bps=bps)
path.write_bytes(payload)
return meta
# ---------------------------------------------------------------------------
# Rotating-sample scan: one shape, imaged at several known rotations
# ---------------------------------------------------------------------------
#
# The scan the angle-alignment path actually has to solve: every angle images
# the *same* sample at a different known rotation and offset, and a correct
# alignment stacks them all back into one shape. Two properties are
# deliberately hostile:
#
# * every angle gets a different window size and a different, meaningless
# stage x_start / y0 — alignment must ignore per-angle stage coordinates
# entirely, so any code that reads them will visibly fail here;
# * the pixel grid is strongly anisotropic (5 µm along x, 50 µm along y),
# like the real instrument, so any registration that rotates raw indices
# instead of millimetres shears the image and cannot converge.
_ROT_DX_MM = 0.005 # x pitch, from velocity/laser_freq below
_ROT_DY_MM = 0.05 # row spacing
_ROT_BG_MV = 4.0
_ROT_FG_MV = 160.0
# How far the sample sits from the rotation axis. Non-zero on purpose: on the
# real instrument every angle's scan window is centred on the rotation axis
# while the sample is not, so each scan sees the sample somewhere else along a
# circle. That offset is exactly what a wrong rotation pivot turns into a ring
# of scans instead of a stack, so a centred test sample would hide the bug.
_ROT_SAMPLE_OFFSET_MM = (0.55, 0.40)
def _sample_shape_mv(u: np.ndarray, v: np.ndarray) -> np.ndarray:
"""An asymmetric test sample in its own mm frame, chirally distinct at
every rotation (no 180° ambiguity) and with structure at several radii so
rotation is well determined."""
u = u - _ROT_SAMPLE_OFFSET_MM[0]
v = v - _ROT_SAMPLE_OFFSET_MM[1]
img = np.full(u.shape, _ROT_BG_MV, dtype=np.float32)
img[((u / 0.85) ** 2 + (v / 0.40) ** 2) <= 1.0] = _ROT_FG_MV # bar
img[(np.abs(u - 0.55) <= 0.22) & (np.abs(v - 0.62) <= 0.22)] = _ROT_FG_MV # nub
img[((u + 0.75) ** 2 + (v + 0.30) ** 2) <= 0.20 ** 2] = _ROT_FG_MV # dot
return img
def write_rotating(path: Path, n_angles: int = 5, samples_per_frame: int = 4,
seed: int = 0) -> dict:
"""Write a v6 file whose CH4 DC image is one sample seen at n_angles known
rotations, and return the ground truth each angle should register to.
``truth[a] = (rotation_deg, (shift_x_mm, shift_y_mm))`` is the rigid map
from angle *a*'s local mm (origin at its own array center) to angle 0's —
exactly what ``register_angle_to_reference`` is supposed to recover.
"""
rng = np.random.default_rng(seed)
n_ch, bps = 3, 1
cal = [(1.5625e-3, -87.04, 0.0), (2.0e-3, -60.0, 1.0e-3), (2.5e-3, -40.0, -2.0e-3)]
ymult_mv, yoff, yzero_mv = cal[2][0] * 1000, cal[2][1], cal[2][2] * 1000
stage_angles, geom, x_starts, y_starts, thetas, offsets = [], [], [], [], [], []
for a in range(n_angles):
stage = -37.0 * a # what the rotation stage reports
stage_angles.append(stage)
# The true image rotation is the negative of the stage's reported
# angle: the stage's positive sense is the opposite of math-positive
# (x toward y) in scan mm. Nothing may depend on knowing that — the
# registration search tries both signs.
thetas.append(-stage)
offsets.append((0.0, 0.0) if a == 0
else (float(rng.uniform(-0.3, 0.3)), float(rng.uniform(-0.3, 0.3))))
# A different window per angle, all centred on the same array center —
# the real instrument grows each angle's axis-aligned bounding box to
# cover the rotated ROI. Sized so the off-axis sample stays inside every
# window at every angle, keeping the expected result unambiguous.
geom.append((88 + 8 * a, 780 + 60 * a))
# Meaningless per-angle stage positions: correct alignment never reads
# them, so scattering them proves it.
x_starts.append(float(20.0 + rng.uniform(-6.0, 6.0)))
y_starts.append(float(30.0 + rng.uniform(-6.0, 6.0)))
out = bytearray()
out += struct.pack(
HDR_FMT_V6, b"SRAS", 6, n_angles,
x_starts[0], y_starts[0], 1.0, 1.0, _ROT_DY_MM,
_VELOCITY_MM_S, _VELOCITY_MM_S / _ROT_DX_MM, # velocity/freq -> 5 µm pitch
samples_per_frame, _SAMPLE_RATE_HZ, bps, n_ch,
)
out += np.array(stage_angles, dtype=">f4").tobytes()
for a, (n_rows, n_frames) in enumerate(geom):
out += struct.pack(GEO_FMT_V6, x_starts[a], 1.0, n_frames, n_rows)
for a, (n_rows, _) in enumerate(geom):
out += (y_starts[a] + np.arange(n_rows) * _ROT_DY_MM).astype(">f4").tobytes()
for ymult_v, yoff_a, yzero_v in cal:
p = _preamble(ymult_v, yoff_a, yzero_v)
out += struct.pack(">H", len(p)) + p
background = rng.integers(-8, 9, size=samples_per_frame, dtype=np.int8)
out += struct.pack(">I", samples_per_frame) + background.tobytes()
truth, dc4_images = {}, []
for a, (n_rows, n_frames) in enumerate(geom):
# Local mm of every pixel, measured from this angle's own array center.
lx = (np.arange(n_frames) - (n_frames - 1) / 2.0) * _ROT_DX_MM
ly = (np.arange(n_rows) - (n_rows - 1) / 2.0) * _ROT_DY_MM
gx, gy = np.meshgrid(lx, ly)
# local = R(theta) @ sample + offset, so sample = R(theta)^T @ (local - offset)
rel = np.stack([gx - offsets[a][0], gy - offsets[a][1]], axis=-1)
s = rel @ _rot(thetas[a]) # == rel @ R^T.T == R^T @ rel
dc4 = _sample_shape_mv(s[..., 0], s[..., 1])
dc4_images.append(dc4)
inv = _rot(-thetas[a])
truth[a] = (-thetas[a],
tuple(float(v) for v in -(inv @ np.array(offsets[a]))))
adc4 = np.clip(np.round((dc4 - yzero_mv) / ymult_mv + yoff), -128, 127).astype(np.int8)
block = np.zeros((n_rows, n_ch, n_frames, samples_per_frame), dtype=np.int8)
block[:, 2] = adc4[:, :, None] # CH4 carries the sample
block[:, 1] = 10 # CH3 flat
block[:, 0] = rng.integers(-40, 41, size=(n_rows, n_frames, samples_per_frame),
dtype=np.int8) # CH1 noise
out += block.tobytes()
path.write_bytes(bytes(out))
return {"n_angles": n_angles, "geometry": geom, "stage_angles_deg": stage_angles,
"truth": truth, "dc4_mv": dc4_images, "x_starts": x_starts,
"y_starts": y_starts, "dx_mm": _ROT_DX_MM, "dy_mm": _ROT_DY_MM}
def write_legacy(path: Path, version: int = 4, n_angles: int = 2,
n_rows: int = 4, n_frames: int = 10,
samples_per_frame: int = 32, seed: int = 0) -> dict:
-485
View File
@@ -1,485 +0,0 @@
#!/usr/bin/env python3
"""Headless GUI test: drives SrasViewerWindow through the real Qt widgets,
signals and worker threads under the offscreen platform plugin.
Covers the interactions a manual smoke test would: load, switch angles and
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
changes, angle alignment, manual angle alignment, aligned view, ROI
draw/move, and CSV export.
Usage: QT_QPA_PLATFORM=offscreen python tools/test_gui.py [file.sras]
"""
import json
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import numpy as np # noqa: E402
from PyQt6.QtCore import QEventLoop, Qt, QTimer # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from PyQt6.QtWidgets import QApplication, QMessageBox # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import sras_compute as compute # noqa: E402
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX # noqa: E402
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX # noqa: E402
import tools.make_test_sras as gen # noqa: E402
_failures: list[str] = []
def check(name: str, ok: bool, detail: str = ""):
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else ""))
if not ok:
_failures.append(name)
def pump(ms: int = 250):
"""Run the event loop for a while so queued signals and worker threads
make progress."""
loop = QEventLoop()
QTimer.singleShot(ms, loop.quit)
loop.exec()
def wait_until(pred, timeout_ms: int = 20000, step: int = 100) -> bool:
waited = 0
while waited < timeout_ms:
if pred():
return True
pump(step)
waited += step
return pred()
def main():
app = QApplication(sys.argv)
errors: list[str] = []
tmpdir = Path(tempfile.mkdtemp(prefix="sras_gui_"))
path = Path(sys.argv[1]) if len(sys.argv) > 1 else tmpdir / "gui.sras"
if len(sys.argv) <= 1:
gen.write(path, n_angles=4, seed=11, samples_per_frame=256)
print(f"\nloading {path.name}")
win = SrasViewerWindow()
win.show()
# Capture anything the app reports as an error via the status bar.
win.statusBar().messageChanged.connect(
lambda m: errors.append(m) if m and "error" in m.lower() else None)
win._load_file(str(path))
check("file loaded", wait_until(lambda: win._sras is not None))
s = win._sras
check("parsed as v6", s.version == 6, f"v{s.version}")
check("defaults to CH4", win.combo_channel.currentIndex() == CH4_IDX)
check("image displayed", win._current_image is not None)
check("angle spinbox ranges over all angles",
win.spin_angle.maximum() == s.n_angles - 1)
check("scan info populated",
win._info["Angles"].text() == f"Angles: {s.n_angles}",
win._info["Angles"].text())
print("\nbackground DC precompute (all angles)")
ok = wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
and (a, CH3_IDX) in win._dc_cache
for a in range(s.n_angles)))
check("every angle cached for CH3 and CH4", ok,
f"{len(win._dc_cache)} entries")
check("status label reports completion",
"ready for all angles" in win.lbl_dc_precompute.text(),
win.lbl_dc_precompute.text())
print("\nangle switching (DC, should be served from cache)")
for a in range(s.n_angles):
win.spin_angle.setValue(a)
win._on_view_changed()
pump(60)
expected = win._sras.image_shape(a)
check(f"angle {a} shows its own geometry {expected}",
win._current_image.shape == expected,
str(win._current_image.shape))
check("no compute job needed for cached DC angles",
not win._job_running("compute"))
print("\nchannel switching")
win.spin_angle.setValue(0)
win._on_view_changed()
pump(60)
win.combo_channel.setCurrentIndex(CH3_IDX)
check("CH3 displayed", wait_until(lambda: win._current_ch == CH3_IDX))
win.combo_channel.setCurrentIndex(CH1_IDX)
check("CH1 (FFT) computed", wait_until(
lambda: win._current_ch == CH1_IDX and not win._job_running("compute")))
check("FFT result cached", len(win._fft_cache) > 0, f"{len(win._fft_cache)} keys")
rf_img = win._current_image
check("FFT image is non-degenerate", len(np.unique(rf_img)) > 1,
f"{len(np.unique(rf_img))} unique values")
print("\nvelocity mode (pure post-multiply, no recompute)")
n_fft_before = len(win._fft_cache)
win.combo_channel.setCurrentIndex(VELOCITY_MODE_IDX)
check("velocity displayed", wait_until(
lambda: win._current_ch == VELOCITY_MODE_IDX and not win._job_running("compute")))
grating = win.spin_grating_um.value()
check("velocity == freq x grating",
np.allclose(win._current_image, rf_img * grating, atol=1e-3))
check("velocity reused the cached FFT", len(win._fft_cache) == n_fft_before,
f"{n_fft_before} -> {len(win._fft_cache)}")
check("grating spinbox visible in velocity mode", win.grp_velocity.isVisible())
print("\nthreshold change (genuine cache-key change)")
win.combo_channel.setCurrentIndex(CH1_IDX)
wait_until(lambda: not win._job_running("compute"))
dc4 = win._dc_cache[(0, CH4_IDX)]
win.spin_threshold_mv.setValue(float(np.median(dc4)))
win._on_threshold_changed()
check("recomputed at new threshold", wait_until(
lambda: not win._job_running("compute") and len(win._fft_cache) > n_fft_before))
check("masking zeroed some pixels",
int((win._current_image == 0).sum()) > 0,
f"{int((win._current_image == 0).sum())} of {win._current_image.size}")
print("\nbackground subtraction toggle")
n_before = len(win._fft_cache)
win.chk_bg_sub.setChecked(False)
check("recomputed without bg-sub", wait_until(
lambda: not win._job_running("compute") and len(win._fft_cache) > n_before))
win.chk_bg_sub.setChecked(True)
pump(200)
check("returning to bg-sub was a cache hit (no recompute)",
not win._job_running("compute"))
print("\nROI")
x = s.x_axis_mm(0)
y = s.y_positions_mm(0)
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]),
float(x[-2]), float(y[-2]))
win.image_canvas.set_roi(roi)
pump(120)
check("ROI registered", win.image_canvas.get_roi() is not None)
check("pixel count reported",
"pixels inside" in win.lbl_roi_npix.text()
and win.lbl_roi_npix.text() != "pixels inside: —",
win.lbl_roi_npix.text())
npix = int(win.lbl_roi_npix.text().split(":")[1])
check("ROI pixel count is plausible",
0 < npix <= win._current_image.size, f"{npix}")
check("Export ROI enabled", win.btn_export_roi.isEnabled())
csv_path = tmpdir / "roi.csv"
with patch("sras_viewer.QFileDialog.getSaveFileName",
return_value=(str(csv_path), "")):
win._on_export_roi_csv()
check("ROI CSV written", csv_path.exists())
if csv_path.exists():
body = [l for l in csv_path.read_text().splitlines() if not l.startswith("#")]
check("ROI CSV has header + one line per pixel",
len(body) == npix + 1, f"{len(body)} lines for {npix} pixels")
img_csv = tmpdir / "img.csv"
with patch("sras_viewer.QFileDialog.getSaveFileName",
return_value=(str(img_csv), "")):
win._on_export_csv()
check("image CSV written", img_csv.exists())
if img_csv.exists():
arr = np.loadtxt(img_csv, delimiter=",")
check("image CSV round-trips the displayed image",
arr.shape == win._current_image.shape
and np.allclose(arr, win._current_image, rtol=1e-5, atol=1e-4))
print("\nROI survives angle and channel switches")
win.spin_angle.setValue(1)
win._on_view_changed()
wait_until(lambda: not win._job_running("compute"))
check("ROI still present after angle switch",
win.image_canvas.get_roi() is not None)
win.combo_channel.setCurrentIndex(CH4_IDX)
wait_until(lambda: win._current_ch == CH4_IDX)
check("ROI still present after channel switch",
win.image_canvas.get_roi() is not None)
print("\nangle alignment (Fusion)")
win.spin_angle.setValue(0)
win._on_view_changed()
wait_until(lambda: not win._job_running("compute"))
check("alignment action enabled", win._alignment_act.isEnabled())
win._on_angle_alignment()
check("alignment completed", wait_until(
lambda: win._alignment_result is not None and not win._job_running("align"),
timeout_ms=60000))
if win._alignment_result is not None:
r = win._alignment_result
check("transform for every angle", len(r.per_angle) == s.n_angles)
check("canvas is at least as large as any single angle",
all(r.canvas_shape[0] >= int(s.n_rows[a])
and r.canvas_shape[1] >= int(s.n_frames[a])
for a in range(s.n_angles)), str(r.canvas_shape))
check("reference angle has zero shift",
r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0))
check("Aligned View auto-enabled and checked",
win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked())
pump(200)
check("displayed image is on the alignment canvas",
win.image_canvas._img_shape == r.canvas_shape,
f"{win.image_canvas._img_shape} vs {r.canvas_shape}")
win.chk_aligned_view.setChecked(False)
pump(200)
check("unchecking returns to the raw per-angle grid",
win.image_canvas._img_shape == s.image_shape(0),
str(win.image_canvas._img_shape))
print("\nmanual alignment (Fusion)")
check("manual alignment action enabled", win._manual_align_act.isEnabled())
# --- Alignment pivot is a signal-weighted centroid, not the raw bbox --
# center, and is independent of any DC threshold (so a threshold that
# happens to leave a real angle's binary mask empty can't silently
# degrade the pivot back to the bbox center).
corner_signal = np.zeros(s.image_shape(0), dtype=np.float32)
corner_signal[0, 0] = 1.0 # single spike -> weighted centroid is exact
expected_corner = (float(s.x_axis_mm(0)[0]), float(s.y_positions_mm(0)[0]))
centroid = compute._signal_centroid_mm(s, 0, corner_signal)
check("signal-weighted centroid of a single spike pixel is that pixel exactly",
np.allclose(centroid, expected_corner), f"{centroid} vs {expected_corner}")
bbox_center = compute._bbox_center_mm(s, 0)
check("signal centroid differs from the raw scan-window bbox center",
not np.allclose(centroid, bbox_center),
f"centroid {centroid} vs bbox center {bbox_center}")
# compute_pivot_points_mm should reuse a pre-computed dc4_mv dict rather
# than recomputing from the real DC4 image (which has no such spike and
# would give a different answer if silently recomputed).
reused_pivot = compute.compute_pivot_points_mm(s, dc4_mv={0: corner_signal})[0]
check("compute_pivot_points_mm reuses a pre-computed dc4_mv dict",
np.allclose(reused_pivot, expected_corner))
# A perfectly flat signal carries no information to weight by, so it
# falls back to the bbox center rather than producing a NaN/degenerate
# centroid.
flat_signal = np.full(s.image_shape(0), 5.0, dtype=np.float32)
flat_centroid = compute._signal_centroid_mm(s, 0, flat_signal)
check("a perfectly flat signal falls back to the bbox center",
np.allclose(flat_centroid, bbox_center))
# --- Rotation sign convention: negative of the raw angles_deg delta ----
check("_theta_deg negates the raw angles_deg delta (GR stage's positive "
"angle is the opposite rotational sense from this module's CCW "
"math convention)",
all(np.isclose(compute._theta_deg(s, a, 0),
-(float(s.angles_deg[a]) - float(s.angles_deg[0])))
for a in range(s.n_angles)))
# --- FFT phase correlation recovers a known synthetic pixel shift ------
rng = np.random.default_rng(0)
corr_ref = np.zeros((40, 50), dtype=np.float32)
corr_ref[10:25, 15:35] = 1.0
corr_ref += 0.05 * rng.standard_normal(corr_ref.shape).astype(np.float32)
corr_mov = np.roll(corr_ref, shift=(4, -7), axis=(0, 1))
dr, dc = compute._phase_correlate_shift(corr_ref, corr_mov)
check("phase correlation recovers the shift that aligns mov onto ref",
(dr, dc) == (-4, 7), f"got (dr, dc)={(dr, dc)}")
# --- Open: must NOT seed from the still-live automatic AlignmentResult --
# The automatic result's translation comes from FFT phase correlation --
# the very thing manual mode exists to work around -- so manual mode
# must start from identity (centroids coincide, zero shift) regardless
# of whatever the automatic run last computed. Only a previously *saved
# manual* alignment (sidecar) should ever seed this dialog.
win._on_manual_alignment()
check("dialog opened", win._manual_align_dialog is not None)
dlg = win._manual_align_dialog
check("mask prep needed no background worker (already DC-cached)",
not win._job_running("manual_align_masks"))
check("no manual sidecar yet -> dialog starts at identity, not the "
"automatic result",
all(dlg._angle_params[a] == compute.ManualAngleParams()
for a in range(s.n_angles)))
# --- Reference angle is locked -------------------------------------------
dlg.combo_active_angle.setCurrentIndex(dlg._ref_angle_idx)
pump(30)
before_ref = dlg._angle_params[dlg._ref_angle_idx]
dlg._on_nudge_translate(1, 0, False)
dlg._on_nudge_rotate(1, False)
check("reference angle group disabled", not dlg.grp_manual_adjust.isEnabled())
check("reference angle untouched by nudge attempts",
dlg._angle_params[dlg._ref_angle_idx] == before_ref)
# --- Nudging a real angle (fine + coarse, translate + rotate) -----------
active = 1 if s.n_angles > 1 else 0
dlg.combo_active_angle.setCurrentIndex(active)
pump(30)
before = dlg._angle_params[active].shift_mm
dlg._on_nudge_translate(1, 0, False) # fine +X
fine_step = dlg.spin_step_translate_mm.value()
check("fine translate nudge moved shift_x by exactly one fine step",
abs(dlg._angle_params[active].shift_mm[0] - (before[0] + fine_step)) < 1e-9)
before = dlg._angle_params[active].shift_mm
dlg._on_nudge_translate(0, -1, True) # coarse -Y
coarse_step = fine_step * dlg.spin_step_multiplier.value()
check("coarse translate nudge uses the multiplier",
abs(dlg._angle_params[active].shift_mm[1] - (before[1] - coarse_step)) < 1e-9)
before_rot = dlg._angle_params[active].rotation_deg
dlg._on_nudge_rotate(1, False)
check("rotate nudge changed rotation_deg",
dlg._angle_params[active].rotation_deg != before_rot)
check("preview canvas rebuilt for every angle after a rotation nudge",
len(dlg._preview_layers) == s.n_angles)
# --- Real key-event wiring (proves keyPressEvent -> signal -> slot) -----
before = dlg._angle_params[active].shift_mm
QTest.keyClick(dlg.canvas, Qt.Key.Key_Right)
check("a real Right-arrow key event nudged shift_x",
dlg._angle_params[active].shift_mm[0] > before[0])
# --- Auto De-rotate: rotation only, translation untouched ---------------
shift_before_derotate = dlg._angle_params[active].shift_mm
dlg._on_auto_derotate()
expected_theta = compute._theta_deg(s, active, dlg._ref_angle_idx)
check("auto de-rotate set the known analytic angle",
abs(dlg._angle_params[active].rotation_deg - expected_theta) < 1e-6)
check("auto de-rotate left translation untouched",
dlg._angle_params[active].shift_mm == shift_before_derotate)
check("reference angle stays identity after auto de-rotate",
dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0)
# --- Auto Cross-Correlate: rotation + FFT-correlated shift, backgrounded -
check("cross-correlate action enabled once masks are ready",
dlg.btn_auto_correlate.isEnabled())
dlg._on_auto_correlate()
check("auto cross-correlate completed", wait_until(
lambda: not win._job_running("manual_align_correlate"), timeout_ms=30000))
check("auto cross-correlate set the known analytic angle for every angle",
all(abs(dlg._angle_params[a].rotation_deg
- compute._theta_deg(s, a, dlg._ref_angle_idx)) < 1e-6
for a in range(s.n_angles) if a != dlg._ref_angle_idx))
check("auto cross-correlate reference angle stays identity",
dlg._angle_params[dlg._ref_angle_idx] == compute.ManualAngleParams())
check("auto cross-correlate re-enabled controls when done",
dlg.grp_correlate.isEnabled() and dlg.btn_save.isEnabled())
check("preview canvas rebuilt after cross-correlate",
len(dlg._preview_layers) == s.n_angles)
# The thresholded-mask option should also work end to end.
dlg.combo_correlate_source.setCurrentIndex(1) # thresholded mask
dlg._on_auto_correlate()
check("auto cross-correlate (thresholded-mask option) completed", wait_until(
lambda: not win._job_running("manual_align_correlate"), timeout_ms=30000))
# --- Save -----------------------------------------------------------------
dlg._on_save()
sidecar = compute.sidecar_path(s.path)
check("sidecar file written", sidecar.exists())
raw = json.loads(sidecar.read_text()) if sidecar.exists() else {}
check("sidecar schema_version is current",
raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION)
check("sidecar per_angle round-trips the dialog's resolved params",
all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg")
== dlg._angle_params[a].rotation_deg for a in range(s.n_angles)))
check("main window's alignment_result replaced by the manual build",
win._alignment_result is not None
and win._alignment_result.per_angle[active].rotation_deg
== dlg._angle_params[active].rotation_deg)
check("Aligned View auto-enabled after Save",
win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked())
# --- An old-schema sidecar (pre-pivot/sign fix) is treated as absent ------
stale = dict(raw)
stale["schema_version"] = compute._SIDECAR_SCHEMA_VERSION - 1
sidecar.write_text(json.dumps(stale))
check("a sidecar with an old schema_version is not loaded",
compute.load_manual_alignment(s) is None)
sidecar.write_text(json.dumps(raw)) # restore for the rest of this section
# --- Clear (with confirmation) --------------------------------------------
with patch("sras_viewer.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes):
dlg._on_clear()
check("sidecar file deleted", not sidecar.exists())
check("dialog params reset to identity",
all(dlg._angle_params[a] == compute.ManualAngleParams()
for a in range(s.n_angles)))
check("main window alignment_result cleared", win._alignment_result is None)
check("Aligned View disabled after Clear",
not win.chk_aligned_view.isEnabled() and not win.chk_aligned_view.isChecked())
dlg.close()
pump(150)
check("dialog reference released on close", win._manual_align_dialog is None)
# --- Sidecar auto-restore on next load ------------------------------------
win._on_manual_alignment()
dlg = win._manual_align_dialog
dlg.combo_active_angle.setCurrentIndex(active)
pump(30)
dlg._on_auto_derotate()
dlg._on_nudge_translate(1, 1, True)
saved_rotation = dlg._angle_params[active].rotation_deg
saved_shift = dlg._angle_params[active].shift_mm
dlg._on_save()
dlg.close()
pump(150)
old_sras_id = id(win._sras)
win._load_file(str(path)) # reload the same file fresh
check("file reloaded", wait_until(
lambda: win._sras is not None and id(win._sras) != old_sras_id))
s = win._sras
check("manual dialog force-closed by a reload", win._manual_align_dialog is None)
check("reload restores the saved manual alignment automatically",
win._alignment_result is not None)
if win._alignment_result is not None:
check("restored rotation matches what was saved",
abs(win._alignment_result.per_angle[active].rotation_deg
- saved_rotation) < 1e-9)
check("restored shift matches what was saved",
win._alignment_result.per_angle[active].shift_mm == saved_shift)
check("Aligned View auto-checked after restoring a saved alignment",
win.chk_aligned_view.isChecked())
print("\npixel inspector")
win.chk_aligned_view.setChecked(False)
pump(100)
win._on_pixel_clicked(0, 0)
pump(150)
check("waveform hint hidden after a click", win.lbl_wave_hint.isHidden())
win.combo_channel.setCurrentIndex(CH1_IDX)
wait_until(lambda: not win._job_running("compute"))
win._on_pixel_clicked(1, 1)
pump(150)
check("RF waveform panel rendered",
len(win.wave_canvas.ax_wave.lines) > 0,
f"{len(win.wave_canvas.ax_wave.lines)} lines")
print("\nshutdown")
win.close()
pump(400)
check("all background jobs released", len(win._jobs) == 0,
f"{list(win._jobs)}")
print()
unexpected = [e for e in errors if e]
if unexpected:
print(f"status-bar errors seen: {unexpected}")
_failures.append("status-bar errors")
if _failures:
print(f"{len(_failures)} FAILURE(S): " + ", ".join(_failures))
return 1
print("All GUI checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
-365
View File
@@ -1,365 +0,0 @@
#!/usr/bin/env python3
"""Behavioural tests for the sras-viewer refactor.
Covers what the golden-hash harness can't: the v6->v7 cache round-trip
(including block carry-forward), parallel-vs-serial identity, the no-mask
fast path, and the ROI bounding-box mask optimisation.
Usage: python tools/test_refactor.py [--scratch DIR]
"""
import argparse
import shutil
import sys
import tempfile
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import sras_compute as compute # noqa: E402
from sras_compute import ( # noqa: E402
cache_file, compute_dc_image, compute_rf_image, dc_image_mv,
)
from sras_format import CH3_IDX, CH4_IDX, SrasFile, adc_to_mv # noqa: E402
import tools.make_test_sras as gen # noqa: E402
_failures: list[str] = []
def check(name: str, ok: bool, detail: str = ""):
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" — {detail}" if detail else ""))
if not ok:
_failures.append(name)
def test_cache_roundtrip(scratch: Path):
"""v6 -> v7 for DC, then FFT, asserting the first block survives the
second write (the carry-forward path in write_v7_cache)."""
print("\ncache round-trip (v6 -> v7, both blocks)")
path = scratch / "roundtrip.sras"
gen.write(path, n_angles=3, seed=1, samples_per_frame=64)
src = SrasFile(str(path))
check("source is v6", src.version == 6, f"got v{src.version}")
expect_dc3 = [dc_image_mv(src, a, CH3_IDX) for a in range(src.n_angles)]
expect_dc4 = [dc_image_mv(src, a, CH4_IDX) for a in range(src.n_angles)]
expect_fft = [compute_rf_image(src, a, dc_threshold_mv=None, apply_bg_sub=True)
for a in range(src.n_angles)]
err = cache_file(str(path), "dc", True)
check("dc cache_file succeeded", err == "", err)
after_dc = SrasFile(str(path))
check("version flipped to 7", after_dc.version == 7, f"got v{after_dc.version}")
check("dc3 stored for every angle",
all(x is not None for x in after_dc.precomputed_dc3_mv))
check("dc3 values round-trip",
all(np.allclose(after_dc.precomputed_dc3_mv[a], expect_dc3[a], atol=1e-4)
for a in range(after_dc.n_angles)))
check("dc4 values round-trip",
all(np.allclose(after_dc.precomputed_dc4_mv[a], expect_dc4[a], atol=1e-4)
for a in range(after_dc.n_angles)))
check("no fft block yet",
all(x is None for x in after_dc.precomputed_freq_mhz))
check("cached images are native float32",
after_dc.precomputed_dc3_mv[0].dtype == np.float32
and after_dc.precomputed_dc3_mv[0].dtype.byteorder in ("=", "|"),
str(after_dc.precomputed_dc3_mv[0].dtype.byteorder))
check("cached images are writable",
after_dc.precomputed_dc3_mv[0].flags.writeable)
err = cache_file(str(path), "fft", True)
check("fft cache_file succeeded", err == "", err)
both = SrasFile(str(path))
check("fft stored for every angle",
all(x is not None for x in both.precomputed_freq_mhz))
check("fft values round-trip",
all(np.allclose(both.precomputed_freq_mhz[a], expect_fft[a], atol=1e-3)
for a in range(both.n_angles)))
check("DC block carried forward through the FFT write",
all(np.allclose(both.precomputed_dc3_mv[a], expect_dc3[a], atol=1e-4)
for a in range(both.n_angles)))
check("bg_sub flag persisted", both.precomputed_bg_sub is True)
# The fast path must reproduce a fresh compute, and masking must still
# apply on top of a cached (unmasked) image.
fresh = SrasFile(str(path))
fresh.precomputed_freq_mhz = [None] * fresh.n_angles
dc4 = dc_image_mv(both, 0, CH4_IDX)
thr = float(np.median(dc4))
check("cached fast path == fresh compute (unmasked)",
np.allclose(compute_rf_image(both, 0, dc_threshold_mv=None, apply_bg_sub=True),
compute_rf_image(fresh, 0, dc_threshold_mv=None, apply_bg_sub=True),
atol=1e-3))
check("cached fast path == fresh compute (masked)",
np.allclose(compute_rf_image(both, 0, dc_threshold_mv=thr, apply_bg_sub=True),
compute_rf_image(fresh, 0, dc_threshold_mv=thr, apply_bg_sub=True),
atol=1e-3))
# Waveform data must be byte-identical to the pre-cache file.
orig = scratch / "roundtrip_orig.sras"
gen.write(orig, n_angles=3, seed=1, samples_per_frame=64)
o, n = SrasFile(str(orig)), SrasFile(str(path))
check("waveform data untouched by the cache write",
all(np.array_equal(np.asarray(o.data[a]), np.asarray(n.data[a]))
for a in range(o.n_angles)))
def test_partial_v7_cache(scratch: Path):
"""Only some angles cached: uncached angles must compute, not read zeros.
This is the v5 bug the ragged normalisation fixed, checked via v7."""
print("\npartial cache (only some angles stored)")
path = scratch / "partial.sras"
gen.write(path, n_angles=3, seed=2, samples_per_frame=64)
src = SrasFile(str(path))
expected = [compute_rf_image(src, a, dc_threshold_mv=None, apply_bg_sub=True)
for a in range(src.n_angles)]
partial = [expected[0], None, expected[2]] # angle 1 deliberately absent
src.write_v7_cache(new_freq_mhz=partial, new_bg_sub=True)
reread = SrasFile(str(path))
check("angle 1 is not cached", reread.precomputed_freq_mhz[1] is None)
check("angles 0 and 2 are cached",
reread.precomputed_freq_mhz[0] is not None
and reread.precomputed_freq_mhz[2] is not None)
img1 = compute_rf_image(reread, 1, dc_threshold_mv=None, apply_bg_sub=True)
check("uncached angle computes rather than returning zeros",
np.any(img1 != 0) and np.allclose(img1, expected[1], atol=1e-3))
def test_parallel_identity(scratch: Path):
"""Forcing 1 worker vs many must give identical output — catches
chunk-boundary and race bugs."""
print("\nparallel vs serial identity")
path = scratch / "parallel.sras"
# Many rows, so the row loop actually splits into several chunks.
n_rows, n_frames, spf = 48, 9, 256
gen.write(path, n_angles=1, seed=3, samples_per_frame=spf,
geometry=[(n_rows, n_frames)])
sras = SrasFile(str(path))
saved_budget, saved_workers = compute._TOTAL_BYTES_BUDGET, compute._MAX_WORKERS
try:
# Shrink the budget so chunk_rows collapses to 1 and every row is
# its own chunk — the worst case for boundary bugs.
compute._TOTAL_BYTES_BUDGET = 8 * n_frames * spf * 4
compute._MAX_WORKERS = 1
dc_serial = compute_dc_image(sras, 0, CH4_IDX)
rf_serial = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
dc4 = adc_to_mv(dc_serial, *sras.cal(CH4_IDX))
thr = float(np.median(dc4))
rf_masked_serial = compute_rf_image(sras, 0, dc_threshold_mv=thr,
apply_bg_sub=True)
chunk_rows, n_workers = compute._plan_chunks(
n_rows, n_frames, spf, live_multiplier=compute._FFT_LIVE_MULTIPLIER)
check("serial plan uses 1 worker", n_workers == 1, f"chunk_rows={chunk_rows}")
check("work actually splits into multiple chunks", chunk_rows < n_rows,
f"chunk_rows={chunk_rows} of {n_rows} rows")
compute._MAX_WORKERS = 8
chunk_rows, n_workers = compute._plan_chunks(
n_rows, n_frames, spf, live_multiplier=compute._FFT_LIVE_MULTIPLIER)
check("parallel plan uses >1 worker", n_workers > 1,
f"chunk_rows={chunk_rows} workers={n_workers}")
dc_par = compute_dc_image(sras, 0, CH4_IDX)
rf_par = compute_rf_image(sras, 0, dc_threshold_mv=None, apply_bg_sub=True)
rf_masked_par = compute_rf_image(sras, 0, dc_threshold_mv=thr, apply_bg_sub=True)
check("dc image identical", np.array_equal(dc_serial, dc_par))
check("rf image identical (unmasked)", np.array_equal(rf_serial, rf_par))
check("rf image identical (masked)",
np.array_equal(rf_masked_serial, rf_masked_par))
finally:
compute._TOTAL_BYTES_BUDGET, compute._MAX_WORKERS = saved_budget, saved_workers
def test_nomask_equals_low_threshold(scratch: Path):
"""dc_threshold_mv=None must equal a threshold below every pixel, while
skipping the CH4 read."""
print("\nno-mask path")
path = scratch / "nomask.sras"
gen.write(path, n_angles=2, seed=4, samples_per_frame=128)
sras = SrasFile(str(path))
for a in range(sras.n_angles):
none_img = compute_rf_image(sras, a, dc_threshold_mv=None, apply_bg_sub=True)
low_img = compute_rf_image(sras, a, dc_threshold_mv=-1e9, apply_bg_sub=True)
check(f"angle {a}: None == -1e9 threshold",
np.array_equal(none_img, low_img))
check(f"angle {a}: image is non-degenerate",
len(np.unique(none_img)) > 1, f"{len(np.unique(none_img))} unique")
def test_roi_mask():
"""The bbox-restricted mask must equal a full-grid point-in-polygon test."""
print("\nROI mask (bbox fast path vs full grid)")
from matplotlib.path import Path as MplPath
from sras_viewer import RoiQuad
rng = np.random.default_rng(0)
x = np.linspace(-2.0, 3.0, 137)
y = np.linspace(1.0, 4.0, 91)
cases = {
"axis-aligned rect": np.array([[0.0, 1.5], [1.0, 1.5], [1.0, 3.0], [0.0, 3.0]]),
"skewed quad": np.array([[-0.5, 1.2], [1.7, 1.9], [1.2, 3.4], [-1.0, 2.6]]),
"entirely outside": np.array([[8.0, 8.0], [9.0, 8.0], [9.0, 9.0], [8.0, 9.0]]),
"covers whole grid": np.array([[-9.0, -9.0], [9.0, -9.0], [9.0, 9.0], [-9.0, 9.0]]),
"straddles left edge": np.array([[-4.0, 2.0], [0.5, 2.0], [0.5, 3.0], [-4.0, 3.0]]),
}
for _ in range(5):
cases[f"random {_}"] = rng.uniform([-2.5, 0.5], [3.5, 4.5], size=(4, 2))
for name, pts in cases.items():
roi = RoiQuad(pts)
fast = roi.mask_for_grid(x, y)
X, Y = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
slow = MplPath(pts).contains_points(
np.column_stack([X.ravel(), Y.ravel()])).reshape(X.shape)
check(f"{name} ({int(slow.sum())} px inside)", np.array_equal(fast, slow))
# Descending y axis (images are stored top-down in some scans).
roi = RoiQuad(cases["skewed quad"])
y_desc = y[::-1]
fast = roi.mask_for_grid(x, y_desc)
X, Y = np.meshgrid(x.astype(np.float64), y_desc.astype(np.float64))
slow = MplPath(cases["skewed quad"]).contains_points(
np.column_stack([X.ravel(), Y.ravel()])).reshape(X.shape)
check("descending y axis", np.array_equal(fast, slow))
def test_legacy_parse_and_average(scratch: Path):
"""v2-v4 parsing plus the sras_average.py rewrite (which now streams via
SrasFile rather than slurping the whole file)."""
import subprocess
print("\nlegacy formats (v2-v4) and sras_average")
repo = Path(__file__).resolve().parent.parent
for version in (2, 3, 4):
path = scratch / f"legacy_v{version}.sras"
meta = gen.write_legacy(path, version=version, n_angles=2, n_rows=4,
n_frames=12, samples_per_frame=32, seed=version)
s = SrasFile(str(path))
check(f"v{version} parses", s.version == version, f"got v{s.version}")
check(f"v{version} geometry uniform across angles",
list(s.n_rows) == [4, 4] and list(s.n_frames) == [12, 12],
f"rows={list(s.n_rows)} frames={list(s.n_frames)}")
check(f"v{version} waveform data matches what was written",
all(np.array_equal(np.asarray(s.data[a]), meta["data"][a])
for a in range(s.n_angles)))
check(f"v{version} background {'present' if version >= 4 else 'absent'}",
(s.background is not None) == (version >= 4))
check(f"v{version} precomputed stores are ragged lists",
isinstance(s.precomputed_freq_mhz, list)
and len(s.precomputed_freq_mhz) == s.n_angles)
# DC image must equal a direct mean of the known input.
expect = meta["data"][0][:, CH3_IDX, :, :].astype(np.float64).mean(axis=-1)
check(f"v{version} DC image equals a direct mean",
np.allclose(compute_dc_image(s, 0, CH3_IDX), expect, atol=1e-3))
src = scratch / "legacy_v4.sras"
meta = gen.write_legacy(src, version=4, n_angles=2, n_rows=4, n_frames=12,
samples_per_frame=32, seed=4)
dst = scratch / "legacy_v4_avg.sras"
if dst.exists():
dst.unlink()
proc = subprocess.run(
[sys.executable, str(repo / "sras_average.py"), str(src), str(dst), "--n", "4"],
capture_output=True, text=True, cwd=repo)
check("sras_average ran", proc.returncode == 0,
(proc.stderr or proc.stdout).strip()[-200:])
if dst.exists():
avg = SrasFile(str(dst))
check("averaged file parses", avg.version == 4)
check("frame count divided by 4",
list(avg.n_frames) == [3, 3], f"{list(avg.n_frames)}")
check("angles/rows/channels unchanged",
avg.n_angles == 2 and list(avg.n_rows) == [4, 4]
and avg.n_channels == meta["n_channels"])
check("calibration preserved",
np.allclose(avg.ch_ymult_mv, SrasFile(str(src)).ch_ymult_mv))
check("background preserved",
np.array_equal(avg.background, SrasFile(str(src)).background))
src_data = meta["data"]
# 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",
np.array_equal(np.asarray(avg.data[0])[:, :, 0, :], expect0))
# Remainder handling: 12 frames / 5 -> 2 full groups + 1 partial.
dst2 = scratch / "legacy_v4_avg5.sras"
proc2 = subprocess.run([sys.executable, str(repo / "sras_average.py"),
str(src), str(dst2), "--n", "5"],
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():
check("partial trailing group kept by default",
list(SrasFile(str(dst2)).n_frames) == [3, 3],
f"{list(SrasFile(str(dst2)).n_frames)}")
dst3 = scratch / "legacy_v4_avg5d.sras"
proc3 = subprocess.run([sys.executable, str(repo / "sras_average.py"),
str(src), str(dst3), "--n", "5", "--discard-remainder"],
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():
check("--discard-remainder drops the partial group",
list(SrasFile(str(dst3)).n_frames) == [2, 2],
f"{list(SrasFile(str(dst3)).n_frames)}")
def test_unsupported_version_reported(scratch: Path):
"""cache_file must report, not raise, for a file it can't handle."""
print("\nerror reporting")
bogus = scratch / "bogus.sras"
bogus.write_bytes(b"SRAS" + bytes([99]) + b"\x00" * 200)
err = cache_file(str(bogus), "dc", True)
check("bad version returns an error string", bool(err), err)
missing = cache_file(str(scratch / "does_not_exist.sras"), "dc", True)
check("missing file returns an error string", bool(missing), missing)
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--scratch")
args = p.parse_args()
tmp = None
if args.scratch:
scratch = Path(args.scratch)
scratch.mkdir(parents=True, exist_ok=True)
else:
tmp = tempfile.mkdtemp(prefix="sras_test_")
scratch = Path(tmp)
try:
test_cache_roundtrip(scratch)
test_partial_v7_cache(scratch)
test_parallel_identity(scratch)
test_nomask_equals_low_threshold(scratch)
test_roi_mask()
test_legacy_parse_and_average(scratch)
test_unsupported_version_reported(scratch)
finally:
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
print()
if _failures:
print(f"{len(_failures)} FAILURE(S): " + ", ".join(_failures))
sys.exit(1)
print("All checks passed.")
if __name__ == "__main__":
main()