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 = `
`;
+ } 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
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
📷
+
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; }
+}