Compare commits
4 Commits
3989c2a1b8
...
c30c8b1815
| Author | SHA1 | Date | |
|---|---|---|---|
| c30c8b1815 | |||
| 8348ad313c | |||
| f40c965b74 | |||
| 6d0e30b9ce |
+180
@@ -134,6 +134,52 @@ 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
|
`scan_format.md`'s Cache Tail / CACH tail version history sections for the
|
||||||
on-disk `row_avg_n` field this depends on.
|
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.
|
||||||
|
|
||||||
|
### 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`)
|
## Angle alignment coordinate frames (`sras_compute.py`)
|
||||||
|
|
||||||
Alignment puts every angle's images onto one shared, zero-padded pixel grid
|
Alignment puts every angle's images onto one shared, zero-padded pixel grid
|
||||||
@@ -171,6 +217,140 @@ 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,
|
rotation/shift → that angle's own local mm → that angle's own raw index,
|
||||||
matching the output→input convention `scipy.ndimage.affine_transform` wants.
|
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`)
|
## Manual-alignment sidecar (`sras_compute.py`)
|
||||||
|
|
||||||
`<name>.sras.align.json` lives next to the scan file. The code lives in
|
`<name>.sras.align.json` lives next to the scan file. The code lives in
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ py-modules = [
|
|||||||
"sras_format",
|
"sras_format",
|
||||||
"sras_compute",
|
"sras_compute",
|
||||||
"sras_workers",
|
"sras_workers",
|
||||||
|
"sras_align_export",
|
||||||
"sras_average",
|
"sras_average",
|
||||||
"sras_edit_scans",
|
"sras_edit_scans",
|
||||||
]
|
]
|
||||||
|
|||||||
+67
-12
@@ -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 —
|
> 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
|
> 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.
|
> 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 |
|
| Offset | Size | Type | Field | Description |
|
||||||
|--------|------|------|-------|-------------|
|
|--------|------|------|-------|-------------|
|
||||||
| 0 | 4 | `char[4]` | `cach_magic` | `CACH` (ASCII). Missing/wrong magic → treat file as having no cache. |
|
| 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 `2`; readers also accept `1` (a `1` tail predates row-averaged FFT caching — 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). |
|
| 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. |
|
| 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`)
|
### DC block `SDCB` (present iff `block_flags & 0x01`)
|
||||||
@@ -251,18 +286,27 @@ value, same as v5's `PREC` section.
|
|||||||
|
|
||||||
Block header layout depends on `cach_version`:
|
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` 1**: 7 bytes, format `">4sBH"` — magic, flags, n_stored.
|
||||||
- **`cach_version` 2**: 8 bytes, format `">4sBHB"` — magic, flags, n_stored,
|
- **`cach_version` 2**: 8 bytes, format `">4sBHB"` — + `row_avg_n`.
|
||||||
`row_avg_n`. Always written by current code; a `cach_version` 1 tail (no
|
- **`cach_version` 3**: 10 bytes, format `">4sBHBH"` — + `pad_factor`.
|
||||||
trailing byte) is still read, with `row_avg_n` taken as `0` for every
|
Always written by current code.
|
||||||
entry it stores.
|
|
||||||
|
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 |
|
| Offset (rel) | Size | Type | Field | Description |
|
||||||
|--------------|------|------|-------|-------------|
|
|--------------|------|------|-------|-------------|
|
||||||
| 0 | 4 | `char[4]` | `magic` | `SFFT` |
|
| 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. 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. |
|
| 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 |
|
| 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`. |
|
| 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:
|
followed by `n_stored` entries, each:
|
||||||
|
|
||||||
@@ -293,13 +337,18 @@ size. Readers still apply their own live DC4 threshold at display time
|
|||||||
exactly as for a raw store, using whatever mask they currently have.
|
exactly as for a raw store, using whatever mask they currently have.
|
||||||
|
|
||||||
Readers must fall back to real-time FFT computation (ignoring stored
|
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
|
`peak_freq_mhz`) whenever the store's recorded provenance doesn't match what
|
||||||
gating is active, zero-padding (`n_fft ≠ samples_per_frame`) is requested,
|
the reader is asking for: time-domain gating is active, the reader's
|
||||||
the reader's background-subtraction setting doesn't match
|
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
|
`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
|
the stored value exactly. A raw request must never be served a row-averaged
|
||||||
row-averaged store, or vice versa, and a request at one window size must
|
store, or vice versa; a request at one row-averaging window size must never
|
||||||
never be served a store at another.
|
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
|
### In-place write ordering
|
||||||
|
|
||||||
@@ -324,6 +373,12 @@ which has stayed `7` since the Cache Tail was introduced — this is the inner
|
|||||||
|--------------|--------|
|
|--------------|--------|
|
||||||
| 1 | Initial Cache Tail: `SDCB` (DC) and `SFFT` (FFT, 7-byte header) blocks. |
|
| 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. |
|
| 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+304
-71
@@ -18,7 +18,8 @@ import numpy as np
|
|||||||
import scipy.fft as scipy_fft
|
import scipy.fft as scipy_fft
|
||||||
import scipy.ndimage as scipy_ndimage
|
import scipy.ndimage as scipy_ndimage
|
||||||
|
|
||||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile, adc_to_mv
|
from sras_format import (CH1_IDX, CH3_IDX, CH4_IDX, MAX_PAD_FACTOR, SrasFile,
|
||||||
|
adc_to_mv)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# FFT backend
|
# FFT backend
|
||||||
@@ -284,6 +285,13 @@ _TOTAL_BYTES_BUDGET = int(os.environ.get("SRAS_MEM_BUDGET_MB", 1024)) * 1024 * 1
|
|||||||
_CHUNK_ROWS_MAX = 32 # cap for small scans (original behavior)
|
_CHUNK_ROWS_MAX = 32 # cap for small scans (original behavior)
|
||||||
_MAX_WORKERS = int(os.environ.get("SRAS_MAX_WORKERS", 0)) or (os.cpu_count() or 4)
|
_MAX_WORKERS = int(os.environ.get("SRAS_MAX_WORKERS", 0)) or (os.cpu_count() or 4)
|
||||||
|
|
||||||
|
def memory_budget_bytes() -> int:
|
||||||
|
"""The module-wide ceiling on concurrently-live working buffers, for
|
||||||
|
callers outside this module that read the same data (the aligned exporter
|
||||||
|
sizes its source-band reader against it)."""
|
||||||
|
return _TOTAL_BYTES_BUDGET
|
||||||
|
|
||||||
|
|
||||||
def _chunk_rows_for(n_frames: int, samples_per_frame: int,
|
def _chunk_rows_for(n_frames: int, samples_per_frame: int,
|
||||||
budget: int = _TOTAL_BYTES_BUDGET) -> int:
|
budget: int = _TOTAL_BYTES_BUDGET) -> int:
|
||||||
bytes_per_row = max(1, n_frames * samples_per_frame * 4) # float32
|
bytes_per_row = max(1, n_frames * samples_per_frame * 4) # float32
|
||||||
@@ -482,6 +490,53 @@ def _row_average_waveforms(masked_waves: np.ndarray, valid: np.ndarray,
|
|||||||
return num / den_safe[:, None]
|
return num / den_safe[:, None]
|
||||||
|
|
||||||
|
|
||||||
|
def pad_factor_for(sras: SrasFile, n_fft: int | None) -> int:
|
||||||
|
"""The integer zero-padding factor an *n_fft* request represents, or 0
|
||||||
|
if it represents none — i.e. it is not a whole multiple of this file's
|
||||||
|
samples_per_frame, so no stored image (which only ever records an
|
||||||
|
integer factor) can answer it.
|
||||||
|
|
||||||
|
0 rather than None so callers can compare it straight against
|
||||||
|
``sras.precomputed_pad_factor``, which is never 0.
|
||||||
|
"""
|
||||||
|
if n_fft is None:
|
||||||
|
return 1
|
||||||
|
spf = sras.samples_per_frame
|
||||||
|
if spf <= 0 or n_fft % spf:
|
||||||
|
return 0
|
||||||
|
return max(1, n_fft // spf)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_mismatch_reasons(sras: SrasFile, *, n_fft: int | None,
|
||||||
|
apply_bg_sub: bool, row_avg_n: int) -> list[str]:
|
||||||
|
"""Why this file's stored FFT images can't answer a request, as human-
|
||||||
|
readable phrases; empty means they can.
|
||||||
|
|
||||||
|
Padding, background subtraction and row-averaging are all baked into
|
||||||
|
the stored numbers, so a request that differs in any of them has to go
|
||||||
|
back through a real FFT. This is the single accept rule: cached_rf_image
|
||||||
|
serves a stored image iff this returns nothing, and callers that want to
|
||||||
|
*explain* the miss (rather than silently recompute) format these same
|
||||||
|
strings, so the two can't drift apart.
|
||||||
|
"""
|
||||||
|
reasons = []
|
||||||
|
pad = pad_factor_for(sras, n_fft)
|
||||||
|
if pad != sras.precomputed_pad_factor:
|
||||||
|
want = f"pad {pad}x" if pad else "a ragged n_fft"
|
||||||
|
reasons.append(f"stored at pad {sras.precomputed_pad_factor}x, "
|
||||||
|
f"requested at {want}")
|
||||||
|
if sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None):
|
||||||
|
reasons.append("stored with background subtraction "
|
||||||
|
f"{'on' if sras.precomputed_bg_sub else 'off'}")
|
||||||
|
if sras.precomputed_row_avg_n != row_avg_n:
|
||||||
|
have = (f"row-averaged (n={sras.precomputed_row_avg_n})"
|
||||||
|
if sras.precomputed_row_avg_n else "raw per-pixel")
|
||||||
|
want = (f"row-averaged (n={row_avg_n})"
|
||||||
|
if row_avg_n else "raw per-pixel")
|
||||||
|
reasons.append(f"stored {have}, requested {want}")
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
def cached_rf_image(sras: SrasFile, angle_idx: int,
|
def cached_rf_image(sras: SrasFile, angle_idx: int,
|
||||||
dc_threshold_mv: float | None,
|
dc_threshold_mv: float | None,
|
||||||
apply_bg_sub: bool = True,
|
apply_bg_sub: bool = True,
|
||||||
@@ -495,13 +550,21 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
|
|||||||
real FFT).
|
real FFT).
|
||||||
|
|
||||||
A stored image stands in only when *all* hold: this angle actually has
|
A stored image stands in only when *all* hold: this angle actually has
|
||||||
a stored image (v5 PREC or v7 CACH); no custom zero-padding is
|
a stored image (v5 PREC or v7 CACH); the requested zero-padding matches
|
||||||
requested (n_fft is None) — the store is always natural-resolution;
|
what the store was computed at (see below); the stored bg-sub flag
|
||||||
the stored bg-sub flag matches what the caller wants; and
|
matches what the caller wants; and sras.precomputed_row_avg_n ==
|
||||||
sras.precomputed_row_avg_n == row_avg_n exactly (0 == "raw") — this
|
row_avg_n exactly (0 == "raw") — this last check is what stops a raw
|
||||||
last check is what stops a raw request from ever being silently served
|
request from ever being silently served a row-averaged image, or vice
|
||||||
a row-averaged image, or vice versa, or a request at one window size
|
versa, or a request at one window size being served a cache stored at a
|
||||||
being served a cache stored at a different one.
|
different one.
|
||||||
|
|
||||||
|
Padding is checked the same way, and for the same reason: a padded FFT
|
||||||
|
interpolates between the natural bins, so it resolves genuinely
|
||||||
|
different peak frequencies. *n_fft* of None means natural resolution,
|
||||||
|
i.e. pad 1; anything else must be an exact whole multiple of
|
||||||
|
samples_per_frame equal to sras.precomputed_pad_factor. A ragged n_fft
|
||||||
|
that is not such a multiple can never match a stored image, since the
|
||||||
|
store only ever records an integer pad factor.
|
||||||
|
|
||||||
If *allow_dc_recompute* is False and no DC4 image is already cached or
|
If *allow_dc_recompute* is False and no DC4 image is already cached or
|
||||||
supplied via *dc4_mv*, applying the mask would mean reading a whole
|
supplied via *dc4_mv*, applying the mask would mean reading a whole
|
||||||
@@ -510,15 +573,15 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
|
|||||||
fall through to a real compute rather than block.
|
fall through to a real compute rather than block.
|
||||||
"""
|
"""
|
||||||
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
cached_freq = sras.precomputed_freq_mhz[angle_idx]
|
||||||
if (cached_freq is None
|
if cached_freq is None or cache_mismatch_reasons(
|
||||||
or n_fft is not None
|
sras, n_fft=n_fft, apply_bg_sub=apply_bg_sub, row_avg_n=row_avg_n):
|
||||||
or sras.precomputed_bg_sub != (apply_bg_sub and sras.background is not None)
|
|
||||||
or sras.precomputed_row_avg_n != row_avg_n):
|
|
||||||
return None
|
return None
|
||||||
freq_img = cached_freq.copy()
|
dc4_img = None
|
||||||
if dc_threshold_mv is not None:
|
if dc_threshold_mv is not None:
|
||||||
# DC4 mask, in priority order: already-cached DC block, caller-
|
# DC4 mask, in priority order: already-cached DC block, caller-
|
||||||
# supplied image, or a fresh (cheap — no FFT) recompute.
|
# supplied image, or a fresh (cheap — no FFT) recompute. Resolved
|
||||||
|
# before the copy below so the allow_dc_recompute bail-out doesn't
|
||||||
|
# allocate a full image it is about to throw away.
|
||||||
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
|
dc4_img = sras.cached_dc_mv(angle_idx, CH4_IDX)
|
||||||
if dc4_img is None:
|
if dc4_img is None:
|
||||||
if dc4_mv is not None:
|
if dc4_mv is not None:
|
||||||
@@ -528,6 +591,8 @@ def cached_rf_image(sras: SrasFile, angle_idx: int,
|
|||||||
*sras.cal(CH4_IDX))
|
*sras.cal(CH4_IDX))
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
freq_img = cached_freq.copy()
|
||||||
|
if dc4_img is not None:
|
||||||
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
freq_img[dc4_img < dc_threshold_mv] = 0.0
|
||||||
return freq_img
|
return freq_img
|
||||||
|
|
||||||
@@ -723,6 +788,7 @@ def compute_rf_image(sras: SrasFile, angle_idx: int,
|
|||||||
|
|
||||||
def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
||||||
fft_backend: str = "scipy", max_workers: int = 0,
|
fft_backend: str = "scipy", max_workers: int = 0,
|
||||||
|
pad_factor: int = 1,
|
||||||
dc_threshold_mv: float | None = None,
|
dc_threshold_mv: float | None = None,
|
||||||
row_avg_n: int = 0) -> str:
|
row_avg_n: int = 0) -> str:
|
||||||
"""Compute and store DC or FFT images for every angle of one file,
|
"""Compute and store DC or FFT images for every angle of one file,
|
||||||
@@ -732,21 +798,30 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
|||||||
FFT backend and worker cap are passed explicitly because module globals
|
FFT backend and worker cap are passed explicitly because module globals
|
||||||
do not survive a spawn.
|
do not survive a spawn.
|
||||||
|
|
||||||
mode is "dc", "fft" (raw per-pixel FFT, natural-resolution, unmasked —
|
mode is "dc", "fft" (raw per-pixel FFT, unmasked — masking applied at
|
||||||
masking applied at display time), or "fft_rowavg" (same-row,
|
display time), or "fft_rowavg" (same-row, distance-weighted CH1
|
||||||
distance-weighted CH1 averaging before the FFT — see compute_rf_image's
|
averaging before the FFT — see compute_rf_image's row_avg_n).
|
||||||
row_avg_n). fft_rowavg needs *dc_threshold_mv* up front, unlike plain
|
fft_rowavg needs *dc_threshold_mv* up front, unlike plain "fft":
|
||||||
"fft": neighbor validity is baked into the stored numbers, so it can't
|
neighbor validity is baked into the stored numbers, so it can't be
|
||||||
be deferred to display time the way plain masking can.
|
deferred to display time the way plain masking can.
|
||||||
|
|
||||||
The stored FFT cache is always natural-resolution (pad 1): the v7 SFFT
|
*pad_factor* is the zero-padding factor to resolve the peaks at: 1 (the
|
||||||
block records no pad factor, and padded views compute live fast enough
|
default) is natural resolution, n_fft == samples_per_frame. It is
|
||||||
(see _peak_bins_zoom) that caching them is not worth a format change.
|
recorded in the SFFT block so a reader knows which views the stored
|
||||||
|
numbers answer for — and it has to be the pad the viewer is *actually*
|
||||||
|
using, since a pad-1 cache is dead weight to a padded view and vice
|
||||||
|
versa (cached_rf_image refuses the mismatch rather than showing peaks
|
||||||
|
resolved at the wrong resolution).
|
||||||
"""
|
"""
|
||||||
global _MAX_WORKERS
|
global _MAX_WORKERS
|
||||||
try:
|
try:
|
||||||
if mode not in ("dc", "fft", "fft_rowavg"):
|
if mode not in ("dc", "fft", "fft_rowavg"):
|
||||||
return f"unknown cache mode {mode!r} (expected 'dc', 'fft', or 'fft_rowavg')"
|
return f"unknown cache mode {mode!r} (expected 'dc', 'fft', or 'fft_rowavg')"
|
||||||
|
# write_v7_cache enforces this bound too, but only once every angle's
|
||||||
|
# FFT has already been computed. Checking up front is the difference
|
||||||
|
# between a bad argument costing nothing and costing the whole run.
|
||||||
|
if not (1 <= pad_factor <= MAX_PAD_FACTOR):
|
||||||
|
return f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {pad_factor}"
|
||||||
set_fft_backend(fft_backend)
|
set_fft_backend(fft_backend)
|
||||||
if max_workers:
|
if max_workers:
|
||||||
_MAX_WORKERS = max_workers
|
_MAX_WORKERS = max_workers
|
||||||
@@ -757,6 +832,7 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
|||||||
"can be batch-cached")
|
"can be batch-cached")
|
||||||
|
|
||||||
n = sras.n_angles
|
n = sras.n_angles
|
||||||
|
n_fft = sras.samples_per_frame * pad_factor if pad_factor > 1 else None
|
||||||
n_workers, angle_budget = plan_angle_level(sras)
|
n_workers, angle_budget = plan_angle_level(sras)
|
||||||
if mode == "dc":
|
if mode == "dc":
|
||||||
dc3 = _parallel_map(
|
dc3 = _parallel_map(
|
||||||
@@ -778,9 +854,13 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
|||||||
# internally over blocks, so angles run one at a time with the
|
# internally over blocks, so angles run one at a time with the
|
||||||
# full budget.
|
# full budget.
|
||||||
freq = [compute_rf_image(sras, a, dc_threshold_mv=None,
|
freq = [compute_rf_image(sras, a, dc_threshold_mv=None,
|
||||||
apply_bg_sub=effective_bg)
|
apply_bg_sub=effective_bg, n_fft=n_fft)
|
||||||
for a in range(n)]
|
for a in range(n)]
|
||||||
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg)
|
# new_row_avg_n=0 explicitly: these images are raw per-pixel FFTs,
|
||||||
|
# and carrying forward a row_avg_n left by an earlier fft_rowavg
|
||||||
|
# write would label them as something they are not.
|
||||||
|
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
|
||||||
|
new_row_avg_n=0, new_pad_factor=pad_factor)
|
||||||
else: # "fft_rowavg"
|
else: # "fft_rowavg"
|
||||||
if row_avg_n <= 0:
|
if row_avg_n <= 0:
|
||||||
return "row_avg_n must be a positive neighbor half-width for fft_rowavg mode"
|
return "row_avg_n must be a positive neighbor half-width for fft_rowavg mode"
|
||||||
@@ -789,10 +869,12 @@ def cache_file(path: str, mode: str, apply_bg_sub: bool,
|
|||||||
"(neighbor validity depends on it)")
|
"(neighbor validity depends on it)")
|
||||||
effective_bg = apply_bg_sub and sras.background is not None
|
effective_bg = apply_bg_sub and sras.background is not None
|
||||||
freq = [compute_rf_image(sras, a, dc_threshold_mv=dc_threshold_mv,
|
freq = [compute_rf_image(sras, a, dc_threshold_mv=dc_threshold_mv,
|
||||||
apply_bg_sub=effective_bg, row_avg_n=row_avg_n)
|
apply_bg_sub=effective_bg, n_fft=n_fft,
|
||||||
|
row_avg_n=row_avg_n)
|
||||||
for a in range(n)]
|
for a in range(n)]
|
||||||
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
|
sras.write_v7_cache(new_freq_mhz=freq, new_bg_sub=effective_bg,
|
||||||
new_row_avg_n=row_avg_n)
|
new_row_avg_n=row_avg_n,
|
||||||
|
new_pad_factor=pad_factor)
|
||||||
return ""
|
return ""
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return str(exc)
|
return str(exc)
|
||||||
@@ -1235,13 +1317,22 @@ def default_max_workers() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _rotation_candidates(nominal_deg: float, search_deg: float,
|
def _rotation_candidates(nominal_deg: float, search_deg: float,
|
||||||
step_deg: float) -> list[float]:
|
step_deg: float,
|
||||||
"""Coarse rotation candidates: a window around *both* signs of the stage's
|
signs: tuple[int, ...] = (-1, 1)) -> list[float]:
|
||||||
reported angle change. Scoring both is what makes the stage's sign
|
"""Coarse rotation candidates: a window around each requested sign of the
|
||||||
convention a non-issue — the images decide which way the stage turns, and
|
stage's reported angle change. Scoring both signs (the default) is what
|
||||||
a file whose stage reports the opposite sense registers just as well."""
|
makes the stage's sign convention a non-issue — the images decide which way
|
||||||
|
the stage turns, and a file whose stage reports the opposite sense
|
||||||
|
registers just as well.
|
||||||
|
|
||||||
|
*signs* exists only so a user who has established which way their own stage
|
||||||
|
turns can halve the coarse sweep from the alignment wizard. It is an
|
||||||
|
override, not an inference: nothing in the file says which sign is right.
|
||||||
|
"""
|
||||||
out: list[float] = []
|
out: list[float] = []
|
||||||
for center in (-nominal_deg, nominal_deg):
|
step_deg = max(abs(step_deg), 1e-9) # a 0 step would divide by zero below
|
||||||
|
for sign in signs:
|
||||||
|
center = sign * nominal_deg
|
||||||
k = int(np.floor(search_deg / step_deg))
|
k = int(np.floor(search_deg / step_deg))
|
||||||
for i in range(-k, k + 1):
|
for i in range(-k, k + 1):
|
||||||
out.append(center + i * step_deg)
|
out.append(center + i * step_deg)
|
||||||
@@ -1351,15 +1442,18 @@ def register_angle_to_reference(
|
|||||||
dc_threshold_mv: float = 0.0,
|
dc_threshold_mv: float = 0.0,
|
||||||
sources: tuple[str, ...] = ("signal", "mask"),
|
sources: tuple[str, ...] = ("signal", "mask"),
|
||||||
coarse_dim: int = 256, fine_dim: int = _DEFAULT_FINE_DIM,
|
coarse_dim: int = 256, fine_dim: int = _DEFAULT_FINE_DIM,
|
||||||
search_deg: float = 6.0, coarse_step_deg: float = 2.0) -> RigidFit:
|
search_deg: float = 6.0, coarse_step_deg: float = 2.0,
|
||||||
|
seed_deg: float | None = None,
|
||||||
|
seed_signs: tuple[int, ...] = (-1, 1),
|
||||||
|
refine: bool = True) -> RigidFit:
|
||||||
"""Rigid (rotation + translation, never scale) fit of *angle_idx* onto
|
"""Rigid (rotation + translation, never scale) fit of *angle_idx* onto
|
||||||
*ref_angle_idx*, found entirely by cross-correlating image content.
|
*ref_angle_idx*, found entirely by cross-correlating image content.
|
||||||
|
|
||||||
Two stages:
|
Two stages:
|
||||||
1. Coarse sweep at *coarse_dim* over a ±*search_deg* window around both
|
1. Coarse sweep at *coarse_dim* over a ±*search_deg* window around the
|
||||||
signs of the stage's reported angle change (see _rotation_candidates),
|
requested signs of the stage's reported angle change (see
|
||||||
for each requested source image, scored by _overlap_ncc. Only has to
|
_rotation_candidates), for each requested source image, scored by
|
||||||
pick the right basin.
|
_overlap_ncc. Only has to pick the right basin.
|
||||||
2. Hill-climbing refinement of the winning (source, rotation) at
|
2. Hill-climbing refinement of the winning (source, rotation) at
|
||||||
*fine_dim* with sub-pixel translation folded into every score
|
*fine_dim* with sub-pixel translation folded into every score
|
||||||
(_refine_rotation, _score_rotation), down to 0.05°.
|
(_refine_rotation, _score_rotation), down to 0.05°.
|
||||||
@@ -1368,6 +1462,22 @@ def register_angle_to_reference(
|
|||||||
local mm to ref mm (q = R @ l + shift); score is the final NCC, which the
|
local mm to ref mm (q = R @ l + shift); score is the final NCC, which the
|
||||||
caller can surface so a bad scan is visible rather than silently fused in.
|
caller can surface so a bad scan is visible rather than silently fused in.
|
||||||
Returns identity for the reference angle itself.
|
Returns identity for the reference angle itself.
|
||||||
|
|
||||||
|
The last three arguments only exist to let the alignment wizard expose the
|
||||||
|
rotation search; every default reproduces the search this function has
|
||||||
|
always done.
|
||||||
|
|
||||||
|
*seed_deg* replaces the stage's reported angle change as the center of the
|
||||||
|
coarse sweep — the pre-rotation the search starts from. None (the default)
|
||||||
|
means the stage angle from the file's Angle Table, which is nearly always
|
||||||
|
what you want: it puts the sweep within a couple of degrees of the answer.
|
||||||
|
Pass 0.0 to search around no rotation at all, which is the honest choice for
|
||||||
|
a file whose stage angles are known to be wrong.
|
||||||
|
|
||||||
|
*refine=False* stops after the coarse sweep, leaving rotation on the
|
||||||
|
*coarse_step_deg* grid. Combined with search_deg=0.0 and a single seed sign
|
||||||
|
it pins rotation to exactly the seed and searches translation only — for a
|
||||||
|
scan whose stage angles are trusted more than its image content.
|
||||||
"""
|
"""
|
||||||
if angle_idx == ref_angle_idx:
|
if angle_idx == ref_angle_idx:
|
||||||
return RigidFit(0.0, (0.0, 0.0), 1.0, "reference")
|
return RigidFit(0.0, (0.0, 0.0), 1.0, "reference")
|
||||||
@@ -1377,8 +1487,10 @@ def register_angle_to_reference(
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return RigidFit(0.0, (0.0, 0.0), -1.0, "none")
|
return RigidFit(0.0, (0.0, 0.0), -1.0, "none")
|
||||||
|
|
||||||
nominal = nominal_delta_deg(sras, angle_idx, ref_angle_idx)
|
nominal = (nominal_delta_deg(sras, angle_idx, ref_angle_idx)
|
||||||
thetas = _rotation_candidates(nominal, search_deg, coarse_step_deg)
|
if seed_deg is None else float(seed_deg))
|
||||||
|
thetas = _rotation_candidates(nominal, search_deg, coarse_step_deg,
|
||||||
|
seed_signs)
|
||||||
|
|
||||||
# ---- Stage 1: coarse sweep, every source ------------------------------
|
# ---- Stage 1: coarse sweep, every source ------------------------------
|
||||||
pitch_c, n_c = _reg_pitch_and_size(sras, coarse_dim)
|
pitch_c, n_c = _reg_pitch_and_size(sras, coarse_dim)
|
||||||
@@ -1407,6 +1519,7 @@ def register_angle_to_reference(
|
|||||||
theta = best[1]
|
theta = best[1]
|
||||||
score, shift = _score_rotation(ref_img, ref_valid, mov_reg, pitch_f, n_f,
|
score, shift = _score_rotation(ref_img, ref_valid, mov_reg, pitch_f, n_f,
|
||||||
theta, subpixel=True)
|
theta, subpixel=True)
|
||||||
|
if refine:
|
||||||
theta, score, shift = _refine_rotation(
|
theta, score, shift = _refine_rotation(
|
||||||
ref_img, ref_valid, mov_reg, pitch_f, n_f, theta, score, shift,
|
ref_img, ref_valid, mov_reg, pitch_f, n_f, theta, score, shift,
|
||||||
step_deg=coarse_step_deg)
|
step_deg=coarse_step_deg)
|
||||||
@@ -1421,24 +1534,17 @@ def register_angle_to_reference(
|
|||||||
def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
|
def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
|
||||||
pitch_mm: tuple[float, float],
|
pitch_mm: tuple[float, float],
|
||||||
per_angle_params: dict[int, ManualAngleParams],
|
per_angle_params: dict[int, ManualAngleParams],
|
||||||
*, margin_frac: float = 0.0, snap: bool = True
|
|
||||||
) -> tuple[tuple[float, float], tuple[int, int]]:
|
) -> tuple[tuple[float, float], tuple[int, int]]:
|
||||||
"""Shared-canvas origin (stage mm) and (n_rows, n_cols) at *pitch_mm* that
|
"""Shared-canvas origin (stage mm) and (n_rows, n_cols) at *pitch_mm* that
|
||||||
contains every angle's footprint after its own rigid transform. Angles
|
contains every angle's footprint after its own rigid transform. Angles
|
||||||
missing from per_angle_params default to identity (e.g. a sidecar saved
|
missing from per_angle_params default to identity (e.g. a sidecar saved
|
||||||
before a rescan added more angles).
|
before a rescan added more angles).
|
||||||
|
|
||||||
snap=True aligns the canvas grid with the reference angle's own pixel grid,
|
The canvas grid is aligned with the reference angle's own pixel grid, so
|
||||||
so the reference lands on integer canvas pixels and is resampled by an
|
the reference lands on integer canvas pixels and is resampled by an exact
|
||||||
exact integer translation — the concrete meaning of "the canvas carries
|
integer translation — the concrete meaning of "the canvas carries angle
|
||||||
angle 0's X/Y coordinates". It requires pitch_mm to be the reference's own
|
0's X/Y coordinates". This requires pitch_mm to be the reference's own
|
||||||
pitch; the manual-alignment preview passes a coarser pitch and snap=False.
|
pitch.
|
||||||
|
|
||||||
margin_frac pads the box on every side: 0 for a final canvas, nonzero for
|
|
||||||
ManualAlignmentDialog's preview canvas, which needs headroom so an ordinary
|
|
||||||
translation nudge never has to trigger a full canvas resize (an extreme
|
|
||||||
nudge can still push content past this padding; accepted, and cheap to
|
|
||||||
recover from by re-opening the dialog).
|
|
||||||
"""
|
"""
|
||||||
dx, dy = pitch_mm
|
dx, dy = pitch_mm
|
||||||
corners = np.vstack([
|
corners = np.vstack([
|
||||||
@@ -1449,13 +1555,7 @@ def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
|
|||||||
for a in range(sras.n_angles)])
|
for a in range(sras.n_angles)])
|
||||||
x_min, y_min = corners.min(axis=0)
|
x_min, y_min = corners.min(axis=0)
|
||||||
x_max, y_max = corners.max(axis=0)
|
x_max, y_max = corners.max(axis=0)
|
||||||
if margin_frac:
|
|
||||||
pad_x, pad_y = (x_max - x_min) * margin_frac, (y_max - y_min) * margin_frac
|
|
||||||
x_min, x_max = x_min - pad_x, x_max + pad_x
|
|
||||||
y_min, y_max = y_min - pad_y, y_max + pad_y
|
|
||||||
|
|
||||||
center = ref_center_mm(sras, ref_angle_idx)
|
center = ref_center_mm(sras, ref_angle_idx)
|
||||||
if snap:
|
|
||||||
# Express the box in the reference's own pixel indices and grow it
|
# Express the box in the reference's own pixel indices and grow it
|
||||||
# outward to whole pixels, so canvas index k lands exactly where the
|
# outward to whole pixels, so canvas index k lands exactly where the
|
||||||
# reference's own pixel (k + const) does.
|
# reference's own pixel (k + const) does.
|
||||||
@@ -1466,10 +1566,6 @@ def canvas_for_params(sras: SrasFile, ref_angle_idx: int,
|
|||||||
row0, row1 = int(np.floor(rows[0])), int(np.ceil(rows[1]))
|
row0, row1 = int(np.floor(rows[0])), int(np.ceil(rows[1]))
|
||||||
origin_ref = np.array([(col0 - cx) * dx, (row0 - cy) * dy])
|
origin_ref = np.array([(col0 - cx) * dx, (row0 - cy) * dy])
|
||||||
shape = (row1 - row0 + 1, col1 - col0 + 1)
|
shape = (row1 - row0 + 1, col1 - col0 + 1)
|
||||||
else:
|
|
||||||
origin_ref = np.array([x_min, y_min if dy > 0 else y_max])
|
|
||||||
shape = (int(np.ceil((y_max - y_min) / abs(dy))) + 1,
|
|
||||||
int(np.ceil((x_max - x_min) / dx)) + 1)
|
|
||||||
|
|
||||||
origin_stage = origin_ref + center
|
origin_stage = origin_ref + center
|
||||||
return (float(origin_stage[0]), float(origin_stage[1])), shape
|
return (float(origin_stage[0]), float(origin_stage[1])), shape
|
||||||
@@ -1523,7 +1619,7 @@ def _result_from_params(sras: SrasFile, ref_angle_idx: int,
|
|||||||
thread on every manual edit."""
|
thread on every manual edit."""
|
||||||
pitch = pixel_pitch_mm(sras, ref_angle_idx)
|
pitch = pixel_pitch_mm(sras, ref_angle_idx)
|
||||||
canvas_origin_mm, canvas_shape = canvas_for_params(
|
canvas_origin_mm, canvas_shape = canvas_for_params(
|
||||||
sras, ref_angle_idx, pitch, params, snap=True)
|
sras, ref_angle_idx, pitch, params)
|
||||||
|
|
||||||
extra = extra or {}
|
extra = extra or {}
|
||||||
per_angle: dict[int, AngleTransform] = {}
|
per_angle: dict[int, AngleTransform] = {}
|
||||||
@@ -1540,6 +1636,144 @@ def _result_from_params(sras: SrasFile, ref_angle_idx: int,
|
|||||||
pitch[0], pitch[1], canvas_origin_mm, per_angle)
|
pitch[0], pitch[1], canvas_origin_mm, per_angle)
|
||||||
|
|
||||||
|
|
||||||
|
def crop_alignment_result(result: AlignmentResult, row0: int, col0: int,
|
||||||
|
n_rows: int, n_cols: int) -> AlignmentResult:
|
||||||
|
"""The same alignment restricted to a rectangular window of its canvas —
|
||||||
|
canvas pixel (row0, col0) becomes the cropped canvas's (0, 0).
|
||||||
|
|
||||||
|
Cropping folds into each angle's existing affine instead of becoming a
|
||||||
|
second transform, because a canvas crop is *pure index translation*. From
|
||||||
|
_affine_out_to_src, matrix = D @ Rinv @ A_out depends only on pitch and
|
||||||
|
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
|
||||||
|
|
||||||
|
i.e. shifting the offset by matrix @ [row0, col0] reproduces the original
|
||||||
|
mapping at the shifted indices, exactly. That equality is what lets
|
||||||
|
apply_alignment, reproject_mask and the aligned .sras exporter all keep
|
||||||
|
working on a cropped result with no special-casing — and it is why the crop
|
||||||
|
preview a user approves is guaranteed to be the same pixels the exporter
|
||||||
|
writes.
|
||||||
|
|
||||||
|
Bounds are the caller's responsibility (the wizard's ROI page clamps to the
|
||||||
|
canvas): an out-of-range window is geometrically well-defined here and
|
||||||
|
simply resamples padding.
|
||||||
|
"""
|
||||||
|
if n_rows <= 0 or n_cols <= 0:
|
||||||
|
raise ValueError(f"empty crop: {n_rows} x {n_cols}")
|
||||||
|
|
||||||
|
delta = np.array([float(row0), float(col0)])
|
||||||
|
per_angle = {
|
||||||
|
a: AngleTransform(t.rotation_deg, t.shift_mm, t.matrix,
|
||||||
|
t.offset + t.matrix @ delta, t.score, t.source)
|
||||||
|
for a, t in result.per_angle.items()
|
||||||
|
}
|
||||||
|
origin = (result.canvas_origin_mm[0] + col0 * result.canvas_dx_mm,
|
||||||
|
result.canvas_origin_mm[1] + row0 * result.canvas_dy_mm)
|
||||||
|
return AlignmentResult(result.ref_angle_idx, result.dc_threshold_mv,
|
||||||
|
(int(n_rows), int(n_cols)),
|
||||||
|
result.canvas_dx_mm, result.canvas_dy_mm,
|
||||||
|
origin, per_angle)
|
||||||
|
|
||||||
|
|
||||||
|
def coarse_rect_to_canvas(rect: tuple[int, int, int, int],
|
||||||
|
downsample: tuple[int, int],
|
||||||
|
canvas_shape: tuple[int, int],
|
||||||
|
*, inset_blocks: int = 0
|
||||||
|
) -> tuple[int, int, int, int]:
|
||||||
|
"""A rectangle in coarse (block-mean preview) indices as canvas pixels,
|
||||||
|
both as (row0, col0, n_rows, n_cols) and clamped to the canvas.
|
||||||
|
|
||||||
|
The single place the preview grid's relation to the real canvas is
|
||||||
|
written down: the coarse grid samples canvas pixels 0, f, 2f, …, so a
|
||||||
|
coarse pixel stands for a whole block and every caller has to agree on
|
||||||
|
which canvas pixels that block means, or a crop the user approved on the
|
||||||
|
preview lands a few pixels off in the export.
|
||||||
|
|
||||||
|
*inset_blocks* shrinks the rectangle by that many coarse blocks on each
|
||||||
|
side. A coarse pixel reported as fully covered stands for a block whose
|
||||||
|
far edge may not be, so "fit to full overlap" insets by 1 to stay honestly
|
||||||
|
inside the overlap region; a union rectangle insets by 0 because it wants
|
||||||
|
to contain the region rather than fit inside it.
|
||||||
|
"""
|
||||||
|
row0, col0, nr, nc = rect
|
||||||
|
fy, fx = downsample
|
||||||
|
n_rows, n_cols = canvas_shape
|
||||||
|
r0 = min(n_rows - 1, (row0 + inset_blocks) * fy)
|
||||||
|
c0 = min(n_cols - 1, (col0 + inset_blocks) * fx)
|
||||||
|
r1 = min(n_rows, (row0 + nr - inset_blocks) * fy)
|
||||||
|
c1 = min(n_cols, (col0 + nc - inset_blocks) * fx)
|
||||||
|
return r0, c0, max(1, r1 - r0), max(1, c1 - c0)
|
||||||
|
|
||||||
|
|
||||||
|
def overlap_stats(counts: np.ndarray, n_angles: int) -> dict:
|
||||||
|
"""Summarize a per-pixel "how many angles cover this pixel" image — the
|
||||||
|
number the alignment wizard's mask-stack view is colored by.
|
||||||
|
|
||||||
|
Separated from the drawing code because it is the actual judgement the user
|
||||||
|
makes on that screen ("do the angles land on top of each other?"), and a
|
||||||
|
plain array-in/dict-out function can be tested without Qt.
|
||||||
|
"""
|
||||||
|
counts = np.asarray(counts)
|
||||||
|
union = int(np.count_nonzero(counts))
|
||||||
|
full = int(np.count_nonzero(counts >= n_angles))
|
||||||
|
return {
|
||||||
|
"union_px": union,
|
||||||
|
"full_px": full,
|
||||||
|
"full_frac": (full / union) if union else 0.0,
|
||||||
|
"mean_count": float(counts[counts > 0].mean()) if union else 0.0,
|
||||||
|
"max_count": int(counts.max()) if counts.size else 0,
|
||||||
|
"empty": union == 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def largest_rect_at_least(counts: np.ndarray, min_count: int
|
||||||
|
) -> tuple[int, int, int, int] | None:
|
||||||
|
"""Largest axis-aligned rectangle whose every pixel has counts >= min_count,
|
||||||
|
as (row0, col0, n_rows, n_cols), or None if no pixel qualifies.
|
||||||
|
|
||||||
|
Backs the wizard's "fit to full overlap" button. A *bounding box* of the
|
||||||
|
qualifying pixels would be the obvious thing and is wrong: the full-overlap
|
||||||
|
region of several rotated scans is roughly a disc, whose bounding box has
|
||||||
|
corners no angle covers at all. Offering that as the crop would hand the
|
||||||
|
user padding they explicitly asked to avoid, so this finds a rectangle that
|
||||||
|
is entirely inside the region.
|
||||||
|
|
||||||
|
Standard largest-rectangle-in-a-histogram sweep — O(rows * cols) on a
|
||||||
|
preview-sized array, so it is instant at interactive rates.
|
||||||
|
"""
|
||||||
|
good = np.asarray(counts) >= min_count
|
||||||
|
if not good.any():
|
||||||
|
return None
|
||||||
|
|
||||||
|
n_rows, n_cols = good.shape
|
||||||
|
best = (0, 0, 0, 0) # area, row0, col0, ...
|
||||||
|
best_area = 0
|
||||||
|
heights = np.zeros(n_cols, dtype=np.int64)
|
||||||
|
|
||||||
|
for r in range(n_rows):
|
||||||
|
heights = np.where(good[r], heights + 1, 0)
|
||||||
|
# Sentinel column of height 0 flushes the stack at the end of the row.
|
||||||
|
stack: list[tuple[int, int]] = [] # (start col, height)
|
||||||
|
for c in range(n_cols + 1):
|
||||||
|
h = int(heights[c]) if c < n_cols else 0
|
||||||
|
start = c
|
||||||
|
while stack and stack[-1][1] >= h:
|
||||||
|
s, sh = stack.pop()
|
||||||
|
area = sh * (c - s)
|
||||||
|
if area > best_area:
|
||||||
|
best_area = area
|
||||||
|
best = (s, sh, c - s, r)
|
||||||
|
start = s
|
||||||
|
if h:
|
||||||
|
stack.append((start, h))
|
||||||
|
|
||||||
|
col0, height, width, row_end = best
|
||||||
|
return (row_end - height + 1, col0, height, width)
|
||||||
|
|
||||||
|
|
||||||
def _parallel_map(fn, items, n_workers: int) -> list:
|
def _parallel_map(fn, items, n_workers: int) -> list:
|
||||||
"""fn over items, in order, threaded when it pays."""
|
"""fn over items, in order, threaded when it pays."""
|
||||||
items = list(items)
|
items = list(items)
|
||||||
@@ -1555,7 +1789,7 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
|
|||||||
"""Top-level alignment driver: register every angle onto *ref_angle_idx* by
|
"""Top-level alignment driver: register every angle onto *ref_angle_idx* by
|
||||||
content, then lay them all out on that angle's own coordinate grid.
|
content, then lay them all out on that angle's own coordinate grid.
|
||||||
|
|
||||||
Runs on a background thread (see AngleAlignmentWorker) — deliberately
|
Meant for a background thread — deliberately
|
||||||
recomputes CH4 DC images from scratch rather than reading the GUI-thread
|
recomputes CH4 DC images from scratch rather than reading the GUI-thread
|
||||||
_dc_cache dict, since background-thread workers must not touch
|
_dc_cache dict, since background-thread workers must not touch
|
||||||
GUI-thread-owned caches.
|
GUI-thread-owned caches.
|
||||||
@@ -1618,9 +1852,9 @@ def compute_angle_alignment(sras: SrasFile, ref_angle_idx: int,
|
|||||||
# with no per-pixel image work at all, so build_manual_alignment is cheap enough
|
# with no per-pixel image work at all, so build_manual_alignment is cheap enough
|
||||||
# to call synchronously on the GUI thread on every edit. The only genuinely
|
# to call synchronously on the GUI thread on every edit. The only genuinely
|
||||||
# expensive per-pixel operation anywhere in this flow is reproject_mask, and
|
# expensive per-pixel operation anywhere in this flow is reproject_mask, and
|
||||||
# only ManualAlignmentDialog's own downsampled preview calls that per keystroke
|
# only the wizard's own downsampled mask stack calls that per keystroke
|
||||||
# — see that class's docstring for how it limits each nudge to reprojecting only
|
# — see AlignmentWizard.rebuild_stack for how it limits a nudge to reprojecting
|
||||||
# the actively-edited angle.
|
# only the actively-edited angle.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
|
def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
|
||||||
@@ -1631,9 +1865,8 @@ def reproject_mask(sras: SrasFile, angle_idx: int, ref_angle_idx: int,
|
|||||||
canvas_shape: tuple[int, int],
|
canvas_shape: tuple[int, int],
|
||||||
*, src_downsample: tuple[int, int] = (1, 1)) -> np.ndarray:
|
*, src_downsample: tuple[int, int] = (1, 1)) -> np.ndarray:
|
||||||
"""Resample one angle's binary/float mask onto an arbitrary canvas via an
|
"""Resample one angle's binary/float mask onto an arbitrary canvas via an
|
||||||
explicit rotation+shift — the single building block ManualAlignmentDialog's
|
explicit rotation+shift — the single building block the alignment wizard's
|
||||||
live preview repeatedly calls (once per keystroke, for only the
|
live mask stack repeatedly calls (once per angle per edit). *src_downsample* must match the (rows, cols)
|
||||||
actively-nudged angle). *src_downsample* must match the (rows, cols)
|
|
||||||
block-mean factor already applied to *mask*, or the reprojection lands at
|
block-mean factor already applied to *mask*, or the reprojection lands at
|
||||||
the wrong scale. order=0 (nearest) matches apply_alignment's own reasoning:
|
the wrong scale. order=0 (nearest) matches apply_alignment's own reasoning:
|
||||||
a binary mask must never be blended with zero-padding."""
|
a binary mask must never be blended with zero-padding."""
|
||||||
@@ -1746,7 +1979,7 @@ def delete_manual_alignment(sras: SrasFile) -> bool:
|
|||||||
"""Delete the sidecar if present. Returns whether a file actually existed
|
"""Delete the sidecar if present. Returns whether a file actually existed
|
||||||
to delete, so Clear Alignment's status message can say so. Genuine I/O
|
to delete, so Clear Alignment's status message can say so. Genuine I/O
|
||||||
errors (permission denied, read-only share) propagate — the caller
|
errors (permission denied, read-only share) propagate — the caller
|
||||||
(ManualAlignmentDialog._on_clear) surfaces them rather than silently
|
(the wizard's Clear path) surfaces them rather than silently
|
||||||
pretending the destructive action succeeded."""
|
pretending the destructive action succeeded."""
|
||||||
path = sidecar_path(sras.path)
|
path = sidecar_path(sras.path)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ 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
|
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.
|
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:
|
Usage:
|
||||||
python sras_edit_scans.py input.sras --list
|
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 --drop 2,5
|
||||||
|
|||||||
+107
-20
@@ -60,11 +60,15 @@ PREC_FLAG_BG_SUB = 0x01
|
|||||||
CACH_MAGIC = b"CACH"
|
CACH_MAGIC = b"CACH"
|
||||||
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
|
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
|
||||||
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
|
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
|
||||||
CACH_VERSION = 2 # written on every fresh write
|
CACH_VERSION = 3 # written on every fresh write
|
||||||
CACH_VERSIONS_READABLE = (1, 2) # accepted on read — see
|
CACH_VERSIONS_READABLE = (1, 2, 3) # accepted on read — see
|
||||||
# _read_sfft_block: a v1 tail
|
# _read_sfft_block. Each bump only
|
||||||
# predates row-averaged FFT
|
# appended a field, and every older
|
||||||
# caching and reads as row_avg_n=0
|
# 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_DC = 0x01
|
||||||
CACH_FLAG_FFT = 0x02
|
CACH_FLAG_FFT = 0x02
|
||||||
|
|
||||||
@@ -74,9 +78,12 @@ SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
|
|||||||
|
|
||||||
SFFT_MAGIC = b"SFFT"
|
SFFT_MAGIC = b"SFFT"
|
||||||
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored (cach_version 1)
|
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored (cach_version 1)
|
||||||
SFFT_HDR_FMT = ">4sBHB" # + row_avg_n (cach_version 2)
|
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_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)
|
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
|
||||||
|
MAX_PAD_FACTOR = 0xFFFF # the H field above
|
||||||
SFFT_FLAG_BG_SUB = 0x01
|
SFFT_FLAG_BG_SUB = 0x01
|
||||||
SFFT_FLAG_ROW_AVG = 0x02 # peak_freq_mhz came from same-row,
|
SFFT_FLAG_ROW_AVG = 0x02 # peak_freq_mhz came from same-row,
|
||||||
# distance-weighted averaged CH1
|
# distance-weighted averaged CH1
|
||||||
@@ -180,7 +187,10 @@ class SrasFile:
|
|||||||
``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``,
|
``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``,
|
||||||
always as ragged per-angle lists (``list[np.ndarray | None]``, one entry
|
always as ragged per-angle lists (``list[np.ndarray | None]``, one entry
|
||||||
per angle, ``None`` where that angle was never stored) regardless of
|
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):
|
def __init__(self, path: str):
|
||||||
@@ -238,6 +248,53 @@ class SrasFile:
|
|||||||
self.precomputed_dc3_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_bg_sub: bool = False
|
||||||
self.precomputed_row_avg_n: int = 0
|
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:
|
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."""
|
"""A stored DC image (already in mV) for (angle, channel), or None."""
|
||||||
@@ -269,6 +326,15 @@ class SrasFile:
|
|||||||
self.scan_aborted = False
|
self.scan_aborted = False
|
||||||
self.n_angles_declared = n_angles
|
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)
|
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)
|
y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32)
|
||||||
|
|
||||||
@@ -529,24 +595,30 @@ class SrasFile:
|
|||||||
store[angle_idx] = _read_f32_image(f, shape)
|
store[angle_idx] = _read_f32_image(f, shape)
|
||||||
return flags
|
return flags
|
||||||
|
|
||||||
def _read_sfft_block(self, f, cach_version: int) -> tuple[int, int] | None:
|
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,
|
"""Read the SFFT block header — its layout depends on cach_version,
|
||||||
since v2 appended a trailing row_avg_n byte — then n_stored per-angle
|
since each bump appended a trailing field (v2 row_avg_n, v3
|
||||||
peak_freq_mhz entries (unchanged across versions).
|
pad_factor) — then n_stored per-angle peak_freq_mhz entries
|
||||||
|
(unchanged across versions).
|
||||||
|
|
||||||
Returns (flags, row_avg_n), or None if the block is malformed.
|
Returns (flags, row_avg_n, pad_factor), or None if the block is
|
||||||
row_avg_n is always 0 for a v1 tail, which predates row-averaged FFT
|
malformed. The absent fields of an older tail take the value that
|
||||||
caching entirely.
|
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 = SFFT_HDR_FMT_V1 if cach_version == 1 else SFFT_HDR_FMT
|
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))
|
raw = f.read(struct.calcsize(hdr_fmt))
|
||||||
if len(raw) < struct.calcsize(hdr_fmt):
|
if len(raw) < struct.calcsize(hdr_fmt):
|
||||||
return None
|
return None
|
||||||
|
row_avg_n, pad_factor = 0, 1
|
||||||
if cach_version == 1:
|
if cach_version == 1:
|
||||||
magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
|
magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
|
||||||
row_avg_n = 0
|
elif cach_version == 2:
|
||||||
else:
|
|
||||||
magic, flags, n_stored, row_avg_n = struct.unpack(hdr_fmt, raw)
|
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:
|
if magic != SFFT_MAGIC:
|
||||||
return None
|
return None
|
||||||
for _ in range(n_stored):
|
for _ in range(n_stored):
|
||||||
@@ -555,7 +627,7 @@ class SrasFile:
|
|||||||
break
|
break
|
||||||
self.precomputed_freq_mhz[angle_idx] = _read_f32_image(
|
self.precomputed_freq_mhz[angle_idx] = _read_f32_image(
|
||||||
f, self.image_shape(angle_idx))
|
f, self.image_shape(angle_idx))
|
||||||
return flags, row_avg_n
|
return flags, row_avg_n, pad_factor
|
||||||
|
|
||||||
def _parse_cach_section(self, offset: int):
|
def _parse_cach_section(self, offset: int):
|
||||||
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
|
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
|
||||||
@@ -578,16 +650,18 @@ class SrasFile:
|
|||||||
result = self._read_sfft_block(f, cach_version)
|
result = self._read_sfft_block(f, cach_version)
|
||||||
if result is None:
|
if result is None:
|
||||||
return
|
return
|
||||||
flags, row_avg_n = result
|
flags, row_avg_n, pad_factor = result
|
||||||
self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB)
|
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_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, *,
|
def write_v7_cache(self, *,
|
||||||
new_dc3_mv: list[np.ndarray | None] | None = None,
|
new_dc3_mv: list[np.ndarray | None] | None = None,
|
||||||
new_dc4_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_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_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,
|
"""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
|
in place, converting a v6 source to v7 (or updating an existing v7
|
||||||
file). Only the block(s) passed in are recomputed; whichever block
|
file). Only the block(s) passed in are recomputed; whichever block
|
||||||
@@ -601,6 +675,12 @@ class SrasFile:
|
|||||||
It describes the whole stored FFT block, not per-angle, mirroring
|
It describes the whole stored FFT block, not per-angle, mirroring
|
||||||
how bg-sub has never been tracked per-angle either.
|
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
|
The waveform data itself is never touched: the cache tail always
|
||||||
starts at ``_cache_tail_offset()``, a fixed offset derived from the
|
starts at ``_cache_tail_offset()``, a fixed offset derived from the
|
||||||
header and geometry table alone.
|
header and geometry table alone.
|
||||||
@@ -615,8 +695,13 @@ class SrasFile:
|
|||||||
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
|
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
|
||||||
final_row_avg_n = (new_row_avg_n if new_row_avg_n is not None
|
final_row_avg_n = (new_row_avg_n if new_row_avg_n is not None
|
||||||
else self.precomputed_row_avg_n)
|
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):
|
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}")
|
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}")
|
||||||
|
|
||||||
dc_entries = [a for a in range(self.n_angles) if final_dc3[a] is not None]
|
dc_entries = [a for a in range(self.n_angles) if final_dc3[a] is not None]
|
||||||
fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
|
fft_entries = [a for a in range(self.n_angles) if final_freq[a] is not None]
|
||||||
@@ -638,7 +723,8 @@ class SrasFile:
|
|||||||
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
|
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
|
||||||
fft_flags |= SFFT_FLAG_ROW_AVG if final_row_avg_n else 0
|
fft_flags |= SFFT_FLAG_ROW_AVG if final_row_avg_n else 0
|
||||||
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags,
|
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags,
|
||||||
len(fft_entries), final_row_avg_n)
|
len(fft_entries), final_row_avg_n,
|
||||||
|
final_pad_factor)
|
||||||
for a in fft_entries:
|
for a in fft_entries:
|
||||||
payload += struct.pack(">H", a)
|
payload += struct.pack(">H", a)
|
||||||
payload += final_freq[a].astype(">f4").tobytes()
|
payload += final_freq[a].astype(">f4").tobytes()
|
||||||
@@ -667,6 +753,7 @@ class SrasFile:
|
|||||||
self.precomputed_freq_mhz = final_freq
|
self.precomputed_freq_mhz = final_freq
|
||||||
self.precomputed_bg_sub = final_bg_sub
|
self.precomputed_bg_sub = final_bg_sub
|
||||||
self.precomputed_row_avg_n = final_row_avg_n
|
self.precomputed_row_avg_n = final_row_avg_n
|
||||||
|
self.precomputed_pad_factor = final_pad_factor
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Axes helpers
|
# Axes helpers
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import faulthandler
|
|||||||
|
|
||||||
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
faulthandler.enable() # print a native stack trace on SIGSEGV/SIGABRT/etc.
|
||||||
|
|
||||||
from .canvases import ImageCanvas, RoiQuad, WaveformCanvas # noqa: E402,F401
|
from .align_wizard import AlignmentWizard # noqa: E402,F401
|
||||||
from .common import CH_LABELS, CMAPS, VELOCITY_MODE_IDX # noqa: E402,F401
|
from .canvases import ( # noqa: E402,F401
|
||||||
from .dialogs import ( # noqa: E402,F401
|
AlignOverlayCanvas, ImageCanvas, RoiQuad, WaveformCanvas,
|
||||||
FftOptionsDialog, ManualAlignmentDialog, RowAverageFftOptionsDialog,
|
|
||||||
)
|
)
|
||||||
|
from .common import CH_LABELS, CMAPS, VELOCITY_MODE_IDX # noqa: E402,F401
|
||||||
|
from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog # noqa: E402,F401
|
||||||
from .main_window import SrasViewerWindow, main # noqa: E402,F401
|
from .main_window import SrasViewerWindow, main # noqa: E402,F401
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+99
-12
@@ -1,7 +1,9 @@
|
|||||||
"""Matplotlib canvases and the ROI primitive."""
|
"""Matplotlib canvases and the ROI primitive."""
|
||||||
|
|
||||||
|
import matplotlib as mpl
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
||||||
|
from matplotlib.colors import BoundaryNorm, ListedColormap
|
||||||
from matplotlib.figure import Figure
|
from matplotlib.figure import Figure
|
||||||
from matplotlib.patches import Polygon
|
from matplotlib.patches import Polygon
|
||||||
from matplotlib.path import Path as MplPath
|
from matplotlib.path import Path as MplPath
|
||||||
@@ -11,6 +13,27 @@ from PyQt6.QtWidgets import QSizePolicy
|
|||||||
|
|
||||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, adc_to_mv
|
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)
|
# ROI (free quadrilateral in data coordinates)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -100,7 +123,15 @@ class ImageCanvas(FigureCanvasQTAgg):
|
|||||||
_HANDLE_PX = 12
|
_HANDLE_PX = 12
|
||||||
_CLICK_THRESH_PX = 4 # releases within this of press count as a click
|
_CLICK_THRESH_PX = 4 # releases within this of press count as a click
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
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)
|
fig = Figure(figsize=(7, 5), tight_layout=True)
|
||||||
self.ax = fig.add_subplot(111)
|
self.ax = fig.add_subplot(111)
|
||||||
super().__init__(fig)
|
super().__init__(fig)
|
||||||
@@ -108,6 +139,7 @@ class ImageCanvas(FigureCanvasQTAgg):
|
|||||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
self._extent = None
|
self._extent = None
|
||||||
self._img_shape = None
|
self._img_shape = None
|
||||||
|
self._rect_only = rect_only
|
||||||
|
|
||||||
# ROI state
|
# ROI state
|
||||||
self._roi: RoiQuad | None = None
|
self._roi: RoiQuad | None = None
|
||||||
@@ -132,9 +164,13 @@ class ImageCanvas(FigureCanvasQTAgg):
|
|||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def show_image(self, img: np.ndarray, extent: list[float], cmap: str,
|
def show_image(self, img: np.ndarray, extent: list[float], cmap,
|
||||||
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
|
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
|
||||||
colorbar_label: 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.figure.clf()
|
||||||
self.ax = self.figure.add_subplot(111)
|
self.ax = self.figure.add_subplot(111)
|
||||||
# Patches and lines are destroyed by figure.clf(); drop stale refs.
|
# Patches and lines are destroyed by figure.clf(); drop stale refs.
|
||||||
@@ -143,12 +179,14 @@ class ImageCanvas(FigureCanvasQTAgg):
|
|||||||
self._extent = extent
|
self._extent = extent
|
||||||
self._img_shape = img.shape
|
self._img_shape = img.shape
|
||||||
|
|
||||||
|
kw = ({"norm": norm} if norm is not None
|
||||||
|
else {"vmin": vmin, "vmax": vmax})
|
||||||
im = self.ax.imshow(
|
im = self.ax.imshow(
|
||||||
img, aspect="auto", origin="upper",
|
img, aspect="auto", origin="upper",
|
||||||
extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
|
extent=extent, cmap=cmap, interpolation="nearest", **kw,
|
||||||
interpolation="nearest",
|
|
||||||
)
|
)
|
||||||
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04)
|
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04,
|
||||||
|
ticks=cb_ticks)
|
||||||
if colorbar_label:
|
if colorbar_label:
|
||||||
cb.set_label(colorbar_label)
|
cb.set_label(colorbar_label)
|
||||||
|
|
||||||
@@ -302,10 +340,28 @@ class ImageCanvas(FigureCanvasQTAgg):
|
|||||||
self._roi._pts = self._snapshot.corners() + delta
|
self._roi._pts = self._snapshot.corners() + delta
|
||||||
elif self._state == self._DRAG_CORNER:
|
elif self._state == self._DRAG_CORNER:
|
||||||
self._roi._pts[self._drag_corner_idx] = [event.xdata, event.ydata]
|
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_roi()
|
||||||
self.draw_idle()
|
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):
|
def _on_release(self, event):
|
||||||
if event.button != 1 and self._press_button != 1:
|
if event.button != 1 and self._press_button != 1:
|
||||||
return
|
return
|
||||||
@@ -475,14 +531,20 @@ class WaveformCanvas(FigureCanvasQTAgg):
|
|||||||
self.draw()
|
self.draw()
|
||||||
|
|
||||||
|
|
||||||
class ManualAlignOverlayCanvas(FigureCanvasQTAgg):
|
class AlignOverlayCanvas(FigureCanvasQTAgg):
|
||||||
"""Renders ManualAlignmentDialog's multi-angle mask overlay and turns
|
"""Renders the alignment wizard's multi-angle mask views and turns keyboard
|
||||||
keyboard input into translate/rotate nudge requests for whichever angle
|
input into translate/rotate nudge requests for whichever angle is active.
|
||||||
the dialog currently has 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
|
A pure input+render widget — it holds no alignment state and never
|
||||||
touches SrasFile itself; ManualAlignmentDialog owns all of that and
|
touches SrasFile itself; the wizard page owns all of that and decides,
|
||||||
decides, from these signals, whether a cheap single-layer refresh or a
|
from these signals, whether a cheap single-layer refresh or a
|
||||||
full preview-canvas rebuild is needed.
|
full preview-canvas rebuild is needed.
|
||||||
|
|
||||||
FigureCanvasQTAgg is a real QWidget, so keyPressEvent works like on any
|
FigureCanvasQTAgg is a real QWidget, so keyPressEvent works like on any
|
||||||
@@ -522,6 +584,31 @@ class ManualAlignOverlayCanvas(FigureCanvasQTAgg):
|
|||||||
self.figure.clf()
|
self.figure.clf()
|
||||||
self.ax = self.figure.add_subplot(111)
|
self.ax = self.figure.add_subplot(111)
|
||||||
self.ax.imshow(rgba, extent=extent, origin="upper", aspect="auto")
|
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_xlabel("X (mm)")
|
||||||
self.ax.set_ylabel("Y (mm)")
|
self.ax.set_ylabel("Y (mm)")
|
||||||
self.ax.set_title(title)
|
self.ax.set_title(title)
|
||||||
|
|||||||
+32
-5
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel, QScrollArea,
|
QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QGroupBox, QLabel,
|
||||||
QSizePolicy, QVBoxLayout, QWidget,
|
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX
|
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX
|
||||||
@@ -60,9 +60,13 @@ class Jobs:
|
|||||||
COMPUTE = "compute"
|
COMPUTE = "compute"
|
||||||
DC_PRECOMPUTE = "dc_precompute"
|
DC_PRECOMPUTE = "dc_precompute"
|
||||||
BATCH = "batch"
|
BATCH = "batch"
|
||||||
ALIGN = "align"
|
# The alignment wizard's three background steps: fetching each angle's CH4
|
||||||
MANUAL_ALIGN_MASKS = "manual_align_masks"
|
# image for the mask stack, registering the angles, and writing the aligned
|
||||||
MANUAL_ALIGN_CORRELATE = "manual_align_correlate"
|
# 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 = "",
|
def _make_dspin(lo: float, hi: float, decimals: int, *, suffix: str = "",
|
||||||
@@ -81,6 +85,29 @@ def _make_dspin(lo: float, hi: float, decimals: int, *, suffix: str = "",
|
|||||||
return spin
|
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]:
|
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
|
"""Matplotlib imshow extent with half-pixel margins, Y flipped so row 0
|
||||||
renders at the top."""
|
renders at the top."""
|
||||||
|
|||||||
+9
-609
@@ -1,33 +1,18 @@
|
|||||||
"""FFT Options and Manual Alignment dialogs."""
|
"""FFT option dialogs.
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
import matplotlib as mpl
|
|
||||||
import numpy as np
|
|
||||||
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
|
|
||||||
from PyQt6.QtCore import QSignalBlocker, pyqtSignal
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QButtonGroup, QComboBox, QDialog, QDialogButtonBox, QGroupBox,
|
QButtonGroup, QDialog, QDialogButtonBox, QGroupBox, QHBoxLayout, QLabel,
|
||||||
QHBoxLayout, QLabel, QMessageBox, QPushButton, QRadioButton, QSpinBox,
|
QRadioButton, QSpinBox, QVBoxLayout,
|
||||||
QVBoxLayout, QWidget,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import sras_compute as compute
|
from sras_compute import PYFFTW_AVAILABLE
|
||||||
from sras_compute import (
|
|
||||||
PYFFTW_AVAILABLE, ManualAngleParams, build_manual_alignment,
|
|
||||||
delete_manual_alignment, save_manual_alignment,
|
|
||||||
)
|
|
||||||
from sras_format import SrasFile
|
|
||||||
from sras_workers import Ch4MaskWorker, CrossCorrelateWorker
|
|
||||||
|
|
||||||
from .canvases import ManualAlignOverlayCanvas
|
from .common import _CSS_HINT, _make_dspin
|
||||||
from .common import (
|
|
||||||
_CSS_HINT, _CSS_MUTED, _CSS_WARN, Jobs, _axes_extent, _form, _group, _make_dspin,
|
|
||||||
_scroll_panel, _wrap_label,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .main_window import SrasViewerWindow
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -250,588 +235,3 @@ class RowAverageFftOptionsDialog(QDialog):
|
|||||||
def get_threshold_mv(self) -> float:
|
def get_threshold_mv(self) -> float:
|
||||||
return self._spin_threshold.value()
|
return self._spin_threshold.value()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ManualAlignmentDialog(QDialog):
|
|
||||||
"""Non-modal manual angle-alignment editor (Fusion -> Manual Alignment...).
|
|
||||||
|
|
||||||
Shows every angle's binarized CH4 (Bias B) mask overlaid in a distinct
|
|
||||||
color at partial opacity on one shared canvas, so translation/rotation
|
|
||||||
misalignment is visible by eye. Reference angle (always index 0) is
|
|
||||||
ground truth and never moves; every other angle is aligned to it. The
|
|
||||||
user picks an "active" angle and nudges its rotation+translation with
|
|
||||||
the keyboard; Auto Cross-Correlate finds every non-reference angle's
|
|
||||||
rotation *and* translation by registering its image against the
|
|
||||||
reference's (see compute.register_angle_to_reference) — meant to get every
|
|
||||||
angle stacked on top of each other so keyboard nudging only has to make
|
|
||||||
small corrections, not find an alignment from scratch; Auto De-rotate is
|
|
||||||
the weaker fallback that just seeds rotation from the stage's reported
|
|
||||||
angle, leaving translation alone. Save writes a JSON sidecar next to the
|
|
||||||
.sras file and hands a freshly-built, full-resolution AlignmentResult back
|
|
||||||
to the main window — the exact same object shape compute_angle_alignment
|
|
||||||
produces, so every existing Aligned-View code path (apply_alignment,
|
|
||||||
_aligned_canvas_axes, the pixel-inspector inverse-transform) works
|
|
||||||
completely unmodified.
|
|
||||||
|
|
||||||
Non-modal by design (shown via .show(), never .exec() or setModal(True))
|
|
||||||
so the user can still interact with the main window. Talks back to
|
|
||||||
SrasViewerWindow two ways: it reuses parent._run_worker/_jobs directly
|
|
||||||
for its background mask-fetch and cross-correlate steps, so the main
|
|
||||||
window's existing shutdown/lifecycle plumbing covers both for free, and
|
|
||||||
it emits alignment_saved / alignment_cleared signals for the two moments
|
|
||||||
that should actually mutate the main window's persistent state —
|
|
||||||
everything else (nudging, Auto De-rotate, Auto Cross-Correlate, threshold
|
|
||||||
edits) stays purely local to this dialog until Save.
|
|
||||||
"""
|
|
||||||
|
|
||||||
alignment_saved = pyqtSignal(object, str) # AlignmentResult, sidecar path (str)
|
|
||||||
alignment_cleared = pyqtSignal()
|
|
||||||
|
|
||||||
_PREVIEW_MARGIN_FRAC = 0.15
|
|
||||||
_BASE_ALPHA = 0.42
|
|
||||||
_ACTIVE_ALPHA = 0.75
|
|
||||||
_MAX_PREVIEW_DIM = 1024
|
|
||||||
|
|
||||||
# (label, sources passed to compute.register_angle_to_reference). "Both"
|
|
||||||
# registers on each and keeps whichever scores higher per angle, which
|
|
||||||
# costs roughly double but removes the failure mode where the single
|
|
||||||
# chosen source is the one that happens to be uninformative for one angle.
|
|
||||||
_CORRELATE_SOURCES = (
|
|
||||||
("Both, keep best (recommended)", ("signal", "mask")),
|
|
||||||
("Raw signal", ("signal",)),
|
|
||||||
("Thresholded mask", ("mask",)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, parent: "SrasViewerWindow", sras: SrasFile, *,
|
|
||||||
ref_angle_idx: int, dc_threshold_mv: float,
|
|
||||||
seed_per_angle: dict[int, ManualAngleParams] | None,
|
|
||||||
cached_dc4_mv: dict[int, np.ndarray]):
|
|
||||||
super().__init__(parent)
|
|
||||||
self._parent = parent
|
|
||||||
self._sras = sras
|
|
||||||
self._ref_angle_idx = ref_angle_idx
|
|
||||||
self._downsample = (1, 1) # (rows, cols) block-mean factors
|
|
||||||
self._dc4_mv: dict[int, np.ndarray] = {}
|
|
||||||
self._masks_small: dict[int, np.ndarray] = {}
|
|
||||||
self._preview_layers: dict[int, np.ndarray] = {}
|
|
||||||
self._preview_origin_mm = (0.0, 0.0)
|
|
||||||
self._preview_shape = (1, 1)
|
|
||||||
self._preview_pitch_mm = (1.0, 1.0)
|
|
||||||
self._masks_ready = False
|
|
||||||
self._fit_notes: dict[int, tuple[float, str]] = {}
|
|
||||||
self._derotate_sign_flipped = False
|
|
||||||
|
|
||||||
self.setWindowTitle(f"Manual Alignment — {sras.path.name}")
|
|
||||||
self.resize(1150, 760)
|
|
||||||
|
|
||||||
self._seed_initial_params(seed_per_angle)
|
|
||||||
n = sras.n_angles
|
|
||||||
cmap = mpl.colormaps["tab10"] if n <= 10 else mpl.colormaps["tab20"]
|
|
||||||
self._angle_colors = {a: cmap(a % cmap.N)[:3] for a in range(n)}
|
|
||||||
self._active_angle = 1 if ref_angle_idx == 0 and n > 1 else 0
|
|
||||||
|
|
||||||
self._build_ui(dc_threshold_mv)
|
|
||||||
self._set_controls_enabled(False) # re-enabled once masks are ready
|
|
||||||
self._start_mask_prep(cached_dc4_mv)
|
|
||||||
|
|
||||||
def showEvent(self, event):
|
|
||||||
super().showEvent(event)
|
|
||||||
self.canvas.setFocus()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Construction
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _seed_initial_params(self, seed_per_angle: dict[int, ManualAngleParams] | None):
|
|
||||||
seed = seed_per_angle or {}
|
|
||||||
self._angle_params: dict[int, ManualAngleParams] = {
|
|
||||||
a: (ManualAngleParams(seed[a].rotation_deg, seed[a].shift_mm)
|
|
||||||
if a in seed else ManualAngleParams())
|
|
||||||
for a in range(self._sras.n_angles)
|
|
||||||
}
|
|
||||||
self._angle_params[self._ref_angle_idx] = ManualAngleParams()
|
|
||||||
|
|
||||||
def _build_ui(self, dc_threshold_mv: float):
|
|
||||||
root = QHBoxLayout(self)
|
|
||||||
|
|
||||||
self.canvas = ManualAlignOverlayCanvas()
|
|
||||||
left = QWidget()
|
|
||||||
left_l = QVBoxLayout(left)
|
|
||||||
left_l.setContentsMargins(0, 0, 0, 0)
|
|
||||||
left_l.setSpacing(4)
|
|
||||||
left_l.addWidget(NavigationToolbar2QT(self.canvas, left))
|
|
||||||
left_l.addWidget(self.canvas)
|
|
||||||
root.addWidget(left, stretch=1)
|
|
||||||
|
|
||||||
panel = QWidget()
|
|
||||||
panel_l = QVBoxLayout(panel)
|
|
||||||
panel_l.setContentsMargins(0, 0, 0, 0)
|
|
||||||
panel_l.setSpacing(8)
|
|
||||||
panel_l.addWidget(self._build_angle_group())
|
|
||||||
panel_l.addWidget(self._build_adjust_group())
|
|
||||||
panel_l.addWidget(self._build_step_group())
|
|
||||||
panel_l.addWidget(self._build_threshold_group(dc_threshold_mv))
|
|
||||||
panel_l.addWidget(self._build_correlate_group())
|
|
||||||
panel_l.addWidget(self._build_actions_group())
|
|
||||||
self.lbl_status = _wrap_label("", _CSS_MUTED)
|
|
||||||
panel_l.addWidget(self.lbl_status)
|
|
||||||
panel_l.addStretch()
|
|
||||||
|
|
||||||
root.addWidget(_scroll_panel(panel, 320))
|
|
||||||
self._connect_controls()
|
|
||||||
|
|
||||||
def _build_angle_group(self) -> QWidget:
|
|
||||||
grp_angle, al = _group("Active Angle")
|
|
||||||
self.combo_active_angle = QComboBox()
|
|
||||||
for a in range(self._sras.n_angles):
|
|
||||||
label = f"Angle {a} ({self._sras.angles_deg[a]:.1f}°)"
|
|
||||||
if a == self._ref_angle_idx:
|
|
||||||
label += " [reference]"
|
|
||||||
self.combo_active_angle.addItem(label)
|
|
||||||
al.addWidget(self.combo_active_angle)
|
|
||||||
self.lbl_active_note = _wrap_label("", _CSS_WARN)
|
|
||||||
al.addWidget(self.lbl_active_note)
|
|
||||||
return grp_angle
|
|
||||||
|
|
||||||
def _build_adjust_group(self) -> QWidget:
|
|
||||||
self.grp_manual_adjust, mform_box = _group("Manual Adjustment")
|
|
||||||
mform = _form()
|
|
||||||
self.spin_active_rotation_deg = _make_dspin(-3600.0, 3600.0, 3, suffix=" °")
|
|
||||||
mform.addRow("Rotation:", self.spin_active_rotation_deg)
|
|
||||||
|
|
||||||
self.spin_active_shift_x_mm = _make_dspin(-1e5, 1e5, 4, suffix=" mm")
|
|
||||||
mform.addRow("Shift X:", self.spin_active_shift_x_mm)
|
|
||||||
|
|
||||||
self.spin_active_shift_y_mm = _make_dspin(-1e5, 1e5, 4, suffix=" mm")
|
|
||||||
mform.addRow("Shift Y:", self.spin_active_shift_y_mm)
|
|
||||||
mform_box.addLayout(mform)
|
|
||||||
return self.grp_manual_adjust
|
|
||||||
|
|
||||||
def _build_step_group(self) -> QWidget:
|
|
||||||
self.grp_step_sizes, sl = _group("Nudge Step Sizes")
|
|
||||||
sform = _form()
|
|
||||||
self.spin_step_translate_mm = _make_dspin(0.0001, 1000.0, 4,
|
|
||||||
suffix=" mm", value=0.01)
|
|
||||||
sform.addRow("Translate step:", self.spin_step_translate_mm)
|
|
||||||
|
|
||||||
self.spin_step_rotate_deg = _make_dspin(0.001, 90.0, 3,
|
|
||||||
suffix=" °", value=0.1)
|
|
||||||
sform.addRow("Rotate step:", self.spin_step_rotate_deg)
|
|
||||||
|
|
||||||
self.spin_step_multiplier = _make_dspin(1.0, 1000.0, 1, value=10.0)
|
|
||||||
sform.addRow("Coarse × (Shift):", self.spin_step_multiplier)
|
|
||||||
sl.addLayout(sform)
|
|
||||||
sl.addWidget(_wrap_label(
|
|
||||||
"Arrow keys nudge X/Y translation; Q/E nudge rotation (CCW/CW). "
|
|
||||||
"Hold Shift for the coarse step. Click the image once so it has "
|
|
||||||
"keyboard focus.", _CSS_HINT))
|
|
||||||
return self.grp_step_sizes
|
|
||||||
|
|
||||||
def _build_threshold_group(self, dc_threshold_mv: float) -> QWidget:
|
|
||||||
self.grp_mask_threshold, tl = _group("Mask Threshold")
|
|
||||||
tform = _form()
|
|
||||||
self.spin_mask_threshold_mv = _make_dspin(-500.0, 500.0, 3,
|
|
||||||
suffix=" mV", value=dc_threshold_mv)
|
|
||||||
tform.addRow("DC threshold:", self.spin_mask_threshold_mv)
|
|
||||||
tl.addLayout(tform)
|
|
||||||
return self.grp_mask_threshold
|
|
||||||
|
|
||||||
def _build_correlate_group(self) -> QWidget:
|
|
||||||
self.grp_correlate, cl = _group("Cross-Correlate (FFT)")
|
|
||||||
cform = _form()
|
|
||||||
self.combo_correlate_source = QComboBox()
|
|
||||||
for label, sources in self._CORRELATE_SOURCES:
|
|
||||||
self.combo_correlate_source.addItem(label, sources)
|
|
||||||
cform.addRow("Correlate on:", self.combo_correlate_source)
|
|
||||||
|
|
||||||
self.spin_correlate_search_deg = _make_dspin(0.0, 180.0, 1, suffix=" °",
|
|
||||||
value=6.0, step=1.0)
|
|
||||||
cform.addRow("Rotation search (±):", self.spin_correlate_search_deg)
|
|
||||||
cl.addLayout(cform)
|
|
||||||
self.btn_auto_correlate = QPushButton("Auto Cross-Correlate (vs Reference)")
|
|
||||||
cl.addWidget(self.btn_auto_correlate)
|
|
||||||
cl.addWidget(_wrap_label(
|
|
||||||
"Finds each non-reference angle's rotation *and* translation by "
|
|
||||||
"cross-correlating its image against the reference's — the stage's "
|
|
||||||
"reported angle is only the starting point of the search, and both "
|
|
||||||
"of its signs are tried. Run this first, then nudge only for small "
|
|
||||||
"corrections.", _CSS_HINT))
|
|
||||||
return self.grp_correlate
|
|
||||||
|
|
||||||
def _build_actions_group(self) -> QWidget:
|
|
||||||
grp_actions, acl = _group("Actions")
|
|
||||||
self.btn_auto_derotate = QPushButton("Auto De-rotate (use known angles)")
|
|
||||||
self.btn_save = QPushButton("Save Alignment")
|
|
||||||
self.btn_clear = QPushButton("Clear Alignment…")
|
|
||||||
self.btn_close = QPushButton("Close")
|
|
||||||
for btn in (self.btn_auto_derotate, self.btn_save, self.btn_clear, self.btn_close):
|
|
||||||
acl.addWidget(btn)
|
|
||||||
return grp_actions
|
|
||||||
|
|
||||||
def _connect_controls(self):
|
|
||||||
self.combo_active_angle.currentIndexChanged.connect(self._on_active_angle_changed)
|
|
||||||
self.spin_active_rotation_deg.editingFinished.connect(self._on_rotation_spin_edited)
|
|
||||||
self.spin_active_shift_x_mm.editingFinished.connect(self._on_shift_spin_edited)
|
|
||||||
self.spin_active_shift_y_mm.editingFinished.connect(self._on_shift_spin_edited)
|
|
||||||
self.spin_mask_threshold_mv.editingFinished.connect(self._on_mask_threshold_edited)
|
|
||||||
self.btn_auto_derotate.clicked.connect(self._on_auto_derotate)
|
|
||||||
self.btn_auto_correlate.clicked.connect(self._on_auto_correlate)
|
|
||||||
self.btn_save.clicked.connect(self._on_save)
|
|
||||||
self.btn_clear.clicked.connect(self._on_clear)
|
|
||||||
self.btn_close.clicked.connect(self.close)
|
|
||||||
self.canvas.nudge_translate.connect(self._on_nudge_translate)
|
|
||||||
self.canvas.nudge_rotate.connect(self._on_nudge_rotate)
|
|
||||||
|
|
||||||
with QSignalBlocker(self.combo_active_angle):
|
|
||||||
self.combo_active_angle.setCurrentIndex(self._active_angle)
|
|
||||||
self._on_active_angle_changed(self._active_angle)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Mask preparation (initial CH4 fetch + threshold + downsample)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _start_mask_prep(self, cached_dc4_mv: dict[int, np.ndarray]):
|
|
||||||
self._dc4_mv = dict(cached_dc4_mv)
|
|
||||||
missing = [a for a in range(self._sras.n_angles) if a not in self._dc4_mv]
|
|
||||||
if not missing:
|
|
||||||
self._finish_mask_prep()
|
|
||||||
return
|
|
||||||
self.lbl_status.setText(f"Preparing masks: 0/{len(missing)} angle(s) needed…")
|
|
||||||
started = self._parent._run_worker(
|
|
||||||
Jobs.MANUAL_ALIGN_MASKS, Ch4MaskWorker(self._sras, missing),
|
|
||||||
connect=(
|
|
||||||
("angle_done", self._on_mask_angle_done),
|
|
||||||
("error", lambda msg: self.lbl_status.setText(f"Mask prep error: {msg}")),
|
|
||||||
),
|
|
||||||
on_done=self._finish_mask_prep)
|
|
||||||
if not started:
|
|
||||||
self.lbl_status.setText(
|
|
||||||
"Could not start mask preparation (busy) — close and reopen.")
|
|
||||||
|
|
||||||
def _on_mask_angle_done(self, angle_idx: int, dc4_mv: np.ndarray):
|
|
||||||
self._dc4_mv[angle_idx] = dc4_mv
|
|
||||||
self.lbl_status.setText(
|
|
||||||
f"Preparing masks: {len(self._dc4_mv)}/{self._sras.n_angles} ready…")
|
|
||||||
|
|
||||||
def _finish_mask_prep(self):
|
|
||||||
if len(self._dc4_mv) < self._sras.n_angles:
|
|
||||||
return # a mask-worker error left some angles unfetched
|
|
||||||
# Rows and columns get their own factor. A real scan is ~7500 frames
|
|
||||||
# wide but only ~750 rows tall, so one shared factor sized for the
|
|
||||||
# frames would throw away 8x more row detail than the preview needs and
|
|
||||||
# leave the overlay too coarse in y to judge alignment by eye.
|
|
||||||
max_rows = max(img.shape[0] for img in self._dc4_mv.values())
|
|
||||||
max_cols = max(img.shape[1] for img in self._dc4_mv.values())
|
|
||||||
self._downsample = (
|
|
||||||
max(1, int(np.ceil(max_rows / self._MAX_PREVIEW_DIM))),
|
|
||||||
max(1, int(np.ceil(max_cols / self._MAX_PREVIEW_DIM))))
|
|
||||||
self._recompute_masks_small()
|
|
||||||
self._rebuild_preview_canvas()
|
|
||||||
self._set_controls_enabled(True)
|
|
||||||
self.lbl_status.setText("Ready.")
|
|
||||||
|
|
||||||
def _recompute_masks_small(self):
|
|
||||||
"""Threshold + downsample every angle's already-in-memory full-res
|
|
||||||
CH4 mV image. Cheap (a compare + block-mean), so this re-runs in
|
|
||||||
full whenever the mask-threshold spin box changes — no re-fetch.
|
|
||||||
Purely for the overlay's visuals: no alignment geometry depends on this
|
|
||||||
threshold, only which pixels the overlay paints."""
|
|
||||||
threshold = self.spin_mask_threshold_mv.value()
|
|
||||||
fy, fx = self._downsample
|
|
||||||
self._masks_small = {
|
|
||||||
a: compute.block_mean_2d((img >= threshold).astype(np.float32), fy, fx)
|
|
||||||
for a, img in self._dc4_mv.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Preview canvas: full rebuild vs. incremental single-layer refresh
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _rebuild_preview_canvas(self):
|
|
||||||
"""Full geometry rebuild: recomputes the shared preview canvas's
|
|
||||||
origin/shape (rotation can grow the union bbox — translation alone
|
|
||||||
cannot, per the padding baked in via _PREVIEW_MARGIN_FRAC) and every
|
|
||||||
angle's reprojected mask layer. Triggered by: dialog open,
|
|
||||||
mask-threshold change, Auto De-rotate, a rotation nudge/edit of the
|
|
||||||
active angle. NOT triggered by a translation-only nudge — see
|
|
||||||
_refresh_active_preview_layer."""
|
|
||||||
dx_ref, dy_ref = compute.pixel_pitch_mm(self._sras, self._ref_angle_idx)
|
|
||||||
fy, fx = self._downsample
|
|
||||||
pitch = (dx_ref * fx, dy_ref * fy)
|
|
||||||
origin, shape = compute.canvas_for_params(
|
|
||||||
self._sras, self._ref_angle_idx, pitch, self._angle_params,
|
|
||||||
margin_frac=self._PREVIEW_MARGIN_FRAC, snap=False)
|
|
||||||
self._preview_origin_mm, self._preview_shape = origin, shape
|
|
||||||
self._preview_pitch_mm = pitch
|
|
||||||
self._preview_layers = {
|
|
||||||
a: self._reproject(a) for a in range(self._sras.n_angles)
|
|
||||||
}
|
|
||||||
self._redraw_overlay()
|
|
||||||
|
|
||||||
def _reproject(self, angle_idx: int) -> np.ndarray:
|
|
||||||
"""One angle's downsampled mask on the current preview canvas.
|
|
||||||
src_downsample must match _masks_small's block-mean factors, or the
|
|
||||||
layer lands magnified and offset instead of where the alignment
|
|
||||||
actually puts it."""
|
|
||||||
p = self._angle_params[angle_idx]
|
|
||||||
return compute.reproject_mask(
|
|
||||||
self._sras, angle_idx, self._ref_angle_idx,
|
|
||||||
self._masks_small[angle_idx], p.rotation_deg, p.shift_mm,
|
|
||||||
self._preview_pitch_mm, self._preview_origin_mm, self._preview_shape,
|
|
||||||
src_downsample=self._downsample)
|
|
||||||
|
|
||||||
def _refresh_active_preview_layer(self):
|
|
||||||
"""Cheap path for a translation-only nudge/edit of the active angle:
|
|
||||||
reproject just that one angle's downsampled mask onto the *existing*
|
|
||||||
preview canvas — every other angle's cached layer is untouched."""
|
|
||||||
self._preview_layers[self._active_angle] = self._reproject(self._active_angle)
|
|
||||||
self._redraw_overlay()
|
|
||||||
|
|
||||||
def _redraw_overlay(self):
|
|
||||||
"""Alpha-composite every angle's colored mask layer into one RGBA
|
|
||||||
image ("all thresholds overlaid with varying opacity"). Each angle
|
|
||||||
keeps a fixed, distinct color regardless of which is active; the
|
|
||||||
active angle is drawn last (on top) at a visibly higher alpha so
|
|
||||||
it's easy to track while nudging."""
|
|
||||||
if not self._preview_layers:
|
|
||||||
return # mask prep hasn't finished yet — nothing to draw
|
|
||||||
n_rows, n_cols = self._preview_shape
|
|
||||||
rgba = np.zeros((n_rows, n_cols, 4), dtype=np.float32)
|
|
||||||
order = sorted(range(self._sras.n_angles), key=lambda a: a == self._active_angle)
|
|
||||||
for a in order:
|
|
||||||
layer = self._preview_layers.get(a)
|
|
||||||
if layer is None:
|
|
||||||
continue
|
|
||||||
alpha = self._ACTIVE_ALPHA if a == self._active_angle else self._BASE_ALPHA
|
|
||||||
color = self._angle_colors[a]
|
|
||||||
fg_a = layer * alpha
|
|
||||||
for c in range(3):
|
|
||||||
rgba[..., c] = color[c] * fg_a + rgba[..., c] * rgba[..., 3] * (1 - fg_a)
|
|
||||||
rgba[..., 3] = fg_a + rgba[..., 3] * (1 - fg_a)
|
|
||||||
|
|
||||||
x0, y0 = self._preview_origin_mm
|
|
||||||
dx, dy = self._preview_pitch_mm
|
|
||||||
x_axis = x0 + np.arange(n_cols) * dx
|
|
||||||
y_axis = y0 + np.arange(n_rows) * dy
|
|
||||||
extent = _axes_extent(x_axis, y_axis, dx, dy)
|
|
||||||
title = (f"Angle {self._active_angle} active "
|
|
||||||
f"({self._sras.angles_deg[self._active_angle]:.1f}°)")
|
|
||||||
self.canvas.show_overlay(rgba, extent, title)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Angle selection / nudge / edit handlers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_active_angle_changed(self, angle_idx: int):
|
|
||||||
self._active_angle = angle_idx
|
|
||||||
is_ref = angle_idx == self._ref_angle_idx
|
|
||||||
self.grp_manual_adjust.setEnabled(self._masks_ready and not is_ref)
|
|
||||||
self.lbl_active_note.setText(
|
|
||||||
"Reference angle — defines the shared origin, not adjustable." if is_ref else "")
|
|
||||||
self._sync_active_spinboxes()
|
|
||||||
self._redraw_overlay()
|
|
||||||
|
|
||||||
def _sync_active_spinboxes(self):
|
|
||||||
p = self._angle_params[self._active_angle]
|
|
||||||
for spin, val in ((self.spin_active_rotation_deg, p.rotation_deg),
|
|
||||||
(self.spin_active_shift_x_mm, p.shift_mm[0]),
|
|
||||||
(self.spin_active_shift_y_mm, p.shift_mm[1])):
|
|
||||||
with QSignalBlocker(spin):
|
|
||||||
spin.setValue(val)
|
|
||||||
|
|
||||||
def _on_nudge_translate(self, dir_x: int, dir_y: int, coarse: bool):
|
|
||||||
if not self._masks_ready or self._active_angle == self._ref_angle_idx:
|
|
||||||
return
|
|
||||||
step = self.spin_step_translate_mm.value()
|
|
||||||
if coarse:
|
|
||||||
step *= self.spin_step_multiplier.value()
|
|
||||||
p = self._angle_params[self._active_angle]
|
|
||||||
p.shift_mm = (p.shift_mm[0] + dir_x * step, p.shift_mm[1] + dir_y * step)
|
|
||||||
self._sync_active_spinboxes()
|
|
||||||
self._refresh_active_preview_layer()
|
|
||||||
|
|
||||||
def _on_nudge_rotate(self, direction: int, coarse: bool):
|
|
||||||
if not self._masks_ready or self._active_angle == self._ref_angle_idx:
|
|
||||||
return
|
|
||||||
step = self.spin_step_rotate_deg.value()
|
|
||||||
if coarse:
|
|
||||||
step *= self.spin_step_multiplier.value()
|
|
||||||
self._angle_params[self._active_angle].rotation_deg += direction * step
|
|
||||||
self._sync_active_spinboxes()
|
|
||||||
self._rebuild_preview_canvas()
|
|
||||||
|
|
||||||
def _on_rotation_spin_edited(self):
|
|
||||||
if self._active_angle == self._ref_angle_idx:
|
|
||||||
return
|
|
||||||
self._angle_params[self._active_angle].rotation_deg = self.spin_active_rotation_deg.value()
|
|
||||||
self._rebuild_preview_canvas()
|
|
||||||
|
|
||||||
def _on_shift_spin_edited(self):
|
|
||||||
if self._active_angle == self._ref_angle_idx:
|
|
||||||
return
|
|
||||||
p = self._angle_params[self._active_angle]
|
|
||||||
p.shift_mm = (self.spin_active_shift_x_mm.value(), self.spin_active_shift_y_mm.value())
|
|
||||||
self._refresh_active_preview_layer()
|
|
||||||
|
|
||||||
def _on_mask_threshold_edited(self):
|
|
||||||
if not self._masks_ready:
|
|
||||||
return
|
|
||||||
self._recompute_masks_small()
|
|
||||||
self._rebuild_preview_canvas()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Actions
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_auto_derotate(self):
|
|
||||||
"""Seed every angle's rotation from the stage's reported angle.
|
|
||||||
|
|
||||||
A starting point for nudging by eye, not an alignment: the stage's
|
|
||||||
sign convention relative to this module's is not knowable from the
|
|
||||||
file, so the sign that lines the scans up is whichever of the two looks
|
|
||||||
right in the overlay. Auto Cross-Correlate decides that from the images
|
|
||||||
instead, and is the button to reach for first.
|
|
||||||
"""
|
|
||||||
sign = -1.0 if self._derotate_sign_flipped else 1.0
|
|
||||||
self._derotate_sign_flipped = not self._derotate_sign_flipped
|
|
||||||
n_changed = 0
|
|
||||||
for a in range(self._sras.n_angles):
|
|
||||||
if a == self._ref_angle_idx:
|
|
||||||
continue
|
|
||||||
self._angle_params[a].rotation_deg = sign * compute.nominal_delta_deg(
|
|
||||||
self._sras, a, self._ref_angle_idx)
|
|
||||||
n_changed += 1
|
|
||||||
self._sync_active_spinboxes()
|
|
||||||
self._rebuild_preview_canvas()
|
|
||||||
self.lbl_status.setText(
|
|
||||||
f"Rotation set to the stage angle ({'−' if sign < 0 else '+'}delta) "
|
|
||||||
f"for {n_changed} angle(s); translation untouched. Click again to "
|
|
||||||
"try the opposite sign.")
|
|
||||||
|
|
||||||
def _on_auto_correlate(self):
|
|
||||||
if not self._masks_ready:
|
|
||||||
return
|
|
||||||
angles = [a for a in range(self._sras.n_angles) if a != self._ref_angle_idx]
|
|
||||||
if not angles:
|
|
||||||
return
|
|
||||||
worker = CrossCorrelateWorker(
|
|
||||||
self._sras, self._ref_angle_idx, angles, self._dc4_mv,
|
|
||||||
sources=self.combo_correlate_source.currentData(),
|
|
||||||
dc_threshold_mv=self.spin_mask_threshold_mv.value(),
|
|
||||||
search_deg=self.spin_correlate_search_deg.value())
|
|
||||||
self._correlate_done_count = 0
|
|
||||||
self._correlate_total = len(angles)
|
|
||||||
self._fit_notes = {}
|
|
||||||
self._set_controls_enabled(False)
|
|
||||||
self.lbl_status.setText(f"Cross-correlating: 0/{self._correlate_total} angle(s)…")
|
|
||||||
started = self._parent._run_worker(
|
|
||||||
Jobs.MANUAL_ALIGN_CORRELATE, worker,
|
|
||||||
connect=(
|
|
||||||
("angle_done", self._on_correlate_angle_done),
|
|
||||||
("error", self._on_correlate_error),
|
|
||||||
),
|
|
||||||
on_done=self._finish_auto_correlate)
|
|
||||||
if not started:
|
|
||||||
self._set_controls_enabled(True)
|
|
||||||
self.lbl_status.setText("Could not start cross-correlation (busy) — try again.")
|
|
||||||
|
|
||||||
def _on_correlate_angle_done(self, angle_idx: int, rotation_deg: float,
|
|
||||||
shift_x_mm: float, shift_y_mm: float,
|
|
||||||
score: float, source: str):
|
|
||||||
self._angle_params[angle_idx] = ManualAngleParams(rotation_deg, (shift_x_mm, shift_y_mm))
|
|
||||||
self._fit_notes[angle_idx] = (score, source)
|
|
||||||
self._correlate_done_count += 1
|
|
||||||
self.lbl_status.setText(
|
|
||||||
f"Cross-correlating: {self._correlate_done_count}/{self._correlate_total} angle(s)…")
|
|
||||||
|
|
||||||
def _on_correlate_error(self, msg: str):
|
|
||||||
self.lbl_status.setText(f"Cross-correlation error: {msg}")
|
|
||||||
|
|
||||||
def _finish_auto_correlate(self):
|
|
||||||
self._sync_active_spinboxes()
|
|
||||||
self._rebuild_preview_canvas()
|
|
||||||
self._set_controls_enabled(True)
|
|
||||||
self.lbl_status.setText(
|
|
||||||
f"Cross-correlated {self._correlate_done_count} angle(s) against "
|
|
||||||
f"Angle {self._ref_angle_idx}.\n" + self._fit_report())
|
|
||||||
|
|
||||||
def _fit_report(self) -> str:
|
|
||||||
"""Per-angle registration quality, worst first.
|
|
||||||
|
|
||||||
Surfaced rather than buried because a single bad acquisition (stage
|
|
||||||
glitch, laser dropout) registers poorly and would otherwise be fused in
|
|
||||||
silently — seeing which angle it is, is what makes dropping it with
|
|
||||||
sras_edit_scans.py actionable. The deviation from the stage's own
|
|
||||||
reported angle is shown alongside: a large one means the search and the
|
|
||||||
stage disagree, which is either a genuine mechanical error or a sign
|
|
||||||
that this angle's fit is not to be trusted.
|
|
||||||
"""
|
|
||||||
if not self._fit_notes:
|
|
||||||
return ""
|
|
||||||
rows = sorted(self._fit_notes.items(), key=lambda kv: kv[1][0])
|
|
||||||
worst = rows[0]
|
|
||||||
lines = [f"Worst fit: angle {worst[0]} (score {worst[1][0]:.3f}, "
|
|
||||||
f"{worst[1][1]})."]
|
|
||||||
drifted = []
|
|
||||||
for a, _note in rows:
|
|
||||||
nominal = compute.nominal_delta_deg(self._sras, a, self._ref_angle_idx)
|
|
||||||
got = self._angle_params[a].rotation_deg
|
|
||||||
dev = min(abs(got - nominal), abs(got + nominal))
|
|
||||||
if dev > 1.0:
|
|
||||||
drifted.append(f"{a} ({dev:.2f}°)")
|
|
||||||
if drifted:
|
|
||||||
lines.append("Rotation differs from the stage angle by >1° for "
|
|
||||||
"angle(s) " + ", ".join(drifted) + ".")
|
|
||||||
lines.append("Nudge from here for any remaining fine correction.")
|
|
||||||
return " ".join(lines)
|
|
||||||
|
|
||||||
def _on_save(self):
|
|
||||||
threshold = self.spin_mask_threshold_mv.value()
|
|
||||||
resolved = dict(self._angle_params) # already concrete floats
|
|
||||||
try:
|
|
||||||
path = save_manual_alignment(self._sras, self._ref_angle_idx, threshold, resolved)
|
|
||||||
result = build_manual_alignment(self._sras, self._ref_angle_idx,
|
|
||||||
threshold, resolved)
|
|
||||||
except OSError as exc:
|
|
||||||
QMessageBox.warning(self, "Save Alignment Failed", str(exc))
|
|
||||||
return
|
|
||||||
self.lbl_status.setText(f"Saved to {path.name}.")
|
|
||||||
self.alignment_saved.emit(result, str(path))
|
|
||||||
|
|
||||||
def _on_clear(self):
|
|
||||||
reply = QMessageBox.question(
|
|
||||||
self, "Clear Alignment",
|
|
||||||
"This resets every angle back to raw/unaligned (0° rotation, no "
|
|
||||||
"shift) and deletes the saved alignment file for this scan, if "
|
|
||||||
"any. This cannot be undone. Continue?",
|
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
||||||
QMessageBox.StandardButton.No)
|
|
||||||
if reply != QMessageBox.StandardButton.Yes:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
existed = delete_manual_alignment(self._sras)
|
|
||||||
except OSError as exc:
|
|
||||||
QMessageBox.warning(self, "Clear Alignment Failed",
|
|
||||||
f"Could not delete the saved alignment file: {exc}")
|
|
||||||
return
|
|
||||||
self._angle_params = {a: ManualAngleParams() for a in range(self._sras.n_angles)}
|
|
||||||
self._fit_notes = {}
|
|
||||||
self._sync_active_spinboxes()
|
|
||||||
self._rebuild_preview_canvas()
|
|
||||||
self.lbl_status.setText(
|
|
||||||
"Alignment cleared; saved file removed." if existed
|
|
||||||
else "Alignment cleared (there was no saved file).")
|
|
||||||
self.alignment_cleared.emit()
|
|
||||||
|
|
||||||
def _set_controls_enabled(self, enabled: bool):
|
|
||||||
self._masks_ready = enabled
|
|
||||||
self.combo_active_angle.setEnabled(enabled)
|
|
||||||
self.grp_manual_adjust.setEnabled(enabled and self._active_angle != self._ref_angle_idx)
|
|
||||||
self.grp_step_sizes.setEnabled(enabled)
|
|
||||||
self.grp_mask_threshold.setEnabled(enabled)
|
|
||||||
self.grp_correlate.setEnabled(enabled)
|
|
||||||
self.btn_auto_derotate.setEnabled(enabled)
|
|
||||||
self.btn_save.setEnabled(enabled)
|
|
||||||
self.btn_clear.setEnabled(enabled)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+151
-133
@@ -16,24 +16,25 @@ from PyQt6.QtWidgets import (
|
|||||||
import sras_compute as compute
|
import sras_compute as compute
|
||||||
from sras_compute import (
|
from sras_compute import (
|
||||||
ManualAngleParams, apply_alignment, build_manual_alignment,
|
ManualAngleParams, apply_alignment, build_manual_alignment,
|
||||||
load_manual_alignment, sidecar_path,
|
load_manual_alignment, save_manual_alignment, sidecar_path,
|
||||||
)
|
)
|
||||||
from sras_format import (
|
from sras_format import (
|
||||||
CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, mv_to_adc,
|
CH3_IDX, CH4_IDX, CH_NAMES, SrasFile, mv_to_adc,
|
||||||
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
|
_FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC,
|
||||||
)
|
)
|
||||||
from sras_workers import (
|
from sras_workers import (
|
||||||
AngleAlignmentWorker, BatchCacheWorker, ComputeWorker, DcPrecomputeWorker,
|
BatchCacheWorker, ComputeWorker, DcPrecomputeWorker, LoadWorker,
|
||||||
LoadWorker,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from .canvases import ImageCanvas, WaveformCanvas
|
from .canvases import ImageCanvas, WaveformCanvas
|
||||||
from .common import (
|
from .common import (
|
||||||
CH1_DERIVED_MODES, CH_LABELS, CMAPS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY,
|
CH1_DERIVED_MODES, CH_LABELS, CMAPS, VELOCITY_MODE_IDX, _CHANNEL_DISPLAY,
|
||||||
_CSS_BUSY, _axes_extent, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W,
|
_CSS_BUSY, _axes_extent, _CSS_HINT, _CSS_INFO, _CSS_MUTED, _CSS_WARN, _LEFT_PANEL_W,
|
||||||
_RIGHT_PANEL_W, Jobs, _form, _group, _make_dspin, _scroll_panel, _wrap_label,
|
_RIGHT_PANEL_W, Jobs, _combo, _form, _group, _make_dspin, _scroll_panel,
|
||||||
|
_wrap_label,
|
||||||
)
|
)
|
||||||
from .dialogs import FftOptionsDialog, ManualAlignmentDialog, RowAverageFftOptionsDialog
|
from .align_wizard import AlignmentWizard
|
||||||
|
from .dialogs import FftOptionsDialog, RowAverageFftOptionsDialog
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main window
|
# Main window
|
||||||
@@ -97,9 +98,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
|
|
||||||
# Angle alignment ("Fusion" menu)
|
# Angle alignment ("Fusion" menu)
|
||||||
self._alignment_result = None
|
self._alignment_result = None
|
||||||
self._alignment_generation: int = 0
|
|
||||||
self._aligned_cache: dict[tuple, np.ndarray] = {}
|
self._aligned_cache: dict[tuple, np.ndarray] = {}
|
||||||
self._manual_align_dialog: ManualAlignmentDialog | None = None
|
self._align_wizard: AlignmentWizard | None = None
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
@@ -224,7 +224,14 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self.spin_angle.setRange(0, 0)
|
self.spin_angle.setRange(0, 0)
|
||||||
self.spin_angle.setEnabled(False)
|
self.spin_angle.setEnabled(False)
|
||||||
self.spin_angle.setMinimumWidth(64)
|
self.spin_angle.setMinimumWidth(64)
|
||||||
self.spin_angle.editingFinished.connect(self._on_view_changed)
|
# valueChanged with keyboard tracking off, not editingFinished: the
|
||||||
|
# latter fires only on Return or focus-out, so stepping the angle (an
|
||||||
|
# arrow click or Up/Down, the ordinary way to walk a scan) changed the
|
||||||
|
# number and left the image behind. Tracking off is what keeps
|
||||||
|
# valueChanged from also firing per keystroke mid-typing, which on a
|
||||||
|
# large scan would launch a compute for every intermediate angle.
|
||||||
|
self.spin_angle.setKeyboardTracking(False)
|
||||||
|
self.spin_angle.valueChanged.connect(self._on_view_changed)
|
||||||
self.lbl_angle_deg = QLabel("—")
|
self.lbl_angle_deg = QLabel("—")
|
||||||
angle_field = QWidget()
|
angle_field = QWidget()
|
||||||
ar = QHBoxLayout(angle_field)
|
ar = QHBoxLayout(angle_field)
|
||||||
@@ -235,14 +242,10 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
ar.addStretch()
|
ar.addStretch()
|
||||||
view_form.addRow("Angle:", angle_field)
|
view_form.addRow("Angle:", angle_field)
|
||||||
|
|
||||||
self.combo_channel = QComboBox()
|
self.combo_channel = _combo(CH_LABELS, min_chars=12)
|
||||||
self.combo_channel.addItems(CH_LABELS)
|
|
||||||
self.combo_channel.setEnabled(False)
|
self.combo_channel.setEnabled(False)
|
||||||
self.combo_channel.setSizePolicy(QSizePolicy.Policy.Expanding,
|
self.combo_channel.setSizePolicy(QSizePolicy.Policy.Expanding,
|
||||||
QSizePolicy.Policy.Fixed)
|
QSizePolicy.Policy.Fixed)
|
||||||
self.combo_channel.setSizeAdjustPolicy(
|
|
||||||
QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
|
|
||||||
self.combo_channel.setMinimumContentsLength(12)
|
|
||||||
self.combo_channel.currentIndexChanged.connect(self._on_channel_changed)
|
self.combo_channel.currentIndexChanged.connect(self._on_channel_changed)
|
||||||
view_form.addRow("Channel:", self.combo_channel)
|
view_form.addRow("Channel:", self.combo_channel)
|
||||||
vl.addLayout(view_form)
|
vl.addLayout(view_form)
|
||||||
@@ -430,23 +433,13 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
fft_menu.addAction(fft_act)
|
fft_menu.addAction(fft_act)
|
||||||
|
|
||||||
fusion_menu = menubar.addMenu("&Fusion")
|
fusion_menu = menubar.addMenu("&Fusion")
|
||||||
self._alignment_act = QAction("Angle &Alignment", self)
|
self._wizard_act = QAction("Alignment &Wizard…", self)
|
||||||
self._alignment_act.setStatusTip(
|
self._wizard_act.setStatusTip(
|
||||||
"Compute a rotation+translation alignment across all angles "
|
"Align the angles, crop to a region of interest, and save the "
|
||||||
"(from CH4 masks) and enable Aligned View. Requires >1 angle.")
|
"aligned data as a new .sras file. Requires >1 angle.")
|
||||||
self._alignment_act.setEnabled(False)
|
self._wizard_act.setEnabled(False)
|
||||||
self._alignment_act.triggered.connect(self._on_angle_alignment)
|
self._wizard_act.triggered.connect(self._on_alignment_wizard)
|
||||||
fusion_menu.addAction(self._alignment_act)
|
fusion_menu.addAction(self._wizard_act)
|
||||||
|
|
||||||
self._manual_align_act = QAction("&Manual Alignment…", self)
|
|
||||||
self._manual_align_act.setStatusTip(
|
|
||||||
"Open an interactive dialog to align angles by eye: overlaid CH4 "
|
|
||||||
"threshold masks, keyboard nudge (translate + rotate), auto "
|
|
||||||
"de-rotate to the known scan angles, and save/clear a persistent "
|
|
||||||
"alignment.")
|
|
||||||
self._manual_align_act.setEnabled(False)
|
|
||||||
self._manual_align_act.triggered.connect(self._on_manual_alignment)
|
|
||||||
fusion_menu.addAction(self._manual_align_act)
|
|
||||||
|
|
||||||
convert_menu = menubar.addMenu("&Convert")
|
convert_menu = menubar.addMenu("&Convert")
|
||||||
self._batch_dc_act = QAction("Batch Compute DC and &Store…", self)
|
self._batch_dc_act = QAction("Batch Compute DC and &Store…", self)
|
||||||
@@ -522,9 +515,9 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
# A manual-alignment dialog bound to the previous file must not
|
# A manual-alignment dialog bound to the previous file must not
|
||||||
# survive a reload — its per-angle state (and the sras it was
|
# survive a reload — its per-angle state (and the sras it was
|
||||||
# constructed against) no longer matches the new file's geometry.
|
# constructed against) no longer matches the new file's geometry.
|
||||||
if self._manual_align_dialog is not None:
|
if self._align_wizard is not None:
|
||||||
self._manual_align_dialog.close()
|
self._align_wizard.close()
|
||||||
self._manual_align_dialog = None
|
self._align_wizard = None
|
||||||
|
|
||||||
# Caches (and any in-flight DC precompute) belong to the previous
|
# Caches (and any in-flight DC precompute) belong to the previous
|
||||||
# file's geometry — discard and start fresh. Bumping the generation
|
# file's geometry — discard and start fresh. Bumping the generation
|
||||||
@@ -616,15 +609,42 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)"
|
bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)"
|
||||||
avg_note = (f", row-averaged n={s.precomputed_row_avg_n}"
|
avg_note = (f", row-averaged n={s.precomputed_row_avg_n}"
|
||||||
if s.precomputed_row_avg_n else "")
|
if s.precomputed_row_avg_n else "")
|
||||||
|
pad_note = (f", pad {s.precomputed_pad_factor}x"
|
||||||
|
if s.precomputed_pad_factor > 1 else "")
|
||||||
notes.append(
|
notes.append(
|
||||||
f"Cached images: DC {n_dc}/{s.n_angles} angles, "
|
f"Cached images: DC {n_dc}/{s.n_angles} angles, "
|
||||||
f"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''}"
|
f"FFT {n_fft}/{s.n_angles} angles{bg_note if n_fft else ''}"
|
||||||
f"{avg_note if n_fft else ''} "
|
f"{avg_note if n_fft else ''}{pad_note if n_fft else ''} "
|
||||||
"— display is instant for cached angles")
|
"— display is instant for cached angles")
|
||||||
|
notes += self._cache_mismatch_notes()
|
||||||
elif s.version == 7:
|
elif s.version == 7:
|
||||||
notes.append("v7 format: no cache blocks stored yet")
|
notes.append("v7 format: no cache blocks stored yet")
|
||||||
self.lbl_frame_warn.setText("\n".join(notes))
|
self.lbl_frame_warn.setText("\n".join(notes))
|
||||||
|
|
||||||
|
def _cache_mismatch_notes(self) -> list[str]:
|
||||||
|
"""Why the file's stored FFT images can't serve the current view, if
|
||||||
|
they can't. Padding, bg-sub and row-averaging are all baked into the
|
||||||
|
stored numbers, so changing any of them silently sends every angle
|
||||||
|
back through a real FFT — worth saying out loud rather than leaving
|
||||||
|
the user to wonder why a file they batch-computed got slow.
|
||||||
|
|
||||||
|
Asks compute for the reasons rather than restating the accept rule,
|
||||||
|
so a new provenance field can only be added in one place. The display
|
||||||
|
always asks for a raw per-pixel image, since row-averaging is a batch
|
||||||
|
option with no display control.
|
||||||
|
"""
|
||||||
|
s = self._sras
|
||||||
|
if s is None or all(x is None for x in s.precomputed_freq_mhz):
|
||||||
|
return []
|
||||||
|
|
||||||
|
reasons = compute.cache_mismatch_reasons(
|
||||||
|
s, n_fft=self._current_n_fft(),
|
||||||
|
apply_bg_sub=self.chk_bg_sub.isChecked(), row_avg_n=0)
|
||||||
|
if not reasons:
|
||||||
|
return []
|
||||||
|
return ["! Cached FFT unusable for this view — " + "; ".join(reasons)
|
||||||
|
+ ". FFT angles will recompute."]
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Controls
|
# Controls
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -661,10 +681,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._batch_fft_act.setEnabled(can_batch)
|
self._batch_fft_act.setEnabled(can_batch)
|
||||||
self._batch_fft_rowavg_act.setEnabled(can_batch)
|
self._batch_fft_rowavg_act.setEnabled(can_batch)
|
||||||
|
|
||||||
self._alignment_act.setEnabled(
|
self._wizard_act.setEnabled(
|
||||||
has_file and s.n_angles > 1 and not self._job_running(Jobs.ALIGN))
|
has_file and s.n_angles > 1 and self._align_wizard is None)
|
||||||
self._manual_align_act.setEnabled(
|
|
||||||
has_file and s.n_angles > 1 and not self._job_running(Jobs.ALIGN))
|
|
||||||
self.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None)
|
self.chk_aligned_view.setEnabled(enabled and self._alignment_result is not None)
|
||||||
self._update_roi_ui()
|
self._update_roi_ui()
|
||||||
|
|
||||||
@@ -897,23 +915,60 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._aligned_cache[key] = cached
|
self._aligned_cache[key] = cached
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
def _stored_fft_image(self, angle_idx: int) -> np.ndarray | None:
|
||||||
|
"""The open file's own stored peak-frequency image for the current
|
||||||
|
view, masked and ready to display, or None if the file has nothing
|
||||||
|
that answers this exact view.
|
||||||
|
|
||||||
|
allow_dc_recompute=False keeps this off the I/O path: if the mask
|
||||||
|
would mean reading a whole CH4 channel, this declines and the caller
|
||||||
|
falls through to the background worker, which reaches the same stored
|
||||||
|
image via compute_rf_image and pays for the mask off the GUI thread.
|
||||||
|
"""
|
||||||
|
return compute.cached_rf_image(
|
||||||
|
self._sras, angle_idx,
|
||||||
|
dc_threshold_mv=self.spin_threshold_mv.value(),
|
||||||
|
apply_bg_sub=self.chk_bg_sub.isChecked(),
|
||||||
|
n_fft=self._current_n_fft(),
|
||||||
|
dc4_mv=self._dc_cache.get((angle_idx, CH4_IDX)),
|
||||||
|
allow_dc_recompute=False)
|
||||||
|
|
||||||
def _refresh_display(self):
|
def _refresh_display(self):
|
||||||
"""Show the image for the current angle/channel/threshold, using
|
"""Show the image for the current angle/channel/threshold, using
|
||||||
cached data whenever possible and only falling back to a background
|
cached data whenever possible and only falling back to a background
|
||||||
compute (with progress popup) when genuinely nothing is cached yet."""
|
compute (with progress popup) when genuinely nothing is cached yet.
|
||||||
|
|
||||||
|
Two caches are consulted, in cost order: this window's own in-session
|
||||||
|
dicts, then the file's stored v5/v7 cache blocks. The second 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 on
|
||||||
|
disk, which is exactly the cost the batch was run to avoid.
|
||||||
|
"""
|
||||||
if self._sras is None:
|
if self._sras is None:
|
||||||
return
|
return
|
||||||
angle_idx = self.spin_angle.value()
|
angle_idx = self.spin_angle.value()
|
||||||
ch_idx = self.combo_channel.currentIndex()
|
ch_idx = self.combo_channel.currentIndex()
|
||||||
|
|
||||||
if ch_idx in CH1_DERIVED_MODES:
|
if ch_idx in CH1_DERIVED_MODES:
|
||||||
raw = self._fft_cache.get(self._fft_cache_key(angle_idx))
|
key = self._fft_cache_key(angle_idx)
|
||||||
|
raw = self._fft_cache.get(key)
|
||||||
|
if raw is None:
|
||||||
|
raw = self._stored_fft_image(angle_idx)
|
||||||
|
if raw is not None:
|
||||||
|
# Masking the stored image is cheap but not free; keep the
|
||||||
|
# result so revisiting this angle costs nothing at all.
|
||||||
|
self._fft_cache[key] = raw
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
self._show_image_now(self._scale_for_display(raw, ch_idx),
|
self._show_image_now(self._scale_for_display(raw, ch_idx),
|
||||||
angle_idx, ch_idx)
|
angle_idx, ch_idx)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
# A stored DC image needs no post-processing, so the file's own
|
||||||
|
# parsed array is served directly — as the compute path already
|
||||||
|
# does, _current_image is treated as read-only by every consumer.
|
||||||
cached = self._dc_cache.get((angle_idx, ch_idx))
|
cached = self._dc_cache.get((angle_idx, ch_idx))
|
||||||
|
if cached is None:
|
||||||
|
cached = self._sras.cached_dc_mv(angle_idx, ch_idx)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
self._show_image_now(cached, angle_idx, ch_idx)
|
self._show_image_now(cached, angle_idx, ch_idx)
|
||||||
return
|
return
|
||||||
@@ -1183,7 +1238,10 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._batch_errors = []
|
self._batch_errors = []
|
||||||
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked())
|
# Cache the FFT at the pad the viewer is actually displaying at,
|
||||||
|
# otherwise the batch stores images this window can never use.
|
||||||
|
worker = BatchCacheWorker(paths, mode, self.chk_bg_sub.isChecked(),
|
||||||
|
pad_factor=self._fft_pad_factor)
|
||||||
started = self._run_worker(
|
started = self._run_worker(
|
||||||
Jobs.BATCH, worker,
|
Jobs.BATCH, worker,
|
||||||
connect=(
|
connect=(
|
||||||
@@ -1224,7 +1282,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
|
|
||||||
self._batch_errors = []
|
self._batch_errors = []
|
||||||
worker = BatchCacheWorker(paths, "fft_rowavg", self.chk_bg_sub.isChecked(),
|
worker = BatchCacheWorker(paths, "fft_rowavg", self.chk_bg_sub.isChecked(),
|
||||||
dc_threshold_mv=threshold_mv, row_avg_n=n)
|
dc_threshold_mv=threshold_mv, row_avg_n=n,
|
||||||
|
pad_factor=self._fft_pad_factor)
|
||||||
started = self._run_worker(
|
started = self._run_worker(
|
||||||
Jobs.BATCH, worker,
|
Jobs.BATCH, worker,
|
||||||
connect=(
|
connect=(
|
||||||
@@ -1278,118 +1337,77 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
# Fusion: angle alignment
|
# Fusion: angle alignment
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _on_angle_alignment(self):
|
def _on_alignment_wizard(self):
|
||||||
if self._sras is None or self._sras.n_angles <= 1:
|
if self._sras is None or self._sras.n_angles <= 1:
|
||||||
return
|
return
|
||||||
ref_idx = 0
|
if self._align_wizard is not None:
|
||||||
threshold_mv = self.spin_threshold_mv.value()
|
self._align_wizard.raise_()
|
||||||
generation = self._alignment_generation
|
self._align_wizard.activateWindow()
|
||||||
|
|
||||||
started = self._run_worker(
|
|
||||||
Jobs.ALIGN, AngleAlignmentWorker(self._sras, ref_idx, threshold_mv),
|
|
||||||
connect=(
|
|
||||||
("progress", lambda pct: self._set_progress("main", pct)),
|
|
||||||
("finished", lambda result, err, g=generation:
|
|
||||||
self._on_alignment_done(g, result, err)),
|
|
||||||
),
|
|
||||||
on_done=lambda: self._update_controls_enabled(self._sras is not None),
|
|
||||||
)
|
|
||||||
if not started:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._alignment_act.setEnabled(False)
|
|
||||||
self._show_progress(
|
|
||||||
"main",
|
|
||||||
f"Computing angle alignment ({self._sras.n_angles} angles, "
|
|
||||||
f"ref=angle 0, CH4 mask ≥ {threshold_mv:.3f} mV)…",
|
|
||||||
maximum=100)
|
|
||||||
|
|
||||||
def _on_alignment_done(self, generation: int, result, error_msg: str):
|
|
||||||
self._close_progress("main")
|
|
||||||
if generation != self._alignment_generation:
|
|
||||||
return # a new file was loaded while this was computing — discard
|
|
||||||
if error_msg:
|
|
||||||
self.statusBar().showMessage(f"Angle alignment failed: {error_msg}")
|
|
||||||
return
|
|
||||||
# No generation bump: this result *is* the current generation's.
|
|
||||||
self._apply_alignment_result(result, view_checked=True,
|
|
||||||
bump_generation=False)
|
|
||||||
nr, nc = result.canvas_shape
|
|
||||||
self.statusBar().showMessage(
|
|
||||||
f"Angle alignment computed ({self._sras.n_angles} angles, "
|
|
||||||
f"canvas {nc}×{nr} px).")
|
|
||||||
self._refresh_display()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Fusion: manual alignment
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _on_manual_alignment(self):
|
|
||||||
if self._sras is None or self._sras.n_angles <= 1:
|
|
||||||
return
|
|
||||||
if self._manual_align_dialog is not None:
|
|
||||||
self._manual_align_dialog.raise_()
|
|
||||||
self._manual_align_dialog.activateWindow()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
ref_idx = 0
|
ref_idx = 0
|
||||||
threshold_mv = self.spin_threshold_mv.value()
|
threshold_mv = self.spin_threshold_mv.value()
|
||||||
seed: dict[int, ManualAngleParams] = {}
|
# Only the threshold carries over from a saved alignment. The wizard's
|
||||||
# Seed only from a previously *saved manual* alignment (this dialog's
|
# first page starts every angle pre-rotated from the stage angles and
|
||||||
# own Save also writes this sidecar) -- never from self._alignment_result
|
# owns the parameters from there, so inheriting saved per-angle values
|
||||||
# when it holds the automatic Fusion -> Angle Alignment's output. That
|
# would make "Reset to pre-rotation only" mean something different
|
||||||
# path's translation comes from FFT phase correlation, which is the
|
# each time.
|
||||||
# very thing manual mode exists to work around; inheriting it here
|
|
||||||
# would silently reintroduce the same bad translations under a
|
|
||||||
# "manual" label, on top of the (correct) analytic rotation, which is
|
|
||||||
# exactly what makes manual mode look like it "still does the same
|
|
||||||
# thing" the automatic one does.
|
|
||||||
sidecar = load_manual_alignment(self._sras)
|
sidecar = load_manual_alignment(self._sras)
|
||||||
if sidecar is not None and sidecar.ref_angle_idx == ref_idx:
|
if sidecar is not None and sidecar.ref_angle_idx == ref_idx:
|
||||||
seed = dict(sidecar.per_angle)
|
|
||||||
threshold_mv = sidecar.dc_threshold_mv
|
threshold_mv = sidecar.dc_threshold_mv
|
||||||
|
|
||||||
cached_dc4 = {a: img for (a, ch), img in self._dc_cache.items() if ch == CH4_IDX}
|
cached_dc4 = {a: img for (a, ch), img in self._dc_cache.items() if ch == CH4_IDX}
|
||||||
dlg = ManualAlignmentDialog(
|
wiz = AlignmentWizard(
|
||||||
self, self._sras, ref_angle_idx=ref_idx, dc_threshold_mv=threshold_mv,
|
self, self._sras, ref_angle_idx=ref_idx, dc_threshold_mv=threshold_mv,
|
||||||
seed_per_angle=seed, cached_dc4_mv=cached_dc4)
|
cached_dc4_mv=cached_dc4)
|
||||||
dlg.alignment_saved.connect(self._on_manual_alignment_saved)
|
wiz.alignment_ready.connect(self._on_wizard_finished)
|
||||||
dlg.alignment_cleared.connect(self._on_manual_alignment_cleared)
|
wiz.finished.connect(self._on_wizard_closed)
|
||||||
dlg.finished.connect(self._on_manual_align_dialog_closed)
|
wiz.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
self._align_wizard = wiz
|
||||||
self._manual_align_dialog = dlg
|
self._update_controls_enabled(True)
|
||||||
dlg.show()
|
wiz.show()
|
||||||
|
|
||||||
def _on_manual_align_dialog_closed(self, _result_code: int):
|
def _on_wizard_closed(self, _result_code: int):
|
||||||
self._manual_align_dialog = None
|
self._align_wizard = None
|
||||||
|
self._update_controls_enabled(self._sras is not None)
|
||||||
|
|
||||||
def _apply_alignment_result(self, result, *, view_checked: bool,
|
def _on_wizard_finished(self, result, out_path: str):
|
||||||
bump_generation: bool = True):
|
"""The wizard exported a file; make the session match what was written.
|
||||||
|
|
||||||
|
Applies the *cropped* result, so Aligned View shows exactly the extent
|
||||||
|
that went into the file rather than the wider uncropped canvas, and
|
||||||
|
saves the sidecar for the input scan so reopening it lands in the same
|
||||||
|
place.
|
||||||
|
"""
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
self._apply_alignment_result(result, view_checked=True)
|
||||||
|
note = ""
|
||||||
|
try:
|
||||||
|
save_manual_alignment(
|
||||||
|
self._sras, result.ref_angle_idx, result.dc_threshold_mv,
|
||||||
|
{a: ManualAngleParams(t.rotation_deg, t.shift_mm)
|
||||||
|
for a, t in result.per_angle.items()})
|
||||||
|
except OSError as exc:
|
||||||
|
note = f" (could not save the sidecar: {exc})"
|
||||||
|
self._update_controls_enabled(self._sras is not None)
|
||||||
|
nr, nc = result.canvas_shape
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Aligned scan written to {Path(out_path).name}; Aligned View now "
|
||||||
|
f"shows the exported {nc}×{nr} px region.{note}")
|
||||||
|
if self._current_image is not None:
|
||||||
|
self._refresh_display()
|
||||||
|
|
||||||
|
def _apply_alignment_result(self, result, *, view_checked: bool):
|
||||||
"""Install (or clear, with result=None) the active alignment: reset
|
"""Install (or clear, with result=None) the active alignment: reset
|
||||||
the aligned-image cache and set the Aligned View checkbox without
|
the aligned-image cache and set the Aligned View checkbox without
|
||||||
firing its change signal."""
|
firing its change signal."""
|
||||||
self._alignment_result = result
|
self._alignment_result = result
|
||||||
self._aligned_cache = {}
|
self._aligned_cache = {}
|
||||||
if bump_generation:
|
|
||||||
self._alignment_generation += 1
|
|
||||||
with QSignalBlocker(self.chk_aligned_view):
|
with QSignalBlocker(self.chk_aligned_view):
|
||||||
self.chk_aligned_view.setChecked(view_checked)
|
self.chk_aligned_view.setChecked(view_checked)
|
||||||
self.chk_aligned_view.setEnabled(result is not None)
|
self.chk_aligned_view.setEnabled(result is not None)
|
||||||
|
|
||||||
def _manual_alignment_changed(self, result, message: str):
|
|
||||||
self._apply_alignment_result(result, view_checked=result is not None)
|
|
||||||
self._update_controls_enabled(self._sras is not None)
|
|
||||||
self.statusBar().showMessage(message)
|
|
||||||
if self._current_image is not None:
|
|
||||||
self._refresh_display()
|
|
||||||
|
|
||||||
def _on_manual_alignment_saved(self, result, sidecar_path_str: str):
|
|
||||||
self._manual_alignment_changed(
|
|
||||||
result, f"Manual alignment saved to {Path(sidecar_path_str).name}")
|
|
||||||
|
|
||||||
def _on_manual_alignment_cleared(self):
|
|
||||||
self._manual_alignment_changed(None, "Manual alignment cleared.")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# FFT Options
|
# FFT Options
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1418,8 +1436,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
if self._manual_align_dialog is not None:
|
if self._align_wizard is not None:
|
||||||
self._manual_align_dialog.close()
|
self._align_wizard.close()
|
||||||
|
|
||||||
# Signal every cancellable worker first, then wait. Waiting without
|
# Signal every cancellable worker first, then wait. Waiting without
|
||||||
# signalling means sitting out whatever is in flight — on a large
|
# signalling means sitting out whatever is in flight — on a large
|
||||||
|
|||||||
+60
-43
@@ -15,9 +15,8 @@ import numpy as np
|
|||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
import sras_compute as compute
|
import sras_compute as compute
|
||||||
from sras_compute import (
|
from sras_align_export import write_aligned_sras
|
||||||
cache_file, compute_angle_alignment, compute_rf_image, dc_image_mv,
|
from sras_compute import cache_file, compute_rf_image, dc_image_mv
|
||||||
)
|
|
||||||
from sras_format import CH3_IDX, CH4_IDX, SrasFile
|
from sras_format import CH3_IDX, CH4_IDX, SrasFile
|
||||||
|
|
||||||
# Concurrency caps. Batch conversion runs one process per file, and each of
|
# Concurrency caps. Batch conversion runs one process per file, and each of
|
||||||
@@ -193,6 +192,10 @@ class BatchCacheWorker(QObject):
|
|||||||
averaging before the FFT — needs *dc_threshold_mv* and a positive
|
averaging before the FFT — needs *dc_threshold_mv* and a positive
|
||||||
*row_avg_n*; see ``sras_compute.cache_file``).
|
*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
|
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
|
opens its own memmap and writes only its own bytes, and only path strings
|
||||||
and scalars cross the process boundary. Emits ``progress(int)`` (0–100 by
|
and scalars cross the process boundary. Emits ``progress(int)`` (0–100 by
|
||||||
@@ -204,13 +207,15 @@ class BatchCacheWorker(QObject):
|
|||||||
finished = pyqtSignal()
|
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):
|
dc_threshold_mv: float | None = None, row_avg_n: int = 0,
|
||||||
|
pad_factor: int = 1):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._paths = paths
|
self._paths = paths
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self._apply_bg_sub = apply_bg_sub
|
self._apply_bg_sub = apply_bg_sub
|
||||||
self._dc_threshold = dc_threshold_mv
|
self._dc_threshold = dc_threshold_mv
|
||||||
self._row_avg_n = row_avg_n
|
self._row_avg_n = row_avg_n
|
||||||
|
self._pad_factor = pad_factor
|
||||||
|
|
||||||
def _report(self, path: str, err: str, done: int, total: int):
|
def _report(self, path: str, err: str, done: int, total: int):
|
||||||
self.file_done.emit(path, err)
|
self.file_done.emit(path, err)
|
||||||
@@ -236,6 +241,7 @@ class BatchCacheWorker(QObject):
|
|||||||
futures = {
|
futures = {
|
||||||
executor.submit(cache_file, p, self._mode, self._apply_bg_sub,
|
executor.submit(cache_file, p, self._mode, self._apply_bg_sub,
|
||||||
compute.get_fft_backend(), per_proc_workers,
|
compute.get_fft_backend(), per_proc_workers,
|
||||||
|
pad_factor=self._pad_factor,
|
||||||
dc_threshold_mv=self._dc_threshold,
|
dc_threshold_mv=self._dc_threshold,
|
||||||
row_avg_n=self._row_avg_n): p
|
row_avg_n=self._row_avg_n): p
|
||||||
for p in paths
|
for p in paths
|
||||||
@@ -262,6 +268,7 @@ class BatchCacheWorker(QObject):
|
|||||||
err = cache_file(path, self._mode, self._apply_bg_sub,
|
err = cache_file(path, self._mode, self._apply_bg_sub,
|
||||||
compute.get_fft_backend(),
|
compute.get_fft_backend(),
|
||||||
compute.default_max_workers(),
|
compute.default_max_workers(),
|
||||||
|
pad_factor=self._pad_factor,
|
||||||
dc_threshold_mv=self._dc_threshold,
|
dc_threshold_mv=self._dc_threshold,
|
||||||
row_avg_n=self._row_avg_n)
|
row_avg_n=self._row_avg_n)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -301,34 +308,9 @@ class BatchCacheWorker(QObject):
|
|||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
class AngleAlignmentWorker(QObject):
|
|
||||||
"""Computes the rigid (rotation + translation, never scale) alignment for
|
|
||||||
every angle in *sras* against *ref_angle_idx*, by cross-correlating each
|
|
||||||
angle's CH4 image against the reference's. Both the rotation and the
|
|
||||||
translation are found from image content — see compute_angle_alignment.
|
|
||||||
"""
|
|
||||||
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(_PooledWorker):
|
class Ch4MaskWorker(_PooledWorker):
|
||||||
"""Fetches each requested angle's CH4 (Bias B) DC image in mV, for
|
"""Fetches each requested angle's CH4 (Bias B) DC image in mV, for the
|
||||||
ManualAlignmentDialog's initial threshold-mask overlay.
|
alignment wizard's initial threshold-mask stack.
|
||||||
|
|
||||||
Reuses dc_image_mv, which prefers a stored v5/v7 cache over recomputing
|
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
|
from raw waveforms, so this only does real work for a file that hasn't
|
||||||
@@ -337,7 +319,7 @@ class Ch4MaskWorker(_PooledWorker):
|
|||||||
every file load) hasn't reached yet. In the common case — the user opens
|
every file load) hasn't reached yet. In the common case — the user opens
|
||||||
Fusion -> Manual Alignment after DC precompute has already finished —
|
Fusion -> Manual Alignment after DC precompute has already finished —
|
||||||
*angle_indices* is empty and this worker is never even constructed (see
|
*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
|
angle_done = pyqtSignal(int, np.ndarray) # angle_idx, dc4_mv
|
||||||
|
|
||||||
@@ -364,8 +346,8 @@ class Ch4MaskWorker(_PooledWorker):
|
|||||||
|
|
||||||
class CrossCorrelateWorker(_PooledWorker):
|
class CrossCorrelateWorker(_PooledWorker):
|
||||||
"""Rigid registration (rotation + translation, never scale) of each of
|
"""Rigid registration (rotation + translation, never scale) of each of
|
||||||
*angle_indices* against *ref_angle_idx*, for ManualAlignmentDialog's Auto
|
*angle_indices* against *ref_angle_idx*, for the alignment wizard's
|
||||||
Cross-Correlate button.
|
Run/Re-run Correlation button.
|
||||||
|
|
||||||
Runs on a background thread — registering a real many-angle,
|
Runs on a background thread — registering a real many-angle,
|
||||||
high-resolution scan takes long enough that doing it on the GUI thread
|
high-resolution scan takes long enough that doing it on the GUI thread
|
||||||
@@ -379,17 +361,18 @@ class CrossCorrelateWorker(_PooledWorker):
|
|||||||
angle_done = pyqtSignal(int, float, float, float, float, str)
|
angle_done = pyqtSignal(int, float, float, float, float, str)
|
||||||
|
|
||||||
def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int],
|
def __init__(self, sras: SrasFile, ref_angle_idx: int, angle_indices: list[int],
|
||||||
dc4_mv: dict[int, np.ndarray], *,
|
dc4_mv: dict[int, np.ndarray], *, reg_kwargs: dict | None = None):
|
||||||
sources: tuple[str, ...], dc_threshold_mv: float,
|
"""*reg_kwargs* is splatted into register_angle_to_reference — every
|
||||||
search_deg: float):
|
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__()
|
super().__init__()
|
||||||
self._sras = sras
|
self._sras = sras
|
||||||
self._ref = ref_angle_idx
|
self._ref = ref_angle_idx
|
||||||
self._angles = angle_indices
|
self._angles = angle_indices
|
||||||
self._dc4_mv = dc4_mv
|
self._dc4_mv = dc4_mv
|
||||||
self._sources = sources
|
self._reg_kwargs = dict(reg_kwargs or {})
|
||||||
self._threshold = dc_threshold_mv
|
|
||||||
self._search_deg = search_deg
|
|
||||||
|
|
||||||
def _plan(self) -> int:
|
def _plan(self) -> int:
|
||||||
return compute.registration_workers(self._sras)
|
return compute.registration_workers(self._sras)
|
||||||
@@ -399,11 +382,45 @@ class CrossCorrelateWorker(_PooledWorker):
|
|||||||
|
|
||||||
def _one(self, a: int) -> tuple[int, compute.RigidFit]:
|
def _one(self, a: int) -> tuple[int, compute.RigidFit]:
|
||||||
return a, compute.register_angle_to_reference(
|
return a, compute.register_angle_to_reference(
|
||||||
self._sras, a, self._ref, self._dc4_mv,
|
self._sras, a, self._ref, self._dc4_mv, **self._reg_kwargs)
|
||||||
dc_threshold_mv=self._threshold, sources=self._sources,
|
|
||||||
search_deg=self._search_deg)
|
|
||||||
|
|
||||||
def _emit(self, result):
|
def _emit(self, result):
|
||||||
a, fit = result
|
a, fit = result
|
||||||
self.angle_done.emit(a, fit.rotation_deg, fit.shift_mm[0],
|
self.angle_done.emit(a, fit.rotation_deg, fit.shift_mm[0],
|
||||||
fit.shift_mm[1], fit.score, fit.source)
|
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:
|
||||||
|
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.finished.emit("", str(exc))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -155,7 +155,7 @@ def test_all_angles_stack(rig):
|
|||||||
|
|
||||||
|
|
||||||
def test_downsampled_preview_lands_with_full_res(rig):
|
def test_downsampled_preview_lands_with_full_res(rig):
|
||||||
# ManualAlignmentDialog reprojects block-mean-downsampled masks, so the
|
# The wizard reprojects block-mean-downsampled masks, so the
|
||||||
# affine has to account for the factor. When it did not, every preview
|
# 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
|
# 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.
|
# blown-up crop of each mask, which is not something you can align by eye.
|
||||||
|
|||||||
+357
-170
@@ -3,8 +3,8 @@ signals and worker threads under the offscreen platform plugin.
|
|||||||
|
|
||||||
Covers the interactions a manual smoke test would: load, switch angles and
|
Covers the interactions a manual smoke test would: load, switch angles and
|
||||||
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
|
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
|
||||||
changes, angle alignment, manual angle alignment, aligned view, ROI
|
changes, the alignment wizard end to end (pre-rotation, correlation, manual
|
||||||
draw/move, and CSV export.
|
nudging, crop, export), aligned view, ROI draw/move, and CSV export.
|
||||||
|
|
||||||
NOTE: this module is one ordered integration sequence over a single shared
|
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
|
window — the tests build on each other's state and must run in definition
|
||||||
@@ -106,6 +106,71 @@ def test_angle_switching_from_cache(ctx):
|
|||||||
"no compute job needed for cached DC angles"
|
"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):
|
def test_channel_switching(ctx):
|
||||||
win = ctx.win
|
win = ctx.win
|
||||||
win.spin_angle.setValue(0)
|
win.spin_angle.setValue(0)
|
||||||
@@ -218,44 +283,16 @@ def test_roi_survives_switches(ctx):
|
|||||||
"ROI still present after channel switch"
|
"ROI still present after channel switch"
|
||||||
|
|
||||||
|
|
||||||
def test_angle_alignment(ctx):
|
def test_alignment_geometry_is_stage_independent(ctx):
|
||||||
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._alignment_act.isEnabled(), "alignment action enabled"
|
|
||||||
win._on_angle_alignment()
|
|
||||||
assert wait_until(
|
|
||||||
lambda: win._alignment_result is not None and not win._job_running("align"),
|
|
||||||
timeout_ms=60000), "alignment completed"
|
|
||||||
|
|
||||||
r = win._alignment_result
|
|
||||||
assert len(r.per_angle) == s.n_angles, "transform for every angle"
|
|
||||||
assert 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)), \
|
|
||||||
f"canvas is at least as large as any single angle: {r.canvas_shape}"
|
|
||||||
assert r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0), \
|
|
||||||
"reference angle has zero shift"
|
|
||||||
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked(), \
|
|
||||||
"Aligned View auto-enabled and checked"
|
|
||||||
pump(200)
|
|
||||||
assert 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)
|
|
||||||
assert win.image_canvas._img_shape == s.image_shape(0), \
|
|
||||||
f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_manual_alignment_geometry(ctx):
|
|
||||||
"""Local mm is anchored on each angle's array center, not its stage
|
"""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
|
position: that is what makes a scan's placement independent of where its
|
||||||
window happened to sit. (Registration accuracy itself is covered by
|
window happened to sit. (Registration accuracy itself is covered by
|
||||||
tests/test_alignment.py, which has a synthetic sample to register.)"""
|
tests/test_alignment.py, which has a synthetic sample to register.)"""
|
||||||
win, s = ctx.win, ctx.s
|
win, s = ctx.win, ctx.s
|
||||||
assert win._manual_align_act.isEnabled(), "manual alignment action enabled"
|
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)
|
n_rows, n_frames = s.image_shape(0)
|
||||||
assert np.allclose(compute._center_idx(s, 0),
|
assert np.allclose(compute._center_idx(s, 0),
|
||||||
@@ -276,7 +313,7 @@ def test_manual_alignment_geometry(ctx):
|
|||||||
("moving every non-reference angle's scan window must leave the canvas "
|
("moving every non-reference angle's scan window must leave the canvas "
|
||||||
f"unchanged: {origin_a} {shape_a} vs {origin_b} {shape_b}")
|
f"unchanged: {origin_a} {shape_a} vs {origin_b} {shape_b}")
|
||||||
|
|
||||||
# Both signs of the stage's reported angle are searched.
|
# Both signs of the stage's reported angle are searched by default.
|
||||||
cands = compute._rotation_candidates(30.0, 6.0, 2.0)
|
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)}"
|
assert min(cands) < -29.0 and max(cands) > 29.0, f"{min(cands)}..{max(cands)}"
|
||||||
|
|
||||||
@@ -289,128 +326,295 @@ def test_manual_alignment_geometry(ctx):
|
|||||||
"_shift_into moves content by exactly the requested offset"
|
"_shift_into moves content by exactly the requested offset"
|
||||||
|
|
||||||
|
|
||||||
def test_manual_dialog_opens_at_identity(ctx):
|
def test_wizard_opens_prerotated(ctx):
|
||||||
"""Open must NOT seed from the still-live automatic AlignmentResult.
|
"""The wizard shows a mask stack before any correlation has run, built from
|
||||||
Manual mode exists to fix up whatever the automatic registration got
|
the stage angles in the file — the "pre-rotate" step. Nothing may seed from
|
||||||
wrong, so it must start from identity (every angle centered on the
|
the still-live automatic result; only a saved sidecar."""
|
||||||
reference, no rotation) regardless of whatever the automatic run last
|
|
||||||
computed. Only a previously *saved manual* alignment (sidecar) should
|
|
||||||
ever seed this dialog."""
|
|
||||||
win, s = ctx.win, ctx.s
|
win, s = ctx.win, ctx.s
|
||||||
win._on_manual_alignment()
|
win._on_alignment_wizard()
|
||||||
assert win._manual_align_dialog is not None, "dialog opened"
|
assert win._align_wizard is not None, "wizard opened"
|
||||||
ctx.dlg = dlg = win._manual_align_dialog
|
ctx.wiz = wiz = win._align_wizard
|
||||||
assert not win._job_running("manual_align_masks"), \
|
ctx.p1 = p1 = wiz.page(wiz.PAGE_CORRELATE)
|
||||||
|
|
||||||
|
assert not win._job_running("align_masks"), \
|
||||||
"mask prep needed no background worker (already DC-cached)"
|
"mask prep needed no background worker (already DC-cached)"
|
||||||
assert all(dlg._angle_params[a] == compute.ManualAngleParams()
|
assert wait_until(lambda: p1.isComplete()), "masks ready, Next enabled"
|
||||||
for a in range(s.n_angles)), \
|
assert not win._wizard_act.isEnabled(), \
|
||||||
"no manual sidecar yet -> dialog starts at identity, not the automatic result"
|
"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_reference_angle_is_locked(ctx):
|
def test_wizard_reference_angle_is_locked(ctx):
|
||||||
dlg = ctx.dlg
|
p1, wiz = ctx.p1, ctx.wiz
|
||||||
dlg.combo_active_angle.setCurrentIndex(dlg._ref_angle_idx)
|
p1.combo_active.setCurrentIndex(wiz.state.ref_angle_idx)
|
||||||
pump(30)
|
pump(30)
|
||||||
before_ref = dlg._angle_params[dlg._ref_angle_idx]
|
before = wiz.state.params[wiz.state.ref_angle_idx]
|
||||||
dlg._on_nudge_translate(1, 0, False)
|
p1._on_nudge_translate(1, 0, False)
|
||||||
dlg._on_nudge_rotate(1, False)
|
p1._on_nudge_rotate(1, False)
|
||||||
assert not dlg.grp_manual_adjust.isEnabled(), "reference angle group disabled"
|
assert wiz.state.params[wiz.state.ref_angle_idx] == before, \
|
||||||
assert dlg._angle_params[dlg._ref_angle_idx] == before_ref, \
|
|
||||||
"reference angle untouched by nudge attempts"
|
"reference angle untouched by nudge attempts"
|
||||||
|
|
||||||
|
|
||||||
def test_nudges(ctx):
|
def test_wizard_nudges(ctx):
|
||||||
"""Nudging a real angle (fine + coarse, translate + rotate)."""
|
"""Manual correction, which the wizard absorbed from the old dialog."""
|
||||||
dlg, s = ctx.dlg, ctx.s
|
p1, wiz, s = ctx.p1, ctx.wiz, ctx.s
|
||||||
ctx.active = active = 1 if s.n_angles > 1 else 0
|
ctx.active = active = 1 if s.n_angles > 1 else 0
|
||||||
dlg.combo_active_angle.setCurrentIndex(active)
|
p1.combo_active.setCurrentIndex(active)
|
||||||
pump(30)
|
pump(30)
|
||||||
before = dlg._angle_params[active].shift_mm
|
|
||||||
dlg._on_nudge_translate(1, 0, False) # fine +X
|
before = wiz.state.params[active].shift_mm
|
||||||
fine_step = dlg.spin_step_translate_mm.value()
|
p1._on_nudge_translate(1, 0, False)
|
||||||
assert abs(dlg._angle_params[active].shift_mm[0] - (before[0] + fine_step)) < 1e-9, \
|
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"
|
"fine translate nudge moved shift_x by exactly one fine step"
|
||||||
|
|
||||||
before = dlg._angle_params[active].shift_mm
|
before = wiz.state.params[active].shift_mm
|
||||||
dlg._on_nudge_translate(0, -1, True) # coarse -Y
|
p1._on_nudge_translate(0, -1, True)
|
||||||
coarse_step = fine_step * dlg.spin_step_multiplier.value()
|
coarse = fine * p1.spin_step_mult.value()
|
||||||
assert abs(dlg._angle_params[active].shift_mm[1] - (before[1] - coarse_step)) < 1e-9, \
|
assert abs(wiz.state.params[active].shift_mm[1] - (before[1] - coarse)) < 1e-9, \
|
||||||
"coarse translate nudge uses the multiplier"
|
"coarse translate nudge uses the multiplier"
|
||||||
|
|
||||||
before_rot = dlg._angle_params[active].rotation_deg
|
before_rot = wiz.state.params[active].rotation_deg
|
||||||
dlg._on_nudge_rotate(1, False)
|
p1._on_nudge_rotate(1, False)
|
||||||
assert dlg._angle_params[active].rotation_deg != before_rot, \
|
assert wiz.state.params[active].rotation_deg != before_rot
|
||||||
"rotate nudge changed rotation_deg"
|
assert len(wiz.state.layers) == s.n_angles, \
|
||||||
assert len(dlg._preview_layers) == s.n_angles, \
|
"stack rebuilt for every angle after a rotation nudge"
|
||||||
"preview canvas rebuilt for every angle after a rotation nudge"
|
|
||||||
|
|
||||||
# Real key-event wiring (proves keyPressEvent -> signal -> slot).
|
# A nudge only reprojects the angle that moved, patching the overlap counts
|
||||||
before = dlg._angle_params[active].shift_mm
|
# in place. That shortcut is only sound if it lands on exactly what a full
|
||||||
QTest.keyClick(dlg.canvas, Qt.Key.Key_Right)
|
# rebuild would have produced.
|
||||||
assert dlg._angle_params[active].shift_mm[0] > before[0], \
|
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"
|
"a real Right-arrow key event nudged shift_x"
|
||||||
|
|
||||||
|
# Both views render from the same reprojected layers.
|
||||||
def test_auto_derotate(ctx):
|
p1.combo_view.setCurrentIndex(1)
|
||||||
"""Auto De-rotate: seeds rotation from the stage angle, no translation."""
|
pump(50)
|
||||||
dlg, s, active = ctx.dlg, ctx.s, ctx.active
|
p1.combo_view.setCurrentIndex(0)
|
||||||
shift_before_derotate = dlg._angle_params[active].shift_mm
|
pump(50)
|
||||||
dlg._on_auto_derotate()
|
|
||||||
nominal = compute.nominal_delta_deg(s, active, dlg._ref_angle_idx)
|
|
||||||
assert abs(dlg._angle_params[active].rotation_deg - nominal) < 1e-6, \
|
|
||||||
"auto de-rotate seeded rotation from the stage's reported angle"
|
|
||||||
assert dlg._angle_params[active].shift_mm == shift_before_derotate, \
|
|
||||||
"auto de-rotate left translation untouched"
|
|
||||||
assert dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0, \
|
|
||||||
"reference angle stays identity after auto de-rotate"
|
|
||||||
# Clicking again offers the other sign, since which one lines the scans up
|
|
||||||
# is not knowable from the file.
|
|
||||||
dlg._on_auto_derotate()
|
|
||||||
assert abs(dlg._angle_params[active].rotation_deg + nominal) < 1e-6, \
|
|
||||||
"auto de-rotate offers the opposite sign on a second click"
|
|
||||||
|
|
||||||
|
|
||||||
def test_auto_cross_correlate(ctx):
|
def test_wizard_correlate(ctx):
|
||||||
"""Auto Cross-Correlate: searches rotation *and* translation."""
|
"""Cross-correlation, for every source option, retryable."""
|
||||||
win, dlg, s = ctx.win, ctx.dlg, ctx.s
|
win, p1, wiz, s = ctx.win, ctx.p1, ctx.wiz, ctx.s
|
||||||
assert dlg.btn_auto_correlate.isEnabled(), \
|
from sras_viewer.align_wizard import _CORRELATE_SOURCES
|
||||||
"cross-correlate action enabled once masks are ready"
|
|
||||||
for label_idx, (label, _sources) in enumerate(dlg._CORRELATE_SOURCES):
|
for idx, (label, _sources) in enumerate(_CORRELATE_SOURCES):
|
||||||
dlg.combo_correlate_source.setCurrentIndex(label_idx)
|
p1.combo_source.setCurrentIndex(idx)
|
||||||
dlg._on_auto_correlate()
|
p1.btn_correlate.click()
|
||||||
assert wait_until(
|
assert not p1.isComplete(), \
|
||||||
lambda: not win._job_running("manual_align_correlate"),
|
f"Next must be disabled while correlating ({label})"
|
||||||
timeout_ms=60000), f"auto cross-correlate completed ({label})"
|
assert wait_until(lambda: not win._job_running("align_correlate"),
|
||||||
assert all(a in dlg._fit_notes for a in range(s.n_angles)
|
timeout_ms=60000), f"correlation finished ({label})"
|
||||||
if a != dlg._ref_angle_idx), \
|
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})"
|
f"every non-reference angle got a fit ({label})"
|
||||||
assert dlg._angle_params[dlg._ref_angle_idx] == compute.ManualAngleParams(), \
|
|
||||||
"auto cross-correlate reference angle stays identity"
|
assert wiz.state.params[wiz.state.ref_angle_idx] == compute.ManualAngleParams(), \
|
||||||
assert dlg.grp_correlate.isEnabled() and dlg.btn_save.isEnabled(), \
|
"reference angle stays identity after correlation"
|
||||||
"auto cross-correlate re-enabled controls when done"
|
assert p1.btn_correlate.isEnabled(), "controls re-enabled when done"
|
||||||
assert len(dlg._preview_layers) == s.n_angles, \
|
assert p1.table.rowCount() == s.n_angles and p1.table.item(0, 0) is not None, \
|
||||||
"preview canvas rebuilt after cross-correlate"
|
"per-angle fit table populated"
|
||||||
assert dlg._fit_report(), "fit quality is reported per angle"
|
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_save_sidecar(ctx):
|
def test_wizard_retry_changes_geometry(ctx):
|
||||||
win, dlg, s, active = ctx.win, ctx.dlg, ctx.s, ctx.active
|
"""Editing a parameter and re-running is the retry path, and it must
|
||||||
dlg._on_save()
|
invalidate anything indexed against the old canvas."""
|
||||||
sidecar = compute.sidecar_path(s.path)
|
p1, wiz = ctx.p1, ctx.wiz
|
||||||
assert sidecar.exists(), "sidecar file written"
|
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 = sidecar
|
||||||
ctx.sidecar_raw = raw = json.loads(sidecar.read_text())
|
ctx.sidecar_raw = raw = json.loads(sidecar.read_text())
|
||||||
assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION, \
|
assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION
|
||||||
"sidecar schema_version is current"
|
assert all(raw["per_angle"][str(a)]["rotation_deg"]
|
||||||
assert all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg")
|
== win._alignment_result.per_angle[a].rotation_deg
|
||||||
== dlg._angle_params[a].rotation_deg for a in range(s.n_angles)), \
|
for a in range(ctx.s.n_angles)), \
|
||||||
"sidecar per_angle round-trips the dialog's resolved params"
|
"sidecar round-trips the applied rotations"
|
||||||
assert (win._alignment_result is not None
|
|
||||||
and win._alignment_result.per_angle[active].rotation_deg
|
win.chk_aligned_view.setChecked(False)
|
||||||
== dlg._angle_params[active].rotation_deg), \
|
pump(200)
|
||||||
"main window's alignment_result replaced by the manual build"
|
assert win.image_canvas._img_shape == ctx.s.image_shape(0), \
|
||||||
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked(), \
|
f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}"
|
||||||
"Aligned View auto-enabled after Save"
|
|
||||||
|
|
||||||
|
|
||||||
def test_stale_schema_sidecar_ignored(ctx):
|
def test_stale_schema_sidecar_ignored(ctx):
|
||||||
@@ -424,56 +628,39 @@ def test_stale_schema_sidecar_ignored(ctx):
|
|||||||
sidecar.write_text(json.dumps(raw)) # restore for the rest of the sequence
|
sidecar.write_text(json.dumps(raw)) # restore for the rest of the sequence
|
||||||
|
|
||||||
|
|
||||||
def test_clear_with_confirmation(ctx):
|
|
||||||
win, dlg, s = ctx.win, ctx.dlg, ctx.s
|
|
||||||
with patch("sras_viewer.dialogs.QMessageBox.question",
|
|
||||||
return_value=QMessageBox.StandardButton.Yes):
|
|
||||||
dlg._on_clear()
|
|
||||||
assert not ctx.sidecar.exists(), "sidecar file deleted"
|
|
||||||
assert all(dlg._angle_params[a] == compute.ManualAngleParams()
|
|
||||||
for a in range(s.n_angles)), "dialog params reset to identity"
|
|
||||||
assert win._alignment_result is None, "main window alignment_result cleared"
|
|
||||||
assert (not win.chk_aligned_view.isEnabled()
|
|
||||||
and not win.chk_aligned_view.isChecked()), \
|
|
||||||
"Aligned View disabled after Clear"
|
|
||||||
|
|
||||||
dlg.close()
|
|
||||||
pump(150)
|
|
||||||
assert win._manual_align_dialog is None, "dialog reference released on close"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sidecar_restored_on_reload(ctx):
|
def test_sidecar_restored_on_reload(ctx):
|
||||||
win, active = ctx.win, ctx.active
|
win, active = ctx.win, ctx.active
|
||||||
win._on_manual_alignment()
|
saved = json.loads(ctx.sidecar.read_text())["per_angle"][str(active)]
|
||||||
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)
|
old_sras_id = id(win._sras)
|
||||||
win._load_file(str(ctx.path)) # reload the same file fresh
|
win._load_file(str(ctx.path)) # reload the same file fresh
|
||||||
assert wait_until(
|
assert wait_until(
|
||||||
lambda: win._sras is not None and id(win._sras) != old_sras_id), \
|
lambda: win._sras is not None and id(win._sras) != old_sras_id), \
|
||||||
"file reloaded"
|
"file reloaded"
|
||||||
ctx.s = win._sras
|
ctx.s = win._sras
|
||||||
assert win._manual_align_dialog is None, \
|
assert win._align_wizard is None, "no wizard left open across a reload"
|
||||||
"manual dialog force-closed by a reload"
|
|
||||||
assert win._alignment_result is not None, \
|
assert win._alignment_result is not None, \
|
||||||
"reload restores the saved manual alignment automatically"
|
"reload restores the saved alignment automatically"
|
||||||
assert abs(win._alignment_result.per_angle[active].rotation_deg
|
assert abs(win._alignment_result.per_angle[active].rotation_deg
|
||||||
- saved_rotation) < 1e-9, "restored rotation matches what was saved"
|
- saved["rotation_deg"]) < 1e-9, \
|
||||||
assert win._alignment_result.per_angle[active].shift_mm == saved_shift, \
|
"restored rotation matches what was saved"
|
||||||
"restored shift matches what was saved"
|
|
||||||
assert win.chk_aligned_view.isChecked(), \
|
assert win.chk_aligned_view.isChecked(), \
|
||||||
"Aligned View auto-checked after restoring a saved alignment"
|
"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
|
||||||
|
|
||||||
|
|
||||||
def test_pixel_inspector(ctx):
|
def test_pixel_inspector(ctx):
|
||||||
win = ctx.win
|
win = ctx.win
|
||||||
win.chk_aligned_view.setChecked(False)
|
win.chk_aligned_view.setChecked(False)
|
||||||
|
|||||||
Reference in New Issue
Block a user