porting-neuro-tools-to-browser-wasm
Playbook for building a 100%-client-side, in-browser neuroimaging tool (QSMbly / NeuroDesk style) by porting a reference Python/MATLAB pipeline to Rust + WebAssembly. Use when the goal is a drag-and-d
SKILL.md.claude/skills/porting-neuro-tools-to-browser-wasm/SKILL.md
GitHubPorting a neuroimaging pipeline to an in-browser Rust/WASM app
Goal: turn a reference implementation (Python/MATLAB, e.g. the Marques MP2RAGE scripts) into a static web app that runs the whole pipeline locally in the browser tab (no server, no upload) and downloads results. The numeric core is Rust, compiled to WASM, validated to match the reference to machine precision.
This is the recipe used to build "Easy MP2RAGE T1 Map" (T1 mapping, B1 correction, UNI denoising, plus a BIDS batch mode). It reuses for any similar tool (QSM, R2*, fieldmaps, segmentation post-proc). Sections 6 to 15 cover the browser app, BIDS mode, and the review discipline, which are the parts easiest to get wrong.
0. Project conventions (apply from the start)
Apply these from the start to avoid a later cleanup sweep of the whole codebase:
- No em dashes or en dashes anywhere, in the page, the tutorial, comments, or
docs. Use plain hyphens, commas, periods, and parentheses. A quick sweep (the
regex targets em dash U+2014 and en dash U+2013 by escape, so the code itself
stays dash-free):
node -e 'const fs=require("fs");for(const f of process.argv.slice(1)){let s=fs.readFileSync(f,"utf8");s=s.replace(/(\\d)[ \\t]*\\u2013[ \\t]*(\\d)/g,"$1-$2").replace(/[ \\t]*[\\u2014\\u2013][ \\t]*/g,", ");fs.writeFileSync(f,s)}' web/index.html web/js/*.jsthen hand-fix any comma splices it creates in the most visible strings. - Plain copy. Direct and concrete. The README leads with the live site and "what it is / what it is based on", then a barebones CLI section. No "seamless", "powerful", "effortless", "unlock", no exclamation-heavy copy.
- Scope privacy claims to the data. Once analytics is added, "nothing leaves your computer" is false; say "your images never leave your browser" and disclose the analytics honestly (see section 12).
- Validate on real data, not just the phantom. Run real subjects through the CLI and render slices to check before declaring anything works.
1. Decide the split before writing code
- What is the reference? Get the canonical Python/MATLAB. It is the source of truth. Port it, do not reinvent it.
- Data is PHI. The repo contains ZERO subject data. Keep test data outside the repo and gitignore aggressively (section 14).
- Deliverable shape: three implementations sharing one validated core: reference Python (kept), a Rust port (core + CLI + WASM), and the static web app.
2. Workspace layout
crates/
<tool>-core/ pure Rust, no wasm/web deps: ALL the maths, unit-tested
src/{model,correct,interp,filt,mask,resample,pipeline,dicom,denoise,b1fill,...}.rs
<tool>-cli/ native CLI over the core (validation + batch); hand-rolled NIfTI I/O
<tool>-wasm/ wasm-bindgen bindings only (thin); exposes core to JS
web/
index.html dark theme, mode toggle, drag-drop, role table, params, viewer, downloads
js/app.js controller; js/worker.js Web Worker; js/nifti.js; js/zip.js; js/bids.js
wasm/ GENERATED by wasm-pack (gitignored)
tools/ gen_golden.py (reference -> golden/*.npy); build_wasm.sh
<pkg>/ the reference Python package + CLI (kept for parity)
The core is I/O-free and wasm-free: pure ndarray + math, so the SAME code runs
natively (fast tests) and in WASM. Keep pipeline.rs (the end-to-end assembly) in
the core so the CLI and WASM share one code path.
3. Bit-exact parity is the whole game
Port numerically, then prove it. tools/gen_golden.py runs the reference on a small
synthetic phantom and saves intermediate arrays; Rust parity tests reload them and
assert closeness. Common traps:
np.arangeuses a fused-multiply-add fill, notstart + i*step. Reproduce with(i as f64).mul_add(delta, start)wheredelta = (start+step) - start. Wrong here shifts every lookup-table row and silently corrupts results.- Rounding is banker's (round-half-even) in numpy/
.round(). Writeround_half_even; do not use Rustf64::round(half-away-from-zero). np.percentiledefault is 'linear':rank = q/100*(n-1), lerp floor/ceil.scipy.ndimage.gaussian_filter:mode='reflect',truncate=4.0; accumulate in f64 but mirror the Python dtype path (it may store f32 between axes).- Array layout: on-disk NIfTI is i-fastest
i + nx*(j + ny*k); numpy C-order isi*ny*nz + j*nz + k. Convert explicitly at the WASM boundary (arr3/flat_ifast). Layout bugs look like transposed/rolled volumes. - NaN-safe compares:
partial_cmp().unwrap()panics on NaN, which in WASM is an ugly abort. Usetotal_cmpfor sorts; guard interpolators against NaN queries. NaN-free inputs sort identically, so golden parity holds. - Clamp/lookup consistency: a fill/clamp range must sit INSIDE the correction lookup's valid domain, or one saturation direction gets zeroed (NaN->sentinel) while the other returns a wrong value.
Validate at three levels: (1) cargo test parity, (2) native CLI reproduces the
reference on real data (max|diff| ~ 0), (3) headless Node e2e driving
nifti.js -> WASM -> nifti.js vs the golden (expect ~1e-4 from f32 storage).
4. DICOM & NIfTI in the browser
- NIfTI: hand-roll a small NIfTI-1 reader/writer in Rust (CLI) and JS (
nifti.js), mirrored field-for-field (sform/qform, datatype, scl_slope/inter,vox_offset). Writer gotcha: allocate a 352-byte header and do NOT append pad bytes, or vox_offset will not match the data start. Reader safety: size the allocation from the actual file byte length, never from header dims alone (a header claiming huge dims must not OOM the tab); do not use| 0on vox_offset (ToInt32 wraps large offsets). - DICOM: Explicit VR Little Endian covers Siemens. Read
ImageType (0008,0008),SeriesDescription, geometry (IPP/IOP/PixelSpacing), and Siemens CSA/ASCCONV (MrPhoenixProtocol) for sequence params. Assemble by projecting IPP on the slice normal; build an RAS affine (negate x,y from LPS). Harden every slice (pos+len <= bytes.len()), long-VR length reads, and undefined-length walks (saturating_add). A malformed file returns a clean error, never panics. - Derived output DICOM: clone source slices, swap in freshly computed pixels and a
few tags (
DERIVED\SECONDARY, new SOP/Series UIDs, RescaleType, bits), keep geometry. Validate with pydicom (geometry preserved, pixels are the computed map). - De-identify (offer it as an opt-in): use a fail-safe whitelist, not a blacklist.
Keep only the technical/geometry/rendering/UID tags a viewer needs (the image-pixel
group 0x0028 and pixel data 0x7FE0 wholesale, file-meta 0x0002 minus the AE-title
tags 0016/0017, and an enumerated set of SOP/geometry/MR-technique tags); drop
everything else, so any tag you did not list is removed by omission. Keep the DICOM
Type-2 identity tags (PatientName, PatientID, StudyDate/Time, StudyID, ...) present
but blanked for conformance. Re-map StudyInstanceUID and FrameOfReferenceUID to
salted values to break linkage back to the source study. Only then stamp
PatientIdentityRemoved=YES, so the flag is truthful. A blacklist that blanks named tags leaks any tag it forgot (InstanceCreationDate, AcquisitionDateTime, physician names, AdditionalPatientHistory, EthnicGroup) while still asserting YES. A metadata scrub cannot remove burned-in pixel PHI, but the derived map's pixels are freshly computed, so that is not a risk for this output. Unit-test the whitelist: feed a slice carrying a spread of PHI tags and assert they are gone, the identity tags are blank, the UIDs are re-mapped, and the technical tags survive.
5. Web app architecture (the core plumbing)
- Web Worker runs the WASM off the UI thread. Transfer COPIES, not source buffers:
postMessage(msg, [transferables])detaches the source ArrayBuffer, which empties your in-memory inputs and crashes the NEXT run. Slice each input, post the copy, keep originals pristine. Recreate the worker per run for a clean WASM heap; expose a Stop button (worker.terminate()); add afreeWorker()that terminates the idle worker to release its multi-GB heap between jobs. - Self-contained canvas viewer. Roll your own ortho slice viewer + histogram (a few
hundred lines). Do NOT pull NiiVue/three from a CDN:
esm.sh?bundlere-imports from the network and breaks the no-external-requests guarantee and CSP. - Dependency-free ZIP (store-only + CRC32) for "download all"; gzip the NIfTIs.
- Ingest: folder drag-drop via
webkitGetAsEntry(); capture ALL entries SYNCHRONOUSLY before the firstawait(dataTransfer.itemsempties after the handler returns). Support several folders at once. Keep awebkitdirectorypicker as fallback; note Chrome's "upload..." prompt is just folder read-access wording, drag-drop avoids it.
6. Input modes: Single vs BIDS (make them mutually exclusive)
Provide two distinct input modes behind a toggle at the top of step 1; they must NOT bleed into each other (no stray single scan loaded next to a batch):
- A segmented toggle
[Single dataset] [BIDS directory]setsappMode('single'|'bids'). setAppMode(mode): refuse to switch whilerunning(log and return); if leaving BIDS, callcloseBids(); then clear everything (state.files/jsons, outputs, viewer, downloads, warnings),freeWorker(), setappMode, and toggle control visibility.applyModeVisibility(): in BIDS mode hide the drop zone and the DICOM-folder row and show the BIDS button; in single mode the reverse. Call it once at init.- Guard the single load paths:
addFiles/addDicomFolderearly-return in BIDS mode. resetAll()must also setappMode='single'+applyModeVisibility()(otherwise it leaves you stranded in BIDS mode with the panel gone).
7. BIDS batch mode (the largest piece)
A web/js/bids.js indexer (pure, no DOM, testable in Node) plus a BIDS panel in
app.js. Design goal: load the whole tree, run subjects one at a time, keep only ONE
session in memory. Copy web/js/bids.js and the BIDS section of app.js as the base.
Indexer (bids.js): parseEntities(path) -> {sub, ses, datatype, suffix, ext, base,
entities{acq,run,inv,...}}. indexBids(entries[{path,file}]) -> subjects[].sessions[]
each with roles{UNI,INV1,INV2,B1SOURCE}, b1kind ('sa2rage'|'b1map'), candidates,
files, sidecars, status{state,reason}, runnable{t1,denoise,b1only}, warnings.
Match rules (MP2RAGE): INV1/INV2 = anat MP2RAGE inv-1/2; UNI = anat UNIT1 (or T1w with a
uni/uniden/mp2rage acq, flagged nonstandard); B1SOURCE = fmap TB1SRGE (sa2rage) /
TB1TFL-famp / TB1map / RB1COR (b1map); a TB1TFL "anat" export is NEVER the B1 source.
runnable.b1only = UNI && INV2 && B1SOURCE && b1kind==='sa2rage'. Ship a Node test
(web/test/bids_test.mjs) with synthetic paths mirroring the real dataset.
Per-session UI:
- Clickable role chips (UNI/INV1/INV2/B1 source). Clicking a chip does TWO things: preview that image in the docked right viewer AND set it as the active role for a single "Reassign <role>:" dropdown that follows the clicked chip, so any role can be reassigned, not just ambiguous ones.
- A per-session Task selector listing only the runnable tasks (Make T1 map / SA2RAGE -> B1 map / Denoise UNI -> UNI-DEN); it drives the Calculate button label and the output name.
- Buttons in this order:
load & edit, thenCalculate. - One-click Calculate: load the session, run it.
load & editloads without running so params can be tweaked, and it must NOT scroll the page (the viewer is on the right).
Six state-machine gotchas that will bite (fix them up front):
- rerender no-op.
renderBidsSession(ses)reassignsses._el = boxinternally, so capture the old node BEFORE rendering:const old = ses._el; const fresh = renderBidsSession(ses); if (old && old.parentNode) old.parentNode.replaceChild(fresh, old);Otherwise every reassign silently does nothing on screen. - Parameter leak between sessions. In
loadBidsEntry, RESET the parameter form to a field-appropriate preset (read MagneticFieldStrength from a sidecar, applyPreset 3T/7T) BEFORE applying that session's sidecars. Otherwise a session with no/partial sidecars silently reuses the previous session's TR/TI/flip and produces a wrong T1. Coerce the field strength withparseFloatfirst (sidecars sometimes store it as a string). - Stale status after reassign. On reassign, refresh ONLY the reassigned role's status, and derive its ok/warn state from an actual filename/modality check rather than a hard-coded ok, so a mismatched hand-pick is flagged. Leave the other roles' status intact. Do NOT wipe the index-time warnings (they may flag a real problem with a role the user did not touch); keep them and mark them possibly-stale instead. If the session already has a computed result, save/free its download blob before demoting it to 'pending', or the blob leaks and its download button disappears with no prompt.
- Async re-entrancy. Guard
loadBidsEntry/calculateBidsEntrywith abidsLoadingflag (set before the first await, clear infinally). Two fast Calculate clicks (or a double-click) otherwise interleave on the sharedstate.filesand mislabel outputs / strand a row in 'running'. - Stop mid-run leaves a stuck row.
stopProcessingmust reset a BIDS row that is 'running' back to 'loaded' and re-render its actions. - Worker error path. The
onResulterror branch must callupdateBidsActions(not just the status text), or the row stays on "processing..." with no retry button.
Lean memory: only one session in memory. Loading/calculating another frees the
previous entirely (clearOutputs, state.files=[], revoke its download blob, freeWorker()
to drop the WASM heap). Calculate next walks the batch.
Cross-session preview. Clicking a role chip on a session that is not the current one reads that file on demand and previews it. Do NOT append the current session's result views to that preview's view selector, or it offers another subject's outputs under the previewed subject's chips.
8. Permanent docked viewer (QSMbly-style, on the RIGHT)
Dock the viewer permanently on the right in BIDS mode so stepping through subjects never scrolls the page up and down.
- Wrap the tree in a 2-column grid
#bidsCols { grid-template-columns: 1fr }, add a.with-viewerclass that switches tominmax(0,1fr) minmax(360px,48%); the right slot isposition: sticky; top: 12px. Stack on narrow screens with a media query. - Physically MOVE the existing
#viewerWrapnode into the right slot when entering BIDS mode (capture its home parent/nextSibling so you can move it back), and reset it to its full-width home in single mode. Canvas state survives the DOM move. - Fit the viewer to the narrow column:
#viewerWrap.docked #sliders { flex-direction: column }(the A/C/S sliders otherwise overflow), smaller slice canvases, and REDRAW the histogram + slices onwindow.resize(debounced) so the histogram re-fits. - Clicking any input (role chip or the file table's eye icon) previews on the right; do
not
scrollIntoViewin BIDS mode. On load, auto-preview the UNI so the right panel is never empty. Show a placeholder ("Load or calculate a session...") when nothing is up.
9. Download and memory flow (review first, then prompt)
Flow: show the result BEFORE anything downloads, then prompt to save it before it is freed.
- Auto-download OFF by default. On completion, build the result blob + a prominent per-session "Download <file>" link and show the result. Do not auto-download unless the auto-download box is ticked.
- Track
ses._downloaded(set true on auto-download, on clicking the row link, on Download-and-unload, and on clicking a viewer download link that carries the result). If saving via the docked viewer's own download links does not set it, the eviction prompt fires falsely for a result the user already saved. - Eviction prompt. When loading the next session evicts a done, un-downloaded result,
confirm("The result for X has not been downloaded and will be freed... Download now?")and download on OK. This is the memory-constraint safety net. - Defer the blob-URL revoke. After a click()-triggered download, revoke the object URL
on a later tick (
setTimeout(revoke, ~4000)), not synchronously in the same tick. Revoking immediately can abort a still-queued download (Firefox/Safari, multi-MB files) and silently lose the file. - Blob-URL lifecycle: keep at most ONE live result blob (the current done session's);
revoke on eviction, on Download-and-unload, on recompute, and in
closeBids. Do not push these into the genericobjectUrlslist that gets revoked every run.
10. Threading a new option through the whole stack (fast recipe)
Every optional flag (extend-B1-FOV, de-identify, fallback-uncorrected, denoise beta) follows the same path. Do it as one mechanical pass:
- Core: add a trailing param to the pipeline fn (
run_b1map(..., extend_fov, fallback)). - WASM: add the same trailing param to the
#[wasm_bindgen]fn; forward it. - Worker: pass
!!msg.flagin the wasm call. - run(): set
msg.flag = $('#control').checked. - UI: a checkbox in the run controls, plus a PER-SESSION control in each BIDS row. Make
the per-session controls task-contextual: show extend-FOV and fill-non-converged for
the T1 task, a denoise-beta input for the denoise task. A BIDS toolbar holds batch
DEFAULTS that seed each pending session (
reseedBidsDefaultson a toolbar change);loadBidsEntrypushes the session's own values into the run controls (applyBidsSessionOptions) before Calculate. The controls belong per-session, not only batch-wide. - Update ALL callers so it compiles, including tests: pass the default (usually
false) so golden parity holds. Rebuild the wasm.
Parallelize with background agents on disjoint files. The Rust threading (crates/) is independent of the web JS (web/) and the reference Python. Spawn a background agent for the Rust part while doing the JS/HTML, then integrate + rebuild. Specify the exact final signatures so the JS matches.
11. Domain knowledge and sanity checks (MP2RAGE-specific)
This section is domain-specific, not general, but it illustrates the kind of sanity checks worth adding for any modality.
- CSF/ventricle holes are expected, not a bug and not a UNI-DEN artifact. The MP2RAGE
UNI->T1 lookup can only invert a limited T1 range; very long-T1 fluid (CSF, eyeball
vitreous, ~4 s) falls off the invertible part, gets a sentinel (
t=4.0-> 4000 ms) and is zeroed. It happens with the plain UNI too. UNI-DEN separately punches holes in low-SNR regions, which is why plain UNI is preferred as the correction input. - fallback_uncorrected fills non-converged in-mask voxels with the uncorrected T1. It recovers roughly half the holes (the CSF voxels that have a valid uncorrected value, ~4500 ms); the rest are genuinely ambiguous (zero in the uncorrected map too). Expose it as an option; state that it does not recover everything.
- A "B1 map" file may not be a B1 field. A Siemens
B1_Head_3d/TB1mapcan be the MAGNITUDE anatomical reference (ImageType hasM/MAGNITUDE, values not near nominal, shows brain anatomy), not the flip-angle field. Feeding it as B1 produces garbage. Add a post-run sanity check on the relative B1: the in-brain median is the reliable tell. Warn if the median is outside [0.5, 1.7], or the spread(p90-p10)/median > 1.0while the median is also off (below 0.75 or above 1.3). Do not warn on spread alone: a real 7T field with cerebellar/temporal drop-out can reach spread ~0.9 at a median near 1. Make the message task-aware (a B1-only run has no uncorrected T1 to fall back to). - TB1map units are ambiguous across sequences (percent vs flip x10 vs relative). Expose the type; the sanity check catches a wrong choice.
- Messy real BIDS: a session can carry several
UNIT1-tagged files that are actually MPR reformats (SECONDARY, different grid) and single-slice projection thumbnails. Pick the PRIMARY full-3D one; the dimension check (UNI vs INV2 grid) catches a wrong pick.
12. Privacy, safety, analytics
- Zero data egress. Audit
grep -rniE 'fetch\(|XMLHttpRequest|WebSocket|sendBeacon|FormData'over web/. The only intended external request is analytics (below); the wasmfetchis its own same-origin.wasm. Images/results never leave. - Escape every user-controlled string put into innerHTML (filenames, chip text,
warnings, download names, sidecar-derived table rows). One unescaped
${name}is stored XSS that can exfiltrate the loaded volumes. Prefer textContent/createElement or anesc()helper, and check EVERY newinnerHTMLsink. - Validate before compute: matching dims across same-grid inputs, required multi-volume shapes (SA2RAGE = 2 vols), finite numeric params. A clear message beats a WASM panic or a silent all-NaN download.
- Analytics (NeuroDesk asks for GA4). Add the standard gtag snippet, then rescope ALL the "nothing leaves" copy to "your images never leave your browser" and disclose the anonymous page-view analytics honestly (badge, About/Privacy, tutorial, README, drop-zone tooltip, footer, startup log, code comments). GA changes the "no external requests / works fully offline" story but NOT the "your images never leave" story. Give an off-switch (delete the script) in the README/DEPLOY. GA4 is the only external runtime dependency and the only thing that makes any network request; it has no SRI and a static Pages deploy has no CSP, so it is the one supply-chain surface to keep in mind.
13. Build & deploy
tools/build_wasm.sh:wasm-pack build --target web-> copy pkg ->web/wasm/. ParameterizeWASM_PACK=...so CI can override.web/wasm/is gitignored, so it must be rebuilt and re-staged after ANY Rust change, or the deployed app runs stale code.- GitHub Pages via Actions: single-threaded + SIMD means no COOP/COEP headers needed.
Build job installs Rust + wasm-pack (taiki-e/install-action), runs the script, uploads
web/; deploy job publishes. Relative asset paths so it works at both/<repo>/and a custom domain root. One-time: Settings -> Pages -> Source = GitHub Actions. - NeuroDesk custom domain: they give you
<name>.neurodesk.organd set the DNS. Addweb/CNAMEwith the domain, set it in Settings -> Pages, tick Enforce HTTPS. CI runs cargo test + a wasm32 build check + the Node e2e.
14. Data hygiene
Gitignore aggressively: /data/, derivatives/, *.nii, *.nii.gz, *.dcm, *.IMA,
dcm2niix *.json sidecars (BIDS-named too), *.bval/*.bvec, plus negations for committed
assets (!web/assets/..., !tools/phantom/...). Keep real data OUTSIDE the repo. Watch
for PHI in filenames and in the on-screen log.
15. Verification discipline
After each substantial change, run an adversarial multi-agent review: fan out parallel reviewers by lens (security, speed/efficiency, runtime/robustness, Rust correctness, usability, PHI-in-outputs, parity), then adversarially VERIFY each material finding (confirm with a concrete trigger, or refute) before acting. Do not skip the verify pass; it removes hallucinated findings and calibrates severity.
Bug classes this review discipline surfaces in a codebase like this (look for them proactively):
- DOM rerender no-ops (reassigning
ses._elbeforereplaceChild). - State leaking between items in a batch (parameter form not reset per session).
- Blob-URL accumulation, use-after-revoke, and synchronous revoke aborting a download.
- Async re-entrancy on shared state (double-click during file read).
- Unescaped innerHTML with a filename or sidecar value.
- Clamp/lookup-range mismatch producing a silently-wrong value on one side.
- Worker heap not freed between jobs (multi-GB idle).
- Allow-list vs blacklist for de-identification: a blacklist leaks any tag it forgot.
Fix the clear wins directly (security, validation, parse hardening, leaks, a11y); surface the design calls (defaults, transparency of extrapolated/filled regions) for the maintainer. Parallelize implementation only across disjoint files (Rust crates vs web JS vs Python) to avoid edit conflicts.
Reusable scaffolding (copy these from this repo)
About 80% of a new tool is domain-agnostic; only the maths and the UI task set change.
web/js/nifti.js,web/js/zip.js(reuse as-is),web/js/worker.js(copy-transfer).web/js/bids.js(the BIDS indexer) and the BIDS section ofweb/js/app.js(the whole panel + docked viewer + reassign + task selector + download flow); rewire the roles/tasks.web/js/app.jscanvas viewer, histogram, guided tour, drag-drop ingest, warnings, download builder, mode toggle.crates/<tool>-core/src/{dicom,interp,filt,mask,resample,b1fill}.rsandnifti_io.rs;crates/<tool>-wasm/src/lib.rs(arr3/flat_ifast boundary).tools/gen_golden.py,tools/build_wasm.sh,.github/workflows/*.
Rewrite the algorithm modules (model.rs, correct.rs, denoise.rs, pipeline.rs), the
reference Python, and the task/parameter set.
Quick start checklist
- Vendor the reference; write
tools/gen_golden.py(phantom + checkpoints). - Port the core module-by-module; parity tests until
cargo testis green. - Native CLI reproduces the reference on real data (max|diff| ~ 0).
- wasm crate +
build_wasm.sh; Node e2e reproduces the golden (~1e-4). - Web app single mode: worker (copy-transfer!), self-contained viewer, ingest, downloads.
- Add the Single/BIDS toggle, then BIDS mode (indexer + panel + docked viewer + review- first download), watching the six state-machine gotchas in section 7.
- Thread the optional flags (section 10); add the sanity checks (section 11).
- Privacy audit + validation + parser hardening + whitelist de-identify; scope copy for analytics.
- Pages deploy + CI; gitignore data; barebones README (site first); no em dashes.
- Adversarial review pass; fix confirmed findings.