@dejadex/web 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +214 -2
  3. package/dist/errors.d.ts +8 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +9 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/geometry/homography.d.ts +75 -0
  8. package/dist/geometry/homography.d.ts.map +1 -0
  9. package/dist/geometry/homography.js +146 -0
  10. package/dist/geometry/homography.js.map +1 -0
  11. package/dist/identify/assets.d.ts +39 -0
  12. package/dist/identify/assets.d.ts.map +1 -0
  13. package/dist/identify/assets.js +167 -0
  14. package/dist/identify/assets.js.map +1 -0
  15. package/dist/identify/decode.d.ts +6 -0
  16. package/dist/identify/decode.d.ts.map +1 -0
  17. package/dist/identify/decode.js +53 -0
  18. package/dist/identify/decode.js.map +1 -0
  19. package/dist/identify/engine.d.ts +32 -0
  20. package/dist/identify/engine.d.ts.map +1 -0
  21. package/dist/identify/engine.js +127 -0
  22. package/dist/identify/engine.js.map +1 -0
  23. package/dist/identify/identifier.d.ts +81 -0
  24. package/dist/identify/identifier.d.ts.map +1 -0
  25. package/dist/identify/identifier.js +327 -0
  26. package/dist/identify/identifier.js.map +1 -0
  27. package/dist/identify/manifest.d.ts +60 -0
  28. package/dist/identify/manifest.d.ts.map +1 -0
  29. package/dist/identify/manifest.js +211 -0
  30. package/dist/identify/manifest.js.map +1 -0
  31. package/dist/identify/preprocess.d.ts +52 -0
  32. package/dist/identify/preprocess.d.ts.map +1 -0
  33. package/dist/identify/preprocess.js +195 -0
  34. package/dist/identify/preprocess.js.map +1 -0
  35. package/dist/identify/router.d.ts +60 -0
  36. package/dist/identify/router.d.ts.map +1 -0
  37. package/dist/identify/router.js +83 -0
  38. package/dist/identify/router.js.map +1 -0
  39. package/dist/identify/runtime.d.ts +69 -0
  40. package/dist/identify/runtime.d.ts.map +1 -0
  41. package/dist/identify/runtime.js +140 -0
  42. package/dist/identify/runtime.js.map +1 -0
  43. package/dist/identify/search.d.ts +35 -0
  44. package/dist/identify/search.d.ts.map +1 -0
  45. package/dist/identify/search.js +99 -0
  46. package/dist/identify/search.js.map +1 -0
  47. package/dist/identify/server.d.ts +60 -0
  48. package/dist/identify/server.d.ts.map +1 -0
  49. package/dist/identify/server.js +149 -0
  50. package/dist/identify/server.js.map +1 -0
  51. package/dist/identify/shard.d.ts +31 -0
  52. package/dist/identify/shard.d.ts.map +1 -0
  53. package/dist/identify/shard.js +146 -0
  54. package/dist/identify/shard.js.map +1 -0
  55. package/dist/identify/types.d.ts +107 -0
  56. package/dist/identify/types.d.ts.map +1 -0
  57. package/dist/identify/types.js +7 -0
  58. package/dist/identify/types.js.map +1 -0
  59. package/dist/identify/worker.d.ts +2 -0
  60. package/dist/identify/worker.d.ts.map +1 -0
  61. package/dist/identify/worker.js +13 -0
  62. package/dist/identify/worker.js.map +1 -0
  63. package/dist/identify/workerProtocol.d.ts +54 -0
  64. package/dist/identify/workerProtocol.d.ts.map +1 -0
  65. package/dist/identify/workerProtocol.js +113 -0
  66. package/dist/identify/workerProtocol.js.map +1 -0
  67. package/dist/index.d.ts +20 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +16 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/lease/verify.d.ts +69 -0
  72. package/dist/lease/verify.d.ts.map +1 -0
  73. package/dist/lease/verify.js +235 -0
  74. package/dist/lease/verify.js.map +1 -0
  75. package/dist/scanner.d.ts +10 -0
  76. package/dist/scanner.d.ts.map +1 -0
  77. package/dist/scanner.js +58 -0
  78. package/dist/scanner.js.map +1 -0
  79. package/dist/types.d.ts +180 -0
  80. package/dist/types.d.ts.map +1 -0
  81. package/dist/types.js +2 -0
  82. package/dist/types.js.map +1 -0
  83. package/dist/usage/meter.d.ts +83 -0
  84. package/dist/usage/meter.d.ts.map +1 -0
  85. package/dist/usage/meter.js +111 -0
  86. package/dist/usage/meter.js.map +1 -0
  87. package/dist/usage/store.d.ts +52 -0
  88. package/dist/usage/store.d.ts.map +1 -0
  89. package/dist/usage/store.js +198 -0
  90. package/dist/usage/store.js.map +1 -0
  91. package/package.json +64 -4
  92. package/src/errors.ts +24 -0
  93. package/src/geometry/homography.ts +178 -0
  94. package/src/identify/assets.ts +189 -0
  95. package/src/identify/decode.ts +54 -0
  96. package/src/identify/engine.ts +167 -0
  97. package/src/identify/identifier.ts +419 -0
  98. package/src/identify/manifest.ts +240 -0
  99. package/src/identify/preprocess.ts +227 -0
  100. package/src/identify/router.ts +115 -0
  101. package/src/identify/runtime.ts +197 -0
  102. package/src/identify/search.ts +115 -0
  103. package/src/identify/server.ts +167 -0
  104. package/src/identify/shard.ts +154 -0
  105. package/src/identify/types.ts +122 -0
  106. package/src/identify/worker.ts +13 -0
  107. package/src/identify/workerProtocol.ts +136 -0
  108. package/src/index.ts +71 -0
  109. package/src/lease/verify.ts +252 -0
  110. package/src/scanner.ts +56 -0
  111. package/src/types.ts +196 -0
  112. package/src/usage/meter.ts +165 -0
  113. package/src/usage/store.ts +245 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Crop → model input, identical to the Python reference that embedded the
3
+ * gallery (`ml/scripts/phase0_zero_shot.py`, `preprocess`):
4
+ *
5
+ * RGB → stretch-resize to width × height with PIL's BILINEAR filter
6
+ * (antialiased when shrinking) → x / 255 → (x − mean) / std → CHW float32.
7
+ *
8
+ * The resize is a port of Pillow's `Resample.c` for 8-bit images, fixed-point
9
+ * coefficients and all: a horizontal pass into an 8-bit intermediate, then a
10
+ * vertical one. A canvas `drawImage` would resample with whatever filter the
11
+ * browser picks, which differs between browsers and from the gallery's.
12
+ *
13
+ * Pure functions on typed arrays: they run the same in a Worker, on the main
14
+ * thread and in Node (the parity tests).
15
+ */
16
+
17
+ /** Interleaved 8-bit pixels, row-major, top row first. */
18
+ export interface PixelImage {
19
+ readonly width: number
20
+ readonly height: number
21
+ /** 3 (RGB) or 4 (RGBA, as `ImageData` has; alpha is ignored). */
22
+ readonly channels: 3 | 4
23
+ readonly data: Uint8Array | Uint8ClampedArray
24
+ }
25
+
26
+ export interface Normalisation {
27
+ readonly mean: readonly [number, number, number]
28
+ readonly std: readonly [number, number, number]
29
+ }
30
+
31
+ /** Pillow: `#define PRECISION_BITS (32 - 8 - 2)`. */
32
+ const PRECISION_BITS = 22
33
+ const ONE_HALF = 1 << (PRECISION_BITS - 1)
34
+ const CLIP_MAX = (1 << PRECISION_BITS) << 8
35
+
36
+ interface Coeffs {
37
+ readonly ksize: number
38
+ /** Per output pixel: first input index, then how many inputs. */
39
+ readonly bounds: Int32Array
40
+ /** outSize × ksize fixed-point weights. */
41
+ readonly kk: Int32Array
42
+ }
43
+
44
+ const bilinear = (x: number): number => {
45
+ const a = x < 0 ? -x : x
46
+ return a < 1 ? 1 - a : 0
47
+ }
48
+
49
+ /** Pillow's `precompute_coeffs` + `normalize_coeffs_8bpc`, for the bilinear filter (support 1). */
50
+ function precomputeCoeffs(inSize: number, outSize: number): Coeffs {
51
+ const scale = inSize / outSize
52
+ const filterscale = scale < 1 ? 1 : scale
53
+ const support = 1 * filterscale
54
+ const ksize = Math.ceil(support) * 2 + 1
55
+ const bounds = new Int32Array(outSize * 2)
56
+ const pre = new Float64Array(outSize * ksize)
57
+ const ss = 1 / filterscale
58
+ for (let xx = 0; xx < outSize; xx++) {
59
+ const center = (xx + 0.5) * scale
60
+ // C's (int) truncates toward zero.
61
+ let xmin = Math.trunc(center - support + 0.5)
62
+ if (xmin < 0) xmin = 0
63
+ let xmax = Math.trunc(center + support + 0.5)
64
+ if (xmax > inSize) xmax = inSize
65
+ xmax -= xmin
66
+ let ww = 0
67
+ const base = xx * ksize
68
+ for (let x = 0; x < xmax; x++) {
69
+ const w = bilinear((x + xmin - center + 0.5) * ss)
70
+ pre[base + x] = w
71
+ ww += w
72
+ }
73
+ if (ww !== 0) for (let x = 0; x < xmax; x++) pre[base + x] = (pre[base + x] as number) / ww
74
+ bounds[xx * 2] = xmin
75
+ bounds[xx * 2 + 1] = xmax
76
+ }
77
+ const kk = new Int32Array(pre.length)
78
+ const one = 1 << PRECISION_BITS
79
+ for (let i = 0; i < pre.length; i++) {
80
+ const v = pre[i] as number
81
+ kk[i] = v < 0 ? Math.trunc(-0.5 + v * one) : Math.trunc(0.5 + v * one)
82
+ }
83
+ return { ksize, bounds, kk }
84
+ }
85
+
86
+ const clip8 = (v: number): number => (v >= CLIP_MAX ? 255 : v <= 0 ? 0 : v >> PRECISION_BITS)
87
+
88
+ /**
89
+ * Resize to `outWidth × outHeight` RGB, bit-exact with Pillow's
90
+ * `Image.resize((w, h), Image.Resampling.BILINEAR)` on an RGB image (no box,
91
+ * no `reducing_gap`). Returns 3-channel pixels.
92
+ */
93
+ export function resizeBilinear(src: PixelImage, outWidth: number, outHeight: number): PixelImage {
94
+ const { width: inW, height: inH, channels: c, data } = src
95
+ if (!Number.isInteger(outWidth) || !Number.isInteger(outHeight) || outWidth <= 0 || outHeight <= 0) {
96
+ throw new RangeError(`bad output size ${outWidth}x${outHeight}`)
97
+ }
98
+ if (data.length < inW * inH * c) throw new RangeError('pixel buffer is shorter than width × height × channels')
99
+
100
+ // Horizontal pass, over only the rows the vertical pass will read.
101
+ const vert = precomputeCoeffs(inH, outHeight)
102
+ const needH = outWidth !== inW
103
+ const needV = outHeight !== inH
104
+ let rows: Uint8Array
105
+ let rowOffset = 0
106
+ let rowsHeight = inH
107
+ if (needH) {
108
+ const horiz = precomputeCoeffs(inW, outWidth)
109
+ const first = vert.bounds[0] as number
110
+ const last = (vert.bounds[outHeight * 2 - 2] as number) + (vert.bounds[outHeight * 2 - 1] as number)
111
+ rowsHeight = needV ? last - first : inH
112
+ rowOffset = needV ? first : 0
113
+ rows = new Uint8Array(outWidth * rowsHeight * 3)
114
+ const { ksize, bounds, kk } = horiz
115
+ for (let y = 0; y < rowsHeight; y++) {
116
+ const inRow = (y + rowOffset) * inW * c
117
+ const outRow = y * outWidth * 3
118
+ for (let xx = 0; xx < outWidth; xx++) {
119
+ const xmin = bounds[xx * 2] as number
120
+ const xmax = bounds[xx * 2 + 1] as number
121
+ const k = xx * ksize
122
+ let s0 = ONE_HALF
123
+ let s1 = ONE_HALF
124
+ let s2 = ONE_HALF
125
+ for (let x = 0; x < xmax; x++) {
126
+ const w = kk[k + x] as number
127
+ const p = inRow + (x + xmin) * c
128
+ s0 += (data[p] as number) * w
129
+ s1 += (data[p + 1] as number) * w
130
+ s2 += (data[p + 2] as number) * w
131
+ }
132
+ const o = outRow + xx * 3
133
+ rows[o] = clip8(s0)
134
+ rows[o + 1] = clip8(s1)
135
+ rows[o + 2] = clip8(s2)
136
+ }
137
+ }
138
+ } else {
139
+ rows = toRgb(src)
140
+ }
141
+ if (!needV) return { width: outWidth, height: outHeight, channels: 3, data: rows }
142
+
143
+ // Vertical pass. Bounds were computed against the full input height; the
144
+ // intermediate starts at `rowOffset`.
145
+ const out = new Uint8Array(outWidth * outHeight * 3)
146
+ const { ksize, bounds, kk } = vert
147
+ const stride = outWidth * 3
148
+ for (let yy = 0; yy < outHeight; yy++) {
149
+ const ymin = (bounds[yy * 2] as number) - rowOffset
150
+ const ymax = bounds[yy * 2 + 1] as number
151
+ const k = yy * ksize
152
+ const outRow = yy * stride
153
+ for (let i = 0; i < stride; i++) {
154
+ let s = ONE_HALF
155
+ for (let y = 0; y < ymax; y++) s += (rows[(y + ymin) * stride + i] as number) * (kk[k + y] as number)
156
+ out[outRow + i] = clip8(s)
157
+ }
158
+ }
159
+ return { width: outWidth, height: outHeight, channels: 3, data: out }
160
+ }
161
+
162
+ function toRgb(src: PixelImage): Uint8Array {
163
+ const n = src.width * src.height
164
+ const out = new Uint8Array(n * 3)
165
+ for (let i = 0; i < n; i++) {
166
+ out[i * 3] = src.data[i * src.channels] as number
167
+ out[i * 3 + 1] = src.data[i * src.channels + 1] as number
168
+ out[i * 3 + 2] = src.data[i * src.channels + 2] as number
169
+ }
170
+ return out
171
+ }
172
+
173
+ /** The image turned 180°. Pillow's bilinear resize commutes with it, so the SDK turns the small resized crop. */
174
+ export function rotate180(img: PixelImage): PixelImage {
175
+ const c = img.channels
176
+ const n = img.width * img.height
177
+ const out = new Uint8Array(n * c)
178
+ for (let i = 0; i < n; i++) {
179
+ const src = (n - 1 - i) * c
180
+ for (let k = 0; k < c; k++) out[i * c + k] = img.data[src + k] as number
181
+ }
182
+ return { width: img.width, height: img.height, channels: c, data: out }
183
+ }
184
+
185
+ /**
186
+ * Writes the normalised CHW tensor of an RGB image into `out` at `offset`
187
+ * (in floats). Float32 arithmetic step by step, as numpy does it, so the
188
+ * values are the reference's to the last bit.
189
+ */
190
+ export function writeTensor(img: PixelImage, norm: Normalisation, out: Float32Array, offset = 0): void {
191
+ const plane = img.width * img.height
192
+ if (out.length < offset + plane * 3) throw new RangeError('tensor buffer too small')
193
+ const f = Math.fround
194
+ const inv255 = f(1 / 255)
195
+ const c = img.channels
196
+ for (let ch = 0; ch < 3; ch++) {
197
+ const mean = f(norm.mean[ch] as number)
198
+ const std = f(norm.std[ch] as number)
199
+ const base = offset + ch * plane
200
+ for (let i = 0; i < plane; i++) {
201
+ const x = f((img.data[i * c + ch] as number) * inv255)
202
+ out[base + i] = f(f(x - mean) / std)
203
+ }
204
+ }
205
+ }
206
+
207
+ /** 0 and/or 180: the orientations each crop is embedded at. */
208
+ export type Orientation = 0 | 180
209
+
210
+ /**
211
+ * One crop → `orientations.length` consecutive CHW tensors in `out` at
212
+ * `offset`, ready for an NCHW batch.
213
+ */
214
+ export function preprocessCrop(
215
+ src: PixelImage,
216
+ size: { readonly width: number; readonly height: number },
217
+ norm: Normalisation,
218
+ orientations: readonly Orientation[],
219
+ out: Float32Array,
220
+ offset = 0,
221
+ ): void {
222
+ const resized = resizeBilinear(src, size.width, size.height)
223
+ const plane = size.width * size.height * 3
224
+ orientations.forEach((deg, i) => {
225
+ writeTensor(deg === 180 ? rotate180(resized) : resized, norm, out, offset + i * plane)
226
+ })
227
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Where an identification runs, and when the server route is skipped. Pure:
3
+ * the clock and every input are passed in, so each rule has its own test.
4
+ *
5
+ * - `local`: the device, always; nothing is ever sent.
6
+ * - `server`: the server first, then the device.
7
+ * - `auto`: the device when it is loaded and fast enough (WebGPU, or a
8
+ * measured per-crop time within the budget); otherwise the server first,
9
+ * then the device. A device whose model is still downloading uses the
10
+ * server meanwhile.
11
+ *
12
+ * The server is skipped, straight to the device, while the browser reports
13
+ * itself offline; for the rest of a lease the server refused (401/403/409);
14
+ * until a 429's `Retry-After`; and for 30 s after three consecutive failures
15
+ * (timeout, network, 5xx, malformed answer).
16
+ */
17
+ import type { ServerAnswer } from './server.js'
18
+ import type { IdentificationMode } from './types.js'
19
+
20
+ /** Phase 0: a mid-range phone without WebGPU took ~326 ms per crop on 4 WASM threads. */
21
+ export const DEFAULT_SLOW_DEVICE_BUDGET_MS = 300
22
+ export const FALLBACK_FLOOR_MS = 300
23
+ export const FALLBACK_CAP_MS = 3_000
24
+ /** The server's deadline while the device has nothing to fall back on yet. */
25
+ export const DEFAULT_SERVER_TIMEOUT_MS = 8_000
26
+ export const BREAKER_FAILURES = 3
27
+ export const BREAKER_PAUSE_MS = 30_000
28
+
29
+ export interface DeviceState {
30
+ readonly ready: boolean
31
+ readonly backend: 'webgpu' | 'wasm' | null
32
+ /** NaN until measured. */
33
+ readonly perCropMs: number
34
+ }
35
+
36
+ export type Route = 'device' | 'server'
37
+
38
+ export function chooseRoute(input: {
39
+ readonly mode: IdentificationMode
40
+ readonly device: DeviceState
41
+ /** Why the server can't be used now, or null when it can. */
42
+ readonly serverBlocked: string | null
43
+ readonly slowDeviceBudgetMs: number
44
+ }): Route {
45
+ if (input.mode === 'local' || input.serverBlocked !== null) return 'device'
46
+ if (input.mode === 'server') return 'server'
47
+ const { ready, backend, perCropMs } = input.device
48
+ if (ready && (backend === 'webgpu' || perCropMs <= input.slowDeviceBudgetMs)) return 'device'
49
+ return 'server'
50
+ }
51
+
52
+ /**
53
+ * How long a server attempt may take before the device takes over: the
54
+ * device's own time for the same crops, within [300 ms, 3 s]. With no device
55
+ * engine yet there is nothing faster to fall back on, so the server gets
56
+ * `serverTimeoutMs`.
57
+ */
58
+ export function serverDeadline(device: DeviceState, crops: number, serverTimeoutMs: number): number {
59
+ if (!device.ready || !Number.isFinite(device.perCropMs)) return serverTimeoutMs
60
+ return Math.min(FALLBACK_CAP_MS, Math.max(FALLBACK_FLOOR_MS, device.perCropMs * crops))
61
+ }
62
+
63
+ /** The server route's health, per identifier. */
64
+ export class ServerHealth {
65
+ private refusedLease: string | null = null
66
+ private refusedWhy = ''
67
+ private pausedUntil = 0
68
+ private pausedWhy = ''
69
+ private failures = 0
70
+
71
+ /** Why the server is skipped for `leaseId` at `now`, or null when it may be tried. */
72
+ blocked(leaseId: string | null, now: number, online: boolean): string | null {
73
+ if (!online) return 'offline'
74
+ if (leaseId !== null && leaseId === this.refusedLease) return `refused for this lease (${this.refusedWhy})`
75
+ if (now < this.pausedUntil) return `${this.pausedWhy} until ${new Date(this.pausedUntil).toISOString()}`
76
+ return null
77
+ }
78
+
79
+ record(answer: ServerAnswer, leaseId: string | null, now: number): void {
80
+ if (answer.ok) {
81
+ this.failures = 0
82
+ return
83
+ }
84
+ switch (answer.kind) {
85
+ case 'refused':
86
+ this.refusedLease = leaseId
87
+ this.refusedWhy = answer.detail
88
+ return
89
+ case 'rate_limited':
90
+ this.pause(now + (answer.retryAfterMs ?? 10_000), 'rate limited')
91
+ return
92
+ case 'timeout':
93
+ case 'network':
94
+ case 'server_error':
95
+ case 'malformed':
96
+ this.failures++
97
+ if (this.failures >= BREAKER_FAILURES) {
98
+ this.failures = 0
99
+ this.pause(now + BREAKER_PAUSE_MS, `${BREAKER_FAILURES} failures in a row`)
100
+ }
101
+ return
102
+ case 'aborted':
103
+ case 'bad_request':
104
+ // Neither says anything about the server's health.
105
+ return
106
+ }
107
+ }
108
+
109
+ private pause(until: number, why: string): void {
110
+ if (until > this.pausedUntil) {
111
+ this.pausedUntil = until
112
+ this.pausedWhy = why
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The embedder session: which execution provider, which file, and the call
3
+ * that turns a batch of preprocessed crops into L2-normalised CLS embeddings.
4
+ *
5
+ * Order (web-sdk-pipeline, "Execution providers are tried in a fixed order"):
6
+ * WebGPU through the `onnxruntime-web/webgpu` build when `requestAdapter()`
7
+ * returns an adapter and the model loads on it — the fp16 file, or fp32 when
8
+ * the adapter lacks `shader-f16`; otherwise WASM (SIMD), with threads only when
9
+ * the page is cross-origin isolated (SharedArrayBuffer). int8 and q4f16 files
10
+ * are never listed, so never loaded.
11
+ */
12
+ import type * as Ort from 'onnxruntime-web'
13
+ import { ScannerError } from '../errors.js'
14
+ import type { Manifest, ManifestModel, ModelPrecision } from './manifest.js'
15
+ import { l2NormaliseRows } from './search.js'
16
+
17
+ export type OrtModule = typeof Ort
18
+ export type OrtBuild = 'webgpu' | 'wasm'
19
+ export type BackendChoice = 'auto' | 'webgpu' | 'wasm'
20
+
21
+ export interface RuntimeOptions {
22
+ /** Force an execution provider. Default `auto` (WebGPU when it loads, else WASM). */
23
+ readonly backend?: BackendChoice
24
+ /** Model file for WASM. Default `fp16` (half the download; fp32 is faster on WASM). */
25
+ readonly wasmPrecision?: ModelPrecision
26
+ /** Where onnxruntime-web finds its `.wasm`/`.mjs` files, e.g. `https://cdn.example/ort/`. */
27
+ readonly wasmPaths?: string
28
+ /** WASM threads when cross-origin isolated. Default min(4, hardwareConcurrency). */
29
+ readonly threads?: number
30
+ }
31
+
32
+ /** Test seams: everything the runtime reads from its environment. */
33
+ export interface RuntimeEnv {
34
+ readonly loadOrt: (build: OrtBuild) => Promise<OrtModule>
35
+ readonly gpu: GpuLike | undefined
36
+ readonly crossOriginIsolated: boolean
37
+ readonly hardwareConcurrency: number
38
+ }
39
+
40
+ export interface GpuLike {
41
+ requestAdapter(options?: { powerPreference?: 'low-power' | 'high-performance' }): Promise<{ readonly features: { has(name: string): boolean } } | null>
42
+ }
43
+
44
+ export interface EmbedBackend {
45
+ readonly backend: OrtBuild
46
+ readonly precision: ModelPrecision
47
+ readonly threads: number
48
+ /** `n` crops' CHW tensors back to back → n × dims, L2-normalised. */
49
+ embed(input: Float32Array, n: number): Promise<Float32Array>
50
+ release(): Promise<void>
51
+ }
52
+
53
+ const ADAPTER_TIMEOUT_MS = 5_000
54
+
55
+ export interface GpuProbe {
56
+ readonly adapter: boolean
57
+ readonly shaderF16: boolean
58
+ }
59
+
60
+ export async function probeWebGpu(gpu: GpuLike | undefined): Promise<GpuProbe> {
61
+ if (!gpu || typeof gpu.requestAdapter !== 'function') return { adapter: false, shaderF16: false }
62
+ let timer: ReturnType<typeof setTimeout> | undefined
63
+ try {
64
+ // Bounded: a driver that never answers counts as no adapter.
65
+ const adapter = await Promise.race([
66
+ gpu.requestAdapter({ powerPreference: 'high-performance' }),
67
+ new Promise<null>((resolve) => {
68
+ timer = setTimeout(() => resolve(null), ADAPTER_TIMEOUT_MS)
69
+ }),
70
+ ])
71
+ if (!adapter) return { adapter: false, shaderF16: false }
72
+ return { adapter: true, shaderF16: adapter.features.has('shader-f16') }
73
+ } catch {
74
+ return { adapter: false, shaderF16: false }
75
+ } finally {
76
+ clearTimeout(timer)
77
+ }
78
+ }
79
+
80
+ export interface Plan {
81
+ readonly backend: OrtBuild
82
+ readonly model: ManifestModel
83
+ }
84
+
85
+ /** The (backend, file) pairs to try, best first. Pure, so every path is testable. */
86
+ export function planBackends(probe: GpuProbe, models: readonly ManifestModel[], options: RuntimeOptions = {}): Plan[] {
87
+ const by = (p: ModelPrecision) => models.find((m) => m.precision === p)
88
+ const plans: Plan[] = []
89
+ const choice = options.backend ?? 'auto'
90
+ if (choice !== 'wasm' && probe.adapter) {
91
+ const gpuModel = probe.shaderF16 ? (by('fp16') ?? by('fp32')) : by('fp32')
92
+ if (gpuModel) plans.push({ backend: 'webgpu', model: gpuModel })
93
+ }
94
+ if (choice !== 'webgpu') {
95
+ const wanted = options.wasmPrecision ?? 'fp16'
96
+ const wasmModel = by(wanted) ?? by(wanted === 'fp16' ? 'fp32' : 'fp16')
97
+ if (wasmModel) plans.push({ backend: 'wasm', model: wasmModel })
98
+ }
99
+ return plans
100
+ }
101
+
102
+ export function wasmThreads(env: Pick<RuntimeEnv, 'crossOriginIsolated' | 'hardwareConcurrency'>, options: RuntimeOptions = {}): number {
103
+ if (!env.crossOriginIsolated) return 1
104
+ const cap = options.threads ?? 4
105
+ return Math.max(1, Math.min(cap, env.hardwareConcurrency || 1))
106
+ }
107
+
108
+ export function defaultRuntimeEnv(): RuntimeEnv {
109
+ const nav = (globalThis as { navigator?: { gpu?: GpuLike; hardwareConcurrency?: number } }).navigator
110
+ return {
111
+ // Two builds, loaded only when used: the WebGPU one carries the larger WASM.
112
+ loadOrt: (build) => (build === 'webgpu' ? import('onnxruntime-web/webgpu') : import('onnxruntime-web/wasm')),
113
+ gpu: nav?.gpu,
114
+ crossOriginIsolated: (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated === true,
115
+ hardwareConcurrency: nav?.hardwareConcurrency ?? 1,
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Opens a session on the first plan that loads. `modelBytes` fetches (and
121
+ * verifies) a plan's file; it is called only for the plans actually tried.
122
+ */
123
+ export async function createEmbedBackend(
124
+ manifest: Manifest,
125
+ modelBytes: (model: ManifestModel) => Promise<ArrayBuffer>,
126
+ options: RuntimeOptions,
127
+ env: RuntimeEnv,
128
+ ): Promise<EmbedBackend> {
129
+ const probe = await probeWebGpu(options.backend === 'wasm' ? undefined : env.gpu)
130
+ const plans = planBackends(probe, manifest.models, options)
131
+ if (plans.length === 0) throw new ScannerError('UNSUPPORTED_DEVICE', 'no model file fits this device')
132
+ const failures: string[] = []
133
+ for (const plan of plans) {
134
+ try {
135
+ return await openSession(plan, manifest, await modelBytes(plan.model), options, env)
136
+ } catch (err) {
137
+ // A download or checksum failure is not the device's fault: report it as is.
138
+ if (err instanceof ScannerError && (err.code === 'NETWORK' || err.code === 'MODEL_INTEGRITY')) throw err
139
+ failures.push(`${plan.backend}/${plan.model.precision}: ${err instanceof Error ? err.message : String(err)}`)
140
+ }
141
+ }
142
+ throw new ScannerError('UNSUPPORTED_DEVICE', `no execution provider loaded the model (${failures.join('; ')})`)
143
+ }
144
+
145
+ async function openSession(plan: Plan, manifest: Manifest, bytes: ArrayBuffer, options: RuntimeOptions, env: RuntimeEnv): Promise<EmbedBackend> {
146
+ const ort = await env.loadOrt(plan.backend)
147
+ const threads = plan.backend === 'wasm' ? wasmThreads(env, options) : 1
148
+ ort.env.wasm.numThreads = threads
149
+ ort.env.wasm.proxy = false
150
+ if (options.wasmPaths !== undefined) ort.env.wasm.wasmPaths = options.wasmPaths
151
+ ort.env.logLevel = 'error'
152
+ if (plan.backend === 'webgpu') ort.env.webgpu.powerPreference = 'high-performance'
153
+
154
+ const session = await ort.InferenceSession.create(new Uint8Array(bytes), {
155
+ executionProviders: [plan.backend],
156
+ graphOptimizationLevel: 'all',
157
+ })
158
+ const { input, output, token } = manifest.embedding
159
+ if (!session.inputNames.includes(input) || !session.outputNames.includes(output)) {
160
+ await session.release()
161
+ throw new ScannerError('MODEL_INTEGRITY', `model has no ${input} → ${output}`)
162
+ }
163
+ const { width, height } = manifest.preprocessing
164
+ const dims = manifest.dims
165
+ const plane = 3 * width * height
166
+
167
+ return {
168
+ backend: plan.backend,
169
+ precision: plan.model.precision,
170
+ threads,
171
+ async embed(data, n) {
172
+ if (data.length < n * plane) throw new RangeError('input is shorter than n crops')
173
+ const tensor = new ort.Tensor('float32', data.subarray(0, n * plane), [n, 3, height, width])
174
+ let hidden: Ort.Tensor | undefined
175
+ try {
176
+ const out = await session.run({ [input]: tensor })
177
+ hidden = out[output]
178
+ if (!hidden) throw new ScannerError('MODEL_INTEGRITY', `model returned no ${output}`)
179
+ const [bn, tokens, d] = hidden.dims as readonly number[]
180
+ if (bn !== n || d !== dims || tokens === undefined || token >= tokens) {
181
+ throw new ScannerError('MODEL_INTEGRITY', `model output ${hidden.dims.join('x')} does not match the manifest`)
182
+ }
183
+ const values = (await hidden.getData()) as Float32Array
184
+ const result = new Float32Array(n * dims)
185
+ for (let i = 0; i < n; i++) {
186
+ const from = (i * tokens + token) * dims
187
+ result.set(values.subarray(from, from + dims), i * dims)
188
+ }
189
+ return l2NormaliseRows(result, dims)
190
+ } finally {
191
+ tensor.dispose()
192
+ hidden?.dispose()
193
+ }
194
+ },
195
+ release: () => session.release(),
196
+ }
197
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Brute-force cosine top-K over a shard, and the rules that turn the ranking
3
+ * into an answer. Plain loops over typed arrays: no DOM, no allocation per row,
4
+ * the same in a Worker as anywhere else.
5
+ *
6
+ * Rows and queries are L2-normalised, so a dot product is the cosine. A crop
7
+ * embedded at several orientations scores each row by the best of them (the
8
+ * max-pool the server does).
9
+ */
10
+ import type { IdentifyCandidate, IdentifyResult } from './types.js'
11
+
12
+ export interface Ranked {
13
+ /** Row indexes, best first. */
14
+ readonly rows: Int32Array
15
+ readonly scores: Float32Array
16
+ }
17
+
18
+ /**
19
+ * The `k` best rows for a set of query vectors (`queries.length / dim` of
20
+ * them), each row scored by its best dot product with any query. Ties keep the
21
+ * lower row first.
22
+ */
23
+ export function topK(matrix: Float32Array, dim: number, queries: Float32Array, k: number): Ranked {
24
+ if (!Number.isInteger(dim) || dim <= 0 || matrix.length % dim !== 0) throw new RangeError('matrix is not rows × dim')
25
+ if (queries.length === 0 || queries.length % dim !== 0) throw new RangeError('queries are not n × dim')
26
+ const rowCount = matrix.length / dim
27
+ const q = queries.length / dim
28
+ const want = Math.min(Math.max(0, Math.floor(k)), rowCount)
29
+ const bestRows = new Int32Array(want)
30
+ const bestScores = new Float32Array(want).fill(Number.NEGATIVE_INFINITY)
31
+ if (want === 0) return { rows: bestRows, scores: bestScores }
32
+ let floor = Number.NEGATIVE_INFINITY
33
+ let filled = 0
34
+
35
+ for (let r = 0; r < rowCount; r++) {
36
+ const base = r * dim
37
+ let score: number
38
+ if (q === 2) {
39
+ let a = 0
40
+ let b = 0
41
+ for (let i = 0; i < dim; i++) {
42
+ const v = matrix[base + i] as number
43
+ a += v * (queries[i] as number)
44
+ b += v * (queries[dim + i] as number)
45
+ }
46
+ score = a > b ? a : b
47
+ } else {
48
+ score = Number.NEGATIVE_INFINITY
49
+ for (let j = 0; j < q; j++) {
50
+ let s = 0
51
+ const qb = j * dim
52
+ for (let i = 0; i < dim; i++) s += (matrix[base + i] as number) * (queries[qb + i] as number)
53
+ if (s > score) score = s
54
+ }
55
+ }
56
+ if (filled === want && score <= floor) continue
57
+ // Insert into the sorted list; strictly greater moves up, so ties keep the earlier row.
58
+ let at = filled === want ? want - 1 : filled
59
+ while (at > 0 && score > (bestScores[at - 1] as number)) {
60
+ bestScores[at] = bestScores[at - 1] as number
61
+ bestRows[at] = bestRows[at - 1] as number
62
+ at--
63
+ }
64
+ bestScores[at] = score
65
+ bestRows[at] = r
66
+ if (filled < want) filled++
67
+ floor = bestScores[want - 1] as number
68
+ }
69
+ return { rows: bestRows, scores: bestScores }
70
+ }
71
+
72
+ /** In place: scales each `dim`-wide row of `v` to unit length (a zero row stays zero). */
73
+ export function l2NormaliseRows(v: Float32Array, dim: number): Float32Array {
74
+ for (let base = 0; base < v.length; base += dim) {
75
+ let s = 0
76
+ for (let i = 0; i < dim; i++) s += (v[base + i] as number) ** 2
77
+ const inv = 1 / Math.max(Math.sqrt(s), 1e-12)
78
+ for (let i = 0; i < dim; i++) v[base + i] = (v[base + i] as number) * inv
79
+ }
80
+ return v
81
+ }
82
+
83
+ export interface Thresholds {
84
+ /** `kind: 'not_a_card'` when the best score is below this. */
85
+ readonly notACardBelow: number
86
+ /** `confident` when top1 − top2 ≥ this. */
87
+ readonly confidentMargin: number
88
+ }
89
+
90
+ /** Scores in answers carry six decimals, as the server's do. */
91
+ const round6 = (x: number): number => Math.round(x * 1e6) / 1e6
92
+
93
+ /**
94
+ * The answer for one crop from its ranking. `ranked` holds at least
95
+ * `max(top, 2)` rows where the shard has them: the margin needs a second one.
96
+ */
97
+ export function toResult(index: number, ranked: Ranked, ids: readonly string[], top: number, t: Thresholds): IdentifyResult {
98
+ const s0 = ranked.scores[0]
99
+ const s1 = ranked.scores[1]
100
+ const notACard = s0 === undefined || s0 < t.notACardBelow
101
+ const margin = s0 !== undefined && s1 !== undefined ? s0 - s1 : Number.POSITIVE_INFINITY
102
+ const candidates: IdentifyCandidate[] = []
103
+ const n = Math.min(top, ranked.rows.length)
104
+ for (let i = 0; i < n; i++) {
105
+ const id = ids[ranked.rows[i] as number]
106
+ if (id === undefined) throw new RangeError(`row ${ranked.rows[i]} has no id`)
107
+ candidates.push({ cardId: id, score: round6(ranked.scores[i] as number) })
108
+ }
109
+ return {
110
+ index,
111
+ kind: notACard ? 'not_a_card' : 'card',
112
+ confident: !notACard && margin >= t.confidentMargin,
113
+ candidates,
114
+ }
115
+ }