From 6902067322cd5294209bd973c54606491a771602 Mon Sep 17 00:00:00 2001 From: kiyo9w Date: Thu, 24 Sep 2026 12:42:02 +0900 Subject: [PATCH] feat: web client with live and photo modes, served by the server The client is a pure API consumer: live frames go out over /v1/stream, stills over /v1/recognize, and it can be pointed at another server with ?server=&token=. It keeps the team's visual identity (green palette, logo, EN/JA toggle) and adds the live surface: state-coloured boxes with name/price chips, interpolated track boxes, a results/s + latency HUD, quality hints, and an in-view list that highlights a SKU's boxes. Photo mode recognises the whole shelf at once instead of one click per product: boxes paint immediately, identities fill in, tapping a product inspects it (crop + feature-match visualization), the algorithm selector re-classifies, and dispense updates stock. Fixes found by browser verification with a synthetic camera feed: - The WebSocket handler referenced its processing loop before definition, so the socket died with NameError and live mode never received a result. Helpers are now defined inside the handler. - The client never kept a reference to the video element, so frame capture returned early and nothing was ever sent. - /v1/images/{id}/classify passed detection dicts where the engine expected box dicts (KeyError: 'width'). - Placeholder chrome (thumb, dashes, hidden groups) leaked on first paint. Verified in Chrome with a fake camera: live 8-12 results/s, ~60 products in view, product card and feature matching populated, photo 105/170 recognised on image.jpg and 59/111 on add/image6.jpg, 0 console errors. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- recognization/code/app.py | 101 +-- recognization/code/engine.py | 9 +- recognization/code/web/app.js | 999 ++++++++++++++++++++++++++++++ recognization/code/web/index.html | 171 +++++ recognization/code/web/styles.css | 224 +++++++ 5 files changed, 1454 insertions(+), 50 deletions(-) create mode 100644 recognization/code/web/app.js create mode 100644 recognization/code/web/index.html create mode 100644 recognization/code/web/styles.css diff --git a/recognization/code/app.py b/recognization/code/app.py index e773046..04e0361 100644 --- a/recognization/code/app.py +++ b/recognization/code/app.py @@ -445,7 +445,57 @@ def create_app( } ) - processing = asyncio.create_task(_processing_loop(session, send, loop)) + async def processing_loop() -> None: + while not session.closed: + pending = await loop.run_in_executor(None, session.take_pending, 0.25) + if pending is None: + continue + frame_id, payload = pending + try: + result = await loop.run_in_executor(None, session.process_frame, frame_id, payload) + except UndecodableImage: + await send({"type": "error", "code": "UNDECODABLE_IMAGE", "frame_id": frame_id, + "message": "frame is not a readable image"}) + continue + except Exception as exc: # keep the stream alive on one bad frame + LOGGER.exception("live frame failed: %s", exc) + await send({"type": "error", "code": "INTERNAL", "frame_id": frame_id, + "message": "frame processing failed"}) + continue + await send(result) + + async def handle_inspect(message: Dict[str, Any]) -> None: + request_id = message.get("request_id") + try: + track_id = int(message.get("track_id")) + except (TypeError, ValueError): + await send({"type": "error", "code": "BAD_MESSAGE", "request_id": request_id, + "message": "track_id required"}) + return + method = message.get("method") + if method is not None: + try: + method = _require_method(method) + except HTTPException as exc: + await send({"type": "error", "code": "BAD_MESSAGE", "request_id": request_id, + "message": str(exc.detail)}) + return + try: + result = await loop.run_in_executor(None, session.inspect, track_id, method) + except KeyError: + await send({"type": "error", "code": "TRACK_NOT_IN_VIEW", "request_id": request_id, + "message": "track is not in the latest frame"}) + return + except Exception as exc: + LOGGER.exception("inspect failed: %s", exc) + await send({"type": "error", "code": "INTERNAL", "request_id": request_id, + "message": "inspect failed"}) + return + payload = {"type": "inspect_result", "request_id": request_id, "track_id": track_id} + payload.update(result) + await send(payload) + + processing = asyncio.create_task(processing_loop()) async def handle_control(message: Dict[str, Any]) -> None: kind = message.get("type") @@ -466,7 +516,7 @@ def create_app( elif kind == "ping": await send({"type": "pong", "t": message.get("t")}) elif kind == "inspect": - await _handle_inspect(session, message, send, loop) + await handle_inspect(message) else: await send({"type": "error", "code": "BAD_MESSAGE", "message": f"unknown type '{kind}'"}) @@ -510,53 +560,6 @@ def create_app( async with sessions_lock: sessions.pop(session_id, None) - async def _processing_loop(session: LiveSession, send, loop: asyncio.AbstractEventLoop) -> None: - while not session.closed: - pending = await loop.run_in_executor(None, session.take_pending, 0.25) - if pending is None: - continue - frame_id, payload = pending - try: - result = await loop.run_in_executor(None, session.process_frame, frame_id, payload) - except UndecodableImage: - await send( - {"type": "error", "code": "UNDECODABLE_IMAGE", "frame_id": frame_id, "message": "frame is not a readable image"} - ) - continue - except Exception as exc: # keep the stream alive on one bad frame - LOGGER.exception("live frame failed: %s", exc) - await send({"type": "error", "code": "INTERNAL", "frame_id": frame_id, "message": "frame processing failed"}) - continue - await send(result) - - async def _handle_inspect(session: LiveSession, message: Dict[str, Any], send, loop) -> None: - request_id = message.get("request_id") - track_id = message.get("track_id") - try: - track_id = int(track_id) - except (TypeError, ValueError): - await send({"type": "error", "code": "BAD_MESSAGE", "request_id": request_id, "message": "track_id required"}) - return - method = message.get("method") - if method is not None: - try: - method = _require_method(method) - except HTTPException as exc: - await send({"type": "error", "code": "BAD_MESSAGE", "request_id": request_id, "message": str(exc.detail)}) - return - try: - result = await loop.run_in_executor(None, session.inspect, track_id, method) - except KeyError: - await send({"type": "error", "code": "TRACK_NOT_IN_VIEW", "request_id": request_id, "message": "track is not in the latest frame"}) - return - except Exception as exc: - LOGGER.exception("inspect failed: %s", exc) - await send({"type": "error", "code": "INTERNAL", "request_id": request_id, "message": "inspect failed"}) - return - payload = {"type": "inspect_result", "request_id": request_id, "track_id": track_id} - payload.update(result) - await send(payload) - # ---------------------------------------------------------------- legacy session routes @app.post("/api/sessions") async def legacy_start_session(payload: Dict[str, Any]) -> Dict[str, Any]: diff --git a/recognization/code/engine.py b/recognization/code/engine.py index cf279c2..640390e 100644 --- a/recognization/code/engine.py +++ b/recognization/code/engine.py @@ -374,7 +374,14 @@ class RecognitionEngine: raise UnknownMethod(method) if not boxes: return [] - ordered = sorted(boxes, key=lambda b: b["width"] * b["height"], reverse=True) + + def area_of(box: Dict[str, Any]) -> int: + if "width" in box and "height" in box: + return int(box["width"]) * int(box["height"]) + x1, y1, x2, y2 = [int(v) for v in box["box"]] + return max(0, x2 - x1) * max(0, y2 - y1) + + ordered = sorted(boxes, key=area_of, reverse=True) crops = [self.crop(image, box["box"]) for box in ordered] if method == "hybrid_fast": futures = [self._executor.submit(self.matcher.score_crop_fast, crop) for crop in crops] diff --git a/recognization/code/web/app.js b/recognization/code/web/app.js new file mode 100644 index 0000000..7dd3be4 --- /dev/null +++ b/recognization/code/web/app.js @@ -0,0 +1,999 @@ +/* + * Dorayaki web client. + * + * A client of the recognition server, nothing else: live frames go out over + * /v1/stream, stills go out over /v1/recognize. Point it at another server with + * ?server=https://host:port and pass a token with ?token=... + */ + +const params = new URLSearchParams(location.search); +const SERVER = (params.get('server') || '').replace(/\/$/, ''); +const TOKEN = params.get('token') || ''; + +const I18N = { + en: { + subtitle: 'Smart Supermarket Shelf AI & Inventory Recognition', + live: 'Live', photo: 'Photo', start: 'Start', stop: 'Stop', + capture: 'Take photo', upload: 'Upload image', empty: 'Empty spaces', + stageEmpty: 'Start the live scan, or open Photo mode to inspect a shelf image.', + cardTitle: 'Product details', cardPlaceholder: 'Tap a product on the shelf to see its details.', + metricOn: 'On shelf', metricOut: 'Sold / dispatched', metricReceipt: 'Received', metricExpiry: 'Expires', + dispense: 'Dispense 1 unit', matchTitle: 'Feature matching', + matchPlaceholder: 'Tap a product, then “Show matching”.', showMatch: 'Show matching', + inviewLive: 'In view', inviewPhoto: 'On this photo', + inviewPlaceholder: 'Recognized products appear here.', + cameraTitle: 'Take shelf photo', snapshot: 'Capture', + offline: 'Offline', connecting: 'Connecting', liveBadge: 'Live', photoBadge: 'Photo', + recognized: 'Recognized', needsConfirm: 'Check this', unrecognized: 'Not in catalog', + identifying: 'Identifying…', detected: 'Detecting…', emptySlot: 'Empty shelf space', + emptySlotNote: 'No product detected here', candidatesTitle: 'Possible matches', + hintGlare: 'Glare — tilt the camera', hintBlur: 'Hold steady', hintTooFar: 'Move closer', + noCamera: 'Live camera needs https or localhost.', + cameraDenied: 'Camera access was denied.', + connectionLost: 'Connection lost — retrying…', unknownMethod: 'Unknown algorithm', + noServer: 'Server unreachable', confirmEmpty: 'Hide empty spaces', showEmpty: 'Show empty spaces', + facings: 'facings', inView: 'in view', products: 'products', + }, + ja: { + subtitle: 'スマートスーパーマーケット棚認識 & 在庫管理AI', + live: 'ライブ', photo: '写真', start: '開始', stop: '停止', + capture: 'カメラ撮影', upload: '画像アップロード', empty: '空きスペース', + stageEmpty: 'ライブスキャンを開始するか、写真モードで棚画像を確認してください。', + cardTitle: '商品情報', cardPlaceholder: '棚の商品をタップすると詳細が表示されます。', + metricOn: '陳列在庫数', metricOut: '販売済 / 出庫数', metricReceipt: '受入日', metricExpiry: '賞味期限', + dispense: '1点出庫 / 購入する', matchTitle: '特徴点マッチング', + matchPlaceholder: '商品をタップして「照合を表示」を押してください。', showMatch: '照合を表示', + inviewLive: '表示中の商品', inviewPhoto: 'この写真の商品', + inviewPlaceholder: '認識した商品がここに表示されます。', + cameraTitle: '棚の写真を撮影', snapshot: '撮影する', + offline: '未接続', connecting: '接続中', liveBadge: 'ライブ', photoBadge: '写真', + recognized: '認識済み', needsConfirm: '要確認', unrecognized: '未登録', + identifying: '識別中…', detected: '検出中…', emptySlot: '棚の空きスペース', + emptySlotNote: 'ここに商品はありません', candidatesTitle: '候補', + hintGlare: '反射しています — 角度を変えてください', hintBlur: 'カメラを固定してください', hintTooFar: '近づいてください', + noCamera: 'ライブカメラには https または localhost が必要です。', + cameraDenied: 'カメラの使用が許可されていません。', + connectionLost: '接続が切れました — 再接続中…', unknownMethod: '不明なアルゴリズム', + noServer: 'サーバーに接続できません', confirmEmpty: '空きスペースを隠す', showEmpty: '空きスペースを表示', + facings: 'フェイス', inView: '表示中', products: '商品', + }, +}; + +const state = { + lang: 'en', + mode: 'live', + method: 'hybrid', + showEmpty: true, + catalog: new Map(), + catalogVersion: '', + ws: null, + wsState: 'idle', + video: null, + stream: null, + frames: 0, + results: 0, + rtts: [], + detectMs: [], + dropped: 0, + lastFrameAt: 0, + inFlight: false, + inflightLimit: 1, + sendWidth: 1280, + quality: 0.75, + tracks: new Map(), // track_id -> {box, target, state, identity, product, lastSeen} + slots: [], + selectedTrack: null, + selectedDetection: null, + currentImageId: null, + photoDetections: [], + photoWidth: 0, + photoHeight: 0, + reconnectDelay: 1000, + sendTimer: null, + lastHint: null, +}; + +const el = (id) => document.getElementById(id); +const t = (key) => (I18N[state.lang] || I18N.en)[key] || key; + +function api(path) { + return (SERVER || '') + path; +} + +function headers() { + return TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}; +} + +// ---------------------------------------------------------------- rendering + +const overlay = el('overlay'); +const ctx = overlay.getContext('2d'); +const videoEl = el('video'); +const photoEl = el('photo'); +state.video = videoEl; +let displayWidth = 0; +let displayHeight = 0; + +function setCanvasSize(width, height) { + displayWidth = width; + displayHeight = height; + overlay.width = width; + overlay.height = height; +} + +function chip(text, x, y, background, color) { + ctx.font = '700 12px "Plus Jakarta Sans", "Noto Sans JP", sans-serif'; + const width = ctx.measureText(text).width + 12; + const height = 18; + const boxY = Math.max(0, y - height - 3); + ctx.fillStyle = background; + ctx.beginPath(); + ctx.roundRect(x, boxY, width, height, 6); + ctx.fill(); + ctx.fillStyle = color; + ctx.fillText(text, x + 6, boxY + 13); +} + +function drawBox(box, { color, dashed = false, fill = null, width = 2 }) { + ctx.save(); + ctx.lineWidth = width; + ctx.strokeStyle = color; + if (dashed) ctx.setLineDash([6, 4]); + if (fill) { + ctx.fillStyle = fill; + ctx.fillRect(box[0], box[1], box[2] - box[0], box[3] - box[1]); + } + ctx.strokeRect(box[0], box[1], box[2] - box[0], box[3] - box[1]); + ctx.restore(); +} + +function render() { + ctx.clearRect(0, 0, overlay.width, overlay.height); + const now = performance.now(); + + if (state.mode === 'photo' && state.photoDetections.length) { + drawPhotoDetections(); + } + + for (const [trackId, track] of state.tracks) { + const target = track.target; + if (!track.box) track.box = [...target]; + for (let i = 0; i < 4; i += 1) { + track.box[i] += (target[i] - track.box[i]) * 0.35; + } + const age = now - track.lastSeen; + const alpha = Math.max(0, 1 - age / 400); + if (alpha <= 0.02) continue; + + const identity = track.identity || { state: 'unconfirmed' }; + const selected = state.selectedTrack === trackId; + ctx.globalAlpha = alpha; + if (identity.state === 'recognized') { + drawBox(track.box, { color: selected ? '#10b981' : '#22c55e', width: selected ? 4 : 2, fill: 'rgba(34,197,94,0.10)' }); + const name = track.product ? track.product.product_name : identity.sku_id; + const price = track.product ? ` ¥${Math.round(track.product.price)}` : ''; + chip(`${name}${price}`, track.box[0], track.box[1], '#22c55e', '#ffffff'); + } else if (identity.state === 'needs_confirmation') { + drawBox(track.box, { color: '#f59e0b', width: 2, dashed: true, fill: 'rgba(245,158,11,0.08)' }); + chip(t('needsConfirm'), track.box[0], track.box[1], '#f59e0b', '#ffffff'); + } else if (identity.state === 'unrecognized') { + drawBox(track.box, { color: '#94a3b8', width: 2 }); + } else if (identity.state === 'unconfirmed') { + drawBox(track.box, { color: 'rgba(226,232,240,0.55)', width: 1, dashed: true }); + } else { + drawBox(track.box, { color: 'rgba(148,163,184,0.8)', width: 1, dashed: true }); + } + ctx.globalAlpha = 1; + } + + if (state.showEmpty && state.mode === 'live') { + for (const slot of state.slots) { + drawBox(slot.box, { color: '#ef4444', width: 3, fill: 'rgba(239,68,68,0.20)' }); + chip(`EMPTY ~${slot.missing_items_est}`, slot.box[0], slot.box[1], '#ef4444', '#ffffff'); + } + } + requestAnimationFrame(render); +} +requestAnimationFrame(render); + +function drawPhotoDetections() { + for (const detection of state.photoDetections) { + const identity = detection.identity || {}; + const selected = state.selectedDetection === detection.id; + if (identity.state === 'recognized') { + drawBox(detection.box, { color: selected ? '#10b981' : '#22c55e', width: selected ? 4 : 2, fill: 'rgba(34,197,94,0.08)' }); + chip(identity.sku_id || '', detection.box[0], detection.box[1], '#22c55e', '#fff'); + } else if (identity.state === 'needs_confirmation') { + drawBox(detection.box, { color: '#f59e0b', width: 2, dashed: true }); + } else if (identity.state === 'unrecognized') { + drawBox(detection.box, { color: '#94a3b8', width: 2 }); + } else { + drawBox(detection.box, { color: '#22c55e', width: 2 }); + } + } + if (state.showEmpty) { + for (const slot of state.slots) { + drawBox(slot.box, { color: '#ef4444', width: 3, fill: 'rgba(239,68,68,0.20)' }); + chip(`EMPTY ~${slot.missing_items_est}`, slot.box[0], slot.box[1], '#ef4444', '#ffffff'); + } + } +} + +// ---------------------------------------------------------------- i18n / chrome + +function applyLanguage() { + document.querySelectorAll('[id^="t-"]').forEach((node) => { + const key = node.id.slice(2).replace(/-([a-z])/g, (m, c) => c.toUpperCase()); + const value = t(key); + if (value && value !== key) node.textContent = value; + }); + el('langText').textContent = state.lang === 'en' ? '日本語 / JA' : 'English / EN'; + el('inViewCount').textContent = String(state.mode === 'live' ? state.tracks.size : state.photoDetections.length); + updateInViewTitle(); + updateEmptyButton(); + setConnection(state.wsState); +} + +function updateInViewTitle() { + el('t-inview-title').textContent = state.mode === 'live' ? t('inviewLive') : t('inviewPhoto'); +} + +function updateEmptyButton() { + el('t-empty').textContent = state.showEmpty ? t('confirmEmpty') : t('showEmpty'); +} + +function setConnection(status) { + state.wsState = status; + const pill = el('connPill'); + pill.dataset.state = status; + const label = { idle: t('offline'), connecting: t('connecting'), live: t('liveBadge'), photo: t('photoBadge'), error: t('noServer') }[status] || t('offline'); + el('connText').textContent = label; +} + +function setStatus(kind, text) { + const badge = el('statusBadge'); + badge.className = `status-badge badge-${{ recognized: 'success', needs: 'warning', danger: 'danger' }[kind] || 'neutral'}`; + badge.textContent = text; +} + +function showHint(kind, text) { + const banner = el('hintBanner'); + if (!text) { banner.hidden = true; return; } + banner.hidden = false; + banner.dataset.kind = kind || 'info'; + banner.textContent = text; +} + +// ---------------------------------------------------------------- HUD + +function updateHud() { + const hud = el('hud'); + if (state.mode !== 'live' || !state.results) { + hud.textContent = state.mode === 'live' ? '—' : `${state.photoDetections.length} ${t('products')}`; + return; + } + const rtt = state.rtts.length ? median(state.rtts.slice(-30)) : 0; + const detect = state.detectMs.length ? median(state.detectMs.slice(-30)) : 0; + hud.dataset.detail = hud.dataset.detail === '1' ? '1' : '0'; + if (hud.dataset.detail === '1') { + hud.textContent = `${state.results} frames · rtt ${rtt.toFixed(0)}ms · detect ${detect.toFixed(0)}ms · dropped ${state.dropped}`; + } else { + hud.textContent = `${(state.results / Math.max(1, (performance.now() - state.startedAt) / 1000)).toFixed(1)}/s · ${rtt.toFixed(0)}ms`; + } +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] || 0; +} +setInterval(updateHud, 500); + +function clearCard() { + el('cardPlaceholder').hidden = false; + el('cardBody').hidden = true; + el('scoreLabel').textContent = ''; + setStatus('neutral', ''); +} + +// ---------------------------------------------------------------- catalog + +async function loadCatalog() { + try { + const response = await fetch(api('/v1/catalog'), { headers: headers() }); + if (!response.ok) throw new Error(`catalog ${response.status}`); + const data = await response.json(); + state.catalogVersion = data.catalog_version; + state.catalog = new Map(data.products.map((product) => [product.sku_id, product])); + renderInView(); + } catch (error) { + console.warn('catalog load failed', error); + } +} + +function productFor(skuId) { + return skuId ? state.catalog.get(skuId) : null; +} + +function thumbUrl(skuId) { + return productFor(skuId) ? api(`/v1/catalog/${skuId}/image`) : ''; +} + +// ---------------------------------------------------------------- live mode + +async function listCameras() { + const select = el('sourceSelect'); + select.innerHTML = ''; + if (!navigator.mediaDevices || !window.isSecureContext) { + const option = document.createElement('option'); + option.value = 'file'; + option.textContent = t('noCamera'); + select.appendChild(option); + } else { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + for (const device of devices.filter((d) => d.kind === 'videoinput')) { + const option = document.createElement('option'); + option.value = device.deviceId; + option.textContent = device.label || 'Camera'; + select.appendChild(option); + } + } catch (error) { /* permission not granted yet */ } + } + const fileOption = document.createElement('option'); + fileOption.value = 'file'; + fileOption.textContent = '🎞 ' + (state.lang === 'en' ? 'Video file…' : '動画ファイル…'); + select.appendChild(fileOption); +} + +function connectStream() { + if (state.ws && (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING)) return; + setConnection('connecting'); + const base = SERVER || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`; + const url = `${base.replace(/^http/, 'ws')}/v1/stream${TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : ''}`; + const socket = new WebSocket(url); + socket.binaryType = 'arraybuffer'; + state.ws = socket; + + socket.onopen = () => { + state.reconnectDelay = 1000; + setConnection('live'); + socket.send(JSON.stringify({ type: 'config', method: state.method, empty_slots: state.showEmpty })); + startSending(); + }; + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.type === 'result') handleResult(message); + else if (message.type === 'inspect_result') handleInspect(message); + else if (message.type === 'error' && message.message) console.warn('server error', message); + }; + socket.onclose = () => { + stopSending(); + if (state.mode === 'live' && state.stream) { + setConnection('error'); + setTimeout(connectStream, state.reconnectDelay); + state.reconnectDelay = Math.min(10000, state.reconnectDelay * 2); + } else { + setConnection('idle'); + } + }; + socket.onerror = () => setConnection('error'); +} + +function startSending() { + stopSending(); + state.startedAt = performance.now(); + state.sendTimer = setInterval(sendFrame, 1000 / 30); +} + +function stopSending() { + if (state.sendTimer) clearInterval(state.sendTimer); + state.sendTimer = null; + state.inFlight = false; +} + +function sendFrame() { + const video = state.video; + if (!video || video.readyState < 2 || document.hidden) return; + if (state.inFlight && state.inflightLimit <= 1) return; + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return; + + const sourceWidth = video.videoWidth; + const sourceHeight = video.videoHeight; + if (!sourceWidth || !sourceHeight) return; + const width = Math.min(state.sendWidth, sourceWidth); + const height = Math.round(sourceHeight * (width / sourceWidth)); + const canvas = state.captureCanvas || (state.captureCanvas = document.createElement('canvas')); + canvas.width = width; + canvas.height = height; + const captureCtx = canvas.getContext('2d'); + captureCtx.drawImage(video, 0, 0, width, height); + canvas.toBlob((blob) => { + if (!blob) return; + blob.arrayBuffer().then((buffer) => { + const frameId = ++state.frames & 0xffffffff; + const payload = new Uint8Array(4 + buffer.byteLength); + new DataView(payload.buffer).setUint32(0, frameId, false); + payload.set(new Uint8Array(buffer), 4); + state.ws.send(payload); + state.frameSentAt = new Map([[frameId, performance.now()]]); + state.inFlight = true; + }); + }, 'image/jpeg', state.quality); +} + +function handleResult(message) { + state.results += 1; + state.inFlight = false; + const sentAt = state.frameSentAt && state.frameSentAt.get(message.frame_id); + if (sentAt) state.rtts.push(performance.now() - sentAt); + state.detectMs.push(message.timing_ms.detect); + state.dropped = message.stats.frames_dropped; + + const seen = new Set(); + for (const track of message.tracks) { + seen.add(track.track_id); + const existing = state.tracks.get(track.track_id); + state.tracks.set(track.track_id, { + box: existing ? existing.box : [...track.box], + target: track.box, + identity: track.identity, + product: track.product || productFor(track.identity.sku_id), + lastSeen: performance.now(), + }); + } + for (const [trackId, track] of state.tracks) { + if (!seen.has(trackId) && performance.now() - track.lastSeen > 400) state.tracks.delete(trackId); + } + state.slots = message.empty_slots || []; + el('inViewCount').textContent = String(message.summary.in_view); + + const hint = message.quality && message.quality.hint; + if (hint !== state.lastHint) { + state.lastHint = hint; + if (hint === 'glare') showHint('warning', t('hintGlare')); + else if (hint === 'blur') showHint('warning', t('hintBlur')); + else if (hint === 'too_far') showHint('warning', t('hintTooFar')); + else showHint(null, ''); + } + renderInView(); + if (state.selectedTrack !== null && !state.cardPinned) { + const track = state.tracks.get(state.selectedTrack); + if (track) showTrackCard(track); + } +} + +// ---------------------------------------------------------------- photo mode + +async function loadSamples() { + try { + const response = await fetch(api('/v1/samples'), { headers: headers() }); + const data = await response.json(); + const select = el('sampleSelect'); + select.innerHTML = ''; + for (const sample of data.samples) { + const option = document.createElement('option'); + option.value = sample.id; + option.textContent = sample.label; + select.appendChild(option); + } + if (data.samples.length) loadSample(data.samples[0].id); + } catch (error) { + console.warn('samples failed', error); + } +} + +async function loadSample(sampleId) { + const response = await fetch(api(`/v1/samples/${sampleId}`), { headers: headers() }); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + await recognizeBlob(blob, url); +} + +async function recognizeBlob(blob, previewUrl) { + setConnection('photo'); + setStatus('neutral', t('identifying')); + const photo = el('photo'); + photo.src = previewUrl || URL.createObjectURL(blob); + photo.hidden = false; + el('video').hidden = true; + el('stageEmpty').hidden = true; + await photo.decode().catch(() => {}); + + const response = await fetch(api('/v1/recognize?classify=false&empty_slots=true'), { + method: 'POST', + headers: { 'Content-Type': 'image/jpeg', ...headers() }, + body: blob, + }); + if (!response.ok) { + setStatus('danger', t('noServer')); + return; + } + const data = await response.json(); + state.currentImageId = data.image_id; + state.photoDetections = data.detections; + state.photoWidth = data.width; + state.photoHeight = data.height; + state.slots = data.empty_slots || []; + setCanvasSize(photo.naturalWidth || data.width, photo.naturalHeight || data.height); + el('inViewCount').textContent = String(data.detections.length); + setStatus('neutral', t('detected')); + classifyPhoto(); +} + +async function classifyPhoto(detectionIds = null) { + if (!state.currentImageId) return; + setStatus('neutral', t('identifying')); + const response = await fetch(api(`/v1/images/${state.currentImageId}/classify`), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers() }, + body: JSON.stringify({ method: state.method, detection_ids: detectionIds }), + }); + if (!response.ok) { setStatus('danger', t('noServer')); return; } + const data = await response.json(); + const byId = new Map(data.identities.map((row) => [row.detection_id, row.identity])); + for (const detection of state.photoDetections) { + if (byId.has(detection.id)) detection.identity = byId.get(detection.id); + } + const summary = data.summary || {}; + setStatus('recognized', `${summary.recognized || 0} / ${data.identities.length}`); + renderInView(); +} + +function renderInView() { + const list = el('inViewList'); + const counts = new Map(); + if (state.mode === 'live') { + for (const track of state.tracks.values()) { + const identity = track.identity || {}; + if (identity.state === 'recognized' && identity.sku_id) { + counts.set(identity.sku_id, (counts.get(identity.sku_id) || 0) + 1); + } + } + } else { + for (const detection of state.photoDetections) { + const identity = detection.identity || {}; + if (identity.state === 'recognized' && identity.sku_id) { + counts.set(identity.sku_id, (counts.get(identity.sku_id) || 0) + 1); + } + } + } + if (!counts.size) { + list.innerHTML = `
${t('inviewPlaceholder')}
`; + return; + } + const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([skuId, facings]) => { + const product = productFor(skuId); + const name = product ? product.product_name : skuId; + const price = product ? `¥${Math.round(product.price)}` : ''; + return `
+ +
${name}
+
${price} · ${t('facings')} ${facings}
+
${facings}
`; + }).join(''); + list.innerHTML = rows; + list.querySelectorAll('.inview-row').forEach((row) => { + row.addEventListener('click', () => selectBySku(row.dataset.sku)); + }); +} + +function selectBySku(skuId) { + if (state.mode === 'photo') { + const detection = state.photoDetections.find((d) => (d.identity || {}).sku_id === skuId); + if (detection) selectPhotoDetection(detection); + return; + } + for (const [trackId, track] of state.tracks) { + const identity = track.identity || {}; + if (identity.sku_id === skuId) { + state.selectedTrack = trackId; + // Show the product this row names, even if the track has since moved + // to a neighbour: the row is the user's intent. Pinning stops the + // per-frame refresh from replacing the card the user just opened. + state.cardPinned = true; + showProductCard(productFor(skuId), identity); + break; + } + } +} + +// ---------------------------------------------------------------- product card + +function showTrackCard(track) { + const identity = track.identity || {}; + if (identity.state === 'recognized') { + const product = track.product || productFor(identity.sku_id); + showProductCard(product, identity); + } else if (identity.state === 'needs_confirmation') { + showCandidatesCard(identity); + } else if (identity.state === 'unrecognized') { + showPlainCard(t('unrecognized'), t('emptySlotNote')); + } else { + showPlainCard(t('detected'), ''); + } +} + +function showProductCard(product, identity) { + if (!product) { showPlainCard(t('detected'), ''); return; } + el('cardPlaceholder').hidden = true; + el('cardBody').hidden = false; + el('productSku').hidden = false; + el('productSku').textContent = product.sku_id; + el('productName').textContent = product.product_name; + el('productPrice').textContent = `¥${Math.round(product.price)}`; + el('productThumb').src = thumbUrl(product.sku_id); + el('productThumb').hidden = false; + el('metricOn').textContent = product.number_on; + el('metricOut').textContent = product.number_out; + el('metricReceipt').textContent = product.receipt_date || '—'; + el('metricExpiry').textContent = product.expiration_date || '—'; + el('candidates').hidden = true; + el('showMatchBtn').disabled = false; + const confidence = identity.confidence ? ` · ${(identity.confidence * 100).toFixed(0)}%` : ''; + el('scoreLabel').textContent = identity.score ? `score ${identity.score}${confidence}` : ''; + setStatus('recognized', t('recognized')); +} + +function showCandidatesCard(identity) { + el('cardPlaceholder').hidden = true; + el('cardBody').hidden = false; + el('productSku').hidden = true; + el('productThumb').hidden = true; + el('productName').textContent = t('needsConfirm'); + el('productPrice').textContent = '—'; + el('productThumb').src = ''; + el('metricOn').textContent = '—'; + el('metricOut').textContent = '—'; + el('metricReceipt').textContent = '—'; + el('metricExpiry').textContent = '—'; + const candidates = el('candidates'); + const rows = (identity.candidates || []).map((candidate) => { + const product = productFor(candidate.sku_id); + const name = product ? product.product_name : candidate.sku_id; + return `
${name}${candidate.score}
`; + }).join(''); + candidates.innerHTML = `
${t('candidatesTitle')}
${rows}`; + candidates.hidden = false; + el('showMatchBtn').disabled = false; + setStatus('needs', t('needsConfirm')); +} + +function showPlainCard(title, note) { + el('cardPlaceholder').hidden = true; + el('cardBody').hidden = false; + el('productSku').hidden = true; + el('productThumb').hidden = true; + el('productName').textContent = title; + el('productPrice').textContent = ''; + el('productThumb').src = ''; + el('metricOn').textContent = '—'; + el('metricOut').textContent = '—'; + el('metricReceipt').textContent = '—'; + el('metricExpiry').textContent = '—'; + el('candidates').hidden = true; + el('showMatchBtn').disabled = true; + setStatus('danger', title); + if (note) el('productPrice').textContent = note; +} + +// ---------------------------------------------------------------- interaction + +function stagePointToImage(event) { + const rect = overlay.getBoundingClientRect(); + const scaleX = overlay.width / rect.width; + const scaleY = overlay.height / rect.height; + return [(event.clientX - rect.left) * scaleX, (event.clientY - rect.top) * scaleY]; +} + +overlay.addEventListener('click', (event) => { + const [x, y] = stagePointToImage(event); + if (state.mode === 'live') { + let best = null; + for (const [trackId, track] of state.tracks) { + const [x1, y1, x2, y2] = track.target; + if (x >= x1 && x <= x2 && y >= y1 && y <= y2) { + const area = (x2 - x1) * (y2 - y1); + if (!best || area < best.area) best = { trackId, track, area }; + } + } + if (best) { + state.selectedTrack = best.trackId; + state.selectedDetection = null; + state.cardPinned = false; + showTrackCard(best.track); + } + return; + } + if (state.mode === 'photo') { + const [px, py] = photoPointToDetectionSpace(x, y); + let best = null; + for (const detection of state.photoDetections) { + const [x1, y1, x2, y2] = detection.box; + if (px >= x1 && px <= x2 && py >= y1 && py <= y2) { + const area = (x2 - x1) * (y2 - y1); + if (!best || area < best.area) best = { detection, area }; + } + } + if (best) selectPhotoDetection(best.detection); + else { + const slot = state.slots.find((s) => { + const [x1, y1, x2, y2] = s.box; + return px >= x1 && px <= x2 && py >= y1 && py <= y2; + }); + if (slot) showEmptySlotCard(slot); + } + } +}); + +function photoPointToDetectionSpace(x, y) { + const photo = el('photo'); + const scaleX = state.photoWidth / (photo.naturalWidth || state.photoWidth); + const scaleY = state.photoHeight / (photo.naturalHeight || state.photoHeight); + return [x * scaleX, y * scaleY]; +} + +function selectPhotoDetection(detection) { + state.selectedDetection = detection.id; + state.selectedTrack = null; + const identity = detection.identity || {}; + if (identity.state === 'recognized') showProductCard(productFor(identity.sku_id), identity); + else if (identity.state === 'needs_confirmation') showCandidatesCard(identity); + else showPlainCard(t(identity.state === 'unrecognized' ? 'unrecognized' : 'detected'), ''); + inspectCurrent(detection.box); +} + +function showEmptySlotCard(slot) { + el('cardPlaceholder').hidden = true; + el('cardBody').hidden = false; + el('productSku').hidden = true; + el('productThumb').hidden = true; + el('productName').textContent = t('emptySlot'); + el('productPrice').textContent = t('emptySlotNote'); + el('productThumb').src = ''; + el('metricOn').textContent = '0'; + el('metricOut').textContent = `~${slot.missing_items_est}`; + el('metricReceipt').textContent = `row ${slot.row_index}`; + el('metricExpiry').textContent = `${slot.width}×${slot.height}px`; + el('candidates').hidden = true; + el('showMatchBtn').disabled = true; + setStatus('danger', t('emptySlot')); +} + +async function inspectCurrent(box) { + if (!state.currentImageId || !box) return; + const response = await fetch(api(`/v1/images/${state.currentImageId}/inspect`), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers() }, + body: JSON.stringify({ box, method: state.method }), + }); + if (!response.ok) return; + handleInspect(await response.json()); +} + +function handleInspect(result) { + if (result.crop_jpeg_b64) { + el('productThumb').src = result.crop_jpeg_b64; + } + if (result.match_visualization_jpeg_b64) { + el('matchBox').innerHTML = `feature matches`; + } else { + el('matchBox').innerHTML = `${t('matchPlaceholder')}`; + } + const identity = result.identity || {}; + const parts = []; + if (identity.score) parts.push(`score ${identity.score}`); + if (identity.confidence) parts.push(`confidence ${(identity.confidence * 100).toFixed(0)}%`); + if (result.visualized_sku_id) parts.push(`${result.is_candidate ? 'candidate' : 'match'} ${result.visualized_sku_id}`); + el('scoreLabel').textContent = parts.join(' · '); +} + +el('showMatchBtn').addEventListener('click', () => { + if (state.mode === 'live' && state.selectedTrack !== null) { + const track = state.tracks.get(state.selectedTrack); + if (track && state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: 'inspect', track_id: state.selectedTrack, method: state.method, request_id: String(Date.now()) })); + } + } else if (state.mode === 'photo' && state.selectedDetection !== null) { + const detection = state.photoDetections.find((d) => d.id === state.selectedDetection); + if (detection) inspectCurrent(detection.box); + } +}); + +// ---------------------------------------------------------------- controls + +el('modeControl').addEventListener('click', (event) => { + const button = event.target.closest('.seg-btn'); + if (!button) return; + setMode(button.dataset.mode); +}); + +function setMode(mode) { + state.mode = mode; + document.querySelectorAll('#modeControl .seg-btn').forEach((b) => b.classList.toggle('active', b.dataset.mode === mode)); + el('liveControls').hidden = mode !== 'live'; + el('photoControls').hidden = mode === 'live'; + el('video').hidden = mode !== 'live'; + el('photo').hidden = mode !== 'photo'; + state.tracks.clear(); + state.slots = []; + state.photoDetections = []; + state.selectedTrack = null; + state.selectedDetection = null; + state.cardPinned = false; + el('stageEmpty').hidden = false; + clearCard(); + updateInViewTitle(); + if (mode === 'photo') { + setConnection('photo'); + loadSamples(); + } else { + stopCamera(); + setConnection(state.ws && state.ws.readyState === WebSocket.OPEN ? 'live' : 'idle'); + } + el('inViewCount').textContent = '0'; + renderInView(); +} + +el('methodControl').addEventListener('click', async (event) => { + const button = event.target.closest('.seg-btn'); + if (!button) return; + state.method = button.dataset.method; + document.querySelectorAll('#methodControl .seg-btn').forEach((b) => b.classList.toggle('active', b === button)); + if (state.mode === 'live' && state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: 'config', method: state.method })); + state.tracks.clear(); + } else if (state.mode === 'photo') { + await classifyPhoto(); + } +}); + +el('startBtn').addEventListener('click', async () => { + const source = el('sourceSelect').value; + if (source === 'file') { pickVideoFile(); return; } + try { + state.stream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: source ? { exact: source } : undefined, facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }); + } catch (error) { + showHint('danger', error.name === 'NotAllowedError' ? t('cameraDenied') : t('noCamera')); + return; + } + el('video').srcObject = state.stream; + videoEl.hidden = false; + photoEl.hidden = true; + el('stageEmpty').hidden = true; + await videoEl.play().catch(() => {}); + setCanvasSize(videoEl.videoWidth || 1280, videoEl.videoHeight || 720); + el('startBtn').disabled = true; + el('stopBtn').disabled = false; + connectStream(); + listCameras(); +}); + +el('stopBtn').addEventListener('click', () => { + stopCamera(); + if (state.ws) state.ws.close(); + setConnection('idle'); + el('stageEmpty').hidden = false; +}); + +function stopCamera() { + stopSending(); + if (state.stream) { + state.stream.getTracks().forEach((track) => track.stop()); + state.stream = null; + } + el('video').srcObject = null; + el('startBtn').disabled = false; + el('stopBtn').disabled = true; +} + +function pickVideoFile() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'video/*'; + input.onchange = async () => { + const file = input.files[0]; + if (!file) return; + const url = URL.createObjectURL(file); + const video = el('video'); + video.srcObject = null; + video.src = url; + video.loop = true; + video.muted = true; + video.hidden = false; + el('photo').hidden = true; + el('stageEmpty').hidden = true; + await video.play().catch(() => {}); + setCanvasSize(video.videoWidth || 1280, video.videoHeight || 720); + el('startBtn').disabled = true; + el('stopBtn').disabled = false; + connectStream(); + }; + input.click(); +} + +el('emptyBtn').addEventListener('click', () => { + state.showEmpty = !state.showEmpty; + updateEmptyButton(); + if (state.mode === 'live' && state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: 'config', empty_slots: state.showEmpty })); + } +}); + +el('sampleSelect').addEventListener('change', (event) => loadSample(event.target.value)); +el('uploadBtn').addEventListener('click', () => el('fileInput').click()); +el('fileInput').addEventListener('change', (event) => { + const file = event.target.files[0]; + if (file) recognizeBlob(file); +}); + +el('captureBtn').addEventListener('click', async () => { + el('cameraModal').classList.add('open'); + try { + state.cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 } }, audio: false }); + el('cameraVideo').srcObject = state.cameraStream; + } catch (error) { + showHint('danger', t('cameraDenied')); + el('cameraModal').classList.remove('open'); + } +}); +el('cameraClose').addEventListener('click', closeCameraModal); +el('snapshotBtn').addEventListener('click', () => { + const video = el('cameraVideo'); + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth || 1280; + canvas.height = video.videoHeight || 720; + canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.toBlob((blob) => { + closeCameraModal(); + if (blob) recognizeBlob(blob); + }, 'image/jpeg', 0.9); +}); +function closeCameraModal() { + el('cameraModal').classList.remove('open'); + if (state.cameraStream) { + state.cameraStream.getTracks().forEach((track) => track.stop()); + state.cameraStream = null; + } +} + +el('dispenseBtn').addEventListener('click', async () => { + const sku = el('productSku').textContent; + if (!sku || sku === '—') return; + const response = await fetch(api(`/v1/catalog/${sku}/stock`), { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers() }, + body: JSON.stringify({ delta: -1 }), + }); + if (!response.ok) return; + const data = await response.json(); + el('metricOn').textContent = data.product.number_on; + el('metricOut').textContent = data.product.number_out; + state.catalog.set(data.product.sku_id, data.product); + renderInView(); +}); + +el('langBtn').addEventListener('click', () => { + state.lang = state.lang === 'en' ? 'ja' : 'en'; + applyLanguage(); + listCameras(); +}); + +el('hud').addEventListener('click', () => { + el('hud').dataset.detail = el('hud').dataset.detail === '1' ? '0' : '1'; + updateHud(); +}); + +document.addEventListener('visibilitychange', () => { + if (document.hidden) stopSending(); + else if (state.mode === 'live' && state.stream) startSending(); +}); + +// ---------------------------------------------------------------- boot + +applyLanguage(); +loadCatalog(); +setMode('live'); +listCameras(); diff --git a/recognization/code/web/index.html b/recognization/code/web/index.html new file mode 100644 index 0000000..c827f80 --- /dev/null +++ b/recognization/code/web/index.html @@ -0,0 +1,171 @@ + + + + + + Dorayaki - Smart Supermarket Shelf AI + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
+
Dorayaki どら焼き
+
Smart Supermarket Shelf AI & Inventory Recognition
+
+
+
+ Offline + +
+
+ +
+
+
+
+
+ + +
+
+ + + + +
+
+
+ + + +
--
+
+ +
+ +
+
+ +
+ + + +
+
📷
+
Start the live scan, or open Photo mode to inspect a shelf image.
+
+ +
+
+ + +
+ + + + + + diff --git a/recognization/code/web/styles.css b/recognization/code/web/styles.css new file mode 100644 index 0000000..2418236 --- /dev/null +++ b/recognization/code/web/styles.css @@ -0,0 +1,224 @@ +:root { + --bg-body: #f4fbf6; + --bg-card: #ffffff; + --primary-green: #22c55e; + --primary-dark: #15803d; + --primary-light: #dcfce7; + --primary-subtle: #f0fdf4; + --border-color: #d1fae5; + --border-strong: #86efac; + --text-main: #0f172a; + --text-muted: #64748b; + --amber: #f59e0b; + --amber-soft: #fef3c7; + --danger: #ef4444; + --danger-soft: #fee2e2; + --slate-soft: #e2e8f0; + --night: #0f172a; + --shadow-soft: 0 4px 20px -2px rgba(34, 197, 94, 0.08), 0 2px 6px -1px rgba(0, 0, 0, 0.04); + --radius-lg: 18px; + --radius-md: 12px; + --radius-sm: 8px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +[hidden] { display: none !important; } + +body { + background-color: var(--bg-body); + color: var(--text-main); + min-height: 100vh; + display: flex; + flex-direction: column; + font-family: 'Plus Jakarta Sans', 'Noto Sans JP', sans-serif; +} + +header { + background: var(--bg-card); + border-bottom: 1px solid var(--border-color); + padding: 14px 32px; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; +} + +.brand { display: flex; align-items: center; gap: 14px; } +.brand-logo-svg { width: 44px; height: 44px; filter: drop-shadow(0 2px 6px rgba(34, 197, 94, 0.25)); } +.brand-title { font-size: 20px; font-weight: 800; color: #14532d; letter-spacing: -0.5px; display: flex; align-items: center; gap: 6px; } +.brand-jp { font-size: 13px; font-weight: 600; color: #16a34a; background: var(--primary-light); padding: 2px 8px; border-radius: 6px; } +.brand-sub { font-size: 12px; color: var(--text-muted); font-weight: 500; } + +.header-actions { display: flex; align-items: center; gap: 12px; } + +.lang-switch-btn { + background: var(--primary-subtle); + border: 1px solid var(--border-color); + color: var(--primary-dark); + padding: 6px 14px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-family: inherit; +} +.lang-switch-btn:hover { background: var(--primary-light); border-color: var(--primary-green); } + +.conn-pill { + display: flex; align-items: center; gap: 6px; + font-size: 11px; font-weight: 700; color: #166534; + background: var(--primary-subtle); border: 1px solid var(--border-color); + padding: 5px 12px; border-radius: 999px; +} +.conn-pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); } +.conn-pill[data-state="live"] .dot { background: var(--primary-green); box-shadow: 0 0 6px var(--primary-green); } +.conn-pill[data-state="photo"] .dot { background: #38bdf8; box-shadow: 0 0 6px #38bdf8; } +.conn-pill[data-state="error"] .dot { background: var(--danger); box-shadow: 0 0 6px var(--danger); } +.conn-pill[data-state="connecting"] .dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); } + +main { + flex: 1; + padding: 20px 32px; + display: grid; + grid-template-columns: 1fr 420px; + gap: 24px; + align-items: start; +} + +.stage-panel { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + box-shadow: var(--shadow-soft); +} + +.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); } +.toolbar-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +.segmented { display: flex; background: var(--primary-subtle); border: 1px solid var(--border-color); border-radius: 10px; padding: 3px; gap: 2px; } +.seg-btn { background: transparent; border: none; color: var(--text-muted); padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; } +.seg-btn.active { background: var(--primary-green); color: white; box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3); } + +.tool-btn { + background: var(--bg-card); border: 1px solid var(--border-color); color: var(--primary-dark); + padding: 7px 14px; border-radius: 10px; font-size: 12px; font-weight: 700; cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; font-family: inherit; +} +.tool-btn:hover:not(:disabled) { background: var(--primary-subtle); border-color: var(--primary-green); } +.tool-btn:disabled { opacity: 0.45; cursor: default; } +.tool-btn.primary { background: var(--primary-green); border-color: var(--primary-green); color: white; } +.tool-btn.danger { background: var(--danger-soft); border-color: #fca5a5; color: #b91c1c; } +.tool-btn.wide { width: 100%; justify-content: center; } + +.select { background: var(--bg-card); color: var(--text-main); border: 1px solid var(--border-color); padding: 7px 12px; border-radius: 10px; font-size: 12px; font-weight: 600; font-family: inherit; max-width: 220px; } + +.hud { font-size: 11px; font-weight: 700; color: #166534; background: var(--primary-subtle); border: 1px solid var(--border-color); padding: 6px 12px; border-radius: 999px; cursor: pointer; font-variant-numeric: tabular-nums; } +.hud[data-detail="1"] { border-radius: 10px; } + +.stage { + position: relative; + background: var(--night); + border-radius: 14px; + overflow: hidden; + min-height: 540px; + display: flex; + align-items: center; + justify-content: center; +} +.stage video, .stage img { max-width: 100%; max-height: 74vh; display: block; } +.stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; } + +.stage-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: #cbd5e1; font-size: 13px; text-align: center; padding: 24px; } +.stage-empty-icon { font-size: 34px; } + +.hint-banner { + position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); + background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(8px); + border: 1px solid var(--primary-green); color: #14532d; + padding: 8px 20px; border-radius: 999px; font-size: 12px; font-weight: 700; + box-shadow: 0 4px 16px rgba(34, 197, 94, 0.2); pointer-events: none; +} +.hint-banner[data-kind="warning"] { border-color: var(--amber); color: #92400e; } +.hint-banner[data-kind="danger"] { border-color: var(--danger); color: #991b1b; } + +.side-panel { display: flex; flex-direction: column; gap: 16px; } + +.card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 18px; display: flex; flex-direction: column; gap: 14px; box-shadow: var(--shadow-soft); } +.card-header { font-size: 12px; font-weight: 800; color: #166534; text-transform: uppercase; letter-spacing: 0.6px; display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; border-bottom: 1px solid var(--border-color); } +.score-label { font-size: 12px; color: #16a34a; font-weight: 700; text-transform: none; letter-spacing: 0; } + +.placeholder { text-align: center; padding: 32px 10px; color: var(--text-muted); font-size: 13px; display: flex; flex-direction: column; gap: 8px; align-items: center; } +.placeholder.small { padding: 16px 8px; font-size: 12px; } +.placeholder-icon { font-size: 32px; } + +.card-body { display: flex; flex-direction: column; gap: 14px; } + +.product-hero { display: flex; gap: 14px; align-items: center; } +.product-thumb { width: 92px; height: 116px; border-radius: var(--radius-md); object-fit: cover; border: 2px solid var(--primary-light); background: #f8fafc; } +.product-details { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.product-name { font-size: 16px; font-weight: 800; line-height: 1.3; } +.product-sku { font-size: 11px; color: #16a34a; font-family: ui-monospace, monospace; background: var(--primary-light); padding: 2px 6px; border-radius: 4px; width: fit-content; font-weight: 700; } +.product-price { font-size: 24px; font-weight: 800; color: var(--primary-dark); } + +.candidates { display: flex; flex-direction: column; gap: 6px; background: var(--amber-soft); border: 1px solid #fcd34d; border-radius: var(--radius-md); padding: 10px 12px; } +.candidates-title { font-size: 11px; font-weight: 800; color: #92400e; text-transform: uppercase; letter-spacing: 0.5px; } +.candidate-row { display: flex; justify-content: space-between; font-size: 12px; color: #78350f; font-weight: 600; } + +.stock-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.stock-metric { background: var(--primary-subtle); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 10px 14px; } +.metric-title { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; font-weight: 600; } +.metric-val { font-size: 16px; font-weight: 800; color: var(--primary-dark); } +.metric-val.small { font-size: 13px; } +.metric-val.warn { color: #b45309; } + +.action-btn { + width: 100%; padding: 12px; border: none; border-radius: var(--radius-md); + background: linear-gradient(135deg, #22c55e, #16a34a); color: white; + font-weight: 800; font-size: 14px; cursor: pointer; font-family: inherit; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + box-shadow: 0 4px 14px rgba(34, 197, 94, 0.3); +} +.action-btn:hover { transform: translateY(-1px); } + +.match-box { width: 100%; min-height: 150px; background: var(--night); border-radius: var(--radius-md); border: 1px solid var(--border-color); display: flex; align-items: center; justify-content: center; overflow: hidden; } +.match-box img { width: 100%; height: 100%; object-fit: contain; } +.match-placeholder { color: #94a3b8; font-size: 12px; padding: 20px; text-align: center; } + +.inview-list { display: flex; flex-direction: column; gap: 8px; max-height: 320px; overflow-y: auto; } +.inview-row { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: var(--radius-md); cursor: pointer; } +.inview-row:hover { background: var(--primary-subtle); } +.inview-thumb { width: 34px; height: 44px; border-radius: 6px; object-fit: cover; border: 1px solid var(--border-color); background: #f8fafc; } +.inview-text { flex: 1; min-width: 0; } +.inview-name { font-size: 12px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.inview-meta { font-size: 11px; color: var(--text-muted); } +.inview-facings { font-size: 12px; font-weight: 800; color: var(--primary-dark); } + +.status-badge { font-size: 11px; padding: 3px 10px; border-radius: 999px; font-weight: 700; text-transform: none; letter-spacing: 0; } +.badge-neutral { background: var(--slate-soft); color: var(--text-muted); } +.badge-success { background: var(--primary-light); color: var(--primary-dark); } +.badge-warning { background: var(--amber-soft); color: #b45309; } +.badge-danger { background: var(--danger-soft); color: #b91c1c; } + +.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.6); backdrop-filter: blur(4px); z-index: 1000; align-items: center; justify-content: center; } +.modal-backdrop.open { display: flex; } +.modal-window { background: #fff; border-radius: 20px; width: 90%; max-width: 600px; padding: 22px; display: flex; flex-direction: column; gap: 14px; } +.modal-header { display: flex; justify-content: space-between; align-items: center; } +.modal-title { font-size: 17px; font-weight: 800; color: #14532d; } +.close-btn { background: none; border: none; font-size: 24px; color: var(--text-muted); cursor: pointer; } +.video-box { width: 100%; height: 340px; background: #000; border-radius: var(--radius-md); overflow: hidden; } +.video-box video { width: 100%; height: 100%; object-fit: cover; } + +@media (max-width: 1024px) { + main { grid-template-columns: 1fr; padding: 16px; } + .stage { min-height: 380px; } +}