From 55531c8a65bbb16ab5a082c46fa445769625b383 Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 26 Mar 2026 16:11:27 +0100 Subject: [PATCH] next version --- .../flomni/flomni_webpage_generator.py | 281 +++++++++++++++++- 1 file changed, 271 insertions(+), 10 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index cb95548..b30c51b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -45,6 +45,12 @@ import threading import time from pathlib import Path +try: + from PIL import Image as _PILImage + _PIL_AVAILABLE = True +except ImportError: + _PIL_AVAILABLE = False + from bec_lib import bec_logger logger = bec_logger.logger @@ -58,6 +64,11 @@ _LOCK_STALE_AFTER_S = 35 # just over 2 missed cycles (2×15s+5s margin) _CYCLE_INTERVAL_S = 15 _TOMO_HEARTBEAT_STALE_S = 90 +# Path where online ptycho reconstructions are written by the analysis pipeline. +# 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 + VERBOSITY_SILENT = 0 VERBOSITY_NORMAL = 1 VERBOSITY_VERBOSE = 2 @@ -198,7 +209,8 @@ class WebpageGeneratorBase: self._thread = None self._stop_event = threading.Event() - self._last_active_time = None # epoch of last tomo/queue activity + self._last_active_time = None # epoch of last tomo/queue activity + self._last_ptycho_scan = None # scan id of last copied ptycho images self._had_activity = False # True once any activity has been observed self._last_queue_id = None # tracks queue history changes between cycles self._owner_id = f"{socket.gethostname()}:{os.getpid()}" @@ -455,6 +467,13 @@ class WebpageGeneratorBase: # ── Reconstruction queue ────────────────────────────────────── recon = self._collect_recon_data() + # ── Ptychography images ────────────────────────────────────── + ptycho = {} + try: + ptycho = self._collect_ptycho_images() + except Exception as exc: + self._log(VERBOSITY_VERBOSE, f"ptycho image error: {exc}", level="warning") + # ── Setup-specific data (subclass hook) ─────────────────────── setup = {} try: @@ -488,6 +507,7 @@ class WebpageGeneratorBase: "tomo_heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None, }, "recon": recon, + "ptycho": ptycho, "setup": setup, "generator": { "owner_id": self._owner_id, @@ -541,6 +561,136 @@ class WebpageGeneratorBase: "failed": failed, } + # ------------------------------------------------------------------ + # Ptychography images + # ------------------------------------------------------------------ + + def _collect_ptycho_images(self) -> dict: + """Find the latest ptycho reconstruction, copy/thumbnail changed images. + + Scans _PTYCHO_ONLINE_DIR for the most-recently-modified subfolder whose + name contains an underscore (e.g. dset_00005). Within that folder looks + for PNG files matching S*_phase.png, S*_probe.png, S*_amplitude.png, + S*_err.png, S*_spectrum.png. + + When the scan changes (new S##### prefix detected): + - Thumbnails are generated with Pillow and saved to output_dir + - Full-size PNGs are also copied to output_dir (for click-to-full) + - Old ptycho files from previous scans are removed from output_dir + + Returns a dict consumed by the JS ptycho renderer: + scan_id str e.g. "S06666" + images list [{"role": "phase", "thumb": "..._thumb.jpg", + "full": "..._phase.png"}, ...] + folder str source folder path (for diagnostics) + """ + base = Path(_PTYCHO_ONLINE_DIR).expanduser() + if not base.exists(): + return {} + + # Find latest subfolder by modification time. + # Typically named dset_NNNNN but any subfolder is accepted. + candidates = sorted( + [d for d in base.iterdir() if d.is_dir()], + key=lambda d: d.stat().st_mtime, + ) + if not candidates: + return {} + latest_dir = candidates[-1] + + # Roles we care about — phase and probe are primary (always shown), + # amplitude/err/spectrum are secondary (shown on expand) + _ROLES = [ + ("phase", "S*phase.png", True), + ("probe", "S*probe.png", True), + ("amplitude", "S*amplitude.png", False), + ("error", "S*err.png", False), + ("spectrum", "S*spectrum.png", False), + ] + + # Collect available source files + found = {} + scan_id = None + for role, pattern, _primary in _ROLES: + matches = sorted(latest_dir.glob(pattern)) + if matches: + src = matches[-1] + # Extract scan id from filename prefix (e.g. S06666) + stem = src.stem # e.g. S06666_400x400_b0_run_1_phase + sid = stem.split("_")[0] + if scan_id is None: + scan_id = sid + found[role] = src + + if not scan_id: + return {} + + # If scan unchanged and all output files exist, skip all work + thumb_key = f"{scan_id}_phase_thumb.jpg" + if (scan_id == self._last_ptycho_scan + and (self._output_dir / thumb_key).exists()): + # Rebuild result dict from existing files without doing I/O + images = [] + for role, _pat, primary in _ROLES: + thumb = self._output_dir / f"{scan_id}_{role}_thumb.jpg" + full = self._output_dir / f"{scan_id}_{role}.png" + if thumb.exists() and full.exists(): + images.append({ + "role": role, + "thumb": thumb.name, + "full": full.name, + "primary": primary, + }) + return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)} + + # Scan changed (or first run) — clean up old ptycho files in output_dir + self._log(VERBOSITY_VERBOSE, + f"New ptycho scan detected: {scan_id} in {latest_dir.name}") + for old_file in self._output_dir.glob("S*_*_thumb.jpg"): + if not old_file.name.startswith(scan_id + "_"): + old_file.unlink(missing_ok=True) + for old_file in self._output_dir.glob("S*_*.png"): + if not old_file.name.startswith(scan_id + "_"): + # Only clean up ptycho role files, not other PNGs (e.g. logo) + for role, _, _ in _ROLES: + if f"_{role}." in old_file.name or f"_{role}_" in old_file.name: + old_file.unlink(missing_ok=True) + break + + # Generate thumbnails and copy full-size for each found role + images = [] + for role, _pat, primary in _ROLES: + src = found.get(role) + if src is None: + continue + thumb_name = f"{scan_id}_{role}_thumb.jpg" + full_name = f"{scan_id}_{role}.png" + thumb_path = self._output_dir / thumb_name + full_path = self._output_dir / full_name + try: + # Thumbnail via Pillow + if _PIL_AVAILABLE: + img = _PILImage.open(src) + img.thumbnail((_PTYCHO_THUMB_PX, _PTYCHO_THUMB_PX)) + img.save(thumb_path, "JPEG", quality=85) + else: + shutil.copy2(src, thumb_path) + # Full-size copy + shutil.copy2(src, full_path) + images.append({ + "role": role, + "thumb": thumb_name, + "full": full_name, + "primary": primary, + }) + self._log(VERBOSITY_VERBOSE, f" ptycho {role}: thumb={thumb_name}") + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f" ptycho {role} failed: {exc}", level="warning") + + self._last_ptycho_scan = scan_id + return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)} + # ------------------------------------------------------------------ # Subclass hooks # ------------------------------------------------------------------ @@ -927,12 +1077,61 @@ def _render_html(phone_numbers: list) -> str: margin-top: 0.75rem; word-break: break-all; }} - /* ── Ptycho placeholder ── */ - .ptycho-placeholder {{ - border: 2px dashed var(--border); border-radius: 6px; - padding: 2rem; text-align: center; - color: var(--text-dim); font-size: 0.85rem; font-family: var(--mono); - letter-spacing: 0.06em; + /* ── Ptycho reconstructions ── */ + .ptycho-scan-id {{ + font-family: var(--mono); font-size: 0.75rem; font-weight: 700; + color: var(--text-dim); letter-spacing: 0.1em; margin-bottom: 0.9rem; + }} + .ptycho-primary {{ + display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.5rem; + }} + .ptycho-thumb-wrap {{ + position: relative; cursor: pointer; flex-shrink: 0; + }} + .ptycho-thumb-wrap img {{ + display: block; max-width: 100%; height: auto; + border-radius: 5px; border: 1px solid var(--border); + transition: border-color 0.2s; + }} + .ptycho-thumb-wrap:hover img {{ border-color: var(--text-dim); }} + .ptycho-role-label {{ + font-family: var(--mono); font-size: 0.55rem; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--text-dim); + margin-top: 0.3rem; text-align: center; + }} + /* Expandable secondary images via
*/ + .ptycho-details {{ + margin-top: 0.75rem; + }} + .ptycho-details summary {{ + font-family: var(--mono); font-size: 0.6rem; font-weight: 700; + letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim); + cursor: pointer; padding: 0.4rem 0; list-style: none; + display: flex; align-items: center; gap: 0.4rem; + border-top: 1px solid var(--border); + }} + .ptycho-details summary::-webkit-details-marker {{ display: none; }} + .ptycho-details summary::before {{ + content: '▶'; font-size: 0.55rem; transition: transform 0.2s; + }} + .ptycho-details[open] summary::before {{ transform: rotate(90deg); }} + .ptycho-secondary {{ + display: flex; gap: 0.75rem; flex-wrap: wrap; padding-top: 0.75rem; + }} + .ptycho-none {{ + font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); + padding: 1rem 0; + }} + /* Lightbox overlay */ + #ptycho-lightbox {{ + display: none; position: fixed; inset: 0; z-index: 200; + background: rgba(0,0,0,0.85); align-items: center; justify-content: center; + cursor: zoom-out; + }} + #ptycho-lightbox.open {{ display: flex; }} + #ptycho-lightbox img {{ + max-width: 95vw; max-height: 95vh; object-fit: contain; + border-radius: 4px; box-shadow: 0 8px 40px rgba(0,0,0,0.6); }} /* ── Instrument details ── */ @@ -1107,9 +1306,16 @@ def _render_html(phone_numbers: list) -> str:
-
+ +
+ +
+ +
Ptychography reconstructions
-
Reconstruction images will appear here
+
+
No reconstruction found
+
@@ -1341,11 +1547,14 @@ function handleAudioForStatus(status, prevStatus){{ const isScanning=(status==='scanning'); const wasScanning=(prevStatus==='scanning'); - // Auto-arm when scan starts + // Auto-arm when scan starts; also refresh UI when already armed and scan resumes if(isScanning && !audioArmed){{ audioArmed=true; if(warningActive){{ stopWarning(); warningActive=false; document.getElementById('btn-confirm').style.display='none'; }} updateAudioUI(); + }} else if(isScanning && audioArmed){{ + // Scan resumed while already armed (e.g. after confirm) — refresh LED + updateAudioUI(); }} // Armed scan ended → trigger warning @@ -1360,6 +1569,57 @@ function handleAudioForStatus(status, prevStatus){{ }} }} +// ── Ptychography images ──────────────────────────────────────────── +let _ptychoScanId = null; + +function openLightbox(src){{ + document.getElementById('ptycho-lightbox-img').src=src; + document.getElementById('ptycho-lightbox').classList.add('open'); +}} +function closeLightbox(){{ + document.getElementById('ptycho-lightbox').classList.remove('open'); + document.getElementById('ptycho-lightbox-img').src=''; +}} +document.addEventListener('keydown', e=>{{ if(e.key==='Escape') closeLightbox(); }}); + +function thumbHTML(img){{ + // Use data-full attribute to avoid any filename-quoting issues in onclick + return '
' + + '' + img.role + '' + + '
' + img.role + '
' + + '
'; +}} + +function renderPtycho(ptycho){{ + const container=document.getElementById('ptycho-content'); + if(!ptycho||!ptycho.scan_id||!ptycho.images||ptycho.images.length===0){{ + container.innerHTML='
No reconstruction found
'; + _ptychoScanId=null; return; + }} + + // Skip full re-render if scan unchanged — avoids image flicker on each poll + if(ptycho.scan_id===_ptychoScanId) return; + _ptychoScanId=ptycho.scan_id; + + const primary=ptycho.images.filter(i=>i.primary); + const secondary=ptycho.images.filter(i=>!i.primary); + + let html='
'+esc(ptycho.scan_id)+'
'; + html+='
'; + primary.forEach(img=>{{ html+=thumbHTML(img); }}); + html+='
'; + + if(secondary.length>0){{ + html+='
' + +'More images ('+secondary.length+')' + +'
'; + secondary.forEach(img=>{{ html+=thumbHTML(img); }}); + html+='
'; + }} + + container.innerHTML=html; +}} + // ── Status rendering ────────────────────────────────────────────────────── const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',error:'STOPPED',unknown:'UNKNOWN'}}; const DETAILS={{ @@ -1421,6 +1681,7 @@ function render(d){{ document.getElementById('recon-path').textContent=d.recon.folder_path||''; }} renderInstrument(d.setup); + renderPtycho(d.ptycho); document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString(); const ageS=(Date.now()/1000)-d.generated_at_epoch; const isStale=ageS>STALE_S;