From e5c5d64307c08f941a2cce46506828346b192348 Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 15 Oct 2026 16:10:46 +0200 Subject: [PATCH] fix(webpage): downscale large full-size ptycho PNGs before upload omny.psi.ch/upload.php rejected the full-size probe/spectrum PNGs of stitched reconstructions with HTTP 413. Full-size copies above 900 kB are now saved as downscaled PNGs until they fit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ALDnH16dC6iyHjhe5Ey4w9 --- .../OMNY_shared/webpage_generator_base.py | 26 ++++++++++++++- .../test_webpage_ptycho_full_size_cap.py | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 tests/tests_bec_ipython_client/test_webpage_ptycho_full_size_cap.py diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py index 8b0dbf25..b460d6c7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/webpage_generator_base.py @@ -136,6 +136,10 @@ _MISMATCH_LOCAL_PORT = 8081 # Subfolders follow the pattern dset_NNNNN and contain S#####_*_phase.png etc. _PTYCHO_ONLINE_DIR = "~/data/raw/analysis/online/ptycho" _PTYCHO_THUMB_PX = 200 # longest edge of generated thumbnail in pixels +# The upload server (omny.psi.ch/upload.php) rejects large POSTs with HTTP 413; +# stitched reconstructions produce full-size PNGs above that. Full-size copies +# larger than this are downscaled until they fit. +_PTYCHO_FULL_MAX_BYTES = 900_000 VERBOSITY_SILENT = 0 VERBOSITY_NORMAL = 1 @@ -1324,7 +1328,7 @@ class WebpageGeneratorBase: img.save(thumb_path, "JPEG", quality=85) else: shutil.copy2(src, thumb_path) - shutil.copy2(src, full_path) + self._copy_full_capped(src, full_path) images.append({ "role": role, "thumb": thumb_name, @@ -1339,6 +1343,26 @@ class WebpageGeneratorBase: self._last_ptycho_scan = scan_id return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)} + @staticmethod + def _copy_full_capped(src: Path, dst: Path, max_bytes: int = _PTYCHO_FULL_MAX_BYTES) -> None: + """Copy src to dst; if src is larger than max_bytes, save a downscaled + PNG instead (halving the pixel area each step) so the upload server + accepts it. Without Pillow, or if it can't get small enough, the + plain/smallest copy is kept.""" + if src.stat().st_size <= max_bytes or not _PIL_AVAILABLE: + shutil.copy2(src, dst) + return + img = _PILImage.open(src) + img.load() + w, h = img.size + scale = 1.0 + while True: + scale *= 0.7071 + size = (max(1, int(w * scale)), max(1, int(h * scale))) + img.resize(size, _PILImage.LANCZOS).save(dst, "PNG", optimize=True) + if dst.stat().st_size <= max_bytes or min(size) <= 64: + return + # ------------------------------------------------------------------ # Subclass hooks # ------------------------------------------------------------------ diff --git a/tests/tests_bec_ipython_client/test_webpage_ptycho_full_size_cap.py b/tests/tests_bec_ipython_client/test_webpage_ptycho_full_size_cap.py new file mode 100644 index 00000000..db2cc063 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_webpage_ptycho_full_size_cap.py @@ -0,0 +1,32 @@ +"""Full-size ptycho PNGs copied for upload are capped in size: the upload +server rejected large (stitched) probe/spectrum images with HTTP 413.""" + +import numpy as np +import pytest + +PIL = pytest.importorskip("PIL.Image") + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import ( + WebpageGeneratorBase, +) + + +def test_large_png_is_downscaled_below_cap(tmp_path): + src, dst = tmp_path / "S00085-S00093_probe.png", tmp_path / "out.png" + PIL.fromarray((np.random.default_rng(0).random((800, 1200, 3)) * 255).astype("uint8")).save(src) + cap = 300_000 + assert src.stat().st_size > cap + + WebpageGeneratorBase._copy_full_capped(src, dst, max_bytes=cap) + + assert dst.stat().st_size <= cap + assert PIL.open(dst).size[0] < 1200 + + +def test_small_png_is_copied_unchanged(tmp_path): + src, dst = tmp_path / "S00001_phase.png", tmp_path / "out.png" + PIL.new("RGB", (50, 50)).save(src) + + WebpageGeneratorBase._copy_full_capped(src, dst, max_bytes=300_000) + + assert dst.read_bytes() == src.read_bytes()