Source code for acia.notebook

"""Jupyter notebook visualization mixin for image sequence sources."""

from __future__ import annotations

import base64
import dataclasses
import inspect
import io
import logging
import os
import time
from typing import TYPE_CHECKING

import numpy as np
from PIL import Image

if TYPE_CHECKING:
    from collections.abc import Callable

    from acia.base import BaseImage, RotatedCropSpec
    from acia.registration import FrameTransform
    from acia.registration_persistence import RegistrationManifest, RegistrationRecord


[docs] def normalize_to_uint8(image_array: np.ndarray) -> np.ndarray: """Normalize an image array to the uint8 ``[0, 255]`` range. A ``uint8`` array is passed through unchanged; any other dtype is min-max scaled to ``[0, 255]``. A flat array (``max == min``) maps to all zeros. Args: image_array: Source image array of any dtype/shape. Returns: np.ndarray: A ``uint8`` array of the same shape. """ if image_array.dtype == np.uint8: return image_array # Normalize to 0-255 range min_val = np.min(image_array) max_val = np.max(image_array) normalized: np.ndarray if max_val > min_val: normalized = ((image_array - min_val) / (max_val - min_val) * 255).astype( np.uint8 ) else: normalized = np.zeros_like(image_array, dtype=np.uint8) return normalized
[docs] class JupyterVisualizationMixin: """Mixin providing interactive Jupyter notebook visualization for image sequences. This mixin expects the host class to implement the ImageSequenceSource interface with these properties/methods: - size_t: int - number of time frames - num_channels: int - number of channels - get_frame(frame: int) -> BaseImage Example usage: class MyImageSource(ImageSequenceSource, JupyterVisualizationMixin): ... """ if TYPE_CHECKING: # `size_t`/`num_channels` are supplied by the host ImageSequenceSource. # They are declared here for the type checker only (guarded by # TYPE_CHECKING so no runtime descriptor is created) and as read-only # properties so concrete sources may override them with `@property`. @property def size_t(self) -> int: ... @property def num_channels(self) -> int: ...
[docs] def get_frame(self, frame: int) -> BaseImage: """Get frame at given index.""" raise NotImplementedError()
def _repr_html_(self) -> str | None: """Jupyter notebook rich display integration with interactive ipywidgets viewer. Returns an interactive viewer with: - Time slider to navigate through frames (T dimension) - Channel toggle controls (C dimension) - Real-time image display updates Returns: str: HTML representation for Jupyter display, or None for non-Jupyter environments """ try: # Try to import ipywidgets and IPython display import ipywidgets as widgets from IPython.display import HTML, display # Check if we're in a Jupyter environment try: get_ipython() # type: ignore[name-defined] # noqa: F821 except NameError: # Not in Jupyter/IPython environment return None except ImportError: # ipywidgets not available logging.warning( "ipywidgets not installed. Install with: pip install ipywidgets>=8.0.0" ) return None # Get dimensions try: num_frames = self.size_t num_channels = self.num_channels except (NotImplementedError, AttributeError): # If properties not implemented, try to get from __len__ num_frames = len(self) if hasattr(self, "__len__") else 1 # type: ignore[arg-type] num_channels = 1 # Create output widget for displaying images output = widgets.Output() # Create time slider (only if more than 1 frame) if num_frames > 1: time_slider = widgets.IntSlider( value=0, min=0, max=num_frames - 1, step=1, description="Frame:", continuous_update=False, # Update only when slider is released layout=widgets.Layout(width="80%"), ) else: time_slider = None # Create channel toggle buttons (only if more than 1 channel) if num_channels > 1: channel_toggles = [ widgets.Checkbox( value=True, description=f"Channel {i}", indent=False, ) for i in range(num_channels) ] else: channel_toggles = [] # Create overlay controls (only if overlay is provided) overlay = getattr(self, "overlay", None) if overlay is not None: overlay_checkbox = widgets.Checkbox( value=True, description="Overlay", indent=False, ) opacity_slider = widgets.FloatSlider( value=0.8, min=0.0, max=1.0, step=0.05, description="Opacity:", continuous_update=False, layout=widgets.Layout(width="80%"), ) else: overlay_checkbox = None opacity_slider = None def render_image(frame_idx: int, active_channels: list[bool]) -> None: """Render image for given frame and active channels.""" with output: output.clear_output(wait=True) try: # Get the frame frame = self.get_frame(frame_idx) image_data = frame.raw # Handle channel selection if num_channels > 1 and len(active_channels) > 0: # Get active channel indices active_indices = [ i for i, active in enumerate(active_channels) if active ] if len(active_indices) == 0: # No channels selected, show blank image if len(image_data.shape) == 3: height, width = image_data.shape[:2] else: height, width = image_data.shape image_data = np.zeros((height, width, 3), dtype=np.uint8) elif len(active_indices) == 1: # Single channel - display as grayscale channel_data = frame.get_channel(active_indices[0]) channel_data = normalize_to_uint8(channel_data) # Convert to RGB by repeating channel if len(channel_data.shape) == 2: image_data = np.repeat( channel_data[:, :, np.newaxis], 3, axis=-1 ) else: image_data = channel_data else: # Multiple channels - combine them # For now, we'll overlay them as RGB if 3 channels, # otherwise grayscale blend combined = None for idx in active_indices[ :3 ]: # Take at most first 3 channels for RGB channel_data = frame.get_channel(idx) channel_data = normalize_to_uint8(channel_data) if combined is None: if len(channel_data.shape) == 2: combined = np.zeros( (*channel_data.shape, 3), dtype=np.uint8 ) else: combined = np.zeros_like( channel_data, dtype=np.uint8 ) if len(channel_data.shape) == 2: # Grayscale channel channel_idx = active_indices.index(idx) % 3 combined[:, :, channel_idx] = channel_data else: # RGB channel combined = np.maximum(combined, channel_data) image_data = combined else: # Single channel or no channel selection image_data = normalize_to_uint8(image_data) # Ensure image is in correct format for PIL if image_data.ndim == 3 and image_data.shape[-1] == 1: # a raw single-channel (H, W, 1) frame -- drop the axis so # the 2D branch below promotes it to RGB; PIL's fromarray # has no mode for a trailing size-1 channel axis image_data = image_data[..., 0] if len(image_data.shape) == 2: # Grayscale - convert to RGB image_data = np.repeat(image_data[:, :, np.newaxis], 3, axis=-1) # Convert to PIL Image pil_image = Image.fromarray(image_data) # Convert to base64-encoded PNG for display buffer = io.BytesIO() pil_image.save(buffer, format="PNG") buffer.seek(0) img_base64 = base64.b64encode(buffer.read()).decode("utf-8") # Display as HTML image html = f'<img src="data:image/png;base64,{img_base64}" style="max-width: 100%; height: auto;" />' display(HTML(html)) except Exception as e: logging.error(f"Error rendering image: {e}") display( HTML(f"<p style='color: red;'>Error displaying frame: {e}</p>") ) def on_update(*args) -> None: """Callback for widget updates.""" frame_idx = time_slider.value if time_slider else 0 active_channels = ( [toggle.value for toggle in channel_toggles] if channel_toggles else [True] ) render_image(frame_idx, active_channels) # Connect widgets to callback if time_slider: time_slider.observe(on_update, names="value") for toggle in channel_toggles: toggle.observe(on_update, names="value") # Build layout controls = [] if time_slider: controls.append(time_slider) if overlay_checkbox is not None and opacity_slider is not None: controls.append( widgets.HBox( [overlay_checkbox, opacity_slider], layout=widgets.Layout(flex_flow="row wrap"), ) ) if channel_toggles: controls.append( widgets.HBox( channel_toggles, layout=widgets.Layout(flex_flow="row wrap"), ) ) if controls: viewer = widgets.VBox([*controls, output]) else: viewer = widgets.VBox([output]) # Render initial image on_update() # Display the widget display(viewer) # Return empty string to satisfy _repr_html_ protocol return ""
def _encode_frame_png(source, frame: int = 0, channel: int | None = None): """Encode one frame of ``source`` as a PNG data URL for an anywidget. Selects a single 2D display channel (channel ``0`` by default for a multi-channel frame), min-max normalizes it to ``uint8``, promotes grayscale to RGB, and returns a base64 PNG ``data:`` URL plus the frame ``(width, height)`` in pixels. Shared by the ``ROICropper`` / ``FilterExplorer`` widgets so frames travel to the browser as bytes. Args: source: An :class:`~acia.base.ImageSequenceSource` (uses ``get_frame``). frame: Frame index to encode. channel: Display channel for a multi-channel frame; ``None`` -> channel 0. Returns: tuple[str, int, int]: ``(data_url, width, height)``. Raises: ValueError: if ``channel`` is out of range for the frame. """ raw = np.asarray(source.get_frame(frame).raw) num_channels = raw.shape[-1] if raw.ndim == 3 else 1 if channel is not None and not (0 <= channel < num_channels): raise ValueError(f"channel must be in [0, {num_channels}); got {channel}.") if raw.ndim == 3: if raw.shape[-1] == 1: display = raw[..., 0] else: display = raw[..., 0 if channel is None else channel] else: display = raw display = normalize_to_uint8(display) if display.ndim == 2: display = np.repeat(display[:, :, np.newaxis], 3, axis=-1) frame_h, frame_w = int(raw.shape[0]), int(raw.shape[1]) pil_image = Image.fromarray(display) buffer = io.BytesIO() pil_image.save(buffer, format="PNG") buffer.seek(0) img_b64 = base64.b64encode(buffer.read()).decode("utf-8") return f"data:image/png;base64,{img_b64}", frame_w, frame_h # --------------------------------------------------------------------------- # ROICropper -- optional anywidget for drawing a rotated-rectangle ROI on # frame 0. anywidget is an OPTIONAL dependency (pip install acia[widget]); # acia.notebook MUST stay importable without it (it is imported by base.py / # local.py / nd2_source.py). The anywidget subclass is therefore defined only # when the import succeeds; otherwise ``ROICropper`` is bound to a stub that # raises a clear ImportError on instantiation. # --------------------------------------------------------------------------- try: import anywidget import traitlets _HAS_ANYWIDGET = True except ImportError: # pragma: no cover - exercised only when extra is absent _HAS_ANYWIDGET = False # The ESM render() is BEST-EFFORT and CANNOT be exercised by the headless Python # test-suite. It is verified only by a real run in Jupyter/Colab/marimo. It is # self-contained (no external imports). Keep it in sync with the synced traits. # # BEST-EFFORT / UNVERIFIED-HEADLESS NOTICE # ---------------------------------------- # Everything below this notice is JavaScript executed in the browser by anywidget # and is *not* covered by the Python test-suite. The fixes here come from a # careful code review; the interactive feel still needs a real Jupyter/Colab/ # marimo run to confirm. A real run must still verify, by hand: # * the rotate knob turns the box in the direction the pointer moves (CCW feel) # and does not "teleport" the angle when first grabbed at its rest position; # * corner resize anchors the OPPOSITE corner (drag a corner; the diagonally # opposite corner should stay put); # * click-to-add-point works everywhere, including *inside* the box body, and # a small accidental movement on press does not get mis-read as a drag; # * the box lands on the right image pixels even when the notebook host # stretches the canvas with CSS. _ROI_CROPPER_ESM = r""" // ROICropper render() -- canvas draw + click-to-add-points + drag/resize/rotate. // UNVERIFIED in CI: validated by a real Jupyter/Colab/marimo run only. // render() returns a cleanup function (anywidget calls it on teardown; marimo // re-renders frequently, so we must NOT stack duplicate canvases/listeners). function render({ model, el }) { // Fix A: wipe any previous render output so re-renders don't stack canvases. el.innerHTML = ""; const wrap = document.createElement("div"); wrap.style.position = "relative"; wrap.style.display = "inline-block"; const canvas = document.createElement("canvas"); canvas.style.touchAction = "none"; wrap.appendChild(canvas); const hint = document.createElement("div"); hint.style.font = "12px sans-serif"; hint.style.marginTop = "4px"; hint.textContent = "Click ≥3 points to fit a box (clicks inside the box add points too), " + "or drag the box / corners / rotate knob."; wrap.appendChild(hint); el.appendChild(wrap); const img = new Image(); let imgReady = false; img.onload = () => { imgReady = true; layout(); draw(); }; // Fix F: surface a broken/empty data URL instead of silently showing nothing. img.onerror = () => { imgReady = false; draw(); }; // Display scale: fit within a max width while tracking image<->canvas px. const MAX_W = 640; let scale = 1; const MOVE_THRESHOLD = 4; // px in canvas space; below this a press is a CLICK function imgW() { return model.get("image_w") || img.naturalWidth || 1; } function imgH() { return model.get("image_h") || img.naturalHeight || 1; } function layout() { const w = imgW(), h = imgH(); scale = Math.min(1, MAX_W / w); canvas.width = Math.round(w * scale); canvas.height = Math.round(h * scale); } // canvas px <-> image px function toImg(px, py) { return [px / scale, py / scale]; } function toCanvas(ix, iy) { return [ix * scale, iy * scale]; } function getRect() { return { cx: model.get("center_x"), cy: model.get("center_y"), w: model.get("width"), h: model.get("height"), angle: model.get("angle"), }; } // Corner offsets in the rect's local frame (image px). angle is CCW degrees // (OpenCV getRotationMatrix2D convention). Screen y is down, so a positive // CCW angle rotates with -sin in the y component to match the Python warp. // Rotation matrix R (local -> image), used everywhere for consistency: // ix = cx + lx*ca + ly*sa // iy = cy - lx*sa + ly*ca function corners(r) { const a = (r.angle * Math.PI) / 180; const ca = Math.cos(a), sa = Math.sin(a); const hw = r.w / 2, hh = r.h / 2; const local = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]; return local.map(([lx, ly]) => [ r.cx + lx * ca + ly * sa, r.cy - lx * sa + ly * ca, ]); } // Forward map local (lx, ly) -> image (ix, iy) using the SAME matrix. function localToImg(r, lx, ly) { const a = (r.angle * Math.PI) / 180; const ca = Math.cos(a), sa = Math.sin(a); return [r.cx + lx * ca + ly * sa, r.cy - lx * sa + ly * ca]; } // Inverse map image (ix, iy) -> local (lx, ly) (transpose of R). function imgToLocal(r, ix, iy) { const a = (r.angle * Math.PI) / 180; const ca = Math.cos(a), sa = Math.sin(a); const dx = ix - r.cx, dy = iy - r.cy; // local = R^T * d ; from corners(): lx = dx*ca - dy*sa, ly = dx*sa + dy*ca return [dx * ca - dy * sa, dx * sa + dy * ca]; } // Knob sits along the box's local -y axis (i.e. local (0, -off)). function rotateHandle(r) { const off = r.h / 2 + 24 / scale; return localToImg(r, 0, -off); } function draw() { const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); if (imgReady) ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // clicked points const pts = model.get("points") || []; ctx.fillStyle = "#00e5ff"; for (const [ix, iy] of pts) { const [px, py] = toCanvas(ix, iy); ctx.beginPath(); ctx.arc(px, py, 3, 0, 2 * Math.PI); ctx.fill(); } // rect const r = getRect(); const cs = corners(r).map(([ix, iy]) => toCanvas(ix, iy)); ctx.strokeStyle = "#ffeb3b"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cs[0][0], cs[0][1]); for (let i = 1; i < cs.length; i++) ctx.lineTo(cs[i][0], cs[i][1]); ctx.closePath(); ctx.stroke(); // corner handles ctx.fillStyle = "#ffeb3b"; for (const [px, py] of cs) { ctx.fillRect(px - 4, py - 4, 8, 8); } // rotate knob const [rx, ry] = toCanvas(...rotateHandle(r)); const [ccx, ccy] = toCanvas(r.cx, r.cy); ctx.strokeStyle = "#ff5252"; ctx.beginPath(); ctx.moveTo(ccx, ccy); ctx.lineTo(rx, ry); ctx.stroke(); ctx.fillStyle = "#ff5252"; ctx.beginPath(); ctx.arc(rx, ry, 5, 0, 2 * Math.PI); ctx.fill(); } let drag = null; // {mode, cornerIndex, r, ix, iy, startPx, startPy, moved} function hit(px, py) { const r = getRect(); const cs = corners(r).map(([ix, iy]) => toCanvas(ix, iy)); for (let i = 0; i < cs.length; i++) { if (Math.hypot(px - cs[i][0], py - cs[i][1]) <= 8) { return { mode: "resize", cornerIndex: i }; } } const [rx, ry] = toCanvas(...rotateHandle(r)); if (Math.hypot(px - rx, py - ry) <= 8) return { mode: "rotate" }; // inside body? const [ix, iy] = toImg(px, py); const [lx, ly] = imgToLocal(r, ix, iy); if (Math.abs(lx) <= r.w / 2 && Math.abs(ly) <= r.h / 2) { return { mode: "move" }; } return null; } // Fix C: convert a pointer event to canvas pixels robustly under CSS scaling. // The host may stretch the canvas via CSS, so getBoundingClientRect() can // differ from the canvas backing-store size. Scale client coords by // canvas.width/rect.width (and height) so clicks land on the right pixel. function localPos(ev) { const rect = canvas.getBoundingClientRect(); const sx = rect.width ? canvas.width / rect.width : 1; const sy = rect.height ? canvas.height / rect.height : 1; return [(ev.clientX - rect.left) * sx, (ev.clientY - rect.top) * sy]; } function addPoint(px, py) { const [ix, iy] = toImg(px, py); const pts = (model.get("points") || []).slice(); pts.push([ix, iy]); model.set("points", pts); model.save_changes(); draw(); } function onPointerDown(ev) { const [px, py] = localPos(ev); const h = hit(px, py); const r = getRect(); const [ix, iy] = toImg(px, py); // Fix D: record the press; do NOT enter a drag mode yet. A press becomes a // drag only once the pointer moves past MOVE_THRESHOLD; otherwise pointerup // treats it as a CLICK and appends a point -- even over the box body. drag = { ...(h || {}), candidateMode: h ? h.mode : null, mode: null, // activated on first significant move r, ix, iy, startPx: px, startPy: py, moved: false, }; // Fix F: setPointerCapture can throw on some hosts; never let it break drag. try { canvas.setPointerCapture(ev.pointerId); } catch (e) {} } function onPointerMove(ev) { if (!drag) return; const [px, py] = localPos(ev); if (!drag.moved) { if (Math.hypot(px - drag.startPx, py - drag.startPy) < MOVE_THRESHOLD) { return; // still within click tolerance } drag.moved = true; // Only now commit to a manipulation mode (if the press hit a handle/body). drag.mode = drag.candidateMode; } if (!drag.mode) return; // moved on empty space -> ignore (no point yet) const [ix, iy] = toImg(px, py); const r = drag.r; if (drag.mode === "move") { model.set("center_x", r.cx + (ix - drag.ix)); model.set("center_y", r.cy + (iy - drag.iy)); } else if (drag.mode === "rotate") { // Fix B: derive the angle from the SAME rotation matrix corners() uses. // The knob's rest position is local (0, -off), which maps to image offset // (dx, dy) = (-off*sa, -off*ca) [from localToImg with lx=0, ly=-off] // We want angle(pointer) such that at rest it equals r.angle. With // angle = atan2(-(ix-cx), -(iy-cy)) * 180/PI // at rest: atan2(-(-off*sa), -(-off*ca)) = atan2(off*sa, off*ca) = a (rad) // => angle == r.angle exactly (no teleport on grab). A small CCW pointer // move increases the angle, matching the Python CCW / warp convention. const angle = Math.atan2(-(ix - r.cx), -(iy - r.cy)) * 180 / Math.PI; model.set("angle", angle); } else if (drag.mode === "resize") { // Fix E: anchor the OPPOSITE corner (standard UX). Using the drag-start // snapshot r, compute the fixed opposite corner in image px, then derive // the new center as the midpoint of (pointer, opposite) and the new // width/height from the pointer-vs-opposite delta projected onto the // box's rotated local axes. const hw = r.w / 2, hh = r.h / 2; const localCorners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]; const i = drag.cornerIndex; const opp = localCorners[(i + 2) % 4]; // diagonally opposite corner const [ox, oy] = localToImg(r, opp[0], opp[1]); // fixed anchor (image px) // new center = midpoint of dragged pointer and the fixed opposite corner. const ncx = (ix + ox) / 2; const ncy = (iy + oy) / 2; // project (pointer - opposite) onto the box's (unchanged) local axes to // get the new full width/height. const rAxes = { ...r, cx: ncx, cy: ncy }; const [lx, ly] = imgToLocal(rAxes, ix, iy); const oLocal = imgToLocal(rAxes, ox, oy); // == -[lx, ly] by construction const nw = Math.max(1, Math.round(Math.abs(lx - oLocal[0]))); const nh = Math.max(1, Math.round(Math.abs(ly - oLocal[1]))); model.set("center_x", ncx); model.set("center_y", ncy); model.set("width", nw); model.set("height", nh); } model.save_changes(); draw(); } function onPointerEnd(ev) { if (!drag) return; // Fix D: a press that never crossed the threshold is a click -> add a point // (works inside the box body too, since we deferred entering "move"). if (!drag.moved) { addPoint(drag.startPx, drag.startPy); } try { canvas.releasePointerCapture(ev.pointerId); } catch (e) {} drag = null; } // Fix A: named handlers + a disposer. anywidget calls the returned function // on teardown; we remove listeners, model observers, and the appended node. function onImageChange() { const b64 = model.get("image_b64"); if (b64) { img.src = b64; } // Fix F: guard empty/None src } function onGeomChange() { draw(); } canvas.addEventListener("pointerdown", onPointerDown); canvas.addEventListener("pointermove", onPointerMove); canvas.addEventListener("pointerup", onPointerEnd); canvas.addEventListener("pointercancel", onPointerEnd); model.on("change:image_b64", onImageChange); model.on( "change:center_x change:center_y change:width change:height change:angle change:points", onGeomChange, ); layout(); const initB64 = model.get("image_b64"); if (initB64) { img.src = initB64; } // Fix F: guard empty/None src draw(); // Disposer: undo everything this render() set up. return () => { canvas.removeEventListener("pointerdown", onPointerDown); canvas.removeEventListener("pointermove", onPointerMove); canvas.removeEventListener("pointerup", onPointerEnd); canvas.removeEventListener("pointercancel", onPointerEnd); model.off("change:image_b64", onImageChange); model.off( "change:center_x change:center_y change:width change:height change:angle change:points", onGeomChange, ); if (wrap.parentNode) { wrap.parentNode.removeChild(wrap); } }; } export default { render }; """ # The FilterExplorer ESM is BEST-EFFORT JavaScript, exercised by the headless # Playwright suite (tests/notebook/test_filter_explorer_esm_playwright.py) but # not by the pure-Python tests. Live mask filtering happens entirely client-side # from the precomputed per-contour values, so NO kernel round-trip is needed as # the sliders move (the spec's "reactive, no observer wiring"). render() returns # a disposer so marimo re-renders don't stack canvases/listeners. _FILTER_EXPLORER_ESM = r""" // FilterExplorer render() -- one (min,max) slider row per filter; live overlay // recolouring kept=green / dropped=red as the handles move. Client-side only. function render({ model, el }) { el.innerHTML = ""; // wipe prior render output (no stacked canvases on re-run) const wrap = document.createElement("div"); wrap.style.font = "12px sans-serif"; const canvas = document.createElement("canvas"); canvas.style.display = "block"; wrap.appendChild(canvas); const count = document.createElement("div"); count.style.margin = "4px 0"; wrap.appendChild(count); const controls = document.createElement("div"); wrap.appendChild(controls); el.appendChild(wrap); const MAX_W = 480; let scale = 1; function imgW() { return model.get("image_w") || 1; } function imgH() { return model.get("image_h") || 1; } function layout() { const w = imgW(), h = imgH(); scale = Math.min(1, MAX_W / w); canvas.width = Math.round(w * scale); canvas.height = Math.round(h * scale); } const img = new Image(); let imgReady = false; img.onload = () => { imgReady = true; draw(); }; img.onerror = () => { imgReady = false; draw(); }; // local working copy of the handle values; written back to the model on input. let sel = JSON.parse(JSON.stringify(model.get("selection") || [])); function fmt(x) { const a = Math.abs(x); if (a >= 100) return x.toFixed(0); if (a >= 1) return x.toFixed(2); return x.toFixed(3); } // a contour is kept iff every filter's value is within its [vmin, vmax]. // The positive form (rather than `v < vmin || v > vmax`) also drops a // non-finite value, matching Python's `accepts` (>=/<= are false for NaN). function keep(rec) { const v = rec.values || []; for (let i = 0; i < sel.length; i++) { if (!(v[i] >= sel[i].vmin && v[i] <= sel[i].vmax)) return false; } return true; } function draw() { const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); if (imgReady) ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const conts = model.get("contours") || []; let kept = 0; for (const rec of conts) { const pts = rec.points || []; if (pts.length < 2) continue; const k = keep(rec); if (k) kept++; ctx.beginPath(); ctx.moveTo(pts[0][0] * scale, pts[0][1] * scale); for (let i = 1; i < pts.length; i++) { ctx.lineTo(pts[i][0] * scale, pts[i][1] * scale); } ctx.closePath(); ctx.lineWidth = 1.5; ctx.strokeStyle = k ? "#2ecc40" : "#ff4136"; ctx.fillStyle = k ? "rgba(46,204,64,0.25)" : "rgba(255,65,54,0.12)"; ctx.fill(); ctx.stroke(); } count.textContent = "kept " + kept + " / " + conts.length; } function commit() { model.set("selection", JSON.parse(JSON.stringify(sel))); model.save_changes(); draw(); } // build one control row per filter spec. const specs = model.get("filter_specs") || []; const rowHandlers = []; specs.forEach((spec, i) => { const row = document.createElement("div"); row.style.margin = "6px 0"; const label = document.createElement("div"); const unit = spec.unit ? " [" + spec.unit + "]" : ""; label.textContent = spec.name + unit; label.style.fontWeight = "bold"; row.appendChild(label); const mkSlider = () => { const s = document.createElement("input"); s.type = "range"; s.min = String(spec.lo); s.max = String(spec.hi); s.step = String(spec.step || (spec.hi - spec.lo) / 200 || 1); s.style.width = "240px"; return s; }; const lo = mkSlider(); lo.value = String(sel[i].vmin); const hi = mkSlider(); hi.value = String(sel[i].vmax); const readout = document.createElement("span"); readout.style.marginLeft = "8px"; function refresh() { let a = parseFloat(lo.value), b = parseFloat(hi.value); if (a > b) { // keep min <= max if (this === lo) { b = a; hi.value = String(b); } else { a = b; lo.value = String(a); } } sel[i] = { vmin: a, vmax: b }; readout.textContent = "[" + fmt(a) + ", " + fmt(b) + "]"; commit(); } lo.addEventListener("input", refresh); hi.addEventListener("input", refresh); rowHandlers.push([lo, hi, refresh]); readout.textContent = "[" + fmt(sel[i].vmin) + ", " + fmt(sel[i].vmax) + "]"; row.appendChild(document.createElement("br")); row.appendChild(lo); row.appendChild(hi); row.appendChild(readout); controls.appendChild(row); }); function onSelectionChange() { sel = JSON.parse(JSON.stringify(model.get("selection") || [])); specs.forEach((spec, i) => { const [lo, hi] = rowHandlers[i]; // Only rewrite a handle whose value actually changed, so a slider the // user is dragging (already equal to sel[i]) is not snapped/reset by our // own committed change:selection echo. if (parseFloat(lo.value) !== sel[i].vmin) lo.value = String(sel[i].vmin); if (parseFloat(hi.value) !== sel[i].vmax) hi.value = String(sel[i].vmax); }); draw(); } function onDataChange() { draw(); } model.on("change:selection", onSelectionChange); model.on("change:contours change:filter_specs", onDataChange); layout(); const b64 = model.get("image_b64"); if (b64) { img.src = b64; } draw(); return () => { for (const [lo, hi, refresh] of rowHandlers) { lo.removeEventListener("input", refresh); hi.removeEventListener("input", refresh); } model.off("change:selection", onSelectionChange); model.off("change:contours change:filter_specs", onDataChange); if (wrap.parentNode) { wrap.parentNode.removeChild(wrap); } }; } export default { render }; """ def _fit_rotated_rect(points): """Fit the tightest oriented rectangle to ``points`` -> ``RotatedCropSpec``. Shared min-area-rectangle geometry (``cv2.minAreaRect``) used by the :class:`SequenceDashboard` point-fit path. Angle normalized into ``(-45, 45]`` degrees (CCW / ``RotatedCropSpec`` convention), width/height swapped per 90-degree step. ``cv2`` is a core dependency (always available). Raises: ValueError: If fewer than 3 points, or the points are degenerate. """ import cv2 from acia.base import RotatedCropSpec pts = np.asarray(points, dtype=np.float32) if pts.ndim != 2 or pts.shape[0] < 3 or pts.shape[1] != 2: raise ValueError(f"fit requires at least 3 [x, y] points; got {pts.shape}.") (cx, cy), (w, h), angle = cv2.minAreaRect(pts) if w == 0 or h == 0: raise ValueError("degenerate rectangle (collinear/duplicate points)") w = int(round(w)) h = int(round(h)) while angle > 45.0: angle -= 90.0 w, h = h, w while angle <= -45.0: angle += 90.0 w, h = h, w return RotatedCropSpec( center=(float(cx), float(cy)), size=(max(1, w), max(1, h)), angle=float(angle) ) # The 5 acia.registration.RegistrationMethod subclass names, in the order they # appear in the RegistrationDashboard method picker. Kept as plain strings at # module level (no import) so both the traitlets validator and the ESM's # <select> options list can use them without touching acia.registration. _REGISTRATION_METHOD_NAMES: tuple[str, ...] = ( "PhaseCorrelationHighpass", "MaskedTemplateCorrelation", "HoughLineRigidFit", "FeatureRANSACEuclidean", "GradientECC", ) # batch_apply's checkpoint cadence: the manifest is persisted after every this # many newly-estimated frames *within* a position (not only after the # position fully completes), bounding worst-case lost progress on interrupt # to this many frames without rewriting the whole manifest every single frame # across a long, multi-position run. See the # registration-dashboard-progress-video spec's Design Notes. CHECKPOINT_INTERVAL = 20 def _estimate_eta( *, elapsed: float, frames_done: int, frames_left_in_position: int, positions_remaining_after: int, position_frame_counts: list[int], current_position_num_frames: int, ) -> float | None: """Best-effort ETA (seconds) for the remainder of a batch-apply run. Returns ``None`` when there isn't yet enough signal (no elapsed time or no completed frames) to produce a rate -- the ESM only renders an ETA once this is non-``None``. Heuristic (approximate, not exact -- see the spec's Design Notes): ``rate = frames_done / elapsed``; ``remaining = frames_left_in_position + positions_remaining_after * average_frames_per_position`` where the average is over positions *completed so far in this run* (``position_frame_counts``), falling back to the current position's own frame count when no position has completed yet; ``eta = remaining / rate``. Args: elapsed: Seconds elapsed since the batch-apply run started. frames_done: Total frames estimated so far across the whole run. frames_left_in_position: Frames remaining in the position currently being processed. positions_remaining_after: Number of positions still to process after the current one. position_frame_counts: Frame counts of positions already fully completed in this run (for averaging). current_position_num_frames: Frame count of the position currently being processed (fallback average when nothing has completed yet). Returns: float | None: Estimated remaining seconds, or ``None`` if no rate can be computed yet. """ if elapsed <= 0 or frames_done <= 0: return None rate = frames_done / elapsed avg_frames_per_position = ( sum(position_frame_counts) / len(position_frame_counts) if position_frame_counts else current_position_num_frames ) remaining = ( frames_left_in_position + positions_remaining_after * avg_frames_per_position ) return remaining / rate def _registration_method_classes() -> dict[str, type]: """Lazy import of the 5 :class:`~acia.registration.RegistrationMethod` subclasses. Deliberately NOT a module-level import: ``acia.registration`` imports from ``acia.base``, which itself imports this module (``acia.notebook``) at load time for :class:`JupyterVisualizationMixin` -- a module-level import here would be circular. """ from acia.registration import ( FeatureRANSACEuclidean, GradientECC, HoughLineRigidFit, MaskedTemplateCorrelation, PhaseCorrelationHighpass, ) return { "PhaseCorrelationHighpass": PhaseCorrelationHighpass, "MaskedTemplateCorrelation": MaskedTemplateCorrelation, "HoughLineRigidFit": HoughLineRigidFit, "FeatureRANSACEuclidean": FeatureRANSACEuclidean, "GradientECC": GradientECC, } # The SequenceDashboard CSS + ESM are ported near-verbatim from the approved # clickable mockup (three-pane curation UI). Like the other widgets here, the ESM # is UNVERIFIED by the headless Python suite and validated only by a real # Jupyter/marimo run (or the Playwright suite in the devcontainer). Images arrive # from Python as PNG bytes over anywidget's buffer channel; only single frames and # the small `selections` list cross the wire. _SEQUENCE_DASHBOARD_CSS = _SEQUENCE_DASHBOARD_CSS_TEXT = r""" .acia-sd{--bg:#e9edf1;--panel:#fff;--panel-2:#f4f7f9;--panel-3:#eef2f5;--border:#d5dce2; --border-strong:#c2ccd4;--text:#182028;--text-dim:#596a76;--text-faint:#8695a0; --accent:#0f8f82;--accent-ink:#fff;--accent-soft:rgba(15,143,130,.12); --roi-1:#e08a12;--roi-2:#d24d92;--roi-3:#2b7fd6;--roi-4:#25a06f;--roi-5:#8a6ef0; --font-ui:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; --font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace; font-family:var(--font-ui);color:var(--text);font-size:14px; border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--bg); display:flex;flex-direction:column;height:640px;} .acia-sd:fullscreen{height:100vh;border-radius:0;} @media (prefers-color-scheme:dark){.acia-sd{--bg:#0c1216;--panel:#141c22;--panel-2:#1a232a; --panel-3:#202b33;--border:#28333c;--border-strong:#34424c;--text:#e7eef3;--text-dim:#8b9aa6; --text-faint:#657481;--accent:#2fd4c1;--accent-ink:#062521;--accent-soft:rgba(47,212,193,.14); --roi-1:#f0a83a;--roi-2:#ec6fac;--roi-3:#5aa6f0;--roi-4:#3cc78d;--roi-5:#a78bfa;}} .acia-sd *{box-sizing:border-box} .acia-sd .sd-src{display:flex;gap:10px;align-items:center;padding:9px 12px;background:var(--panel-2); border-bottom:1px solid var(--border);flex-wrap:wrap;font-family:var(--font-mono);font-size:11.5px;} .acia-sd .sd-src input{flex:1;min-width:160px;background:var(--panel);border:1px solid var(--border); color:var(--text);border-radius:7px;padding:6px 9px;font-family:var(--font-mono);font-size:12px;} .acia-sd .sd-src input[readonly]{background:var(--panel-3);color:var(--text-dim);cursor:text;} .acia-sd .sd-meta{color:var(--text-dim);width:100%;} .acia-sd .sd-meta b{color:var(--text)} .acia-sd .sd-main{flex:1;display:grid;grid-template-columns:var(--lw,190px) 5px minmax(0,1fr) 5px var(--rw,250px); gap:0;background:var(--border);min-height:0;} .acia-sd .sd-pane{background:var(--panel);display:flex;flex-direction:column;min-height:0;min-width:0;} .acia-sd .sd-head{padding:8px 11px;border-bottom:1px solid var(--border);font-family:var(--font-mono); font-size:10px;letter-spacing:.09em;text-transform:uppercase;color:var(--text-dim);display:flex; justify-content:space-between;align-items:center;gap:8px;} .acia-sd .sd-rz{background:var(--border);cursor:col-resize;} .acia-sd .sd-rz:hover{background:var(--accent);} .acia-sd .sd-gal{overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:6px;} .acia-sd .sd-thumb{position:relative;height:44px;flex:none;border-radius:7px;overflow:hidden;cursor:pointer; border:1.5px solid transparent;background:var(--panel-3); transition:height .3s cubic-bezier(.2,.75,.2,1);} .acia-sd .sd-thumb:hover{height:var(--exp,300px);} .acia-sd .sd-thumb.sel{border-color:var(--accent);} .acia-sd .sd-thumb img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;} .acia-sd .sd-thumb .ix{position:absolute;left:6px;bottom:4px;font-family:var(--font-mono);font-size:11px; font-weight:600;color:#fff;text-shadow:0 1px 3px #000;} .acia-sd .sd-thumb .dot{position:absolute;right:6px;top:6px;width:8px;height:8px;border-radius:50%; background:var(--accent);box-shadow:0 0 0 1.5px rgba(0,0,0,.4);} .acia-sd .sd-editor{display:flex;flex-direction:column;} .acia-sd .sd-ebody{flex:1;display:flex;gap:14px;padding:14px;overflow:auto;align-items:flex-start;} .acia-sd .sd-cwrap{position:relative;border-radius:8px;overflow:hidden;background:#9aa0a4;flex:none;touch-action:none;} .acia-sd .sd-cwrap.pick{cursor:crosshair;} .acia-sd .sd-cwrap img{display:block;width:100%;height:100%;} .acia-sd .sd-cstatus{position:absolute;inset:0;display:none;align-items:center;justify-content:center; text-align:center;padding:12px;font-family:var(--font-mono);font-size:12px;color:#fff; background:rgba(0,0,0,.35);pointer-events:none;} .acia-sd .sd-cstatus.show{display:flex;} .acia-sd .sd-cstatus.err{background:rgba(140,30,20,.55);} .acia-sd .roi{position:absolute;border:2px solid var(--rc,#e08a12);cursor:move; background:color-mix(in srgb,var(--rc,#e08a12) 12%,transparent);} /* Annotated on a different frame than the one on screen: these coordinates describe frame `anchor_frame`, so here the box is only approximate (it is exact again once drift correction is applied). Dashed as well as muted, so it does not read as colour alone. */ .acia-sd .roi.stale{--rc:#8b8b8b;border-style:dashed;} .acia-sd .roi.stale .tag{opacity:.75;} .acia-sd .sd-frhint{color:var(--text-faint);cursor:help;border-bottom:1px dotted currentColor;} .acia-sd .roi.active{box-shadow:0 0 0 3px color-mix(in srgb,var(--rc) 30%,transparent);} .acia-sd .roi .tag{position:absolute;left:0;top:-18px;font-family:var(--font-mono);font-size:10px; background:var(--rc);color:#fff;padding:0 5px;border-radius:4px;white-space:nowrap;} .acia-sd .roi .knob{position:absolute;left:50%;top:-22px;width:11px;height:11px;margin-left:-5.5px; border-radius:50%;background:var(--panel);border:2px solid var(--rc);cursor:grab;} .acia-sd .roi .rz{position:absolute;width:12px;height:12px;border-radius:3px; background:var(--panel);border:2px solid var(--rc);} .acia-sd .roi .rz[data-c="tl"]{left:-6px;top:-6px;cursor:nwse-resize;} .acia-sd .roi .rz[data-c="tr"]{right:-6px;top:-6px;cursor:nesw-resize;} .acia-sd .roi .rz[data-c="bl"]{left:-6px;bottom:-6px;cursor:nesw-resize;} .acia-sd .roi .rz[data-c="br"]{right:-6px;bottom:-6px;cursor:nwse-resize;} .acia-sd .ptmark{position:absolute;width:11px;height:11px;margin:-5.5px;border-radius:50%; background:var(--accent);border:2px solid #fff;pointer-events:none;} .acia-sd .sd-readout{flex:1;min-width:150px;display:flex;flex-direction:column;gap:12px; position:sticky;right:0;background:var(--bg);padding-left:4px;} .acia-sd .sd-card{background:var(--panel-2);border:1px solid var(--border);border-radius:9px;padding:10px; font-family:var(--font-mono);font-size:12px;} .acia-sd .sd-card h3{margin:0 0 7px;font-size:10px;letter-spacing:.08em;text-transform:uppercase; color:var(--text-faint);display:flex;align-items:center;gap:6px;} .acia-sd .sd-hint{font-size:12px;letter-spacing:normal;text-transform:none;cursor:help; color:var(--text-dim);} .acia-sd .sd-crop{width:100%;height:auto;display:block;border-radius:6px;background:var(--panel-3);} .acia-sd .sd-frbar{display:flex;align-items:center;gap:8px;padding:7px 14px;flex:none; border-top:1px solid var(--border);background:var(--panel-2); font-family:var(--font-mono);font-size:11px;color:var(--text-dim);} .acia-sd .sd-frbar input[type=range]{vertical-align:middle;} .acia-sd .sd-tools{display:flex;gap:8px;align-items:center;padding:9px 12px;border-top:1px solid var(--border); background:var(--panel-2);flex-wrap:wrap;} .acia-sd button{border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:7px; padding:6px 10px;cursor:pointer;font-size:12.5px;} .acia-sd button.primary{border-color:var(--accent);color:var(--accent);} .acia-sd button.accent{background:var(--accent);color:var(--accent-ink);border-color:var(--accent);font-weight:600;} .acia-sd .sd-list{flex:1;overflow-y:auto;padding:8px;} .acia-sd .sd-poshd{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);padding:5px 8px; cursor:pointer;border-radius:6px;user-select:none;} .acia-sd .sd-poshd:hover{background:var(--panel-3);color:var(--text);} .acia-sd .sd-row{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:7px;cursor:pointer;} .acia-sd .sd-row.active{background:var(--accent-soft);} .acia-sd .sd-row .sw{width:11px;height:11px;border-radius:3px;flex:none;} .acia-sd .sd-row .nm{flex:1;font-size:13px;} .acia-sd .sd-foot{border-top:1px solid var(--border);padding:11px;display:flex;flex-direction:column;gap:9px;} .acia-sd .sd-mode{display:flex;background:var(--panel-3);border-radius:7px;padding:3px;gap:3px;} .acia-sd .sd-mode button{flex:1;border:0;background:none;font-family:var(--font-mono);font-size:12px;} .acia-sd .sd-mode button.on{background:var(--panel);box-shadow:0 1px 3px rgba(0,0,0,.15);font-weight:600;} .acia-sd .sd-saverow{display:flex;align-items:center;gap:10px;} .acia-sd .sd-saverow .sd-save{flex:1;} .acia-sd .sd-auto{display:flex;align-items:center;gap:5px;font-family:var(--font-mono);font-size:11px; color:var(--text-dim);white-space:nowrap;cursor:pointer;} .acia-sd .sd-view{display:flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:10px; color:var(--text-faint);} .acia-sd .sd-toast{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);background:var(--text); color:var(--bg);padding:8px 14px;border-radius:8px;font-size:12.5px;opacity:0;transition:.25s;pointer-events:none;} .acia-sd .sd-toast.show{opacity:1;} """ _SEQUENCE_DASHBOARD_ESM = _SEQUENCE_DASHBOARD_ESM_TEXT = r""" // SequenceDashboard render() -- three-pane curation UI (accordion gallery, // resizable panes, ROI editor with draw + point-fit). Images arrive from Python // as PNG bytes (model.send/on). UNVERIFIED headless; validated by a real run. function render({ model, el }) { el.innerHTML = ""; const root = document.createElement("div"); root.className = "acia-sd"; el.appendChild(root); const md = model.get("metadata") || {}; const dims = (md.sizes && md.sizes.Y && md.sizes.X) ? [md.sizes.X, md.sizes.Y] : [1, 1]; const ASPECT = dims[1] / dims[0]; const NPOS = md.num_positions || (model.get("positions") || []).length || 1; const NT = md.num_timepoints || 1; // The frame the editor opens on -- the middle one by default, since frame 0 // of a growing culture is usually empty (see SequenceDashboard.preview_frame). const FRAME0 = Math.max(0, Math.min(NT - 1, +model.get("preview_frame") || 0)); const PX = md.pixel_size_um || null; const COLORS = ["--roi-1","--roi-2","--roi-3","--roi-4","--roi-5"]; const cvar = (v) => getComputedStyle(root).getPropertyValue(v).trim(); const hexOf = (ci) => cvar(COLORS[((ci % 5) + 5) % 5]); const metaLine = NPOS + " positions · " + NT + " T · " + dims[0] + "×" + dims[1] + " " + (md.dtype || "") + (PX ? " · " + PX.toFixed(4) + " µm/px" : "") + " · " + ((md.channels || []).join(", ")); root.innerHTML = "<div class='sd-src'><span>Source</span>" + "<input class='sd-path' readonly spellcheck='false' title='source file'>" + "<div class='sd-meta'>" + metaLine + "</div></div>" + "<div class='sd-main'>" + "<div class='sd-pane'><div class='sd-head'><span>Positions</span>" + "<span class='sd-galn'>" + NPOS + "</span></div><div class='sd-gal'></div></div>" + "<div class='sd-rz' data-side='l'></div>" + "<div class='sd-pane sd-editor'><div class='sd-head'>" + "<span class='sd-epos'>pos 000</span>" + "<span style='display:flex;gap:10px;align-items:center'>" + "<span class='sd-view'>size <input type='range' class='sd-vs' min='300' max='2400' value='" + model.get("view_size") + "'></span>" + "<button class='sd-full' title='Toggle fullscreen (Ctrl/Cmd+scroll over the image also zooms)'>⛶ Fullscreen</button>" + "</span></div>" + "<div class='sd-ebody'><div class='sd-cwrap'><div class='sd-cstatus'></div></div>" + "<div class='sd-readout'><div class='sd-card'><h3>Active ROI → RotatedCropSpec" + "<span class='sd-hint' title='Delete/Backspace removes the active ROI; Ctrl/Cmd+C duplicates it (multi mode) -- hover the widget for shortcuts to apply'>⌨</span></h3>" + "<div class='sd-spec'>no ROI selected</div></div>" + "<div class='sd-card sd-crop-card' hidden><h3>Crop Preview</h3>" + "<canvas class='sd-crop'></canvas></div></div></div>" + "<div class='sd-frbar'>frame <input type='range' class='sd-frame' min='0' max='" + (NT - 1) + "' value='" + FRAME0 + "'>" + " <span class='sd-frlbl'>" + FRAME0 + " / " + NT + "</span>" + " <span class='sd-frhint' title='An ROI you draw or edit here is anchored to this frame. " + "The anchor is saved in selection.json and used to place the crop correctly after drift " + "correction. A dashed, greyed box was annotated on a different frame -- move it to re-anchor " + "it here.'>· ROI anchor &#9432;</span></div>" + "<div class='sd-tools'><button class='sd-draw'>✎ Draw ROI</button>" + "<button class='sd-point primary' title='Click the 4 corners of the rectangle'>✛ Point-fit ROI</button>" + "<button class='sd-del' title='Delete the active ROI (Delete/Backspace)'>🗑 Delete</button>" + "<span style='margin-left:auto;display:flex;gap:6px;align-items:center'>" + "<span class='sd-sw' style='width:12px;height:12px;border-radius:3px'></span>" + "<input class='sd-lbl' placeholder='label' style='width:120px'></span></div></div>" + "<div class='sd-rz' data-side='r'></div>" + "<div class='sd-pane'><div class='sd-head'><span>Selections</span>" + "<span class='sd-seln'>0</span></div><div class='sd-list'></div>" + "<div class='sd-foot'><div class='sd-mode'>" + "<button data-m='single'>single</button><button data-m='multi'>multi</button></div>" + "<div class='sd-saverow'>" + "<button class='sd-save accent'>💾 Save selection.json</button>" + "<label class='sd-auto' title='Automatically write selection.json a moment after each change'>" + "<input type='checkbox' class='sd-autochk'" + (model.get("auto_save") ? " checked" : "") + "> auto-save</label>" + "</div></div></div>" + "</div><div class='sd-toast'></div>"; const $ = (s) => root.querySelector(s); // Set as a property, not interpolated into the innerHTML above: a source path // containing a quote would otherwise break out of the value attribute. $(".sd-path").value = md.path || "(in-memory source)"; const gal = $(".sd-gal"), wrap = $(".sd-cwrap"), main = $(".sd-main"); const cstatus = wrap.querySelector(".sd-cstatus"); function showStatus(text, isErr) { cstatus.textContent = text; cstatus.classList.toggle("err", !!isErr); cstatus.classList.add("show"); } function hideStatus() { cstatus.classList.remove("show", "err"); } // editor geometry: ROIs stored in IMAGE px; s = display px per image px let EH = model.get("view_size") || 430; let EW = Math.round(EH / ASPECT), s = EW / dims[0]; wrap.style.width = EW + "px"; wrap.style.height = EH + "px"; const D = (v) => v * s, I = (v) => v / s; // shared by the size slider, ctrl/cmd+scroll zoom, and fullscreen enter/exit function applyViewSize(v) { EH = Math.max(150, Math.round(v)); EW = Math.round(EH / ASPECT); s = EW / dims[0]; wrap.style.width = EW + "px"; wrap.style.height = EH + "px"; $(".sd-vs").value = EH; renderEditor(); } let selections = (model.get("selections") || []).map((x) => Object.assign({}, x)); let currentPos = 0, activeId = null, mode = model.get("roi_mode") || "single"; const collapsedPos = new Set(); // position headers collapsed in the selections list // start past the highest id already present (e.g. resumed from a saved // selection.json) so newly drawn/fitted ROIs never collide with those ids let frame = FRAME0, uid = selections.reduce((m, x) => Math.max(m, +x.id || 0), 0) + 1, picking = false, points = []; const frameImg = new Image(); // Which position/frame `frameImg` currently holds -- lets the crop preview // tell "the loaded pixels match the active ROI's position" apart from // "still showing the previous position while the new one is in flight", // instead of drawing a confidently-wrong crop from stale pixels. let frameImgPos = -1; const roisAt = (p) => selections.filter((x) => x.position === p); // ---- image requests over the wire ---- const pending = {}; model.on("msg:custom", (msg, buffers) => { if (!msg) return; if (msg.type === "thumb") { const cb = pending["t" + msg.pos]; delete pending["t" + msg.pos]; if (cb && buffers && buffers[0]) cb(blobUrl(buffers[0])); thumbInFlight--; pumpThumbQueue(); } else if (msg.type === "frame") { clearTimeout(frameTimer); if (buffers && buffers[0]) { frameImgPos = msg.pos; frameImg.src = blobUrl(buffers[0]); } } else if (msg.type === "error") { if (msg.kind === "thumb") { delete pending["t" + msg.pos]; thumbInFlight--; pumpThumbQueue(); return; } if (msg.kind === "frame" && msg.pos === currentPos) { clearTimeout(frameTimer); showStatus("Error loading pos " + String(msg.pos).padStart(3, "0") + ": " + (msg.message || "failed to load"), true); frameImgPos = -1; renderSpec(); return; } if (msg.kind === "fit" && picking) { // surfaced persistently in the readout (not a fleeting toast) and // reset for a fresh 4-point attempt, instead of leaving the counter // stuck with no way forward. fitting = false; points = []; drawPick(); pickError = msg.message || "fit failed"; renderSpec(); return; } toast("Error: " + (msg.message || "something went wrong")); } else if (msg.type === "fit" && msg.roi) { finishFit(msg.roi); } else if (msg.type === "saved") { toast("Saved " + (msg.path || "selection.json")); } }); function blobUrl(buf) { const arr = buf instanceof DataView ? new Uint8Array(buf.buffer) : new Uint8Array(buf); return URL.createObjectURL(new Blob([arr], { type: "image/png" })); } frameImg.onload = () => { wrap.style.backgroundImage = "url(" + frameImg.src + ")"; wrap.style.backgroundSize = "cover"; hideStatus(); renderSpec(); }; frameImg.onerror = () => { clearTimeout(frameTimer); showStatus("Failed to decode frame image", true); }; // ---- gallery (accordion + lazy thumb) ---- // A Jupyter kernel processes comm messages one at a time on a single // thread, and each thumb read is a blocking SMB call -- scrolling the // gallery can bring many rows into view almost at once, and firing a // request per row would queue dozens of slow reads ahead of anything else // (e.g. a point-fit or Save the user triggers moments later). Throttle to a // small number in flight so the wire queue stays short. const MAX_THUMB_INFLIGHT = 2; let thumbInFlight = 0; const thumbQueue = []; function pumpThumbQueue() { while (thumbInFlight < MAX_THUMB_INFLIGHT && thumbQueue.length) { const ix = thumbQueue.shift(); thumbInFlight++; model.send({ type: "thumb", pos: ix, downscale: 8 }); } } const io = new IntersectionObserver((ents) => { ents.forEach((e) => { if (e.isIntersecting) { const t = e.target, ix = +t.dataset.ix; pending["t" + ix] = (url) => { const img = document.createElement("img"); img.src = url; t.insertBefore(img, t.firstChild); }; thumbQueue.push(ix); io.unobserve(t); } }); pumpThumbQueue(); }, { root: gal, rootMargin: "150px" }); function computeExp() { const w = gal.clientWidth - 16; root.querySelectorAll(".sd-thumb").forEach((t) => t.style.setProperty("--exp", Math.max(150, Math.round(w * ASPECT)) + "px")); } function buildGallery() { gal.innerHTML = ""; for (let i = 0; i < NPOS; i++) { const d = document.createElement("div"); d.className = "sd-thumb" + (i === currentPos ? " sel" : ""); d.dataset.ix = i; d.innerHTML = "<span class='ix'>pos " + String(i).padStart(3, "0") + "</span>" + (roisAt(i).length ? "<span class='dot'></span>" : ""); d.onclick = () => selectPos(i); gal.appendChild(d); io.observe(d); } computeExp(); } // ---- editor ---- // Move the scrubber, its label and the requested frame together. An ROI is // anchored to whichever frame is shown while it is drawn or edited, so these // three must never drift apart. function setFrame(t) { frame = Math.max(0, Math.min(NT - 1, +t || 0)); $(".sd-frame").value = frame; $(".sd-frlbl").textContent = frame + " / " + NT; } let frameTimer = null; function requestFrame() { clearTimeout(frameTimer); hideStatus(); frameTimer = setTimeout(() => showStatus("Loading pos " + String(currentPos).padStart(3, "0") + "…", false), 150); model.send({ type: "frame", pos: currentPos, t: frame }); } function renderEditor() { wrap.querySelectorAll(".roi:not(.preview)").forEach((n) => n.remove()); roisAt(currentPos).forEach((sel) => { const eln = document.createElement("div"); // An ROI's coordinates belong to the frame it was drawn on; on any other // frame it is shown muted+dashed and labelled with that frame, so it is // obvious whether the annotation belongs to what is on screen. const anchor = sel.anchor_frame || 0, stale = anchor !== frame; eln.className = "roi" + (sel.id === activeId ? " active" : "") + (stale ? " stale" : ""); eln.style.cssText = "--rc:" + hexOf(sel.ci) + ";left:" + D(sel.x) + "px;top:" + D(sel.y) + "px;width:" + D(sel.w) + "px;height:" + D(sel.h) + "px;transform:translate(-50%,-50%) rotate(" + sel.angle + "deg)"; eln.innerHTML = "<span class='tag'>" + sel.label + (stale ? " · f" + anchor : "") + "</span><span class='knob'></span>" + "<span class='rz' data-c='tl'></span><span class='rz' data-c='tr'></span>" + "<span class='rz' data-c='bl'></span><span class='rz' data-c='br'></span>"; eln.addEventListener("pointerdown", (ev) => startDrag(ev, sel, "move", eln)); eln.querySelector(".knob").addEventListener("pointerdown", (ev) => startDrag(ev, sel, "rotate", eln)); eln.querySelectorAll(".rz").forEach((h) => h.addEventListener( "pointerdown", (ev) => startDrag(ev, sel, "resize", eln, h.dataset.c))); wrap.appendChild(eln); }); $(".sd-epos").textContent = "pos " + String(currentPos).padStart(3, "0"); renderSpec(); renderLabel(); } function renderSpec() { const box = $(".sd-spec"); if (picking) { box.textContent = pickHint(); renderCropPreview(null); return; } const sel = selections.find((x) => x.id === activeId); if (!sel || sel.position !== currentPos) { box.textContent = "no ROI selected"; renderCropPreview(null); return; } const um = PX ? " (" + (sel.w * PX).toFixed(1) + "×" + (sel.h * PX).toFixed(1) + " µm)" : ""; const anchor = sel.anchor_frame || 0; box.innerHTML = "center " + Math.round(sel.x) + ", " + Math.round(sel.y) + " px<br>" + "size " + Math.round(sel.w) + "×" + Math.round(sel.h) + " px" + um + "<br>angle " + sel.angle.toFixed(1) + "°" + "<br>anchor frame " + anchor + (anchor === frame ? "" : " <span style='color:var(--text-faint)'>(not this frame)</span>"); renderCropPreview(sel); } // Renders exactly what crop_rotated(RotatedCropSpec(...)) would produce for // the active ROI: sample the already-loaded frame image into a small // un-rotated canvas, de-rotating by the SAME angle (and sense) the ROI box // itself is drawn with (CSS `rotate(sel.angle + "deg")` in renderEditor), // so the preview always matches what's on screen without a kernel round-trip. const MAX_PREVIEW_PX = 220; function renderCropPreview(sel) { const card = $(".sd-crop-card"); if (!sel || frameImgPos !== currentPos || !frameImg.complete || !frameImg.naturalWidth) { card.hidden = true; return; } card.hidden = false; const canvas = $(".sd-crop"); const w = Math.max(1, Math.round(sel.w)), h = Math.max(1, Math.round(sel.h)); const scalePrev = Math.min(1, MAX_PREVIEW_PX / Math.max(w, h)); const cw = Math.max(1, Math.round(w * scalePrev)), ch = Math.max(1, Math.round(h * scalePrev)); if (canvas.width !== cw) canvas.width = cw; if (canvas.height !== ch) canvas.height = ch; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, cw, ch); ctx.save(); ctx.translate(cw / 2, ch / 2); ctx.rotate(-(sel.angle * Math.PI) / 180); ctx.scale(scalePrev, scalePrev); ctx.drawImage(frameImg, -sel.x, -sel.y); ctx.restore(); } function renderLabel() { const sel = selections.find((x) => x.id === activeId); const inp = $(".sd-lbl"), sw = $(".sd-sw"); if (sel && sel.position === currentPos) { inp.value = sel.label; inp.disabled = false; sw.style.background = hexOf(sel.ci); } else { inp.value = ""; inp.disabled = true; sw.style.background = "var(--border)"; } } function selectPos(p) { if (picking) exitPick(); currentPos = p; const h = roisAt(p); activeId = h.length ? h[0].id : null; root.querySelectorAll(".sd-thumb").forEach((t) => t.classList.toggle("sel", +t.dataset.ix === p)); // The frame is deliberately NOT reset here: comparing the same timepoint // across positions is what a curator actually wants, and resetting made // this inconsistent with clicking a row in the selections list, which // never reset it either. requestFrame(); renderEditor(); renderList(); } // ---- selections ---- let autosave = !!model.get("auto_save"), autoSaveTimer = null; function pushSelections() { model.set("selections", selections.map((x) => ({ id: x.id, position: x.position, label: x.label, ci: x.ci, anchor_frame: x.anchor_frame || 0, roi: { center: [x.x, x.y], size: [Math.round(x.w), Math.round(x.h)], angle: x.angle }, }))); model.save_changes(); if (autosave) { // debounced so rapid edits (dragging, typing a label) trigger one // disk write shortly after things settle, not one per keystroke/pixel clearTimeout(autoSaveTimer); autoSaveTimer = setTimeout(() => model.send({ type: "save" }), 800); } } function renderList() { const list = $(".sd-list"); list.innerHTML = ""; const byPos = {}; selections.forEach((x) => { (byPos[x.position] = byPos[x.position] || []).push(x); }); Object.keys(byPos).map(Number).sort((a, b) => a - b).forEach((p) => { const collapsed = collapsedPos.has(p); const hd = document.createElement("div"); hd.className = "sd-poshd"; hd.textContent = (collapsed ? "▸" : "▾") + " pos " + String(p).padStart(3, "0") + " (" + byPos[p].length + ")"; hd.onclick = () => { if (collapsedPos.has(p)) collapsedPos.delete(p); else collapsedPos.add(p); renderList(); }; list.appendChild(hd); if (collapsed) return; byPos[p].forEach((sel) => { const row = document.createElement("div"); row.className = "sd-row" + (sel.id === activeId ? " active" : ""); row.innerHTML = "<span class='sw' style='background:" + hexOf(sel.ci) + "'></span>" + "<span class='nm'>" + sel.label + "</span>" + "<span style='font-family:var(--font-mono);font-size:10px;color:var(--text-faint)'>" + Math.round(sel.w) + "×" + Math.round(sel.h) + " · f" + (sel.anchor_frame || 0) + "</span>"; row.onclick = () => { currentPos = sel.position; activeId = sel.id; root.querySelectorAll(".sd-thumb").forEach((t) => t.classList.toggle("sel", +t.dataset.ix === currentPos)); // Jump to the frame this ROI was drawn on, so it shows solid over // the pixels its coordinates actually describe. setFrame(sel.anchor_frame || 0); requestFrame(); renderEditor(); renderList(); }; list.appendChild(row); }); }); $(".sd-seln").textContent = selections.length; root.querySelectorAll(".sd-thumb").forEach((t) => { const has = roisAt(+t.dataset.ix).length; let dot = t.querySelector(".dot"); if (has && !dot) { dot = document.createElement("span"); dot.className = "dot"; t.appendChild(dot); } if (!has && dot) dot.remove(); }); } function addRoi() { if (mode === "single") selections = selections.filter((x) => x.position !== currentPos); const h = roisAt(currentPos), ci = h.length ? Math.max.apply(null, h.map((x) => x.ci)) + 1 : 0; const sel = { id: uid++, position: currentPos, x: dims[0] / 2, y: dims[1] / 2, w: Math.round(dims[0] / 3), h: Math.round(dims[1] / 3), angle: 0, label: "roi_" + String(ci + 1).padStart(2, "0"), ci: ci, anchor_frame: frame }; selections.push(sel); activeId = sel.id; renderEditor(); renderList(); pushSelections(); } function duplicateActive() { const src = selections.find((x) => x.id === activeId); if (!src || src.position !== currentPos) return; const h = roisAt(currentPos), ci = h.length ? Math.max.apply(null, h.map((x) => x.ci)) + 1 : 0; // Offset so the copy doesn't sit exactly on top of the source (which would // otherwise look like nothing happened) but stays inside the frame. Flip // the offset's sign per-axis when the source is already flush against // that edge, so a corner-positioned source doesn't clamp both axes back // to the same point as the source. const OFFSET = 24; const offX = (src.x + OFFSET <= dims[0] - 5) ? OFFSET : -OFFSET; const offY = (src.y + OFFSET <= dims[1] - 5) ? OFFSET : -OFFSET; const sel = { id: uid++, position: currentPos, x: Math.max(5, Math.min(dims[0] - 5, src.x + offX)), y: Math.max(5, Math.min(dims[1] - 5, src.y + offY)), w: src.w, h: src.h, angle: src.angle, // The copy's geometry is the source's, so it is correct in the *source's* // anchor frame -- not in whatever frame happens to be on screen. The drag // that follows re-anchors it. label: "roi_" + String(ci + 1).padStart(2, "0"), ci: ci, anchor_frame: src.anchor_frame || 0 }; selections.push(sel); activeId = sel.id; renderEditor(); renderList(); pushSelections(); toast("Duplicated ROI -- drag it into place"); } function removeSel(id) { selections = selections.filter((x) => x.id !== id); if (activeId === id) { const h = roisAt(currentPos); activeId = h.length ? h[0].id : null; } renderEditor(); renderList(); pushSelections(); } // ---- drag / rotate / resize ---- let drag = null; function startDrag(ev, sel, kind, eln, corner) { if (picking) return; ev.preventDefault(); ev.stopPropagation(); if (activeId !== sel.id) { activeId = sel.id; renderEditor(); renderList(); } drag = { sel, kind, eln, rect: wrap.getBoundingClientRect(), px: ev.clientX, py: ev.clientY, ox: sel.x, oy: sel.y }; if (kind === "resize") { // Anchor the corner diagonally opposite the one being dragged: it stays // fixed in image space for the whole drag, so only the two dimensions // toward the dragged corner change (w/h + the center shift that keeps // the anchor put), instead of resizing symmetrically about the center. const signX = (corner === "tr" || corner === "br") ? 1 : -1; const signY = (corner === "bl" || corner === "br") ? 1 : -1; const theta = sel.angle * Math.PI / 180, cos = Math.cos(theta), sin = Math.sin(theta); const alx = -signX * sel.w / 2, aly = -signY * sel.h / 2; drag.anchorX = sel.x + (alx * cos - aly * sin); drag.anchorY = sel.y + (alx * sin + aly * cos); drag.signX = signX; drag.signY = signY; drag.rAngle = theta; } window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp, { once: true }); } function onMove(ev) { if (!drag) return; drag.moved = true; const { sel, kind, rect } = drag, mx = ev.clientX - rect.left, my = ev.clientY - rect.top; if (kind === "move") { sel.x = Math.max(5, Math.min(dims[0] - 5, drag.ox + I(ev.clientX - drag.px))); sel.y = Math.max(5, Math.min(dims[1] - 5, drag.oy + I(ev.clientY - drag.py))); } else if (kind === "rotate") { sel.angle = Math.round((Math.atan2(my - D(sel.y), mx - D(sel.x)) * 180 / Math.PI + 90) * 10) / 10; } else if (kind === "resize") { const cos = Math.cos(drag.rAngle), sin = Math.sin(drag.rAngle); const vx = I(mx) - drag.anchorX, vy = I(my) - drag.anchorY; // anchor->pointer vector, expressed in the box's own (unrotated) frame const lx = vx * cos + vy * sin, ly = -vx * sin + vy * cos; sel.w = Math.max(20, Math.round(Math.abs(lx))); sel.h = Math.max(20, Math.round(Math.abs(ly))); const hx = drag.signX * sel.w / 2, hy = drag.signY * sel.h / 2; sel.x = drag.anchorX + (hx * cos - hy * sin); sel.y = drag.anchorY + (hx * sin + hy * cos); } const eln = drag.eln; eln.style.left = D(sel.x) + "px"; eln.style.top = D(sel.y) + "px"; eln.style.width = D(sel.w) + "px"; eln.style.height = D(sel.h) + "px"; eln.style.transform = "translate(-50%,-50%) rotate(" + sel.angle + "deg)"; renderSpec(); } function onUp() { window.removeEventListener("pointermove", onMove); // Re-anchor to the frame the user was actually looking at while editing -- // the new coordinates are in *that* frame's system. Gated on an actual // move: a bare click just selects a box (pointerdown -> pointerup with no // pointermove), and must not silently re-anchor an ROI merely looked at. if (drag && drag.moved) { drag.sel.anchor_frame = frame; renderEditor(); } drag = null; renderList(); pushSelections(); } // ---- point-fit (via Python cv2) ---- const FIT_POINTS = 4; // one click per corner of the intended rectangle let fitting = false, pickError = null; // fitting: request in flight; pickError: last failure (persistent, not a fleeting toast) function pickHint() { if (fitting) return "Fitting…"; if (pickError) return "Error: " + pickError + " -- click " + FIT_POINTS + " corners again (0 / " + FIT_POINTS + ")"; return "Click the " + FIT_POINTS + " corners (" + points.length + " / " + FIT_POINTS + ")"; } function enterPick() { picking = true; points = []; fitting = false; pickError = null; activeId = null; wrap.classList.add("pick"); renderEditor(); drawPick(); $(".sd-point").classList.add("on"); } function exitPick() { picking = false; points = []; fitting = false; wrap.classList.remove("pick"); wrap.querySelectorAll(".ptmark").forEach((n) => n.remove()); $(".sd-point").classList.remove("on"); } wrap.addEventListener("click", (ev) => { if (!picking || fitting) return; const r = wrap.getBoundingClientRect(), x = ev.clientX - r.left, y = ev.clientY - r.top; if (x < 0 || y < 0 || x > EW || y > EH) return; pickError = null; points.push([I(x), I(y)]); drawPick(); if (points.length >= FIT_POINTS) { fitting = true; model.send({ type: "fit", points: points }); } renderSpec(); }); function drawPick() { wrap.querySelectorAll(".ptmark").forEach((n) => n.remove()); points.forEach((p) => { const d = document.createElement("div"); d.className = "ptmark"; d.style.left = D(p[0]) + "px"; d.style.top = D(p[1]) + "px"; wrap.appendChild(d); }); } function finishFit(roi) { if (!picking) return; // commit immediately as a normal, adjustable ROI -- renderEditor() below // draws it with the usual drag/rotate/resize handles right away. const cx = roi.center[0], cy = roi.center[1], w = roi.size[0], h = roi.size[1]; if (mode === "single") selections = selections.filter((x) => x.position !== currentPos); const hh = roisAt(currentPos), ci = hh.length ? Math.max.apply(null, hh.map((x) => x.ci)) + 1 : 0; const sel = { id: uid++, position: currentPos, x: cx, y: cy, w: w, h: h, angle: roi.angle, label: "roi_" + String(ci + 1).padStart(2, "0"), ci: ci, anchor_frame: frame }; selections.push(sel); activeId = sel.id; exitPick(); renderEditor(); renderList(); pushSelections(); toast("Fitted ROI from points"); } // ---- controls ---- $(".sd-vs").addEventListener("input", (e) => { applyViewSize(+e.target.value); model.set("view_size", EH); model.save_changes(); }); // Ctrl/Cmd+scroll zooms the editor image; plain scroll passes through to pan // the surrounding (overflow:auto) pane, so it never fights normal scrolling. wrap.addEventListener("wheel", (e) => { if (!(e.ctrlKey || e.metaKey)) return; e.preventDefault(); applyViewSize(EH * (e.deltaY < 0 ? 1.12 : 1 / 1.12)); model.set("view_size", EH); model.save_changes(); }, { passive: false }); // ---- fullscreen ---- let preFsViewSize = EH; const fsBtn = $(".sd-full"); const requestFs = root.requestFullscreen || root.webkitRequestFullscreen; const exitFs = document.exitFullscreen || document.webkitExitFullscreen; fsBtn.onclick = () => { if (!requestFs) { toast("Fullscreen not supported in this browser"); return; } if (document.fullscreenElement === root) exitFs.call(document); else requestFs.call(root); }; function onFsChange() { const isFs = document.fullscreenElement === root; fsBtn.textContent = isFs ? "⤢ Exit fullscreen" : "⛶ Fullscreen"; if (isFs) { preFsViewSize = EH; applyViewSize(Math.round(window.innerHeight * 0.82)); } else applyViewSize(preFsViewSize); } document.addEventListener("fullscreenchange", onFsChange); $(".sd-frame").addEventListener("input", (e) => { setFrame(e.target.value); requestFrame(); renderEditor(); }); $(".sd-draw").onclick = () => { if (picking) exitPick(); addRoi(); }; $(".sd-point").onclick = () => { picking ? exitPick() : enterPick(); if (!picking) renderEditor(); }; $(".sd-del").onclick = () => { if (activeId) removeSel(activeId); }; $(".sd-lbl").addEventListener("input", (e) => { const sel = selections.find((x) => x.id === activeId); if (sel) { sel.label = e.target.value; renderEditor(); renderList(); pushSelections(); } }); root.querySelectorAll(".sd-mode button").forEach((b) => { b.onclick = () => { mode = b.dataset.m; model.set("roi_mode", mode); model.save_changes(); root.querySelectorAll(".sd-mode button").forEach((x) => x.classList.toggle("on", x === b)); if (mode === "single") { const seen = {}; selections = selections.filter((x) => { if (seen[x.position]) return false; seen[x.position] = 1; return true; }); renderEditor(); renderList(); pushSelections(); } }; }); root.querySelectorAll(".sd-mode button").forEach((x) => x.classList.toggle("on", x.dataset.m === mode)); $(".sd-save").onclick = () => model.send({ type: "save" }); $(".sd-autochk").addEventListener("change", (e) => { autosave = e.target.checked; // round-trip to the trait so the choice survives a re-render and is // readable/settable from Python (and preserved across `resume`) model.set("auto_save", autosave); model.save_changes(); }); // ---- keyboard shortcuts (Delete/Backspace, Ctrl/Cmd+C) ---- // Scoped to "mouse is over this widget instance" (not tab/window focus) so // multiple dashboards in one notebook, or typing elsewhere on the page, // don't cross-trigger each other -- same reasoning as the wheel-zoom above. let hovering = false; root.addEventListener("pointerenter", () => { hovering = true; }); root.addEventListener("pointerleave", () => { hovering = false; }); function onKeyDown(ev) { if (!hovering || picking) return; const t = ev.target; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; if (!activeId) return; if (ev.key === "Delete" || ev.key === "Backspace") { ev.preventDefault(); removeSel(activeId); } else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "c") { // Only steal the shortcut when we're actually going to act: leave native // copy alone in single mode (duplicate is meaningless there -- at most // one ROI per position) and when the user has text selected (they're // copying that, not asking to duplicate the ROI). if (mode !== "multi") return; const textSel = window.getSelection(); if (textSel && textSel.toString()) return; ev.preventDefault(); duplicateActive(); } } document.addEventListener("keydown", onKeyDown); // ---- splitters ---- root.querySelectorAll(".sd-rz").forEach((rz) => { rz.addEventListener("pointerdown", (e) => { e.preventDefault(); const side = rz.dataset.side, sx = e.clientX; const pane = side === "l" ? main.children[0] : main.children[4]; const sw = pane.getBoundingClientRect().width; const mv = (ev) => { const d = ev.clientX - sx; if (side === "l") { main.style.setProperty("--lw", Math.max(140, Math.min(360, sw + d)) + "px"); computeExp(); } else main.style.setProperty("--rw", Math.max(180, Math.min(420, sw - d)) + "px"); }; const up = () => { window.removeEventListener("pointermove", mv); window.removeEventListener("pointerup", up); }; window.addEventListener("pointermove", mv); window.addEventListener("pointerup", up, { once: true }); }); }); let toastT; function toast(m) { const t = root.querySelector(".sd-toast"); t.textContent = m; t.classList.add("show"); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove("show"), 2200); } buildGallery(); selectPos(0); renderList(); return () => { io.disconnect(); document.removeEventListener("fullscreenchange", onFsChange); document.removeEventListener("keydown", onKeyDown); }; } export default { render }; """ # The RegistrationDashboard ESM: method/position picker, a verify view (drift # trajectory + before/after toggle), a mask-rect editor for # MaskedTemplateCorrelation (porting ROICropper's click-to-fit + drag/resize/ # rotate interaction model -- same corner/rotate-handle math, own canvas/model # keys so ROICropper itself is untouched), and a batch-apply panel with a live # progress bar fed by the widget's "progress" messages. Like the other widgets # here, this is BEST-EFFORT JavaScript, unverified by the headless Python # suite -- validated only by a real Jupyter/Colab/marimo run (no ESM/Playwright # suite for this widget in v1, per the spec's Never section). _REGISTRATION_DASHBOARD_ESM = r""" // RegistrationDashboard render() -- method/position/verify controls, a mask-rect // editor (shown only for MaskedTemplateCorrelation), a drift-trajectory + // before/after verify view, and a batch-apply panel with a live progress bar. // UNVERIFIED in CI: validated by a real Jupyter/Colab/marimo run only. function render({ model, el }) { el.innerHTML = ""; const root = document.createElement("div"); root.style.font = "13px sans-serif"; root.style.border = "1px solid #ccc"; root.style.borderRadius = "8px"; root.style.padding = "10px"; root.style.maxWidth = "720px"; el.appendChild(root); const METHODS = [ "PhaseCorrelationHighpass", "MaskedTemplateCorrelation", "HoughLineRigidFit", "FeatureRANSACEuclidean", "GradientECC", ]; const md = model.get("metadata") || {}; const numPositions = md.num_positions || (model.get("positions") || []).length || 1; root.innerHTML = "<div class='rd-head' style='display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px;'>" + "<label>Method <select class='rd-method'>" + METHODS.map((m) => "<option value='" + m + "'>" + m + "</option>").join("") + "</select></label>" + "<label>Position <input class='rd-pos' type='number' min='0' max='" + (numPositions - 1) + "' value='0' style='width:60px'></label>" + "<label>Samples <input class='rd-nsamp' type='number' min='1' value='" + model.get("n_sample_frames") + "' style='width:50px'></label>" + "<button class='rd-verify'>Verify</button>" + "</div>" + "<div class='rd-mask' style='display:none;margin-bottom:10px;'>" + "<div style='font-size:11px;color:#666;margin-bottom:4px;'>Mask rect for " + "MaskedTemplateCorrelation: click &ge;3 points around a static landmark " + "(frame 0 of the position above), or drag the box / corners / rotate knob.</div>" + "<canvas class='rd-mask-canvas' style='touch-action:none;border:1px solid #999;'></canvas>" + "</div>" + "<div class='rd-verify-out' style='display:none;margin-bottom:10px;'>" + "<canvas class='rd-traj' width='640' height='140' style='border:1px solid #ddd;'></canvas>" + "<div class='rd-player' style='margin-top:8px;'>" + "<div style='display:flex;gap:8px;'>" + "<canvas class='rd-player-before' width='260' height='260' style='border:1px solid #ddd;max-width:48%;background:#111;'></canvas>" + "<canvas class='rd-player-after' width='260' height='260' style='border:1px solid #ddd;max-width:48%;background:#111;'></canvas>" + "</div>" + "<div style='display:flex;gap:8px;align-items:center;margin-top:4px;'>" + "<button type='button' class='rd-play-btn'>Play</button>" + "<input type='range' class='rd-scrubber' min='0' max='0' value='0' step='1' style='flex:1;'>" + "<span class='rd-player-label' style='font-size:11px;color:#666;white-space:nowrap;'></span>" + "</div>" + "</div>" + "</div>" + "<div class='rd-status' style='font-size:11px;color:#a33;margin-bottom:6px;'></div>" + "<div class='rd-batch' style='border-top:1px solid #ddd;padding-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;'>" + "<input class='rd-dir' placeholder='(current working directory)' style='flex:1;min-width:160px;'>" + "<button class='rd-batch-btn'>Batch Apply</button>" + "<button class='rd-save-btn'>Save</button>" + "</div>" + "<div class='rd-progress-wrap' style='display:none;margin-top:8px;'>" + "<div style='background:#eee;border-radius:4px;height:10px;overflow:hidden;'>" + "<div class='rd-progress-bar' style='background:#0f8f82;height:100%;width:0%;'></div>" + "</div>" + "<div class='rd-progress-label' style='font-size:11px;color:#666;margin-top:2px;'></div>" + "</div>"; const $ = (s) => root.querySelector(s); const methodSel = $(".rd-method"), posInput = $(".rd-pos"), nsampInput = $(".rd-nsamp"); const maskWrap = $(".rd-mask"), maskCanvas = $(".rd-mask-canvas"); const verifyOut = $(".rd-verify-out"), trajCanvas = $(".rd-traj"); const playerBeforeCanvas = $(".rd-player-before"), playerAfterCanvas = $(".rd-player-after"); const playBtn = $(".rd-play-btn"), scrubber = $(".rd-scrubber"), playerLabel = $(".rd-player-label"); const statusEl = $(".rd-status"); const dirInput = $(".rd-dir"), progWrap = $(".rd-progress-wrap"); const progBar = $(".rd-progress-bar"), progLabel = $(".rd-progress-label"); methodSel.value = model.get("method_name"); function updateMaskVisibility() { maskWrap.style.display = methodSel.value === "MaskedTemplateCorrelation" ? "block" : "none"; } updateMaskVisibility(); function requestMaskFrame() { model.send({ type: "mask_frame", position: parseInt(posInput.value, 10) || 0 }); } methodSel.addEventListener("change", () => { model.set("method_name", methodSel.value); model.save_changes(); updateMaskVisibility(); if (methodSel.value === "MaskedTemplateCorrelation") { layoutMask(); requestMaskFrame(); } }); nsampInput.addEventListener("change", () => { model.set("n_sample_frames", Math.max(1, parseInt(nsampInput.value, 10) || 1)); model.save_changes(); }); posInput.addEventListener("change", () => { if (methodSel.value === "MaskedTemplateCorrelation") requestMaskFrame(); }); function showStatus(msg, isErr) { statusEl.textContent = msg || ""; statusEl.style.color = isErr ? "#a33" : "#666"; } function blobUrl(buf) { const arr = buf instanceof DataView ? new Uint8Array(buf.buffer) : new Uint8Array(buf); return URL.createObjectURL(new Blob([arr], { type: "image/png" })); } // ---- mask-rect editor: click-to-fit + drag/resize/rotate, porting // ROICropper's interaction model (same corner/rotate-handle geometry) onto // this widget's own mask_* traits/canvas -- ROICropper itself is untouched. const maskImg = new Image(); let maskReady = false; maskImg.onload = () => { maskReady = true; layoutMask(); drawMask(); }; maskImg.onerror = () => { maskReady = false; drawMask(); }; let mscale = 1; function maskImgW() { return model.get("mask_image_w") || 1; } function maskImgH() { return model.get("mask_image_h") || 1; } function layoutMask() { const w = maskImgW(), h = maskImgH(); const MAX_W = 480; mscale = Math.min(1, MAX_W / w); maskCanvas.width = Math.round(w * mscale); maskCanvas.height = Math.round(h * mscale); } function mToImg(px, py) { return [px / mscale, py / mscale]; } function mToCanvas(ix, iy) { return [ix * mscale, iy * mscale]; } function getMaskRect() { return { cx: model.get("mask_center_x"), cy: model.get("mask_center_y"), w: model.get("mask_width"), h: model.get("mask_height"), angle: model.get("mask_angle"), }; } function maskCorners(r) { const a = (r.angle * Math.PI) / 180, ca = Math.cos(a), sa = Math.sin(a); const hw = r.w / 2, hh = r.h / 2; const local = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]; return local.map(([lx, ly]) => [r.cx + lx * ca + ly * sa, r.cy - lx * sa + ly * ca]); } function maskLocalToImg(r, lx, ly) { const a = (r.angle * Math.PI) / 180, ca = Math.cos(a), sa = Math.sin(a); return [r.cx + lx * ca + ly * sa, r.cy - lx * sa + ly * ca]; } function maskImgToLocal(r, ix, iy) { const a = (r.angle * Math.PI) / 180, ca = Math.cos(a), sa = Math.sin(a); const dx = ix - r.cx, dy = iy - r.cy; return [dx * ca - dy * sa, dx * sa + dy * ca]; } function maskRotateHandle(r) { const off = r.h / 2 + 24 / mscale; return maskLocalToImg(r, 0, -off); } function drawMask() { const ctx = maskCanvas.getContext("2d"); ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); if (maskReady) ctx.drawImage(maskImg, 0, 0, maskCanvas.width, maskCanvas.height); const pts = model.get("mask_points") || []; ctx.fillStyle = "#00e5ff"; for (const [ix, iy] of pts) { const [px, py] = mToCanvas(ix, iy); ctx.beginPath(); ctx.arc(px, py, 3, 0, 2 * Math.PI); ctx.fill(); } const r = getMaskRect(); if (r.w > 0 && r.h > 0) { const cs = maskCorners(r).map(([ix, iy]) => mToCanvas(ix, iy)); ctx.strokeStyle = "#ffeb3b"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(cs[0][0], cs[0][1]); for (let i = 1; i < cs.length; i++) ctx.lineTo(cs[i][0], cs[i][1]); ctx.closePath(); ctx.stroke(); ctx.fillStyle = "#ffeb3b"; for (const [px, py] of cs) ctx.fillRect(px - 4, py - 4, 8, 8); const [rx, ry] = mToCanvas(...maskRotateHandle(r)); const [ccx, ccy] = mToCanvas(r.cx, r.cy); ctx.strokeStyle = "#ff5252"; ctx.beginPath(); ctx.moveTo(ccx, ccy); ctx.lineTo(rx, ry); ctx.stroke(); ctx.fillStyle = "#ff5252"; ctx.beginPath(); ctx.arc(rx, ry, 5, 0, 2 * Math.PI); ctx.fill(); } } let mdrag = null; const MOVE_THRESHOLD = 4; function maskLocalPos(ev) { const rect = maskCanvas.getBoundingClientRect(); const sx = rect.width ? maskCanvas.width / rect.width : 1; const sy = rect.height ? maskCanvas.height / rect.height : 1; return [(ev.clientX - rect.left) * sx, (ev.clientY - rect.top) * sy]; } function maskHit(px, py) { const r = getMaskRect(); if (r.w > 0 && r.h > 0) { const cs = maskCorners(r).map(([ix, iy]) => mToCanvas(ix, iy)); for (let i = 0; i < cs.length; i++) { if (Math.hypot(px - cs[i][0], py - cs[i][1]) <= 8) return { mode: "resize", cornerIndex: i }; } const [rx, ry] = mToCanvas(...maskRotateHandle(r)); if (Math.hypot(px - rx, py - ry) <= 8) return { mode: "rotate" }; const [ix, iy] = mToImg(px, py); const [lx, ly] = maskImgToLocal(r, ix, iy); if (Math.abs(lx) <= r.w / 2 && Math.abs(ly) <= r.h / 2) return { mode: "move" }; } return null; } function addMaskPoint(px, py) { const [ix, iy] = mToImg(px, py); const pts = (model.get("mask_points") || []).slice(); pts.push([ix, iy]); model.set("mask_points", pts); model.save_changes(); drawMask(); } function onMaskDown(ev) { const [px, py] = maskLocalPos(ev); const h = maskHit(px, py); const r = getMaskRect(); const [ix, iy] = mToImg(px, py); mdrag = { ...(h || {}), candidateMode: h ? h.mode : null, mode: null, r, ix, iy, startPx: px, startPy: py, moved: false, }; try { maskCanvas.setPointerCapture(ev.pointerId); } catch (e) {} } function onMaskMove(ev) { if (!mdrag) return; const [px, py] = maskLocalPos(ev); if (!mdrag.moved) { if (Math.hypot(px - mdrag.startPx, py - mdrag.startPy) < MOVE_THRESHOLD) return; mdrag.moved = true; mdrag.mode = mdrag.candidateMode; } if (!mdrag.mode) return; const [ix, iy] = mToImg(px, py); const r = mdrag.r; if (mdrag.mode === "move") { model.set("mask_center_x", r.cx + (ix - mdrag.ix)); model.set("mask_center_y", r.cy + (iy - mdrag.iy)); } else if (mdrag.mode === "rotate") { const angle = Math.atan2(-(ix - r.cx), -(iy - r.cy)) * 180 / Math.PI; model.set("mask_angle", angle); } else if (mdrag.mode === "resize") { const hw = r.w / 2, hh = r.h / 2; const localCorners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]; const i = mdrag.cornerIndex; const opp = localCorners[(i + 2) % 4]; const [ox, oy] = maskLocalToImg(r, opp[0], opp[1]); const ncx = (ix + ox) / 2, ncy = (iy + oy) / 2; const rAxes = { ...r, cx: ncx, cy: ncy }; const [lx, ly] = maskImgToLocal(rAxes, ix, iy); const oLocal = maskImgToLocal(rAxes, ox, oy); const nw = Math.max(1, Math.round(Math.abs(lx - oLocal[0]))); const nh = Math.max(1, Math.round(Math.abs(ly - oLocal[1]))); model.set("mask_center_x", ncx); model.set("mask_center_y", ncy); model.set("mask_width", nw); model.set("mask_height", nh); } model.save_changes(); drawMask(); } function onMaskUp(ev) { if (!mdrag) return; if (!mdrag.moved) addMaskPoint(mdrag.startPx, mdrag.startPy); try { maskCanvas.releasePointerCapture(ev.pointerId); } catch (e) {} mdrag = null; } maskCanvas.addEventListener("pointerdown", onMaskDown); maskCanvas.addEventListener("pointermove", onMaskMove); maskCanvas.addEventListener("pointerup", onMaskUp); maskCanvas.addEventListener("pointercancel", onMaskUp); function onMaskImageChange() { const b64 = model.get("mask_image_b64"); if (b64) { maskImg.src = b64; } } function onMaskGeomChange() { drawMask(); } model.on("change:mask_image_b64", onMaskImageChange); model.on( "change:mask_center_x change:mask_center_y change:mask_width change:mask_height " + "change:mask_angle change:mask_points", onMaskGeomChange, ); // ---- verify: drift trajectory (dx/dy/theta) + a play/pause+scrubber // side-by-side comparison player over every sampled frame ---- $(".rd-verify").addEventListener("click", () => { showStatus(""); progWrap.style.display = "block"; progBar.style.width = "0%"; progLabel.textContent = "starting verify..."; model.send({ type: "verify", position: parseInt(posInput.value, 10) || 0, method: methodSel.value, }); }); function fmtDuration(seconds) { const s = Math.max(0, Math.round(seconds)); const m = Math.floor(s / 60); const r = s % 60; return m + "m " + r + "s"; } // ---- comparison player: preloads each sampled frame's uncorrected/ // corrected PNGs as Image objects from blob URLs (same blobUrl() helper // used elsewhere), then a scrubber + optional setInterval-driven autoplay // redraws the two side-by-side canvases per tick -- no new dependency, same // PNG-over-comm-buffer delivery mechanism already in use. let player = []; let playTimer = null; // loadImage() creates one object URL per PNG (via blobUrl()) that stays // alive until explicitly revoked -- each frame keeps its uncorrImgUrl/ // corrImgUrl alongside the decoded Image so revokePlayerUrls() can release // them once a player array is no longer displayed (superseded by a new // verify_result, or the widget itself is torn down). function loadImage(buf) { return new Promise((resolve) => { if (!buf) { resolve({ img: null, url: null }); return; } const url = blobUrl(buf); const img = new Image(); img.onload = () => resolve({ img, url }); img.onerror = () => resolve({ img: null, url }); img.src = url; }); } function revokePlayerUrls(frames) { (frames || []).forEach((f) => { if (f.uncorrImgUrl) URL.revokeObjectURL(f.uncorrImgUrl); if (f.corrImgUrl) URL.revokeObjectURL(f.corrImgUrl); }); } async function buildPlayer(msg, buffers) { const frameIndices = msg.frame_indices || []; const hasCorrection = msg.has_correction || []; const bufs = buffers || []; let cursor = 1; // bufs[0] is the reference frame, not part of the player const frames = []; for (let i = 0; i < frameIndices.length; i++) { const uncorr = await loadImage(bufs[cursor++]); let corr = { img: null, url: null }; if (hasCorrection[i]) corr = await loadImage(bufs[cursor++]); frames.push({ frameIndex: frameIndices[i], uncorrImg: uncorr.img, uncorrImgUrl: uncorr.url, corrImg: corr.img, corrImgUrl: corr.url, hasCorrection: !!hasCorrection[i], }); } return frames; } function drawHalf(canvas, img) { const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); if (img) ctx.drawImage(img, 0, 0, canvas.width, canvas.height); } function drawPlayerFrame(idx) { const f = player[idx]; if (!f) return; drawHalf(playerBeforeCanvas, f.uncorrImg); drawHalf(playerAfterCanvas, f.hasCorrection ? f.corrImg : f.uncorrImg); playerLabel.textContent = "frame " + f.frameIndex + " (" + (idx + 1) + "/" + player.length + ")" + (f.hasCorrection ? "" : " -- no correction available"); } function stopPlayback() { if (playTimer) { clearInterval(playTimer); playTimer = null; } playBtn.textContent = "Play"; } function startPlayback() { if (player.length < 2) return; playBtn.textContent = "Pause"; playTimer = setInterval(() => { let idx = (parseInt(scrubber.value, 10) || 0) + 1; if (idx >= player.length) idx = 0; scrubber.value = String(idx); drawPlayerFrame(idx); }, 500); } playBtn.addEventListener("click", () => { if (playTimer) stopPlayback(); else startPlayback(); }); scrubber.addEventListener("input", () => { stopPlayback(); drawPlayerFrame(parseInt(scrubber.value, 10) || 0); }); function drawTrajectory(frameIndices, transforms) { const ctx = trajCanvas.getContext("2d"); ctx.clearRect(0, 0, trajCanvas.width, trajCanvas.height); const n = frameIndices.length; if (n === 0) return; const dx = transforms.map((t) => (t ? t.dx : null)); const dy = transforms.map((t) => (t ? t.dy : null)); const theta = transforms.map((t) => (t ? t.theta : null)); const nums = [].concat(dx, dy).filter((v) => v !== null && v !== undefined); const maxAbs = Math.max(1, ...nums.map((v) => Math.abs(v))); const midY = trajCanvas.height / 2; const scaleY = (trajCanvas.height / 2 - 10) / maxAbs; const stepX = n > 1 ? (trajCanvas.width - 20) / (n - 1) : 0; function plot(series, color) { ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 1.5; let started = false, prevX = 0, prevY = 0; series.forEach((v, i) => { const x = 10 + i * stepX; if (v === null || v === undefined) { started = false; return; } const y = midY - v * scaleY; ctx.beginPath(); if (started) ctx.moveTo(prevX, prevY); else ctx.moveTo(x, y); ctx.lineTo(x, y); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, 2, 0, 2 * Math.PI); ctx.fill(); started = true; prevX = x; prevY = y; }); } ctx.strokeStyle = "#ccc"; ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(trajCanvas.width, midY); ctx.stroke(); plot(dx, "#2b7fd6"); plot(dy, "#d24d92"); plot(theta, "#25a06f"); ctx.fillStyle = "#555"; ctx.font = "10px sans-serif"; ctx.fillText("dx (blue) dy (pink) theta (green)", 10, 12); } model.on("msg:custom", (msg, buffers) => { if (!msg) return; if (msg.type === "verify_result") { verifyOut.style.display = "block"; drawTrajectory(msg.frame_indices, msg.transforms); stopPlayback(); buildPlayer(msg, buffers).then((frames) => { revokePlayerUrls(player); player = frames; scrubber.max = String(Math.max(0, frames.length - 1)); scrubber.value = "0"; drawPlayerFrame(0); }); } else if (msg.type === "progress") { progWrap.style.display = "block"; if (msg.phase === "verify") { const frac = msg.num_frames ? (msg.frame + 1) / msg.num_frames : 0; progBar.style.width = Math.min(100, Math.round(frac * 100)) + "%"; progLabel.textContent = "verify: frame " + (msg.frame + 1) + "/" + msg.num_frames; } else { const frac = msg.num_frames ? msg.frame / msg.num_frames : 0; const posFrac = msg.num_positions ? (msg.position + frac) / msg.num_positions : 0; progBar.style.width = Math.min(100, Math.round(posFrac * 100)) + "%"; let label = "position " + msg.position + "/" + msg.num_positions + " · frame " + msg.frame + "/" + msg.num_frames; if (msg.elapsed_seconds !== undefined && msg.elapsed_seconds !== null) { label += " · elapsed " + fmtDuration(msg.elapsed_seconds); if (msg.eta_seconds !== undefined && msg.eta_seconds !== null) { label += " · ETA ~" + fmtDuration(msg.eta_seconds); } } progLabel.textContent = label; } } else if (msg.type === "batch_done") { progBar.style.width = "100%"; const nFailed = msg.failed_positions ? msg.failed_positions.length : 0; progLabel.textContent = "done: " + msg.completed.length + " completed, " + msg.skipped.length + " skipped, " + nFailed + " failed -- saved to " + msg.path; showStatus(""); } else if (msg.type === "saved") { showStatus("saved " + msg.path); } else if (msg.type === "error") { showStatus("Error (" + msg.kind + "): " + msg.message, true); } }); // ---- batch-apply ---- $(".rd-batch-btn").addEventListener("click", () => { showStatus(""); progWrap.style.display = "block"; progBar.style.width = "0%"; progLabel.textContent = "starting..."; model.send({ type: "batch_apply", directory: dirInput.value || null }); }); $(".rd-save-btn").addEventListener("click", () => { model.send({ type: "save" }); }); if (methodSel.value === "MaskedTemplateCorrelation") { layoutMask(); const initB64 = model.get("mask_image_b64"); if (initB64) { maskImg.src = initB64; } else { requestMaskFrame(); } } drawMask(); return () => { stopPlayback(); revokePlayerUrls(player); maskCanvas.removeEventListener("pointerdown", onMaskDown); maskCanvas.removeEventListener("pointermove", onMaskMove); maskCanvas.removeEventListener("pointerup", onMaskUp); maskCanvas.removeEventListener("pointercancel", onMaskUp); model.off("change:mask_image_b64", onMaskImageChange); model.off( "change:mask_center_x change:mask_center_y change:mask_width change:mask_height " + "change:mask_angle change:mask_points", onMaskGeomChange, ); }; } export default { render }; """ if _HAS_ANYWIDGET: class ROICropper(anywidget.AnyWidget): # type: ignore[no-redef] """Interactive rotated-rectangle ROI selector over frame 0 (anywidget). Draw/drag/resize/rotate a rectangle over the first frame of an :class:`~acia.base.ImageSequenceSource` and emit a :class:`~acia.base.RotatedCropSpec`. Two ways to set the box, both feeding the same synced traits: 1. Click >=3 points around the ROI (the ``points`` trait); an observer runs :meth:`fit_to_points` (``cv2.minAreaRect``) to seed the tightest oriented rectangle. The geometry lives in Python, so it is unit-tested. 2. Drag the box / corner handles / rotate knob in the ESM ``render()``. ROI coordinates are kept in **parent image pixels** so :attr:`spec` plugs straight into :meth:`~acia.base.ImageSequenceSource.crop_rotated`. Works in Jupyter/Colab and in marimo via ``mo.ui.anywidget(cropper)`` (it IS-A ipywidgets ``DOMWidget``). The ESM JavaScript is best-effort and is verified only by a real notebook run, not by the headless test-suite. """ center_x = traitlets.Float(0.0).tag(sync=True) center_y = traitlets.Float(0.0).tag(sync=True) width = traitlets.Int(1).tag(sync=True) height = traitlets.Int(1).tag(sync=True) angle = traitlets.Float(0.0).tag(sync=True) points = traitlets.List().tag(sync=True) # type: ignore[var-annotated] # [[x, y], ...] image px image_b64 = traitlets.Unicode("").tag(sync=True) image_w = traitlets.Int(0).tag(sync=True) image_h = traitlets.Int(0).tag(sync=True) _esm = _ROI_CROPPER_ESM
[docs] def __init__( self, source, *, width: int | None = None, height: int | None = None, channel: int | None = None, **kwargs, ) -> None: """Build the widget from a source's frame 0. Args: source: The :class:`~acia.base.ImageSequenceSource` to crop. width: Default ROI width (px). Defaults to ``frame_w // 2``. height: Default ROI height (px). Defaults to ``frame_h // 2``. channel: Display channel for a multi-channel frame. Defaults to channel ``0``. **kwargs: Forwarded to ``anywidget.AnyWidget``. """ self._source = source raw = np.asarray(source.get_frame(0).raw) # Validate an explicitly requested display channel against the frame. num_channels = raw.shape[-1] if raw.ndim == 3 else 1 if channel is not None and not (0 <= channel < num_channels): raise ValueError( f"channel must be in [0, {num_channels}); got {channel}." ) self._channel = channel # Select a 2D display channel; grayscale stays grayscale. if raw.ndim == 3: if raw.shape[-1] == 1: display = raw[..., 0] else: display = raw[..., 0 if self._channel is None else self._channel] else: display = raw display = normalize_to_uint8(display) # Grayscale -> RGB for display. if display.ndim == 2: display = np.repeat(display[:, :, np.newaxis], 3, axis=-1) frame_h, frame_w = int(raw.shape[0]), int(raw.shape[1]) pil_image = Image.fromarray(display) buffer = io.BytesIO() pil_image.save(buffer, format="PNG") buffer.seek(0) img_b64 = base64.b64encode(buffer.read()).decode("utf-8") default_w = int(width) if width is not None else max(1, frame_w // 2) default_h = int(height) if height is not None else max(1, frame_h // 2) super().__init__( center_x=frame_w / 2.0, center_y=frame_h / 2.0, width=default_w, height=default_h, angle=0.0, image_b64=f"data:image/png;base64,{img_b64}", image_w=frame_w, image_h=frame_h, **kwargs, )
[docs] def fit_to_points(self, points=None): """Fit the tightest oriented rectangle to ``points`` and set traits. Uses ``cv2.minAreaRect`` on the given (or the ``points`` trait's) ``[x, y]`` image-px points, then normalizes the angle into ``(-45, 45]`` degrees (CCW, OpenCV ``getRotationMatrix2D`` / :class:`~acia.base.RotatedCropSpec` convention), swapping width and height with each 90-degree step so the round-trip ``fit_to_points -> crop_rotated`` straightens the region. Sizes are rounded to positive ints. Args: points: ``[[x, y], ...]`` image-px points. Defaults to the current ``points`` trait when ``None``. Raises: ValueError: If fewer than 3 points are supplied, or if the points are collinear/duplicate (degenerate rectangle). """ import cv2 pts = self.points if points is None else points pts_arr = np.asarray(pts, dtype=np.float32) if pts_arr.ndim != 2 or pts_arr.shape[0] < 3 or pts_arr.shape[1] != 2: raise ValueError( "fit_to_points requires at least 3 [x, y] points; " f"got {pts_arr.shape}." ) (cx, cy), (w, h), angle = cv2.minAreaRect(pts_arr) # Collinear / duplicate points yield a degenerate rect (zero extent). # Reject BEFORE the max(1, ...) clamp so callers see a clear error # rather than a silently-clamped 1px box. if w == 0 or h == 0: raise ValueError("degenerate rectangle (collinear/duplicate points)") w = int(round(w)) h = int(round(h)) # Normalize the OpenCV angle into (-45, 45] degrees. The rectangle has # 90-degree symmetry, so each 90-degree step swaps width/height. This # keeps fit_to_points -> crop_rotated a faithful straightening and # maps an axis-aligned box to angle == 0. while angle > 45.0: angle -= 90.0 w, h = h, w while angle <= -45.0: angle += 90.0 w, h = h, w self.center_x = float(cx) self.center_y = float(cy) self.width = max(1, w) self.height = max(1, h) self.angle = float(angle)
@traitlets.observe("points") def _on_points(self, change) -> None: """Re-fit the box whenever >=3 points are present. Degenerate / too-few-point states during interactive clicking are ignored (the box is only seeded once enough points arrive). """ import cv2 pts = change.get("new") if isinstance(change, dict) else change.new try: if pts is not None and len(pts) >= 3: self.fit_to_points(pts) except (ValueError, cv2.error): # Only swallow the expected degenerate/too-few-point states # (raised by fit_to_points / cv2.minAreaRect). Real bugs such as # an ImportError must NOT be silently dropped. pass @property def spec(self): """Return the current ROI as a :class:`~acia.base.RotatedCropSpec`.""" from acia.base import RotatedCropSpec return RotatedCropSpec( center=(self.center_x, self.center_y), size=(int(self.width), int(self.height)), angle=self.angle, )
[docs] def cropped(self): """Return ``source.crop_rotated(self.spec)`` (a lazy crop source).""" return self._source.crop_rotated(self.spec)
[docs] def save(self, dataset_dir, **kwargs): """Persist the crop via :func:`~acia.crop_capture.save_crop_capture`. Args: dataset_dir: Directory the capture is written to. **kwargs: Forwarded to ``save_crop_capture`` (``frame``, ``channel``, ``clip_percentiles``, ``source_ref``). Returns: dict: The ``save_crop_capture`` result. """ from acia.crop_capture import save_crop_capture return save_crop_capture(self._source, self.spec, dataset_dir, **kwargs)
def _repr_html_(self) -> str: """Static fallback so a non-executed/persisted notebook shows frame 0.""" return ( f'<div><img src="{self.image_b64}" ' 'style="max-width: 100%; height: auto;" />' "<p style='font:12px sans-serif;color:#666;'>" "ROICropper (interactive widget renders when the notebook is run)." "</p></div>" ) class FilterExplorer(anywidget.AnyWidget): # type: ignore[no-redef] """Interactive cell-filter threshold explorer with live mask preview. Auto-builds **one (min, max) slider per filter** from the passed ``filters`` list (modular -- add a :class:`~acia.segm.filter.CellFilter` and it appears) and live-recolours the contour overlay (kept = green, dropped = red) as the handles move. The live filtering runs **entirely client-side**: each contour's value under each filter is precomputed once in Python (reusing the goal-E ``value()`` calibration) and shipped to the browser, so dragging a slider needs **no kernel round-trip** (the workflow's "reactive, no observer wiring"). All control ranges/handles are in each filter's **physical unit** (µm, µm², dimensionless). The widget previews a **single frame** (``frame=0`` by default), drawing only that frame's contours over that frame's image. :attr:`params` and :meth:`configured_filters` emit/restore frame-independent thresholds; :meth:`filtered_overlay` applies them across the **whole** overlay via :func:`~acia.segm.filter.apply_cell_filters`. Works in Jupyter/Colab and in marimo via ``mo.ui.anywidget(explorer)``. The ESM JavaScript is best-effort, verified by the headless Playwright suite (and a real notebook run), not by the pure-Python tests. """ image_b64 = traitlets.Unicode("").tag(sync=True) image_w = traitlets.Int(0).tag(sync=True) image_h = traitlets.Int(0).tag(sync=True) # one spec per filter: {name, unit, lo, hi, step, vmin, vmax} filter_specs = traitlets.List().tag(sync=True) # type: ignore[var-annotated] # one record per displayed contour: {points: [[x,y],...], values: [m0,m1,...]} contours = traitlets.List().tag(sync=True) # type: ignore[var-annotated] # live handle values, aligned with filter_specs: [{vmin, vmax}, ...] selection = traitlets.List().tag(sync=True) # type: ignore[var-annotated] _esm = _FILTER_EXPLORER_ESM
[docs] def __init__( self, overlay, images, filters, properties, *, frame: int = 0, channel: int | None = None, **kwargs, ) -> None: """Build the explorer from an overlay, a calibrated source and filters. Args: overlay: The :class:`~acia.base.Overlay` to filter. images: The calibrated :class:`~acia.base.ImageSequenceSource` (must expose a non-``None`` ``pixel_size``). filters: A list of :class:`~acia.segm.filter.CellFilter` instances -- one slider control is built per filter. properties: The extractor table for ``overlay`` (see :meth:`~acia.analysis.ExtractorExecutor.execute`). Each filter's slider is seeded from its own column, so it must contain a column named after every filter passed in. frame: Frame to preview (image + its contours). Defaults to ``0``. channel: Display channel for a multi-channel frame. Defaults to 0. **kwargs: Forwarded to ``anywidget.AnyWidget``. Raises: ValueError: If ``images`` is ``None`` or its ``pixel_size`` is ``None`` (physical-unit thresholds need calibration). """ if images is None or getattr(images, "pixel_size", None) is None: raise ValueError( "FilterExplorer requires a calibrated source (pixel_size); " "physical-unit filtering cannot run on uncalibrated data." ) self._overlay = overlay self._images = images self._filters = list(filters) self._properties = properties self._frame = frame img_b64, frame_w, frame_h = _encode_frame_png(images, frame, channel) # contours shown for this frame (default frame attr -> include it) conts = [c for c in overlay.contours if getattr(c, "frame", frame) == frame] records = [ { "points": np.asarray(c.coordinates, dtype=float).tolist(), "values": [], } for c in conts ] specs = [] for f in self._filters: unit, mags = self._measure(f, conts) for rec, m in zip(records, mags, strict=False): rec["values"].append(m) lo, hi, vmin, vmax = self._axis(f, unit, mags) step = (hi - lo) / 200.0 or 1.0 specs.append( { "name": f.name, "unit": unit, "lo": lo, "hi": hi, "step": step, "vmin": vmin, "vmax": vmax, } ) selection = [{"vmin": s["vmin"], "vmax": s["vmax"]} for s in specs] super().__init__( image_b64=img_b64, image_w=frame_w, image_h=frame_h, filter_specs=specs, contours=records, selection=selection, **kwargs, )
# --- construction helpers ------------------------------------------- def _measure(self, f, conts): """Return ``(unit_str, [magnitude per contour])`` for filter ``f``. Reads filter ``f``'s own column out of the ``properties`` table -- the values the extractors already produced, rather than measuring every contour again once per filter. All contours of a filter share one unit. For an empty overlay the unit is inferred from the filter's existing bound, else falls back to dimensionless. """ import math from acia.segm.filter import _column_magnitudes values, unit = _column_magnitudes(self._properties, f.name) position = { contour_id: i for i, contour_id in enumerate(self._properties.index) } mags = [] for c in conts: m = float(values[position[c.id]]) # a non-finite magnitude would serialize as invalid JSON (NaN / # Infinity) and break the browser trait sync. nan means the # property could not be measured; 0 keeps the slider usable, and # `filtered_overlay` still drops the contour because the Python # filters reject a non-finite value outright. mags.append(m if math.isfinite(m) else 0.0) unit_str = f"{unit}" if unit is not None else "" if not mags and not unit_str: unit_str = self._unit_of(f.vmin) or self._unit_of(f.vmax) or "" return unit_str, mags @staticmethod def _unit_of(bound) -> str | None: """Unit string of a pint bound, or ``None`` for plain numbers/``None``.""" if bound is not None and hasattr(bound, "units"): return f"{bound.units}" return None def _axis(self, f, unit: str, mags): """Build a control axis: ``(lo, hi, vmin, vmax)`` for filter ``f``. The track ``[lo, hi]`` spans the data, then is **widened to include any explicitly-set bound** so seeding is lossless: a bound outside the data range is preserved exactly instead of being clamped to the data extreme (which would silently rewrite the threshold). A ``None`` bound opens that side (handle parked at the track extreme: ``vmin`` at ``lo``, ``vmax`` at ``hi``). An empty overlay falls back to a ``[0, 1]`` track. """ raw_vmin = self._bound_magnitude(f.vmin, unit) raw_vmax = self._bound_magnitude(f.vmax, unit) bounds = [b for b in (raw_vmin, raw_vmax) if b is not None] if mags: lo, hi = float(min(mags)), float(max(mags)) elif bounds: lo, hi = float(min(bounds)), float(max(bounds)) else: lo, hi = 0.0, 1.0 # widen so an out-of-range seed sits inside the track (lossless). for b in bounds: lo, hi = min(lo, b), max(hi, b) if hi <= lo: # single-valued data / single bound -> non-zero width hi = lo + 1.0 vmin = lo if raw_vmin is None else raw_vmin vmax = hi if raw_vmax is None else raw_vmax return lo, hi, vmin, vmax @staticmethod def _bound_magnitude(bound, unit: str): """Magnitude of ``bound`` in ``unit`` (``None`` stays ``None``).""" if bound is None: return None if hasattr(bound, "to") and hasattr(bound, "magnitude"): target = unit if unit else "dimensionless" return float(bound.to(target).magnitude) return float(bound) # --- outputs --------------------------------------------------------- @property def params(self): """Current thresholds as ``[{name, vmin, vmax}]`` pint ``Quantity``\\s. A handle parked at its track extreme is reported as ``None`` (open on that side), so a one-sided filter round-trips faithfully. """ result = [] for spec, sel in zip(self.filter_specs, self.selection, strict=False): unit = spec["unit"] vmin = self._as_quantity(sel["vmin"], spec["lo"], unit, lower=True) vmax = self._as_quantity(sel["vmax"], spec["hi"], unit, lower=False) result.append({"name": spec["name"], "vmin": vmin, "vmax": vmax}) return result @staticmethod def _as_quantity(value: float, extreme: float, unit: str, *, lower: bool): """``Q_(value, unit)`` unless ``value`` is at the open extreme.""" from acia import Q_ if (lower and value <= extreme) or (not lower and value >= extreme): return None return Q_(value, unit) if unit else Q_(value)
[docs] def configured_filters(self): """Update each passed filter's ``vmin``/``vmax`` from the sliders. Mutates the filter instances supplied to ``__init__`` in place (a handle at its extreme sets that bound to ``None``) and returns the list, ready for :func:`~acia.segm.filter.apply_cell_filters` or the scaled batch run. """ for f, spec, sel in zip( self._filters, self.filter_specs, self.selection, strict=False ): unit = spec["unit"] f.vmin = self._as_quantity(sel["vmin"], spec["lo"], unit, lower=True) f.vmax = self._as_quantity(sel["vmax"], spec["hi"], unit, lower=False) return self._filters
[docs] def filtered_overlay(self): """Return the whole overlay filtered by the current thresholds.""" from acia.segm.filter import apply_cell_filters return apply_cell_filters( self._overlay, self.configured_filters(), properties=self._properties, )
[docs] def save(self, path): """Write the current thresholds to ``path`` as ``filter_params.json``. Serializes ``[{name, unit, vmin, vmax}]`` (magnitudes; ``None`` for an open side) -- a small spec the scaled batch run reloads to rebuild the filters. Args: path: Destination JSON file path. Returns: list[dict]: The serialized filter parameters. """ import json from pathlib import Path data = [] for spec, sel in zip(self.filter_specs, self.selection, strict=False): lo, hi = spec["lo"], spec["hi"] data.append( { "name": spec["name"], "unit": spec["unit"], "vmin": None if sel["vmin"] <= lo else sel["vmin"], "vmax": None if sel["vmax"] >= hi else sel["vmax"], } ) Path(path).write_text(json.dumps({"filters": data}, indent=2)) return data
def _repr_html_(self) -> str: """Static fallback so a non-executed/persisted notebook shows frame 0.""" return ( f'<div><img src="{self.image_b64}" ' 'style="max-width: 100%; height: auto;" />' "<p style='font:12px sans-serif;color:#666;'>" "FilterExplorer (interactive widget renders when the notebook is run)." "</p></div>" ) class SequenceDashboard(anywidget.AnyWidget): # type: ignore[no-redef] """Curate positions + ROIs across a multi-position acquisition (anywidget). A three-pane UI (position gallery / ROI editor / selection list) over a :class:`~acia.segm.open.SequenceFile`. Browse positions, mark ROIs (draw or point-fit), and emit a :class:`~acia.selection.SelectionManifest`. Frames are read lazily from the source and pushed to the browser as PNG bytes; the widget never loads the whole (possibly hundreds-of-GB) file. Works in Jupyter/Colab and in marimo via ``mo.ui.anywidget(dash)``. The ESM is best-effort and verified only by a real notebook run (or the Playwright suite in the devcontainer), not by the headless Python test-suite. """ metadata = traitlets.Dict().tag(sync=True) # type: ignore[var-annotated] positions = traitlets.List().tag(sync=True) # type: ignore[var-annotated] selections = traitlets.List().tag(sync=True) # type: ignore[var-annotated] roi_mode = traitlets.Unicode("single").tag(sync=True) view_size = traitlets.Int(430).tag(sync=True) auto_save = traitlets.Bool(True).tag(sync=True) preview_frame = traitlets.Int(0).tag(sync=True) _esm = _SEQUENCE_DASHBOARD_ESM _css = _SEQUENCE_DASHBOARD_CSS
[docs] def __init__( self, source, *, roi_mode: str = "single", save_dir=None, preview_frame: int | None = None, **kwargs, ) -> None: """Build the dashboard from a source (no pixel reads at construction). Args: source: A :class:`~acia.segm.open.SequenceFile`, or a path/str that is opened via :func:`~acia.segm.open.open_sequence`. roi_mode: ``"single"`` (<=1 ROI/position) or ``"multi"``. save_dir: Default output directory for :meth:`save` (and hence for auto-save, which is on by default). ``None`` keeps the previous behaviour of writing into the current working directory. preview_frame: Which frame the gallery thumbnails and the ROI editor open on. ``None`` (the default) picks the middle frame, ``num_timepoints // 2``: in a growing culture frame 0 is typically empty, so opening there gives no indication whether a chamber holds cells at all. Clamped into range. Note this indexes the *source's* timepoints -- if a later export step truncates the sequence, pick a frame inside that range (see :attr:`~acia.selection.RoiSelection.anchor_frame`). **kwargs: Forwarded to ``anywidget.AnyWidget`` (e.g. ``auto_save`` to start with auto-save switched off). Raises: traitlets.TraitError: If ``preview_frame`` is negative. """ from acia.segm.open import open_sequence if isinstance(source, (str, os.PathLike)): source = open_sequence(source) self._file = source self._save_dir = None if save_dir is None else os.fspath(save_dir) meta = source.metadata # Cells need time to grow, so frame 0 of a chamber is usually blank # and says nothing about whether it is worth curating. Open on the # middle frame instead -- thumbnails and editor alike. Clamped here, # where num_timepoints is known for certain, rather than in the # trait validator. num_t = max(1, int(meta.num_timepoints or 1)) chosen = num_t // 2 if preview_frame is None else int(preview_frame) if chosen < 0: raise traitlets.TraitError(f"preview_frame must be >= 0, got {chosen}.") preview_frame = min(num_t - 1, chosen) positions = [ {"index": p.index, "name": p.name, "has_roi": False} for p in source.positions ] super().__init__( # `path`/`format` alongside the metadata so the header can show # which file is open -- same identity fields `make_source_block` # bakes into the manifest. `getattr` because in-memory sources are # only SequenceFile-compatible, not necessarily subclasses. metadata={ **meta.to_dict(), "path": str(getattr(source, "path", "") or ""), "format": str(getattr(source, "format", "") or ""), }, positions=positions, selections=[], roi_mode=roi_mode, preview_frame=preview_frame, **kwargs, ) self.on_msg(self._on_custom_msg)
@traitlets.validate("preview_frame") def _validate_preview_frame(self, proposal): """Reject a negative frame index. Only the lower bound is checked here: the upper one depends on the source's ``num_timepoints``, which ``__init__`` clamps against directly rather than relying on trait-assignment ordering inside ``super().__init__``. """ value = int(proposal["value"]) if value < 0: raise traitlets.TraitError(f"preview_frame must be >= 0, got {value}.") return value def _on_custom_msg(self, _widget, content, buffers) -> None: """Serve lazy frames/thumbnails and run point-fit for the ESM. Named to avoid colliding with ``ipywidgets.Widget._handle_msg``, the internal method the base class uses to dispatch comm messages to callbacks registered via ``on_msg`` -- reusing that name here silently shadowed the real dispatcher and broke every custom message (frame/thumb/fit/save) for this widget. """ kind = content.get("type") if isinstance(content, dict) else None # Diagnostic timing (temporary): a "fit" request appearing to hang # has been reported repeatedly against real SMB data; these lines # print to the kernel's terminal so it's visible whether a given # message is received promptly and how long its handler took -- # the two things needed to tell "queued behind a slow read" apart # from "stuck inside the handler itself". t0 = time.time() logging.warning( "SequenceDashboard: received %r at %.3f (content=%s)", kind, t0, {k: v for k, v in content.items() if k != "points"} if isinstance(content, dict) else content, ) if kind == "thumb": pos = int(content["pos"]) try: # The gallery shows one fixed frame for every position: # thumbnails are fetched lazily as rows scroll into view and # inserted once, so letting the editor's scrubber drive them # would fire a blocking read per row per scrub and leave the # gallery showing a mix of timepoints. png = self._file.thumbnail_png( pos, downscale=int(content.get("downscale", 8)), frame=self.preview_frame, ) except Exception as exc: # noqa: BLE001 - report to the frontend self.send( {"type": "error", "kind": kind, "pos": pos, "message": str(exc)} ) return self.send({"type": "thumb", "pos": pos}, buffers=[png]) logging.warning( "SequenceDashboard: thumb %d done in %.3fs", pos, time.time() - t0 ) elif kind == "frame": pos, t = int(content["pos"]), int(content.get("t", 0)) try: png = self._frame_png(pos, t) except Exception as exc: # noqa: BLE001 - report to the frontend self.send( {"type": "error", "kind": kind, "pos": pos, "message": str(exc)} ) return self.send({"type": "frame", "pos": pos, "t": t}, buffers=[png]) logging.warning( "SequenceDashboard: frame %d/%d done in %.3fs", pos, t, time.time() - t0, ) elif kind == "fit": try: spec = _fit_rotated_rect(content["points"]) except Exception as exc: # noqa: BLE001 - report to the frontend # too-few / degenerate points, or anything else -- the UI # always sends exactly 4 points, so any failure here is # unexpected and must be visible, never swallowed silently. self.send({"type": "error", "kind": "fit", "message": str(exc)}) return self.send({"type": "fit", "roi": spec.to_dict()}) logging.warning( "SequenceDashboard: fit done in %.3fs", time.time() - t0 ) elif kind == "save": try: path = self.save() except Exception as exc: # noqa: BLE001 - report to the frontend self.send({"type": "error", "kind": "save", "message": str(exc)}) return self.send({"type": "saved", "path": path}) logging.warning( "SequenceDashboard: save done in %.3fs", time.time() - t0 ) def _frame_png(self, pos: int, t: int) -> bytes: """PNG bytes of one lazily-read frame (display channel, normalized).""" raw = np.asarray(self._file.position(pos).get_frame(t).raw) plane = raw[..., 0] if raw.ndim == 3 else raw rgb = np.repeat(normalize_to_uint8(plane)[:, :, np.newaxis], 3, axis=-1) buffer = io.BytesIO() Image.fromarray(rgb).save(buffer, format="PNG") return buffer.getvalue() @property def manifest(self): """Build a :class:`~acia.selection.SelectionManifest` from current state.""" from acia.base import RotatedCropSpec from acia.selection import ( RoiSelection, SelectionManifest, make_source_block, ) sels = [] for item in self.selections: roi = item["roi"] spec = RotatedCropSpec( center=(float(roi["center"][0]), float(roi["center"][1])), size=(int(roi["size"][0]), int(roi["size"][1])), angle=float(roi["angle"]), ) sels.append( RoiSelection( position=int(item["position"]), roi=spec, label=item.get("label", ""), id=str(item.get("id", "")), # .get with a default: trait dicts built before this # field existed (and any set from Python) omit the key. anchor_frame=int(item.get("anchor_frame", 0)), ) ) return SelectionManifest( source=make_source_block(self._file), selections=sels, roi_mode=self.roi_mode, )
[docs] def save(self, directory=None) -> str: """Write ``selection.json`` (+ previews) via :func:`save_selection`. Args: directory: Output dir; defaults to the ``save_dir`` passed to the constructor, and failing that to the current working directory (the notebook's dir at run time). Returns: The path to the written ``selection.json``. """ from acia.selection import save_selection if directory is None: directory = ( self._save_dir if self._save_dir is not None else os.getcwd() ) return save_selection(self.manifest, directory)
[docs] @classmethod def resume(cls, manifest_or_path, source=None, **kwargs) -> SequenceDashboard: """Reopen a dashboard pre-populated from a saved ``selection.json``. Lets a curation session be saved with :meth:`save` and continued later in a fresh dashboard, instead of starting over. Args: manifest_or_path: A path to a ``selection.json`` file (or its containing directory), or an already-loaded :class:`~acia.selection.SelectionManifest`. source: ``None`` to reopen the manifest's own source path, a path/str to apply the selections to a *different* file, or an already-open :class:`~acia.segm.open.SequenceFile` -- same convention as :func:`~acia.selection.load_selection`. **kwargs: Forwarded to the constructor (e.g. ``roi_mode`` to override the manifest's saved mode). Returns: A :class:`SequenceDashboard` with ``.selections`` restored. Raises: ValueError: If a selection's position is out of range for ``source``. """ from acia.segm.open import open_sequence from acia.selection import SelectionManifest if isinstance(manifest_or_path, SelectionManifest): manifest = manifest_or_path else: path = os.fspath(manifest_or_path) manifest = SelectionManifest.load(path) # Keep saving back where it was resumed from, so a continued # session doesn't silently start writing to the cwd instead. kwargs.setdefault( "save_dir", path if os.path.isdir(path) else os.path.dirname(path) ) if source is None: source = open_sequence(manifest.source_path) elif isinstance(source, (str, os.PathLike)): source = open_sequence(source) kwargs.setdefault("roi_mode", manifest.roi_mode) dash = cls(source, **kwargs) num_positions = dash.metadata.get("num_positions", 0) next_ci: dict[int, int] = {} restored = [] for i, sel in enumerate(manifest.selections): if not 0 <= sel.position < num_positions: raise ValueError( f"selection position {sel.position} out of range " f"for source with {num_positions} positions" ) ci = next_ci.get(sel.position, 0) next_ci[sel.position] = ci + 1 restored.append( { "id": i + 1, "position": sel.position, "label": sel.label, "ci": ci, "anchor_frame": sel.anchor_frame, "roi": sel.roi.to_dict(), } ) dash.selections = restored return dash
def _repr_html_(self) -> str: """Static fallback for a non-executed/persisted notebook.""" n = self.metadata.get("num_positions", "?") return ( "<div style='font:12px sans-serif;color:#666;'>" f"SequenceDashboard — {n} positions " "(interactive widget renders when the notebook is run).</div>" ) class RegistrationDashboard(anywidget.AnyWidget): # type: ignore[no-redef] """Pick + verify + batch-apply a drift-correction method (anywidget). Pick one of the 5 :class:`~acia.registration.RegistrationMethod` implementations (default ``"GradientECC"``), verify it on sampled frames of a single position (drift trajectory + before/after), then batch-apply it across every position/frame of the acquisition with live progress and resumability. Frames are read lazily from the source; the widget never loads a whole (possibly hundreds-of-GB) file, and batch-apply holds at most one position's frames in memory at a time. ``MaskedTemplateCorrelation`` additionally needs a ``mask_rect`` (:class:`~acia.base.RotatedCropSpec`): the ``mask_*`` traits and the ESM's mask editor port :class:`ROICropper`'s click-to-fit + drag/resize/rotate interaction model (:func:`_fit_rotated_rect` is the same geometry helper ``SequenceDashboard``'s point-fit tool uses) -- ``ROICropper`` itself is not touched. Works in Jupyter/Colab and in marimo via ``mo.ui.anywidget(dash)``. The ESM is best-effort and verified only by a real notebook run, not by the headless Python test-suite (no ESM/Playwright suite for this widget in v1, per the spec). """ metadata = traitlets.Dict().tag(sync=True) # type: ignore[var-annotated] positions = traitlets.List().tag(sync=True) # type: ignore[var-annotated] method_name = traitlets.Unicode("GradientECC").tag(sync=True) n_sample_frames = traitlets.Int(8).tag(sync=True) mask_center_x = traitlets.Float(0.0).tag(sync=True) mask_center_y = traitlets.Float(0.0).tag(sync=True) mask_width = traitlets.Int(0).tag(sync=True) mask_height = traitlets.Int(0).tag(sync=True) mask_angle = traitlets.Float(0.0).tag(sync=True) mask_points = traitlets.List().tag(sync=True) # type: ignore[var-annotated] mask_image_b64 = traitlets.Unicode("").tag(sync=True) mask_image_w = traitlets.Int(0).tag(sync=True) mask_image_h = traitlets.Int(0).tag(sync=True) batch_running = traitlets.Bool(False).tag(sync=True) _esm = _REGISTRATION_DASHBOARD_ESM
[docs] def __init__( self, source, *, method_name: str = "GradientECC", method_kwargs: dict | None = None, reference_mode: str = "reanchor", low_confidence: str = "keep", **kwargs, ) -> None: """Build the dashboard from a source (no pixel reads at construction). Args: source: A :class:`~acia.segm.open.SequenceFile`, or a path/str that is opened via :func:`~acia.segm.open.open_sequence`. method_name: The initially-selected :class:`~acia.registration.RegistrationMethod` name; one of :data:`_REGISTRATION_METHOD_NAMES`. method_kwargs: Constructor keyword arguments for the chosen method, e.g. ``{"min_confidence": 0.8}`` or ``{"exclude_rects": [...]}`` for :class:`~acia.registration.GradientECC`. Deliberately a plain attribute rather than a synced trait: arbitrary Python objects (``RotatedCropSpec`` instances) must not be pushed across the widget comm. reference_mode: The reference policy batch-apply registers under; one of :data:`~acia.registration.ReanchoringReference.MODES`. Defaults to ``"reanchor"``, which only changes what happens to a frame that would otherwise be recorded as a failure. Pass ``"fixed"`` to always compare against frame 0. low_confidence: What the registration method does with a fit scoring below its own confidence threshold; one of :data:`~acia.registration.LOW_CONFIDENCE_POLICIES`. Defaults to ``"keep"``, so every frame ends up with a stored transform and a weak fit is reported through its ``confidence`` (and a warning) rather than by being missing. Pass ``"reject"`` to record those frames in ``failed_frames`` instead, leaving them without a transform. Forwarded to the method as ``on_low_confidence``; an explicit ``on_low_confidence`` in ``method_kwargs`` wins. Methods with no confidence gate ignore it. **kwargs: Forwarded to ``anywidget.AnyWidget``. Raises: ValueError: If ``reference_mode`` or ``low_confidence`` is not a known value. """ from acia.registration import ( LOW_CONFIDENCE_POLICIES, ReanchoringReference, ) from acia.segm.open import open_sequence if reference_mode not in ReanchoringReference.MODES: raise ValueError( f"Unknown reference_mode {reference_mode!r}; expected one " f"of {', '.join(ReanchoringReference.MODES)}." ) if low_confidence not in LOW_CONFIDENCE_POLICIES: raise ValueError( f"Unknown low_confidence policy {low_confidence!r}; " f"expected one of {', '.join(LOW_CONFIDENCE_POLICIES)}." ) if isinstance(source, (str, os.PathLike)): source = open_sequence(source) self._file = source self._records: dict[int, RegistrationRecord] = {} self._method_kwargs: dict = dict(method_kwargs or {}) self._reference_mode = reference_mode self._low_confidence = low_confidence meta = source.metadata positions = [{"index": p.index, "name": p.name} for p in source.positions] super().__init__( metadata=meta.to_dict(), positions=positions, method_name=method_name, **kwargs, ) self.on_msg(self._on_custom_msg)
@traitlets.validate("method_name") def _validate_method_name(self, proposal): value = proposal["value"] if value not in _REGISTRATION_METHOD_NAMES: raise traitlets.TraitError( f"method_name must be one of {_REGISTRATION_METHOD_NAMES}, " f"got {value!r}" ) return value @traitlets.validate("n_sample_frames") def _validate_n_sample_frames(self, proposal): value = int(proposal["value"]) if value < 1: raise traitlets.TraitError( f"n_sample_frames must be >= 1, got {value}." ) return value @traitlets.observe("mask_points") def _on_mask_points(self, change) -> None: """Re-fit the mask box whenever >=3 points are present. Mirrors :meth:`ROICropper._on_points` exactly (same :func:`_fit_rotated_rect` geometry helper); degenerate/too-few-point states during interactive clicking are ignored. """ import cv2 pts = change.get("new") if isinstance(change, dict) else change.new try: if pts is not None and len(pts) >= 3: spec = _fit_rotated_rect(pts) self.mask_center_x, self.mask_center_y = spec.center self.mask_width, self.mask_height = spec.size self.mask_angle = spec.angle except (ValueError, cv2.error): # Only swallow the expected degenerate/too-few-point states; # a real bug such as an ImportError must not be silently # dropped. pass def _on_custom_msg(self, _widget, content, buffers) -> None: """Serve the mask frame, run verify, and drive batch-apply/save. Named to avoid colliding with ``ipywidgets.Widget._handle_msg``, same reasoning as ``SequenceDashboard._on_custom_msg``. """ kind = content.get("type") if isinstance(content, dict) else None if kind == "mask_frame": pos = int(content.get("position", 0)) try: data_url, w, h = _encode_frame_png( self._file.position(pos), frame=0 ) except Exception as exc: # noqa: BLE001 - report to the frontend self.send({"type": "error", "kind": kind, "message": str(exc)}) return self.mask_image_b64 = data_url self.mask_image_w = w self.mask_image_h = h self.send({"type": "mask_frame", "position": pos}) elif kind == "verify": pos = int(content.get("position", 0)) method_name = str(content.get("method", self.method_name)) try: payload, verify_buffers = self._run_verify(pos, method_name) except Exception as exc: # noqa: BLE001 - report to the frontend self.send({"type": "error", "kind": kind, "message": str(exc)}) return self.send(payload, buffers=verify_buffers) elif kind == "batch_apply": directory = content.get("directory") or None subset = content.get("positions") try: summary = self.batch_apply(directory=directory, positions=subset) except Exception as exc: # noqa: BLE001 - report to the frontend self.send({"type": "error", "kind": kind, "message": str(exc)}) return self.send({"type": "batch_done", **summary}) elif kind == "save": try: path = self.save() except Exception as exc: # noqa: BLE001 - report to the frontend self.send({"type": "error", "kind": kind, "message": str(exc)}) return self.send({"type": "saved", "path": path}) def _build_method( self, method_name: str, mask_rect: RotatedCropSpec | None = None, **overrides, ): """Construct a fresh :class:`~acia.registration.RegistrationMethod`. Settings are layered: this dashboard's ``method_kwargs``, then ``overrides``. That is what lets batch-apply pass per-position settings (an ``exclude_rects`` list differing per position) without the caller having to reach into the method object afterwards. Args: method_name: One of :data:`_REGISTRATION_METHOD_NAMES`. mask_rect: The mask rect to use for a method that accepts one (currently ``MaskedTemplateCorrelation``); defaults to :attr:`mask_rect` when ``None``. Silently ignored by methods whose constructor has no ``mask_rect`` parameter, which run directly on raw frame pairs. **overrides: Constructor keyword arguments taking precedence over this dashboard's ``method_kwargs``. The dashboard's ``low_confidence`` policy is forwarded as ``on_low_confidence`` to whichever methods accept one -- the ungated methods have no such parameter -- and an explicit ``on_low_confidence`` from either settings layer wins. Raises: ValueError: If ``method_name`` is unknown, or if ``MaskedTemplateCorrelation`` is requested without a mask rect available. """ classes = _registration_method_classes() cls = classes[method_name] kwargs = {**self._method_kwargs, **overrides} # Pass mask_rect to whichever methods actually accept one, rather # than naming a single class here. params = inspect.signature(cls).parameters if "on_low_confidence" in params and "on_low_confidence" not in kwargs: kwargs["on_low_confidence"] = self._low_confidence accepts_mask = "mask_rect" in params if accepts_mask and "mask_rect" not in kwargs: rect = mask_rect if mask_rect is not None else self.mask_rect if rect is None: raise ValueError( f"{method_name} requires a mask rect -- draw " "one (click >=3 points on the mask editor) first." ) kwargs["mask_rect"] = rect return cls(**kwargs) @property def mask_rect(self) -> RotatedCropSpec | None: """The current mask rect, or ``None`` if none has been drawn yet.""" from acia.base import RotatedCropSpec if self.mask_width <= 0 or self.mask_height <= 0: return None return RotatedCropSpec( center=(self.mask_center_x, self.mask_center_y), size=(int(self.mask_width), int(self.mask_height)), angle=self.mask_angle, ) @staticmethod def _array_png_bytes(raw: np.ndarray) -> bytes: """PNG-encode a single in-memory frame array (channel 0, normalized). Sibling of ``SequenceDashboard._frame_png``, but for a frame that already lives in memory (e.g. an :func:`~acia.registration.apply_correction` result) rather than one read fresh from a source -- ``_encode_frame_png`` only accepts the latter. """ plane = raw[..., 0] if raw.ndim == 3 else raw rgb = np.repeat(normalize_to_uint8(plane)[:, :, np.newaxis], 3, axis=-1) buffer = io.BytesIO() Image.fromarray(rgb).save(buffer, format="PNG") return buffer.getvalue() def _run_verify(self, pos: int, method_name: str): """Run verify for one position: drift trajectory + full-range before/after. Sends a ``"progress"`` message (``phase="verify"``) via :meth:`send` after each sampled frame is compared (wired through :func:`~acia.registration.run_comparison`'s ``on_progress`` callback), so the widget shows visible progress while verify runs -- previously this method computed silently. Every sampled ``frame_indices`` entry gets an uncorrected PNG buffer, plus a corrected one when a transform estimate is available for it (mirrors, per-frame, what the single compare frame used to do) -- the ESM's comparison player cycles through all of them instead of showing one static toggle image. Returns: tuple[dict, list[bytes]]: The ``"verify_result"`` message content and its PNG buffers: ``[reference, uncorrected_0, (corrected_0)?, uncorrected_1, (corrected_1)?, ...]`` -- one uncorrected buffer per sampled frame, plus a corrected buffer only where ``has_correction[i]`` is true, so the ESM can walk the flat buffer array in lock-step with ``frame_indices``/ ``has_correction``. """ from acia.registration import ( apply_correction, build_sample_frame_indices, run_comparison, ) method = self._build_method(method_name) source = self._file.position(pos) frame_indices = build_sample_frame_indices( source.size_t, 0, self.n_sample_frames ) reference = np.asarray(source.get_frame(0).raw) # Cache each comparison frame as it's read during run_comparison # so the buffer-encoding pass below doesn't re-read it from the # (possibly slow) source a second time. frame_cache: dict[int, np.ndarray] = {} def get_frame(t: int) -> np.ndarray: frame = np.asarray(source.get_frame(t).raw) frame_cache[t] = frame return frame total = len(frame_indices) def on_progress(i: int, _total: int) -> None: self.send( { "type": "progress", "phase": "verify", "frame": i, "num_frames": total, } ) results = run_comparison( {method_name: method}, reference, get_frame, frame_indices, on_progress=on_progress, ) transforms = results[method_name] buffers = [self._array_png_bytes(reference)] has_correction: list[bool] = [] for t, transform in zip(frame_indices, transforms, strict=True): frame = frame_cache[t] buffers.append(self._array_png_bytes(frame)) available = transform is not None has_correction.append(available) if transform is not None: corrected = apply_correction(frame, transform) buffers.append(self._array_png_bytes(corrected)) payload = { "type": "verify_result", "position": pos, "method": method_name, "reference_frame": 0, "frame_indices": frame_indices, "transforms": [t.to_dict() if t else None for t in transforms], "has_correction": has_correction, # Per-frame goodness-of-fit, where the method reports one. A # monotone decay across the sequence is the signature of # changing content rather than of a worsening fit -- see # GradientECC's note on min_confidence. "confidences": [t.confidence if t else None for t in transforms], } return payload, buffers def _register_position( self, pos: int, method_name: str, mask_rect: RotatedCropSpec | None, num_positions: int, *, source=None, existing_record: RegistrationRecord | None = None, positions_remaining_after: int = 0, progress_state: dict | None = None, on_checkpoint: Callable[[RegistrationRecord], None] | None = None, method_kwargs: dict | None = None, ) -> RegistrationRecord: """Estimate a per-frame transform for every not-yet-computed frame. Resumable: frames are always processed in order (``0, 1, 2, ...``) and checkpointed periodically, so "how many frames are already in ``existing_record``" is always the index of the first uncomputed frame -- ``existing_record`` (when given) seeds ``transforms``/ ``failed_frames`` and estimation resumes right after it instead of redoing the whole position. Reads (and releases) exactly one frame at a time -- never more than one position's frames in memory at once. A per-frame failure is caught and recorded in ``failed_frames``; it never aborts the rest of the position. Sends a ``"progress"`` message (with best-effort ``elapsed_seconds``/``eta_seconds``, see :func:`_estimate_eta`) after every frame, and invokes ``on_checkpoint`` with the record-so-far every :data:`CHECKPOINT_INTERVAL` newly-estimated frames so an interrupted run loses at most that many. Args: pos: Position index to register. method_name: One of :data:`_REGISTRATION_METHOD_NAMES`. mask_rect: Mask rect for ``MaskedTemplateCorrelation``; ignored otherwise. num_positions: Total position count, forwarded into progress messages unchanged. source: Optional :class:`~acia.base.ImageSequenceSource` to register instead of ``self._file.position(pos)`` -- e.g. a lazily-sliced ``self._file.position(pos)[:30]`` to limit registration to the first 30 frames. Its own ``size_t`` (not the full position's) drives the frame loop, so a resumed record's "already done" count is compared against the *sliced* length. ``None`` (default) reproduces the prior always-whole-position behavior. existing_record: A partial (or empty) prior result to resume from; ``None`` is equivalent to a from-scratch position. Assumed to already be for ``method_name`` -- callers (e.g. :meth:`batch_apply`) are responsible for not passing a record recorded under a different method. positions_remaining_after: Positions still to process after this one in the current batch-apply run (for the ETA heuristic). progress_state: Mutable dict shared across the whole batch-apply run (``batch_start``, ``frames_done``, ``position_frame_counts``) driving :func:`_estimate_eta`; a fresh one is created if ``None`` (single-position use). on_checkpoint: Optional callback invoked with the record-so-far every ``CHECKPOINT_INTERVAL`` frames. method_kwargs: Per-position constructor overrides for the method, layered over the dashboard's own ``method_kwargs``. """ from acia.registration import ReanchoringReference from acia.registration_persistence import RegistrationRecord method = self._build_method(method_name, mask_rect, **(method_kwargs or {})) source = source if source is not None else self._file.position(pos) num_frames = source.size_t reference = np.asarray(source.get_frame(0).raw) tracker = ReanchoringReference( method, reference, reference_frame=0, mode=self._reference_mode ) transforms: dict[int, FrameTransform] = ( dict(existing_record.transforms) if existing_record else {} ) failed: dict[int, str] = ( dict(existing_record.failed_frames) if existing_record else {} ) if existing_record: tracker.anchors_used.update(existing_record.reference_frames) start_frame = len(transforms) + len(failed) if transforms: # Resuming: hand the tracker the last frame that succeeded, so # the chain continues from there instead of behaving as though # nothing had been registered yet. Costs one extra frame read. last_good = max(transforms) tracker.seed( last_good, np.asarray(source.get_frame(last_good).raw), transforms[last_good], ) state = ( progress_state if progress_state is not None else {"batch_start": time.monotonic(), "position_frame_counts": []} ) state.setdefault("frames_done", 0) since_checkpoint = 0 for t in range(start_frame, num_frames): try: frame = np.asarray(source.get_frame(t).raw) transforms[t] = tracker.estimate(t, frame) except Exception as exc: # noqa: BLE001 -- isolate per-frame failures failed[t] = f"{type(exc).__name__}: {exc}" state["frames_done"] += 1 since_checkpoint += 1 if ( on_checkpoint is not None and since_checkpoint >= CHECKPOINT_INTERVAL ): on_checkpoint( RegistrationRecord( position=pos, method=method_name, transforms=dict(transforms), reference_frame=0, failed_frames=dict(failed), reference_mode=self._reference_mode, reference_frames=dict(tracker.anchors_used), ) ) since_checkpoint = 0 elapsed = time.monotonic() - state["batch_start"] eta = _estimate_eta( elapsed=elapsed, frames_done=state["frames_done"], frames_left_in_position=num_frames - (t + 1), positions_remaining_after=positions_remaining_after, position_frame_counts=state["position_frame_counts"], current_position_num_frames=num_frames, ) self.send( { "type": "progress", "position": pos, "num_positions": num_positions, "frame": t, "num_frames": num_frames, "elapsed_seconds": elapsed, "eta_seconds": eta, } ) state["position_frame_counts"].append(num_frames) return RegistrationRecord( position=pos, method=method_name, transforms=transforms, reference_frame=0, failed_frames=failed, reference_mode=self._reference_mode, reference_frames=dict(tracker.anchors_used), ) def _mode_resumable(self, record: RegistrationRecord) -> bool: """Whether ``record``'s frames are valid progress under this mode. Transforms are only resumable under the policy that produced them, with one exception worth honoring: a ``"fixed"`` record with no failed frames is exactly what ``"reanchor"`` would have produced, since re-anchoring is a pure fallback that never fires on a frame that succeeded against the reference. Accepting that case keeps a long run recorded before ``"reanchor"`` became the default from being silently thrown away and recomputed. A record with failures is *not* resumable across that boundary -- re-anchoring exists precisely to turn some of those failures into successes, so continuing past them would bake in failures the current settings would have avoided. """ if record.reference_mode == self._reference_mode: return True return ( record.reference_mode == "fixed" and self._reference_mode == "reanchor" and not record.failed_frames )
[docs] def batch_apply( self, directory=None, positions=None, sources=None, method_kwargs=None ) -> dict: """Estimate transforms for every (or a subset of) position, live. For the currently-selected :attr:`method_name`, processes every position in ``positions`` (default: all), one at a time, estimating a :class:`~acia.registration.FrameTransform` per frame against that position's own frame 0. Sends a ``"progress"`` message (with best-effort ``elapsed_seconds``/``eta_seconds``) after every frame, and persists the manifest both after every position *and* periodically within a position (every :data:`CHECKPOINT_INTERVAL` newly-estimated frames), so an interrupted run can be resumed. A position already fully complete (every frame accounted for in ``transforms``/``failed_frames``) is skipped; a partial one resumes from its first uncomputed frame instead of being re-skipped or fully redone. A prior record recorded under a *different* ``method_name`` is never treated as resume/skip data for the currently-selected method -- the position is processed from scratch instead of silently merging frames across methods. A whole-position failure (whether from ``_register_position`` itself or from the resume/skip bookkeeping above it, e.g. a ``size_t`` lookup) never aborts the rest of the run, and never discards progress already checkpointed for that position in this or a prior run -- the failure is recorded as a note on top of whatever record (checkpointed or pre-existing) is already known, not as a fresh empty one. Because a complete position is skipped and a partial one resumes by *count*, re-running over an existing ``registration_transforms.json`` never re-estimates frames a previous run already recorded -- including ones it recorded as failures. Changing ``low_confidence`` (or any method setting) therefore does not retroactively fix an existing file: delete it, or the affected records, and register those positions again. Args: directory: Output directory for ``registration_transforms.json``; defaults to the current working directory. Also the path consulted for already-completed positions to skip. positions: Optional subset of position indices to process; defaults to every position in the acquisition. sources: Optional ``{position: ImageSequenceSource}`` override -- when a position has an entry, that source is registered instead of ``self._file.position(position)``, and its own ``size_t`` (not the full position's) is what "already complete" is checked against. Lets a caller limit registration to a sub-range via the lazy numpy-style indexing every ``ImageSequenceSource`` already supports, e.g. ``sources={2: seqfile.position(2)[:30]}`` to register only the first 30 frames of position 2. A position absent from ``sources`` (or when ``sources`` is ``None``) falls back to the whole position, unchanged from before. method_kwargs: Constructor overrides for the registration method, applied to **this call only** and layered over the dashboard's own ``method_kwargs``. The intended use is settings that differ per position -- calling this once per position with that position's own :class:`~acia.registration.GradientECC` ``exclude_rects``. Not recorded in the manifest: ``method_params`` is a single manifest-level dict, so per-position settings have nowhere to go without mislabelling one position's values as another's. Returns: dict: ``{"num_positions", "completed", "skipped", "failed_positions", "path"}``. Raises: RuntimeError: If a batch-apply run is already in progress. ValueError: If ``method_name`` is unknown, or ``MaskedTemplateCorrelation`` is selected without a mask rect. """ if self.batch_running: raise RuntimeError("batch-apply is already running") method_name = self.method_name if method_name not in _REGISTRATION_METHOD_NAMES: raise ValueError(f"unknown method_name {method_name!r}") mask_rect = None if method_name == "MaskedTemplateCorrelation": mask_rect = self.mask_rect if mask_rect is None: raise ValueError( "MaskedTemplateCorrelation requires a mask rect -- draw " "one (click >=3 points on the mask editor) before " "running batch-apply." ) from acia.registration_persistence import ( RegistrationManifest, save_registration, ) directory = os.getcwd() if directory is None else os.fspath(directory) target_path = os.path.join(directory, "registration_transforms.json") records: dict[int, RegistrationRecord] = dict(self._records) if os.path.exists(target_path): try: existing = RegistrationManifest.load(target_path) except Exception: # noqa: BLE001 - a corrupt manifest must not block a run existing = None if existing is not None: for rec in existing.records: records.setdefault(rec.position, rec) num_positions = int(self.metadata.get("num_positions", len(self.positions))) target_positions = ( list(range(num_positions)) if positions is None else [int(p) for p in positions] ) self.batch_running = True completed: list[int] = [] skipped: list[int] = [] failed_positions: list[int] = [] progress_state: dict = { "batch_start": time.monotonic(), "frames_done": 0, "position_frame_counts": [], } try: for idx, i in enumerate(target_positions): pos_source = (sources or {}).get(i) existing_record = records.get(i) if existing_record is not None and ( existing_record.method != method_name or not self._mode_resumable(existing_record) ): # Progress recorded under a different method -- or under # an incompatible reference policy, which changes what a # transform is measured against just as much -- is not # valid resume/skip data for the current settings. Treat # the position as if it had no prior record at all # rather than silently merging frames across methods. existing_record = None if existing_record is not None: # A failure looking up size_t must not abort the whole # batch-apply run -- fall through to "not complete" so # this position is attempted below, where the # per-position try/except (which re-derives num_frames # via _register_position) records it as a per-position # failure instead. try: num_frames_i = ( pos_source.size_t if pos_source is not None else self._file.position(i).size_t ) already_done = len(existing_record.transforms) + len( existing_record.failed_frames ) already_complete = ( num_frames_i > 0 and already_done >= num_frames_i ) except Exception: # noqa: BLE001 -- see comment above already_complete = False if already_complete: skipped.append(i) continue positions_remaining_after = len(target_positions) - idx - 1 def _checkpoint(record: RegistrationRecord, _pos: int = i) -> None: records[_pos] = record self._records = records save_registration(self.manifest, directory) try: record = self._register_position( i, method_name, mask_rect, num_positions, source=pos_source, existing_record=existing_record, positions_remaining_after=positions_remaining_after, progress_state=progress_state, on_checkpoint=_checkpoint, method_kwargs=method_kwargs, ) except Exception as exc: # noqa: BLE001 -- isolate whole-position failures from acia.registration_persistence import RegistrationRecord note = f"position failed: {type(exc).__name__}: {exc}" # `_register_position`'s on_checkpoint callback (above) may # already have persisted a partial record into records[i] # (and to disk) before this exception fired; records.get(i) # also reflects the original existing_record when no # checkpoint fired yet this run. Preserve whatever progress # is already there instead of clobbering it with an empty # record -- only synthesize an empty one when there's truly # no prior record at all. prior = records.get(i) record = ( dataclasses.replace(prior, notes=note) if prior is not None else RegistrationRecord( position=i, method=method_name, transforms={}, reference_frame=0, notes=note, reference_mode=self._reference_mode, ) ) failed_positions.append(i) records[i] = record completed.append(i) self._records = records save_registration(self.manifest, directory) finally: self.batch_running = False self._records = records saved_path = save_registration(self.manifest, directory) return { "num_positions": num_positions, "completed": completed, "skipped": skipped, "failed_positions": failed_positions, "path": saved_path, }
@property def manifest(self) -> RegistrationManifest: """Build a :class:`~acia.registration_persistence.RegistrationManifest` from the accumulated :class:`~acia.registration_persistence.RegistrationRecord` results (mirrors ``SequenceDashboard.manifest`` building a ``SelectionManifest``). """ from acia.registration_persistence import RegistrationManifest from acia.selection import make_source_block return RegistrationManifest( source=make_source_block(self._file), records=sorted(self._records.values(), key=lambda r: r.position), method=self.method_name, method_params={ **self._method_kwargs, "reference_mode": self._reference_mode, "low_confidence": self._low_confidence, }, )
[docs] def save(self, directory=None) -> str: """Write ``registration_transforms.json`` via :func:`save_registration`. Args: directory: Output dir; defaults to the current working directory (the notebook's dir at run time). Returns: The path to the written ``registration_transforms.json``. """ from acia.registration_persistence import save_registration directory = os.getcwd() if directory is None else directory return save_registration(self.manifest, directory)
def _repr_html_(self) -> str: """Static fallback for a non-executed/persisted notebook.""" n = self.metadata.get("num_positions", "?") return ( "<div style='font:12px sans-serif;color:#666;'>" f"RegistrationDashboard — {n} positions, method=" f"{self.method_name} (interactive widget renders when the " "notebook is run).</div>" ) else:
[docs] def ROICropper(*args, **kwargs): # type: ignore[no-redef] """Stub raised when the optional ``widget`` extra is not installed.""" raise ImportError( "ROICropper requires the optional dependency: pip install acia[widget]" )
[docs] def FilterExplorer(*args, **kwargs): # type: ignore[no-redef] """Stub raised when the optional ``widget`` extra is not installed.""" raise ImportError( "FilterExplorer requires the optional dependency: pip install acia[widget]" )
[docs] def SequenceDashboard(*args, **kwargs): # type: ignore[no-redef] """Stub raised when the optional ``widget`` extra is not installed.""" raise ImportError( "SequenceDashboard requires the optional dependency: pip install acia[widget]" )
[docs] def RegistrationDashboard(*args, **kwargs): # type: ignore[no-redef] """Stub raised when the optional ``widget`` extra is not installed.""" raise ImportError( "RegistrationDashboard requires the optional dependency: pip install acia[widget]" )