Alpha application platform

Browser-native scientific image analysis, without a mandatory conversion step.

Build local, reproducible tools for materials microscopy and instrument imagery on original vendor files and large multidimensional datasets. The platform keeps quantitative samples separate from display pixels and preserves bounded reads from source through analysis.

Application APIs: alphaProvider and extension APIs: experimentalCodec pipeline: established npm path
Introduced in 0.10.0. These application entrypoints are alpha and provider and extension APIs remain experimental. Use the ordinary codec pipeline for established image workflows.
See the platform in an application. PureJsImage Lab is the first application built on these APIs: an in-progress, browser-native electron microscopy file analysis workbench.

Application architecture

The scientific platform layers above portable readers and sources without pulling application code back into codecs. The ordinary image pipeline stays a separate path through the same source boundary.

PureJsImage application-platform dependency architectureApplications use either the ordinary image pipeline and codecs or the scientific analysis stack. Analysis depends on operations, providers, native numeric tiles, portable scientific datasets and RasterBlocks, scientific readers, and finally ImageSource. Trusted extensions explicitly compose readers, descriptors, and providers. Dependencies never point back from sources or readers into application code.Application layerUI · scripts · plugins · future agentsOrdinary image pipelineImmutable resize().jpeg() pathImage codecsExplicit codec entriesScientific application APIsGraphs · ROIs · results · tile runtimeOperationsJSON-safe descriptorsProvidersExplicit executable codeNative NumericTileTyped repeated computationScientificDatasetDescriptors · RasterBlock readsScientific readersExplicit local registrationTrusted extension bundlesExplicit local compositionReaders · descriptors · providersImageSource and platform adaptersMemory · File/Blob · path · HTTP Range
Dependencies flow downward toward readers and sources. The ordinary image pipeline remains independent, and no codec or reader imports application, graph, ROI, or extension code.

One labeled-axis data model

ScientificDataset describes arbitrary named axes, sample types, components, resolution levels, physical coordinates, and bounded region reads. Native one-dimensional spectra and profiles retain one true axis and advertise bounded readSeries() support rather than inventing a singleton display dimension; raster datasets continue to advertise their exact ordered readPlane() pairs. First-party GSF, ENVI, FITS, MRC, CBF, OME-TIFF, and Aperio SVS readers expose that model directly. Aperio documents keep the calibrated pyramid and label, macro, thumbnail, or other associated images as distinct datasets. An application chooses a dataset from a document rather than assuming one fixed z/c/t layout.

Local browser File resources, Node file adapters, in-memory bytes, and HTTP Range-backed ImageSource implementations meet at the same portable reader boundary. Source identity starts with available file or remote metadata; displaying the first tile does not require hashing a multi-gigabyte file.

createAperioSvsReader({ limits }) configures explicit source-size, dimension, directory, requested-region, decoded-byte, and associated-image ceilings. Large reported SVS sizes remain lazy while every actual decode is admitted before allocation. Slide enumeration records only ICC presence, tag, and byte length; it does not download or base64-encode profile payloads merely to build descriptors.

Bounded numeric tiles and explicit lifetimes

Readers yield portable RasterBlock bytes. Repeated quantitative work converts them once to native-endian typed NumericTile values. The tile runtime adds byte budgets, bounded concurrency, request coalescing, semantic source and derived cache identities, measurable cache behavior, cancellation, and release propagation without making full-frame materialization the generic fallback.

Lifecycle rule. Release every numeric tile, execution result, and prepared plan; dispose the tile runtime; close the scientific document; and pass an AbortSignal through reads, planning, and execution. Reducer scratch uses lexical scopes released internally by the runtime. Intermediate lazy datasets captured by later lazy outputs remain owned and accounted until the execution result is released.

ROIs, results, and reproducible graphs

Versioned pixel or physical-coordinate ROIs bind to graph inputs. Initial bounded operations cover resolution-level selection, crop, resampling, thresholding, Gaussian blur, projection, statistics, histograms, line profiles, and deterministic connected components with per-object measurements. Results remain typed and bounded rather than becoming an implicit display image.

The analysis entry also exposes JSON-safe plans over bounded numeric tiles for parsed band math, normalized difference, linear combinations, raster subtraction, unit-aware slope/aspect/hillshade, regional statistics, line profiles, and explicit target-grid resampling. Cross-CRS execution requires a matching caller-supplied inverse transform with recorded identity and exact or estimated accuracy; the library never invents or silently labels a transform.

Canonical graph JSON, invocation identities, explicit provider policy, implementation versions, and project hashes make mutation, planning, and execution independently auditable. Dry-run validates the graph and estimates setup, transfer, compute, readback, and memory costs before execution begins.

Threshold and Gaussian blur now materialize lazy output tiles through the exact provider selected during planning. Crop and slice remain coordinate views; resample and projection are the next neighborhood/reducer convergence work; statistics, histogram, and line profile remain result reducers.

Connected components is the first globally prepared transform. Graph execution scans the selected plane in a fixed row-major tile order, reconciles component boundaries under checked scan/finalization memory bounds, then returns a lazy uint32 label dataset plus a bounded columnar object table. It does not retain a full mask or label plane; lazy label reads recompute only requested source tiles. Labels, ordering, counts, and integer bounds are exact; floating shape and calibrated columns are tolerance-based. The same execution characteristic can host a future bounded watershed implementation, but watershed is not included.

Trusted extensions today; accelerator choices by measured fit

purejsimage/extensions composes explicitly supplied readers, value types, operations, providers, and migrations into application-local registries. Contributed IDs must remain under the extension's declared ID namespace. Nothing auto-registers globally.

Extensions execute trusted code in the caller's process and are not a sandbox. A Worker or iframe RPC host for untrusted packages is future work. The strict TypeScript reference provider remains permanent; explicitly registered WASM and WebGPU providers are future options and will be selected only when their exact semantics and measured setup, transfer, compute, readback, and memory costs fit the plan.

A complete, compiled application lifecycle

This example uses only package entry points and is compiled and tested against the exact packed package. It opens a document, selects and identifies a dataset, reads and releases a tile, creates an ROI and graph, dry-runs and executes it, serializes a hashed project, and releases every owned resource. The source below is imported directly from the repository's compiled external example, so the website copy cannot drift independently.

examples/scientific-application-platform/index.ts
import { MemorySource } from 'purejsimage'
import {
  analysisConnectedComponentsOperationId,
  analysisSelectResolutionLevelOperationId,
  analysisStatisticsOperationId,
  analysisThresholdOperationId,
  computeAnalysisProjectHashes,
  createAnalysisController,
  createBuiltInAnalysisBundle,
  scientificDatasetCharacteristics,
  scientificDatasetValueTypeId,
  type AnalysisExecutionResult,
  type AnalysisGraph,
  type AnalysisProjectV1,
  type AnalysisResultSummary,
  type PreparedAnalysisPlan,
} from 'purejsimage/analysis'
import { createTileRuntime } from 'purejsimage/analysis/runtime'
import { summarizeResult, validateAnalysisResult } from 'purejsimage/analysis/results'
import {
  canonicalNormalizedRoiSemanticsJson,
  normalizeRoi,
  roiValueTypeId,
} from 'purejsimage/analysis/roi'
import { hashCanonicalJson } from 'purejsimage/analysis/project'
import {
  createScientificLibrary,
  getScientificDatasetIdentity,
  resolveNumericTileSource,
} from 'purejsimage/scientific'
import { encodeGsf, gsfReader } from 'purejsimage/scientific/readers/gsf'
import { aperioSvsReader } from 'purejsimage/scientific/readers/aperio-svs'

export interface ApplicationPlatformExampleResult {
  readonly projectJson: string
  readonly result: AnalysisResultSummary
}

/** Public-package-only WSI to deterministic object-table pipeline. */
export const runWholeSlideConnectedComponentsExample = async (
  svsBytes: Uint8Array,
  signal?: AbortSignal,
): Promise<AnalysisResultSummary> => {
  const science = createScientificLibrary({ readers: [aperioSvsReader] })
  const document = await science.open({
    primary: { id: 'slide-file', name: 'slide.svs', source: new MemorySource(svsBytes) },
    readerId: aperioSvsReader.descriptor.id,
    ...(signal === undefined ? {} : { signal }),
  })
  const dataset = await document.openDataset('pyramid', signal === undefined ? {} : { signal })
  const identity = getScientificDatasetIdentity(dataset)
  if (identity === undefined) throw new Error('The opened WSI dataset has no source identity')
  const runtime = createTileRuntime({
    limits: {
      maxTotalManagedBytes: 128 * 1_024 * 1_024,
      maxOperationWorkingBytes: 64 * 1_024 * 1_024,
      maxConcurrency: 2,
    },
  })
  const bundle = createBuiltInAnalysisBundle({ descriptor: dataset.descriptor, runtime })
  const controller = createAnalysisController({
    ...bundle,
    library: { version: '0.9.0', buildFingerprint: 'wsi-connected-components-example-v1' },
  })
  const graph: AnalysisGraph = Object.freeze({
    schemaVersion: 1,
    inputs: Object.freeze([
      Object.freeze({
        name: 'source',
        valueType: { id: scientificDatasetValueTypeId, version: 1 },
      }),
    ]),
    nodes: Object.freeze([
      Object.freeze({
        id: 'level',
        operation: { id: analysisSelectResolutionLevelOperationId, version: 1 },
        inputs: Object.freeze([
          Object.freeze({ port: 'dataset', source: { kind: 'input' as const, input: 'source' } }),
        ]),
        parameters: Object.freeze({ level: 0 }),
      }),
      Object.freeze({
        id: 'threshold',
        operation: { id: analysisThresholdOperationId, version: 1 },
        inputs: Object.freeze([
          Object.freeze({
            port: 'dataset',
            source: { kind: 'node' as const, nodeId: 'level', output: 'dataset' },
          }),
        ]),
        parameters: Object.freeze({ mode: 'less-than', component: 0, threshold: 220 }),
      }),
      Object.freeze({
        id: 'objects',
        operation: { id: analysisConnectedComponentsOperationId, version: 1 },
        inputs: Object.freeze([
          Object.freeze({
            port: 'dataset',
            source: { kind: 'node' as const, nodeId: 'threshold', output: 'dataset' },
          }),
        ]),
        parameters: Object.freeze({
          displayAxes: ['x', 'y'],
          fixedIndices: [],
          component: 0,
          connectivity: 8,
        }),
      }),
    ]),
    outputs: Object.freeze([
      Object.freeze({
        name: 'objects',
        source: { kind: 'node' as const, nodeId: 'objects', output: 'objects' },
      }),
    ]),
  })
  const options = {
    bindings: {
      source: {
        value: dataset,
        identity,
        characteristics: scientificDatasetCharacteristics(dataset),
      },
    },
    policy: {
      mode: 'pinned' as const,
      providerId: 'purejsimage.analysis.reference',
      providerVersion: 1,
    },
    ...(signal === undefined ? {} : { signal }),
  }
  let plan: PreparedAnalysisPlan | undefined
  let execution: AnalysisExecutionResult | undefined
  try {
    const dryRun = await controller.dryRun(graph, options)
    if (!dryRun.valid) throw new Error(`WSI graph dry-run failed: ${JSON.stringify(dryRun.issues)}`)
    plan = await controller.planGraph(graph, options)
    execution = await controller.executeGraph(plan, signal === undefined ? {} : { signal }).result
    return summarizeResult(validateAnalysisResult(execution.outputs.get('objects')), {
      maxPreviewValues: 8,
    })
  } finally {
    try {
      if (execution !== undefined) await execution.release()
    } finally {
      try {
        if (plan !== undefined) await plan.dispose()
      } finally {
        try {
          await runtime.dispose()
        } finally {
          await document.close?.()
        }
      }
    }
  }
}

/**
 * Complete bounded application-platform lifecycle using only installed package exports.
 * Replace the in-memory GSF resource with a browser File or HTTP Range-backed ImageSource.
 */
export const runApplicationPlatformExample = async (
  signal?: AbortSignal,
): Promise<ApplicationPlatformExampleResult> => {
  const source = new MemorySource(
    encodeGsf({
      width: 4,
      height: 4,
      values: new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]),
    }),
  )
  const science = createScientificLibrary({ readers: [gsfReader] })
  const document = await science.open({
    primary: { id: 'surface-file', name: 'surface.gsf', source },
    ...(signal === undefined ? {} : { signal }),
  })
  const runtime = createTileRuntime({
    limits: { maxTotalManagedBytes: 8 * 1_024 * 1_024, maxConcurrency: 2 },
  })
  let plan: PreparedAnalysisPlan | undefined
  let execution: AnalysisExecutionResult | undefined

  try {
    const selected = document.datasets[0]
    if (selected === undefined) throw new Error('The document contains no scientific dataset')
    const dataset = await document.openDataset(selected.id, signal === undefined ? {} : { signal })
    const datasetIdentity = getScientificDatasetIdentity(dataset)
    if (datasetIdentity === undefined) throw new Error('The opened dataset has no source identity')
    const fixedIndices = Object.freeze([])

    // NumericTile storage is native-endian and must be released by its consumer.
    for await (const tile of resolveNumericTileSource(dataset).readNumericTiles({
      displayAxes: ['x', 'y'],
      fixedIndices,
      resolutionLevel: 0,
      x: 0,
      y: 0,
      width: 2,
      height: 2,
      ...(signal === undefined ? {} : { signal }),
    })) {
      tile.release()
    }

    const roi = normalizeRoi(
      {
        schemaVersion: 1,
        id: 'center',
        axisIds: ['x', 'y'],
        fixedIndices,
        coordinateSpace: 'pixel',
        geometry: { kind: 'rectangle', x: 1, y: 1, width: 2, height: 2 },
      },
      dataset.descriptor,
    )
    const graph: AnalysisGraph = Object.freeze({
      schemaVersion: 1,
      inputs: Object.freeze([
        Object.freeze({
          name: 'source',
          valueType: { id: scientificDatasetValueTypeId, version: 1 },
        }),
        Object.freeze({ name: 'selection', valueType: { id: roiValueTypeId, version: 1 } }),
      ]),
      nodes: Object.freeze([
        Object.freeze({
          id: 'statistics',
          operation: { id: analysisStatisticsOperationId, version: 1 },
          inputs: Object.freeze([
            Object.freeze({
              port: 'dataset',
              source: { kind: 'input' as const, input: 'source' },
            }),
            Object.freeze({
              port: 'roi',
              source: { kind: 'input' as const, input: 'selection' },
            }),
          ]),
          parameters: Object.freeze({
            displayAxes: ['x', 'y'],
            fixedIndices,
            component: 0,
            percentiles: [50],
            percentileMaxSamples: 1_024,
            emptyPolicy: 'error',
          }),
        }),
      ]),
      outputs: Object.freeze([
        Object.freeze({
          name: 'statistics',
          source: { kind: 'node' as const, nodeId: 'statistics', output: 'statistics' },
        }),
      ]),
    })

    const bundle = createBuiltInAnalysisBundle({ descriptor: dataset.descriptor, runtime })
    const controller = createAnalysisController({
      ...bundle,
      roi: { descriptor: dataset.descriptor },
      library: { version: '0.9.0', buildFingerprint: 'application-platform-example-v1' },
    })
    const roiDomain = 'purejsimage.roi-semantics.v1'
    const roiIdentity = Object.freeze({
      kind: 'semantic-json' as const,
      domain: roiDomain,
      sha256: await hashCanonicalJson(roiDomain, canonicalNormalizedRoiSemanticsJson(roi)),
    })
    const bindings = Object.freeze({
      source: Object.freeze({
        value: dataset,
        identity: datasetIdentity,
        characteristics: scientificDatasetCharacteristics(dataset),
      }),
      selection: Object.freeze({ value: roi, identity: roiIdentity }),
    })
    const providerPolicy = Object.freeze({
      mode: 'pinned' as const,
      providerId: 'purejsimage.analysis.reference',
      providerVersion: 1,
    })
    const executionOptions = {
      bindings,
      policy: providerPolicy,
      ...(signal === undefined ? {} : { signal }),
    }
    const dryRun = await controller.dryRun(graph, executionOptions)
    if (!dryRun.valid) throw new Error(`Graph dry-run failed: ${JSON.stringify(dryRun.issues)}`)

    plan = await controller.planGraph(graph, executionOptions)
    execution = await controller.executeGraph(plan, signal === undefined ? {} : { signal }).result
    const output = execution.outputs.get('statistics')
    const result = summarizeResult(validateAnalysisResult(output))

    const projectWithoutHashes = Object.freeze({
      schemaVersion: 1 as const,
      graph,
      roiSet: Object.freeze({ schemaVersion: 1 as const, rois: Object.freeze([roi]) }),
      bindings: Object.freeze([
        Object.freeze({
          input: 'source',
          valueType: { id: scientificDatasetValueTypeId, version: 1 },
          identity: datasetIdentity,
          value: { kind: 'source' as const, sourceReference: 'surface-file' },
        }),
        Object.freeze({
          input: 'selection',
          valueType: { id: roiValueTypeId, version: 1 },
          identity: roiIdentity,
          value: { kind: 'roi' as const, roiId: roi.id },
        }),
      ]),
      sourceReferences: Object.freeze([
        Object.freeze({ id: 'surface-file', identity: datasetIdentity }),
      ]),
      providerPolicy,
      createdWith: Object.freeze({
        packageVersion: '0.9.0',
        buildFingerprint: 'application-platform-example-v1',
      }),
    })
    const project: AnalysisProjectV1 = Object.freeze({
      ...projectWithoutHashes,
      hashes: await computeAnalysisProjectHashes(projectWithoutHashes),
    })
    return Object.freeze({ projectJson: JSON.stringify(project), result })
  } finally {
    try {
      if (execution !== undefined) await execution.release()
    } finally {
      try {
        if (plan !== undefined) await plan.dispose()
      } finally {
        try {
          await runtime.dispose()
        } finally {
          await document.close?.()
        }
      }
    }
  }
}

Alpha scope and honest limitations

The application platform is alpha in PureJsImage 0.10.0. Provider and extension APIs are experimental and may change in later releases through explicit versioning. The ordinary codec workflow, including immutable resize().jpeg() pipelines, continues as the established npm package path and is not replaced by these entries.

The current focus is native, range-backed analysis for AFM, detector, diffraction, hyperspectral, and volumetric data. PureJsImage does not yet claim mature SEM/TEM workflows, DM3/DM4, EDS overlays, FFT tooling, mature particle or grain workflows, broad segmentation, watershed, registration, morphology, 3D mesh editing, or a mature plugin system. Quantitative uint64 analysis above 2^53 - 1 is also not yet exact in every operation and result path.