SeaWolf-AI commited on
Commit
d562f50
·
verified ·
1 Parent(s): 4aba844

Load the decision fine-tune (ONNX served from the Space), trained prompt format and scale, Egg check tab

Browse files
.gitattributes CHANGED
@@ -42,3 +42,8 @@ examples/textvqa-mc.jpg filter=lfs diff=lfs merge=lfs -text
42
  examples/vqav2-yesno.jpg filter=lfs diff=lfs merge=lfs -text
43
  models/egemma2-decide/onnx/audio_encoder_q4.onnx_data filter=lfs diff=lfs merge=lfs -text
44
  models/egemma2-decide/onnx/audio_encoder_q4f16.onnx_data filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
42
  examples/vqav2-yesno.jpg filter=lfs diff=lfs merge=lfs -text
43
  models/egemma2-decide/onnx/audio_encoder_q4.onnx_data filter=lfs diff=lfs merge=lfs -text
44
  models/egemma2-decide/onnx/audio_encoder_q4f16.onnx_data filter=lfs diff=lfs merge=lfs -text
45
+ models/egemma2-decide/onnx/model_q4.onnx_data filter=lfs diff=lfs merge=lfs -text
46
+ models/egemma2-decide/onnx/model_q4f16.onnx_data filter=lfs diff=lfs merge=lfs -text
47
+ models/egemma2-decide/onnx/vision_encoder_q4.onnx_data filter=lfs diff=lfs merge=lfs -text
48
+ models/egemma2-decide/onnx/vision_encoder_q4f16.onnx_data filter=lfs diff=lfs merge=lfs -text
49
+ models/egemma2-decide/tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,65 +1,68 @@
1
- ---
2
- title: Decide Live
3
- emoji: 🎯
4
- colorFrom: green
5
- colorTo: gray
6
- sdk: static
7
- pinned: false
8
- license: apache-2.0
9
- short_description: On-device decisions from camera and microphone
10
- models:
11
- - google/embeddinggemma-2
12
- - FINAL-Bench/EmbeddingGemma2-ZTC-ONNX
13
- ---
14
-
15
- # Decide Live: on-device decisions from camera and microphone
16
-
17
- A static page that makes zero-token decisions in the browser. A multimodal embedding model,
18
- [EmbeddingGemma 2](https://huggingface.co/google/embeddinggemma-2) (Apache-2.0) fine-tuned for decisions,
19
- runs locally with [transformers.js](https://github.com/huggingface/transformers.js) 4.3.1 on WebGPU,
20
- or on the CPU (WASM) when WebGPU is not available (slower, and it uses the larger fp16 files).
21
-
22
- The ONNX files (from the private model repo `FINAL-Bench/EmbeddingGemma2-ZTC-ONNX`, same file layout as
23
- [onnx-community/embeddinggemma-2-ONNX](https://huggingface.co/onnx-community/embeddinggemma-2-ONNX)) are served
24
- from this Space under `models/egemma2-decide/`, so the page needs no access to any other repository.
25
- There is no server and no API call per decision. The weights are downloaded once and cached by the browser.
26
- Camera frames, microphone audio, images and text are processed on the device and are never uploaded.
27
-
28
- ## What it does
29
-
30
- - **Live**: embeds camera frames (or a demo feed made from the sample images) and scores up to 4 decision
31
- cards. By default each card embeds the frame together with its own question, in the trained format
32
- (one pass per card). The "frame alone" setting embeds the frame once for all cards (faster, not the
33
- trained format). Each card shows probability bars, the current decision, and a confidence gate.
34
- - **Microphone / sound file**: the same cards for 2 second sound windows (16 kHz mono). Windows
35
- quieter than -55 dBFS are not sent to the model. The audio encoder is the unchanged base one.
36
- - **Single input**: one image, text, audio clip or short video, a question, an optional context,
37
- and 2 to 8 options written as `key: description` (or a yes / no question).
38
- - **Egg check**: 8 held-out candling photos (not used in training) and your own photo, decided with
39
- the trained egg prompt.
40
- - **Sample tests**: 18 image tests with gold answers, run in the browser with an accuracy report,
41
- plus a comparison of prompt formats.
42
-
43
- ## How a decision is computed (trained format)
44
-
45
- 1. Query: the media placeholders first, then `task: classification | query: {question}`, then
46
- `\nContext: {context}` when a context is given. The model embeds media and text together.
47
- 2. Each option: `title: none | text: {description}`, embedded once and cached.
48
- 3. Embedding: mean pooling over the attention mask, then L2 normalization (768 numbers).
49
- 4. P(option) = softmax(scale * cosine). The scale was learned in training: 19.96
50
- (temperature 1 / scale, about 0.0501), stored in `models/egemma2-decide/decision.json`.
51
-
52
- Yes / no questions keep the question in the query and score the options `Yes` and `No`.
53
- The older prompt formats of the first version of this page remain selectable for comparison.
54
-
55
- ## Files
56
-
57
- - `index.html`, `style.css`, `app.js`: the page.
58
- - `worker.js`: loads the model in a Web Worker and returns embeddings.
59
- - `models/egemma2-decide/`: ONNX model (q4, q4f16 and fp16 text and vision graphs, 4-bit audio graphs),
60
- config, processor and tokenizer files, and `decision.json` (scale and prompt formats).
61
- - `examples/`: sample images and `samples.json` (name, files, state, type, question, options, gold);
62
- `examples/egg-test/`: held-out egg candling photos.
63
- - `CREDITS.md`: sources and licenses of the sample images.
64
-
65
- Code: Apache-2.0. Model: derived from google/embeddinggemma-2 (Apache-2.0).
 
 
 
 
1
+ ---
2
+ title: Decide Live
3
+ emoji: 🎯
4
+ colorFrom: green
5
+ colorTo: gray
6
+ sdk: static
7
+ pinned: false
8
+ license: apache-2.0
9
+ short_description: On-device decisions from camera and microphone
10
+ models:
11
+ - google/embeddinggemma-2
12
+ - FINAL-Bench/EmbeddingGemma2-ZTC-ONNX
13
+ ---
14
+
15
+ # Decide Live: on-device decisions from camera and microphone
16
+
17
+ A static page that makes zero-token decisions in the browser. A multimodal embedding model,
18
+ [EmbeddingGemma 2](https://huggingface.co/google/embeddinggemma-2) (Apache-2.0) fine-tuned for decisions,
19
+ runs locally with [transformers.js](https://github.com/huggingface/transformers.js) 4.3.1 on WebGPU,
20
+ or on the CPU (WASM) when WebGPU is not available (much slower).
21
+
22
+ The ONNX files (from the private model repo `FINAL-Bench/EmbeddingGemma2-ZTC-ONNX`, same file layout as
23
+ [onnx-community/embeddinggemma-2-ONNX](https://huggingface.co/onnx-community/embeddinggemma-2-ONNX)) are served
24
+ from this Space under `models/egemma2-decide/`, so the page needs no access to any other repository.
25
+ There is no server and no API call per decision. The weights are downloaded once and cached by the browser.
26
+ Camera frames, microphone audio, images and text are processed on the device and are never uploaded.
27
+
28
+ ## What it does
29
+
30
+ - **Live**: embeds camera frames (or a demo feed made from the sample images) and scores up to 4 decision
31
+ cards. By default each card embeds the frame together with its own question, in the trained format
32
+ (one pass per card). The "frame alone" setting embeds the frame once for all cards (faster, not the
33
+ trained format). Each card shows probability bars, the current decision, and a confidence gate.
34
+ - **Microphone / sound file**: the same cards for 2 second sound windows (16 kHz mono). Windows
35
+ quieter than -55 dBFS are not sent to the model. The audio encoder is the unchanged base one.
36
+ - **Single input**: one image, text, audio clip or short video, a question, an optional context,
37
+ and 2 to 8 options written as `key: description` (or a yes / no question).
38
+ - **Egg check**: 8 held-out candling photos (not used in training) and your own photo, decided with
39
+ the trained egg prompt.
40
+ - **Sample tests**: 18 image tests with gold answers, run in the browser with an accuracy report,
41
+ plus a comparison of prompt formats.
42
+
43
+ ## How a decision is computed (trained format)
44
+
45
+ 1. Query: the media placeholders first, then `task: classification | query: {question}`, then
46
+ `\nContext: {context}` when a context is given. The model embeds media and text together.
47
+ 2. Each option: `title: none | text: {description}`, embedded once and cached.
48
+ 3. Embedding: mean pooling over the attention mask, then L2 normalization (768 numbers).
49
+ 4. P(option) = softmax(scale * cosine). The scale was learned in training: 19.96
50
+ (temperature 1 / scale, about 0.0501), stored in `models/egemma2-decide/decision.json`.
51
+
52
+ Yes / no questions keep the question in the query and score the options `Yes` and `No`.
53
+ The older prompt formats of the first version of this page remain selectable for comparison.
54
+
55
+ ## Files
56
+
57
+ - `index.html`, `style.css`, `app.js`: the page.
58
+ - `worker.js`: loads the model in a Web Worker and returns embeddings.
59
+ - `models/egemma2-decide/`: ONNX model, config, processor and tokenizer files, and `decision.json`
60
+ (scale and prompt formats). Text and vision graphs in `q4f16` (as in the model repo) and in a portable
61
+ `q4` (the model repo's `onnx_portable/` files: 4-bit weights, with plain 8-bit gathers for the embedding
62
+ and position tables, so the same files also run on the WASM runtime, which lacks GatherBlockQuantized),
63
+ plus the 4-bit audio encoder. fp16 and fp32 files are not copied here because Space storage is limited to 1 GB.
64
+ - `examples/`: sample images and `samples.json` (name, files, state, type, question, options, gold);
65
+ `examples/egg-test/`: held-out egg candling photos.
66
+ - `CREDITS.md`: sources and licenses of the sample images.
67
+
68
+ Code: Apache-2.0. Model: derived from google/embeddinggemma-2 (Apache-2.0).
index.html CHANGED
@@ -184,9 +184,8 @@
184
  <label class="col">Precision (applies on next load)
185
  <select id="dtype">
186
  <option value="auto">Auto: 4-bit (q4f16 on WebGPU with fp16 support, else q4)</option>
187
- <option value="q4">q4 (4-bit, works everywhere)</option>
188
  <option value="q4f16">q4f16 (4-bit weights, fp16 math; WebGPU with fp16 only)</option>
189
- <option value="fp16">fp16 (largest; WebGPU only)</option>
190
  </select>
191
  </label>
192
  <label class="col">Backend (applies on next load)
@@ -270,7 +269,7 @@
270
  <ul>
271
  <li>An embedding model compares meanings; it does not read, count or reason step by step. Questions about exact counts, small text or negation are hard for it.</li>
272
  <li>Probabilities depend on the temperature. They are comparable between options of one card, not calibrated against ground truth.</li>
273
- <li>WebGPU needs a recent Chrome, Edge or Safari. Without it the page falls back to the CPU (WASM): it works, but it downloads a larger fp16 model (about 880 MB, because the 4-bit graphs need an operator the CPU runtime lacks) and takes several seconds per image, so the live rate drops to a frame every few seconds.</li>
274
  <li>The fine-tuning targeted image decisions (including egg candling). The audio encoder is the unchanged base one, so sound decisions are not tuned.</li>
275
  </ul>
276
  <p class="note">Sample image credits: <a href="./CREDITS.md" target="_blank">CREDITS.md</a>. Code: Apache-2.0.</p>
 
184
  <label class="col">Precision (applies on next load)
185
  <select id="dtype">
186
  <option value="auto">Auto: 4-bit (q4f16 on WebGPU with fp16 support, else q4)</option>
187
+ <option value="q4">q4 (4-bit, works everywhere, also on the CPU)</option>
188
  <option value="q4f16">q4f16 (4-bit weights, fp16 math; WebGPU with fp16 only)</option>
 
189
  </select>
190
  </label>
191
  <label class="col">Backend (applies on next load)
 
269
  <ul>
270
  <li>An embedding model compares meanings; it does not read, count or reason step by step. Questions about exact counts, small text or negation are hard for it.</li>
271
  <li>Probabilities depend on the temperature. They are comparable between options of one card, not calibrated against ground truth.</li>
272
+ <li>WebGPU needs a recent Chrome, Edge or Safari. Without it the page falls back to the CPU (WASM): it works with the same 4-bit files (about 340 MB), but it takes several seconds per image (about 6 s at 70 image tokens, about 30 s at 280 in our test), so the live rate drops to a frame every few seconds.</li>
273
  <li>The fine-tuning targeted image decisions (including egg candling). The audio encoder is the unchanged base one, so sound decisions are not tuned.</li>
274
  </ul>
275
  <p class="note">Sample image credits: <a href="./CREDITS.md" target="_blank">CREDITS.md</a>. Code: Apache-2.0.</p>
models/egemma2-decide/onnx/model_q4.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eb6b3c8cae2c7a65272c6211313f34f70941df1d40869d4021be22ec27aabdee
3
+ size 490555
models/egemma2-decide/onnx/model_q4.onnx_data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0bdc17b2daccb303a367a306f914d9de3781ba58ac1d2c44f3ba7d0a626851e5
3
+ size 224360448
models/egemma2-decide/onnx/model_q4f16.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:53feeced79582d661e30adeaa9829cea90d92b1c78347e26fd123773580f71d0
3
+ size 495298
models/egemma2-decide/onnx/model_q4f16.onnx_data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0e293b62bf40b405a9f0e88985dc4a25a5bb05572ca314da4baad9400be3eae8
3
+ size 156862464
models/egemma2-decide/onnx/vision_encoder_q4.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:daea777fe94c02d19749c286fdd0f52d2cea56697a0f093c9b7f4652e9646a75
3
+ size 164811
models/egemma2-decide/onnx/vision_encoder_q4.onnx_data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6823b98325ae0b4e8108fb28f995f9dfb5496b43c8432c65c8c7d881eeb39fb3
3
+ size 114774016
models/egemma2-decide/onnx/vision_encoder_q4f16.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:572258f9700e2a23edaa478a717d4da29618206e24ccd4fb34c822181ab49e27
3
+ size 152866
models/egemma2-decide/onnx/vision_encoder_q4f16.onnx_data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:27a86792e1d3572b4667cf09cb59165e54a18e3e9bc59bf22f2c35b845ad052a
3
+ size 97614336
models/egemma2-decide/preprocessor_config.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dither": 0.0,
3
+ "feature_extractor_type": "Gemma4AudioFeatureExtractor",
4
+ "feature_size": 128,
5
+ "fft_length": 512,
6
+ "fft_overdrive": false,
7
+ "frame_length": 320,
8
+ "hop_length": 160,
9
+ "input_scale_factor": 1.0,
10
+ "max_frequency": 8000.0,
11
+ "mel_floor": 0.001,
12
+ "min_frequency": 0.0,
13
+ "padding_side": "right",
14
+ "padding_value": 0.0,
15
+ "per_bin_mean": null,
16
+ "per_bin_stddev": null,
17
+ "preemphasis": 0.0,
18
+ "preemphasis_htk_flavor": true,
19
+ "return_attention_mask": true,
20
+ "sampling_rate": 16000,
21
+ "processor_class": "EmbeddingGemma2Processor"
22
+ }
models/egemma2-decide/processor_config.json ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "audio_ms_per_token": 40,
3
+ "audio_seq_length": 280,
4
+ "feature_extractor": {
5
+ "dither": 0.0,
6
+ "feature_extractor_type": "Gemma4AudioFeatureExtractor",
7
+ "feature_size": 128,
8
+ "fft_length": 512,
9
+ "fft_overdrive": false,
10
+ "frame_length": 320,
11
+ "hop_length": 160,
12
+ "input_scale_factor": 1.0,
13
+ "max_frequency": 8000.0,
14
+ "mel_floor": 0.001,
15
+ "min_frequency": 0.0,
16
+ "padding_side": "right",
17
+ "padding_value": 0.0,
18
+ "per_bin_mean": null,
19
+ "per_bin_stddev": null,
20
+ "preemphasis": 0.0,
21
+ "preemphasis_htk_flavor": true,
22
+ "return_attention_mask": true,
23
+ "sampling_rate": 16000
24
+ },
25
+ "image_processor": {
26
+ "do_convert_rgb": true,
27
+ "do_normalize": false,
28
+ "do_rescale": true,
29
+ "do_resize": true,
30
+ "image_mean": [
31
+ 0.0,
32
+ 0.0,
33
+ 0.0
34
+ ],
35
+ "image_processor_type": "Gemma4ImageProcessor",
36
+ "image_seq_length": 280,
37
+ "image_std": [
38
+ 1.0,
39
+ 1.0,
40
+ 1.0
41
+ ],
42
+ "max_soft_tokens": 280,
43
+ "patch_size": 16,
44
+ "pooling_kernel_size": 3,
45
+ "resample": 3,
46
+ "rescale_factor": 0.00392156862745098
47
+ },
48
+ "image_seq_length": 280,
49
+ "processor_class": "EmbeddingGemma2Processor",
50
+ "video_processor": {
51
+ "add_timestamps": false,
52
+ "do_convert_rgb": true,
53
+ "do_normalize": false,
54
+ "do_rescale": true,
55
+ "do_resize": true,
56
+ "do_sample_frames": true,
57
+ "fps": 1,
58
+ "image_mean": [
59
+ 0.0,
60
+ 0.0,
61
+ 0.0
62
+ ],
63
+ "image_std": [
64
+ 1.0,
65
+ 1.0,
66
+ 1.0
67
+ ],
68
+ "max_frames": 32,
69
+ "max_soft_tokens": 140,
70
+ "overflow_strategy": "uniform",
71
+ "patch_size": 16,
72
+ "pooling_kernel_size": 3,
73
+ "resample": 3,
74
+ "rescale_factor": 0.00392156862745098,
75
+ "return_metadata": false,
76
+ "video_processor_type": "EmbeddingGemma2VideoProcessor"
77
+ }
78
+ }
models/egemma2-decide/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4d777ef5bdc1aa36227abdfb77c3e49e7b9c892d16e1b6bda41c393504828be4
3
+ size 32170510
models/egemma2-decide/tokenizer_config.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "audio_token": "<|audio|>",
3
+ "backend": "tokenizers",
4
+ "boa_token": "<|audio>",
5
+ "boi_token": "<|image>",
6
+ "bos_token": "<bos>",
7
+ "eoa_token": "<audio|>",
8
+ "eoc_token": "<channel|>",
9
+ "eoi_token": "<image|>",
10
+ "eos_token": "<eos>",
11
+ "eot_token": "<turn|>",
12
+ "escape_token": "<|\"|>",
13
+ "etc_token": "<tool_call|>",
14
+ "etd_token": "<tool|>",
15
+ "etr_token": "<tool_response|>",
16
+ "extra_special_tokens": [
17
+ "<|video|>"
18
+ ],
19
+ "image_token": "<|image|>",
20
+ "is_local": true,
21
+ "local_files_only": false,
22
+ "mask_token": "<mask>",
23
+ "model_max_length": 1000000000000000019884624838656,
24
+ "model_specific_special_tokens": {
25
+ "audio_token": "<|audio|>",
26
+ "boa_token": "<|audio>",
27
+ "boi_token": "<|image>",
28
+ "eoa_token": "<audio|>",
29
+ "eoc_token": "<channel|>",
30
+ "eoi_token": "<image|>",
31
+ "eot_token": "<turn|>",
32
+ "escape_token": "<|\"|>",
33
+ "etc_token": "<tool_call|>",
34
+ "etd_token": "<tool|>",
35
+ "etr_token": "<tool_response|>",
36
+ "image_token": "<|image|>",
37
+ "soc_token": "<|channel>",
38
+ "sot_token": "<|turn>",
39
+ "stc_token": "<|tool_call>",
40
+ "std_token": "<|tool>",
41
+ "str_token": "<|tool_response>",
42
+ "think_token": "<|think|>",
43
+ "video_token": "<|video|>"
44
+ },
45
+ "pad_token": "<pad>",
46
+ "padding_side": "right",
47
+ "processor_class": "EmbeddingGemma2Processor",
48
+ "soc_token": "<|channel>",
49
+ "sot_token": "<|turn>",
50
+ "stc_token": "<|tool_call>",
51
+ "std_token": "<|tool>",
52
+ "str_token": "<|tool_response>",
53
+ "think_token": "<|think|>",
54
+ "tokenizer_class": "GemmaTokenizer",
55
+ "unk_token": "<unk>",
56
+ "video_token": "<|video|>"
57
+ }
style.css CHANGED
@@ -180,3 +180,17 @@ td.no { color: var(--bad); font-weight: 700; }
180
  .hud { top: 44px; left: 8px; right: auto; font-size: 11.5px; }
181
  .overlay { left: 8px; right: 8px; bottom: 8px; gap: 10px; padding: 8px 10px; }
182
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  .hud { top: 44px; left: 8px; right: auto; font-size: 11.5px; }
181
  .overlay { left: 8px; right: 8px; bottom: 8px; gap: 10px; padding: 8px 10px; }
182
  }
183
+
184
+ /* egg check */
185
+ .egggrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; margin: 12px 0; }
186
+ .eggthumb { padding: 6px; display: flex; flex-direction: column; gap: 4px; align-items: stretch; font-size: 12px; color: var(--muted); }
187
+ .eggthumb img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; background: #000; display: block; }
188
+ .btnrow .drop.inline { display: inline-block; margin: 0; padding: 8px 12px; }
189
+ .eggresult { display: grid; grid-template-columns: minmax(120px, 220px) 1fr; gap: 16px; align-items: start; margin: 12px 0; }
190
+ .eggresult img { width: 100%; border-radius: 10px; background: #000; }
191
+ @media (max-width: 560px) { .eggresult { grid-template-columns: 1fr; } }
192
+ .okc { color: var(--good); font-weight: 600; margin-left: 6px; }
193
+ .noc { color: var(--bad); font-weight: 600; margin-left: 6px; }
194
+ img.mini { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; vertical-align: middle; margin-right: 6px; }
195
+ .dctx { display: block; font-size: 12px; font-weight: 400; color: var(--muted); }
196
+ .dedit input + input { margin-top: 6px; }
worker.js CHANGED
@@ -1,200 +1,211 @@
1
- // SPDX-License-Identifier: Apache-2.0
2
- // Embedding worker: loads google/embeddinggemma-2 (ONNX build) with transformers.js
3
- // and returns L2-normalized 768-d sentence embeddings. Runs entirely in the browser.
4
- // Loading approach follows the transformers.js usage in the model card of
5
- // onnx-community/embeddinggemma-2-ONNX (Apache-2.0).
6
- import {
7
- AutoConfig,
8
- AutoModel,
9
- AutoProcessor,
10
- RawImage,
11
- RawVideo,
12
- RawVideoFrame,
13
- env,
14
- } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.1/dist/transformers.min.js";
15
-
16
- const MODEL_ID = "onnx-community/embeddinggemma-2-ONNX";
17
- env.allowLocalModels = false;
18
- if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1; // static hosting is not cross-origin isolated
19
-
20
- let processor = null;
21
- let model = null;
22
- let loaded = null; // { device, dtype, audio }
23
- let queue = Promise.resolve();
24
-
25
- const post = (msg, transfer) => self.postMessage(msg, transfer || []);
26
- const state = (phase, label, extra = {}) => post({ type: "state", phase, label, ...extra });
27
-
28
- let gpuF16 = false;
29
- async function hasWebGPU() {
30
- try {
31
- if (!("gpu" in navigator)) return false;
32
- const adapter = await navigator.gpu.requestAdapter();
33
- gpuF16 = !!adapter?.features?.has("shader-f16");
34
- return !!adapter;
35
- } catch {
36
- return false;
37
- }
38
- }
39
-
40
- async function loadModel({ device = "auto", dtype = "q4", audio = false }) {
41
- if (loaded && (device === "auto" || device === loaded.device) && dtype === loaded.requested && (!audio || loaded.audio)) {
42
- return loaded;
43
- }
44
- if (model) {
45
- try { await model.dispose(); } catch {}
46
- model = null;
47
- loaded = null;
48
- }
49
- let candidates;
50
- if (device === "auto") candidates = (await hasWebGPU()) ? ["webgpu", "wasm"] : ["wasm"];
51
- else candidates = [device];
52
-
53
- if (!processor) {
54
- state("loading", "Loading processor");
55
- processor = await AutoProcessor.from_pretrained(MODEL_ID);
56
- }
57
- let lastErr = null;
58
- const requested = dtype;
59
- if (candidates.includes("webgpu") && device !== "auto") await hasWebGPU();
60
- for (const dev of candidates) {
61
- // "auto": 4-bit weights; fp16 math on WebGPU adapters that support shader-f16 (faster), else q4
62
- if (dev === "wasm") {
63
- // The quantized text and vision graphs use GatherBlockQuantized, which the WASM (CPU) runtime
64
- // does not implement, so on WASM the text and vision parts run in fp16. The audio graphs have no such op.
65
- const aq = requested === "auto" || requested === "q4f16" || requested === "fp16" ? "q4" : requested;
66
- dtype = audio ? { model: "fp16", vision_encoder: "fp16", audio_encoder: aq } : "fp16";
67
- } else {
68
- dtype = requested === "auto" ? (gpuF16 ? "q4f16" : "q4") : requested;
69
- }
70
- try {
71
- const config = await AutoConfig.from_pretrained(MODEL_ID);
72
- if (!audio) config.audio_config = null; // text + vision only (video uses the vision encoder)
73
- state("loading", `Downloading model (${dev}${audio ? ", with audio encoder" : ""})`, { progress: 0, device: dev });
74
- const t0 = performance.now();
75
- model = await AutoModel.from_pretrained(MODEL_ID, {
76
- config,
77
- device: dev,
78
- dtype,
79
- progress_callback: (p) => {
80
- if (p.status === "progress_total") state("loading", `Downloading model (${dev})`, { progress: p.progress, device: dev, loadedBytes: p.loaded, totalBytes: p.total });
81
- },
82
- });
83
- state("loading", "Warming up", { progress: 100, device: dev });
84
- const warm = await processor(["task: classification | query: warm up"]);
85
- const out = await model(warm);
86
- out.sentence_embedding.dispose?.();
87
- const label = typeof dtype === "string" ? dtype : Object.entries(dtype).map(([k, v]) => `${k.replace("_encoder", "")} ${v}`).join(", ");
88
- loaded = { device: dev, dtype: label, requested, audio, loadMs: performance.now() - t0 };
89
- state("ready", `Ready on ${dev.toUpperCase()} (${label})`, { device: dev, audio });
90
- return loaded;
91
- } catch (e) {
92
- lastErr = e;
93
- console.warn(`Loading on ${dev} failed`, e);
94
- if (model) { try { await model.dispose(); } catch {} model = null; }
95
- state("loading", `Loading on ${dev} failed, trying next backend`, { device: dev });
96
- }
97
- }
98
- state("error", `Could not load the model: ${lastErr?.message || lastErr}`);
99
- throw lastErr;
100
- }
101
-
102
- function rows(tensor) {
103
- const [n, d] = tensor.dims;
104
- const data = tensor.data;
105
- const out = [];
106
- for (let i = 0; i < n; i++) {
107
- const v = new Float32Array(d);
108
- let s = 0;
109
- for (let j = 0; j < d; j++) { v[j] = data[i * d + j]; s += v[j] * v[j]; }
110
- s = Math.sqrt(s) || 1;
111
- for (let j = 0; j < d; j++) v[j] /= s;
112
- out.push(v);
113
- }
114
- return out;
115
- }
116
-
117
- let lastModelMs = 0;
118
- async function run(inputs) {
119
- const tm = performance.now();
120
- const out = await model(inputs);
121
- lastModelMs = performance.now() - tm;
122
- const emb = rows(out.sentence_embedding);
123
- try { out.sentence_embedding.dispose?.(); } catch {}
124
- return emb;
125
- }
126
-
127
- // texts: array of strings, embedded in small batches
128
- async function embedTexts(texts) {
129
- const res = [];
130
- const B = 8;
131
- for (let i = 0; i < texts.length; i += B) {
132
- const batch = texts.slice(i, i + B);
133
- res.push(...(await run(await processor(batch))));
134
- }
135
- return res;
136
- }
137
-
138
- // One input that may interleave text with an image, audio clip or video.
139
- // With empty text the media is embedded on its own (no prefix, as the model card advises for media).
140
- async function embedInput(input) {
141
- const { kind } = input;
142
- const text = (input.text || "").trim();
143
- if (kind === "text" || !kind) {
144
- return (await run(await processor([text])))[0];
145
- }
146
- if (kind === "image" || kind === "rgba") {
147
- if (processor.image_processor) processor.image_processor.max_soft_tokens = input.budget || 280;
148
- const img = kind === "rgba"
149
- ? new RawImage(new Uint8ClampedArray(input.data), input.width, input.height, 4)
150
- : await RawImage.fromBlob(input.blob);
151
- if (!text) return (await run(await processor(null, img)))[0];
152
- const t = text.includes("<|image|>") ? text : `${text} <|image|>`;
153
- return (await run(await processor([t], [[img]])))[0];
154
- }
155
- if (kind === "audio") {
156
- if (!loaded?.audio) throw new Error("The audio encoder is not loaded.");
157
- const audio = input.audio instanceof Float32Array ? input.audio : new Float32Array(input.audio);
158
- if (!text) return (await run(await processor(null, null, audio)))[0];
159
- const t = text.includes("<|audio|>") ? text : `${text} <|audio|>`;
160
- return (await run(await processor([t], null, [audio])))[0];
161
- }
162
- if (kind === "video") {
163
- if (processor.image_processor) processor.image_processor.max_soft_tokens = input.budget || 280;
164
- const frames = input.frames.map((f) => new RawVideoFrame(new RawImage(new Uint8ClampedArray(f.data), f.width, f.height, 4), f.timestamp));
165
- const video = new RawVideo(frames, input.duration);
166
- if (!text) return (await run(await processor(null, null, null, video)))[0];
167
- const t = text.includes("<|video|>") ? text : `${text} <|video|>`;
168
- return (await run(await processor([t], null, null, [video])))[0];
169
- }
170
- throw new Error(`Unknown input kind: ${kind}`);
171
- }
172
-
173
- async function handle(msg) {
174
- const { id, op } = msg;
175
- try {
176
- if (op === "load") {
177
- const info = await loadModel(msg.options || {});
178
- post({ type: "result", id, result: info });
179
- return;
180
- }
181
- if (!model) throw new Error("Model is not loaded yet.");
182
- const t0 = performance.now();
183
- if (op === "embedTexts") {
184
- const embs = await embedTexts(msg.texts);
185
- post({ type: "result", id, result: { embeddings: embs, ms: performance.now() - t0 } }, embs.map((e) => e.buffer));
186
- } else if (op === "embedInput") {
187
- const emb = await embedInput(msg.input);
188
- post({ type: "result", id, result: { embedding: emb, ms: performance.now() - t0, modelMs: lastModelMs } }, [emb.buffer]);
189
- } else {
190
- throw new Error(`Unknown op ${op}`);
191
- }
192
- } catch (e) {
193
- post({ type: "error", id, message: e?.message || String(e) });
194
- }
195
- }
196
-
197
- self.addEventListener("message", ({ data }) => {
198
- // serialize all requests: one model, one queue
199
- queue = queue.then(() => handle(data));
200
- });
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Embedding worker: loads a decision-tuned EmbeddingGemma 2 (ONNX, same graph layout as
3
+ // onnx-community/embeddinggemma-2-ONNX) with transformers.js and returns L2-normalized
4
+ // 768-d embeddings (mean pooling over the attention mask, then L2 normalization).
5
+ // The model files are served next to this page (./models/...), so nothing is fetched from elsewhere.
6
+ import {
7
+ AutoConfig,
8
+ AutoModel,
9
+ AutoProcessor,
10
+ RawImage,
11
+ RawVideo,
12
+ RawVideoFrame,
13
+ env,
14
+ } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.1/dist/transformers.min.js";
15
+
16
+ const MODEL_ID = "egemma2-decide";
17
+ env.allowLocalModels = true;
18
+ env.allowRemoteModels = false;
19
+ env.localModelPath = new URL("./models/", self.location.href).pathname; // same-origin path, e.g. "/models/"
20
+ if (env.backends?.onnx?.wasm) env.backends.onnx.wasm.numThreads = 1; // static hosting is not cross-origin isolated
21
+
22
+ let processor = null;
23
+ let model = null;
24
+ let loaded = null; // { device, dtype, audio }
25
+ let decision = null; // { scale } from decision.json
26
+ let queue = Promise.resolve();
27
+
28
+ const post = (msg, transfer) => self.postMessage(msg, transfer || []);
29
+ const state = (phase, label, extra = {}) => post({ type: "state", phase, label, ...extra });
30
+
31
+ let gpuF16 = false;
32
+ async function hasWebGPU() {
33
+ try {
34
+ if (!("gpu" in navigator)) return false;
35
+ const adapter = await navigator.gpu.requestAdapter();
36
+ gpuF16 = !!adapter?.features?.has("shader-f16");
37
+ return !!adapter;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ async function loadModel({ device = "auto", dtype = "q4", audio = false }) {
44
+ if (loaded && (device === "auto" || device === loaded.device) && dtype === loaded.requested && (!audio || loaded.audio)) {
45
+ return loaded;
46
+ }
47
+ if (model) {
48
+ try { await model.dispose(); } catch {}
49
+ model = null;
50
+ loaded = null;
51
+ }
52
+ let candidates;
53
+ if (device === "auto") candidates = (await hasWebGPU()) ? ["webgpu", "wasm"] : ["wasm"];
54
+ else candidates = [device];
55
+
56
+ if (!decision) {
57
+ try { decision = await (await fetch(`${env.localModelPath}${MODEL_ID}/decision.json`)).json(); } catch { decision = {}; }
58
+ }
59
+ if (!processor) {
60
+ state("loading", "Loading processor");
61
+ processor = await AutoProcessor.from_pretrained(MODEL_ID);
62
+ }
63
+ let lastErr = null;
64
+ const requested = dtype;
65
+ if (candidates.includes("webgpu") && device !== "auto") await hasWebGPU();
66
+ for (const dev of candidates) {
67
+ // Files shipped with this page (Space storage is limited to 1 GB):
68
+ // q4: 4-bit weights with plain 8-bit gathers for the embedding and position tables, so the same files
69
+ // run on WebGPU and on the WASM (CPU) runtime, which lacks GatherBlockQuantized;
70
+ // q4f16: 4-bit weights with fp16 math (WebGPU with shader-f16 only);
71
+ // audio: one 4-bit audio encoder used with both.
72
+ const d = dev === "wasm" ? "q4" : requested === "auto" ? (gpuF16 ? "q4f16" : "q4") : requested;
73
+ dtype = audio ? { model: d, vision_encoder: d, audio_encoder: "q4" } : d;
74
+ try {
75
+ const config = await AutoConfig.from_pretrained(MODEL_ID);
76
+ if (!audio) config.audio_config = null; // text + vision only (video uses the vision encoder)
77
+ state("loading", `Downloading model (${dev}${audio ? ", with audio encoder" : ""})`, { progress: 0, device: dev });
78
+ const t0 = performance.now();
79
+ model = await AutoModel.from_pretrained(MODEL_ID, {
80
+ config,
81
+ device: dev,
82
+ dtype,
83
+ progress_callback: (p) => {
84
+ if (p.status === "progress_total") state("loading", `Downloading model (${dev})`, { progress: p.progress, device: dev, loadedBytes: p.loaded, totalBytes: p.total });
85
+ },
86
+ });
87
+ const sessionMs = performance.now() - t0;
88
+ state("loading", "Warming up", { progress: 100, device: dev });
89
+ const tw = performance.now();
90
+ const warm = await processor(["task: classification | query: warm up"]);
91
+ const out = await model(warm);
92
+ out.sentence_embedding.dispose?.();
93
+ const warmMs = performance.now() - tw;
94
+ const label = typeof dtype === "string" ? dtype : Object.entries(dtype).map(([k, v]) => `${k.replace("_encoder", "")} ${v}`).join(", ");
95
+ loaded = { device: dev, dtype: label, requested, audio, loadMs: performance.now() - t0, sessionMs, warmMs, scale: decision?.scale || null };
96
+ state("ready", `Ready on ${dev.toUpperCase()} (${label})`, { device: dev, audio });
97
+ return loaded;
98
+ } catch (e) {
99
+ lastErr = e;
100
+ console.warn(`Loading on ${dev} failed`, e);
101
+ if (model) { try { await model.dispose(); } catch {} model = null; }
102
+ state("loading", `Loading on ${dev} failed, trying next backend`, { device: dev });
103
+ }
104
+ }
105
+ state("error", `Could not load the model: ${lastErr?.message || lastErr}`);
106
+ throw lastErr;
107
+ }
108
+
109
+ function rows(tensor) {
110
+ const [n, d] = tensor.dims;
111
+ const data = tensor.data;
112
+ const out = [];
113
+ for (let i = 0; i < n; i++) {
114
+ const v = new Float32Array(d);
115
+ let s = 0;
116
+ for (let j = 0; j < d; j++) { v[j] = data[i * d + j]; s += v[j] * v[j]; }
117
+ s = Math.sqrt(s) || 1;
118
+ for (let j = 0; j < d; j++) v[j] /= s;
119
+ out.push(v);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ let lastModelMs = 0;
125
+ async function run(inputs) {
126
+ const tm = performance.now();
127
+ const out = await model(inputs);
128
+ lastModelMs = performance.now() - tm;
129
+ const emb = rows(out.sentence_embedding);
130
+ try { out.sentence_embedding.dispose?.(); } catch {}
131
+ return emb;
132
+ }
133
+
134
+ // texts: array of strings, embedded in small batches (right padding; padded tokens are masked out)
135
+ async function embedTexts(texts) {
136
+ const res = [];
137
+ const B = 8;
138
+ for (let i = 0; i < texts.length; i += B) {
139
+ const batch = texts.slice(i, i + B);
140
+ res.push(...(await run(await processor(batch))));
141
+ }
142
+ return res;
143
+ }
144
+
145
+ // One input that may interleave text with an image, audio clip or video.
146
+ // If the text already holds a placeholder (<|image|>, <|audio|>, <|video|>) it is used as is;
147
+ // otherwise the placeholder is put first, as in the trained prompt format. Empty text embeds the media alone.
148
+ async function embedInput(input) {
149
+ const { kind } = input;
150
+ const text = (input.text || "").trim();
151
+ const withPh = (ph) => (text.includes(ph) ? text : `${ph}${text}`);
152
+ if (kind === "text" || !kind) {
153
+ return (await run(await processor([text])))[0];
154
+ }
155
+ if (kind === "image" || kind === "rgba") {
156
+ if (processor.image_processor) processor.image_processor.max_soft_tokens = input.budget || 280;
157
+ const img = kind === "rgba"
158
+ ? new RawImage(new Uint8ClampedArray(input.data), input.width, input.height, 4)
159
+ : await RawImage.fromBlob(input.blob);
160
+ if (!text) return (await run(await processor(null, img)))[0];
161
+ return (await run(await processor([withPh("<|image|>")], [[img]])))[0];
162
+ }
163
+ if (kind === "audio") {
164
+ if (!loaded?.audio) throw new Error("The audio encoder is not loaded.");
165
+ const audio = input.audio instanceof Float32Array ? input.audio : new Float32Array(input.audio);
166
+ if (!text) return (await run(await processor(null, null, audio)))[0];
167
+ return (await run(await processor([withPh("<|audio|>")], null, [audio])))[0];
168
+ }
169
+ if (kind === "video") {
170
+ if (processor.image_processor) processor.image_processor.max_soft_tokens = input.budget || 280;
171
+ const frames = input.frames.map((f) => new RawVideoFrame(new RawImage(new Uint8ClampedArray(f.data), f.width, f.height, 4), f.timestamp));
172
+ const video = new RawVideo(frames, input.duration);
173
+ if (!text) return (await run(await processor(null, null, null, video)))[0];
174
+ return (await run(await processor([withPh("<|video|>")], null, null, [video])))[0];
175
+ }
176
+ throw new Error(`Unknown input kind: ${kind}`);
177
+ }
178
+
179
+ async function handle(msg) {
180
+ const { id, op } = msg;
181
+ try {
182
+ if (op === "load") {
183
+ const info = await loadModel(msg.options || {});
184
+ post({ type: "result", id, result: info });
185
+ return;
186
+ }
187
+ if (!model) throw new Error("Model is not loaded yet.");
188
+ const t0 = performance.now();
189
+ if (op === "embedTexts") {
190
+ const embs = await embedTexts(msg.texts);
191
+ post({ type: "result", id, result: { embeddings: embs, ms: performance.now() - t0 } }, embs.map((e) => e.buffer));
192
+ } else if (op === "embedInput") {
193
+ const emb = await embedInput(msg.input);
194
+ post({ type: "result", id, result: { embedding: emb, ms: performance.now() - t0, modelMs: lastModelMs } }, [emb.buffer]);
195
+ } else if (op === "embedInputs") {
196
+ // several prompts over the same media (one pass each); used by live cards in the trained format
197
+ const embs = [];
198
+ for (const inp of msg.inputs) embs.push(await embedInput(inp));
199
+ post({ type: "result", id, result: { embeddings: embs, ms: performance.now() - t0 } }, embs.map((e) => e.buffer));
200
+ } else {
201
+ throw new Error(`Unknown op ${op}`);
202
+ }
203
+ } catch (e) {
204
+ post({ type: "error", id, message: e?.message || String(e) });
205
+ }
206
+ }
207
+
208
+ self.addEventListener("message", ({ data }) => {
209
+ // serialize all requests: one model, one queue
210
+ queue = queue.then(() => handle(data));
211
+ });