Download runtime/minicpmv-runtime.mjs from webbrain-one/webbrain-compass-vld-tiny-v5-onnx: direct link, hf CLI and curl.
- Browser
- Download file 25.5 kB
-
https://huggingface.co/webbrain-one/webbrain-compass-vld-tiny-v5-onnx/resolve/main/runtime/minicpmv-runtime.mjs
- Command line
-
hf download hf://webbrain-one/webbrain-compass-vld-tiny-v5-onnx/runtime/minicpmv-runtime.mjs
-
curl -L -o minicpmv-runtime.mjs https://huggingface.co/webbrain-one/webbrain-compass-vld-tiny-v5-onnx/resolve/main/runtime/minicpmv-runtime.mjs
25.5 kB
| // MiniCPM-V-4.7 (WebBrain fine-tune) in-browser runtime: ONNX Runtime Web (WebGPU) + a Transformers.js tokenizer. | |
| // | |
| // The ONNX bundle has three graphs: | |
| // onnx/embed_tokens_q4f16.onnx input_ids int64[1,S] -> inputs_embeds float32[1,S,1024] | |
| // onnx/vision_encoder_fp16.onnx pixel_values float32[n,3,H,W] -> image_features float32[n,T,1024] | |
| // onnx/decoder_model_merged_q4f16.onnx Qwen3.5-0.8B topology (GatedDeltaNet + gated attention, M-RoPE), | |
| // chunked GDN prefill, last-position logits + decision_hidden | |
| // This module reproduces MiniCPM-V-4.7's processor (slicing, PIL-compatible bicubic resize, normalization, image | |
| // placeholders) and its canvas M-RoPE position ids exactly, so the browser builds the same inputs as Transformers. | |
| // | |
| // Everything here is plain JS; `ort` (onnxruntime-web with WebGPU) and a tokenizer with `encode(text)` are injected. | |
| export const MINICPMV = { | |
| imageTokenId: 248056, | |
| imageStartId: 248078, // <image> | |
| imageEndId: 248079, // </image> | |
| sliceStartId: 248088, // <slice> | |
| sliceEndId: 248089, // </slice> | |
| newlineId: 198, // "\n" | |
| imagePad: '<|image_pad|>', | |
| hidden: 1024, | |
| layers: 24, | |
| fullAttentionInterval: 4, | |
| gdnConv: [6144, 4], | |
| gdnState: [16, 128, 128], | |
| kvHeads: 2, | |
| headDim: 256, | |
| eosTokenIds: [248044, 248046], | |
| scaleResolution: 448, | |
| patchSize: 14, | |
| maxSliceNums: 9, | |
| mergeFactor: 4, // "16x": 4x4 patches per LLM token | |
| }; | |
| // ------------------------------------------------------------------------------------------ python numerics | |
| /** Python 3 round(): ties to even. */ | |
| export function pyRound(x) { | |
| const r = Math.round(x); | |
| return (Math.abs(x % 1) === 0.5) ? 2 * Math.round(x / 2) : r; | |
| } | |
| const roundHalfEven = pyRound; | |
| /** torch.linspace(start, end, steps) in float32 (same symmetric formula on CPU and CUDA). */ | |
| function linspaceF32(start, end, steps) { | |
| if (steps === 1) return [Math.fround(start)]; | |
| const f = Math.fround; | |
| const s = f(start), e = f(end); | |
| const step = f(f(e - s) / (steps - 1)); | |
| const half = Math.floor(steps / 2); | |
| const out = new Array(steps); | |
| for (let i = 0; i < steps; i++) { | |
| out[i] = i < half ? f(s + f(step * i)) : f(e - f(step * (steps - i - 1))); | |
| } | |
| return out; | |
| } | |
| // ------------------------------------------------------------------------------------------ slicing (processor) | |
| function ensureDivide(length, divisor) { | |
| return Math.max(pyRound(length / divisor) * divisor, divisor); | |
| } | |
| function findBestResize(height, width, scaleResolution, patchSize, allowUpscale = false) { | |
| if (height * width > scaleResolution * scaleResolution || allowUpscale) { | |
| const aspect = width / height; | |
| height = Math.trunc(scaleResolution / Math.sqrt(aspect)); | |
| width = Math.trunc(height * aspect); | |
| } | |
| return [ensureDivide(height, patchSize * 4), ensureDivide(width, patchSize * 4)]; | |
| } | |
| function getRefineSize(height, width, grid, scaleResolution, patchSize, allowUpscale = false) { | |
| const [gridY, gridX] = grid; | |
| const refineWidth = ensureDivide(width, gridX); | |
| const refineHeight = ensureDivide(height, gridY); | |
| const [bh, bw] = findBestResize(refineHeight / gridY, refineWidth / gridX, scaleResolution, patchSize, allowUpscale); | |
| return [bh * gridY, bw * gridX]; | |
| } | |
| export function getSlicedGrid(height, width, maxSliceNums, scaleResolution) { | |
| const logRatio = Math.log(width / height); | |
| const ratio = (width * height) / (scaleResolution * scaleResolution); | |
| const multiple = Math.min(Math.ceil(ratio), maxSliceNums); | |
| if (multiple <= 1) return null; | |
| let best = [1, 1], minError = Infinity; | |
| for (const numSlices of [multiple - 1, multiple, multiple + 1]) { | |
| if (numSlices === 1 || numSlices > maxSliceNums) continue; | |
| for (let rows = 1; rows <= numSlices; rows++) { | |
| if (numSlices % rows !== 0) continue; | |
| const cols = numSlices / rows; | |
| const error = Math.abs(logRatio - Math.log(cols / rows)); | |
| if (error < minError) { best = [rows, cols]; minError = error; } | |
| else if (error === minError && rows > best[0]) best = [rows, cols]; | |
| } | |
| } | |
| return best; | |
| } | |
| // ------------------------------------------------------------------------------------------ PIL bicubic resample | |
| // Port of Pillow's ImagingResample (BICUBIC, a = -0.5, support 2, antialiased when downscaling, 22-bit fixed-point | |
| // coefficients, horizontal pass then vertical pass with uint8 rounding in between). Torch's uint8 antialiased bicubic | |
| // (what the Transformers torchvision backend calls) reproduces Pillow's integer pipeline. | |
| const PRECISION_BITS = 32 - 8 - 2; | |
| function bicubic(x) { | |
| const a = -0.5; | |
| x = Math.abs(x); | |
| if (x < 1.0) return ((a + 2.0) * x - (a + 3.0)) * x * x + 1; | |
| if (x < 2.0) return (((x - 5) * x + 8) * x - 4) * a; | |
| return 0.0; | |
| } | |
| function precomputeCoeffs(inSize, outSize) { | |
| const scale = inSize / outSize; | |
| const filterscale = Math.max(scale, 1.0); | |
| const support = 2.0 * filterscale; | |
| const ksize = Math.ceil(support) * 2 + 1; | |
| const bounds = new Int32Array(outSize * 2); | |
| const kk = new Float64Array(outSize * ksize); | |
| const ss = 1.0 / filterscale; | |
| for (let xx = 0; xx < outSize; xx++) { | |
| const center = (xx + 0.5) * scale; | |
| let ww = 0.0; | |
| let xmin = Math.trunc(center - support + 0.5); | |
| if (xmin < 0) xmin = 0; | |
| let xmax = Math.trunc(center + support + 0.5); | |
| if (xmax > inSize) xmax = inSize; | |
| xmax -= xmin; | |
| const base = xx * ksize; | |
| let x = 0; | |
| for (; x < xmax; x++) { | |
| const w = bicubic((x + xmin - center + 0.5) * ss); | |
| kk[base + x] = w; | |
| ww += w; | |
| } | |
| for (x = 0; x < xmax; x++) if (ww !== 0.0) kk[base + x] /= ww; | |
| for (; x < ksize; x++) kk[base + x] = 0; | |
| bounds[xx * 2] = xmin; | |
| bounds[xx * 2 + 1] = xmax; | |
| } | |
| // fixed point (Pillow normalize_coeffs_8bpc) | |
| const kint = new Int32Array(outSize * ksize); | |
| for (let i = 0; i < kk.length; i++) { | |
| const v = kk[i] * (1 << PRECISION_BITS); | |
| kint[i] = v < 0 ? Math.trunc(-0.5 + v) : Math.trunc(0.5 + v); | |
| } | |
| return { ksize, bounds, kint }; | |
| } | |
| function clip8(v) { | |
| // Pillow: clip8(in) = in >> PRECISION_BITS clamped to [0, 255] after adding the rounding half | |
| const r = Math.floor(v / (1 << PRECISION_BITS)); | |
| return r < 0 ? 0 : (r > 255 ? 255 : r); | |
| } | |
| /** Resize an RGB uint8 image (Uint8Array, 3 channels interleaved) with Pillow's BICUBIC. */ | |
| export function resizeBicubicRGB(src, width, height, outW, outH) { | |
| const half = 1 << (PRECISION_BITS - 1); | |
| let cur = src, cw = width, ch = height; | |
| if (outW !== cw) { | |
| const { ksize, bounds, kint } = precomputeCoeffs(cw, outW); | |
| const dst = new Uint8Array(outW * ch * 3); | |
| for (let y = 0; y < ch; y++) { | |
| for (let xx = 0; xx < outW; xx++) { | |
| const xmin = bounds[xx * 2], xmax = bounds[xx * 2 + 1], k = xx * ksize; | |
| let s0 = half, s1 = half, s2 = half; | |
| for (let x = 0; x < xmax; x++) { | |
| const p = (y * cw + x + xmin) * 3, w = kint[k + x]; | |
| s0 += cur[p] * w; s1 += cur[p + 1] * w; s2 += cur[p + 2] * w; | |
| } | |
| const o = (y * outW + xx) * 3; | |
| dst[o] = clip8(s0); dst[o + 1] = clip8(s1); dst[o + 2] = clip8(s2); | |
| } | |
| } | |
| cur = dst; cw = outW; | |
| } | |
| if (outH !== ch) { | |
| const { ksize, bounds, kint } = precomputeCoeffs(ch, outH); | |
| const dst = new Uint8Array(cw * outH * 3); | |
| for (let yy = 0; yy < outH; yy++) { | |
| const ymin = bounds[yy * 2], ymax = bounds[yy * 2 + 1], k = yy * ksize; | |
| for (let x = 0; x < cw; x++) { | |
| let s0 = half, s1 = half, s2 = half; | |
| for (let y = 0; y < ymax; y++) { | |
| const p = ((y + ymin) * cw + x) * 3, w = kint[k + y]; | |
| s0 += cur[p] * w; s1 += cur[p + 1] * w; s2 += cur[p + 2] * w; | |
| } | |
| const o = (yy * cw + x) * 3; | |
| dst[o] = clip8(s0); dst[o + 1] = clip8(s1); dst[o + 2] = clip8(s2); | |
| } | |
| } | |
| cur = dst; ch = outH; | |
| } | |
| return cur; | |
| } | |
| function cropRGB(src, width, x0, y0, w, h) { | |
| const out = new Uint8Array(w * h * 3); | |
| for (let y = 0; y < h; y++) out.set(src.subarray(((y0 + y) * width + x0) * 3, ((y0 + y) * width + x0 + w) * 3), y * w * 3); | |
| return out; | |
| } | |
| // The processor fuses rescale (1/255) and normalize (mean = std = 0.5) into (x - 127.5) / 127.5 in float32. | |
| export const NORM_LUT = (() => { | |
| const t = new Float32Array(256); | |
| for (let x = 0; x < 256; x++) t[x] = Math.fround((x - 127.5) / 127.5); | |
| return t; | |
| })(); | |
| /** RGB uint8 HWC -> normalized float32 CHW. */ | |
| function normalizeCHW(rgb, w, h) { | |
| const out = new Float32Array(3 * h * w); | |
| const plane = h * w; | |
| for (let i = 0; i < plane; i++) { | |
| for (let c = 0; c < 3; c++) out[c * plane + i] = NORM_LUT[rgb[i * 3 + c]]; | |
| } | |
| return out; | |
| } | |
| /** | |
| * MiniCPMV4_6ImageProcessor for one image. `rgb` is uint8 RGB (HWC). Returns the crops in processor order | |
| * (thumbnail, then slices row-major) with their patch grids, and the slice grid ([rows, cols] or null). | |
| */ | |
| export function preprocessImage(rgb, width, height, { maxSliceNums = MINICPMV.maxSliceNums, | |
| scaleResolution = MINICPMV.scaleResolution, patchSize = MINICPMV.patchSize, sliceMode = true } = {}) { | |
| const grid = sliceMode ? getSlicedGrid(height, width, maxSliceNums, scaleResolution) : null; | |
| const [sh, sw] = findBestResize(height, width, scaleResolution, patchSize, grid === null); | |
| const crops = []; | |
| const source = resizeBicubicRGB(rgb, width, height, sw, sh); | |
| crops.push({ h: sh / patchSize, w: sw / patchSize, width: sw, height: sh, pixels: normalizeCHW(source, sw, sh) }); | |
| if (grid) { | |
| const [rh, rw] = getRefineSize(height, width, grid, scaleResolution, patchSize, true); | |
| const refine = resizeBicubicRGB(rgb, width, height, rw, rh); | |
| const ph = Math.floor(rh / grid[0]), pw = Math.floor(rw / grid[1]); | |
| for (let i = 0; i < rh; i += ph) { | |
| for (let j = 0; j < rw; j += pw) { | |
| const piece = cropRGB(refine, rw, j, i, pw, ph); | |
| crops.push({ h: ph / patchSize, w: pw / patchSize, width: pw, height: ph, pixels: normalizeCHW(piece, pw, ph) }); | |
| } | |
| } | |
| } | |
| return { crops, grid }; | |
| } | |
| /** The processor's text replacement for the image placeholder (use_image_id, 16x downsampling). */ | |
| export function imagePlaceholder(image, localIndex = 0) { | |
| const div = MINICPMV.mergeFactor * MINICPMV.mergeFactor; | |
| const [thumb, ...slices] = image.crops; | |
| let text = `<image_id>${localIndex}</image_id><image>${MINICPMV.imagePad.repeat((thumb.h * thumb.w) / div)}</image>`; | |
| if (image.grid && slices.length) { | |
| const [rows, cols] = image.grid; | |
| const per = (slices[0].h * slices[0].w) / div; | |
| const slice = `<slice>${MINICPMV.imagePad.repeat(per)}</slice>`; | |
| text += Array.from({ length: rows }, () => slice.repeat(cols)).join('\n'); | |
| } | |
| return text; | |
| } | |
| /** Replace each `<|image_pad|>` produced by the chat template with its image's full placeholder. */ | |
| export function expandImagePlaceholders(prompt, images) { | |
| const parts = prompt.split(MINICPMV.imagePad); | |
| if (parts.length - 1 !== images.length) throw new Error(`prompt has ${parts.length - 1} image slots for ${images.length} images`); | |
| let out = parts[0]; | |
| for (let i = 0; i < images.length; i++) out += imagePlaceholder(images[i], i) + parts[i + 1]; | |
| return out; | |
| } | |
| // ------------------------------------------------------------------------------------------ canvas M-RoPE | |
| /** | |
| * Port of MiniCPMV4_7Model.get_rope_index for one unpadded sequence. `ids` are token ids, `targetSizes` the | |
| * patch grids [h, w] of every crop in order. Returns {t, h, w} Int32Arrays and the next free position. | |
| */ | |
| export function canvasPositions(ids, targetSizes, cfg = MINICPMV) { | |
| const S = ids.length; | |
| const T = new Int32Array(S), H = new Int32Array(S), W = new Int32Array(S); | |
| for (let i = 0; i < S; i++) { T[i] = i; H[i] = i; W[i] = i; } | |
| const markers = new Set([cfg.sliceStartId, cfg.sliceEndId, cfg.imageStartId, cfg.imageEndId, cfg.newlineId]); | |
| const isVisual = id => id === cfg.imageTokenId; | |
| // runs of visual tokens = crops | |
| const groups = []; | |
| let openSlices = null, previousEnd = 0; | |
| for (let i = 0; i < S;) { | |
| if (!isVisual(ids[i])) { i++; continue; } | |
| let j = i; | |
| while (j < S && isVisual(ids[j])) j++; | |
| const crop = [i, j]; | |
| if (openSlices !== null && ids[crop[0] - 1] === cfg.sliceStartId) { | |
| openSlices.push(crop); | |
| } else { | |
| openSlices = []; | |
| const frame = { thumbStart: crop[0], thumbEnd: crop[1], slices: openSlices }; | |
| let adjacent = groups.length > 0; | |
| for (let k = previousEnd; adjacent && k < crop[0] - 1; k++) if (!markers.has(ids[k])) adjacent = false; | |
| if (adjacent) groups[groups.length - 1].frames.push(frame); | |
| else groups.push({ start: crop[0] - 1, frames: [frame] }); | |
| } | |
| previousEnd = crop[1]; | |
| i = j; | |
| } | |
| const frameEnd = last => { let e = last; while (e < S && markers.has(ids[e])) e++; return e; }; | |
| for (const g of groups) { | |
| for (const f of g.frames) f.end = frameEnd(f.slices.length ? f.slices[f.slices.length - 1][1] : f.thumbEnd); | |
| g.end = g.frames[g.frames.length - 1].end; | |
| } | |
| const set3 = (idx, t, h, w) => { T[idx] = t; H[idx] = h; W[idx] = w; }; | |
| const mf = cfg.mergeFactor; | |
| let sizeIter = 0; | |
| const nextSize = () => targetSizes[sizeIter++]; | |
| let currentPos = 0, currentIdx = 0; | |
| for (const g of groups) { | |
| if (g.start > currentIdx) { | |
| for (let k = currentIdx; k < g.start; k++) { const p = currentPos + (k - currentIdx); set3(k, p, p, p); } | |
| currentPos += g.start - currentIdx; | |
| } | |
| let cursor = g.start; | |
| for (const f of g.frames) { | |
| const thumbSize = nextSize(); | |
| const frameStart = f.thumbStart - 1; | |
| if (frameStart > cursor) { | |
| for (let k = cursor; k < frameStart; k++) { const p = currentPos + (k - cursor); set3(k, p, p, p); } | |
| currentPos += frameStart - cursor + 1; | |
| } | |
| const fEnd = Math.min(f.end, g.end); | |
| const origin = currentPos; | |
| const halo = Math.max(origin - 1, 0); | |
| let sliceH = 0, sliceW = 0, numCols = 0; | |
| let canvasH = Math.floor(thumbSize[0] / mf), canvasW = Math.floor(thumbSize[1] / mf); | |
| let firstSlice = null; | |
| if (f.slices.length) { | |
| numCols = f.slices.length; | |
| for (let k = 0; k < f.slices.length - 1; k++) { | |
| if (f.slices[k + 1][0] - f.slices[k][1] > 2) { numCols = k + 1; break; } | |
| } | |
| let numRows = numCols > 0 ? Math.floor(f.slices.length / numCols) : 1; | |
| if (numRows * numCols !== f.slices.length) { numRows = 1; numCols = f.slices.length; } | |
| firstSlice = nextSize(); | |
| sliceH = Math.floor(firstSlice[0] / mf); sliceW = Math.floor(firstSlice[1] / mf); | |
| canvasH = numRows * sliceH; canvasW = numCols * sliceW; | |
| } | |
| for (let k = frameStart; k < fEnd; k++) set3(k, origin, origin, origin); | |
| H[frameStart] = halo; W[frameStart] = halo; | |
| if (f.thumbEnd < fEnd) { H[f.thumbEnd] = origin + canvasH; W[f.thumbEnd] = origin + canvasW; } | |
| // thumbnail stretched over the canvas | |
| { | |
| const gh = Math.floor(thumbSize[0] / mf), gw = Math.floor(thumbSize[1] / mf); | |
| const hc = linspaceF32(0, canvasH - 1, gh).map(roundHalfEven), wc = linspaceF32(0, canvasW - 1, gw).map(roundHalfEven); | |
| let k = f.thumbStart; | |
| for (let a = 0; a < gh; a++) for (let b = 0; b < gw; b++, k++) set3(k, origin, origin + hc[a], origin + wc[b]); | |
| } | |
| f.slices.forEach(([s0, s1], k) => { | |
| const hOff = Math.floor(k / numCols) * sliceH, wOff = (k % numCols) * sliceW; | |
| const size = k === 0 ? firstSlice : nextSize(); | |
| const sh = Math.floor(size[0] / mf), sw = Math.floor(size[1] / mf); | |
| if (s0 - 1 >= frameStart) { H[s0 - 1] = origin + hOff; W[s0 - 1] = origin + wOff; } | |
| if (s1 < fEnd) { H[s1] = origin + hOff + sh - 1; W[s1] = origin + wOff + sw - 1; } | |
| let idx = s0; | |
| for (let a = 0; a < sh; a++) for (let b = 0; b < sw; b++, idx++) set3(idx, origin, origin + hOff + a, origin + wOff + b); | |
| }); | |
| for (let k = 0; k < f.slices.length - 1; k++) { | |
| const gs = f.slices[k][1], ge = f.slices[k + 1][0]; | |
| if (ge - gs <= 2) continue; | |
| const boundaryH = (Math.floor(k / numCols) + 1) * sliceH - 1; | |
| const rightEdge = numCols * sliceW; | |
| for (let n = gs + 1; n < ge - 1; n++) { H[n] = origin + boundaryH; W[n] = origin + rightEdge; } | |
| } | |
| currentPos = origin + Math.max(canvasH, canvasW) + 1; | |
| cursor = fEnd; | |
| } | |
| if (cursor < g.end) { | |
| for (let k = cursor; k < g.end; k++) { const p = currentPos + (k - cursor); set3(k, p, p, p); } | |
| currentPos += g.end - cursor; | |
| } | |
| currentIdx = g.end; | |
| } | |
| for (let k = currentIdx; k < S; k++) { const p = currentPos + (k - currentIdx); set3(k, p, p, p); } | |
| let max = 0; | |
| for (let i = 0; i < S; i++) max = Math.max(max, T[i], H[i], W[i]); | |
| return { t: T, h: H, w: W, nextPosition: max + 1 }; | |
| } | |
| // ------------------------------------------------------------------------------------------ ORT session runtime | |
| const f16Zeros = n => new Uint16Array(n); | |
| export class MiniCPMVRuntime { | |
| /** | |
| * @param ort onnxruntime-web module (WebGPU build) | |
| * @param tokenizer object with encode(text) -> number[] (special tokens recognised, no BOS) | |
| * @param urls { embed, vision, decoder } graph URLs (external data `<graph>_data` sits next to each) | |
| */ | |
| constructor(ort, tokenizer, sessions, cfg = MINICPMV) { | |
| this.ort = ort; | |
| this.tokenizer = tokenizer; | |
| this.embed = sessions.embed; | |
| this.vision = sessions.vision; | |
| this.decoder = sessions.decoder; | |
| this.cfg = cfg; | |
| this.layerKinds = Array.from({ length: cfg.layers }, (_, i) => ((i + 1) % cfg.fullAttentionInterval === 0 ? 'attn' : 'gdn')); | |
| } | |
| static async create(ort, tokenizer, urls, { executionProviders = ['webgpu'], fetchData = null } = {}) { | |
| const make = async url => { | |
| const name = url.split('/').pop(); | |
| const options = { executionProviders, graphOptimizationLevel: 'all' }; | |
| const data = fetchData ? await fetchData(`${url}_data`) : null; | |
| options.externalData = [{ path: `${name}_data`, data: data || `${url}_data` }]; | |
| if (executionProviders.includes('webgpu')) options.preferredOutputLocation = 'gpu-buffer'; | |
| return ort.InferenceSession.create(url, options); | |
| }; | |
| // ORT Web's WebGPU EP creates one session at a time | |
| const embed = await make(urls.embed); | |
| const vision = await make(urls.vision); | |
| const decoder = await make(urls.decoder); | |
| return new MiniCPMVRuntime(ort, tokenizer, { embed, vision, decoder }); | |
| } | |
| tensor(type, data, dims) { return new this.ort.Tensor(type, data, dims); } | |
| async embedIds(ids) { | |
| const out = await this.embed.run({ input_ids: this.tensor('int64', BigInt64Array.from(ids.map(BigInt)), [1, ids.length]) }); | |
| const t = out.inputs_embeds; | |
| const data = t.location === 'gpu-buffer' ? await t.getData(true) : t.data; | |
| return new Float32Array(data.buffer ? data : Float32Array.from(data)); | |
| } | |
| /** Vision features for one preprocessed image, in placeholder order (thumbnail, slices). */ | |
| async encodeImage(image) { | |
| const groups = new Map(); | |
| image.crops.forEach((c, i) => { | |
| const key = `${c.height}x${c.width}`; | |
| if (!groups.has(key)) groups.set(key, []); | |
| groups.get(key).push(i); | |
| }); | |
| const perCrop = new Array(image.crops.length); | |
| for (const idx of groups.values()) { | |
| const c0 = image.crops[idx[0]]; | |
| const plane = 3 * c0.height * c0.width; | |
| const batch = new Float32Array(plane * idx.length); | |
| idx.forEach((ci, b) => batch.set(image.crops[ci].pixels, b * plane)); | |
| const out = await this.vision.run({ pixel_values: this.tensor('float32', batch, [idx.length, 3, c0.height, c0.width]) }); | |
| const t = out.image_features; | |
| const data = t.location === 'gpu-buffer' ? await t.getData(true) : t.data; | |
| const per = (c0.h / this.cfg.mergeFactor) * (c0.w / this.cfg.mergeFactor) * this.cfg.hidden; | |
| idx.forEach((ci, b) => { perCrop[ci] = data.subarray(b * per, (b + 1) * per); }); | |
| } | |
| return perCrop; | |
| } | |
| /** Build prompt inputs: ids, embeddings (image rows filled), canvas positions. */ | |
| async buildInputs(promptText, images = []) { | |
| const text = images.length ? expandImagePlaceholders(promptText, images) : promptText; | |
| const ids = this.tokenizer.encode(text); | |
| const embeds = await this.embedIds(ids); | |
| if (images.length) { | |
| const feats = []; | |
| for (const image of images) feats.push(...await this.encodeImage(image)); | |
| let row = 0, fi = 0, off = 0; | |
| const H = this.cfg.hidden; | |
| for (let i = 0; i < ids.length; i++) { | |
| if (ids[i] !== this.cfg.imageTokenId) continue; | |
| while (fi < feats.length && off >= feats[fi].length) { fi++; off = 0; } | |
| if (fi >= feats.length) throw new Error('more image placeholders than vision features'); | |
| embeds.set(feats[fi].subarray(off, off + H), i * H); | |
| off += H; row++; | |
| } | |
| const total = feats.reduce((s, f) => s + f.length / H, 0); | |
| if (row !== total) throw new Error(`image placeholders ${row} != vision features ${total}`); | |
| } | |
| const sizes = images.flatMap(im => im.crops.map(c => [c.h, c.w])); | |
| const pos = canvasPositions(ids, sizes, this.cfg); | |
| return { ids, embeds, pos, sizes }; | |
| } | |
| emptyCache() { | |
| const feeds = {}; | |
| const [convC, convK] = this.cfg.gdnConv, [hs, dk, dv] = this.cfg.gdnState; | |
| this.layerKinds.forEach((kind, i) => { | |
| if (kind === 'gdn') { | |
| feeds[`past_conv.${i}`] = this.tensor('float16', f16Zeros(convC * convK), [1, convC, convK]); | |
| feeds[`past_recurrent.${i}`] = this.tensor('float16', f16Zeros(hs * dk * dv), [1, hs, dk, dv]); | |
| } else { | |
| feeds[`past_key_values.${i}.key`] = this.tensor('float16', f16Zeros(0), [1, this.cfg.kvHeads, 0, this.cfg.headDim]); | |
| feeds[`past_key_values.${i}.value`] = this.tensor('float16', f16Zeros(0), [1, this.cfg.kvHeads, 0, this.cfg.headDim]); | |
| } | |
| }); | |
| return { feeds, length: 0 }; | |
| } | |
| /** | |
| * Run the decoder over `embeds` rows [start, end) with positions from `pos`, continuing `cache`. | |
| * Returns last-position logits (Float32Array), decision_hidden (Float32Array) and the new cache. | |
| */ | |
| async forward(embeds, pos, start, end, cache, { keepCache = true } = {}) { | |
| const n = end - start, H = this.cfg.hidden; | |
| const posData = new BigInt64Array(3 * n); | |
| for (let i = 0; i < n; i++) { | |
| posData[i] = BigInt(pos.t[start + i]); | |
| posData[n + i] = BigInt(pos.h[start + i]); | |
| posData[2 * n + i] = BigInt(pos.w[start + i]); | |
| } | |
| const total = cache.length + n; | |
| const feeds = { | |
| ...cache.feeds, | |
| inputs_embeds: this.tensor('float32', embeds.slice(start * H, end * H), [1, n, H]), | |
| attention_mask: this.tensor('int64', new BigInt64Array(total).fill(1n), [1, total]), | |
| position_ids: this.tensor('int64', posData, [3, 1, n]), | |
| }; | |
| const out = await this.decoder.run(feeds); | |
| const read = async t => { | |
| if (!t) return null; | |
| const d = t.location === 'gpu-buffer' ? await t.getData() : t.data; | |
| return d instanceof Uint16Array ? f16ToF32(d) : Float32Array.from(d); | |
| }; | |
| const logits = await read(out.logits); | |
| const hidden = await read(out.decision_hidden); | |
| const next = { feeds: {}, length: total }; | |
| for (const [name, t] of Object.entries(out)) { | |
| if (name.startsWith('present')) { | |
| const past = name.replace('present_conv', 'past_conv').replace('present_recurrent', 'past_recurrent').replace('present.', 'past_key_values.'); | |
| if (keepCache) next.feeds[past] = t; else t.dispose?.(); | |
| } else if (name !== 'logits' && name !== 'decision_hidden') t.dispose?.(); | |
| } | |
| out.logits?.dispose?.(); | |
| out.decision_hidden?.dispose?.(); | |
| return { logits, hidden, cache: next }; | |
| } | |
| /** | |
| * Greedy generation. `promptText` is the rendered chat prompt (each image as one `<|image_pad|>`); returns token ids. | |
| * New tokens continue the canvas positions: position = max(prompt positions) + 1 + step on all three axes. | |
| */ | |
| async generate(promptText, images = [], { maxNewTokens = 128, stopIds = this.cfg.eosTokenIds, onToken = null } = {}) { | |
| const inputs = await this.buildInputs(promptText, images); | |
| let { logits, cache } = await this.forward(inputs.embeds, inputs.pos, 0, inputs.ids.length, this.emptyCache()); | |
| let next = inputs.pos.nextPosition; | |
| const out = []; | |
| try { | |
| for (let step = 0; step < maxNewTokens; step++) { | |
| let best = 0; | |
| for (let i = 1; i < logits.length; i++) if (logits[i] > logits[best]) best = i; | |
| if (stopIds.includes(best)) break; | |
| out.push(best); | |
| onToken?.(best); | |
| const emb = await this.embedIds([best]); | |
| const p = { t: Int32Array.of(next), h: Int32Array.of(next), w: Int32Array.of(next) }; | |
| next += 1; | |
| const r = await this.forward(emb, p, 0, 1, cache); | |
| MiniCPMVRuntime.disposeCache(cache); | |
| ({ logits, cache } = r); | |
| } | |
| } finally { | |
| MiniCPMVRuntime.disposeCache(cache); | |
| } | |
| return { ids: out, promptTokens: inputs.ids.length }; | |
| } | |
| static disposeCache(cache) { | |
| for (const t of Object.values(cache?.feeds || {})) t.dispose?.(); | |
| } | |
| } | |
| /** IEEE half -> float. */ | |
| export function f16ToF32(u16) { | |
| const out = new Float32Array(u16.length); | |
| for (let i = 0; i < u16.length; i++) { | |
| const h = u16[i], s = (h & 0x8000) ? -1 : 1, e = (h >> 10) & 0x1f, m = h & 0x3ff; | |
| out[i] = e === 0 ? s * m * 2 ** -24 : (e === 31 ? (m ? NaN : s * Infinity) : s * (1 + m / 1024) * 2 ** (e - 15)); | |
| } | |
| return out; | |
| } | |