Files
musrfit/tests/musrview_check/musrview_check.py
T
suter_aandClaude Opus 5 618e96fb00
Build and Deploy Documentation / build-and-deploy (push) Successful in 27s
fix musrview ctest failures on macOS: RGB order and path buffers
Three unrelated issues made the musrview_check tests fail on macOS while
passing on Linux.

1. Unspecified argument evaluation order

   TColor::GetColor(rand.Integer(255), rand.Integer(255), rand.Integer(255))

   evaluates its (side-effecting) arguments in an unspecified order: clang
   goes left-to-right, gcc right-to-left, so the same seed yielded RGB on
   one platform and BGR on the other. Visible whenever more runs are
   plotted than the startup xml colour list provides, e.g.
   test-histo-HAL9500.msr with 16 runs against 10 colours.

   The rgb values are now drawn into separate variables first. This keeps
   the clang result and changes gcc to match, hence the regenerated
   reference PNG for musrview-histo-HAL9500 (99.94% of the differing
   pixels were exact R<->B swaps).

   Fixed in PMusrCanvas, PFourierCanvas and both mupp PMuppCanvas copies.

2. Fixed size path buffers

   char fileName[128] plus strncpy(dst, src, sizeof(dst)) truncates *and*
   leaves the buffer unterminated once the path reaches the buffer size.
   musrview then failed on a 132 character msr-path in doc/examples/ViewOpts.
   musrFT and musrt0 were worse: an unbounded strcpy of the startup file
   path into char startup_path_name[128], i.e. a stack buffer overflow.

   All path/filename buffers in the drivers are now std::string:
   musrview, musrFT, musrt0, musrfit, any2many, addRun, dump_header.
   PMusrCanvas::SaveGraphicsAndQuit() takes const Char_t* accordingly.

   Along the way in the same files:
   - msr2msr_replace() wrote a 256 byte line into char temp[128]
   - msr2msr assembled "cp"/"rm" shell commands from paths in a 256 byte
     buffer; replaced by std::filesystem
   - msr2msr and addRun read lines with getline(buf, N), which silently
     abandons the rest of the file on the first over-long line
   - addRun: bound the unbounded sscanf "%s" to "%255s"
   - dropped scratch buffers that only held a string literal, in favour of
     TString::ReplaceAll(const char*, const char*)

3. musrview_check.py left its PNGs behind on failure

   The two early error returns skipped the cleanup, and since generated
   PNGs were identified by "not in the pre-run snapshot", one leftover file
   permanently masked the real output of that test: musrview overwrites the
   stale PNG, so it was there, just filtered out. One failure thus poisoned
   every later run (30 of the 37 observed failures) after a 15 s poll each.

   Cleanup now runs in a finally block on every exit path, and generated
   PNGs are detected by mtime instead, which sees rewritten leftovers while
   staying safe for a sibling test running concurrently under ctest -j.
   Also dropped the MUSRVIEW_PNG_DIR env var and its tmp-dir fallback: it
   is read nowhere in src/, so the fallback was dead code that guaranteed
   the full 15 s poll before every "no PNGs found" failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:44:50 +02:00

187 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""
Runs musrview --png on a given msr-file, then compares the generated PNGs
against reference images using pixel-level comparison (Pillow).
Usage:
musrview_check.py <musrview> <msr-file> <ref-dir> <test-name>
[--tol T] [--generate] [musrview-opts...]
Modes:
default Compare generated PNGs against references in <ref-dir>/<test-name>/
--generate Generate reference PNGs into <ref-dir>/<test-name>/ (no comparison)
Tolerance metric: mean absolute pixel difference normalised to [0, 1].
0.0 = identical, 1.0 = maximally different. Default tolerance: 0.03 (~3%).
"""
import argparse
import glob
import os
import shutil
import subprocess
import sys
import time
def check_python_deps():
"""Make sure Pillow and numpy are importable; print a clear, actionable
error message (rather than a bare traceback) if they are not."""
missing = []
for module, pip_name in (("PIL", "Pillow"), ("numpy", "numpy")):
try:
__import__(module)
except ImportError:
missing.append(pip_name)
if missing:
print(f"**ERROR** missing required python package(s): {', '.join(missing)}")
print(f" install with: {sys.executable} -m pip install {' '.join(missing)}")
return False
return True
def pixel_diff(img_a_path, img_b_path):
"""Return the mean absolute pixel difference normalised to [0, 1]."""
from PIL import Image
import numpy as np
a = np.asarray(Image.open(img_a_path).convert("RGBA"), dtype=np.float64)
b = np.asarray(Image.open(img_b_path).convert("RGBA"), dtype=np.float64)
if a.shape != b.shape:
return 1.0 # completely different dimensions
return np.mean(np.abs(a - b)) / 255.0
def main():
# ---- argument parsing ----------------------------------------------------
parser = argparse.ArgumentParser(description="musrview PNG integration test")
parser.add_argument("musrview", help="path to musrview executable")
parser.add_argument("msr_file", help="path to msr input file")
parser.add_argument("ref_dir", help="root reference directory")
parser.add_argument("test_name", help="test name (subdirectory in ref_dir)")
parser.add_argument("--tol", type=float, default=0.03,
help="tolerance for pixel comparison (default 0.03)")
parser.add_argument("--generate", action="store_true",
help="generate reference PNGs instead of comparing")
# everything after the known args is forwarded to musrview
args, musrview_opts = parser.parse_known_args()
if not args.generate and not check_python_deps():
return 1
msr_basename = os.path.splitext(os.path.basename(args.msr_file))[0]
ref_subdir = os.path.join(args.ref_dir, args.test_name)
# ---- run musrview --------------------------------------------------------
# musrview always writes its PNGs next to the msr-file, so the test has to
# run there and clean up after itself.
work_dir = os.path.dirname(os.path.abspath(args.msr_file))
png_glob = os.path.join(work_dir, f"{msr_basename}_*.png")
# Snapshot the PNGs that are already there together with their mtimes. A PNG
# counts as produced by this run if it is either new, or pre-existing but
# rewritten (a leftover from an earlier aborted run -- musrview simply
# overwrites it). Matching on mtime rather than mere existence keeps stale
# leftovers from masking the real output, while still ignoring the PNGs of a
# sibling test running concurrently on the same msr-file.
pre_existing = {p: os.stat(p).st_mtime_ns for p in glob.glob(png_glob)}
generated = []
def collect():
"""Return the PNGs in work_dir that this musrview run created/rewrote."""
found = []
for p in glob.glob(png_glob):
if pre_existing.get(p) != os.stat(p).st_mtime_ns:
found.append(p)
return sorted(found)
def cleanup():
"""Remove everything this run produced -- must happen on every exit path,
otherwise the leftovers pile up in doc/examples."""
for png in generated:
try:
os.remove(png)
except OSError:
pass
try:
cmd = [args.musrview, args.msr_file, "--png"] + musrview_opts
print(f"running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, cwd=work_dir)
if result.returncode != 0:
generated = collect()
print(f"**ERROR** musrview returned exit code {result.returncode}")
print(result.stdout + result.stderr)
return 1
# musrview has already exited by the time subprocess.run() returns, but on
# some systems the PNG file's directory entry becomes visible to this
# process only after a further delay (observed running under ctest, up to
# several seconds) -- poll for a while instead of failing on the first
# empty glob.
for _ in range(150):
generated = collect()
if generated:
break
time.sleep(0.1)
if not generated:
print(f"**ERROR** no PNGs matching '{msr_basename}_*.png' found")
print(f" checked: {work_dir}")
if result.stdout:
print(result.stdout)
return 1
# ---- generate mode ---------------------------------------------------
if args.generate:
os.makedirs(ref_subdir, exist_ok=True)
for png in generated:
dst = os.path.join(ref_subdir, os.path.basename(png))
shutil.copy2(png, dst)
print(f" saved reference: {dst}")
print(f"GENERATE: {len(generated)} reference PNG(s) written to {ref_subdir}")
return 0
# ---- compare mode ----------------------------------------------------
if not os.path.isdir(ref_subdir):
print(f"**ERROR** reference directory not found: {ref_subdir}")
return 1
failures = 0
compared = 0
for png_path in generated:
name = os.path.basename(png_path)
ref_path = os.path.join(ref_subdir, name)
if not os.path.isfile(ref_path):
print(f"FAIL: no reference PNG for {name}")
failures += 1
continue
diff = pixel_diff(png_path, ref_path)
compared += 1
if diff > args.tol:
print(f"FAIL: {name} diff={diff:.6f} > tol={args.tol:.6f}")
failures += 1
else:
print(f"PASS: {name} diff={diff:.6f} <= tol={args.tol:.6f}")
if compared == 0:
print("**ERROR** no PNGs were compared")
return 1
if failures:
print(f"\n{failures} of {len(generated)} PNG(s) FAILED")
return 1
print(f"\nAll {compared} PNG(s) PASSED (tol={args.tol})")
return 0
finally:
cleanup()
if __name__ == "__main__":
sys.exit(main())