Moondream2 Region ONNX — Browser Object Detection & Pointing
4 lightweight ONNX models that add /detect (bounding boxes) and /point (coordinate pointing) capabilities to the existing Xenova/moondream2 ONNX models in the browser.
What This Is
Moondream2 is a vision-language model that can caption images, answer questions, detect objects, and point to things. The Xenova/moondream2 repo provides the vision encoder and text decoder as ONNX for use with Transformers.js — but it does not include the region module needed for detection and pointing.
This repo fills that gap with 4 small ONNX files that implement the region coordinate/size encoder-decoder pipeline.
Files in This Repo
File
Input
Output
Size
onnx/region_coord_encoder.onnx
coord [1] (float 0–1)
embed [2048]
~2 MB
onnx/region_coord_decoder.onnx
hidden [2048]
logits [1024]
~96 MB
onnx/region_size_encoder.onnx
size [2] (w, h float)
embed [2048]
~4 MB
onnx/region_size_decoder.onnx
hidden [2048]
logits [2, 1024]
~128 MB
Each .onnx file has a companion .onnx_data file containing the weights. Both files are required.
How Moondream Detection/Pointing Works
Important: Moondream detection is not single-shot like YOLO. It is autoregressive — the text model generates coordinates one token at a time, using the region models to encode/decode each coordinate.
1// ─── Region helper functions ───23constCOORD_BINS=1024;4constSIZE_BINS=1024;5constHIDDEN_DIM=2048;67asyncfunctiondecodeCoordinate(hidden){8const{ logits }=await regionSessions.coordDecoder.run({9hidden:newort.Tensor("float32", hidden,[HIDDEN_DIM]),10});11let best =0;12for(let i =1; i < logits.data.length; i++)13if(logits.data[i]> logits.data[best]) best = i;14return best / logits.data.length;// normalised 0–115}1617asyncfunctionencodeCoordinate(coord){18const{ embed }=await regionSessions.coordEncoder.run({19coord:newort.Tensor("float32",newFloat32Array([coord]),[1]),20});21return embed.data;22}2324asyncfunctiondecodeSize(hidden){25const{ logits }=await regionSessions.sizeDecoder.run({26hidden:newort.Tensor("float32", hidden,[HIDDEN_DIM]),27});28const d = logits.data;29let wIdx =0,30 hIdx =0;31for(let i =1; i <SIZE_BINS; i++){32if(d[i]> d[wIdx]) wIdx = i;33if(d[SIZE_BINS+ i]> d[SIZE_BINS+ hIdx]) hIdx = i;34}35return{36w:Math.pow(2,(wIdx /1023)*10-10),37h:Math.pow(2,(hIdx /1023)*10-10),38};39}4041asyncfunctionencodeSize(w, h){42const{ embed }=await regionSessions.sizeEncoder.run({43size:newort.Tensor("float32",newFloat32Array([w, h]),[2]),44});45return embed.data;46}4748// ─── The autoregressive detection/pointing loop ───4950/**
51 * @param{object}opts52 * @param{Float32Array}opts.initialHidden - last hidden state from text prefill
53 * @param{number}opts.initialToken - first token after prefill (5=coord, 0=eos)
54 * @param{function}opts.textModelStep - async(embedding) => {hidden, nextToken}55 * @param{boolean}opts.includeSize - true=/detect, false=/point
56 * @param{number}[opts.maxObjects=150]57 */58asyncfunctiongenerateRegionObjects({59 initialHidden,60 initialToken,61 textModelStep,62 includeSize,63 maxObjects =150,64}){65const results =[];66let hidden = initialHidden;67let nextToken = initialToken;68constEOS=0;6970while(nextToken !==EOS&& results.length< maxObjects){71// Decode x72const x =awaitdecodeCoordinate(hidden);73const xEmbed =awaitencodeCoordinate(x);74let step =awaittextModelStep(xEmbed);75 hidden = step.hidden;7677// Decode y78const y =awaitdecodeCoordinate(hidden);79const yEmbed =awaitencodeCoordinate(y);8081if(includeSize){82// /detect: decode size after y83 step =awaittextModelStep(yEmbed);84 hidden = step.hidden;85const{ w, h }=awaitdecodeSize(hidden);86const sizeEmbed =awaitencodeSize(w, h);8788 results.push({89x_min: x - w /2,90y_min: y - h /2,91x_max: x + w /2,92y_max: y + h /2,93});9495 step =awaittextModelStep(sizeEmbed);96}else{97// /point: no size, y-embed goes straight to continue/stop98 results.push({ x, y });99 step =awaittextModelStep(yEmbed);100}101102 hidden = step.hidden;103 nextToken = step.nextToken;104}105106return results;107}
The Hard Part: textModelStep
The region ONNX models handle coordinate encoding/decoding. But the autoregressive loop also needs a textModelStep callback — a function that feeds an embedding into the text decoder and returns the next hidden state.
Transformers.js does not natively expose hidden states from Moondream1ForConditionalGeneration. To wire this up, you have several options:
Option A: Load the Decoder ONNX Directly (Recommended)
Load decoder_model_merged.onnx from Xenova/moondream2 directly with onnxruntime-web, bypassing Transformers.js for the detection loop. This gives you full control over inputs/outputs including hidden states.
js
1const decoderSession =await ort.InferenceSession.create(2"https://huggingface.co/Xenova/moondream2/resolve/main/onnx/decoder_model_merged_q4.onnx",3{executionProviders:["webgpu","wasm"]}4);56// Inspect inputs/outputs to understand the decoder interface:7console.log("Inputs:", decoderSession.inputNames);8console.log("Outputs:", decoderSession.outputNames);910// The decoder typically has:11// Inputs: input_ids, attention_mask, position_ids,12// past_key_values.N.key, past_key_values.N.value, ...13// Outputs: logits, present.N.key, present.N.value, ...14//15// For the region loop, you need to:16// 1. Replace the input_ids embedding with the region-encoded embedding17// 2. Extract the last hidden state (the layer before lm_head)18// or use the logits + hidden → region decoder
Option B: Fork/Patch Transformers.js
Modify the Moondream1ForConditionalGeneration class to expose hidden_states from the decoder output. The relevant code is in @huggingface/transformers/src/models.js.
Option C: Export Your Own Decoder
Use torch.onnx.export to create a custom decoder ONNX that outputs both logits and the last hidden state. This is the most work but gives cleanest integration.
Prompt Token Format
Detection and pointing use different prompt templates. From the Moondream tokenizer config:
1import{ loadRegionModels, generateDetections, generatePoints }from"./moondream_region_worker.js";23// Load region models4awaitloadRegionModels(5"https://huggingface.co/gatorchopps/moondream2-region-onnx/resolve/main/onnx"6);78// After prefilling the text model with image + detect prompt...9const boxes =awaitgenerateDetections({10 initialHidden,// Float32Array[2048] from text decoder11 initialToken,// first generated token (5 = start coords)12 textModelStep,// your callback: async(embed) => {hidden, nextToken}13});1415// Or for pointing:16const points =awaitgeneratePoints({17 initialHidden,18 initialToken,19 textModelStep,20});
Numerical Accuracy
All 4 ONNX models were verified against the original Python region functions:
Requires the text decoder — The region ONNX files alone cannot detect objects. They must be used inside the autoregressive loop driven by the text decoder.
Hidden state access — Transformers.js does not expose hidden states out of the box. You need to load the decoder ONNX directly with onnxruntime-web or patch Transformers.js.
Version coupling — These region weights were exported from vikhyatk/moondream2 (the latest HF revision as of March 2026). If the base model changes its region architecture, re-export may be needed.
Float32 only — No quantized variants of the region models are provided. The total size (~230 MB) is manageable for most browser applications.