Dedup format layer; public accessors replace private reach-throughs

- SrasFile: _parse_v6 now retains per-angle x_delta and the verbatim
  preamble/background byte spans, and gains public data_offset,
  y_pos_per_angle, and iter_angle_blocks() (which now owns the ragged
  block-offset walk used three separate places before).
- sras_edit_scans: the 70-line re-parse of the v6 header sections
  (_read_v6_sections/_reread_span/_v6_angle_offsets) collapses into a
  _write_v6 that consumes SrasFile directly — verified byte-identical
  round-trip on v6 int8/int16 and legacy files. Eight print-and-exit
  pairs become _die().
- tools/make_test_sras imports the struct layouts from sras_format and
  the rotation matrix from sras_compute instead of re-declaring them
  (byte assembly stays independent of the reader).
- sras_compute: block_mean_2d, pixel_pitch_mm, nominal_delta_deg made
  public (they were GUI-facing); registration_workers() and
  default_max_workers() wrap the remaining private reach-throughs from
  sras_workers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-08-06 10:40:42 -05:00
parent e80717d5c5
commit 00a7afade0
10 changed files with 134 additions and 156 deletions
+36 -112
View File
@@ -25,15 +25,17 @@ import struct
import sys
from pathlib import Path
from sras_format import (
GEO_FMT_V6, GEO_SIZE_V6, HDR_FMT, HDR_FMT_V6, HDR_SIZE, HDR_SIZE_V6,
SrasFile,
)
from sras_format import GEO_FMT_V6, HDR_FMT, HDR_FMT_V6, HDR_SIZE, SrasFile
_LEGACY_VERSIONS = (2, 3, 4, 5)
_V6_VERSIONS = (6, 7)
def _die(msg: str):
print(f"Error: {msg}", file=sys.stderr)
sys.exit(1)
def parse_args():
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
@@ -106,7 +108,7 @@ def _write_legacy(sras: SrasFile, keep: list[int], out_path: Path):
angle_table_size = sras.n_angles * 4
with open(sras.path, "rb") as f:
f.seek(HDR_SIZE + angle_table_size)
shared_mid = f.read(sras._data_offset - (HDR_SIZE + angle_table_size))
shared_mid = f.read(sras.data_offset - (HDR_SIZE + angle_table_size))
angle_bytes = n_rows * n_ch * n_frames * spf * bps
@@ -115,128 +117,55 @@ def _write_legacy(sras: SrasFile, keep: list[int], out_path: Path):
fout.write(sras.angles_deg[keep].astype(">f4").tobytes())
fout.write(shared_mid)
for a in keep:
_copy_range(fin, fout, sras._data_offset + a * angle_bytes, angle_bytes)
_copy_range(fin, fout, sras.data_offset + a * angle_bytes, angle_bytes)
# ---------------------------------------------------------------------------
# v6/v7: per-angle geometry, ragged waveform blocks
# ---------------------------------------------------------------------------
def _read_v6_sections(path: Path) -> dict:
"""Raw, low-level read of everything before the waveform data.
SrasFile._parse_v6 reads and discards each angle's x_delta (it's not
part of the display-facing geometry it exposes), so a round-trip through
SrasFile would silently drop that field. Re-parsing here keeps every
byte of the Per-Angle Geometry Table intact.
"""
with open(path, "rb") as f:
hdr_raw = f.read(HDR_SIZE_V6)
(magic, ver, n_angles_declared, x_start_nom, y_start_nom, x_delta_nom,
y_delta_nom, row_spacing, vel, freq, spf, sr, bps,
n_ch) = struct.unpack(HDR_FMT_V6, hdr_raw)
angles_deg = list(struct.unpack(f">{n_angles_declared}f",
f.read(n_angles_declared * 4)))
geo = [struct.unpack(GEO_FMT_V6, f.read(GEO_SIZE_V6))
for _ in range(n_angles_declared)] # (x_start, x_delta, n_frames, n_rows)
row_table = [f.read(geo[a][3] * 4) for a in range(n_angles_declared)]
preamble_start = f.tell()
for _ in range(n_ch):
(length,) = struct.unpack(">H", f.read(2))
f.read(length)
preambles_raw = _reread_span(f, preamble_start)
bg_start = f.tell()
(n_bg,) = struct.unpack(">I", f.read(4))
f.read(n_bg)
background_raw = _reread_span(f, bg_start)
data_offset = f.tell()
return {
"version": ver, "n_angles_declared": n_angles_declared,
"x_start_nom": x_start_nom, "y_start_nom": y_start_nom,
"x_delta_nom": x_delta_nom, "y_delta_nom": y_delta_nom,
"row_spacing": row_spacing, "vel": vel, "freq": freq,
"spf": spf, "sr": sr, "bps": bps, "n_ch": n_ch,
"angles_deg": angles_deg, "geo": geo, "row_table": row_table,
"preambles_raw": preambles_raw, "background_raw": background_raw,
"data_offset": data_offset,
}
def _reread_span(f, start: int) -> bytes:
end = f.tell()
f.seek(start)
span = f.read(end - start)
f.seek(end)
return span
def _v6_angle_offsets(sections: dict, file_size: int) -> list[tuple[int, int]]:
"""(offset, nbytes) of each declared angle's waveform block, stopping at
the first angle whose data isn't fully on disk (aborted scan)."""
n_ch, spf, bps = sections["n_ch"], sections["spf"], sections["bps"]
offsets = []
offset = sections["data_offset"]
for xs, xd, nf, nr in sections["geo"]:
nbytes = nr * n_ch * nf * spf * bps
if offset + nbytes > file_size:
break
offsets.append((offset, nbytes))
offset += nbytes
return offsets
def _write_v6(in_path: Path, sections: dict, keep: list[int], out_path: Path):
file_size = in_path.stat().st_size
offsets = _v6_angle_offsets(sections, file_size)
geo = sections["geo"]
def _write_v6(sras: SrasFile, keep: list[int], out_path: Path):
header = struct.pack(
HDR_FMT_V6, b"SRAS", sections["version"], len(keep),
sections["x_start_nom"], sections["y_start_nom"],
sections["x_delta_nom"], sections["y_delta_nom"],
sections["row_spacing"], sections["vel"], sections["freq"],
sections["spf"], sections["sr"], sections["bps"], sections["n_ch"],
HDR_FMT_V6, b"SRAS", sras.version, len(keep),
sras.x_start_nominal_mm, sras.y_start_nominal_mm,
sras.x_delta_nominal_mm, sras.y_delta_nominal_mm,
sras.row_spacing_mm, sras.velocity_mm_s, sras.laser_freq_hz,
sras.samples_per_frame, sras.sample_rate_hz,
sras.bytes_per_sample, sras.n_channels,
)
with open(in_path, "rb") as fin, open(out_path, "wb") as fout:
blocks = {a: (offset, nbytes) for a, offset, nbytes in sras.iter_angle_blocks()}
with open(sras.path, "rb") as fin, open(out_path, "wb") as fout:
fout.write(header)
fout.write(struct.pack(f">{len(keep)}f",
*[sections["angles_deg"][i] for i in keep]))
fout.write(sras.angles_deg[keep].astype(">f4").tobytes())
for i in keep:
fout.write(struct.pack(GEO_FMT_V6, *geo[i]))
fout.write(struct.pack(
GEO_FMT_V6, float(sras.x_start_mm[i]),
float(sras.x_delta_mm_per_angle[i]),
int(sras.n_frames[i]), int(sras.n_rows[i])))
for i in keep:
fout.write(sections["row_table"][i])
fout.write(sections["preambles_raw"])
fout.write(sections["background_raw"])
fout.write(sras.y_pos_per_angle[i].astype(">f4").tobytes())
fout.write(sras.preambles_raw)
fout.write(sras.background_raw)
for i in keep:
off, nbytes = offsets[i]
_copy_range(fin, fout, off, nbytes)
offset, nbytes = blocks[i]
_copy_range(fin, fout, offset, nbytes)
def main():
args = parse_args()
in_path = Path(args.input)
if not in_path.exists():
print(f"Error: input file not found: {in_path}", file=sys.stderr)
sys.exit(1)
_die(f"input file not found: {in_path}")
print(f"Reading {in_path} ...", flush=True)
try:
sras = SrasFile(str(in_path))
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
_die(str(e))
if sras.version not in (*_LEGACY_VERSIONS, *_V6_VERSIONS):
print(f"Error: unsupported .sras version: {sras.version}", file=sys.stderr)
sys.exit(1)
_die(f"unsupported .sras version: {sras.version}")
aborted_note = " (scan aborted; trailing angle(s) already excluded)" if sras.scan_aborted else ""
print(f" Version : v{sras.version}", flush=True)
@@ -247,16 +176,13 @@ def main():
return
if not args.output:
print("Error: output path required unless --list is given.", file=sys.stderr)
sys.exit(1)
_die("output path required unless --list is given.")
if not (args.drop or args.keep):
print("Error: specify --drop or --keep (see --list for indices).", file=sys.stderr)
sys.exit(1)
_die("specify --drop or --keep (see --list for indices).")
out_path = Path(args.output)
if out_path.resolve() == in_path.resolve():
print("Error: output path must differ from input path.", file=sys.stderr)
sys.exit(1)
_die("output path must differ from input path.")
try:
if args.drop:
@@ -265,12 +191,10 @@ def main():
else:
keep = sorted(_parse_index_list(args.keep, sras.n_angles))
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
_die(str(e))
if not keep:
print("Error: at least one angle must remain.", file=sys.stderr)
sys.exit(1)
_die("at least one angle must remain.")
dropped = [a for a in range(sras.n_angles) if a not in keep]
print(f"\nDropping angle(s): {dropped}")
@@ -280,7 +204,7 @@ def main():
if sras.version in _LEGACY_VERSIONS:
_write_legacy(sras, keep, out_path)
else:
_write_v6(in_path, _read_v6_sections(in_path), keep, out_path)
_write_v6(sras, keep, out_path)
in_mb = in_path.stat().st_size / 1024**2
out_mb = out_path.stat().st_size / 1024**2