Compare commits
6 commits
06f9fb4819
...
19af7ce545
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19af7ce545 | ||
|
|
e9279c58b3 | ||
|
|
ada00da88b | ||
|
|
745a29d979 | ||
|
|
0e85c399bb | ||
|
|
f0e2baaf3d |
12 changed files with 625 additions and 176 deletions
1
recognization/.gitignore
vendored
1
recognization/.gitignore
vendored
|
|
@ -19,6 +19,7 @@ ENV/
|
|||
|
||||
# Temporary weights
|
||||
runs/detect/train/weights/last.pt
|
||||
weights/dinov2_vits14_lvd142m.safetensors
|
||||
*.log
|
||||
stock-*/identity_head.pt
|
||||
.demo-token
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Cần có đầy đủ:
|
|||
|
||||
- `code/` - mã nguồn server, client web, client mẫu.
|
||||
- `runs/` - trọng số YOLO đã train (`runs/detect/train/weights/best.pt`).
|
||||
- `weights/dinov2_vits14_lvd142m.safetensors` - DINOv2 backbone for the identity gallery. Not in Git. See "DINOv2 weights" below.
|
||||
- `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`.
|
||||
|
|
@ -49,6 +50,20 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|||
pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
### DINOv2 weights
|
||||
|
||||
The identity gallery embeds crops with DINOv2 ViT-S/14 (LVD-142M), Apache-2.0. It loads only a local file and does not contact Hugging Face, including when `YOLO_OFFLINE` is unset. Without the file, gallery identity fails and the server log says the weights are missing.
|
||||
|
||||
The pinned file is `model.safetensors` from `timm/vit_small_patch14_dinov2.lvd142m`, sha256 `04d27f3400d059fc0cfd7d17dd1909a75bf3ea8fb3eeb48b97cb99e57ee20081` (~84 MB). Copy a cache you already have; do not fetch one from an unknown URL:
|
||||
|
||||
```bash
|
||||
mkdir -p weights
|
||||
cp "$HOME/.cache/huggingface/hub/models--timm--vit_small_patch14_dinov2.lvd142m/snapshots/"*/model.safetensors \
|
||||
weights/dinov2_vits14_lvd142m.safetensors
|
||||
```
|
||||
|
||||
`DORAYAKI_DINOV2_WEIGHTS` overrides that path. A file with any other sha256 is refused.
|
||||
|
||||
(`requirements-dev.txt` = `requirements.txt` + `httpx` cho test.)
|
||||
|
||||
### Bước 4: Khởi chạy recognition server
|
||||
|
|
|
|||
|
|
@ -270,7 +270,8 @@ def create_app(
|
|||
async def lifespan(_app: FastAPI):
|
||||
yield
|
||||
for session in list(sessions.values()):
|
||||
session.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
engine.shutdown()
|
||||
|
||||
app = FastAPI(
|
||||
|
|
@ -1111,6 +1112,11 @@ def main() -> None:
|
|||
LOGGER.info("Loading model and catalog (device=%s, imgsz=%s)...",
|
||||
engine_settings.device or "auto", engine_settings.imgsz)
|
||||
engine = build_default_engine(engine_settings)
|
||||
if (engine_settings.identity_backend or "").lower() == "gallery":
|
||||
if engine.identity_gallery() is None:
|
||||
raise RuntimeError(
|
||||
"gallery identity backend requires local DINOv2 weights; refusing to start"
|
||||
)
|
||||
warm_ms = engine.warm_up()
|
||||
LOGGER.info(
|
||||
"Ready: %d SKUs, catalog %s, warmup %.0f ms, classify workers %d",
|
||||
|
|
|
|||
|
|
@ -1,66 +1,253 @@
|
|||
"""
|
||||
Enroll a shelf: the products read off its crops become the store's catalog.
|
||||
Crop a labelled shelf photo into a stock directory.
|
||||
|
||||
This is the onboarding flow a real store would go through once - detect the facings,
|
||||
name the products, and keep their crops as the gallery that recognition matches
|
||||
against. The two demo shelves were built with it; the product lists live in this file
|
||||
because reading a contact sheet is a human step.
|
||||
This is a one-shot offline helper. SKU names and prices are supplied by a human
|
||||
who read a contact sheet; this script does not enroll a phone recording and
|
||||
does not talk to the recognition server.
|
||||
|
||||
python code/build_store_from_shelf.py
|
||||
The Meidiya dairy labels that built stock-mediya are kept as --example mediya.
|
||||
Point --image, --boxes, and --output at real paths. There is no default home
|
||||
directory or /tmp path.
|
||||
|
||||
python code/build_store_from_shelf.py \\
|
||||
--image shelf.jpg --boxes boxes.json --output /path/to/new-stock \\
|
||||
--labels labels.json
|
||||
|
||||
boxes.json is {"boxes": [[x1, y1, x2, y2], ...]} in the image's own pixels;
|
||||
this script does not rescale the photo. labels.json is
|
||||
{"products": [{"sku", "name", "price", "boxes": [index, ...]}]}.
|
||||
An existing output directory is left untouched unless --replace is passed,
|
||||
and even then the old directory is renamed to "<output>.previous", not deleted.
|
||||
"""
|
||||
import json, os, shutil, sys
|
||||
sys.path.insert(0, "code")
|
||||
import cv2
|
||||
from __future__ import annotations
|
||||
|
||||
ROOT = os.path.expanduser("~/dorayaki-data/shelf-images")
|
||||
manifest = json.load(open(os.path.join(ROOT, "manifest.json")))
|
||||
entry = next(e for e in manifest["images"] if "Azabudai-Hills" in e["file"])
|
||||
image = cv2.imread(os.path.join(ROOT, entry["file"]))
|
||||
height, width = image.shape[:2]
|
||||
scale = min(1.0, 1600.0 / max(height, width))
|
||||
image = cv2.resize(image, (int(width*scale), int(height*scale)), interpolation=cv2.INTER_AREA)
|
||||
boxes = json.load(open("/tmp/dorayaki-verify/mediya-boxes.json"))["boxes"]
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# read off the numbered contact sheet; prices come from the shelf's own tags
|
||||
PRODUCTS = [
|
||||
("sku_m01", "小岩井 低脂肪牛乳", [9, 23, 31]),
|
||||
("sku_m02", "荻山酪農 カフェ・オ・レ", [10]),
|
||||
("sku_m03", "丹那 3.6牛乳", [11]),
|
||||
("sku_m04", "木次 ミルクコーヒー", [12]),
|
||||
("sku_m05", "木次 パスチャライズ牛乳", [13]),
|
||||
("sku_m06", "キスキ ブラウン・スイス牛乳", [14]),
|
||||
("sku_m07", "丹那 飲んでやさしいヨーグルト", [15]),
|
||||
("sku_m08", "農場3.7牛乳 特選", [24]),
|
||||
("sku_m09", "MINOR FIGURES OAT", [26]),
|
||||
("sku_m10", "北海道 低脂肪乳", [27]),
|
||||
("sku_m11", "タカナシ 低温殺菌牛乳", [28]),
|
||||
("sku_m12", "RUDE HEALTH Oat Organic", [29]),
|
||||
("sku_m13", "小岩井 無脂肪牛乳", [30]),
|
||||
("sku_m14", "きすき ノンホモ牛乳", [8]),
|
||||
# ponytail: demo counts/dates are the Meidiya sheet's bookkeeping, not a catalog API.
|
||||
EXAMPLE_RECEIPT = "2026-09-25"
|
||||
EXAMPLE_EXPIRATION = "2026-10-20"
|
||||
EXAMPLE_COUNTS = (24, 8, 16)
|
||||
|
||||
# Named example only. These are the labels read off the Meidiya contact sheet,
|
||||
# not an enrollment of a phone walk.
|
||||
MEDIYA_PRODUCTS = [
|
||||
{"sku": "sku_m01", "name": "小岩井 低脂肪牛乳", "price": 186, "boxes": [9, 23, 31]},
|
||||
{"sku": "sku_m02", "name": "荻山酪農 カフェ・オ・レ", "price": 397, "boxes": [10]},
|
||||
{"sku": "sku_m03", "name": "丹那 3.6牛乳", "price": 214, "boxes": [11]},
|
||||
{"sku": "sku_m04", "name": "木次 ミルクコーヒー", "price": 195, "boxes": [12]},
|
||||
{"sku": "sku_m05", "name": "木次 パスチャライズ牛乳", "price": 240, "boxes": [13]},
|
||||
{"sku": "sku_m06", "name": "キスキ ブラウン・スイス牛乳", "price": 254, "boxes": [14]},
|
||||
{"sku": "sku_m07", "name": "丹那 飲んでやさしいヨーグルト", "price": 225, "boxes": [15]},
|
||||
{"sku": "sku_m08", "name": "農場3.7牛乳 特選", "price": 216, "boxes": [24]},
|
||||
{"sku": "sku_m09", "name": "MINOR FIGURES OAT", "price": 300, "boxes": [26]},
|
||||
{"sku": "sku_m10", "name": "北海道 低脂肪乳", "price": 214, "boxes": [27]},
|
||||
{"sku": "sku_m11", "name": "タカナシ 低温殺菌牛乳", "price": 396, "boxes": [28]},
|
||||
{"sku": "sku_m12", "name": "RUDE HEALTH Oat Organic", "price": 400, "boxes": [29]},
|
||||
{"sku": "sku_m13", "name": "小岩井 無脂肪牛乳", "price": 178, "boxes": [30]},
|
||||
{"sku": "sku_m14", "name": "きすき ノンホモ牛乳", "price": 198, "boxes": [8]},
|
||||
]
|
||||
PRICES = {"sku_m01": 186, "sku_m02": 397, "sku_m03": 214, "sku_m04": 195, "sku_m05": 240,
|
||||
"sku_m06": 254, "sku_m07": 225, "sku_m08": 216, "sku_m09": 300, "sku_m10": 214,
|
||||
"sku_m11": 396, "sku_m12": 400, "sku_m13": 178, "sku_m14": 198}
|
||||
|
||||
store = "stock-mediya"
|
||||
shutil.rmtree(store, ignore_errors=True)
|
||||
os.makedirs(store, exist_ok=True)
|
||||
rows = ["SKU_ID,product_name,receipt_date,expiration_date,number_in,number_out,number_on,image_link,price"]
|
||||
written = []
|
||||
for sku, name, indices in PRODUCTS:
|
||||
saved = 0
|
||||
for position, index in enumerate(indices):
|
||||
box = boxes[index]
|
||||
x1, y1, x2, y2 = [int(v) for v in box]
|
||||
crop = image[max(0, y1):y2, max(0, x1):x2]
|
||||
if crop.size == 0:
|
||||
continue
|
||||
suffix = "" if saved == 0 else f"_{saved}"
|
||||
cv2.imwrite(os.path.join(store, f"{sku}{suffix}.jpeg"), crop)
|
||||
saved += 1
|
||||
if saved == 0:
|
||||
raise SystemExit(f"{sku}: no readable crop; refusing a catalog row with no view")
|
||||
written.append(sku)
|
||||
rows.append(f"{sku},{name},2026-09-25,2026-10-20,24,8,16,'',{PRICES[sku]}")
|
||||
open(os.path.join(store, "stock.csv"), "w").write("\n".join(rows) + "\n")
|
||||
print(f"{store}: {len(written)} products, csv written")
|
||||
|
||||
class BuildStoreError(Exception):
|
||||
"""Refused input. The destination, if it already existed, is unchanged."""
|
||||
|
||||
|
||||
def _is_within(path: str, root: str) -> bool:
|
||||
path = os.path.realpath(path)
|
||||
root = os.path.realpath(root)
|
||||
return path == root or path.startswith(root + os.sep)
|
||||
|
||||
|
||||
def _load_json(path: str):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise BuildStoreError(f"{path}: {exc}") from exc
|
||||
|
||||
|
||||
def load_boxes(path: str) -> list:
|
||||
payload = _load_json(path)
|
||||
boxes = payload.get("boxes") if isinstance(payload, dict) else None
|
||||
if not isinstance(boxes, list) or not boxes:
|
||||
raise BuildStoreError(f"{path}: expected a non-empty {{\"boxes\": [...]}} list")
|
||||
parsed = []
|
||||
for index, box in enumerate(boxes):
|
||||
if not isinstance(box, (list, tuple)) or len(box) != 4:
|
||||
raise BuildStoreError(f"{path}: box {index} is not [x1, y1, x2, y2]")
|
||||
try:
|
||||
coords = [int(value) for value in box]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise BuildStoreError(f"{path}: box {index} is not four numbers") from exc
|
||||
x1, y1, x2, y2 = coords
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
raise BuildStoreError(f"{path}: box {index} has no area")
|
||||
parsed.append(coords)
|
||||
return parsed
|
||||
|
||||
|
||||
def load_labels(path: str | None, example: str | None) -> list:
|
||||
if example and path:
|
||||
raise BuildStoreError("pass either --labels or --example, not both")
|
||||
if example == "mediya":
|
||||
return [dict(item) for item in MEDIYA_PRODUCTS]
|
||||
if example:
|
||||
raise BuildStoreError(f"unknown example {example!r}; the only named example is mediya")
|
||||
if not path:
|
||||
raise BuildStoreError("pass --labels, or --example mediya for the demo sheet")
|
||||
payload = _load_json(path)
|
||||
products = payload.get("products") if isinstance(payload, dict) else None
|
||||
if not isinstance(products, list) or not products:
|
||||
raise BuildStoreError(f"{path}: expected a non-empty {{\"products\": [...]}} list")
|
||||
return products
|
||||
|
||||
|
||||
def _sku_name(value) -> str:
|
||||
text = str(value).strip()
|
||||
if not text or text in {".", ".."} or "/" in text or "\\" in text or "\x00" in text:
|
||||
raise BuildStoreError(f"refusing sku {value!r}: it is not a single file name")
|
||||
return text
|
||||
|
||||
|
||||
def _products(labels: list) -> list:
|
||||
seen = set()
|
||||
products = []
|
||||
for item in labels:
|
||||
if not isinstance(item, dict):
|
||||
raise BuildStoreError("each product must be an object with sku, name, price, boxes")
|
||||
sku = _sku_name(item.get("sku", ""))
|
||||
if sku in seen:
|
||||
raise BuildStoreError(f"duplicate sku {sku}")
|
||||
seen.add(sku)
|
||||
name = str(item.get("name", "")).strip()
|
||||
if not name or any(char in name for char in ",\n\r"):
|
||||
raise BuildStoreError(f"{sku}: name must be one CSV field")
|
||||
try:
|
||||
price = int(item["price"])
|
||||
indices = [int(index) for index in item["boxes"]]
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise BuildStoreError(f"{sku}: price and boxes must be integers") from exc
|
||||
if price < 0 or not indices:
|
||||
raise BuildStoreError(f"{sku}: price and boxes must be present")
|
||||
products.append({"sku": sku, "name": name, "price": price, "boxes": indices})
|
||||
return products
|
||||
|
||||
|
||||
def check_destination(output: str, image: str, boxes_path: str, labels_path: str | None, replace: bool) -> str:
|
||||
output = os.path.abspath(output)
|
||||
real_output = os.path.realpath(output)
|
||||
if real_output == os.path.realpath(os.sep) or real_output == os.path.realpath(os.getcwd()):
|
||||
raise BuildStoreError("refusing to use the filesystem root or the current directory as the store")
|
||||
parent = os.path.dirname(output)
|
||||
if not parent or not os.path.isdir(parent):
|
||||
raise BuildStoreError(f"{output}: parent directory does not exist")
|
||||
for source in (image, boxes_path, labels_path):
|
||||
if source and (_is_within(source, real_output) or _is_within(real_output, os.path.realpath(source))):
|
||||
raise BuildStoreError(f"refusing to write {output}: it aliases input {source}")
|
||||
if os.path.lexists(output):
|
||||
if not replace:
|
||||
raise BuildStoreError(f"{output}: already exists; pass --replace to move it aside")
|
||||
if os.path.islink(output) or not os.path.isdir(output):
|
||||
raise BuildStoreError(f"{output}: --replace only moves aside a real directory")
|
||||
previous = output + ".previous"
|
||||
if os.path.lexists(previous):
|
||||
raise BuildStoreError(f"{previous}: already exists; move it away before --replace")
|
||||
return output
|
||||
|
||||
|
||||
def build_store(image_path: str, boxes: list, products: list, output: str, replace: bool,
|
||||
boxes_path: str, labels_path: str | None) -> int:
|
||||
output = check_destination(output, image_path, boxes_path, labels_path, replace)
|
||||
import cv2
|
||||
|
||||
image = cv2.imread(image_path)
|
||||
if image is None or getattr(image, "size", 0) == 0:
|
||||
raise BuildStoreError(f"{image_path}: not a readable image")
|
||||
height, width = image.shape[:2]
|
||||
|
||||
parent = os.path.dirname(output)
|
||||
staging = tempfile.mkdtemp(prefix=".build-store-", dir=parent)
|
||||
try:
|
||||
rows = [[
|
||||
"SKU_ID", "product_name", "receipt_date", "expiration_date",
|
||||
"number_in", "number_out", "number_on", "image_link", "price",
|
||||
]]
|
||||
written = []
|
||||
for product in products:
|
||||
saved = 0
|
||||
for index in product["boxes"]:
|
||||
if index < 0 or index >= len(boxes):
|
||||
raise BuildStoreError(f"{product['sku']}: box {index} is outside the boxes file")
|
||||
x1, y1, x2, y2 = boxes[index]
|
||||
x1, y1 = max(0, x1), max(0, y1)
|
||||
x2, y2 = min(width, x2), min(height, y2)
|
||||
crop = image[y1:y2, x1:x2] if x2 > x1 and y2 > y1 else image[0:0, 0:0]
|
||||
if crop.size == 0:
|
||||
raise BuildStoreError(f"{product['sku']}: box {index} does not crop the image")
|
||||
suffix = "" if saved == 0 else f"_{saved}"
|
||||
target = os.path.join(staging, f"{product['sku']}{suffix}.jpeg")
|
||||
if not cv2.imwrite(target, crop):
|
||||
raise BuildStoreError(f"{product['sku']}: failed to write crop {index}")
|
||||
saved += 1
|
||||
written.append(product["sku"])
|
||||
number_in, number_out, number_on = EXAMPLE_COUNTS
|
||||
rows.append([
|
||||
product["sku"], product["name"], EXAMPLE_RECEIPT, EXAMPLE_EXPIRATION,
|
||||
number_in, number_out, number_on, "", product["price"],
|
||||
])
|
||||
with open(os.path.join(staging, "stock.csv"), "w", encoding="utf-8", newline="") as handle:
|
||||
csv.writer(handle).writerows(rows)
|
||||
if not os.path.lexists(output):
|
||||
os.rename(staging, output)
|
||||
staging = None
|
||||
else:
|
||||
previous = output + ".previous"
|
||||
os.rename(output, previous)
|
||||
try:
|
||||
os.rename(staging, output)
|
||||
except OSError:
|
||||
os.rename(previous, output)
|
||||
raise
|
||||
staging = None
|
||||
print(f"previous store kept at {previous}", file=sys.stderr)
|
||||
return len(written)
|
||||
finally:
|
||||
if staging and os.path.isdir(staging):
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--image", required=True, help="shelf photo whose pixels match the box coordinates")
|
||||
parser.add_argument("--boxes", required=True, help='JSON file {"boxes": [[x1, y1, x2, y2], ...]}')
|
||||
parser.add_argument("--output", required=True, help="new stock directory to create")
|
||||
parser.add_argument("--labels", help='JSON file {"products": [{"sku", "name", "price", "boxes"}]}')
|
||||
parser.add_argument("--example", choices=["mediya"], help="use the named demo contact-sheet labels")
|
||||
parser.add_argument("--replace", action="store_true", help="replace an existing output directory")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
products = _products(load_labels(args.labels, args.example))
|
||||
count = build_store(
|
||||
args.image, load_boxes(args.boxes), products, args.output, args.replace,
|
||||
args.boxes, args.labels,
|
||||
)
|
||||
except BuildStoreError as exc:
|
||||
print(f"build_store_from_shelf: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"{os.path.abspath(args.output)}: {count} products, csv written")
|
||||
print("phone recordings are not enrolled by this script; labels stay a manual contact sheet")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
|
|||
|
|
@ -38,6 +38,78 @@ DEFAULT_THRESHOLD = 0.55
|
|||
# The same line for a fine-tuned head, whose similarity scale is its own.
|
||||
DEFAULT_HEAD_THRESHOLD = 0.40
|
||||
|
||||
# DINOv2 ViT-S/14, LVD-142M. timm's default source is the Hugging Face copy of
|
||||
# these weights (Apache-2.0); pretrained=True fetches it even when YOLO_OFFLINE=1.
|
||||
# The pin is the sha256 of that model.safetensors (timm hub snapshot 4610ca14).
|
||||
# Not in Git (~84 MB). Place it at weights/dinov2_vits14_lvd142m.safetensors, or
|
||||
# point DORAYAKI_DINOV2_WEIGHTS at a copy. See README "DINOv2 weights".
|
||||
DINOV2_MODEL = "vit_small_patch14_dinov2.lvd142m"
|
||||
DINOV2_SHA256 = "04d27f3400d059fc0cfd7d17dd1909a75bf3ea8fb3eeb48b97cb99e57ee20081"
|
||||
DINOV2_FILENAME = "dinov2_vits14_lvd142m.safetensors"
|
||||
|
||||
|
||||
def dinov2_weights_path() -> str:
|
||||
override = os.environ.get("DORAYAKI_DINOV2_WEIGHTS")
|
||||
if override:
|
||||
return override
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(os.path.dirname(here), "weights", DINOV2_FILENAME)
|
||||
|
||||
|
||||
def _sha256(path: str) -> str:
|
||||
import hashlib
|
||||
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def load_dinov2(image_size: int) -> Any:
|
||||
"""Build the gallery backbone from the pinned local file. Never downloads.
|
||||
|
||||
A missing or altered file raises FileNotFoundError naming the path and the
|
||||
expected sha256. The head in finetune_identity.py is a Linear(384, 128) on
|
||||
this model's features; a different backbone will not load that head.
|
||||
"""
|
||||
import timm
|
||||
from safetensors.torch import load_file
|
||||
from timm.models.vision_transformer import checkpoint_filter_fn
|
||||
|
||||
path = dinov2_weights_path()
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(
|
||||
f"DINOv2 weights not found at {path}. "
|
||||
f"Copy model.safetensors (sha256 {DINOV2_SHA256}) from the local "
|
||||
f"Hugging Face cache of timm/{DINOV2_MODEL}, or set DORAYAKI_DINOV2_WEIGHTS. "
|
||||
f"The gallery does not download weights."
|
||||
)
|
||||
digest = _sha256(path)
|
||||
if digest != DINOV2_SHA256:
|
||||
raise FileNotFoundError(
|
||||
f"DINOv2 weights at {path} are sha256 {digest}, expected {DINOV2_SHA256}."
|
||||
)
|
||||
model = timm.create_model(
|
||||
DINOV2_MODEL,
|
||||
pretrained=False,
|
||||
num_classes=0,
|
||||
img_size=int(image_size),
|
||||
dynamic_img_size=True,
|
||||
)
|
||||
# The file is the 518px checkpoint. timm's own filter resizes pos_embed when
|
||||
# the caller asks for another size; pretrained=True would do this and also
|
||||
# download, so it stays off.
|
||||
state = checkpoint_filter_fn(load_file(path), model)
|
||||
missing, unexpected = model.load_state_dict(state, strict=False)
|
||||
unexpected = [key for key in unexpected if not key.startswith("head.")]
|
||||
if missing or unexpected:
|
||||
raise RuntimeError(
|
||||
f"DINOv2 weights at {path} do not match {DINOV2_MODEL} "
|
||||
f"(missing={missing}, unexpected={unexpected})."
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
@dataclass
|
||||
class GalleryEntry:
|
||||
|
|
@ -138,13 +210,7 @@ class ProductGallery:
|
|||
device = self.device
|
||||
if device is None:
|
||||
device = "mps" if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() else "cpu"
|
||||
model = timm.create_model(
|
||||
"vit_small_patch14_dinov2.lvd142m",
|
||||
pretrained=True,
|
||||
num_classes=0,
|
||||
img_size=self.image_size,
|
||||
dynamic_img_size=True,
|
||||
)
|
||||
model = load_dinov2(self.image_size)
|
||||
model = model.eval().to(device)
|
||||
self.device = device
|
||||
self._model = model
|
||||
|
|
|
|||
|
|
@ -191,10 +191,6 @@ class LiveSession:
|
|||
self._shift_map = None
|
||||
self._compliance_tracker = None
|
||||
self._last_compliance: Dict[str, Any] = {"violations": 0, "by_rule": {}, "rows": []}
|
||||
# Settled tag + reconciliation from the last result, keyed by track.
|
||||
# Inspect copies these; it does not re-run OCR.
|
||||
self._settled_tags: Dict[int, Dict[str, Any]] = {}
|
||||
self._settled_tag_seq = 0
|
||||
self._label_memory: "collections.deque[Dict[str, Any]]" = collections.deque(
|
||||
maxlen=LABEL_MEMORY_MAX
|
||||
)
|
||||
|
|
@ -297,8 +293,6 @@ class LiveSession:
|
|||
self._latest_frame_id = None
|
||||
self._frame_origin = [0.0, 0.0]
|
||||
self._shift_map = None
|
||||
self._settled_tags = {}
|
||||
self._settled_tag_seq = 0
|
||||
|
||||
# ------------------------------------------------------------------ frame path
|
||||
def process_frame(self, frame_id: int, payload: bytes) -> Dict[str, Any]:
|
||||
|
|
@ -445,17 +439,6 @@ class LiveSession:
|
|||
if tag_summary is not None:
|
||||
summary["price_tags"] = tag_summary["tags"]
|
||||
summary["compliance"] = tag_summary["compliance"]
|
||||
settled: Dict[int, Dict[str, Any]] = {}
|
||||
for payload in track_payload:
|
||||
snapshot: Dict[str, Any] = {}
|
||||
if "price_tag" in payload:
|
||||
snapshot["price_tag"] = payload["price_tag"]
|
||||
if "reconciliation" in payload:
|
||||
snapshot["reconciliation"] = payload["reconciliation"]
|
||||
if snapshot:
|
||||
settled[int(payload["track_id"])] = snapshot
|
||||
self._settled_tags = settled
|
||||
self._settled_tag_seq = seq
|
||||
|
||||
self._submit_classification(submissions, method, generation)
|
||||
self._run_tag_passes(image, tag_bands)
|
||||
|
|
@ -929,16 +912,7 @@ class LiveSession:
|
|||
if track is None or image is None:
|
||||
raise KeyError("TRACK_NOT_IN_VIEW")
|
||||
box = list(track.raw_box)
|
||||
# Snapshot is the frame current at tap time. engine.inspect runs
|
||||
# outside the lock, so a newer frame may land first; the reply still
|
||||
# carries the tag from the frame the tap matched, not that later one.
|
||||
settled = None
|
||||
if self._settled_tag_seq == self._latest_seq:
|
||||
settled = self._settled_tags.get(track_id)
|
||||
result = self.engine.inspect(image, box, method=chosen_method)
|
||||
if settled:
|
||||
result.update(settled)
|
||||
return result
|
||||
return self.engine.inspect(image, box, method=chosen_method)
|
||||
|
||||
# ------------------------------------------------------------------ catalog cache
|
||||
def refresh_catalog(self) -> None:
|
||||
|
|
|
|||
|
|
@ -84,11 +84,19 @@ _TAX_RE = re.compile(r"税(込|抜|不|別)")
|
|||
|
||||
@dataclass
|
||||
class RailBand:
|
||||
"""One horizontal strip of shelf rail, derived from a product row."""
|
||||
"""One horizontal strip of shelf rail, derived from a product row.
|
||||
|
||||
`box` is this frame's pixels (the crop `run_pass` reads). `origin` is the
|
||||
shelf position of the frame origin at the moment `plan` chose the band, and
|
||||
`key` is the rail that origin names. Both travel with the band: a later
|
||||
frame's origin must not relabel a pass that is still queued.
|
||||
"""
|
||||
|
||||
box: List[int]
|
||||
row_index: int
|
||||
product_ids: List[int] = field(default_factory=list)
|
||||
origin: Optional[Tuple[float, float]] = None
|
||||
key: Optional[Tuple[str, int]] = None
|
||||
|
||||
|
||||
# The field model lives with the card reader; the pipeline consumes it unchanged.
|
||||
|
|
@ -439,6 +447,9 @@ class TagService:
|
|||
self.passes_failed = 0
|
||||
self.last_pass_error: Optional[str] = None
|
||||
self.passes_skipped = 0
|
||||
# Latest frame only. A queued pass must not read these; its origin is on the band.
|
||||
self._view_origin: Optional[Tuple[float, float]] = None
|
||||
self._view_shape: Optional[Tuple[int, int]] = None
|
||||
|
||||
# ------------------------------------------------------------------ frame loop
|
||||
def plan(
|
||||
|
|
@ -462,7 +473,8 @@ class TagService:
|
|||
width = float(image_shape[1])
|
||||
bands = [band for band in bands if (band.box[2] - band.box[0]) >= self.settings.min_band_width_ratio * width]
|
||||
with self._lock:
|
||||
self._shelf_offset = shelf_offset
|
||||
self._view_origin = _origin_of(shelf_offset)
|
||||
self._view_shape = (int(image_shape[0]), int(image_shape[1]))
|
||||
self._frame_seq += 1
|
||||
seq = self._frame_seq
|
||||
chosen: List[RailBand] = []
|
||||
|
|
@ -481,6 +493,8 @@ class TagService:
|
|||
break
|
||||
for band in chosen:
|
||||
key = band_key(band, shelf_offset)
|
||||
band.origin = _origin_of(shelf_offset)
|
||||
band.key = key
|
||||
self._last_pass_seq[key] = seq
|
||||
self._pending_bands.add(key)
|
||||
return chosen
|
||||
|
|
@ -492,7 +506,7 @@ class TagService:
|
|||
independent reads of the same tag are what makes the vote meaningful
|
||||
(identical pixels would produce identical output).
|
||||
"""
|
||||
key = band_key(band, getattr(self, "_shelf_offset", None))
|
||||
key = _planned_key(band)
|
||||
try:
|
||||
x1, y1, x2, y2 = band.box
|
||||
crop = image[y1:y2, x1:x2]
|
||||
|
|
@ -632,13 +646,19 @@ class TagService:
|
|||
return all(tag.settled for tag in row_tags)
|
||||
|
||||
def _absorb(self, candidate: Dict[str, Any], band: RailBand, pass_id: int) -> TagObservation:
|
||||
"""Fold one clustered tag into the cache; must be called with the lock held."""
|
||||
"""Fold one clustered tag into the cache; must be called with the lock held.
|
||||
|
||||
The candidate box is this frame's pixels. The record keeps shelf pixels,
|
||||
using the origin `plan` stamped on the band, so a later pan cannot make
|
||||
this read land on a different rail.
|
||||
"""
|
||||
box = candidate["box"]
|
||||
lines = candidate["lines"]
|
||||
fields = candidate["fields"]
|
||||
shelf_key = band_key(band, getattr(self, "_shelf_offset", None))
|
||||
shelf_key = _planned_key(band)
|
||||
shelf_box = _to_shelf(box, band.origin)
|
||||
observation = TagObservation(
|
||||
box=list(box),
|
||||
box=list(shelf_box),
|
||||
lines=list(lines),
|
||||
fields=fields,
|
||||
band_index=band.row_index,
|
||||
|
|
@ -646,11 +666,11 @@ class TagService:
|
|||
observed_at=time.time(),
|
||||
)
|
||||
|
||||
record = self._match_record(box, shelf_key)
|
||||
record = self._match_record(shelf_box, shelf_key)
|
||||
if record is None:
|
||||
record = TagRecord(
|
||||
tag_id=f"tag_{uuid.uuid4().hex[:8]}",
|
||||
box=list(box),
|
||||
box=list(shelf_box),
|
||||
band_index=band.row_index,
|
||||
shelf_key=shelf_key,
|
||||
first_seen=observation.observed_at,
|
||||
|
|
@ -658,7 +678,7 @@ class TagService:
|
|||
if len(self._tags) >= self.settings.max_tags:
|
||||
return observation
|
||||
self._tags[record.tag_id] = record
|
||||
record.box = list(box)
|
||||
record.box = list(shelf_box)
|
||||
record.last_seen = observation.observed_at
|
||||
record.observations.append(observation)
|
||||
record.passes += 1
|
||||
|
|
@ -758,52 +778,47 @@ class TagService:
|
|||
|
||||
# ------------------------------------------------------------------ attach
|
||||
def attach(
|
||||
self, tracks: Sequence[Dict[str, Any]]
|
||||
self,
|
||||
tracks: Sequence[Dict[str, Any]],
|
||||
shelf_offset: Optional[Sequence[float]] = None,
|
||||
image_shape: Optional[Sequence[int]] = None,
|
||||
) -> Tuple[Dict[int, Dict[str, Any]], List[Dict[str, Any]], Dict[str, int]]:
|
||||
"""Give each track its tag for this frame.
|
||||
|
||||
Records live in shelf coordinates. They are projected into this frame
|
||||
before association, and a tag that does not overlap the frame is left
|
||||
out, so an old tag cannot bind a new facing that reused its screen box.
|
||||
Returned boxes stay in screen pixels. `shelf_offset` defaults to the
|
||||
origin of the latest `plan` (a still, or a caller that has not panned).
|
||||
|
||||
Returns (tag_by_track_id, all_tags, state_counts). A track whose tag was
|
||||
never read gets state 'unreadable' when a tag was found near it, and
|
||||
'absent' when nothing tag-shaped was seen there.
|
||||
"""
|
||||
with self._lock:
|
||||
tags = [record.as_dict() for record in self._tags.values()]
|
||||
records = list(self._tags.values())
|
||||
origin = _origin_of(shelf_offset if shelf_offset is not None else self._view_origin)
|
||||
shape = image_shape if image_shape is not None else self._view_shape
|
||||
visible = _visible_records(list(self._tags.values()), origin, shape)
|
||||
|
||||
by_track: Dict[int, Dict[str, Any]] = {}
|
||||
used: set = set()
|
||||
records = [record for record, _screen in visible]
|
||||
screen_of = {record.tag_id: screen for record, screen in visible}
|
||||
tags = [_screen_dict(record, screen_of[record.tag_id]) for record in records]
|
||||
for track in tracks:
|
||||
box = track.get("box") or track.get("raw_box")
|
||||
if not box:
|
||||
continue
|
||||
record = _tag_below(box, records, used)
|
||||
record = _tag_below(box, records, used, screen_of)
|
||||
if record is not None:
|
||||
used.add(record.tag_id)
|
||||
payload = record.as_dict()
|
||||
payload = _screen_dict(record, screen_of[record.tag_id])
|
||||
payload["associated"] = True
|
||||
by_track[int(track["track_id"])] = payload
|
||||
elif self._band_had_readable(record=None, box=box, records=records):
|
||||
by_track[int(track["track_id"])] = {
|
||||
"id": None,
|
||||
"box": None,
|
||||
"state": TAG_STATE_UNREADABLE,
|
||||
"price": None,
|
||||
"name": None,
|
||||
"confidence": 0.0,
|
||||
"observations": 0,
|
||||
"settled": False,
|
||||
}
|
||||
elif self._band_had_readable(record=None, box=box, records=records, screen_of=screen_of):
|
||||
by_track[int(track["track_id"])] = _empty_tag(TAG_STATE_UNREADABLE)
|
||||
else:
|
||||
by_track[int(track["track_id"])] = {
|
||||
"id": None,
|
||||
"box": None,
|
||||
"state": TAG_STATE_ABSENT,
|
||||
"price": None,
|
||||
"name": None,
|
||||
"confidence": 0.0,
|
||||
"observations": 0,
|
||||
"settled": False,
|
||||
}
|
||||
by_track[int(track["track_id"])] = _empty_tag(TAG_STATE_ABSENT)
|
||||
|
||||
counts = {
|
||||
TAG_STATE_READABLE: 0,
|
||||
|
|
@ -815,13 +830,20 @@ class TagService:
|
|||
counts[payload["state"]] = counts.get(payload["state"], 0) + 1
|
||||
return by_track, tags, counts
|
||||
|
||||
def _band_had_readable(self, record, box: Sequence[int], records: Sequence[TagRecord]) -> bool:
|
||||
def _band_had_readable(
|
||||
self,
|
||||
record,
|
||||
box: Sequence[int],
|
||||
records: Sequence[TagRecord],
|
||||
screen_of: Optional[Dict[str, List[int]]] = None,
|
||||
) -> bool:
|
||||
"""Was there any tag-shaped evidence near this facing (readable or not)?"""
|
||||
x1, y1, x2, y2 = box
|
||||
width = max(1, x2 - x1)
|
||||
for candidate in records:
|
||||
cx = (candidate.box[0] + candidate.box[2]) / 2.0
|
||||
cy = (candidate.box[1] + candidate.box[3]) / 2.0
|
||||
shown = (screen_of or {}).get(candidate.tag_id, candidate.box)
|
||||
cx = (shown[0] + shown[2]) / 2.0
|
||||
cy = (shown[1] + shown[3]) / 2.0
|
||||
if x1 - width * 0.3 <= cx <= x2 + width * 0.3 and y1 <= cy <= y2 + (y2 - y1) * 0.5:
|
||||
return True
|
||||
return False
|
||||
|
|
@ -858,7 +880,8 @@ class TagService:
|
|||
self._pending_bands.clear()
|
||||
self._pass_id = 0
|
||||
self._frame_seq = 0
|
||||
self._shelf_offset = None
|
||||
self._view_origin = None
|
||||
self._view_shape = None
|
||||
|
||||
|
||||
def _iou(a: Sequence[int], b: Sequence[int]) -> float:
|
||||
|
|
@ -876,8 +899,17 @@ def _iou(a: Sequence[int], b: Sequence[int]) -> float:
|
|||
return inter / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def _tag_below(box: Sequence[int], records: Sequence[TagRecord], used: set) -> Optional[TagRecord]:
|
||||
"""The tag that belongs to this facing: below it, horizontally overlapping."""
|
||||
def _tag_below(
|
||||
box: Sequence[int],
|
||||
records: Sequence[TagRecord],
|
||||
used: set,
|
||||
screen_of: Optional[Dict[str, List[int]]] = None,
|
||||
) -> Optional[TagRecord]:
|
||||
"""The tag that belongs to this facing: below it, horizontally overlapping.
|
||||
|
||||
`screen_of` is the record projected into the current frame. Without it the
|
||||
stored shelf box is used, which is the screen box when no origin was known.
|
||||
"""
|
||||
x1, y1, x2, y2 = box
|
||||
width = max(1, x2 - x1)
|
||||
height = max(1, y2 - y1)
|
||||
|
|
@ -886,7 +918,7 @@ def _tag_below(box: Sequence[int], records: Sequence[TagRecord], used: set) -> O
|
|||
for record in records:
|
||||
if record.tag_id in used:
|
||||
continue
|
||||
rx1, ry1, rx2, ry2 = record.box
|
||||
rx1, ry1, rx2, ry2 = (screen_of or {}).get(record.tag_id, record.box)
|
||||
center_x = (rx1 + rx2) / 2.0
|
||||
overlap = max(0, min(x2, rx2) - max(x1, rx1))
|
||||
overlap_ratio = overlap / max(1, rx2 - rx1)
|
||||
|
|
@ -907,6 +939,73 @@ def _tag_below(box: Sequence[int], records: Sequence[TagRecord], used: set) -> O
|
|||
return best
|
||||
|
||||
|
||||
def _origin_of(shelf_offset: Optional[Sequence[float]]) -> Optional[Tuple[float, float]]:
|
||||
if not shelf_offset or len(shelf_offset) < 2:
|
||||
return None
|
||||
return (float(shelf_offset[0]), float(shelf_offset[1]))
|
||||
|
||||
|
||||
def _planned_key(band: RailBand) -> Tuple[str, int]:
|
||||
"""The rail `plan` chose. A band built without `plan` falls back to its screen row."""
|
||||
if band.key is not None:
|
||||
return band.key
|
||||
return band_key(band, band.origin)
|
||||
|
||||
|
||||
def _shift_box(box: Sequence[int], dx: float, dy: float) -> List[int]:
|
||||
return [int(round(box[0] + dx)), int(round(box[1] + dy)), int(round(box[2] + dx)), int(round(box[3] + dy))]
|
||||
|
||||
|
||||
def _to_shelf(box: Sequence[int], origin: Optional[Tuple[float, float]]) -> List[int]:
|
||||
if origin is None:
|
||||
return [int(v) for v in box]
|
||||
return _shift_box(box, origin[0], origin[1])
|
||||
|
||||
|
||||
def _to_screen(box: Sequence[int], origin: Optional[Tuple[float, float]]) -> List[int]:
|
||||
if origin is None:
|
||||
return [int(v) for v in box]
|
||||
return _shift_box(box, -origin[0], -origin[1])
|
||||
|
||||
|
||||
def _overlaps_frame(box: Sequence[int], image_shape: Sequence[int]) -> bool:
|
||||
height, width = int(image_shape[0]), int(image_shape[1])
|
||||
return box[2] > 0 and box[0] < width and box[3] > 0 and box[1] < height
|
||||
|
||||
|
||||
def _visible_records(
|
||||
records: Sequence[TagRecord],
|
||||
origin: Optional[Tuple[float, float]],
|
||||
image_shape: Optional[Sequence[int]],
|
||||
) -> List[Tuple[TagRecord, List[int]]]:
|
||||
shown: List[Tuple[TagRecord, List[int]]] = []
|
||||
for record in records:
|
||||
screen = _to_screen(record.box, origin)
|
||||
if image_shape is not None and not _overlaps_frame(screen, image_shape):
|
||||
continue
|
||||
shown.append((record, screen))
|
||||
return shown
|
||||
|
||||
|
||||
def _screen_dict(record: TagRecord, screen_box: Sequence[int]) -> Dict[str, Any]:
|
||||
payload = record.as_dict()
|
||||
payload["box"] = list(screen_box)
|
||||
return payload
|
||||
|
||||
|
||||
def _empty_tag(state: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": None,
|
||||
"box": None,
|
||||
"state": state,
|
||||
"price": None,
|
||||
"name": None,
|
||||
"confidence": 0.0,
|
||||
"observations": 0,
|
||||
"settled": False,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__": # smoke: python code/tags.py <image>
|
||||
import argparse
|
||||
|
||||
|
|
|
|||
|
|
@ -196,6 +196,31 @@ class RestContractTests(unittest.TestCase):
|
|||
self.assertEqual(sent[0]["status"], 413)
|
||||
self.assertIn(b"PAYLOAD_TOO_LARGE", sent[1]["body"])
|
||||
|
||||
def test_overflow_after_response_start_sends_no_second_start(self):
|
||||
"""A body that overflows after headers began must not start again."""
|
||||
import anyio
|
||||
|
||||
from transport import BodyLimit
|
||||
|
||||
async def app(scope, receive, send):
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await receive()
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
sent = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"x" * 40, "more_body": False}
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
anyio.run(BodyLimit(app, 8), {"type": "http"}, receive, send)
|
||||
starts = [message for message in sent if message.get("type") == "http.response.start"]
|
||||
self.assertEqual(len(starts), 1)
|
||||
self.assertEqual(starts[0]["status"], 200)
|
||||
self.assertFalse(any(message.get("status") == 413 for message in sent))
|
||||
|
||||
def test_classify_and_inspect_roundtrip(self):
|
||||
recognized = self.client.post(
|
||||
"/v1/recognize?classify=false", content=jpeg_bytes(),
|
||||
|
|
@ -360,6 +385,16 @@ class WebSocketProtocolTests(unittest.TestCase):
|
|||
finally:
|
||||
engine.shutdown()
|
||||
|
||||
def test_lifespan_shutdown_skips_a_reserved_slot(self):
|
||||
"""A reservation is None until setup finishes. Shutdown must not call it."""
|
||||
client, engine = make_app()
|
||||
client.app.state.sessions["reserved"] = None
|
||||
try:
|
||||
with client:
|
||||
pass
|
||||
finally:
|
||||
engine.shutdown()
|
||||
|
||||
|
||||
class LegacyRouteTests(unittest.TestCase):
|
||||
def test_legacy_sessions_still_respond(self):
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ if os.path.exists(os.path.join(project_root, "stock")):
|
|||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
|
||||
from gallery import GalleryEntry, ProductGallery # noqa: E402
|
||||
from gallery import ( # noqa: E402
|
||||
DINOV2_SHA256,
|
||||
GalleryEntry,
|
||||
ProductGallery,
|
||||
dinov2_weights_path,
|
||||
load_dinov2,
|
||||
)
|
||||
|
||||
|
||||
def a_gallery(views, threshold=0.55):
|
||||
|
|
@ -108,5 +114,35 @@ class EnrollmentTests(unittest.TestCase):
|
|||
self.assertEqual(len(gallery._views_for("sku_ab")), 1)
|
||||
|
||||
|
||||
|
||||
class OfflineWeightsTests(unittest.TestCase):
|
||||
def test_a_missing_weights_file_names_the_path_and_does_not_fetch(self):
|
||||
previous = os.environ.get("DORAYAKI_DINOV2_WEIGHTS")
|
||||
os.environ["DORAYAKI_DINOV2_WEIGHTS"] = os.path.join(
|
||||
"does-not-exist", "dinov2_vits14_lvd142m.safetensors"
|
||||
)
|
||||
try:
|
||||
with self.assertRaises(FileNotFoundError) as raised:
|
||||
load_dinov2(224)
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("DORAYAKI_DINOV2_WEIGHTS", None)
|
||||
else:
|
||||
os.environ["DORAYAKI_DINOV2_WEIGHTS"] = previous
|
||||
message = str(raised.exception)
|
||||
self.assertIn("does-not-exist", message)
|
||||
self.assertIn(DINOV2_SHA256, message)
|
||||
self.assertIn("does not download", message)
|
||||
|
||||
def test_the_default_path_stays_inside_the_repo(self):
|
||||
previous = os.environ.pop("DORAYAKI_DINOV2_WEIGHTS", None)
|
||||
try:
|
||||
self.assertTrue(dinov2_weights_path().endswith(
|
||||
os.path.join("weights", "dinov2_vits14_lvd142m.safetensors")
|
||||
))
|
||||
finally:
|
||||
if previous is not None:
|
||||
os.environ["DORAYAKI_DINOV2_WEIGHTS"] = previous
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -385,44 +385,6 @@ class ClassificationBudgetTests(unittest.TestCase):
|
|||
with self.assertRaises(KeyError):
|
||||
session.inspect(999)
|
||||
|
||||
def test_inspect_includes_settled_tag_from_the_latest_frame(self):
|
||||
session, engine, _, _ = make_session()
|
||||
tag = {"state": "readable", "price": 150, "box": [100, 270, 200, 320]}
|
||||
|
||||
class Tagged:
|
||||
def plan(self, boxes, image_shape, shelf_offset=None):
|
||||
return []
|
||||
|
||||
def attach(self, tracks):
|
||||
by_track = {int(track["track_id"]): dict(tag) for track in tracks}
|
||||
return by_track, list(by_track.values()), {"readable": len(by_track)}
|
||||
|
||||
def stats(self):
|
||||
return {}
|
||||
|
||||
session._tags = Tagged()
|
||||
result = process(session, 1)
|
||||
track = result["tracks"][0]
|
||||
track_id = track["track_id"]
|
||||
self.assertEqual(track["price_tag"]["price"], 150)
|
||||
self.assertIn("reconciliation", track)
|
||||
|
||||
inspected = session.inspect(track_id)
|
||||
self.assertEqual(inspected["price_tag"], track["price_tag"])
|
||||
self.assertEqual(inspected["reconciliation"], track["reconciliation"])
|
||||
self.assertIn("identity", inspected)
|
||||
|
||||
session._tags = None
|
||||
bare = process(session, 2)
|
||||
self.assertNotIn("price_tag", bare["tracks"][0])
|
||||
inspected_bare = session.inspect(track_id)
|
||||
self.assertNotIn("price_tag", inspected_bare)
|
||||
self.assertNotIn("reconciliation", inspected_bare)
|
||||
|
||||
session.reset()
|
||||
with self.assertRaises(KeyError):
|
||||
session.inspect(track_id)
|
||||
engine.shutdown()
|
||||
|
||||
def test_close_stops_the_loop(self):
|
||||
session, engine, _, _ = make_session()
|
||||
|
|
|
|||
|
|
@ -335,6 +335,56 @@ class TagServiceTests(unittest.TestCase):
|
|||
self.assertGreaterEqual(service.stats()["passes_run"], 1)
|
||||
self.assertIsNone(service.stats()["last_pass_error"])
|
||||
|
||||
def test_a_queued_pass_keeps_the_shelf_it_was_planned_on(self):
|
||||
"""OCR that finishes after a pan must clear its own rail, not the new one."""
|
||||
service, _ = self.make(pass_interval_frames=0, settle_observations=1, settle_passes=1)
|
||||
boxes = [box(100, 100, 200, 300, 0), box(220, 100, 320, 300, 1), box(340, 100, 440, 300, 2)]
|
||||
array = image()
|
||||
planned = service.plan(boxes, array.shape, shelf_offset=(0.0, 0.0))
|
||||
self.assertEqual(service.stats()["pending_bands"], 1)
|
||||
moved = service.plan(boxes, array.shape, shelf_offset=(0.0, 400.0))
|
||||
self.assertTrue(moved, "the new shelf is a different rail and must be queued too")
|
||||
self.assertEqual(service.stats()["pending_bands"], 2)
|
||||
service.run_pass(array, planned[0])
|
||||
self.assertEqual(service.stats()["pending_bands"], 1, "the finished pass clears only its own key")
|
||||
self.assertEqual(service._tags[next(iter(service._tags))].shelf_key[0], "shelf")
|
||||
self.assertNotEqual(
|
||||
service._tags[next(iter(service._tags))].shelf_key,
|
||||
moved[0].key,
|
||||
"the late read must not be filed under the shelf now in frame",
|
||||
)
|
||||
service.run_pass(array, moved[0])
|
||||
self.assertEqual(service.stats()["pending_bands"], 0)
|
||||
self.assertEqual(len({tag.shelf_key for tag in service._tags.values()}), 2)
|
||||
|
||||
def test_an_offscreen_tag_does_not_bind_the_new_facing(self):
|
||||
"""A pan that reuses screen coordinates must not hand the old tag to the new facing."""
|
||||
service, _ = self.make(pass_interval_frames=0, settle_observations=1, settle_passes=1)
|
||||
boxes = [box(100, 100, 200, 300, 0), box(220, 100, 320, 300, 1), box(340, 100, 440, 300, 2)]
|
||||
array = image()
|
||||
first = service.plan(boxes, array.shape, shelf_offset=(0.0, 0.0))
|
||||
service.run_pass(array, first[0])
|
||||
by_track, tags, _ = service.attach(
|
||||
[{"track_id": 0, "box": [100, 100, 200, 300]}],
|
||||
shelf_offset=(0.0, 0.0),
|
||||
image_shape=array.shape,
|
||||
)
|
||||
self.assertEqual(by_track[0]["state"], TAG_STATE_READABLE)
|
||||
self.assertEqual(by_track[0]["price"], 269)
|
||||
self.assertGreaterEqual(tags[0]["box"][1], 0)
|
||||
self.assertLess(tags[0]["box"][1], array.shape[0])
|
||||
|
||||
again = service.plan(boxes, array.shape, shelf_offset=(0.0, 400.0))
|
||||
self.assertTrue(again)
|
||||
by_track, tags, _ = service.attach(
|
||||
[{"track_id": 9, "box": [100, 100, 200, 300]}],
|
||||
shelf_offset=(0.0, 400.0),
|
||||
image_shape=array.shape,
|
||||
)
|
||||
self.assertEqual(tags, [], "the old tag is above this frame")
|
||||
self.assertEqual(by_track[9]["state"], TAG_STATE_ABSENT)
|
||||
self.assertIsNone(by_track[9]["id"])
|
||||
|
||||
|
||||
class StorePolicyTests(unittest.TestCase):
|
||||
def test_a_missing_file_uses_the_defaults(self):
|
||||
|
|
|
|||
|
|
@ -104,7 +104,10 @@ class BodyLimit:
|
|||
|
||||
A handler that checks Content-Length, or reads request.body(), runs after
|
||||
the server has already buffered the stream. This counts bytes at receive
|
||||
and returns 413 without handing the rest to the app. WebSocket frames are
|
||||
and returns 413 without handing the rest to the app. Content-Length is
|
||||
not trusted: the stream is what counts, and reading stops at the cap.
|
||||
If the app already started the response, the connection is aborted
|
||||
instead of sending a second http.response.start. WebSocket frames are
|
||||
not HTTP bodies and are left alone.
|
||||
"""
|
||||
|
||||
|
|
@ -116,10 +119,13 @@ class BodyLimit:
|
|||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
class TooLarge(Exception):
|
||||
pass
|
||||
|
||||
seen = 0
|
||||
started = False
|
||||
|
||||
async def limited() -> Dict[str, Any]:
|
||||
nonlocal seen
|
||||
message = await receive()
|
||||
|
|
@ -128,12 +134,24 @@ class BodyLimit:
|
|||
body = message.get("body") or b""
|
||||
seen += len(body)
|
||||
if seen > self.max_bytes:
|
||||
# The app must not see this chunk or any later one.
|
||||
raise TooLarge
|
||||
return message
|
||||
|
||||
async def tracked(message: Dict[str, Any]) -> None:
|
||||
nonlocal started
|
||||
if message.get("type") == "http.response.start":
|
||||
started = True
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, limited, send)
|
||||
await self.app(scope, limited, tracked)
|
||||
except TooLarge:
|
||||
if started:
|
||||
# The status line is already on the wire. Returning leaves the
|
||||
# response unfinished so the server closes the connection
|
||||
# instead of accepting a second http.response.start.
|
||||
return
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue