next version

This commit is contained in:
x12sa
2026-03-26 16:11:27 +01:00
committed by holler
parent 1d408818cc
commit 55531c8a65

View File

@@ -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 <details> */
.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:
<div class="recon-path" id="recon-path"></div>
</div>
<div class="card">
<!-- Lightbox overlay -->
<div id="ptycho-lightbox" onclick="closeLightbox()">
<img id="ptycho-lightbox-img" src="" alt="">
</div>
<div class="card" id="ptycho-card">
<div class="card-title">Ptychography reconstructions</div>
<div class="ptycho-placeholder">Reconstruction images will appear here</div>
<div id="ptycho-content">
<div class="ptycho-none">No reconstruction found</div>
</div>
</div>
<!-- 3. Instrument details -->
@@ -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 '<div class="ptycho-thumb-wrap" data-full="' + img.full + '" onclick="openLightbox(this.dataset.full)">'
+ '<img src="' + img.thumb + '" loading="lazy" alt="' + img.role + '">'
+ '<div class="ptycho-role-label">' + img.role + '</div>'
+ '</div>';
}}
function renderPtycho(ptycho){{
const container=document.getElementById('ptycho-content');
if(!ptycho||!ptycho.scan_id||!ptycho.images||ptycho.images.length===0){{
container.innerHTML='<div class="ptycho-none">No reconstruction found</div>';
_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='<div class="ptycho-scan-id">'+esc(ptycho.scan_id)+'</div>';
html+='<div class="ptycho-primary">';
primary.forEach(img=>{{ html+=thumbHTML(img); }});
html+='</div>';
if(secondary.length>0){{
html+='<details class="ptycho-details">'
+'<summary>More images ('+secondary.length+')</summary>'
+'<div class="ptycho-secondary">';
secondary.forEach(img=>{{ html+=thumbHTML(img); }});
html+='</div></details>';
}}
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;