docs: API contract, README for the server and live mode, real-model test

docs/API.md is the full contract: run flags and environment, auth, coordinate
convention, the identity vocabulary, every REST endpoint and WebSocket message
with examples, the backpressure rule, what a client may rely on, the design
decisions with their rejected alternatives, measured performance, known
limitations, and notes for the future mobile client.

README.md (Vietnamese) now documents the client-server shape, both modes, the
flags, the API, the test commands, and the source layout. It also repairs the
PowerShell escape damage in the original file (backtick-r had become a line
break, backtick-b a backspace, backtick-a a bell), which had left the code
fences and file paths mangled.

test_real_model.py is the opt-in integration test (DORAYAKI_REAL_MODEL=1): it
guards the measured baseline (170 detections, 105 recognized on image.jpg) and
proves a pan keeps labels stable with no unexplained changes.

run_app.sh / run_app.bat pass arguments through to the server.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 12:46:54 +09:00
parent da2bed1c35
commit 4960553254
5 changed files with 568 additions and 77 deletions

View file

@ -1,99 +1,150 @@
# 🍡 Dorayaki - Smart Supermarket Shelf AI & Inventory Recognition
Hệ thống nhận diện sản phẩm trên kệ siêu thị và quản lý tồn kho thời gian thực dựa trên kiến trúc kết hợp **YOLO** (Object Detection) và **Local Feature Matching** (SURF / SIFT, ORB, BRISK, Hybrid) theo paper quốc tế (*Electronics 2023, 12, 3640*).
Hệ thống nhận diện sản phẩm trên kệ siêu thị và quản lý tồn kho theo kiến trúc **client - server**: mô hình thị giác máy tính (YOLO + Local Feature Matching: SURF/SIFT, ORB, BRISK, Hybrid theo paper *Electronics 2023, 12, 3640*) chạy trong một **recognition server**, còn các client (web, thiết bị chụp ảnh) chỉ gửi khung hình và nhận kết quả.
Server phục vụ **2 chế độ**:
1. **Live (nhận diện thời gian thực)** - camera hoặc video gửi liên tục qua WebSocket, sản phẩm được phát hiện trên từng khung hình, theo dõi (tracking) xuyên khung hình, phân loại một lần cho mỗi sản phẩm và gắn nhãn ngay trên màn hình.
2. **Photo (ảnh tĩnh)** - chụp/upload/chọn ảnh kệ, nhận diện toàn bộ kệ trong một lần, bấm vào từng sản phẩm để xem chi tiết, tồn kho và điểm matching.
---
## 📋 Yêu cầu hệ thống (System Requirements)
- **Hệ điều hành:** Windows 10/11, macOS hoặc Linux (Ubuntu/Debian).
- **Python:** Python 3.9, 3.10 hoặc 3.11 (khuyên dùng Python 3.10).
- **Phần cứng:** CPU tối thiểu 4 lõi, RAM 8GB (có GPU NVIDIA là lợi thế nhưng CPU chạy hoàn toàn mượt mà).
- **Hệ điều hành:** Windows 10/11, macOS hoặc Linux.
- **Python:** 3.10 - 3.12 (khuyên dùng 3.11/3.12).
- **Phần cứng:** CPU 4 lõi trở lên, RAM 8GB. GPU không bắt buộc: trên máy M5 đo được CPU nhanh hơn MPS (28ms so với 112ms mỗi khung hình).
---
## 🚀 Hướng dẫn cài đặt và chạy Step-by-Step
## 🚀 Cài đặt và chạy
### Bước 1: Sao chép toàn bộ thư mục dự án sang máy mới
Đảm bảo bạn copy toàn bộ thư mục dự án, đặc biệt là các thư mục cốt lõi:
- code/: Toàn bộ mã nguồn backend, frontend, thuật toán nhận diện.
-
uns/: Chứa file trọng số mô hình YOLO đã train (
uns/detect/train/weights/best.pt).
- stock/: Chứa file catalog stock.csv và ảnh mẫu sản phẩm chuẩn sku_001 -> sku_016.
- dd/ & image.jpg: Các ảnh kệ hàng mẫu để test và demo.
-
equirements.txt: Danh sách thư viện cần thiết.
### Bước 1: Chuẩn bị thư mục dự án
### Bước 2: Tạo môi trường ảo Python (Khuyên dùng)
Mở Terminal / PowerShell / Command Prompt tại thư mục dự án:
Cần có đầy đủ:
#### Cách 1: Sử dụng Conda (Khuyên dùng nếu đã cài Anaconda/Miniconda)
`ash
conda create -n dorayaki python=3.10 -y
- `code/` - mã nguồn server, client web, client mẫu.
- `runs/` - trọng số YOLO đã train (`runs/detect/train/weights/best.pt`).
- `stock/` - catalog `stock.csv` và ảnh mẫu sản phẩm `sku_001` -> `sku_022`.
- `add/` và `image.jpg` - ảnh kệ mẫu để test/demo.
- `requirements.txt`, `requirements-dev.txt`.
### Bước 2: Tạo môi trường ảo
```bash
conda create -n dorayaki python=3.11 -y
conda activate dorayaki
`
```
#### Cách 2: Sử dụng venv chuẩn của Python
- **Trên Windows:**
`ash
python -m venv venv
venv\Scripts\activate
`
- **Trên macOS / Linux:**
`ash
python3 -m venv venv
source venv/bin/activate
`
hoặc venv:
### Bước 3: Cài đặt các thư viện phụ thuộc
Chạy lệnh sau để cài đặt toàn bộ package:
`ash
pip install -r requirements.txt
`
```bash
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
```
*(Ghi chú nếu máy có GPU NVIDIA và muốn tăng tốc độ YOLO, có thể cài PyTorch CUDA theo hướng dẫn tại pytorch.org).*
### Bước 3: Cài đặt thư viện
### Bước 4: Khởi chạy ứng dụng Dorayaki Web App
```bash
pip install -r requirements-dev.txt
```
#### Cách 1: Chạy bằng lệnh Python
`ash
(`requirements-dev.txt` = `requirements.txt` + `httpx` cho test.)
### Bước 4: Khởi chạy recognition server
```bash
python code/app.py --port 8080
`
*(Nếu dùng macOS/Linux có thể dùng python3 code/app.py --port 8080)*
```
#### Cách 2: Chạy nhanh bằng script có sẵn
- **Trên Windows:** Click đúp vào file
un_app.bat
- **Trên Linux/macOS:** Chạy ./run_app.sh
Script có sẵn: `run_app.bat` (Windows) hoặc `./run_app.sh` (Linux/macOS).
### Bước 5: Mở ứng dụng trên trình duyệt
Mở trình duyệt (Chrome, Edge, Safari, Firefox) và truy cập:
👉 **http://localhost:8080** (hoặc **http://127.0.0.1:8080**)
Các tham số thường dùng:
| Tham số | Mặc định | Ý nghĩa |
| --- | --- | --- |
| `--host` | `127.0.0.1` | Đổi thành `0.0.0.0` để mở cho máy khác trong LAN (nên đặt `DORAYAKI_API_TOKEN`). |
| `--device` | `cpu` | `cpu`, `mps`, `cuda`, `cuda:0`. |
| `--imgsz` | `640` | Kích thước đầu vào của detector. |
| `--live-method` | `hybrid` | Thuật toán mặc định cho chế độ live. |
| `--max-sessions` | `4` | Số client live đồng thời. |
| `--ssl-certfile` / `--ssl-keyfile` | - | Bật HTTPS (cần cho camera trên điện thoại). |
### Bước 5: Mở ứng dụng
Mở trình duyệt: **http://127.0.0.1:8080** (hoặc `http://127.0.0.1:8080`)
- Tab **Live**: chọn camera (hoặc "Video file…") rồi bấm **Start**. Sản phẩm hiện khung xanh kèm tên và giá khi đã nhận diện chắc chắn; khung vàng "要確認" là trường hợp chưa chắc chắn.
- Tab **Photo**: chọn ảnh mẫu, chụp ảnh hoặc upload ảnh kệ. Nhấn vào sản phẩm để xem chi tiết và bấm **Show matching** để xem các điểm matching.
Camera trên điện thoại chỉ hoạt động khi trang mở bằng **https** hoặc **localhost** (yêu cầu bảo mật của trình duyệt).
---
## 🛠️ Các lệnh kiểm tra hệ thống (Test Scripts)
## 🔌 API (dùng cho client khác)
1. **Test toàn diện hệ thống (Test Suite):**
`ash
python code/test_suite.py
`
2. **Test nhận diện sản phẩm trên ảnh image.jpg:**
`ash
python code/test_image.py
`
3. **Test nhận diện các chỗ trống trên kệ (Empty Shelf Slots):**
`ash
python code/test_empty.py
`
*(Kết quả trực quan sẽ được xuất ra file detected_empty_slots.jpg với viền đỏ bao quanh các vị trí hết hàng).*
Server có REST + WebSocket đầy đủ, tài liệu chi tiết trong [`docs/API.md`](docs/API.md). Swagger UI: **http://127.0.0.1:8080/docs**.
```bash
# Nhận diện một ảnh kệ
curl -X POST --data-binary @image.jpg -H 'Content-Type: image/jpeg' \
'http://127.0.0.1:8080/v1/recognize?classify=true'
# Client live mẫu (camera, video, hoặc pan qua ảnh tĩnh)
python code/stream_client.py --source image.jpg --pan 12 --seconds 20
python code/stream_client.py --source 0 # webcam
```
Bất kỳ client nào gửi được ảnh JPEG đều dùng được: điện thoại, iPad, camera gắn xe đẩy, camera đeo cổ, robot. Giao thức không phụ thuộc trình duyệt.
---
## 🌟 Các tính năng chính của ứng dụng
1. **Phát hiện sản phẩm (YOLO):** Tự động detect hơn 170+ vật phẩm trên kệ trong 1 lượt scan.
2. **Phát hiện chỗ trống trên kệ (Out-of-Stock Detection):** Tự động phát hiện các vị trí hết hàng và khoanh đỏ, ước lượng số lượng sản phẩm đang thiếu.
3. **Phân loại đa thuật toán:** Cho phép chọn giữa **Hybrid (Paper)**, **SURF / SIFT**, **ORB**, **BRISK**.
4. **Tương tác trực quan:** Click vào bất kỳ sản phẩm nào trên ảnh kệ để xem tên, giá, ngày nhập, hạn sử dụng, số lượng tồn kho và xem các điểm matching inliers.
5. **Đa ngôn ngữ:** Hỗ trợ chuyển đổi nhanh Tiếng Anh và Tiếng Nhật.
6. **Tải ảnh linh hoạt:** Chọn ảnh từ danh mục mẫu, chụp ảnh trực tiếp từ webcam/camera điện thoại, hoặc upload ảnh từ máy tính.
## 🛠️ Kiểm tra hệ thống (Tests)
```bash
cd code
YOLO_OFFLINE=1 ../.venv/bin/python -m unittest test_observe test_engine test_tracking test_live test_api -v
# Test với model thật (tải trọng số, chạy ~15s)
DORAYAKI_REAL_MODEL=1 YOLO_OFFLINE=1 ../.venv/bin/python -m unittest test_real_model -v
cd ..
YOLO_OFFLINE=1 .venv/bin/python code/test_suite.py # bộ test gốc
.venv/bin/python code/bench_engine.py # đo tốc độ detect/classify
```
Lưu ý: chạy test bằng danh sách module cụ thể, **không dùng** `unittest discover` vì sẽ import `test.py`/`fast_test.py` (script scratch với đường dẫn máy khác và cửa sổ OpenCV chặn).
---
## 🌟 Tính năng chính
1. **Nhận diện thời gian thực (Live)**: phát hiện sản phẩm trên từng khung hình, theo dõi xuyên khung hình, phân loại một lần cho mỗi sản phẩm, gắn nhãn tên/giá ngay trên khung hình; hiển thị số khung/giây và độ trễ.
2. **Phát hiện chỗ trống trên kệ (Out-of-Stock)**: khoanh đỏ các vị trí hết hàng kèm ước lượng số lượng thiếu; có bộ lọc ổn định để khung không nhấp nháy.
3. **Nhận diện ảnh tĩnh (Photo)**: nhận diện toàn bộ kệ trong một lần thay vì bấm từng sản phẩm.
4. **Phân loại đa thuật toán**: Hybrid (Paper), SURF/SIFT, ORB, BRISK - đổi được ngay trong lúc chạy.
5. **Trạng thái trung thực**: chỉ hiện tên sản phẩm khi kết quả được chấp nhận; trường hợp mơ hồ hiện "要確認" kèm các ứng viên, không đoán bừa.
6. **Tương tác chi tiết**: bấm vào sản phẩm để xem tên, giá, ngày nhập, hạn sử dụng, tồn kho và điểm matching.
7. **Đa client**: nhiều client kết nối đồng thời, mỗi client có session và tracking riêng.
8. **Đa ngôn ngữ**: Tiếng Anh và Tiếng Nhật.
9. **Gợi ý chất lượng ảnh**: cảnh báo lóa (glare), rung (blur), chụp quá xa.
---
## 📁 Cấu trúc mã nguồn
```
code/
app.py # server: REST /v1 + WebSocket /v1/stream + phục vụ web client
engine.py # RecognitionEngine: detector + matcher + catalog, thread-safe
live.py # LiveSession: tracking, phân loại nền, gắn nhãn, chất lượng ảnh
tracking.py # SessionTracker: ByteTrack với ID riêng cho từng session
detector.py # YOLO detector
feature_matcher.py # SURF/SIFT, ORB, BRISK, Hybrid
identity.py # accepted / ambiguous / unknown / rejected
empty_shelf_detector.py, inventory.py, pipeline.py, observe.py
stream_client.py # client mẫu (camera / video / pan ảnh tĩnh)
bench_engine.py # đo tốc độ
web/ # web client (index.html, styles.css, app.js)
docs/API.md # tài liệu API đầy đủ
```

View file

@ -0,0 +1,122 @@
"""
Opt-in integration test against the real model and the real catalog.
Skipped unless DORAYAKI_REAL_MODEL=1, because it loads the YOLO weights and
classifies a full shelf. It defends the end-to-end claim: the server detects the
whole shelf, accepts a meaningful share of the products, keeps a label stable
while the camera pans, and never names a SKU for a weak match.
cd recognization/code
DORAYAKI_REAL_MODEL=1 YOLO_OFFLINE=1 ../.venv/bin/python -m unittest test_real_model -v
"""
from __future__ import annotations
import math
import os
import sys
import unittest
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
if os.path.exists(os.path.join(project_root, "stock")):
os.chdir(project_root)
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
import cv2 # noqa: E402
from engine import EngineSettings, build_default_engine # noqa: E402
from live import LiveSession, LiveSettings # noqa: E402
ENABLED = os.environ.get("DORAYAKI_REAL_MODEL") == "1"
# Measured baseline on image.jpg (2026-09-24): 170 detections, 105 recognized.
BASELINE_DETECTIONS = 170
BASELINE_RECOGNIZED = 105
@unittest.skipUnless(ENABLED, "set DORAYAKI_REAL_MODEL=1 to run against the real model")
class RealModelTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.engine = build_default_engine(EngineSettings(device="cpu"))
cls.engine.warm_up()
@classmethod
def tearDownClass(cls):
cls.engine.shutdown()
def test_recognize_whole_shelf(self):
with open("image.jpg", "rb") as handle:
payload = handle.read()
image = self.engine.decode_image(payload)
result = self.engine.recognize(image, classify=True)
summary = result["summary"]
self.assertGreaterEqual(
summary["in_view"], int(BASELINE_DETECTIONS * 0.95),
"detection must not regress below the measured baseline",
)
self.assertGreaterEqual(
summary["recognized"], int(BASELINE_RECOGNIZED * 0.9),
"recognition must not regress below the measured baseline",
)
# every named SKU must exist in the catalog
catalog = {product["sku_id"] for product in self.engine.catalog()}
for detection in result["detections"]:
identity = detection["identity"]
if identity["state"] == "recognized":
self.assertIn(identity["sku_id"], catalog)
else:
self.assertIsNone(identity["sku_id"], "only an accepted match may name a SKU")
def test_pan_keeps_labels_stable_and_advances_them(self):
image = cv2.imread("image.jpg")
height, width = image.shape[:2]
frame_w, frame_h = 1280, 720
session = LiveSession("real", self.engine, settings=LiveSettings(method="hybrid"))
session.refresh_catalog()
labels: dict = {}
relabels: dict = {}
restarts: dict = {}
unexplained = []
recognized_frames = 0
frames = 16
for index in range(frames):
phase = index / (frames - 1)
x = int((width - frame_w) * (0.5 - 0.5 * math.cos(2 * math.pi * phase)))
y = max(0, (height - frame_h) // 2)
window = image[y:y + frame_h, x:x + frame_w]
ok, buffer = cv2.imencode(".jpg", window, [cv2.IMWRITE_JPEG_QUALITY, 75])
session.submit_frame(index, buffer.tobytes())
pending = session.take_pending(timeout=5.0)
self.assertIsNotNone(pending)
result = session.process_frame(*pending)
import time
time.sleep(0.4) # let classification land, as a live client's next frame would
if result["summary"]["recognized"]:
recognized_frames += 1
for track in result["tracks"]:
identity = track["identity"]
if identity["state"] != "recognized":
continue
track_id = track["track_id"]
sku = identity["sku_id"]
if restarts.get(track_id, 0) < track["restarts"]:
labels.pop(track_id, None)
if track_id in labels and labels[track_id] != sku:
if track["relabels"] <= relabels.get(track_id, 0):
unexplained.append((track_id, labels[track_id], sku))
labels[track_id] = sku
relabels[track_id] = track["relabels"]
restarts[track_id] = track["restarts"]
self.assertGreater(recognized_frames, frames // 2, "labels should appear on most frames")
self.assertGreater(len(labels), 0, "the pan should label products")
self.assertEqual(unexplained, [], "a label must never change without re-verification")
if __name__ == "__main__":
unittest.main()

318
recognization/docs/API.md Normal file
View file

@ -0,0 +1,318 @@
# Dorayaki recognition server API
The computer-vision model (YOLO26n product detector + the SURF/SIFT, ORB, BRISK and
Hybrid feature-matching classifier + the stock catalog) runs as a service. Capture
clients talk to it over REST and one WebSocket. Any number of clients can connect at
once; no client shares state with another.
- Web client (served by the server itself): `http://127.0.0.1:8080/`
- Reference non-browser client: `code/stream_client.py`
- Interactive API docs: `http://127.0.0.1:8080/docs`
## Running it
```bash
cd recognization
uv pip install --python .venv/bin/python -r requirements-dev.txt # first time
.venv/bin/python code/app.py --port 8080
```
| Flag | Default | Meaning |
| --- | --- | --- |
| `--host` | `127.0.0.1` | Bind address. `0.0.0.0` exposes it on the LAN (set a token). |
| `--port` | `8080` | Port. |
| `--device` | `cpu` | `cpu`, `mps`, `cuda`, `cuda:0`. CPU is the measured default here. |
| `--imgsz` | `640` | Detector input size. |
| `--weights` | `runs/detect/train/weights/best.pt` | Model file. |
| `--stock-dir` | `stock` | Catalog directory (images + `stock.csv`). |
| `--classify-workers` | `cpu_count - 2` | Classification thread pool size. |
| `--live-method` | `hybrid` | Default method for live sessions. |
| `--live-conf` | `0.1` | Detection threshold in live mode (the tracker gates). |
| `--max-sessions` | `4` | Concurrent live sessions. |
| `--ssl-certfile` / `--ssl-keyfile` | – | HTTPS (needed for a phone camera). |
| `--log-level` | `info` | Logging level. |
Environment: `DORAYAKI_*` equivalents (`DORAYAKI_API_TOKEN`, `DORAYAKI_DEVICE`,
`DORAYAKI_MAX_SESSIONS`, `DORAYAKI_CORS_ORIGINS`, `DORAYAKI_LIVE_INFLIGHT`, ...).
Command-line flags win over environment.
The model never phones home: the server sets `YOLO_OFFLINE=1` and
`YOLO_AUTOINSTALL=false` before importing ultralytics, and `lap` is pinned in
`requirements.txt` so the tracker never pip-installs anything mid-request.
## Authentication
When `DORAYAKI_API_TOKEN` is set, every `/v1` route except `GET /v1/health` requires
`Authorization: Bearer <token>`, and `/v1/stream` takes `?token=<token>` (browsers
cannot set WebSocket headers). The token is deliberately environment-only so it never
lands in shell history, and the access log is off because it would print the query
string. A token on a LAN bind is a speed bump, not security: prefer HTTPS.
## Coordinates
Boxes are `[x1, y1, x2, y2]` integers, origin top-left, in the pixel space of the
image or frame the client sent. Responses echo
`"coordinate_convention": "pixel-xyxy-origin-top-left"`.
## Identity states
One vocabulary for REST and WebSocket. **A product name is only ever attached to a
`recognized` result** - the model's uncertainty is never presented as certainty.
| `state` | Meaning | Has `sku_id`? |
| --- | --- | --- |
| `detected` | Product found, not classified yet (photo mode with `classify=false`) | no |
| `unconfirmed` | Live: tracked, not enough frames to classify yet | no |
| `identifying` | Live: classification in flight (reserved) | no |
| `recognized` | Accepted unique catalog match | **yes** |
| `needs_confirmation` | Ambiguous or weak match; `candidates` lists the top 3 | no |
| `unrecognized` | Not in the catalog (or no matching evidence) | no |
`identity_level` carries the raw decision: `accepted`, `ambiguous`, `unknown`,
`rejected`.
## Errors
Every failure uses one shape:
```json
{"error": {"code": "IMAGE_EXPIRED", "message": "image 'img_x' is no longer available"}}
```
| Code | HTTP | When |
| --- | --- | --- |
| `BAD_REQUEST` | 400 | Malformed request, unknown algorithm, empty body |
| `UNAUTHORIZED` | 401 | Missing or wrong bearer token |
| `NOT_FOUND` | 404 | Unknown SKU, sample, or route |
| `IMAGE_EXPIRED` | 404 | `image_id` left the still-image store (15 min / 16 images) |
| `PAYLOAD_TOO_LARGE` | 413 | Upload > 20 MB, live frame > 4 MB |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | Body is not an image content type |
| `UNDECODABLE_IMAGE` | 422 | Bytes are not a readable image |
| `VALIDATION_ERROR` | 422 | Body failed validation |
| `INTERNAL` | 500 | Server fault (details only in the server log) |
## REST
### `GET /v1/health` (no auth)
```json
{"status": "ok",
"model": {"version": "best.pt@ultralytics-8.4.153", "device": "cpu", "imgsz": 640, "warm": true},
"catalog": {"version": "408a658680ed", "sku_count": 21},
"sessions_active": 0, "uptime_s": 21.1}
```
### `GET /v1/catalog`
Products with stock and an `image_url` for the template photo. Local file paths are
never exposed.
### `GET /v1/catalog/{sku_id}/image`
Template image bytes. The path is resolved through the catalog map only, so a crafted
`sku_id` cannot reach the filesystem.
### `POST /v1/catalog/{sku_id}/stock`
```json
{"delta": -1}
```
`-100..100`, non-zero. Stock is in memory and resets on restart, as before.
### `GET /v1/samples` and `GET /v1/samples/{sample_id}`
The demo shelf images (`image.jpg`, `add/*`, the training mosaic) by id, from an
allowlist built at startup.
### `POST /v1/recognize?classify=true&method=hybrid&conf=0.25&empty_slots=true`
Body: raw image bytes (`Content-Type: image/jpeg|image/png|image/webp`) or multipart
with a `file` field.
```json
{"image_id": "img_11d072958c35bc35", "width": 2048, "height": 1365,
"catalog_version": "408a658680ed", "model_version": "best.pt@ultralytics-8.4.153",
"coordinate_convention": "pixel-xyxy-origin-top-left",
"detections": [{"id": 0, "box": [1435, 383, 1493, 520], "confidence": 0.8258,
"identity": {"state": "recognized", "sku_id": "sku_005", "identity_level": "accepted",
"confidence": 0.8948, "score": 47.71,
"candidates": [{"sku_id": "sku_005", "score": 47.71}],
"attempts": 1, "method": "hybrid"}}],
"empty_slots": [{"id": "empty_1", "box": [812, 404, 909, 549], "width": 97, "height": 145,
"missing_items_est": 2, "center": [860, 476], "row_index": 3}],
"summary": {"in_view": 170, "recognized": 105, "needs_confirmation": 64, "unrecognized": 1,
"detected": 0, "empty_slots": 4, "by_sku": [{"sku_id": "sku_003", "facings": 14}]},
"timing_ms": {"detect": 24.77, "classify": 1498.88, "total": 1523.65}}
```
`classify=false` returns `detected` identities immediately so a client can paint boxes
before identities arrive. The `image_id` stays valid for 15 minutes for
`classify`/`inspect`.
### `POST /v1/images/{image_id}/classify`
```json
{"method": "hybrid", "detection_ids": [0, 3]}
```
`detection_ids` optional. Returns identities for the whole image (or the selected
detections) plus a summary. A method change re-runs it.
### `POST /v1/images/{image_id}/inspect`
```json
{"box": [1435, 383, 1493, 520], "method": "hybrid"}
```
Returns the full decision (`identity`, per-method inlier `sub_details`, `product`), the
cropped product as a data URL, and a feature-match visualization. When the result is
not accepted, the visualization is drawn against the top candidate and
`is_candidate` is `true`.
## WebSocket `/v1/stream`
Live frames in, tracked products out. One session per connection.
**On connect**
```json
{"type": "hello", "protocol": 1, "session_id": "...", "catalog_version": "...",
"model": {"version": "...", "device": "cpu", "imgsz": 640},
"config": {"method": "hybrid", "conf": 0.1, "empty_slots": true},
"limits": {"max_frame_bytes": 4194304}}
```
**Frames (binary):** `4-byte big-endian frame id` + JPEG bytes. The id is echoed back
so the client can pair a result with the frame it sent.
**Control (text JSON)**
| Client sends | Server replies |
| --- | --- |
| `{"type":"config","method":"orb","conf":0.15,"empty_slots":false}` | `{"type":"config_ack","config":{...}}` |
| `{"type":"inspect","track_id":12,"method":"hybrid","request_id":"r1"}` | `{"type":"inspect_result","request_id":"r1",...}` or an error |
| `{"type":"reset"}` | `{"type":"reset_ack"}` |
| `{"type":"ping","t":1}` | `{"type":"pong","t":1}` |
**Per processed frame**
```json
{"type": "result", "frame_id": 41, "seq": 41, "width": 1280, "height": 720,
"tracks": [{"track_id": 12, "box": [812, 404, 909, 549], "raw_box": [...], "confidence": 0.83,
"first_seen_seq": 30, "relabels": 0, "restarts": 0, "inherited": false,
"identity": {"state": "recognized", "sku_id": "sku_005", "confidence": 0.89,
"score": 47.71, "candidates": [...], "attempts": 1, "method": "hybrid"},
"product": {"sku_id": "sku_005", "product_name": "...", "price": 179.0}}],
"empty_slots": [...], "quality": {"sharpness": 812.4, "blur": false, "glare": 0.004,
"too_far": false, "hint": null},
"summary": {"in_view": 61, "recognized": 8, "identifying": 0, "needs_confirmation": 1,
"unrecognized": 0, "detected": 22, "unconfirmed": 30, "empty_slots": 0,
"by_sku": [{"sku_id": "sku_005", "facings": 3}]},
"timing_ms": {"decode": 3.1, "detect": 21.4, "track": 0.8, "total": 25.3},
"stats": {"frames_received": 264, "frames_processed": 264, "frames_dropped": 0,
"server_fps": 13.2, "classify_pending": 3, "classify_ms_p50": 88.9}}
```
**Backpressure:** the server keeps one pending frame per session and processes the
newest, counting the ones it replaced in `frames_dropped`. A client should keep one
frame in flight (`stream_client.py --pace result` does exactly that); sending faster
just drops frames and breaks the pan's continuity for the tracker.
**Recoverable errors** (the socket stays open): `BAD_FRAME`, `UNDECODABLE_IMAGE`,
`PAYLOAD_TOO_LARGE`, `BAD_MESSAGE`, `TRACK_NOT_IN_VIEW`, `INTERNAL`.
**Closes:** `1008` wrong/missing token, `1013` session limit reached, `120 s` idle.
## What the client can rely on
- **A label is only shown for an accepted match.** Weak or ambiguous results are
`needs_confirmation` with candidates; unknown products are `unrecognized`.
- **A label never changes silently.** A settled label is dropped only when the box has
drifted off the product for two consecutive frames, or when the track id was
restarted. Both increment `relabels`/`restarts`, so a client can audit every change.
- **One product is classified once.** While the camera pans, a fresh track that covers
a recently recognized product inherits that label (`inherited: true`) instead of
queueing for classification again.
- **Detection never waits for classification.** Frames come back at detection speed
(~25-45 ms on this machine); labels fill in as the classification pool finishes.
## Design decisions
| Decision | Why | Rejected |
| --- | --- | --- |
| Server lives in this repo, entry point `python code/app.py` | It owns the weights, templates, and the team's workflow | A separate service repo (split the model from its data) |
| WebSocket carrying JPEG frames for live, REST for stills | Browsers and embedded clients both speak it; no media stack | WebRTC (heavy dependency, only wins on lossy WAN), gRPC (browsers), in-browser inference (the matcher is OpenCV Python) |
| Latest-frame-wins with one pending slot | Lowest latency and self-throttling on any network | Queueing every frame (adds latency, never catches up) |
| Tracking-by-detection (ultralytics ByteTrack) wrapped for per-session ids | Kalman prediction plus low-score second association is proven; ids must not be shared | No tracking (re-classify everything, far too slow), a hand-rolled tracker (no motion model) |
| Classify once per track, aggregate up to 3 attempts, lock on accept | Keeps detection real-time and labels stable | Classifying every box every frame (measured as impossible: ~44 ms per crop x 50-170 boxes) |
| Label inheritance by place | The same product gets several ids while panning; re-classifying it is waste | Re-classify on every id (measured 9/66 recognized vs 17-21/66 with inheritance) |
| `hybrid` as the live default | Most accurate (21/21 SKUs on `image.jpg`) | `hybrid_fast` (ORB shortlist): 3.4x faster but only 47% agreement with `hybrid` on 553 crops, so it is not the default |
| CPU as the default device | Measured: 26-29 ms p50 vs 112 ms on MPS, with byte-identical boxes | MPS (slower here), CUDA (no NVIDIA GPU on this machine) |
| Identity states instead of raw scores | "Never turn model uncertainty into business certainty" (project master) | Showing a best-guess SKU with a confidence number |
| Token via env var, access log off | Keeps credentials out of shell history and logs | A `--token` flag, default uvicorn access log (prints the query string) |
| Legacy `/api/sessions*` kept, engine-backed | The earlier session model is still useful and was already tested | Deleting it (loses the work), leaving it on the fake adapter (silently returned nothing) |
## Measured performance
Apple M5, 10 cores, 24 GB, CPU device, 2026-09-24 (`code/bench_engine.py`,
`code/stream_client.py --pace result`):
| Measurement | Value |
| --- | --- |
| Model load / first predict | 50 ms / 985 ms (warm-up covers this at startup) |
| Detection, 1280x853 frame | p50 28.7 ms, p95 31.0 ms (169 boxes) |
| Detection, native 2048x1365 | p50 28.7 ms, p95 87.6 ms (170 boxes) |
| Detection under live load | p50 43-66 ms (classification pool running) |
| Classification per crop (hybrid) | p50 44 ms, p95 95 ms |
| Classification throughput | 92 crops/s at 8 threads |
| Still recognition, whole shelf | 170 detections, 105 recognized, ~2.0 s total |
| Live results | 8-14 per second, round-trip p50 58-78 ms |
| Time to first label on a pan | ~1.6-2.7 s (includes waiting for the product to enter) |
The slide's "約28~30ms per image" matches the measured **detection** time (28.7 ms);
the full detect-and-classify of a whole 170-product shelf is ~2 s because each product
is matched against the catalog separately.
## Known limitations
- **No session totals.** Facings are counted only for products currently in view. A
product that leaves and re-enters gets a new track id, so cumulative counts would
double count.
- **Identity thresholds are uncalibrated** closed-set defaults (`identity.py`), not
measured accuracy.
- **`surf` is SIFT** in this OpenCV build (no nonfree SURF); the label stays for
continuity with the paper.
- **Fast pans classify less.** A very fast pan creates more new tracks than the
classification pool can settle; a slower, natural scan labels nearly everything.
- **Coverage guidance is partial**: glare/blur/too-far hints exist; "which part of the
fixture was scanned" does not (that needs mosaicking).
- **Stock is in memory** and resets when the server restarts.
- **`hybrid_fast` is experimental** and off by default.
## Mobile client notes (future lane)
The protocol is capture-source agnostic; an iPad/phone client needs no server changes.
- WebSocket client (Ktor `WebSockets` plugin), one frame in flight.
- Capture JPEG frames from `AVCaptureVideoDataOutput` (iOS) or CameraX
`ImageAnalysis` (Android), sent upright; map server boxes into the view with the
same object-fit transform the web client uses.
- iOS ATS: allow local networking (`NSAllowsLocalNetworking`) or use HTTPS; Android
needs cleartext permission for plain `http://`.
- The dorayaki app repo requires an ADR before adding a second API base URL, and its
scan screen is a timer-driven placeholder today - wiring it to this server is a
separate lane.
## Tests
```bash
cd recognization/code
YOLO_OFFLINE=1 ../.venv/bin/python -m unittest test_observe test_engine test_tracking test_live test_api -v
DORAYAKI_REAL_MODEL=1 YOLO_OFFLINE=1 ../.venv/bin/python -m unittest test_real_model -v
cd .. && YOLO_OFFLINE=1 .venv/bin/python code/test_suite.py # the original CLI suite
```
Run tests with an explicit module list: `unittest discover` would import
`test.py`/`fast_test.py`, which are scratch scripts with hardcoded paths from another
machine and a blocking OpenCV window.

View file

@ -3,7 +3,7 @@ echo ==========================================================
echo DORAYAKI - SMART SHELF RECOGNITION AI APP
echo ==========================================================
echo [1/2] Installing / Checking dependencies...
python -m pip install -r requirements.txt
echo [2/2] Starting Dorayaki Server on port 8080...
python code/app.py --port 8080
python -m pip install -r requirements-dev.txt
echo [2/2] Starting Dorayaki recognition server on port 8080...
python code/app.py --port 8080 %*
pause

6
recognization/run_app.sh Normal file → Executable file
View file

@ -3,6 +3,6 @@ echo '=========================================================='
echo ' DORAYAKI - SMART SHELF RECOGNITION AI APP'
echo '=========================================================='
echo '[1/2] Installing / Checking dependencies...'
pip install -r requirements.txt
echo '[2/2] Starting Dorayaki Server on port 8080...'
python3 code/app.py --port 8080
pip install -r requirements-dev.txt
echo '[2/2] Starting Dorayaki recognition server on port 8080...'
python3 code/app.py --port 8080 "$@"