vision-space / index.html
techresearchspace's picture
Upload 2 files
b8bc0f0 verified
Raw
History Blame Contribute Delete
39.2 kB
<!DOCTYPE html>
<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 !important;
transition-duration: 0.01ms !important;
}
}
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>