API reference
Handwritten from source (packages/denoiser/src/{types,denoiser,index}.ts).
Every default below is verified against the shipped implementation, not the
type comments alone.
import { Denoiser } from 'denoiser';
Denoiser
Construction is async-only — the constructor is private. Denoiser.create()
loads the default (color-only) model and creates the shared GPUDevice
before resolving, so denoiser.device is available immediately after
await. The default runtime is AutoRuntime: WebNN
fp16 for base/large models when WebNN is available, the hand-written WGSL
runtime otherwise. It never loads onnxruntime-web.
Denoiser.create(options?)
static async create(options?: DenoiserCreateOptions): Promise<Denoiser>
DenoiserCreateOptions
| option | type | default | meaning |
|---|---|---|---|
precision | 'fp32' | 'fp16' | 'fp32' | fp16 models + tensors + WGSL IO when the device's shader-f16 feature is available. Denoiser.create auto-falls-back to fp32 (with a console warning) if session creation fails under fp16 |
quality | Quality ('fast' | 'balanced' | 'high') | 'fast' | model family: fast = *_small, balanced = base, high = *_large where that size exists for the variant. Mutable after creation via denoiser.quality |
tzaUrl | string | jsDelivr: https://cdn.jsdelivr.net/gh/pmndrs/denoiser-weights@models-v3/tzas | base URL the OIDN .tza weight files are fetched from by the default runtime. Ignored when runtime is passed |
device | GPUDevice | a max-limits device the runtime creates | run the default runtime on this existing device, for example a three.js renderer's (renderer.backend.device). Ignored when runtime is passed. See The GPUDevice |
maxRunPixels | number | 2048 * 1152 (2 359 296) | per-run pixel budget (padded W×H); above it the engine tiles instead of running whole-frame. See Large images & tiling |
batch | number | 8 | max tiles batched into one session.run in tiled mode |
runtime | NetworkRuntime | AutoRuntime (built from tzaUrl and device) | what executes the network — see Network runtimes and Choosing a runtime. When passed, tzaUrl / device are ignored (configure the runtime itself). The ORT-only options (weightsUrl, wasmPaths, graphCapture, splitAux) are no longer Denoiser.create options; they belong to new OrtRuntime({...}) from denoiser/ort |
Properties
| member | type | notes |
|---|---|---|
device | GPUDevice | the shared device: the device option if you passed one, otherwise one the runtime created. Share it with your renderer — build your own pipelines on it, don't create your own device. See Custom engines |
stats | DenoiseStats | undefined (getter) | per-stage wall-clock timings from the most recently completed run; undefined before the first call. See DenoiseStats below |
modelName | string | undefined (getter) | the model file the last call actually ran (e.g. rt_hdr_calb_cnrm); undefined before the first call. Changes automatically when different inputs (hdr/aux) select a different variant |
quality | Quality (getter/setter) | current quality tier; assign to switch — the next call rebuilds the engine for the new model if needed |
denoise(color, options?)
async denoise(color: ImgInput, options?: DenoiseImageOptions): Promise<ImageData | undefined>
Denoises an image-like input on the CPU-array path. Returns undefined only
if abort() was called before the run resolved.
DenoiseImageOptions
| option | type | default | meaning |
|---|---|---|---|
albedo | ImgInput | – | aux plane; bytes read as [0,1] linear (never sRGB-decoded — only color honors srgb). Must match color's size or throws DenoiserInputError |
normal | ImgInput | – | aux plane; RGBA8 bytes already encoding [-1,1] as [0,1] (n*0.5+0.5); env/miss pixels should be 0 (→ encodes to mid-gray 0.5). Must match color's size |
srgb | boolean | true | color input is sRGB-encoded (photos/screens) — decoded to linear for the model, re-encoded on the way out. Set false if your bytes are already linear |
flipY | boolean | false | flip the result vertically |
onProgress | (p: number) => void | – | see progress semantics |
denoiseToFloat(color, options?)
async denoiseToFloat(color: ImgInput, options?: DenoiseImageOptions): Promise<Float32Array | undefined>
Same as denoise(), but returns normalized [0,1] RGBA floats (ImageData
bytes ÷ 255) instead of ImageData. Same options, same undefined-on-abort
behavior. Internally calls denoise(), so it pays the same 8-bit
quantization — use denoiseTextures if you need real float/HDR fidelity.
denoiseTextures(options)
async denoiseTextures(options: DenoiseTexturesOptions): Promise<GPUTexture | undefined>
Zero-copy: float GPUTextures in, GPUTexture out, no CPU round-trip.
Returns undefined only if abort() was called before the run resolved.
DenoiseTexturesOptions
| option | type | default | meaning |
|---|---|---|---|
color | GPUTexture | required | noisy beauty input — float format (rgba16float/rgba32float), always treated as linear (no sRGB decode on the texture path) |
albedo | GPUTexture | – | aux plane, [0,1] floats |
normal | GPUTexture | – | aux plane, [-1,1] floats — the network's [0,1] encoding is applied internally |
hdr | boolean | false | apply OIDN's PU transfer + autoexposure around the model |
inputScale | number | auto (autoexposure) | manual HDR exposure scale; set to skip the GPU autoexposure pass |
inputFlipY | boolean | false | color rows are bottom-up (compute-written WebGPU targets) |
auxInputFlipY | boolean | = inputFlipY | independent flip for albedo/normal when their convention differs from color |
output | GPUTexture | – | caller-owned destination. Must match color's width/height, have STORAGE_BINDING usage, and be rgba8unorm or rgba16float — else throws. Omit to get an engine-owned texture back instead |
transfer | OutputTransfer ('linear' | 'srgb' | 'aces-srgb') | 'linear' | output encoding: 'linear' = raw model output (HDR-safe), 'srgb' = sRGB-encode, 'aces-srgb' = ACES tonemap + sRGB-encode (display-ready) |
outputFlipY | boolean | false | flip the result vertically |
onProgress | (p: number) => void | – | see progress semantics |
Returns your output texture if one was given; otherwise an engine-owned
rgba8unorm texture valid only until the next call / a size change /
dispose()/destroyDevice() — copy or sample it before calling again.
abort()
abort(): void
Marks the in-flight run so it resolves undefined instead of a result. Does
not cancel GPU work already submitted — the work still completes, its
result is just dropped. Typical use: drop a stale in-progress denoise when
the camera/scene changed mid-run.
dispose()
dispose(): void
Frees per-image GPU buffers and all but the default (first-created) model session/geometry. Keeps the shared device alive. Safe to call between unrelated workloads.
destroyDevice()
destroyDevice(): void
Full teardown: releases every session, including the last one — which
destroys the shared GPUDevice. Anything else built on denoiser.device
(a three.js renderer, canvas contexts, your own pipelines) dies with it. Only
call this when the entire WebGPU stack is going away.
on(event, callback)
on(event: DenoiserEvent, callback: (value: unknown) => void): () => void
DenoiserEvent is 'progress' | 'executed'. Returns an unsubscribe
function.
'progress'— same callback signature/semantics as the per-callonProgressoption, fired for every call (not just ones that passed their ownonProgress). See progress semantics.'executed'— fired once per completed call (including aborted ones) with the call's result:ImageData | Float32Array | GPUTexture | undefineddepending on which method ran.
Progress semantics
onProgress (and the 'progress' event) receive a 0–1 fraction. In
tiled mode it's called once per batch of tiles as they complete
(incremental progress). In whole-frame mode there is exactly one
implicit batch, so it's called exactly once, near the end of the run, with
1 — not incrementally. Don't rely on it for a smooth progress bar on
small/whole-frame images; it's meaningful for large tiled runs.
DenoiseStats
Returned by denoiser.stats — per-stage wall-clock timings (ms) from the
most recently completed run:
| field | type | meaning |
|---|---|---|
width, height | number | the run's image size |
tiles | number | total tile count (1 for whole-frame) |
batches | number | number of session.run calls |
tileW, tileH | number | the chosen tile geometry |
batchSize | number | tiles per batch actually used |
uploadMs | number | input upload + accumulate/weight buffer clear |
encodeMs | number | WGSL extract/accumulate encode + submit (CPU-side) |
runMs | number | sum of awaited network-run time (NetworkBinding.run()). Runtimes submit GPU work asynchronously (ORT and HF kernels both return in ~1 ms), so this is mostly CPU encode/submit time; the network's GPU time lands in resolveMs, where the readback waits for the queue. Use totalMs to compare runtimes |
resolveMs | number | resolve pass + readback/map |
totalMs | number | wall clock for the whole call |
Errors
Both extend Error with name set to match, so err.name or instanceof
both work.
| class | thrown when |
|---|---|
DenoiserUnsupportedError | WebGPU/device-capability problems: no adapter available (Denoiser.create() with no WebGPU support), or precision:'fp16' requested on a device without shader-f16 (only surfaces directly from the lower-level DenoiseEngine.create() — Denoiser.create() catches this case and falls back to fp32 instead) |
DenoiserInputError | bad/inconsistent inputs: albedo/normal size mismatch vs color, a raw {data,width,height} input whose byte length isn't width*height*4, or a model that requires albedo/normal but didn't get one |
An outputTexture/output with the wrong size, missing STORAGE_BINDING,
or an unsupported format throws a plain Error, not
DenoiserInputError — an inconsistency in the current source (worth
catching generically, or catching Error broadly, if you specifically
handle DenoiserInputError for input validation).
See Troubleshooting for the exact wording of each thrown/warned message and how to fix the underlying cause.
Types
| type | shape |
|---|---|
Quality | 'fast' | 'balanced' | 'high' |
ImgInput | ImageData | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | ImageBitmap | OffscreenCanvas | { data: Uint8ClampedArray; width: number; height: number } |
OutputTransfer | 'linear' | 'srgb' | 'aces-srgb' |
DenoiserEvent | 'progress' | 'executed' |
Advanced / lower-level exports
These are exported from the package (import { ... } from 'denoiser/ort', no longer from the root entry) but
sit below Denoiser — most integrations never need them directly. Documented
here for completeness; treat their shapes as more likely to change than the
Denoiser surface above.
Models
From denoiser/ort (no longer exported from the root denoiser entry; needs the optional onnxruntime-web peer dependency). Singleton .onnx model loader/cache the ORT runtime uses internally.
| member | type | meaning |
|---|---|---|
Models.getInstance() | static (): Models | the singleton instance |
path | string | undefined | subdirectory under the site root, used only when url is unset |
url | string | remote base URL for models (default: the jsDelivr models-v2 URL — same default as OrtRuntimeOptions.weightsUrl) |
precision | 'fp32' | 'fp16' | default 'fp32'; OrtRuntime sets it from the precision of each model it loads |
get(name, overrideUrl?) | (string, string?) => Promise<Uint8Array> | fetch (and cache) a model's raw bytes |
has(name) | (string) => boolean | whether a model is already cached for the current precision |
DenoiseEngine
From denoiser/ort. The WebGPU/ONNX inference core of the ORT runtime (session management,
tiling/geometry planning, WGSL pre/post). Denoiser covers everything most
integrations need — reach for DenoiseEngine directly only if you're
bypassing the high-level model-selection logic.
| member | type | meaning |
|---|---|---|
DenoiseEngine.create(modelBytes, options) | static (Uint8Array, EngineOptions) => Promise<DenoiseEngine> | builds a session for one specific model file. precision:'fp16' without shader-f16 throws DenoiserUnsupportedError here (no auto-fallback — that lives in Denoiser) |
device | GPUDevice | the device this engine (and thus Denoiser) runs on |
channels | number | 3 | 6 | 9, must match the loaded model |
precision | 'fp32' | 'fp16' (readonly) | |
maxRunPixels | number | mutable after creation |
batch | number | max tiles per run in tiled mode |
lastStats | DenoiseStats | undefined | same shape as Denoiser.stats |
graphCaptured | boolean | whether this engine's sessions run with graph capture |
planFor(w, h) | (number, number) => Plan | the whole-frame-vs-tiled geometry the engine would choose for a size |
denoise(color, w, h, options?) | (Uint8ClampedArray, number, number, DenoiseOptions?) => Promise<Uint8ClampedArray> | CPU-array path |
denoiseTextures(inputs, options?) | (TextureInputs, TextureDenoiseOptions?) => Promise<Uint8ClampedArray | GPUTexture> | texture path |
tileGrid(w, h) | (number, number) => { tilesX: number; tilesY: number } | tile grid for a size at the current settings |
trim() | () => void | what Denoiser.dispose() calls |
destroy() | () => void | what Denoiser.destroyDevice() calls |
TextureInputs ({ color: GPUTexture; albedo?: GPUTexture; normal?: GPUTexture })
and TextureDenoiseOptions (DenoiseOptions + inputFlipY? + auxInputFlipY?
toTexture?+outputTexture?) are the lower-level shapesDenoiseTexturesOptionsmaps onto.
Network runtimes
Denoiser and TiledEngine do all tiling, transfer functions and WGSL
pre/post themselves; a NetworkRuntime only executes the U-Net. The
default is AutoRuntime (WebNN fp16 for base/large models when available,
hand-written WGSL otherwise; never ORT). Other runtimes (ORT, HF kernels,
WebNN, WGSL) plug in through the same interface:
import { Denoiser } from 'denoiser';
import { OrtRuntime } from 'denoiser/ort'; // needs `npm i onnxruntime-web`
const denoiser = await Denoiser.create({
runtime: new OrtRuntime({ weightsUrl: '/models' }), // or any NetworkRuntime
});
| type | role |
|---|---|
NetworkRuntime | load(model: NetworkModel, hints?) => Promise<NetworkSession> — fetches and opens a model by OIDN name ({ name, channels, precision }). The first load may create the GPUDevice |
NetworkSession | device (the engine runs its WGSL here too), optional fixedGeometry, bind(geometry) => Promise<NetworkBinding>, release() |
NetworkBinding | input / output GPUBuffers (NCHW [B, C, H, W] / [B, 3, H, W] in model precision) and run(): the engine writes input, awaits run(), reads output |
TiledEngine.load(runtime, model, options?) | the runtime-agnostic engine Denoiser uses (same members as DenoiseEngine below, minus the ORT-specific ones) |
OrtRuntime / OrtSession | the onnxruntime-web implementation, from denoiser/ort; OrtRuntimeOptions = { weightsUrl?, wasmPaths?, graphCapture?, splitAux? } |
Entry points (one package, shared chunks, a single copy of the core):
| import | contents |
|---|---|
denoiser | Denoiser defaulting to AutoRuntime (options tzaUrl, device), plus everything in denoiser/core and AutoRuntime. Never loads onnxruntime-web |
denoiser/core | runtime-agnostic Denoiser (requires runtime), TiledEngine, the runtime and temporal types, parseTZA / halfToFloat / DEFAULT_TZA_URL |
denoiser/ort | OrtRuntime, OrtSession, Models, DenoiseEngine. Needs the optional peer dependency onnxruntime-web. Wrong on the *_alb, *_alb_nrm and *_large models, see Choosing a runtime |
denoiser/kernels | experimental KernelsRuntime on @huggingface/kernels (optional peer dependency, install it to use this entry) |
denoiser/wgsl | WgslRuntime: the U-Net in hand-written WGSL, no dependencies, no flags, correct on every model (the AutoRuntime fallback). Tuned and validated on Apple GPUs |
denoiser/auto | AutoRuntime (also exported from denoiser): picks WebNN (fp16 base/large, when available) or WGSL per model, on one shared GPUDevice. See Choosing a runtime |
denoiser/webnn | experimental WebnnRuntime: the U-Net via MLGraphBuilder (WebNN). Needs Chrome with --enable-features=WebMachineLearningNeuralNetwork |
denoiser/ffx | FfxShadowDenoiser and FfxReflectionDenoiser, the real-time TemporalDenoisers (WGSL port of AMD FidelityFX Denoiser v1.1.4). No dependencies |
denoiser/three | three.js (WebGPURenderer) helpers and TSL nodes: getDevice, getGPUTexture, CameraHistory, createDenoiserForRenderer, DenoiseNode, TemporalDenoiseNode and more. three >= r185 is an optional peer dependency. See three.js & TSL |
The classes from denoiser and denoiser/core are the same. The interface is
new in this release and may change, so pin your version if you implement it. See
Choosing a runtime for measured speed and quality.
Runtime options
All runtimes except ORT read the upstream OIDN .tza weights. AutoRuntime (the default) delegates to WebnnRuntime and WgslRuntime.
| runtime | options |
|---|---|
AutoRuntime | { tzaUrl?, device?, webnn?: 'gpu' | 'npu' | false }. webnn defaults to 'gpu'; false disables WebNN. lastChoice reports the runtime that served the last load; choose(model) reports which one a model would use |
OrtRuntime | { weightsUrl?, wasmPaths?, graphCapture?, splitAux? }. weightsUrl is the base URL of the .onnx models (default: jsDelivr models-v2); wasmPaths is where onnxruntime-web loads its wasm (default: jsDelivr, onnxruntime-web@1.27.0); graphCapture (default false) is opt-in ORT WebGPU graph capture, leave off since onnxruntime-web 1.27 crashes captured sessions after about 150-250 cumulative replays; splitAux (default true) works around an onnxruntime-web WebGPU bug on the 9-channel cleanAux models by running the first conv in WGSL, fetching <model>.tail.onnx + <model>.enc0.bin next to the model, and falls back to the plain model with a one-time console warning if those are not hosted |
KernelsRuntime | { tzaUrl?, device?, zeroCopy? }. zeroCopy (default true) binds the engine straight to kernels-owned IO tensors |
WgslRuntime | { tzaUrl?, device?, profile?, tiling?, subgroupMatrix?, mmaTiling? }. subgroupMatrix ('auto' by default) uses the 8x8 subgroup-matrix conv when the device has chromium-experimental-subgroup-matrix, for fp32 only; profile records per-layer GPU timings (diagnostics, needs timestamp-query); tiling and mmaTiling are tuning overrides |
WebnnRuntime | { tzaUrl?, deviceType?, device?, interop?, layout? }. deviceType is 'gpu' (default), 'npu' or 'cpu'; interop is 'auto' by default (WebNN to WebGPU tensor export for fp16 only); layout is 'nchw' (default) or 'nhwc'. WebnnRuntime.isAvailable() reports whether navigator.ml and MLGraphBuilder exist. lastBuildMs holds the graph build time of the most recent bind |
tzaUrl defaults to DEFAULT_TZA_URL. device makes the runtime adopt your
GPUDevice; without it, the runtime creates one with the adapter's maximum
limits and features. OrtRuntime cannot adopt a device: it creates its own.
parseTZA(buf) / DEFAULT_TZA_URL
import { parseTZA, DEFAULT_TZA_URL } from 'denoiser/core';
const buf = await (await fetch(`${DEFAULT_TZA_URL}/rt_hdr_small.tza`)).arrayBuffer();
const tensors = parseTZA(buf); // TensorMap = Map<string, { data: Float32Array; shape: number[] }>
| export | meaning |
|---|---|
DEFAULT_TZA_URL | base URL of the upstream OIDN .tza weights (sha256-identical to RenderKit/oidn-weights), on jsDelivr: https://cdn.jsdelivr.net/gh/pmndrs/denoiser-weights@models-v3/tzas |
parseTZA(buf: ArrayBuffer) | parses an OIDN .tza blob into a TensorMap. fp16 tensors are widened to Float32Array; conv weights stay OIHW. Throws Invalid or corrupted weights blob on a bad magic number and on an unsupported major version |
halfToFloat(h: number) | IEEE half (as a 16-bit integer) to number |
TensorMap, WeightTensor | Map<string, WeightTensor>; WeightTensor = { data: Float32Array; shape: number[] } |
These exist for runtimes that read OIDN weights directly. You only need them if
you write your own NetworkRuntime.
Temporal (real-time) denoising
Exported from denoiser and denoiser/core; the FidelityFX implementations come
from denoiser/ffx. See Real-time (temporal) denoising
for the frame contract, conventions and measured results.
TemporalDenoiser<Inputs>
interface TemporalDenoiser<Inputs extends object = object> {
readonly name: string;
readonly device: GPUDevice;
configure(config: TemporalConfig): void;
dispatch(inputs: Inputs, guides: FrameGuides, camera: FrameCamera): GPUTexture;
resetHistory(): void;
readonly gpuTimings?: ReadonlyMap<string, number>;
dispose(): void;
}
| member | meaning |
|---|---|
configure({ width, height }) | (re)allocate for a size; drops history. Call before the first dispatch |
dispatch(inputs, guides, camera) | denoise one frame. Synchronous: records and submits its passes on device.queue, no awaits, no readback. Call it after the frame's inputs are submitted. The returned texture is owned by the denoiser and is valid until the next dispatch / configure / dispose |
resetHistory() | drop all history on the next dispatch (camera cut, teleport, scene switch) |
gpuTimings | per-pass GPU ms when timestamp queries are available (total = sum of passes) |
dispose() | release GPU resources; never destroys the device |
Frame types
| type | shape |
|---|---|
TemporalConfig | { width: number; height: number } |
Mat4 | Float32Array | ArrayLike<number>, column-major 4x4 (three.js Matrix4.elements) |
FrameGuides | { depth: GPUTexture; normal: GPUTexture; motion: GPUTexture; roughness?: GPUTexture; albedo?: GPUTexture }. depth is hardware depth or r32float; normal is world-space in [-1, 1]; motion is the NDC delta current minus previous (rg16float); roughness is perceptual [0, 1] |
FrameCamera | { view: Mat4; projection: Mat4; prevView: Mat4; prevProjection: Mat4; jitter?: readonly [number, number]; near: number; far: number; position?: readonly [number, number, number]; reversedDepth?: boolean }. projection is unjittered; jitter is in pixels (default [0, 0]); reversedDepth defaults to false |
Signal inputs
| type | shape |
|---|---|
ShadowInputs | { visibility: GPUTexture; hitDistance?: GPUTexture }. visibility in [0, 1], 1 = lit |
SpecularInputs | { radiance: GPUTexture; hitDistance: GPUTexture }. Linear HDR radiance (rgba16float); hit distance r16float, 0 = miss |
DiffuseInputs | { radiance: GPUTexture; hitDistance?: GPUTexture } |
BeautyInputs | { color: GPUTexture; albedo?: GPUTexture; normal?: GPUTexture; hdr?: boolean } |
DiffuseInputs and BeautyInputs are defined for future denoisers; nothing
implements them yet.
FfxShadowDenoiser / FfxReflectionDenoiser
import { FfxShadowDenoiser, FfxReflectionDenoiser } from 'denoiser/ffx';
new FfxShadowDenoiser(device: GPUDevice, options?: FfxShadowDenoiserOptions); // TemporalDenoiser<ShadowInputs>, name 'ffx-shadows'
new FfxReflectionDenoiser(device: GPUDevice, options?: FfxReflectionDenoiserOptions); // TemporalDenoiser<SpecularInputs>, name 'ffx-reflections'
FfxShadowDenoiserOptions | default | meaning |
|---|---|---|
depthSimilaritySigma | 1 | FFX edge-stopping on linear depth, in view units |
visibilityThreshold | 0.5 | visibility at or above this counts as lit (FFX takes a binary ray-hit mask) |
timestamps | true | per-pass GPU timestamps into gpuTimings when supported |
FfxReflectionDenoiserOptions | default | meaning |
|---|---|---|
temporalStabilityFactor | 0.7 | history clip box scale; higher is more stable and more ghosting |
roughnessThreshold | 1 | pixels with linear roughness at or above this pass through untouched; 1 denoises all pixels |
roughnessIsPerceptual | true | guides.roughness is perceptual (squared before use); false for linear |
missDistance | camera.far | ray length used where hitDistance <= 0 |
currentFrameStatistics | true | read this frame's 8x8 average radiance and sample count; false reproduces FFX SDK v1.1.4 exactly (dark first frame after a reset) |
timestamps | true | per-pass GPU timestamps into gpuTimings when supported |
FfxReflectionDenoiser requires guides.roughness. The shadow dispatch
returns an rgba16float texture whose .r is the denoised visibility.
denoiser/three helpers
| export | meaning |
|---|---|
getDevice(renderer) | the GPUDevice of an initialized WebGPURenderer |
getGPUTexture(renderer, texture) | the raw GPUTexture behind a three texture (must already be allocated on the GPU) |
CameraHistory | update(camera, { unjitteredProjection?, jitter? }) => FrameCamera keeps the previous frame's matrices; reset() forgets them |
determineModel(selector)
function determineModel(selector: ModelSelector): { name: string; channels: number }
Resolves an OIDN model filename + channel count from a
{ filterType, quality, hdr, useColor, useAlbedo, useNormal, cleanAux, dirtyAux }
selector (ModelSelector) — e.g. { filterType: 'rt', quality: 'fast', hdr: true, useColor: true, useAlbedo: true, useNormal: true, cleanAux: true, dirtyAux: false }
→ { name: 'rt_hdr_calb_cnrm_small', channels: 9 }. Denoiser calls this
internally on every call to pick which model to run; exported for anyone
building their own model-selection logic on DenoiseEngine directly.