Skip to content

A T.800 decoder in TypeScript

@azohra/meteo.j2k is a JPEG 2000 (ITU-T T.800) decoder in pure TypeScript, scoped to exactly the codestream subset ECCC’s GRIB2 feeds ship. It was written because the forecast engine’s hottest loop used to run through a non-SIMD WASM build of OpenJPEG — and, for 20-bit fields, through OpenJPEG.js, an asm.js-era artifact. Neither was explainable or patchable in this repository.

This decoder is justified by explainability alone: every marker, MQ context, and lifting step lives in ten small modules with its clause of T.800 named. On top of that sit two structural theses no whole-image codec can follow. EBCOT codeblocks are coded independently, so one field’s hundreds of codeblocks can decode in parallel across workers (src/parallel.ts is that seam). And the same independence runs the other way: decodeJ2kRegion (src/region.ts) decodes only the codeblocks a handful of requested gridpoints touch — bit-identical to the full decode at those points, ~16× faster per core on the largest ECCC field — which is exactly the shape a site-sampling forecast engine asks for.

This decoder is the production path: it is @azohra/meteo.grib/j2k-node’s default codec at every bit depth (the WASM build stays selectable for whole-image decodes; OpenJPEG.js is retired outright), that package’s sampled decode is this decoder’s region decode, and its worker pool fans full decodes’ codeblock tasks across threads. The receipts — the measured region-decode and per-core tables — are in Performance, honestly.

The workspace’s golden corpus carries real ECCC messages; the codestream is the GRIB section 7 payload (DRT 5.40). This decodes one and reads a sample:

// decode-fixture.mjs — run inside j2k/ after `pnpm build` (and a grib build)
import { readFileSync } from "node:fs";
import { parseFields, splitMessages } from "../grib/dist/index.js";
import { decodeJ2k } from "./dist/index.js";
const bytes = readFileSync("../grib/test/fixtures/geps-orog-m00.grib2");
const [field] = parseFields(splitMessages(bytes)[0]);
const codestream = field.section7.subarray(5); // DRT 5.40: raw J2K after the section header
const { values, width, height, bitsPerSample, isSigned } = decodeJ2k(codestream);
console.log(`${width}x${height} = ${values.length} samples, ${bitsPerSample}-bit ${isSigned ? "signed" : "unsigned"}`);
console.log(`first samples: ${Array.from(values.slice(0, 4)).join(", ")}`);
720x361 = 259920 samples, 12-bit unsigned
first samples: 1166, 1166, 1166, 1166

When only a few gridpoints matter — a forecast engine sampling sites — decodeJ2kRegion takes full-grid raster indexes and entropy-decodes only the codeblocks those points touch, then runs window-bounded inverse lifts. The values are bit-identical to decodeJ2k’s at those indexes — region decode is a cheaper route to the same integers, never an approximation — and the envelope is the package’s usual subset, guarded by the same loud errors:

import { decodeJ2kRegion } from "./dist/index.js";
const region = decodeJ2kRegion(codestream, [879425, 1439871]);
console.log(region.values); // === decodeJ2k(codestream).values at those indexes
console.log(`${region.codeblocksDecoded}/${region.codeblocksTotal} codeblocks decoded`);

On the largest ECCC field a 4-point region decode touches 49 of 911 codeblocks and runs ~16× faster than the full decode on one core; the measured table is in Performance, honestly, and the exactness gate in Two-ring correctness.

decodeJ2k returns raw integer samples shaped exactly like @azohra/meteo.grib’s J2kSamples, so it drops straight into decodeFieldValuesDecodeJ2k injection seam — no adapter:

const { values } = decodeFieldValues(field, { decodeJ2k });

In @azohra/meteo.grib’s Node path this wiring already exists — createNodeJ2kDecoder() and the worker pool default to this decoder; see JPEG 2000 and the pool.

PageCovers
The subsetThe measured codestream shape, the loud-failure guards, the JasPer extension
Two-ring correctnessThe cross-codec oracle ring, the end-to-end ecCodes ring, region decode’s exactness contract
Performance, honestlyThe measured region-decode and single-thread tables, the Tier-1 profile, the codeblock-parallel thesis

Written against named references, per house convention, vendoring nothing:

  • ITU-T T.800 — the spec; Annex B (packets, cited in packets.ts), Annex C (MQ coder, mq.ts), Annex D (coefficient bit modelling, t1.ts), Annex F (the reversible 5/3 inverse, dwt.ts).
  • OpenJPEG (BSD-2, © Université catholique de Louvain) — the behavioural reference: pass gating and midpoint arithmetic (t1.c), lifting order and edge cases (dwt.c), tag trees (tgt.c), header reading order (t2.c). Also the oracle, through the two codec packages @azohra/meteo.grib/j2k-node wraps.
  • pdf.js’s jpx.js and ArithmeticDecoder (Apache-2.0, Mozilla) — the pure-JavaScript cross-reference for MQ register conventions and Tier-1 neighbourhood bookkeeping, proven on MSC data via grib2class’s lineage.
src/codestream.ts marker walk and subset guards (SIZ/COD/QCD/SOT/SOD/EOC)
src/packets.ts Tier-2: geometry, tag-tree queries, packet headers
src/tagtree.ts the B.10.2 tag trees
src/mq.ts the Annex C MQ arithmetic decoder
src/t1.ts EBCOT Tier-1: the three passes, 19 contexts, sign coding
src/dwt.ts inverse reversible 5/3 lifting over the level ladder
src/image.ts assembly: T1 → DWT → DC shift/clamp → samples
src/parallel.ts the per-codeblock decode plan a worker pool fans out
src/region.ts region decode: exact samples at requested points only
src/errors.ts UnsupportedJ2kError and J2kFormatError, the loud-failure vocabulary
test/ header parse + guards, parallel-plan equality, the JasPer shape,
the region-decode exactness sweep over the whole corpus
(grib-free; the two-ring golden gate is grib/test/j2k-golden.test.ts)
tools/bench.ts single-thread decodeJ2k timing over the corpus

Zero runtime dependencies, and no Node APIs in src/ — the package is browser-safe by construction.