@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.
- package/CHANGELOG.md +21 -0
- package/README.md +214 -2
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/geometry/homography.d.ts +75 -0
- package/dist/geometry/homography.d.ts.map +1 -0
- package/dist/geometry/homography.js +146 -0
- package/dist/geometry/homography.js.map +1 -0
- package/dist/identify/assets.d.ts +39 -0
- package/dist/identify/assets.d.ts.map +1 -0
- package/dist/identify/assets.js +167 -0
- package/dist/identify/assets.js.map +1 -0
- package/dist/identify/decode.d.ts +6 -0
- package/dist/identify/decode.d.ts.map +1 -0
- package/dist/identify/decode.js +53 -0
- package/dist/identify/decode.js.map +1 -0
- package/dist/identify/engine.d.ts +32 -0
- package/dist/identify/engine.d.ts.map +1 -0
- package/dist/identify/engine.js +127 -0
- package/dist/identify/engine.js.map +1 -0
- package/dist/identify/identifier.d.ts +81 -0
- package/dist/identify/identifier.d.ts.map +1 -0
- package/dist/identify/identifier.js +327 -0
- package/dist/identify/identifier.js.map +1 -0
- package/dist/identify/manifest.d.ts +60 -0
- package/dist/identify/manifest.d.ts.map +1 -0
- package/dist/identify/manifest.js +211 -0
- package/dist/identify/manifest.js.map +1 -0
- package/dist/identify/preprocess.d.ts +52 -0
- package/dist/identify/preprocess.d.ts.map +1 -0
- package/dist/identify/preprocess.js +195 -0
- package/dist/identify/preprocess.js.map +1 -0
- package/dist/identify/router.d.ts +60 -0
- package/dist/identify/router.d.ts.map +1 -0
- package/dist/identify/router.js +83 -0
- package/dist/identify/router.js.map +1 -0
- package/dist/identify/runtime.d.ts +69 -0
- package/dist/identify/runtime.d.ts.map +1 -0
- package/dist/identify/runtime.js +140 -0
- package/dist/identify/runtime.js.map +1 -0
- package/dist/identify/search.d.ts +35 -0
- package/dist/identify/search.d.ts.map +1 -0
- package/dist/identify/search.js +99 -0
- package/dist/identify/search.js.map +1 -0
- package/dist/identify/server.d.ts +60 -0
- package/dist/identify/server.d.ts.map +1 -0
- package/dist/identify/server.js +149 -0
- package/dist/identify/server.js.map +1 -0
- package/dist/identify/shard.d.ts +31 -0
- package/dist/identify/shard.d.ts.map +1 -0
- package/dist/identify/shard.js +146 -0
- package/dist/identify/shard.js.map +1 -0
- package/dist/identify/types.d.ts +107 -0
- package/dist/identify/types.d.ts.map +1 -0
- package/dist/identify/types.js +7 -0
- package/dist/identify/types.js.map +1 -0
- package/dist/identify/worker.d.ts +2 -0
- package/dist/identify/worker.d.ts.map +1 -0
- package/dist/identify/worker.js +13 -0
- package/dist/identify/worker.js.map +1 -0
- package/dist/identify/workerProtocol.d.ts +54 -0
- package/dist/identify/workerProtocol.d.ts.map +1 -0
- package/dist/identify/workerProtocol.js +113 -0
- package/dist/identify/workerProtocol.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/lease/verify.d.ts +69 -0
- package/dist/lease/verify.d.ts.map +1 -0
- package/dist/lease/verify.js +235 -0
- package/dist/lease/verify.js.map +1 -0
- package/dist/scanner.d.ts +10 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +58 -0
- package/dist/scanner.js.map +1 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/usage/meter.d.ts +83 -0
- package/dist/usage/meter.d.ts.map +1 -0
- package/dist/usage/meter.js +111 -0
- package/dist/usage/meter.js.map +1 -0
- package/dist/usage/store.d.ts +52 -0
- package/dist/usage/store.d.ts.map +1 -0
- package/dist/usage/store.js +198 -0
- package/dist/usage/store.js.map +1 -0
- package/package.json +64 -4
- package/src/errors.ts +24 -0
- package/src/geometry/homography.ts +178 -0
- package/src/identify/assets.ts +189 -0
- package/src/identify/decode.ts +54 -0
- package/src/identify/engine.ts +167 -0
- package/src/identify/identifier.ts +419 -0
- package/src/identify/manifest.ts +240 -0
- package/src/identify/preprocess.ts +227 -0
- package/src/identify/router.ts +115 -0
- package/src/identify/runtime.ts +197 -0
- package/src/identify/search.ts +115 -0
- package/src/identify/server.ts +167 -0
- package/src/identify/shard.ts +154 -0
- package/src/identify/types.ts +122 -0
- package/src/identify/worker.ts +13 -0
- package/src/identify/workerProtocol.ts +136 -0
- package/src/index.ts +71 -0
- package/src/lease/verify.ts +252 -0
- package/src/scanner.ts +56 -0
- package/src/types.ts +196 -0
- package/src/usage/meter.ts +165 -0
- package/src/usage/store.ts +245 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client of `POST /v1/identify`: crops out, the same answer shape the
|
|
3
|
+
* device produces back. One call is one request, bounded by one deadline that
|
|
4
|
+
* covers the headers and the body; the answer is validated field by field,
|
|
5
|
+
* never trusted.
|
|
6
|
+
*/
|
|
7
|
+
import { anySignal } from './assets.js'
|
|
8
|
+
import { MAX_TOP, type IdentifyCandidate, type IdentifyKind, type IdentifyPurpose, type IdentifyResult } from './types.js'
|
|
9
|
+
|
|
10
|
+
export type ServerFetch = (input: string, init: { method: 'POST'; body: FormData; headers: Record<string, string>; signal: AbortSignal }) => Promise<Response>
|
|
11
|
+
|
|
12
|
+
export interface ServerRequest {
|
|
13
|
+
readonly endpoint: string
|
|
14
|
+
readonly crops: readonly Blob[]
|
|
15
|
+
/** The lease, as bearer. */
|
|
16
|
+
readonly token: string
|
|
17
|
+
readonly top: number
|
|
18
|
+
readonly session?: string
|
|
19
|
+
readonly purpose?: IdentifyPurpose
|
|
20
|
+
readonly timeoutMs: number
|
|
21
|
+
readonly signal?: AbortSignal
|
|
22
|
+
readonly fetch: ServerFetch
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ServerFailureKind =
|
|
26
|
+
/** The deadline passed. */
|
|
27
|
+
| 'timeout'
|
|
28
|
+
/** The caller aborted. */
|
|
29
|
+
| 'aborted'
|
|
30
|
+
/** No answer at all: offline, DNS, CORS, connection reset. */
|
|
31
|
+
| 'network'
|
|
32
|
+
/** 401 / 403 / 409: this lease (its origin, domains, tenant or model version) is refused. */
|
|
33
|
+
| 'refused'
|
|
34
|
+
/** 429, with `retryAfterMs`. */
|
|
35
|
+
| 'rate_limited'
|
|
36
|
+
/** 5xx. */
|
|
37
|
+
| 'server_error'
|
|
38
|
+
/** Another 4xx: something about this request (e.g. a crop that is not a JPEG). */
|
|
39
|
+
| 'bad_request'
|
|
40
|
+
/** A 2xx whose body is not the contract. */
|
|
41
|
+
| 'malformed'
|
|
42
|
+
|
|
43
|
+
export type ServerAnswer =
|
|
44
|
+
| { readonly ok: true; readonly modelVersion: string; readonly results: readonly IdentifyResult[] }
|
|
45
|
+
| { readonly ok: false; readonly kind: ServerFailureKind; readonly status?: number; readonly retryAfterMs?: number; readonly detail: string }
|
|
46
|
+
|
|
47
|
+
const errorText = (err: unknown): string => (err instanceof Error ? err.message || err.name : String(err))
|
|
48
|
+
|
|
49
|
+
/** `Retry-After` as delay-seconds or an HTTP date; null when absent or unreadable. */
|
|
50
|
+
export function parseRetryAfter(value: string | null, now: number): number | null {
|
|
51
|
+
if (value === null) return null
|
|
52
|
+
const v = value.trim()
|
|
53
|
+
if (/^\d+$/.test(v)) return Number(v) * 1000
|
|
54
|
+
const at = Date.parse(v)
|
|
55
|
+
return Number.isNaN(at) ? null : Math.max(0, at - now)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function postIdentify(req: ServerRequest): Promise<ServerAnswer> {
|
|
59
|
+
if (req.signal?.aborted) return { ok: false, kind: 'aborted', detail: 'aborted' }
|
|
60
|
+
const deadline = AbortSignal.timeout(req.timeoutMs)
|
|
61
|
+
const signal = anySignal([deadline, req.signal])
|
|
62
|
+
|
|
63
|
+
const body = new FormData()
|
|
64
|
+
req.crops.forEach((crop, i) => body.append(`crop${i}`, crop, `crop${i}.jpg`))
|
|
65
|
+
body.append('top', String(req.top))
|
|
66
|
+
if (req.session !== undefined) body.append('session', req.session)
|
|
67
|
+
if (req.purpose !== undefined) body.append('purpose', req.purpose)
|
|
68
|
+
|
|
69
|
+
// Which signal fired decides the outcome, not the error's name: runtimes
|
|
70
|
+
// disagree on what they throw for each.
|
|
71
|
+
const failed = (kind: ServerFailureKind, detail: string, extra: { status?: number; retryAfterMs?: number } = {}): ServerAnswer => {
|
|
72
|
+
if (req.signal?.aborted) return { ok: false, kind: 'aborted', detail: 'aborted' }
|
|
73
|
+
if (deadline.aborted) return { ok: false, kind: 'timeout', detail: `no answer within ${req.timeoutMs} ms` }
|
|
74
|
+
return { ok: false, kind, detail, ...extra }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let res: Response
|
|
78
|
+
try {
|
|
79
|
+
res = await req.fetch(req.endpoint, { method: 'POST', body, headers: { Authorization: `Bearer ${req.token}` }, signal })
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return failed('network', errorText(err))
|
|
82
|
+
}
|
|
83
|
+
let data: unknown
|
|
84
|
+
try {
|
|
85
|
+
// Inside the same deadline as the request: a slow body is a late answer.
|
|
86
|
+
const type = res.headers.get('content-type') ?? ''
|
|
87
|
+
if (/[/+]json\b/i.test(type)) data = await res.json()
|
|
88
|
+
else await res.text() // drained, so a slow body still counts against the deadline
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return failed(res.ok ? 'malformed' : 'server_error', `unreadable answer: ${errorText(err)}`, { status: res.status })
|
|
91
|
+
}
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const detail = problemText(res.status, data)
|
|
94
|
+
if (res.status === 401 || res.status === 403 || res.status === 409) return failed('refused', detail, { status: res.status })
|
|
95
|
+
if (res.status === 429) {
|
|
96
|
+
const retryAfterMs = parseRetryAfter(res.headers.get('retry-after'), Date.now()) ?? 10_000
|
|
97
|
+
return failed('rate_limited', detail, { status: res.status, retryAfterMs })
|
|
98
|
+
}
|
|
99
|
+
if (res.status >= 500) return failed('server_error', detail, { status: res.status })
|
|
100
|
+
return failed('bad_request', detail, { status: res.status })
|
|
101
|
+
}
|
|
102
|
+
const parsed = parseAnswer(data, req.crops.length, req.top)
|
|
103
|
+
if (typeof parsed === 'string') return failed('malformed', `malformed answer: ${parsed}`, { status: res.status })
|
|
104
|
+
return { ok: true, ...parsed }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
108
|
+
|
|
109
|
+
function problemText(status: number, data: unknown): string {
|
|
110
|
+
if (isRecord(data)) {
|
|
111
|
+
const code = typeof data.code === 'string' ? data.code : `HTTP_${status}`
|
|
112
|
+
const detail = typeof data.detail === 'string' ? data.detail : typeof data.title === 'string' ? data.title : ''
|
|
113
|
+
return detail ? `${code}: ${detail}` : code
|
|
114
|
+
}
|
|
115
|
+
return `HTTP_${status}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The 200 body, checked field by field. Returns the results in index order,
|
|
120
|
+
* or a string naming the first thing wrong. A crop with no result is allowed:
|
|
121
|
+
* it stays unidentified, as on any failure.
|
|
122
|
+
*/
|
|
123
|
+
export function parseAnswer(data: unknown, cropCount: number, top: number): { modelVersion: string; results: IdentifyResult[] } | string {
|
|
124
|
+
if (!isRecord(data) || !Array.isArray(data.results)) return 'expected { modelVersion, results: [...] }'
|
|
125
|
+
if (typeof data.modelVersion !== 'string' || data.modelVersion.length === 0) return 'modelVersion is missing'
|
|
126
|
+
if (data.results.length > cropCount) return `${data.results.length} results for ${cropCount} crops`
|
|
127
|
+
const limit = Math.min(Math.max(top, 1), MAX_TOP)
|
|
128
|
+
|
|
129
|
+
const seen = new Set<number>()
|
|
130
|
+
const out: IdentifyResult[] = []
|
|
131
|
+
for (const [at, raw] of data.results.entries()) {
|
|
132
|
+
if (!isRecord(raw)) return `results[${at}] is not an object`
|
|
133
|
+
const { index, confident, candidates, kind, identificationId } = raw
|
|
134
|
+
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= cropCount) return `results[${at}].index is not a crop index`
|
|
135
|
+
if (seen.has(index)) return `index ${index} answered twice`
|
|
136
|
+
seen.add(index)
|
|
137
|
+
// An absent kind is a card (the stand-in's earlier answers had none).
|
|
138
|
+
if (kind !== undefined && kind !== 'card' && kind !== 'not_a_card') return `results[${at}].kind is not card or not_a_card`
|
|
139
|
+
const k: IdentifyKind = kind === 'not_a_card' ? 'not_a_card' : 'card'
|
|
140
|
+
if (typeof confident !== 'boolean') return `results[${at}].confident is not a boolean`
|
|
141
|
+
if (k === 'not_a_card' && confident) return `results[${at}] is a confident not_a_card`
|
|
142
|
+
if (!Array.isArray(candidates)) return `results[${at}].candidates is not an array`
|
|
143
|
+
if (candidates.length > limit) return `results[${at}] has ${candidates.length} candidates for top ${limit}`
|
|
144
|
+
if (identificationId !== undefined && identificationId !== null && (typeof identificationId !== 'string' || identificationId.length === 0)) {
|
|
145
|
+
return `results[${at}].identificationId is not a string`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const parsed: IdentifyCandidate[] = []
|
|
149
|
+
for (const [c, cand] of candidates.entries()) {
|
|
150
|
+
if (!isRecord(cand) || typeof cand.cardId !== 'string' || cand.cardId.length === 0) return `results[${at}].candidates[${c}].cardId is not a string`
|
|
151
|
+
if (typeof cand.score !== 'number' || !Number.isFinite(cand.score)) return `results[${at}].candidates[${c}].score is not a number`
|
|
152
|
+
const prev = parsed[parsed.length - 1]
|
|
153
|
+
if (prev && cand.score > prev.score) return `results[${at}].candidates are not best first`
|
|
154
|
+
parsed.push({ cardId: cand.cardId, score: cand.score })
|
|
155
|
+
}
|
|
156
|
+
if (confident && parsed.length === 0) return `results[${at}] is confident with no candidate`
|
|
157
|
+
out.push({
|
|
158
|
+
index,
|
|
159
|
+
kind: k,
|
|
160
|
+
confident,
|
|
161
|
+
candidates: parsed,
|
|
162
|
+
...(typeof identificationId === 'string' ? { identificationId } : {}),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
out.sort((a, b) => a.index - b.index)
|
|
166
|
+
return { modelVersion: data.modelVersion, results: out }
|
|
167
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads an index shard, `index_<domain>_v<N>.bin`, and its ids sidecar.
|
|
3
|
+
*
|
|
4
|
+
* The binary layout is `ml/src/dejadex_ml/index_format.py`'s, a cross-language
|
|
5
|
+
* contract (change the two together):
|
|
6
|
+
*
|
|
7
|
+
* offset size field
|
|
8
|
+
* 0 4 magic "DJIX"
|
|
9
|
+
* 4 2 format_version uint16 LE (1)
|
|
10
|
+
* 6 1 dtype 1 = fp16, 2 = int8
|
|
11
|
+
* 7 1 reserved 0
|
|
12
|
+
* 8 16 domain ASCII, NUL-padded
|
|
13
|
+
* 24 32 model_version ASCII, NUL-padded
|
|
14
|
+
* 56 32 index_version ASCII, NUL-padded
|
|
15
|
+
* 88 4 rows uint32 LE
|
|
16
|
+
* 92 2 dim uint16 LE
|
|
17
|
+
* 94 2 reserved 0
|
|
18
|
+
* 96 … rows × dim fp16, row-major, rows L2-normalised
|
|
19
|
+
*
|
|
20
|
+
* Only fp16 is read: the int8 layout exists in the format, and no shard ships
|
|
21
|
+
* with it yet.
|
|
22
|
+
*/
|
|
23
|
+
import { ScannerError } from '../errors.js'
|
|
24
|
+
|
|
25
|
+
export const HEADER_SIZE = 96
|
|
26
|
+
const MAGIC = 'DJIX'
|
|
27
|
+
const FORMAT_VERSION = 1
|
|
28
|
+
const DTYPE_FP16 = 1
|
|
29
|
+
/** Typed arrays use the platform's byte order; the file is little-endian. */
|
|
30
|
+
const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1
|
|
31
|
+
|
|
32
|
+
export interface ShardHeader {
|
|
33
|
+
readonly domain: string
|
|
34
|
+
readonly modelVersion: string
|
|
35
|
+
readonly indexVersion: string
|
|
36
|
+
readonly rows: number
|
|
37
|
+
readonly dim: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A loaded shard: rows as float32, ready for the dot products. */
|
|
41
|
+
export interface Shard extends ShardHeader {
|
|
42
|
+
/** rows × dim, row-major. */
|
|
43
|
+
readonly matrix: Float32Array
|
|
44
|
+
/** Row id per row (the catalogue's card id). */
|
|
45
|
+
readonly ids: readonly string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const integrity = (message: string): ScannerError => new ScannerError('MODEL_INTEGRITY', `index shard: ${message}`)
|
|
49
|
+
|
|
50
|
+
function text(bytes: Uint8Array, offset: number, size: number, name: string): string {
|
|
51
|
+
const raw = bytes.subarray(offset, offset + size)
|
|
52
|
+
const nul = raw.indexOf(0)
|
|
53
|
+
const end = nul === -1 ? size : nul
|
|
54
|
+
for (let i = end; i < size; i++) if (raw[i] !== 0) throw integrity(`${name} has bytes after its NUL padding`)
|
|
55
|
+
let out = ''
|
|
56
|
+
for (let i = 0; i < end; i++) {
|
|
57
|
+
const ch = raw[i] as number
|
|
58
|
+
if (ch < 0x20 || ch > 0x7e) throw integrity(`${name} is not printable ASCII`)
|
|
59
|
+
out += String.fromCharCode(ch)
|
|
60
|
+
}
|
|
61
|
+
if (out.length === 0) throw integrity(`${name} is empty`)
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseShardHeader(buffer: ArrayBuffer): ShardHeader & { readonly dtype: number } {
|
|
66
|
+
if (buffer.byteLength < HEADER_SIZE) throw integrity(`${buffer.byteLength} bytes is shorter than its header`)
|
|
67
|
+
const bytes = new Uint8Array(buffer, 0, HEADER_SIZE)
|
|
68
|
+
const view = new DataView(buffer, 0, HEADER_SIZE)
|
|
69
|
+
if (String.fromCharCode(...bytes.subarray(0, 4)) !== MAGIC) throw integrity('bad magic')
|
|
70
|
+
const version = view.getUint16(4, true)
|
|
71
|
+
if (version !== FORMAT_VERSION) throw integrity(`unsupported format version ${version}`)
|
|
72
|
+
if (bytes[7] !== 0 || view.getUint16(94, true) !== 0) throw integrity('reserved header bytes are not zero')
|
|
73
|
+
return {
|
|
74
|
+
dtype: bytes[6] as number,
|
|
75
|
+
domain: text(bytes, 8, 16, 'domain'),
|
|
76
|
+
modelVersion: text(bytes, 24, 32, 'model version'),
|
|
77
|
+
indexVersion: text(bytes, 56, 32, 'index version'),
|
|
78
|
+
rows: view.getUint32(88, true),
|
|
79
|
+
dim: view.getUint16(92, true),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** IEEE 754 half → float, by table (65 536 entries, built once). */
|
|
84
|
+
let HALF_TABLE: Float32Array | null = null
|
|
85
|
+
function halfTable(): Float32Array {
|
|
86
|
+
if (HALF_TABLE) return HALF_TABLE
|
|
87
|
+
const t = new Float32Array(65536)
|
|
88
|
+
for (let h = 0; h < 65536; h++) {
|
|
89
|
+
const sign = h & 0x8000 ? -1 : 1
|
|
90
|
+
const exp = (h >> 10) & 0x1f
|
|
91
|
+
const frac = h & 0x3ff
|
|
92
|
+
if (exp === 0) t[h] = sign * frac * 2 ** -24
|
|
93
|
+
else if (exp === 31) t[h] = frac ? Number.NaN : sign * Number.POSITIVE_INFINITY
|
|
94
|
+
else t[h] = sign * (1 + frac / 1024) * 2 ** (exp - 15)
|
|
95
|
+
}
|
|
96
|
+
HALF_TABLE = t
|
|
97
|
+
return t
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function halfToFloat(bits: number): number {
|
|
101
|
+
return halfTable()[bits & 0xffff] as number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ShardExpectations {
|
|
105
|
+
readonly domain: string
|
|
106
|
+
readonly modelVersion: string
|
|
107
|
+
readonly indexVersion: string
|
|
108
|
+
readonly rows: number
|
|
109
|
+
readonly dim: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parses and checks the binary against what the manifest promised, and decodes it to float32. */
|
|
113
|
+
export function decodeShard(buffer: ArrayBuffer, ids: readonly string[], expect: ShardExpectations): Shard {
|
|
114
|
+
const header = parseShardHeader(buffer)
|
|
115
|
+
if (header.dtype !== DTYPE_FP16) throw integrity(`dtype ${header.dtype} is not supported (fp16 only)`)
|
|
116
|
+
for (const key of ['domain', 'modelVersion', 'indexVersion', 'rows', 'dim'] as const) {
|
|
117
|
+
if (header[key] !== expect[key]) {
|
|
118
|
+
throw integrity(`header ${key} ${String(header[key])} does not match the manifest's ${String(expect[key])}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const size = HEADER_SIZE + header.rows * header.dim * 2
|
|
122
|
+
if (buffer.byteLength !== size) throw integrity(`${buffer.byteLength} bytes, the header implies ${size}`)
|
|
123
|
+
if (ids.length !== header.rows) throw integrity(`${ids.length} ids for ${header.rows} rows`)
|
|
124
|
+
const n = header.rows * header.dim
|
|
125
|
+
const table = halfTable()
|
|
126
|
+
const matrix = new Float32Array(n)
|
|
127
|
+
if (LITTLE_ENDIAN) {
|
|
128
|
+
const halves = new Uint16Array(buffer, HEADER_SIZE, n)
|
|
129
|
+
for (let i = 0; i < n; i++) matrix[i] = table[halves[i] as number] as number
|
|
130
|
+
} else {
|
|
131
|
+
const view = new DataView(buffer, HEADER_SIZE)
|
|
132
|
+
for (let i = 0; i < n; i++) matrix[i] = table[view.getUint16(i * 2, true)] as number
|
|
133
|
+
}
|
|
134
|
+
return { domain: header.domain, modelVersion: header.modelVersion, indexVersion: header.indexVersion, rows: header.rows, dim: header.dim, matrix, ids }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The ids sidecar, `index_<domain>_v<N>.json`. */
|
|
138
|
+
export function parseIds(json: unknown, expect: Pick<ShardExpectations, 'domain' | 'modelVersion' | 'indexVersion' | 'rows'>): string[] {
|
|
139
|
+
if (typeof json !== 'object' || json === null) throw integrity('ids sidecar is not an object')
|
|
140
|
+
const doc = json as Record<string, unknown>
|
|
141
|
+
if (doc.format !== 1) throw integrity(`ids sidecar format ${String(doc.format)} is not supported`)
|
|
142
|
+
if (doc.shard !== expect.domain || doc.modelVersion !== expect.modelVersion || doc.indexVersion !== expect.indexVersion) {
|
|
143
|
+
throw integrity('ids sidecar belongs to another shard or version')
|
|
144
|
+
}
|
|
145
|
+
const ids = doc.ids
|
|
146
|
+
if (!Array.isArray(ids) || ids.length !== expect.rows) throw integrity(`ids sidecar lists ${Array.isArray(ids) ? ids.length : 'no'} ids for ${expect.rows} rows`)
|
|
147
|
+
const seen = new Set<string>()
|
|
148
|
+
for (const id of ids) {
|
|
149
|
+
if (typeof id !== 'string' || id.length === 0) throw integrity('ids sidecar has a row id that is not a string')
|
|
150
|
+
if (seen.has(id)) throw integrity(`row id ${id} appears twice`)
|
|
151
|
+
seen.add(id)
|
|
152
|
+
}
|
|
153
|
+
return ids as string[]
|
|
154
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identifier: card crops in, catalogue ids out. This is the snapshot half
|
|
3
|
+
* of the SDK, usable on its own before the live pipeline (`createScanner`)
|
|
4
|
+
* exists. Its answer has the same shape whichever side identified the crops,
|
|
5
|
+
* the device or `POST /v1/identify`, so a host never needs to know which.
|
|
6
|
+
*
|
|
7
|
+
* Wire contract of the server route, which `httpIdentify` speaks:
|
|
8
|
+
*
|
|
9
|
+
* POST {serverUrl}/v1/identify multipart/form-data: crop0..crop8 (JPEG, one
|
|
10
|
+
* card each) and the optional text fields
|
|
11
|
+
* `top` (1..20), `purpose`, `session`
|
|
12
|
+
* Authorization: Bearer <lease token>
|
|
13
|
+
* → 200 { modelVersion,
|
|
14
|
+
* results: [ { index, kind?: 'card' | 'not_a_card', confident,
|
|
15
|
+
* candidates: [ { cardId, score } ], identificationId? } ] }
|
|
16
|
+
*
|
|
17
|
+
* Non-2xx answers are RFC 9457 Problem Details.
|
|
18
|
+
*/
|
|
19
|
+
import type { DomainCode, LeaseClaims } from '../types.js'
|
|
20
|
+
|
|
21
|
+
/** At most this many crops per call, as on the server route. */
|
|
22
|
+
export const MAX_CROPS_PER_REQUEST = 9
|
|
23
|
+
|
|
24
|
+
/** Candidates per crop when `top` is not given. */
|
|
25
|
+
export const DEFAULT_TOP = 5
|
|
26
|
+
|
|
27
|
+
/** The most candidates a call may ask for. */
|
|
28
|
+
export const MAX_TOP = 20
|
|
29
|
+
|
|
30
|
+
export interface IdentifyCandidate {
|
|
31
|
+
/** The catalogue id of the row (the shard's row id). */
|
|
32
|
+
readonly cardId: string
|
|
33
|
+
/** Cosine similarity, the max over the orientations tried. Higher is closer. */
|
|
34
|
+
readonly score: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a crop is: a card (identified, or not confidently), or not a card at
|
|
39
|
+
* all. There is no "unknown": a card with no confident match is
|
|
40
|
+
* `kind: 'card', confident: false`.
|
|
41
|
+
*/
|
|
42
|
+
export type IdentifyKind = 'card' | 'not_a_card'
|
|
43
|
+
|
|
44
|
+
export interface IdentifyResult {
|
|
45
|
+
/** Position of the crop in the call. */
|
|
46
|
+
readonly index: number
|
|
47
|
+
/** `not_a_card` when the best score is below the model version's `notACardBelow`. */
|
|
48
|
+
readonly kind: IdentifyKind
|
|
49
|
+
/** `top1 - top2 >= confidentMargin`; always false for `not_a_card`. */
|
|
50
|
+
readonly confident: boolean
|
|
51
|
+
/** Best first; `top` of them (candidates are returned for `not_a_card` too). */
|
|
52
|
+
readonly candidates: readonly IdentifyCandidate[]
|
|
53
|
+
/**
|
|
54
|
+
* Set when the service counted this answer as an identification itself
|
|
55
|
+
* (usage-metering): the SDK then writes no usage event for it.
|
|
56
|
+
*/
|
|
57
|
+
readonly identificationId?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Where an answer came from. Informational: results are the same shape either way. */
|
|
61
|
+
export type IdentifySource = 'device' | 'server'
|
|
62
|
+
|
|
63
|
+
export type IdentifyFailureReason = 'unavailable' | 'timeout' | 'rejected'
|
|
64
|
+
|
|
65
|
+
export type IdentifyOutcome =
|
|
66
|
+
| {
|
|
67
|
+
readonly ok: true
|
|
68
|
+
/** The model version the thresholds and the candidates belong to. */
|
|
69
|
+
readonly modelVersion: string
|
|
70
|
+
readonly results: readonly IdentifyResult[]
|
|
71
|
+
readonly source: IdentifySource
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
readonly ok: false
|
|
75
|
+
readonly reason: IdentifyFailureReason
|
|
76
|
+
/** A stable code first where there is one, e.g. `ALLOWANCE_EXHAUSTED: …`. */
|
|
77
|
+
readonly detail?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Why a crop is being identified; `retry` answers are never charged. */
|
|
81
|
+
export type IdentifyPurpose = 'live' | 'still' | 'retry'
|
|
82
|
+
|
|
83
|
+
export interface IdentifyCallOptions {
|
|
84
|
+
readonly signal?: AbortSignal
|
|
85
|
+
/** Candidates per crop, 1..20. Default 5. */
|
|
86
|
+
readonly top?: number
|
|
87
|
+
/**
|
|
88
|
+
* The host's scan session id (≤ 128 printable characters). Usage counts one
|
|
89
|
+
* identification per distinct card per session. Default: the identifier's
|
|
90
|
+
* own session, renewed by `newSession()`.
|
|
91
|
+
*/
|
|
92
|
+
readonly session?: string
|
|
93
|
+
readonly purpose?: IdentifyPurpose
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Where identification may run. `local` never sends an image anywhere. */
|
|
97
|
+
export type IdentificationMode = 'auto' | 'local' | 'server'
|
|
98
|
+
|
|
99
|
+
/** What the on-device engine is running on, once loaded. */
|
|
100
|
+
export interface DeviceEngineInfo {
|
|
101
|
+
readonly backend: 'webgpu' | 'wasm'
|
|
102
|
+
readonly precision: 'fp16' | 'fp32'
|
|
103
|
+
/** WASM threads in use (1 when the page is not cross-origin isolated). */
|
|
104
|
+
readonly threads: number
|
|
105
|
+
/** Measured at warm-up: one crop, every orientation, embed + search. */
|
|
106
|
+
readonly perCropMs: number
|
|
107
|
+
readonly modelVersion: string
|
|
108
|
+
readonly indexVersion: string
|
|
109
|
+
readonly shard: DomainCode
|
|
110
|
+
readonly rows: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface IdentifierStatus {
|
|
114
|
+
readonly lease: LeaseClaims | null
|
|
115
|
+
/** `idle` until the first load is asked for; `failed` keeps the server path usable. */
|
|
116
|
+
readonly device: 'idle' | 'loading' | 'ready' | 'failed'
|
|
117
|
+
readonly deviceInfo: DeviceEngineInfo | null
|
|
118
|
+
/** Why the device engine failed to load, when it did. */
|
|
119
|
+
readonly deviceError: string | null
|
|
120
|
+
/** Why the server route is skipped right now, or null when it is usable. */
|
|
121
|
+
readonly serverPaused: string | null
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identification Worker's entry point. `createIdentifier` starts it with
|
|
3
|
+
* `new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })`,
|
|
4
|
+
* the pattern Vite, webpack 5 and Rollup bundle without configuration. A host
|
|
5
|
+
* that builds its own Worker points it at `@dejadex/web/identify-worker`.
|
|
6
|
+
*
|
|
7
|
+
* Everything that touches the model runs here: decoding the crops, the
|
|
8
|
+
* resize, the session and the search. The main thread only routes.
|
|
9
|
+
*/
|
|
10
|
+
import { defaultEngineDeps } from './engine.js'
|
|
11
|
+
import { serveEngine, type WorkerScopeLike } from './workerProtocol.js'
|
|
12
|
+
|
|
13
|
+
serveEngine(self as unknown as WorkerScopeLike, defaultEngineDeps)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device engine across a Worker boundary: a request/response protocol by
|
|
3
|
+
* id, the Worker side (`serveEngine`) and the main-thread side
|
|
4
|
+
* (`connectWorkerEngine`). Crops cross as `Blob`s (structured-cloned, not
|
|
5
|
+
* decoded on the main thread); answers come back as plain data.
|
|
6
|
+
*/
|
|
7
|
+
import { ScannerError, type ScannerErrorCode } from '../errors.js'
|
|
8
|
+
import type { DeviceEngine, EngineConfig, EngineDeps } from './engine.js'
|
|
9
|
+
import { loadDeviceEngine } from './engine.js'
|
|
10
|
+
import type { DeviceEngineInfo, IdentifyResult } from './types.js'
|
|
11
|
+
|
|
12
|
+
export type EngineRequest =
|
|
13
|
+
| { readonly id: number; readonly op: 'load'; readonly config: EngineConfig }
|
|
14
|
+
| { readonly id: number; readonly op: 'identify'; readonly crops: readonly Blob[]; readonly top: number }
|
|
15
|
+
| { readonly id: number; readonly op: 'release' }
|
|
16
|
+
|
|
17
|
+
export type EngineResponse =
|
|
18
|
+
| { readonly id: number; readonly ok: true; readonly info: DeviceEngineInfo | null; readonly results?: readonly IdentifyResult[] }
|
|
19
|
+
| { readonly id: number; readonly ok: false; readonly code: ScannerErrorCode | 'ERROR'; readonly message: string }
|
|
20
|
+
|
|
21
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
|
|
22
|
+
|
|
23
|
+
/** What `serveEngine` needs of `self` in a Worker. */
|
|
24
|
+
export interface WorkerScopeLike {
|
|
25
|
+
addEventListener(type: 'message', listener: (event: { readonly data: unknown }) => void): void
|
|
26
|
+
postMessage(message: EngineResponse): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What `connectWorkerEngine` needs of a `Worker`. */
|
|
30
|
+
export interface WorkerLike {
|
|
31
|
+
addEventListener(type: 'message' | 'error' | 'messageerror', listener: (event: { readonly data?: unknown; readonly message?: string }) => void): void
|
|
32
|
+
postMessage(message: EngineRequest): void
|
|
33
|
+
terminate(): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const failure = (id: number, err: unknown): EngineResponse =>
|
|
37
|
+
err instanceof ScannerError
|
|
38
|
+
? { id, ok: false, code: err.code, message: err.message }
|
|
39
|
+
: { id, ok: false, code: 'ERROR', message: err instanceof Error ? err.message || err.name : String(err) }
|
|
40
|
+
|
|
41
|
+
/** Runs in the Worker: answers requests one at a time, in order. */
|
|
42
|
+
export function serveEngine(scope: WorkerScopeLike, deps: () => Promise<EngineDeps>): void {
|
|
43
|
+
let engine: DeviceEngine | null = null
|
|
44
|
+
let queue: Promise<void> = Promise.resolve()
|
|
45
|
+
|
|
46
|
+
const handle = async (req: EngineRequest): Promise<EngineResponse> => {
|
|
47
|
+
switch (req.op) {
|
|
48
|
+
case 'load':
|
|
49
|
+
await engine?.release()
|
|
50
|
+
engine = null
|
|
51
|
+
engine = await loadDeviceEngine(req.config, await deps())
|
|
52
|
+
return { id: req.id, ok: true, info: engine.info }
|
|
53
|
+
case 'identify': {
|
|
54
|
+
if (!engine) throw new ScannerError('INVALID_OPTIONS', 'the device engine is not loaded')
|
|
55
|
+
const results = await engine.identify(req.crops, req.top)
|
|
56
|
+
return { id: req.id, ok: true, info: engine.info, results }
|
|
57
|
+
}
|
|
58
|
+
case 'release':
|
|
59
|
+
await engine?.release()
|
|
60
|
+
engine = null
|
|
61
|
+
return { id: req.id, ok: true, info: null }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
scope.addEventListener('message', (event) => {
|
|
66
|
+
const req = event.data as EngineRequest
|
|
67
|
+
if (typeof req !== 'object' || req === null || typeof req.id !== 'number') return
|
|
68
|
+
queue = queue.then(async () => {
|
|
69
|
+
let res: EngineResponse
|
|
70
|
+
try {
|
|
71
|
+
res = await handle(req)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
res = failure(req.id, err)
|
|
74
|
+
}
|
|
75
|
+
scope.postMessage(res)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Main thread: loads the engine in `worker` and returns a `DeviceEngine` that forwards to it. */
|
|
81
|
+
export function connectWorkerEngine(worker: WorkerLike, config: EngineConfig): Promise<DeviceEngine> {
|
|
82
|
+
let nextId = 1
|
|
83
|
+
const pending = new Map<number, { resolve: (r: EngineResponse & { ok: true }) => void; reject: (e: Error) => void }>()
|
|
84
|
+
let dead: Error | null = null
|
|
85
|
+
|
|
86
|
+
const failAll = (err: Error) => {
|
|
87
|
+
dead = err
|
|
88
|
+
for (const p of pending.values()) p.reject(err)
|
|
89
|
+
pending.clear()
|
|
90
|
+
}
|
|
91
|
+
worker.addEventListener('message', (event) => {
|
|
92
|
+
const res = event.data as EngineResponse
|
|
93
|
+
const p = typeof res === 'object' && res !== null ? pending.get(res.id) : undefined
|
|
94
|
+
if (!p) return
|
|
95
|
+
pending.delete(res.id)
|
|
96
|
+
if (res.ok) p.resolve(res)
|
|
97
|
+
else p.reject(new ScannerError(res.code === 'ERROR' ? 'UNSUPPORTED_DEVICE' : res.code, res.message))
|
|
98
|
+
})
|
|
99
|
+
worker.addEventListener('error', (event) => {
|
|
100
|
+
failAll(new ScannerError('UNSUPPORTED_DEVICE', `the identification Worker failed: ${event.message ?? 'unknown error'}`))
|
|
101
|
+
})
|
|
102
|
+
worker.addEventListener('messageerror', () => {
|
|
103
|
+
failAll(new ScannerError('UNSUPPORTED_DEVICE', 'the identification Worker sent a message that could not be read'))
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const call = (req: DistributiveOmit<EngineRequest, 'id'>): Promise<EngineResponse & { ok: true }> => {
|
|
107
|
+
if (dead) return Promise.reject(dead)
|
|
108
|
+
const id = nextId++
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
pending.set(id, { resolve, reject })
|
|
111
|
+
worker.postMessage({ ...req, id } as EngineRequest)
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return call({ op: 'load', config }).then((loaded) => {
|
|
116
|
+
let info = loaded.info as DeviceEngineInfo
|
|
117
|
+
return {
|
|
118
|
+
get info() {
|
|
119
|
+
return info
|
|
120
|
+
},
|
|
121
|
+
async identify(crops, top) {
|
|
122
|
+
const res = await call({ op: 'identify', crops, top })
|
|
123
|
+
if (res.info) info = res.info
|
|
124
|
+
return [...(res.results ?? [])]
|
|
125
|
+
},
|
|
126
|
+
async release() {
|
|
127
|
+
try {
|
|
128
|
+
if (!dead) await call({ op: 'release' })
|
|
129
|
+
} finally {
|
|
130
|
+
worker.terminate()
|
|
131
|
+
failAll(new ScannerError('INVALID_OPTIONS', 'the device engine was released'))
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry point of the web SDK.
|
|
3
|
+
*
|
|
4
|
+
* Real today: the homography maths (`geometry/`), and identification of card
|
|
5
|
+
* crops (`createIdentifier`: on the device or through the server, under a
|
|
6
|
+
* verified lease, with usage counted on the device). Stubbed with final
|
|
7
|
+
* shapes: `createScanner` (the live pipeline). See README.md.
|
|
8
|
+
*/
|
|
9
|
+
export { createScanner } from './scanner.js'
|
|
10
|
+
export { createIdentifier, type Identifier, type IdentifierOptions } from './identify/identifier.js'
|
|
11
|
+
export {
|
|
12
|
+
DEFAULT_TOP,
|
|
13
|
+
MAX_CROPS_PER_REQUEST,
|
|
14
|
+
MAX_TOP,
|
|
15
|
+
type DeviceEngineInfo,
|
|
16
|
+
type IdentificationMode,
|
|
17
|
+
type IdentifierStatus,
|
|
18
|
+
type IdentifyCallOptions,
|
|
19
|
+
type IdentifyCandidate,
|
|
20
|
+
type IdentifyFailureReason,
|
|
21
|
+
type IdentifyKind,
|
|
22
|
+
type IdentifyOutcome,
|
|
23
|
+
type IdentifyPurpose,
|
|
24
|
+
type IdentifyResult,
|
|
25
|
+
type IdentifySource,
|
|
26
|
+
} from './identify/types.js'
|
|
27
|
+
export type { RuntimeOptions } from './identify/runtime.js'
|
|
28
|
+
export type { WorkerLike } from './identify/workerProtocol.js'
|
|
29
|
+
export {
|
|
30
|
+
LeaseVerificationError,
|
|
31
|
+
verifyLease,
|
|
32
|
+
type LeaseFailure,
|
|
33
|
+
type LeasePublicKey,
|
|
34
|
+
type VerifyLeaseOptions,
|
|
35
|
+
} from './lease/verify.js'
|
|
36
|
+
export type { DrainResult } from './usage/meter.js'
|
|
37
|
+
export { indexedDbUsageStore, memoryUsageStore, type UsageStore } from './usage/store.js'
|
|
38
|
+
export { ScannerError, type ScannerErrorCode } from './errors.js'
|
|
39
|
+
export {
|
|
40
|
+
IDENTITY,
|
|
41
|
+
UNIT_QUAD,
|
|
42
|
+
applyHomography,
|
|
43
|
+
invertHomography,
|
|
44
|
+
multiplyHomography,
|
|
45
|
+
quadToCssMatrix3d,
|
|
46
|
+
toCssMatrix3d,
|
|
47
|
+
unitSquareToQuad,
|
|
48
|
+
type ElementSize,
|
|
49
|
+
type Homography,
|
|
50
|
+
type Point,
|
|
51
|
+
type Quad,
|
|
52
|
+
} from './geometry/homography.js'
|
|
53
|
+
export type {
|
|
54
|
+
Candidate,
|
|
55
|
+
DeviceTier,
|
|
56
|
+
DomainCode,
|
|
57
|
+
ExternalIds,
|
|
58
|
+
FrameSourceInput,
|
|
59
|
+
Lease,
|
|
60
|
+
LeaseClaims,
|
|
61
|
+
LeaseGrant,
|
|
62
|
+
LeaseRequest,
|
|
63
|
+
PrintId,
|
|
64
|
+
Scanner,
|
|
65
|
+
ScannerOptions,
|
|
66
|
+
ScannerState,
|
|
67
|
+
TrackResult,
|
|
68
|
+
UsageBatch,
|
|
69
|
+
UsageReport,
|
|
70
|
+
VariantCode,
|
|
71
|
+
} from './types.js'
|