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:
parent
da2bed1c35
commit
4960553254
5 changed files with 568 additions and 77 deletions
|
|
@ -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 đủ
|
||||
```
|
||||
|
|
|
|||
122
recognization/code/test_real_model.py
Normal file
122
recognization/code/test_real_model.py
Normal 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
318
recognization/docs/API.md
Normal 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.
|
||||
|
|
@ -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
6
recognization/run_app.sh
Normal file → Executable 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 "$@"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue