Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Vision Lab — Multimodal AI, Entirely In-Browser</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root { | |
| --bg: #0f1420; | |
| --bg-panel: #151b2b; | |
| --bg-raised: #1b2333; | |
| --grid-line: rgba(140, 170, 220, 0.08); | |
| --ink: #e8ecf5; | |
| --ink-dim: #8b96ad; | |
| --ink-faint: #5a6478; | |
| --border: #2a3348; | |
| --cyan: #5ed4d2; | |
| --cyan-dim: rgba(94, 212, 210, 0.14); | |
| --amber: #e8a355; | |
| --coral: #e0715c; | |
| --coral-dim: rgba(224, 113, 92, 0.14); | |
| --ok: #6dbf8a; | |
| --display: "Space Grotesk", sans-serif; | |
| --mono: "JetBrains Mono", ui-monospace, monospace; | |
| --body: "Inter", -apple-system, sans-serif; | |
| } | |
| * { box-sizing: border-box; } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { | |
| animation-duration: 0.01ms ; | |
| transition-duration: 0.01ms ; | |
| } | |
| } | |
| html, body { | |
| margin: 0; | |
| background: var(--bg); | |
| color: var(--ink); | |
| font-family: var(--body); | |
| } | |
| body { | |
| background-image: | |
| linear-gradient(var(--grid-line) 1px, transparent 1px), | |
| linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); | |
| background-size: 32px 32px; | |
| min-height: 100vh; | |
| } | |
| a { color: var(--cyan); } | |
| /* ---------- Header ---------- */ | |
| header { | |
| padding: 40px 28px 28px; | |
| max-width: 1180px; | |
| margin: 0 auto; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .header-top { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| gap: 24px; | |
| flex-wrap: wrap; | |
| } | |
| .eyebrow { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: var(--cyan); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 10px; | |
| } | |
| .eyebrow::before { | |
| content: ""; | |
| width: 6px; | |
| height: 6px; | |
| background: var(--cyan); | |
| border-radius: 1px; | |
| transform: rotate(45deg); | |
| flex-shrink: 0; | |
| } | |
| h1 { | |
| font-family: var(--display); | |
| font-weight: 700; | |
| font-size: clamp(28px, 4vw, 40px); | |
| letter-spacing: -0.02em; | |
| margin: 0 0 10px; | |
| line-height: 1.05; | |
| } | |
| .lede { | |
| color: var(--ink-dim); | |
| font-size: 15px; | |
| max-width: 58ch; | |
| line-height: 1.6; | |
| } | |
| .lede code { | |
| font-family: var(--mono); | |
| font-size: 13px; | |
| background: var(--bg-raised); | |
| border: 1px solid var(--border); | |
| padding: 1px 6px; | |
| border-radius: 4px; | |
| color: var(--cyan); | |
| } | |
| .engine-badge { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--ink-faint); | |
| border: 1px solid var(--border); | |
| background: var(--bg-panel); | |
| border-radius: 6px; | |
| padding: 10px 14px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| min-width: 168px; | |
| } | |
| .engine-badge .row { display: flex; justify-content: space-between; gap: 12px; } | |
| .engine-badge .val { color: var(--ink); } | |
| .engine-badge .val.gpu { color: var(--ok); } | |
| .engine-badge .val.wasm { color: var(--amber); } | |
| /* ---------- Layout ---------- */ | |
| .layout { | |
| max-width: 1180px; | |
| margin: 0 auto; | |
| padding: 28px 28px 0; | |
| display: grid; | |
| grid-template-columns: 200px 1fr; | |
| gap: 28px; | |
| } | |
| @media (max-width: 860px) { | |
| .layout { grid-template-columns: 1fr; } | |
| } | |
| /* ---------- Nav ---------- */ | |
| nav.tabs { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| position: sticky; | |
| top: 20px; | |
| align-self: start; | |
| } | |
| @media (max-width: 860px) { | |
| nav.tabs { | |
| flex-direction: row; | |
| overflow-x: auto; | |
| position: static; | |
| padding-bottom: 4px; | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| } | |
| .tab-btn { | |
| font-family: var(--mono); | |
| font-size: 13px; | |
| text-align: left; | |
| background: transparent; | |
| border: 1px solid transparent; | |
| color: var(--ink-dim); | |
| padding: 10px 12px; | |
| border-radius: 7px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| white-space: nowrap; | |
| transition: background 0.15s, color 0.15s, border-color 0.15s; | |
| } | |
| .tab-btn:hover { background: var(--bg-panel); color: var(--ink); } | |
| .tab-btn:focus-visible { outline: 2px solid var(--cyan); outline-offset: 1px; } | |
| .tab-btn.active { | |
| background: var(--bg-raised); | |
| border-color: var(--border); | |
| color: var(--cyan); | |
| } | |
| .tab-btn .idx { color: var(--ink-faint); font-size: 11px; } | |
| .tab-btn.active .idx { color: var(--cyan); opacity: 0.6; } | |
| /* ---------- Main panel ---------- */ | |
| main { | |
| min-width: 0; | |
| padding-bottom: 40px; | |
| } | |
| .panel { | |
| display: none; | |
| } | |
| .panel.active { display: block; } | |
| .panel-head { | |
| margin-bottom: 18px; | |
| } | |
| .panel-head h2 { | |
| font-family: var(--display); | |
| font-size: 20px; | |
| margin: 0 0 6px; | |
| } | |
| .panel-head p { | |
| color: var(--ink-dim); | |
| font-size: 13.5px; | |
| margin: 0; | |
| max-width: 62ch; | |
| line-height: 1.55; | |
| } | |
| .panel-head .model-tag { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| color: var(--ink-faint); | |
| margin-top: 6px; | |
| display: block; | |
| } | |
| /* ---------- Image upload / viewfinder ---------- */ | |
| .viewfinder { | |
| position: relative; | |
| border: 1px dashed var(--border); | |
| border-radius: 10px; | |
| background: var(--bg-panel); | |
| min-height: 260px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| cursor: pointer; | |
| transition: border-color 0.15s; | |
| } | |
| .viewfinder:hover, .viewfinder.drag { border-color: var(--cyan); } | |
| .viewfinder input[type="file"] { display: none; } | |
| .vf-empty { | |
| text-align: center; | |
| color: var(--ink-faint); | |
| font-size: 13px; | |
| padding: 24px; | |
| } | |
| .vf-empty .icon { font-size: 26px; margin-bottom: 10px; opacity: 0.5; } | |
| .vf-empty .mono { font-family: var(--mono); font-size: 11px; margin-top: 6px; color: var(--ink-faint); } | |
| .vf-image-wrap { | |
| position: relative; | |
| width: 100%; | |
| } | |
| .vf-image-wrap img { | |
| display: block; | |
| width: 100%; | |
| max-height: 380px; | |
| object-fit: contain; | |
| background: #05070c; | |
| } | |
| /* corner bracket reticle — signature element */ | |
| .bracket { | |
| position: absolute; | |
| width: 22px; | |
| height: 22px; | |
| border: 2px solid var(--cyan); | |
| opacity: 0.85; | |
| pointer-events: none; | |
| } | |
| .bracket.tl { top: 10px; left: 10px; border-right: none; border-bottom: none; } | |
| .bracket.tr { top: 10px; right: 10px; border-left: none; border-bottom: none; } | |
| .bracket.bl { bottom: 10px; left: 10px; border-right: none; border-top: none; } | |
| .bracket.br { bottom: 10px; right: 10px; border-left: none; border-top: none; } | |
| .scanline { | |
| position: absolute; | |
| left: 0; right: 0; | |
| height: 2px; | |
| background: linear-gradient(90deg, transparent, var(--cyan), transparent); | |
| box-shadow: 0 0 10px 1px var(--cyan); | |
| top: 0; | |
| opacity: 0; | |
| } | |
| .scanline.active { | |
| opacity: 1; | |
| animation: scan 1.1s ease-in-out infinite; | |
| } | |
| @keyframes scan { | |
| 0% { top: 4%; } | |
| 50% { top: 92%; } | |
| 100% { top: 4%; } | |
| } | |
| .detect-boxes { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| } | |
| .detect-box { | |
| position: absolute; | |
| border: 2px solid var(--coral); | |
| box-shadow: 0 0 0 1px rgba(0,0,0,0.5); | |
| border-radius: 2px; | |
| } | |
| .detect-box .tag { | |
| position: absolute; | |
| top: -20px; | |
| left: -2px; | |
| background: var(--coral); | |
| color: #1a0a06; | |
| font-family: var(--mono); | |
| font-size: 10px; | |
| font-weight: 600; | |
| padding: 1px 6px; | |
| border-radius: 3px; | |
| white-space: nowrap; | |
| } | |
| .detect-box.tag-inside .tag { top: 2px; left: 2px; } | |
| .vf-replace { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 10px; | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| background: rgba(15, 20, 32, 0.85); | |
| border: 1px solid var(--border); | |
| color: var(--ink-dim); | |
| padding: 5px 10px; | |
| border-radius: 6px; | |
| backdrop-filter: blur(3px); | |
| } | |
| /* ---------- Controls ---------- */ | |
| .controls { | |
| margin-top: 16px; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| input[type="text"], textarea { | |
| flex: 1; | |
| min-width: 220px; | |
| font-family: var(--body); | |
| font-size: 14px; | |
| background: var(--bg-panel); | |
| border: 1px solid var(--border); | |
| color: var(--ink); | |
| padding: 11px 14px; | |
| border-radius: 7px; | |
| outline: none; | |
| transition: border-color 0.15s; | |
| } | |
| input[type="text"]:focus, textarea:focus { border-color: var(--cyan); } | |
| input[type="text"]::placeholder, textarea::placeholder { color: var(--ink-faint); } | |
| button.run { | |
| font-family: var(--mono); | |
| font-size: 12.5px; | |
| letter-spacing: 0.03em; | |
| text-transform: uppercase; | |
| background: var(--cyan); | |
| color: #08151a; | |
| border: none; | |
| font-weight: 600; | |
| padding: 12px 20px; | |
| border-radius: 7px; | |
| cursor: pointer; | |
| transition: opacity 0.15s, transform 0.08s; | |
| white-space: nowrap; | |
| } | |
| button.run:hover:not(:disabled) { opacity: 0.88; } | |
| button.run:active:not(:disabled) { transform: scale(0.98); } | |
| button.run:disabled { opacity: 0.3; cursor: not-allowed; } | |
| button.run:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } | |
| /* ---------- Output ---------- */ | |
| .output { | |
| margin-top: 20px; | |
| } | |
| .output-card { | |
| display: none; | |
| border: 1px solid var(--border); | |
| background: var(--bg-panel); | |
| border-radius: 10px; | |
| padding: 20px 22px; | |
| } | |
| .output-card.show { display: block; animation: fadein 0.25s ease; } | |
| @keyframes fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } | |
| .output-label { | |
| font-family: var(--mono); | |
| font-size: 10.5px; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| color: var(--ink-faint); | |
| margin-bottom: 10px; | |
| } | |
| .output-text { | |
| font-family: var(--display); | |
| font-size: 19px; | |
| font-weight: 500; | |
| line-height: 1.4; | |
| } | |
| .bar-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 7px 0; | |
| } | |
| .bar-row .name { | |
| font-family: var(--mono); | |
| font-size: 12.5px; | |
| width: 130px; | |
| flex-shrink: 0; | |
| color: var(--ink); | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .bar-track { | |
| flex: 1; | |
| height: 8px; | |
| background: var(--bg-raised); | |
| border-radius: 100px; | |
| overflow: hidden; | |
| } | |
| .bar-fill { | |
| height: 100%; | |
| background: var(--cyan); | |
| border-radius: 100px; | |
| width: 0%; | |
| transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1); | |
| } | |
| .bar-fill.top { background: var(--coral); } | |
| .bar-pct { | |
| font-family: var(--mono); | |
| font-size: 11.5px; | |
| color: var(--ink-dim); | |
| width: 46px; | |
| text-align: right; | |
| flex-shrink: 0; | |
| } | |
| .ocr-block { | |
| font-family: var(--mono); | |
| font-size: 13.5px; | |
| color: var(--ink); | |
| white-space: pre-wrap; | |
| line-height: 1.6; | |
| } | |
| /* ---------- Gallery / search ---------- */ | |
| .gallery-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); | |
| gap: 10px; | |
| margin-top: 16px; | |
| } | |
| .gallery-item { | |
| position: relative; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| border: 1px solid var(--border); | |
| aspect-ratio: 1; | |
| background: var(--bg-raised); | |
| } | |
| .gallery-item img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| display: block; | |
| } | |
| .gallery-item .score-badge { | |
| position: absolute; | |
| bottom: 6px; | |
| left: 6px; | |
| right: 6px; | |
| font-family: var(--mono); | |
| font-size: 10.5px; | |
| background: rgba(10, 14, 22, 0.82); | |
| color: var(--cyan); | |
| padding: 3px 6px; | |
| border-radius: 5px; | |
| text-align: center; | |
| backdrop-filter: blur(2px); | |
| } | |
| .gallery-item.rank-1 { border-color: var(--coral); } | |
| .gallery-item .rank-tag { | |
| position: absolute; | |
| top: 6px; | |
| left: 6px; | |
| font-family: var(--mono); | |
| font-size: 10px; | |
| background: var(--coral); | |
| color: #1a0a06; | |
| font-weight: 600; | |
| padding: 1px 5px; | |
| border-radius: 4px; | |
| } | |
| .empty-gallery { | |
| color: var(--ink-faint); | |
| font-size: 13px; | |
| font-family: var(--mono); | |
| padding: 30px 0; | |
| text-align: center; | |
| border: 1px dashed var(--border); | |
| border-radius: 8px; | |
| margin-top: 16px; | |
| } | |
| /* ---------- System log (console) ---------- */ | |
| .log-console { | |
| max-width: 1180px; | |
| margin: 0 auto; | |
| padding: 0 28px 40px; | |
| } | |
| .log-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 10px 0; | |
| border-top: 1px solid var(--border); | |
| } | |
| .log-title { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: var(--ink-faint); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .log-title .pulse { | |
| width: 6px; height: 6px; border-radius: 50%; | |
| background: var(--ink-faint); | |
| } | |
| .log-title .pulse.busy { background: var(--amber); animation: blink 1s infinite; } | |
| .log-title .pulse.idle { background: var(--ok); } | |
| @keyframes blink { 50% { opacity: 0.3; } } | |
| .log-toggle { | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| background: none; | |
| border: 1px solid var(--border); | |
| color: var(--ink-faint); | |
| padding: 4px 10px; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| } | |
| .log-toggle:hover { color: var(--ink); } | |
| .log-body { | |
| max-height: 160px; | |
| overflow-y: auto; | |
| background: #090c14; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 12px 14px; | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| line-height: 1.7; | |
| display: none; | |
| } | |
| .log-body.show { display: block; } | |
| .log-line { display: flex; gap: 10px; } | |
| .log-time { color: var(--ink-faint); flex-shrink: 0; } | |
| .log-msg { color: var(--ink-dim); } | |
| .log-msg.ok { color: var(--ok); } | |
| .log-msg.warn { color: var(--amber); } | |
| .log-msg.err { color: var(--coral); } | |
| footer { | |
| max-width: 1180px; | |
| margin: 0 auto; | |
| padding: 0 28px 60px; | |
| font-size: 12px; | |
| color: var(--ink-faint); | |
| display: flex; | |
| justify-content: space-between; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| footer a { text-decoration: none; } | |
| footer a:hover { text-decoration: underline; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="header-top"> | |
| <div> | |
| <div class="eyebrow">Client-side inference · zero backend</div> | |
| <h1>Vision Lab</h1> | |
| <p class="lede"> | |
| Five multimodal AI capabilities running entirely inside this browser tab via | |
| <code>transformers.js</code> — no API calls, no server, nothing you upload ever leaves your machine. | |
| </p> | |
| </div> | |
| <div class="engine-badge"> | |
| <div class="row"><span>backend</span><span class="val" id="badgeBackend">detecting…</span></div> | |
| <div class="row"><span>models cached</span><span class="val" id="badgeCached">0 / 4</span></div> | |
| <div class="row"><span>status</span><span class="val" id="badgeStatus">idle</span></div> | |
| </div> | |
| </div> | |
| </header> | |
| <div class="layout"> | |
| <nav class="tabs" id="tabNav"> | |
| <button class="tab-btn active" data-tab="caption"><span class="idx">01</span> Caption</button> | |
| <button class="tab-btn" data-tab="detect"><span class="idx">02</span> Detect Objects</button> | |
| <button class="tab-btn" data-tab="classify"><span class="idx">03</span> Classify</button> | |
| <button class="tab-btn" data-tab="ocr"><span class="idx">04</span> Read Text</button> | |
| <button class="tab-btn" data-tab="search"><span class="idx">05</span> Semantic Search</button> | |
| </nav> | |
| <main> | |
| <!-- Shared uploader (all tabs except search) --> | |
| <div id="sharedUploaderWrap"> | |
| <div class="viewfinder" id="viewfinder" tabindex="0" role="button" aria-label="Upload an image"> | |
| <input type="file" id="fileInput" accept="image/*" /> | |
| <div class="vf-empty" id="vfEmpty"> | |
| <div class="icon">▨</div> | |
| <div>Drop an image, click to browse, or paste from clipboard</div> | |
| <div class="mono">JPG · PNG · WEBP</div> | |
| </div> | |
| <div class="vf-image-wrap" id="vfImageWrap" style="display:none;"> | |
| <img id="vfImage" alt="Uploaded preview" /> | |
| <div class="bracket tl"></div> | |
| <div class="bracket tr"></div> | |
| <div class="bracket bl"></div> | |
| <div class="bracket br"></div> | |
| <div class="scanline" id="scanline"></div> | |
| <div class="detect-boxes" id="detectBoxes"></div> | |
| <div class="vf-replace">click to replace</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Panel: Caption --> | |
| <section class="panel active" data-panel="caption"> | |
| <div class="panel-head"> | |
| <h2>Image Captioning</h2> | |
| <p>Generates a natural-language description of the uploaded image using an encoder–decoder vision-to-text model.</p> | |
| <span class="model-tag">model: Xenova/vit-gpt2-image-captioning</span> | |
| </div> | |
| <div class="controls"> | |
| <button class="run" id="btnCaption" disabled>Generate caption</button> | |
| </div> | |
| <div class="output"> | |
| <div class="output-card" id="outCaption"> | |
| <div class="output-label">Generated caption</div> | |
| <div class="output-text" id="captionText"></div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Panel: Detect --> | |
| <section class="panel" data-panel="detect"> | |
| <div class="panel-head"> | |
| <h2>Zero-Shot Object Detection</h2> | |
| <p>Define any set of objects to look for — the model locates and draws bounding boxes around them in the image above, even for categories it wasn't explicitly trained to detect.</p> | |
| <span class="model-tag">model: Xenova/owlvit-base-patch32</span> | |
| </div> | |
| <div class="controls"> | |
| <input type="text" id="detectLabels" placeholder="person, dog, car, tree, building" value="person, dog, car, tree, building" /> | |
| <button class="run" id="btnDetect" disabled>Detect objects</button> | |
| </div> | |
| <div class="output"> | |
| <div class="output-card" id="outDetect"> | |
| <div class="output-label">Detections</div> | |
| <div id="detectList"></div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Panel: Classify --> | |
| <section class="panel" data-panel="classify"> | |
| <div class="panel-head"> | |
| <h2>Zero-Shot Classification</h2> | |
| <p>Define any set of candidate labels — the model has never been trained on them specifically, yet ranks them by visual-semantic similarity via CLIP's shared embedding space.</p> | |
| <span class="model-tag">model: Xenova/clip-vit-base-patch32</span> | |
| </div> | |
| <div class="controls"> | |
| <input type="text" id="classifyLabels" placeholder="cat, dog, car, landscape, food, building" value="cat, dog, car, landscape, food, building" /> | |
| <button class="run" id="btnClassify" disabled>Classify</button> | |
| </div> | |
| <div class="output"> | |
| <div class="output-card" id="outClassify"> | |
| <div class="output-label">Ranked labels</div> | |
| <div id="classifyBars"></div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Panel: OCR --> | |
| <section class="panel" data-panel="ocr"> | |
| <div class="panel-head"> | |
| <h2>Optical Character Recognition</h2> | |
| <p>Extracts printed text from the image using a transformer-based OCR model. Works best on clear, printed (not handwritten) text.</p> | |
| <span class="model-tag">model: Xenova/trocr-small-printed</span> | |
| </div> | |
| <div class="controls"> | |
| <button class="run" id="btnOcr" disabled>Extract text</button> | |
| </div> | |
| <div class="output"> | |
| <div class="output-card" id="outOcr"> | |
| <div class="output-label">Extracted text</div> | |
| <div class="ocr-block" id="ocrText"></div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Panel: Search --> | |
| <section class="panel" data-panel="search"> | |
| <div class="panel-head"> | |
| <h2>Semantic Image Search</h2> | |
| <p>Upload a batch of images to build a gallery, then search it with a natural-language query. Each image is scored against your query in CLIP's shared embedding space and ranked by relevance — no keywords or tags required.</p> | |
| <span class="model-tag">model: Xenova/clip-vit-base-patch32 (shared with Classify)</span> | |
| </div> | |
| <div class="viewfinder" id="galleryUploader" tabindex="0" role="button" aria-label="Upload gallery images" style="min-height:120px;"> | |
| <input type="file" id="galleryInput" accept="image/*" multiple /> | |
| <div class="vf-empty"> | |
| <div class="icon">▦</div> | |
| <div>Drop multiple images to build a searchable gallery</div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <input type="text" id="searchQuery" placeholder="e.g. 'a photo taken outdoors at night'" /> | |
| <button class="run" id="btnSearch" disabled>Search gallery</button> | |
| </div> | |
| <div class="gallery-grid" id="galleryGrid" style="display:none;"></div> | |
| <div class="empty-gallery" id="galleryEmpty">No images yet — upload a few to search across them</div> | |
| </section> | |
| </main> | |
| </div> | |
| <div class="log-console"> | |
| <div class="log-header"> | |
| <div class="log-title"><span class="pulse idle" id="logPulse"></span> System log</div> | |
| <button class="log-toggle" id="logToggle">show</button> | |
| </div> | |
| <div class="log-body" id="logBody"></div> | |
| </div> | |
| <footer> | |
| <span>Built with <a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noopener">🤗 Transformers.js</a> · runs on WebGPU when available, WASM otherwise</span> | |
| <span>All 4 models via <a href="https://huggingface.co/Xenova" target="_blank" rel="noopener">Xenova</a> ONNX conversions</span> | |
| </footer> | |
| <script type="module"> | |
| import { pipeline, env } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0"; | |
| env.allowLocalModels = false; | |
| /* ---------------- System log ---------------- */ | |
| const logBody = document.getElementById("logBody"); | |
| const logToggle = document.getElementById("logToggle"); | |
| const logPulse = document.getElementById("logPulse"); | |
| const badgeStatus = document.getElementById("badgeStatus"); | |
| let logOpen = false; | |
| logToggle.addEventListener("click", () => { | |
| logOpen = !logOpen; | |
| logBody.classList.toggle("show", logOpen); | |
| logToggle.textContent = logOpen ? "hide" : "show"; | |
| }); | |
| function log(msg, level = "dim") { | |
| const line = document.createElement("div"); | |
| line.className = "log-line"; | |
| const time = document.createElement("span"); | |
| time.className = "log-time"; | |
| time.textContent = new Date().toLocaleTimeString([], { hour12: false }); | |
| const text = document.createElement("span"); | |
| text.className = "log-msg " + level; | |
| text.textContent = msg; | |
| line.append(time, text); | |
| logBody.appendChild(line); | |
| logBody.scrollTop = logBody.scrollHeight; | |
| } | |
| function setBusy(isBusy, statusText) { | |
| logPulse.className = "pulse " + (isBusy ? "busy" : "idle"); | |
| badgeStatus.textContent = statusText || (isBusy ? "working…" : "idle"); | |
| } | |
| /* ---------------- Backend detection ---------------- */ | |
| const badgeBackend = document.getElementById("badgeBackend"); | |
| let device = "wasm"; | |
| (async () => { | |
| if (navigator.gpu) { | |
| try { | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if (adapter) { | |
| device = "webgpu"; | |
| } | |
| } catch (e) { /* fall back silently */ } | |
| } | |
| badgeBackend.textContent = device === "webgpu" ? "WebGPU ⚡" : "WASM"; | |
| badgeBackend.className = "val " + (device === "webgpu" ? "gpu" : "wasm"); | |
| log(`Compute backend: ${device === "webgpu" ? "WebGPU (hardware accelerated)" : "WASM (CPU fallback)"}`, device === "webgpu" ? "ok" : "warn"); | |
| })(); | |
| /* ---------------- Model registry ---------------- */ | |
| const MODEL_SPECS = { | |
| caption: { task: "image-to-text", model: "Xenova/vit-gpt2-image-captioning" }, | |
| detect: { task: "zero-shot-object-detection", model: "Xenova/owlvit-base-patch32" }, | |
| classify: { task: "zero-shot-image-classification", model: "Xenova/clip-vit-base-patch32" }, | |
| ocr: { task: "image-to-text", model: "Xenova/trocr-small-printed" }, | |
| }; | |
| const pipelines = {}; | |
| const cachedSet = new Set(); | |
| const badgeCached = document.getElementById("badgeCached"); | |
| function updateCachedBadge() { | |
| badgeCached.textContent = `${cachedSet.size} / 4`; | |
| } | |
| async function getPipeline(key) { | |
| if (pipelines[key]) return pipelines[key]; | |
| const spec = MODEL_SPECS[key]; | |
| setBusy(true, "loading model…"); | |
| log(`Loading ${spec.model} (${spec.task})…`); | |
| let lastPct = -1; | |
| try { | |
| const p = await pipeline(spec.task, spec.model, { | |
| device, | |
| progress_callback: (info) => { | |
| if (info.status === "progress" && info.file) { | |
| const pct = Math.round(info.progress || 0); | |
| if (pct !== lastPct && pct % 20 === 0) { | |
| lastPct = pct; | |
| log(` ↳ ${info.file}: ${pct}%`); | |
| } | |
| } | |
| }, | |
| }); | |
| pipelines[key] = p; | |
| cachedSet.add(key); | |
| updateCachedBadge(); | |
| log(`${spec.model} ready`, "ok"); | |
| setBusy(false); | |
| return p; | |
| } catch (err) { | |
| log(`Failed to load ${spec.model}: ${err.message || err}`, "err"); | |
| setBusy(false, "error"); | |
| throw err; | |
| } | |
| } | |
| /* ---------------- Tab navigation ---------------- */ | |
| const tabButtons = document.querySelectorAll(".tab-btn"); | |
| const panels = document.querySelectorAll(".panel"); | |
| const sharedUploaderWrap = document.getElementById("sharedUploaderWrap"); | |
| tabButtons.forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| tabButtons.forEach((b) => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| const tab = btn.dataset.tab; | |
| panels.forEach((p) => p.classList.toggle("active", p.dataset.panel === tab)); | |
| sharedUploaderWrap.style.display = tab === "search" ? "none" : "block"; | |
| }); | |
| }); | |
| /* ---------------- Shared image upload (viewfinder) ---------------- */ | |
| const viewfinder = document.getElementById("viewfinder"); | |
| const fileInput = document.getElementById("fileInput"); | |
| const vfEmpty = document.getElementById("vfEmpty"); | |
| const vfImageWrap = document.getElementById("vfImageWrap"); | |
| const vfImage = document.getElementById("vfImage"); | |
| const scanline = document.getElementById("scanline"); | |
| let currentImageURL = null; | |
| const actionButtons = [ | |
| document.getElementById("btnCaption"), | |
| document.getElementById("btnDetect"), | |
| document.getElementById("btnClassify"), | |
| document.getElementById("btnOcr"), | |
| ]; | |
| function setCurrentImage(url) { | |
| currentImageURL = url; | |
| vfImage.src = url; | |
| vfEmpty.style.display = "none"; | |
| vfImageWrap.style.display = "block"; | |
| actionButtons.forEach((b) => (b.disabled = false)); | |
| clearDetectionBoxes(); | |
| log("Image loaded into viewfinder"); | |
| } | |
| function handleFiles(files) { | |
| const file = files && files[0]; | |
| if (!file || !file.type.startsWith("image/")) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => setCurrentImage(e.target.result); | |
| reader.readAsDataURL(file); | |
| } | |
| viewfinder.addEventListener("click", () => fileInput.click()); | |
| viewfinder.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInput.click(); } | |
| }); | |
| fileInput.addEventListener("change", (e) => handleFiles(e.target.files)); | |
| ["dragenter", "dragover"].forEach((evt) => | |
| viewfinder.addEventListener(evt, (e) => { e.preventDefault(); viewfinder.classList.add("drag"); }) | |
| ); | |
| ["dragleave", "drop"].forEach((evt) => | |
| viewfinder.addEventListener(evt, (e) => { e.preventDefault(); viewfinder.classList.remove("drag"); }) | |
| ); | |
| viewfinder.addEventListener("drop", (e) => handleFiles(e.dataTransfer.files)); | |
| document.addEventListener("paste", (e) => { | |
| const items = e.clipboardData?.items; | |
| if (!items) return; | |
| for (const item of items) { | |
| if (item.type.startsWith("image/")) { | |
| handleFiles([item.getAsFile()]); | |
| break; | |
| } | |
| } | |
| }); | |
| function withScan(on) { | |
| scanline.classList.toggle("active", on); | |
| } | |
| /* ---------------- Caption ---------------- */ | |
| const btnCaption = document.getElementById("btnCaption"); | |
| const outCaption = document.getElementById("outCaption"); | |
| const captionText = document.getElementById("captionText"); | |
| btnCaption.addEventListener("click", async () => { | |
| if (!currentImageURL) return; | |
| btnCaption.disabled = true; | |
| withScan(true); | |
| setBusy(true, "captioning…"); | |
| try { | |
| const cap = await getPipeline("caption"); | |
| const result = await cap(currentImageURL); | |
| captionText.textContent = result[0]?.generated_text || "(no output)"; | |
| outCaption.classList.add("show"); | |
| log("Caption generated", "ok"); | |
| } catch (err) { | |
| captionText.textContent = "Error generating caption — see console."; | |
| outCaption.classList.add("show"); | |
| log(`Caption error: ${err.message || err}`, "err"); | |
| } finally { | |
| btnCaption.disabled = false; | |
| withScan(false); | |
| setBusy(false); | |
| } | |
| }); | |
| /* ---------------- Detect (zero-shot object detection) ---------------- */ | |
| const btnDetect = document.getElementById("btnDetect"); | |
| const detectLabels = document.getElementById("detectLabels"); | |
| const outDetect = document.getElementById("outDetect"); | |
| const detectList = document.getElementById("detectList"); | |
| const detectBoxesEl = document.getElementById("detectBoxes"); | |
| let lastDetections = []; | |
| function clearDetectionBoxes() { | |
| lastDetections = []; | |
| detectBoxesEl.innerHTML = ""; | |
| } | |
| function renderDetectionBoxes() { | |
| detectBoxesEl.innerHTML = ""; | |
| if (!lastDetections.length || !vfImage.naturalWidth) return; | |
| const containerW = vfImageWrap.clientWidth; | |
| const containerH = vfImageWrap.clientHeight; | |
| const naturalW = vfImage.naturalWidth; | |
| const naturalH = vfImage.naturalHeight; | |
| const scale = Math.min(containerW / naturalW, containerH / naturalH); | |
| const renderW = naturalW * scale; | |
| const renderH = naturalH * scale; | |
| const offsetX = (containerW - renderW) / 2; | |
| const offsetY = (containerH - renderH) / 2; | |
| lastDetections.forEach((d) => { | |
| const box = document.createElement("div"); | |
| const top = offsetY + d.box.ymin * scale; | |
| box.className = "detect-box" + (top < 22 ? " tag-inside" : ""); | |
| box.style.left = (offsetX + d.box.xmin * scale) + "px"; | |
| box.style.top = top + "px"; | |
| box.style.width = ((d.box.xmax - d.box.xmin) * scale) + "px"; | |
| box.style.height = ((d.box.ymax - d.box.ymin) * scale) + "px"; | |
| const tag = document.createElement("div"); | |
| tag.className = "tag"; | |
| tag.textContent = `${d.label} ${(d.score * 100).toFixed(0)}%`; | |
| box.appendChild(tag); | |
| detectBoxesEl.appendChild(box); | |
| }); | |
| } | |
| let resizeTimer; | |
| window.addEventListener("resize", () => { | |
| clearTimeout(resizeTimer); | |
| resizeTimer = setTimeout(renderDetectionBoxes, 120); | |
| }); | |
| btnDetect.addEventListener("click", async () => { | |
| if (!currentImageURL) return; | |
| const labels = detectLabels.value.split(",").map((s) => s.trim()).filter(Boolean); | |
| if (!labels.length) { | |
| log("Enter at least one label to detect", "warn"); | |
| return; | |
| } | |
| btnDetect.disabled = true; | |
| withScan(true); | |
| setBusy(true, "detecting objects…"); | |
| try { | |
| const detector = await getPipeline("detect"); | |
| const output = await detector(currentImageURL, labels, { threshold: 0.12, top_k: 12 }); | |
| lastDetections = output; | |
| renderDetectionBoxes(); | |
| detectList.innerHTML = ""; | |
| if (!output.length) { | |
| detectList.innerHTML = `<div class="output-text" style="font-size:14px; color: var(--ink-dim);">No objects matched those labels above the confidence threshold.</div>`; | |
| } else { | |
| renderBars(detectList, [...output].sort((a, b) => b.score - a.score)); | |
| } | |
| outDetect.classList.add("show"); | |
| log(`Detected ${output.length} object(s) across ${labels.length} label(s)`, "ok"); | |
| } catch (err) { | |
| log(`Detect error: ${err.message || err}`, "err"); | |
| } finally { | |
| btnDetect.disabled = false; | |
| withScan(false); | |
| setBusy(false); | |
| } | |
| }); | |
| /* ---------------- Classify ---------------- */ | |
| const btnClassify = document.getElementById("btnClassify"); | |
| const classifyLabels = document.getElementById("classifyLabels"); | |
| const outClassify = document.getElementById("outClassify"); | |
| const classifyBars = document.getElementById("classifyBars"); | |
| function renderBars(container, items, keyName = "label") { | |
| container.innerHTML = ""; | |
| items.forEach((item, i) => { | |
| const row = document.createElement("div"); | |
| row.className = "bar-row"; | |
| const name = document.createElement("div"); | |
| name.className = "name"; | |
| name.textContent = item[keyName]; | |
| const track = document.createElement("div"); | |
| track.className = "bar-track"; | |
| const fill = document.createElement("div"); | |
| fill.className = "bar-fill" + (i === 0 ? " top" : ""); | |
| track.appendChild(fill); | |
| const pct = document.createElement("div"); | |
| pct.className = "bar-pct"; | |
| pct.textContent = (item.score * 100).toFixed(1) + "%"; | |
| row.append(name, track, pct); | |
| container.appendChild(row); | |
| requestAnimationFrame(() => { fill.style.width = (item.score * 100).toFixed(1) + "%"; }); | |
| }); | |
| } | |
| btnClassify.addEventListener("click", async () => { | |
| if (!currentImageURL) return; | |
| const labels = classifyLabels.value.split(",").map((s) => s.trim()).filter(Boolean); | |
| if (labels.length < 2) { | |
| log("Need at least 2 candidate labels", "warn"); | |
| return; | |
| } | |
| btnClassify.disabled = true; | |
| withScan(true); | |
| setBusy(true, "classifying…"); | |
| try { | |
| const clf = await getPipeline("classify"); | |
| const result = await clf(currentImageURL, labels); | |
| const sorted = [...result].sort((a, b) => b.score - a.score); | |
| renderBars(classifyBars, sorted); | |
| outClassify.classList.add("show"); | |
| log(`Classified against ${labels.length} labels — top: ${sorted[0].label}`, "ok"); | |
| } catch (err) { | |
| log(`Classify error: ${err.message || err}`, "err"); | |
| } finally { | |
| btnClassify.disabled = false; | |
| withScan(false); | |
| setBusy(false); | |
| } | |
| }); | |
| /* ---------------- OCR ---------------- */ | |
| const btnOcr = document.getElementById("btnOcr"); | |
| const outOcr = document.getElementById("outOcr"); | |
| const ocrText = document.getElementById("ocrText"); | |
| btnOcr.addEventListener("click", async () => { | |
| if (!currentImageURL) return; | |
| btnOcr.disabled = true; | |
| withScan(true); | |
| setBusy(true, "reading text…"); | |
| try { | |
| const ocr = await getPipeline("ocr"); | |
| const result = await ocr(currentImageURL); | |
| const text = result[0]?.generated_text?.trim(); | |
| ocrText.textContent = text || "(no text detected)"; | |
| outOcr.classList.add("show"); | |
| log("OCR extraction complete", "ok"); | |
| } catch (err) { | |
| ocrText.textContent = "Error extracting text — see console."; | |
| outOcr.classList.add("show"); | |
| log(`OCR error: ${err.message || err}`, "err"); | |
| } finally { | |
| btnOcr.disabled = false; | |
| withScan(false); | |
| setBusy(false); | |
| } | |
| }); | |
| /* ---------------- Semantic search ---------------- */ | |
| const galleryUploader = document.getElementById("galleryUploader"); | |
| const galleryInput = document.getElementById("galleryInput"); | |
| const galleryGrid = document.getElementById("galleryGrid"); | |
| const galleryEmpty = document.getElementById("galleryEmpty"); | |
| const btnSearch = document.getElementById("btnSearch"); | |
| const searchQuery = document.getElementById("searchQuery"); | |
| let galleryImages = []; // { url, el } | |
| galleryUploader.addEventListener("click", () => galleryInput.click()); | |
| galleryInput.addEventListener("change", (e) => addGalleryFiles(e.target.files)); | |
| ["dragenter", "dragover"].forEach((evt) => | |
| galleryUploader.addEventListener(evt, (e) => { e.preventDefault(); galleryUploader.classList.add("drag"); }) | |
| ); | |
| ["dragleave", "drop"].forEach((evt) => | |
| galleryUploader.addEventListener(evt, (e) => { e.preventDefault(); galleryUploader.classList.remove("drag"); }) | |
| ); | |
| galleryUploader.addEventListener("drop", (e) => addGalleryFiles(e.dataTransfer.files)); | |
| function addGalleryFiles(files) { | |
| const imageFiles = Array.from(files || []).filter((f) => f.type.startsWith("image/")); | |
| if (!imageFiles.length) return; | |
| let remaining = imageFiles.length; | |
| imageFiles.forEach((file) => { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| galleryImages.push({ url: e.target.result, score: null }); | |
| remaining -= 1; | |
| if (remaining === 0) renderGallery(); | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| log(`Added ${imageFiles.length} image(s) to gallery`); | |
| } | |
| function renderGallery(sorted = false) { | |
| galleryEmpty.style.display = galleryImages.length ? "none" : "block"; | |
| galleryGrid.style.display = galleryImages.length ? "grid" : "none"; | |
| btnSearch.disabled = galleryImages.length === 0; | |
| galleryGrid.innerHTML = ""; | |
| galleryImages.forEach((img, i) => { | |
| const item = document.createElement("div"); | |
| item.className = "gallery-item" + (sorted && i === 0 ? " rank-1" : ""); | |
| const el = document.createElement("img"); | |
| el.src = img.url; | |
| item.appendChild(el); | |
| if (sorted) { | |
| const rank = document.createElement("div"); | |
| rank.className = "rank-tag"; | |
| rank.textContent = "#" + (i + 1); | |
| item.appendChild(rank); | |
| } | |
| if (img.score !== null) { | |
| const badge = document.createElement("div"); | |
| badge.className = "score-badge"; | |
| badge.textContent = (img.score * 100).toFixed(1) + "% match"; | |
| item.appendChild(badge); | |
| } | |
| galleryGrid.appendChild(item); | |
| }); | |
| } | |
| btnSearch.addEventListener("click", async () => { | |
| const query = searchQuery.value.trim(); | |
| if (!query || !galleryImages.length) return; | |
| btnSearch.disabled = true; | |
| setBusy(true, "searching gallery…"); | |
| log(`Scoring ${galleryImages.length} image(s) against: "${query}"`); | |
| try { | |
| const clf = await getPipeline("classify"); | |
| for (const img of galleryImages) { | |
| const result = await clf(img.url, [query]); | |
| img.score = result[0]?.score ?? 0; | |
| } | |
| galleryImages.sort((a, b) => b.score - a.score); | |
| renderGallery(true); | |
| log(`Search complete — best match: ${(galleryImages[0].score * 100).toFixed(1)}%`, "ok"); | |
| } catch (err) { | |
| log(`Search error: ${err.message || err}`, "err"); | |
| } finally { | |
| btnSearch.disabled = false; | |
| setBusy(false); | |
| } | |
| }); | |
| log("Vision Lab ready. Nothing you upload leaves this browser tab.", "ok"); | |
| </script> | |
| </body> | |
| </html> | |