Personaluser0000 commited on
Commit
d99ce6a
·
verified ·
1 Parent(s): 7b4e6e0

Add browser-based MobileNetV4 image demo

Browse files
Files changed (7) hide show
  1. README.md +19 -5
  2. app.mjs +99 -0
  3. index.html +39 -17
  4. input.mjs +7 -0
  5. style.css +59 -28
  6. tests/input.test.mjs +9 -0
  7. worker.mjs +22 -0
README.md CHANGED
@@ -1,10 +1,24 @@
1
  ---
2
  title: Lens Image Classifier
3
- emoji: 👀
4
- colorFrom: purple
5
- colorTo: purple
6
  sdk: static
7
- pinned: false
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
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
+ };