next version
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user