Skip to content
TheJacksonLaboratoryPublic

About

WebGPU rendering engine porting napari's visualization model to the browser.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

napari-js

A browser-native, WebGPU rendering engine that ports the visualization model of napari (the Python multi-dimensional image viewer) to TypeScript.

Status: published to npm — npm install napari-js (latest: v0.13.0). napari-js implements the POC called for in jit-ui#102 — a browser-based napari shipped as a JS library. The renderer (NJ-0…NJ-5+) is complete and browser-verified, and napari-js now ships in production as the WebGPU backend of sci-image-visualizer — the engine behind the JAX Image Tools viewer. See the CHANGELOG.

Features

  • WebGPU rendering, 100% client-side — no Python, Pyodide, WASM, or server.
  • Image layers: single- and multi-channel, uint8 / uint16 / float32, with live colormap (LUT), contrast limits, gamma, invert, opacity, and blend modes (opaque / translucent / additive / minimum).
  • Tiled & pyramidal large images with level-of-detail + an LRU GPU-tile cache, and z-stacks — fed by a pluggable TextureSource (typed arrays or ImageBitmap tiles).
  • Points (instanced SDF markers) and Labels (uint8/uint16/uint32 ids, cyclic palette).
  • 3D points with per-point colormapped values plus per-point alpha and size — enough to mute a cloud and highlight a subset within a single layer and a single draw — and a dataVersion so recolouring mutates the layer instead of replacing it.
  • 3D volume raymarching — MIP, translucent, and iso-surface, with an orbit camera; a plane depiction (napari's depiction: 'plane') that shows one slice of the volume in 3D, and clipping planes on volumes and surfaces.
  • Surface — a 3D triangular mesh (napari's Surface layer) with per-vertex colormapping, one colour or a colour per vertex, depth-tested flat shading and in-place setData, plus a heightField helper that turns a 2D image into a surface plot. Surface picking (viewer.pickSurface, canvasRay) finds the mesh and triangle under the cursor.
  • Shapes — closed polygon rings in 2D (napari's Shapes layer, rings only) from a flat coords + offsets pair, drawn as 1-pixel boundaries or filled interiors and coloured by one scalar per shape through a colormap — sized for the 10⁵–10⁶ cell outlines a segmentation produces, with pure ringsToOutline / ringsToFan expanders.
  • Readback: displayed-pixel readout, PNG screenshot, and per-channel histograms.
  • Host-friendly: device-loss recovery, ResizeObserver auto-resize, and canvasToWorld / worldToCanvas / visibleWorldRect for overlays and picking.
  • 3D projection & picking: viewer.projectPoints() (or the pure projectPoint / projectPoints) maps world coordinates to canvas CSS pixels under the orbit camera, honouring the near and far planes as well as the eye. nearestProjectedIndex hit-tests the result front-most first, optionally per-point radius and pickability, so a tooltip agrees with what the depth buffer actually drew; ScreenIndex buckets the projection so a multi-million-point cloud picks in microseconds rather than milliseconds. A host no longer reimplements the perspective divide.
  • Explicit camera framing: fit3d: 'always' | 'once' | 'never' on the viewer (or per add), plus fitToLayers() over the union of every 3D layer and resetFit3D() — so building a scene from several layers does not make the view jump. The distance is derived from both half-angles of the frustum, so a portrait viewport frames the scene rather than cropping it.

Install & use

npm install napari-js
import { Viewer } from 'napari-js';

const viewer = new Viewer({ canvas: document.querySelector('canvas')! });
await viewer.ready; // WebGPU device acquired

// one layer per channel; composited additively on the GPU
viewer.addImage(channel0, { colormap: 'green', blending: 'additive', contrastLimits: [0, 4095] });
const dapi = viewer.addImage(channel1, { colormap: 'blue', blending: 'additive' });
dapi.gamma = 0.8; // live — updates a uniform, no texture re-upload

viewer.addPoints(points, { size: 12, faceColor: [1, 1, 0, 1] });
viewer.addLabels(labelImage, width, height, { opacity: 0.5 });

A layer's data is any TextureSource input: an ImageBitmap, a typed-array descriptor ({ kind: 'typed', width, height, channels, dtype, data }), or a pyramidal { kind: 'tiled', …, fetchTile }. Full API in docs/02.

Testing without a GPU: napari-js/testing

Unit tests (Vitest, Jest) have no WebGPU. napari-js/testing is the whole public API with Viewer bound to HeadlessViewer: the real ViewerModel, LayerList, cameras and layers, the real add*, fitToLayers and worldToCanvas, with no device. Nothing is drawn, ready is already resolved, and readback returns a blank frame.

import { HeadlessViewer } from 'napari-js/testing';

const viewer = new HeadlessViewer({ canvasRect: { left: 100, top: 50, width: 800, height: 600 } });
const cloud = viewer.addPoints3D(positions); // a real Points3DLayer, validated as usual
viewer.fitToLayers(); // frames exactly as Viewer does
viewer.worldToCanvas(x, y); // includes the canvas offset

To run code that does new Viewer({ canvas }) unchanged, point the test runner's napari-js at it: Jest moduleNameMapper: { '^napari-js$': '<rootDir>/node_modules/napari-js/dist/testing.js' } (and let Jest transform napari-js, which is ESM), or a Vitest resolve.alias. Layers it builds are instances of the same classes napari-js exports.

Overlays: napari-js/overlays

DOM chrome over the canvas, as napari's viewer overlays: a physical scale bar (2D zoom, or the 3D camera at its target's depth), crisp 3D axis labels, an OpenSeadragon-style 2D navigator, and SlicePlanesWidget: three orthogonal slice planes through a volume with grips that drag them along their axes (demo 9). Each takes (host, viewer, opts) and has dispose(); the main bundle does not include them.

import { ScaleBarOverlay, NavigatorOverlay, scaleBarFor } from 'napari-js/overlays';

const bar = new ScaleBarOverlay(host, viewer, { unitPerWorld: 0.25, unit: 'µm' });
const nav = new NavigatorOverlay(host, viewer, { worldWidth: w, worldHeight: h, image: thumb });
scaleBarFor(pxPerWorld, umPerWorld, 120); // the pure maths, for a bar drawn elsewhere

Exact matplotlib colormaps: napari-js/colormaps

All 83 matplotlib colormaps as exact 256-entry RGB tables, one tree-shakeable Uint8Array(768) per map, so a bundle keeps only the maps it imports (about 1 KB each). The main entry's VIRIDIS, MAGMA and INFERNO already use the exact tables.

import { TURBO_LUT, lutColormap, matplotlibColormap } from 'napari-js/colormaps';

viewer.addImage(img, { colormap: lutColormap('turbo', TURBO_LUT) });
matplotlibColormap('RdBu'); // by name, via the COLORMAP_LUTS registry (keeps every map)

Geometry: napari-js/geometry

Framework-free raster/vector geometry, the operations behind napari's Labels tools: scanline rasterizePolygon (with holes), pointInRing/pointInPolygonWithHoles, labelComponents (4/8), traceContours (outer rings + holes along pixel edges, so a mask round-trips exactly), floodFill and autoContrastLimits (saturation-based auto contrast, also in the main entry), and meshes from voxel masks: surfaceNets, labelsToMeshes and smoothMesh (Laplacian or Taubin). Rings are flat [x0, y0, x1, y1, …]; rasters are row-major { data, width, height }.

import { traceContours, rasterizePolygon, floodFill } from 'napari-js/geometry';

const mask = floodFill({ data, width, height }, seedX, seedY, { tolerance: 12 });
const [blob] = traceContours({ data: mask, width, height }, { minSize: 4, minHoleSize: 4 });
const back = rasterizePolygon(blob.outer, blob.holes); // the same pixels, as a bbox mask

Develop

npm install
npm run dev          # serve the playground (8 demos in the dropdown — see below)
npm test             # GPU-free unit tests (Vitest)
npm run test:gpu     # real-GPU tests in headless Chromium (Vitest browser mode; see below)
npm run test:coverage
npm run typecheck && npm run lint && npm run format:check
npm run build        # library bundle + types → dist/

npm run test:gpu runs test/gpu on the machine's GPU through Playwright's Chromium (run npx playwright install chromium once). NAPARI_GPU_SWIFTSHADER=1 uses Chromium's software adapter, as CI does; without an adapter the tests skip unless NAPARI_GPU_REQUIRED=1. Shader math keeps its CPU reference and GPU-free unit test; a GPU test checks the shader against that reference within a few levels of 255.

npm run dev serves index.html → playground/main.ts. Pick a demo from the dropdown (or press its number key) to verify each render path; if WebGPU is unavailable the page shows the reason instead of crashing. The status line along the bottom reports what is on screen — vertex counts, slice index, active mode — and the per-demo keys.

The demos

napari-js playground: a scalar image rendered through the viridis colormap
1 · Image + colormap — a single-channel image through a scalar colormap. Drag pans, the wheel zooms, c cycles the colormap.
napari-js playground: three fluorescence channels composited additively
2 · Multi-channel additive — three overlapping blobs as red / green / blue tinted image layers, composited on the GPU with additive blending.
napari-js playground: a tiled 16384-square pyramid with a z-stack
3 · Tiled + z-stack — a 16384² pyramid served as tiles, picking a level from the camera; ↑/↓ step through the 8 slices.
napari-js playground: a points layer and a label image over an image
4 · Points + Labels — a PointsLayer and a LabelsLayer over an image, with click-to-pick.
napari-js playground: a 3D volume raymarch under an orbit camera
5 · Volume (3D) — fragment raymarch under the orbit camera. m/t/i switch MIP, translucent DVR and iso-surface.
napari-js playground: a 3D triangular mesh coloured by height
6 · Surface (3D mesh) — 19,200 vertices / 37,842 triangles from heightField, coloured per-vertex; w toggles wireframe.
napari-js playground: 4000 points as screen-facing billboards in 3D
7 · Scatter (3D points) — 4,000 depth-tested billboards coloured by a per-point scalar.
napari-js playground: 2400 polygon ring outlines over an image
8 · Shapes — outline — 2,400 rings × 16 vertices as a line-list, 76,800 vertices. One device pixel wide at any zoom: WebGPU has no line width.
napari-js playground: the same 2400 polygon rings filled and colourmapped
8 · Shapes — fill — the same rings as triangle fans, 115,200 vertices, each coloured by one scalar per shape through the colormap. f switches mode, c recolours.

What this is

napari-js is a standalone, framework-agnostic library. It renders large multi-dimensional / multi-channel scientific images in the browser on the GPU, with napari's model: a Viewer holding a list of Layers (Image, later Points / Labels / Volume), each with its own colormap (LUT), contrast limits, gamma, opacity, and blending.

It is not a port of napari's Python code or its Qt GUI. It is a faithful port of napari's rendering concepts — the layer→visual model, per-layer GPU colormapping, serializable transforms and camera — onto WebGPU and WGSL. See docs/07-napari-concept-mapping.md.

Why

WebGPU now ships in all major browsers (late 2025). napari's strengths — GPU multi-channel fluorescence compositing, live scalar colormapping, and volume rendering — are exactly the things current browser image viewers do poorly or on the CPU. napari-js brings those strengths to the web as a reusable npm package.

Where it fits

~/git/napari                Reference: the Python renderer being ported (napari/_vispy, layers, components)
~/git/napari-js             THIS repo: the standalone TS + WebGPU port, published to npm
~/git/sci-image-visualizer  Main consumer: wraps napari-js as its WebGPU IVisualizer backend

napari-js is built and published independently. Its main downstream consumer is @jax-data-science/sci-image-visualizer, the Angular visualization library behind JAX Image Tools, which registers napari-js as its WebGPU IVisualizer backend alongside OpenSeadragon and Plotly. See Used in production below; the original design of that seam is in docs/06-jit-ui-integration.md.

Used in production: sci-image-visualizer

napari-js is the WebGPU rendering backend of @jax-data-science/sci-image-visualizer (v0.2.2) — The Jackson Laboratory's Angular 17 library for interactive scientific image visualization, and the engine behind the JAX Image Tools viewer shown below.

sci-image-visualizer is a ports-and-adapters library: every renderer implements one IVisualizer contract, and a router (RoutingVisualizerService) picks one per plot type. napari-js sits alongside two other backends and is the production default for 3D:

What's rendered Backend
3D Volume, Isosurface, Surface napari-js (WebGPU) — production default, Plotly fallback
napari 2D image & scatter modes napari-js (WebGPU), with OpenSeadragon / Plotly fallback
Gigapixel whole-slide · other 2D plots OpenSeadragon / Plotly (napari-js 2D image opt-in via VizConfig.useNapariRenderer)
  • Dependency: a plain npm dependency — "napari-js": "^0.11.1" — bundled, not a peer dep.
  • Adapter: NapariVisualizerService (@Injectable, implements IVisualizer) constructs one Viewer, awaits viewer.ready, and dispatches by plot type.
  • DI wiring: provideVisualization() registers all three backends and binds the VISUALIZER token to the router.

How it's called

Condensed from NapariVisualizerService (the volume / isosurface path). One Viewer drives every 3D mode — the only thing that differs between the screenshots below is the rendering flag and how many channels are pushed in:

import { Viewer, MultiChannelVolumeView, tintColormap, type VolumeChannel } from 'napari-js';

// created once, when the host mounts the visualizer
const viewer = new Viewer({ canvas, background: { r: 0.07, g: 0.07, b: 0.09, a: 1 } });
await viewer.ready;

// Volume & Isosurface both go through a MultiChannelVolumeView:
//   · one additive, tinted channel per fluorescence channel → multi-channel volume
//   · a single colormapped channel                          → grayscale / CT volume
const view = new MultiChannelVolumeView(viewer);
const channels: VolumeChannel[] = state.channels.map((ch) => ({
  data: ch.volume, // Uint8Array, length width*height*depth, x-fastest
  width,
  height,
  depth,
  colormap: tintColormap(ch.color), // black → channel colour
  contrastLimits: [ch.min, ch.max],
  gamma: ch.gamma,
  visible: ch.visible,
}));

// rendering: 'mip' for the Volume mode, 'iso' for Isosurface
const rendering = isIsosurface ? 'iso' : 'mip';
view.render(channels.length > 1 ? 'multichannel' : 'grayscale', channels, { rendering });

// the toolbar's live "Iso" slider maps straight onto the volume layer's setters
// (updates a GPU uniform — no data re-upload):
volumeLayer.rendering = 'iso';
volumeLayer.contrastLimits = [isoMin, isoMax];
volumeLayer.isoThreshold = 0.5;

2D images use the sibling MultiChannelImageView (render('multichannel' | 'grayscale' | 'rgb', views, { interpolation })); the region-centroid scatter, 3D point cloud, height-field surface, and axes gizmo call viewer.addPoints, viewer.addPoints3D, heightField + viewer.addSurface, and viewer.addAxes directly.

In the JAX Image Tools viewer

Each shot is a different napari-js render mode, chosen from the toolbar's visualizer dropdown:

Colormapped 3D volume raymarch of a CT scan
Volume (napari · WebGPU) — MIP raymarch of a CT volume (ct-org volume-2.nii) through a scalar colormap.
Grayscale MIP volume of a DICOM series with a 3D axes gizmo
Volume + axes — grayscale MIP of a DICOM series with the 3D scale/axes gizmo (addAxes) and the Channels & Histogram controls.
Multi-channel fluorescence volume with per-channel LUTs
Multi-channel volume — four fluorescence channels composited additively on the GPU (one tinted VolumeChannel each), with live per-channel LUT / contrast / gamma.
Iso-surface extraction of a CT scan with a live threshold slider
Isosurface (napari · WebGPU) — rendering: 'iso' on the CT volume, threshold driven live by the toolbar's Iso slider.

Docs

Doc Contents
00 — Feasibility Why this is feasible: napari architecture findings, what ports cleanly, what's hard
01 — Architecture Engine module layout, render loop, design principles
02 — Public API The Viewer / Layer / Colormap / TextureSource API surface
03 — RenderState IR The serializable intermediate representation between model and GPU
04 — WGSL rendering plan Shader pipelines: image+colormap, multi-channel compositing, future raycasting
05 — Roadmap Milestones NJ-0 … NJ-5+
06 — jit-ui integration The IVisualizer adapter design — now shipped in sci-image-visualizer
07 — napari concept mapping How each napari concept maps to napari-js
08 — Landscape & related work Does a browser napari exist? CZI/roadmap WIP, Viv/vizarr/ndv, and how napari-js differs

Acknowledgments

napari-js is an independent TypeScript reimplementation of the visualization model of napari — the Python n-dimensional image viewer (github.com/napari/napari, BSD-3-Clause). The layer model, naming, and rendering semantics follow napari's; all credit for that design goes to the napari core developers and its community. This project is not affiliated with or endorsed by the napari project.

License

MIT — see LICENSE.

About

WebGPU rendering engine porting napari's visualization model to the browser.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages