webSMLM
August 24, 2026 · View on GitHub
Forward-looking notes only: things worth remembering and testing later, not a
history. Shipped features — and the implementation detail behind them — live
in ../CHANGELOG.md; this file doesn't duplicate it.
Next
-
Rotated-elliptical 2D/3D MLE fitter — SHIPPED (build 2026-08-21f; the
localize3Dcheckbox + rename below shipped 2026-08-22c).gaussianMLEellipticangled()('gaussmleEll', UI label "Gauss MLE 3D rotated elliptical" — renamed from "Gaussian MLE Elliptical (sSMLM)" alongsidegaussianMLE→gaussianMLEspheric/gaussianMLEastig→gaussianMLEelliptic, matching Picasso's own SPHERICAL/ELLIPTIC/ROTATED naming,mle3d's label now "Gauss MLE 3D elliptical") fits independent σx/σy at either a FREE or FIXED rotation angle, chosen by the 3D localisation? checkbox (PARAMS.localize3D, shown formle3d/gaussmleEllonly, default checked): checked = FREE angle (an astigmatism-axis-alignment diagnostic — every emitter should share the same angle if field-position-dependent aberrations are ignored — against real 3D calibration bead data, no longer just a stated follow-up) plus z from a loadedgaussian_widthcalibration; unchecked = angle FIXED at the sSMLM pairing step's own calibrated dispersion bearing (sSmlmAngleCenter), no z — the original sSMLM-only path.mle3dalso respects the checkbox now: unchecked runs the same axis-aligned elliptical fit with no calibration requirement and no z. See fit and sSMLM inCLAUDE.mdfor the full design, including the point-sampled (not pixel-integrated) model matching Picasso 0.11.0's owngaussfit.py_accumulate_rotatedformula (checked directly against its real source before building this, not assumed). Landed alongside the refactor it was designed to build on:gaussianMLEspheric/gaussianMLEellipticnow share onemleNewtonFit()Fisher-scoring driver instead of two independently hand- written copies of the same loop — verified numerically unchanged (a synthetic-data regression harness,gaussianFit/LS deliberately excluded from the unification, different per-pixel weighting and outer solver).pairCore()gained optionalsx0th/sy0th/sx1st/sy1stCSV/table columns (real per-axis widths for BOTH orders) alongside the existingsigma1stproxy; separately, every elliptical-method loc (paired or not) now also gets plainsigma_x/sigma_y [nm]CSV/table columns — which surfaced and fixed a real bug in the process: the worker pool's packed result array never carriedsx/syat all (only the mergedsigma), so a worker-pool sSMLM Run was silently losingsx0th/sy0th/sx1st/sy1steven before this — see export inCLAUDE.md.Deliberately deferred, not forgotten:
- Per-pixel gain/offset/variance calibration maps — Picasso's own
shared per-pixel estimator (
_estimator_terms(mle, value, data, var)) is already per-pixel-noise-aware via thatvarterm, connecting directly to the sCMOS entry further down this file. Not attempted here; the accumulator refactor above makes it a smaller lift when it is (the per-pixel model callback gains one more input), but genuinely not done this round. - Cubic-spline PSF fitting (
picasso/fitting/splinefit.py) for PSFs that deviate from Gaussian — meaningfully bigger scope than the rotated- elliptical case (its own 3D calibration volume, its own PSF-model representation, not just another free parameter). Key references: Babcock & Zhuang, "Analyzing Single Molecule Localization Microscopy Data Using Cubic Splines," Sci. Rep. 7, 552 (2017); Li et al., "Real-time 3D single-molecule localization using experimental point spread functions," Nat. Methods 15, 367–369 (2018).
- Per-pixel gain/offset/variance calibration maps — Picasso's own
shared per-pixel estimator (
-
Headless plot export (
config.exportPlots) — extend to column histograms. Shipped v0.11.3-dev (build 2026-08-20g) covering drift/NeNA/ FRC/PCFO/calibration — one flag renders whichever of those were actually computed as both PNG and SVG, with no browser window needed (see render/pipeline inCLAUDE.md,renderPlotBothFormats()/_plotTarget). Deliberately left out: the shared column histogram (drawHistogram()/computeHist(), table module) and the line-profile plot — both are driven interactively (a user picks a table column, or draws a line on the reconstruction) with no obvious headless default. Line-profile has no real headless equivalent (no reconstruction geometry exists to draw a line on) and should probably stay out of scope permanently. The histogram case is different and worth revisiting — a common real want is an intensity ('photons', or sigma/bg) histogram auto-generated for every batch/CLI run. The mechanism is mostly already there:computeHist(col, vals, unit)already takes an explicitvalsarray (solocs.map(L=>L[col]).filter(isFinite)from insideanalyze()needs no new plumbing), butdrawHistogram()itself reads the module-levelhistData/histView(set bycomputeHist()) the same waydrawCalibration()readscalib/calView— so this needs the same smallrenderHistogramPlotHeadless(col, vals, unit)stash/restore wrapperrenderCalibrationPlotHeadless()already demonstrates, not a new rendering mechanism. Needs a decision on WHICH column(s) to histogram by default: (a) a fixed set always rendered whenexportPlotsis set (e.g.photons/sigma/bg), (b) a new config field naming which columns (e.g.exportHistograms: ['photons','sigma']), or (c) something else — not scoped yet, flagged here as a direction. -
Plot panel UI polish — SHIPPED (2026-08-18, builds d–k). Fixed letterboxing, dark-on-screen/light-on-export plots, the Stack panels/Side by side toggle, PCFO's axis-scale ticks, panel vertical alignment, and a consistent axis-border/tick pass across every plot — see render in
CLAUDE.mdfor the full mechanism. One still-open scrap: no UI to resetlayoutOverrideback tonull(auto) once a user has clicked Stack panels/Side by side — not clearly needed yet (a second click always gets the other state, which for a 2-state toggle is equivalent, unless a third layout mode gets added later). -
File-size/modularity strategy for
webSMLM.html(now past 10,400 lines; SPT alone added roughly a thousand when it shipped in v0.11.2, and a possible future single-molecule FRET (smFRET) module would add a few thousand more). Splitting the core app across multiple files was ruled out —file://blocksfetch()/dynamicimport()under CORS in Chromium, inconsistently across browsers, breaking the "download and it just works" promise. Chosen and landed (v0.11.1): lean on the existingMODULE:banner convention plus the top-of-file MODULE INDEX line-number comment, refreshed every build-letter bump. Still on the table if the file keeps growing:- Dev-time-only source split, single-file at ship time — a
tools/- tier build script concatenatessrc/*.jsfragments into the finalwebSMLM.html, solving editability without touching the deployed artifact or thefile://promise. Not implemented; revisit once the MODULE-banner approach alone stops being enough. - Split SPT/smFRET out as their own single-file sibling apps
(
webSPT.html,webFRET.html) — considered and set aside: they'd share too much of webSMLM's own pipeline (TIFF loading, worker pool, table/render/export) to cleanly separate, and some real analyses combine SPT and smFRET directly (Fontana, Fijen, Lemay, Mathwig & Hohlbein, Lab Chip (2018), 10.1039/C8LC01175C). Revisit only if a future module needs almost nothing from the shared pipeline.
- Dev-time-only source split, single-file at ship time — a
-
Nikon ND2 loading — SHIPPED v0.11.2.
isNd2File()/loadNd2File()/parseNd2LvField()parse the modern (NIS-Elements 4.0+) chunk-container format, reverse-engineered directly from two real native samples since no official spec exists (Bio-Formats/nd2readerare GPL, unsafe to port from into this CC-BY project; the legacy JPEG2000-compressed variant is rejected with a clear error rather than half-supported — no lightweight JS JPEG2000 decoder exists). Wired intoloadTiffFile()/loadTiffFilesAuto()'s single detection path so all three existing TIFF callers (interactive#file, calibration file input, headlessanalyze()) gained ND2 support with no caller-side changes. A real row-0 pixel-corruption bug (a 24-byte per-frame sub-header misread as the first ~6 pixels of every frame — self-consistent enough that no dimension/ frame-count check caught it, only the pixel VALUES were wrong) was found from a user screenshot and fixed the same cycle, cross-validated byte-for-byte against the independent BSD-3-Clause referencetlambert03/nd2(installed locally for exactly this check, not ported from). Embedded pixel size, frame interval (from per-frame timestamps), and camera datasheet info are also logged (never auto-applied — same compute-once/apply-separately convention as everywhere else). Full design and history: in/out inCLAUDE.md, the 0.11.2 entry inCHANGELOG.md.Not implemented — deliberate scope limits, not oversights:
- Multi-channel and non-16-bit files throw a clear unsupported-format error rather than attempting to misread them.
- Multi-file ND2 concatenation — TIFF's own
loadTiffFilesAuto()can combine several single-frame files into one stack; ND2 can't yet. - The trailing
ND2 FILEMAP SIGNATURE NAME 0001!index chunk isn't used as a shortcut —readNd2ChunkHeader()still walks the whole chunk chain linearly. Fine at the sizes tested (indexing cost scales with frame count, not file size beyond that), but a genuinely huge ND2 file could benefit from reading this index instead.
-
FTM (fast temporal median filter) — still open. Shipped in 0.10.1; see
CHANGELOG.mdfor what landed andCLAUDE.md's in/out module note for the current design. Remaining:- Scrub-preview speed at large frame sizes (measured, 8 workers: ~23 ms at 128×128 up to ~510 ms at 1024×1024 per scrubbed frame — fine smaller, laggy for rapid dragging on large ones). A finer row-band split, or reusing detect/fit's frame-batch workers differently, are options if this needs to get faster.
- The worker-parallel Localize path only reports progress at chunk boundaries (no intra-chunk granularity, unlike the serial path) — would need a new worker message type (periodic progress pings ahead of the final result) to smooth out, not attempted yet since the barrier structure already makes that a bit delicate (see CLAUDE.md's Web Worker gotcha).
fitFirstFrame/fitLastFrame(the analysis frame range) stays a separate range from FTM2's own independent Start/End (which frames the filter itself runs over, not which ones get localized afterward) — no UI for the latter yet.- A real (not synthetic) per-candidate MLE fit-time regression was reported on GATTA-PAINT data with FTM on (~160→270 µs/candidate) that a synthetic A/B test couldn't reproduce (µs/candidate came back ~unchanged there) — likely the Newton solver needing more iterations to converge on real corrected-background statistics, but not yet confirmed against real data.
-
Spectrally resolved SMLM (sSMLM) — follow-ups beyond the Phase 1 "Spectral SMLM analysis" module (2-point 0th/1st-order pairing, shipped v0.11.0 — see
CLAUDE.md/docs/DOCUMENTATION.md's sSMLM module entry for the design; from Martens, Gobes, Archontakis, Brillas, Zijlstra, Albertazzi & Hohlbein, Nano Lett. 22(21), 8618–8625 (2022), 10.1021/acs.nanolett.2c03140, ported fromHohlbeinLab/sSMLMAnalyzer). Role assignment (which point of a candidate pair is 0th vs 1st order) went through several real-data-driven revisions before landing on its current, purely directional design — worth remembering if revisiting this area: brightness looked plausible (the paper's own physical model) but measured at ≈50/50 correlation with position even at confident intensity gaps (likely PSF-overlap/crowding corrupting photon estimates at this emitter density); a hypothesized symmetric ±1st-order signal was also ruled out (the "opposite-side" population's intensity-ratio profile was statistically indistinguishable from the real side, inconsistent with a genuinely weaker physical order); PSF width (σ) showed a real but imperfect correlation (~65–70%, stronger with a clearer σ gap) — consistent with the 1st order's spectral smearing — and is now available as an optional, default-off confidence filter (sSmlmRequireNarrower) rather than a requirement. What actually worked was a purely geometric, brightness-free rule: a point qualifies as 0th order only if it has a candidate on the configured (now fully directional, signed) bearing AND no candidate on the opposite bearing — self-disqualifying, no external reference signal needed — verified on the real reference dataset to recover more pairs than the old brightness-gated approach (64.0% vs 59.0%), with only ~5% of points landing in the genuinely ambiguous "candidate on both sides" bucket it correctly excludes. Remaining, not yet implemented:- Multi-order chaining (0-1-2-3+, matching
sSMLMAnalyzer'ssSMLMA.javafull feature set) — true higher orders (2nd, 3rd — same side as 1st, further out) are a distinct question from the ±1st-order symmetry just confirmed above; nothing examined so far demonstrates a genuine 2nd-order signal at a different distance from the 1st-order band, so that part still needs its own dataset/validation, not just the algorithm. - FFT-based automatic angle/distance detection, matching
sSMLMAnalyzer'sAngleAnalyzer.java(render localizations to an image, 2D-FFT it, find the dominant periodic peak — not a drop-in for webSMLM's own inline FFT, which is a 1D radix-2 transform for FRC, not a 2D image FFT + peak-finding routine). Phase 1's Preview pairs distance/angle histograms cover the same "find my window" need more simply for now. - A minimum-neighbour-count spatial consistency filter (reject sparse
false pairs with too few nearby confirmed pairs) —
sSMLMAnalyzerhas one, Phase 1 doesn't.
Headless exposure — shipped v0.11.1 (2026-08-17), alongside a data-model fix it depended on. Checking headless status surfaced a real, live bug: pairing stored the inter-order distance IN
z(aliasing/overwriting it), anddriftCore's "Correct z too (3D)" gate keyed off the samehas3dcheck the colour-by-depth toggle used — so a paired result would show that option, and ticking it would silently 1-D-"correct" the spectral distance as if it were spatial drift, corrupting it. Fixed at the root rather than patched:distis now its own field,zis never touched bypairCore()at all — which also unblocks a future 3D-fit + sSMLM combination (a loc could carry real depth AND spectral distance simultaneously without one clobbering the other), the original reason this was raised rather than just adding a driftZ guard.renderSuperRes()/zRange()gained acolorFieldparameter ('z'or'dist') so the one depth-coded render path serves both without duplication;rerender()/analyze()derive it fromhasZ/hasDist.pairCore()itself now throws on bad input (real 3Dz, or already-paireddist) rather than only the interactive wrapper checking — the second guard is new, needed because withzno longer touched, re-pairing an already-paired result could no longer be caught as a side effect of the old z-guard.config.sSmlmPair(headless),--sSmlmPair(CLI),sSmlmPair=1(autorun) all wire to the samepairCore()call, run right after Localize, before drift/NeNA/FRC. See sSMLM inCLAUDE.mdfor the full design; verified with directpairCore()guard tests, a real button-click UI round-trip (Localize → Pair → confirmdriftZRowstays hidden → Show standard/spectral toggle → Unpair), and a headlessanalyze({sSmlmPair:true, ...})call, all via Playwright. - Multi-order chaining (0-1-2-3+, matching
-
Single particle tracking (spt) — SHIPPED v0.11.2, follow-ups below. Frame-to-frame linking (
linkTracks(), Hungarian-optimal assignment per connected component,sptMemory-gated gap-bridging), per-track diffusion coefficients (trackDiffusionCoeffs(), ported fromdiff_coeffs_per_track()in the user's ownsptPALM-Pythonpipeline), D/track-length histograms with an exponential lifetime fit, live D rescaling on Frame time/ Localization error edits, headless exposure (config.sptTrack/--sptTrack/sptTrack=1), and a per-track Save spt data summary CSV — see spt inCLAUDE.mdfor the full design and the 0.11.2 entry inCHANGELOG.mdfor the round-by-round refinement history (verified against synthetic straight-line/crossing/gap-bridging cases and the real bundled L. lactis dataset). Cell-segmentation-aware tracking SHIPPED build 2026-08-24:Apply segmentation?loads a separate integer- labelled mask (Load segm. image/Show segm. image, recoloured display,segmentedImageData),Min./Max. cell area (px)gates which cells qualify (Show area hist.to help pick them), andTrackthen links each qualifying cell's own localizations SEPARATELY (linkTracksPerCell()) — a track can never cross a cell boundary — ported fromsptPALM-Python's ownapply_cell_segmentation_sptPALM.py/tracking_sptPALM.pyuse_segmentationsbranch.cell_id/cell_area [px]become optional CSV/table columns, same pattern astrack_id/D_coeff. Verified against the real bundled brightfield segmentation + localization CSV: no track ever spans two cells, area filtering exact at both bounds, CSV round-trip exact.Deliberately deferred, not forgotten:
- Length-resolved D histogram (the reference pipeline's
D_track_length_matrix, one histogram per track length rather than one pooled histogram) and colour-reconstruction-by-D/by-track (the render module'scolorFieldmechanism sSMLM'sdistalready uses could extend toD_coeff, but D is track-level not per-loc the way z/ dist are, so this needs its own design, not a drop-in). - Real
10^x-formatted tick labels for the D histogram's log10(D) x-axis — v1 ships literal log10 numbers (a deliberate, documented v1 shortcut, reusingcomputeHist()/drawHistogram()completely unchanged) rather than a genuinely log-scale-aware axis. - Large-subnetwork exact assignment.
linkTracks()'s connected components aboveHUNGARIAN_MAX(120 points) fall back to greedy nearest-neighbor rather than trackpy's own recursive exact-subnetwork solver — real single-molecule (PALM-style, sparse) SPT data isn't expected to produce components that large, but a genuinely crowded dataset could exercise this fallback; no reports of it mattering yet.
- Length-resolved D histogram (the reference pipeline's
-
tempClusteringMemory— gap-frame tolerance for temporal clustering.clusterEvents()(table module) currently requires strictly consecutive frame numbers to chain detections into one event (memory=0, hardcoded) — a molecule that blinks off for even one frame and back on starts a new chain instead of extending the old one.tempClusteringMemory = Nwould allow up to N missed frames between detections of the same chain. Needs a decision on how a gap should weight into the position average (does a skipped frame count as "still on" for the photon-weighted mean, or purely bridge the chain without contributing) before implementing. -
Reconsider the detect/fit/export/table module split. These four already share parameters and data in ways that make the boundary feel arbitrary in places — e.g.
PARAMS.gain/camoffsetsit under a---- exportcomment header (webSMLM.html) even though the conversion now happens insidefit, andtablereads the same already-convertedphotons/bg/bgstdthatexportdoes. Merging any two of them "because they're interwoven" risks snowballing (detect/fit are just as interwoven with each other and stay split, so that alone isn't a sufficient criterion) — needs an actual principle for where the lines go (kind of work vs. data flow) before touching it, not just an ad-hoc merge. At minimum, retitle the stale---- exportPARAMScomment tofitonce this is looked at. -
Let a settings JSON override a parameter's
min/max/step, not just its value. Today Save/Load Settings only round-trips{id: value}pairs (see$('saveSetBtn')/$('setFile')) — the bounds themselves live solely in the hardcodedPARAMSregistry inwebSMLM.htmland can't be changed without editing the file. Letting a loaded JSON optionally carry{id: {value, min, max, step}}and apply the bounds to both the in-memoryPARAMS[id]entry and the DOM control'smin/max/stepattributes (reusingsyncParamControls()'s write path) would let someone with unusual data (e.g. a camera gain far outside today's 0.001–1000 range, or a much larger pixel size) relax a boundary for their own use without touching the source. Needs a decision on whethersaveSetBtnshould also emit bounds by default (so a saved file is a full self-describing spec) or only on request (keeping normal saves small/value-only). -
Let Load Settings introduce whole new modules, not just parameter values. Today a loaded JSON can only set values for parameters the registry already knows about (
PARAMS[id]must exist — seelogParamValues/$('setFile')handler). A further step would let a loaded file extend the registry itself — e.g. ship a new detection filter or fit method's parameters (and, harder, its code) bundled with a settings file, so a new module can be dropped in and used without a webSMLM.html edit. Needs real design (where does the new module's code come from in a single-file, no-build app? aFunction-constructor'd snippet embedded in the JSON? a second file?) — flagged here as a direction, not scoped yet. -
Regression test suite via
analyze(): fixed-seed synthetic stack → assert localization count and RMS error within bounds. There is currently no automated test suite at all; this would be the first one, and it falls out of the v0.10.0 headless pipeline API (docs/DOCUMENTATION.md§8) for free. -
Extend the synthetic generator to also emit known z and known drift (ground truth), so 3D/drift/precision work can be validated quantitatively through the regression check above rather than by eye.
-
Cross-validate MLE 3D vs Phasor 3D on real bead data — only checked against synthetic ground truth and mutual self-consistency so far.
-
3D detection beyond astigmatism — Double Helix, Biplane, etc. could be added later as additional methods in the
3D calibrationmodule, alongside today's astigmatic σ_x/σ_y-vs-z approach. -
Cross-validate NeNA and FRC against established tools (ThunderSTORM, Picasso, FRCbar) — both still ship marked experimental.
-
3D FSC (Fourier Shell Correlation) — the spherical-shell counterpart to 2D FRC, once the 3D voxel-grid memory cost is bounded.
-
Multi-emitter fitting for dense/overlapping PSFs. Single-emitter fitting biases positions where PSFs overlap, and a faster single-emitter fit can't fix that — a better initial guess doesn't help when there's no good single-emitter optimum to find in the first place (see the rejected phasor-seeding idea in the v0.3.0 changelog entry, which ran into exactly this).
-
Robust detection threshold.
mean + k·σ_noiseis computed over the whole filtered frame including signal, so at high blink density the threshold rises and dim localizations get silently dropped — detection sensitivity is density-dependent. Consider MAD or a low percentile of the filtered image instead. Also: threshold statistics currently include border pixels that are never searched for maxima; and plateau handling needs a look — the local-maximum test uses strict>, so two equal adjacent pixels can both survive as separate localizations from one emitter. -
σ_PSF estimation from the data, instead of a fixed, user-supplied value.
-
Photon calibration beyond a single scalar gain/offset. Single-image gain/offset estimation from the data itself (PCFO, a photon-transfer-curve variant — tile-wise mean-signal-vs-noise-variance regression, not a variance-vs-mean-across-frames curve) shipped in 0.10.2 — see
CHANGELOG.md. A scalar still reasonably approximates an EMCCD chip, but most current SMLM runs on sCMOS, where gain, offset and read noise are all pixel-dependent (and non-uniform read noise also affects detection — a noisy pixel can masquerade as an emitter); still open: per-pixel gain/offset/variance calibration maps, with a noise model that uses them — see Huang et al., Nat. Methods 10, 653–658 (2013), https://doi.org/10.1038/nmeth.2488. -
Optional fiducial-based drift correction when beads are present (simpler and more accurate than AIM for that specific case).
-
3D point-cloud view — an interactive, rotatable scatter (orthographic projection, colour = z) as an alternative to the depth-coded 2D reconstruction, where localizations at different z that overlap in x/y currently blend together.
-
Localizations-table filter grammar: parentheses/grouping; filtering presets.
-
Consider deprecating Phasor 2D/3D in a future release. MLE 2D/3D are now the default and statistically superior (Poisson-optimal, reports a real CRLB uncertainty), and the worker pool has closed most of phasor's historical speed advantage for typical stacks. Phasor's own code is a modest ~200–250 lines out of ~6,400 (fit function, its distinct magnitude-ratio 3D calibration model, UI, worker/export wiring) — not huge, but removing it would simplify the calibration module down to one 3D model instead of two co-existing ones that currently need guarding against each other. Weigh against phasor's one remaining real edge: it's still by far the fastest fitter, which could matter for extremely high-throughput or huge stacks where MLE's iterative cost adds up.