Spaces:
Running on Zero
Running on Zero
Add browser-based MobileNetV4 image demo
Browse files- README.md +19 -5
- app.mjs +99 -0
- index.html +39 -17
- input.mjs +7 -0
- style.css +59 -28
- tests/input.test.mjs +9 -0
- worker.mjs +22 -0
README.md
CHANGED
|
@@ -1,10 +1,24 @@
|
|
| 1 |
---
|
| 2 |
title: Lens Image Classifier
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Lens Image Classifier
|
| 3 |
+
emoji: 🔎
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
+
short_description: Recognize everyday objects privately in your browser
|
| 9 |
+
models:
|
| 10 |
+
- onnx-community/mobilenetv4_conv_small.e2400_r224_in1k
|
| 11 |
+
license: apache-2.0
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Lens
|
| 15 |
+
|
| 16 |
+
A small image classification demo using [MobileNetV4](https://huggingface.co/onnx-community/mobilenetv4_conv_small.e2400_r224_in1k) and [Transformers.js](https://huggingface.co/docs/transformers.js).
|
| 17 |
+
|
| 18 |
+
Choose a PNG, JPEG, or WebP image (up to 15 MB), or try the sample tiger. The model returns its top five ImageNet labels and scores. These are model scores, not guarantees; the model only recognizes its 1,000 training categories.
|
| 19 |
+
|
| 20 |
+
Inference runs in a browser worker with WebAssembly. Your selected image stays on your device. The browser downloads and caches the model on first use; internet access is needed for the library, model, and sample image. No API key or server GPU is required.
|
| 21 |
+
|
| 22 |
+
The sample comes from the [Transformers.js documentation dataset](https://huggingface.co/datasets/Xenova/transformers.js-docs).
|
| 23 |
+
|
| 24 |
+
Input validation checks: `node --test tests/input.test.mjs`.
|
app.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { validateImage } from './input.mjs';
|
| 2 |
+
|
| 3 |
+
const fileInput = document.querySelector('#file');
|
| 4 |
+
const sample = document.querySelector('#sample');
|
| 5 |
+
const preview = document.querySelector('#preview');
|
| 6 |
+
const status = document.querySelector('#status');
|
| 7 |
+
const results = document.querySelector('#results');
|
| 8 |
+
let worker;
|
| 9 |
+
let imageURL;
|
| 10 |
+
|
| 11 |
+
function setBusy(busy) {
|
| 12 |
+
fileInput.disabled = busy;
|
| 13 |
+
sample.disabled = busy;
|
| 14 |
+
results.setAttribute('aria-busy', String(busy));
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function showError(message) {
|
| 18 |
+
status.textContent = message;
|
| 19 |
+
status.className = 'error';
|
| 20 |
+
setBusy(false);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function getWorker() {
|
| 24 |
+
if (worker) return worker;
|
| 25 |
+
worker = new Worker(new URL('./worker.mjs', import.meta.url), { type: 'module' });
|
| 26 |
+
worker.onmessage = ({ data }) => {
|
| 27 |
+
if (data.type === 'status') status.textContent = data.message;
|
| 28 |
+
if (data.type === 'error') showError(data.message);
|
| 29 |
+
if (data.type !== 'result') return;
|
| 30 |
+
results.replaceChildren();
|
| 31 |
+
for (const { label, score } of data.predictions) {
|
| 32 |
+
const item = document.createElement('li');
|
| 33 |
+
const row = document.createElement('div');
|
| 34 |
+
row.className = 'prediction';
|
| 35 |
+
const name = document.createElement('strong');
|
| 36 |
+
name.textContent = label;
|
| 37 |
+
const percent = document.createElement('span');
|
| 38 |
+
percent.textContent = `${(score * 100).toFixed(1)}%`;
|
| 39 |
+
const bar = document.createElement('meter');
|
| 40 |
+
bar.min = 0;
|
| 41 |
+
bar.max = 1;
|
| 42 |
+
bar.value = score;
|
| 43 |
+
bar.setAttribute('aria-label', `${label} model score`);
|
| 44 |
+
row.append(name, percent);
|
| 45 |
+
item.append(row, bar);
|
| 46 |
+
results.append(item);
|
| 47 |
+
}
|
| 48 |
+
document.querySelector('#empty-results').hidden = true;
|
| 49 |
+
results.hidden = false;
|
| 50 |
+
status.textContent = 'Done. Choose another image to explore.';
|
| 51 |
+
setBusy(false);
|
| 52 |
+
};
|
| 53 |
+
worker.onerror = () => {
|
| 54 |
+
worker.terminate();
|
| 55 |
+
worker = undefined;
|
| 56 |
+
showError('The model could not start. Check your connection and try again in a current browser.');
|
| 57 |
+
};
|
| 58 |
+
return worker;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function classify(src, alt) {
|
| 62 |
+
setBusy(true);
|
| 63 |
+
status.className = '';
|
| 64 |
+
status.textContent = 'Opening image…';
|
| 65 |
+
results.hidden = true;
|
| 66 |
+
document.querySelector('#empty-results').hidden = false;
|
| 67 |
+
try {
|
| 68 |
+
preview.src = src;
|
| 69 |
+
preview.alt = alt;
|
| 70 |
+
await preview.decode();
|
| 71 |
+
preview.hidden = false;
|
| 72 |
+
document.querySelector('#placeholder').hidden = true;
|
| 73 |
+
status.textContent = 'Loading model on first use. This may take a moment…';
|
| 74 |
+
getWorker().postMessage(src);
|
| 75 |
+
} catch {
|
| 76 |
+
preview.hidden = true;
|
| 77 |
+
document.querySelector('#placeholder').hidden = false;
|
| 78 |
+
showError('Could not open this image. Choose a valid PNG, JPEG, or WebP and try again.');
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
fileInput.addEventListener('change', () => {
|
| 83 |
+
const file = fileInput.files[0];
|
| 84 |
+
fileInput.value = '';
|
| 85 |
+
if (!file) return;
|
| 86 |
+
try {
|
| 87 |
+
validateImage(file);
|
| 88 |
+
if (imageURL) URL.revokeObjectURL(imageURL);
|
| 89 |
+
imageURL = URL.createObjectURL(file);
|
| 90 |
+
classify(imageURL, file.name);
|
| 91 |
+
} catch (error) {
|
| 92 |
+
showError(error.message);
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
sample.addEventListener('click', () => classify(
|
| 97 |
+
'https://huggingface.co/datasets/Xenova/transformers.js-docs/resolve/main/tiger.jpg',
|
| 98 |
+
'Sample photograph of a tiger',
|
| 99 |
+
));
|
index.html
CHANGED
|
@@ -1,19 +1,41 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<meta name="description" content="Explore image recognition with MobileNetV4. Photos are classified locally in your browser.">
|
| 7 |
+
<title>Lens — Image classifier</title>
|
| 8 |
+
<link rel="stylesheet" href="style.css">
|
| 9 |
+
<script type="module" src="app.mjs"></script>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<main>
|
| 13 |
+
<header><a class="brand" href="./" aria-label="Lens home"><span class="mark" aria-hidden="true">◉</span> lens<span class="brand-note">/ vision playground</span></a><span class="badge">Runs on your device</span></header>
|
| 14 |
+
<section class="intro">
|
| 15 |
+
<p class="eyebrow">A LITTLE EXPERIMENT IN COMPUTER VISION</p>
|
| 16 |
+
<h1>What does AI <em>see?</em></h1>
|
| 17 |
+
<p class="lede">Give it a photo. Explore what an image model recognizes,<br class="desktop"> one prediction at a time.</p>
|
| 18 |
+
</section>
|
| 19 |
+
<div class="workspace">
|
| 20 |
+
<section class="panel input-panel" aria-labelledby="input-title">
|
| 21 |
+
<div class="panel-heading"><h2 id="input-title"><span>01</span> Your image</h2><span class="small">PNG · JPEG · WebP</span></div>
|
| 22 |
+
<div class="preview-area">
|
| 23 |
+
<div id="placeholder"><svg width="52" height="52" viewBox="0 0 48 48" fill="none" aria-hidden="true"><rect x="7" y="7" width="34" height="34" rx="7" stroke="currentColor" stroke-width="1.5"/><circle cx="17" cy="17" r="3" stroke="currentColor" stroke-width="1.5"/><path d="m8 34 10-10 7 7 6-6 10 10" stroke="currentColor" stroke-width="1.5"/></svg><h3>A fresh perspective starts here.</h3><p>Choose a photo of an animal or everyday object.</p></div>
|
| 24 |
+
<img id="preview" alt="Selected image to classify" hidden>
|
| 25 |
+
</div>
|
| 26 |
+
<label class="upload" for="file">Choose an image <span aria-hidden="true">↗</span><input id="file" type="file" accept="image/png,image/jpeg,image/webp" aria-describedby="file-help"></label>
|
| 27 |
+
<div class="input-bottom"><span id="file-help" class="small">Up to 15 MB · stays on your device</span><button id="sample" type="button">Try a tiger →</button></div>
|
| 28 |
+
</section>
|
| 29 |
+
<section class="panel results-panel" aria-labelledby="results-title">
|
| 30 |
+
<div class="panel-heading"><h2 id="results-title"><span>02</span> Model predictions</h2><span class="small">TOP 5</span></div>
|
| 31 |
+
<div id="empty-results"><span class="orbit" aria-hidden="true">✳</span><h3>Meet your model’s point of view.</h3><p>Choose an image to reveal its top five guesses and their scores.</p></div>
|
| 32 |
+
<ol id="results" aria-label="Top image predictions" hidden></ol>
|
| 33 |
+
<p id="status" role="status" aria-live="polite">Ready when you are.</p>
|
| 34 |
+
<p class="results-note">Trained on 1,000 ImageNet categories. Predictions can be wrong, especially for images outside those categories.</p>
|
| 35 |
+
</section>
|
| 36 |
+
</div>
|
| 37 |
+
<footer><p><span class="dot" aria-hidden="true"></span> Powered by <a href="https://huggingface.co/onnx-community/mobilenetv4_conv_small.e2400_r224_in1k" target="_blank" rel="noreferrer">MobileNetV4</a> + <a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noreferrer">Transformers.js</a></p><span>Small model. A new way to look.</span></footer>
|
| 38 |
+
<noscript>Enable JavaScript to run this image model in your browser.</noscript>
|
| 39 |
+
</main>
|
| 40 |
+
</body>
|
| 41 |
</html>
|
input.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function validateImage(file) {
|
| 2 |
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
| 3 |
+
throw new Error('Choose a PNG, JPEG, or WebP image.');
|
| 4 |
+
}
|
| 5 |
+
if (!file.size) throw new Error('This image is empty. Choose another file.');
|
| 6 |
+
if (file.size > 15 * 1024 * 1024) throw new Error('Choose an image smaller than 15 MB.');
|
| 7 |
+
}
|
style.css
CHANGED
|
@@ -1,28 +1,59 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;600;700;800&display=swap');
|
| 2 |
+
:root { color-scheme: light; --bg:#f8f7f3; --ink:#252b25; --muted:#686c63; --line:#dedfd6; --accent:#a63e22; --soft:#f0efe8; }
|
| 3 |
+
* { box-sizing:border-box; }
|
| 4 |
+
body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.6 'DM Sans',sans-serif; }
|
| 5 |
+
main { max-width:1180px; margin:auto; padding:34px 40px; }
|
| 6 |
+
header,.brand,.panel-heading,.input-bottom,footer { display:flex; align-items:center; justify-content:space-between; gap:14px; }
|
| 7 |
+
header { padding-bottom:26px; border-bottom:1px solid var(--line); }
|
| 8 |
+
a { color:inherit; text-underline-offset:4px; }
|
| 9 |
+
.brand { justify-content:flex-start; text-decoration:none; font-size:27px; font-weight:700; letter-spacing:-1px; }
|
| 10 |
+
.mark { color:var(--accent); font-size:36px; line-height:1; }
|
| 11 |
+
.brand-note { margin-left:8px; color:var(--muted); font-size:13px; font-weight:400; letter-spacing:0; }
|
| 12 |
+
.badge { border:1px solid var(--line); border-radius:30px; padding:7px 14px; font-size:12px; }
|
| 13 |
+
.intro { margin:58px 0 38px; }
|
| 14 |
+
.eyebrow { color:var(--accent); font-size:11px; letter-spacing:2px; font-weight:700; }
|
| 15 |
+
h1 { font:600 clamp(40px,6vw,64px)/1.15 'Manrope',sans-serif; letter-spacing:-3px; margin:15px 0 18px; }
|
| 16 |
+
h1 em { font-family:Georgia,serif; color:var(--accent); font-weight:400; }
|
| 17 |
+
.lede { color:var(--muted); font-size:17px; margin:0; }
|
| 18 |
+
.workspace { display:grid; grid-template-columns:1.08fr 1fr; gap:24px; }
|
| 19 |
+
.panel { border:1px solid var(--line); border-radius:18px; background:#fff; padding:25px; }
|
| 20 |
+
.panel-heading { margin-bottom:22px; }
|
| 21 |
+
h2 { font-size:14px; font-weight:600; margin:0; }
|
| 22 |
+
h2 span { color:var(--muted); margin-right:11px; font-size:11px; }
|
| 23 |
+
.small { color:var(--muted); font-size:11px; }
|
| 24 |
+
.preview-area { height:288px; background:var(--soft); border:1px dashed #cdcec4; border-radius:10px; display:grid; place-items:center; overflow:hidden; text-align:center; }
|
| 25 |
+
#placeholder { padding:20px; color:var(--muted); }
|
| 26 |
+
#placeholder svg { color:var(--accent); margin-bottom:10px; }
|
| 27 |
+
h3 { color:var(--ink); font-size:16px; font-weight:500; margin:8px 0; }
|
| 28 |
+
#placeholder p,#empty-results p { font-size:13px; max-width:290px; margin:0 auto; color:var(--muted); }
|
| 29 |
+
#preview { width:100%; height:100%; object-fit:contain; }
|
| 30 |
+
[hidden] { display:none !important; }
|
| 31 |
+
.upload { margin-top:18px; min-height:48px; display:flex; align-items:center; justify-content:center; gap:20px; color:#fff; background:var(--ink); border-radius:8px; font-size:14px; font-weight:500; cursor:pointer; position:relative; }
|
| 32 |
+
.upload:hover { background:#414b40; }
|
| 33 |
+
.upload input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; }
|
| 34 |
+
.upload:focus-within { outline:3px solid var(--accent); outline-offset:3px; }
|
| 35 |
+
.upload:has(input:disabled) { opacity:.5; cursor:wait; }
|
| 36 |
+
.input-bottom { margin-top:8px; }
|
| 37 |
+
button { background:none; border:0; color:var(--accent); min-height:44px; padding:6px 0 6px 12px; font:500 12px 'DM Sans',sans-serif; cursor:pointer; }
|
| 38 |
+
button:hover { text-decoration:underline; text-underline-offset:4px; }
|
| 39 |
+
button:disabled { opacity:.5; cursor:wait; }
|
| 40 |
+
:focus-visible { outline:3px solid var(--accent); outline-offset:4px; }
|
| 41 |
+
.results-panel { display:flex; flex-direction:column; }
|
| 42 |
+
#empty-results { flex:1; text-align:center; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:30px 0; }
|
| 43 |
+
.orbit { color:#aaa99c; font:68px/1 Georgia,serif; margin-bottom:15px; }
|
| 44 |
+
#results { flex:1; list-style:none; margin:0; padding:0; }
|
| 45 |
+
#results li { padding:10px 0; }
|
| 46 |
+
.prediction { display:flex; align-items:baseline; justify-content:space-between; gap:12px; font-size:13px; }
|
| 47 |
+
.prediction strong { font-weight:500; }
|
| 48 |
+
.prediction span { white-space:nowrap; font-variant-numeric:tabular-nums; color:var(--muted); font-size:12px; }
|
| 49 |
+
meter { width:100%; height:8px; display:block; margin-top:9px; border:0; border-radius:4px; background:var(--soft); }
|
| 50 |
+
meter::-webkit-meter-bar { background:var(--soft); border:0; height:6px; }
|
| 51 |
+
meter::-webkit-meter-optimum-value { background:var(--accent); border-radius:4px; }
|
| 52 |
+
meter::-moz-meter-bar { background:var(--accent); }
|
| 53 |
+
#status { color:var(--muted); border-top:1px solid var(--line); padding-top:16px; margin:16px 0 0; font-size:12px; min-height:38px; }
|
| 54 |
+
#status.error { color:#a52920; }
|
| 55 |
+
.results-note { color:var(--muted); font-size:11px; line-height:1.6; margin:8px 0 0; }
|
| 56 |
+
footer { margin-top:28px; color:var(--muted); font-size:11px; }
|
| 57 |
+
footer p { margin:0; }
|
| 58 |
+
.dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:#53704c; margin-right:7px; }
|
| 59 |
+
@media(max-width:700px) { main { padding:22px 20px; } .brand-note,.badge { display:none; } .intro { margin:36px 0 28px; } h1 { letter-spacing:-2px; } .workspace { grid-template-columns:1fr; gap:16px; } .panel { padding:20px; } .preview-area { height:250px; } #empty-results { min-height:250px; } footer { align-items:flex-start; flex-direction:column; gap:8px; } .desktop { display:none; } }
|
tests/input.test.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { validateImage } from '../input.mjs';
|
| 3 |
+
|
| 4 |
+
assert.doesNotThrow(() => validateImage({ type: 'image/png', size: 1024 }));
|
| 5 |
+
assert.doesNotThrow(() => validateImage({ type: 'image/jpeg', size: 15 * 1024 * 1024 }));
|
| 6 |
+
assert.throws(() => validateImage({ type: 'text/html', size: 100 }), /PNG, JPEG, or WebP/);
|
| 7 |
+
assert.throws(() => validateImage({ type: 'image/png', size: 0 }), /empty/);
|
| 8 |
+
assert.throws(() => validateImage({ type: 'image/webp', size: 16 * 1024 * 1024 }), /15 MB/);
|
| 9 |
+
console.log('Image input checks passed.');
|
worker.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let classifier;
|
| 2 |
+
|
| 3 |
+
self.onmessage = async ({ data: image }) => {
|
| 4 |
+
try {
|
| 5 |
+
if (!classifier) {
|
| 6 |
+
self.postMessage({ type: 'status', message: 'Downloading the image model… First use takes a moment.' });
|
| 7 |
+
const { pipeline, env } = await import('https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1');
|
| 8 |
+
env.allowLocalModels = false;
|
| 9 |
+
// A single WASM thread also works inside the Space iframe without cross-origin isolation.
|
| 10 |
+
env.backends.onnx.wasm.numThreads = 1;
|
| 11 |
+
classifier = await pipeline('image-classification', 'onnx-community/mobilenetv4_conv_small.e2400_r224_in1k', {
|
| 12 |
+
device: 'wasm', dtype: 'q8', revision: '3ba07f12712fa58fd6b3d661f9909c9e332c5005',
|
| 13 |
+
});
|
| 14 |
+
}
|
| 15 |
+
self.postMessage({ type: 'status', message: 'Looking at your image…' });
|
| 16 |
+
const predictions = await classifier(image, { top_k: 5 });
|
| 17 |
+
self.postMessage({ type: 'result', predictions });
|
| 18 |
+
} catch (error) {
|
| 19 |
+
console.error(error);
|
| 20 |
+
self.postMessage({ type: 'error', message: 'The model could not process this image. Check your connection or try another photo.' });
|
| 21 |
+
}
|
| 22 |
+
};
|