Compare commits

..

6 commits

12 changed files with 625 additions and 176 deletions

View file

@ -19,6 +19,7 @@ ENV/
# Temporary weights # Temporary weights
runs/detect/train/weights/last.pt runs/detect/train/weights/last.pt
weights/dinov2_vits14_lvd142m.safetensors
*.log *.log
stock-*/identity_head.pt stock-*/identity_head.pt
.demo-token .demo-token

View file

@ -25,6 +25,7 @@ Cần có đầy đủ:
- `code/` - mã nguồn server, client web, client mẫu. - `code/` - mã nguồn server, client web, client mẫu.
- `runs/` - trọng số YOLO đã train (`runs/detect/train/weights/best.pt`). - `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`. - `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. - `add/` và `image.jpg` - ảnh kệ mẫu để test/demo.
- `requirements.txt`, `requirements-dev.txt`. - `requirements.txt`, `requirements-dev.txt`.
@ -49,6 +50,20 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements-dev.txt 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.) (`requirements-dev.txt` = `requirements.txt` + `httpx` cho test.)
### Bước 4: Khởi chạy recognition server ### Bước 4: Khởi chạy recognition server

View file

@ -270,6 +270,7 @@ def create_app(
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
yield yield
for session in list(sessions.values()): for session in list(sessions.values()):
if session is not None:
session.close() session.close()
engine.shutdown() engine.shutdown()
@ -1111,6 +1112,11 @@ def main() -> None:
LOGGER.info("Loading model and catalog (device=%s, imgsz=%s)...", LOGGER.info("Loading model and catalog (device=%s, imgsz=%s)...",
engine_settings.device or "auto", engine_settings.imgsz) engine_settings.device or "auto", engine_settings.imgsz)
engine = build_default_engine(engine_settings) 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() warm_ms = engine.warm_up()
LOGGER.info( LOGGER.info(
"Ready: %d SKUs, catalog %s, warmup %.0f ms, classify workers %d", "Ready: %d SKUs, catalog %s, warmup %.0f ms, classify workers %d",

View file

@ -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, This is a one-shot offline helper. SKU names and prices are supplied by a human
name the products, and keep their crops as the gallery that recognition matches who read a contact sheet; this script does not enroll a phone recording and
against. The two demo shelves were built with it; the product lists live in this file does not talk to the recognition server.
because reading a contact sheet is a human step.
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 from __future__ import annotations
sys.path.insert(0, "code")
import argparse
import csv
import json
import os
import shutil
import sys
import tempfile
# 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]},
]
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 import cv2
ROOT = os.path.expanduser("~/dorayaki-data/shelf-images") image = cv2.imread(image_path)
manifest = json.load(open(os.path.join(ROOT, "manifest.json"))) if image is None or getattr(image, "size", 0) == 0:
entry = next(e for e in manifest["images"] if "Azabudai-Hills" in e["file"]) raise BuildStoreError(f"{image_path}: not a readable image")
image = cv2.imread(os.path.join(ROOT, entry["file"]))
height, width = image.shape[:2] 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"]
# read off the numbered contact sheet; prices come from the shelf's own tags parent = os.path.dirname(output)
PRODUCTS = [ staging = tempfile.mkdtemp(prefix=".build-store-", dir=parent)
("sku_m01", "小岩井 低脂肪牛乳", [9, 23, 31]), try:
("sku_m02", "荻山酪農 カフェ・オ・レ", [10]), rows = [[
("sku_m03", "丹那 3.6牛乳", [11]), "SKU_ID", "product_name", "receipt_date", "expiration_date",
("sku_m04", "木次 ミルクコーヒー", [12]), "number_in", "number_out", "number_on", "image_link", "price",
("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]),
]
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 = [] written = []
for sku, name, indices in PRODUCTS: for product in products:
saved = 0 saved = 0
for position, index in enumerate(indices): for index in product["boxes"]:
box = boxes[index] if index < 0 or index >= len(boxes):
x1, y1, x2, y2 = [int(v) for v in box] raise BuildStoreError(f"{product['sku']}: box {index} is outside the boxes file")
crop = image[max(0, y1):y2, max(0, x1):x2] 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: if crop.size == 0:
continue raise BuildStoreError(f"{product['sku']}: box {index} does not crop the image")
suffix = "" if saved == 0 else f"_{saved}" suffix = "" if saved == 0 else f"_{saved}"
cv2.imwrite(os.path.join(store, f"{sku}{suffix}.jpeg"), crop) 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 saved += 1
if saved == 0: written.append(product["sku"])
raise SystemExit(f"{sku}: no readable crop; refusing a catalog row with no view") number_in, number_out, number_on = EXAMPLE_COUNTS
written.append(sku) rows.append([
rows.append(f"{sku},{name},2026-09-25,2026-10-20,24,8,16,'',{PRICES[sku]}") product["sku"], product["name"], EXAMPLE_RECEIPT, EXAMPLE_EXPIRATION,
open(os.path.join(store, "stock.csv"), "w").write("\n".join(rows) + "\n") number_in, number_out, number_on, "", product["price"],
print(f"{store}: {len(written)} products, csv written") ])
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())

View file

@ -38,6 +38,78 @@ DEFAULT_THRESHOLD = 0.55
# The same line for a fine-tuned head, whose similarity scale is its own. # The same line for a fine-tuned head, whose similarity scale is its own.
DEFAULT_HEAD_THRESHOLD = 0.40 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 @dataclass
class GalleryEntry: class GalleryEntry:
@ -138,13 +210,7 @@ class ProductGallery:
device = self.device device = self.device
if device is None: if device is None:
device = "mps" if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() else "cpu" device = "mps" if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() else "cpu"
model = timm.create_model( model = load_dinov2(self.image_size)
"vit_small_patch14_dinov2.lvd142m",
pretrained=True,
num_classes=0,
img_size=self.image_size,
dynamic_img_size=True,
)
model = model.eval().to(device) model = model.eval().to(device)
self.device = device self.device = device
self._model = model self._model = model

View file

@ -191,10 +191,6 @@ class LiveSession:
self._shift_map = None self._shift_map = None
self._compliance_tracker = None self._compliance_tracker = None
self._last_compliance: Dict[str, Any] = {"violations": 0, "by_rule": {}, "rows": []} 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( self._label_memory: "collections.deque[Dict[str, Any]]" = collections.deque(
maxlen=LABEL_MEMORY_MAX maxlen=LABEL_MEMORY_MAX
) )
@ -297,8 +293,6 @@ class LiveSession:
self._latest_frame_id = None self._latest_frame_id = None
self._frame_origin = [0.0, 0.0] self._frame_origin = [0.0, 0.0]
self._shift_map = None self._shift_map = None
self._settled_tags = {}
self._settled_tag_seq = 0
# ------------------------------------------------------------------ frame path # ------------------------------------------------------------------ frame path
def process_frame(self, frame_id: int, payload: bytes) -> Dict[str, Any]: def process_frame(self, frame_id: int, payload: bytes) -> Dict[str, Any]:
@ -445,17 +439,6 @@ class LiveSession:
if tag_summary is not None: if tag_summary is not None:
summary["price_tags"] = tag_summary["tags"] summary["price_tags"] = tag_summary["tags"]
summary["compliance"] = tag_summary["compliance"] 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._submit_classification(submissions, method, generation)
self._run_tag_passes(image, tag_bands) self._run_tag_passes(image, tag_bands)
@ -929,16 +912,7 @@ class LiveSession:
if track is None or image is None: if track is None or image is None:
raise KeyError("TRACK_NOT_IN_VIEW") raise KeyError("TRACK_NOT_IN_VIEW")
box = list(track.raw_box) box = list(track.raw_box)
# Snapshot is the frame current at tap time. engine.inspect runs return self.engine.inspect(image, box, method=chosen_method)
# 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
# ------------------------------------------------------------------ catalog cache # ------------------------------------------------------------------ catalog cache
def refresh_catalog(self) -> None: def refresh_catalog(self) -> None:

View file

@ -84,11 +84,19 @@ _TAX_RE = re.compile(r"税(込|抜|不|別)")
@dataclass @dataclass
class RailBand: 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] box: List[int]
row_index: int row_index: int
product_ids: List[int] = field(default_factory=list) 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. # The field model lives with the card reader; the pipeline consumes it unchanged.
@ -439,6 +447,9 @@ class TagService:
self.passes_failed = 0 self.passes_failed = 0
self.last_pass_error: Optional[str] = None self.last_pass_error: Optional[str] = None
self.passes_skipped = 0 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 # ------------------------------------------------------------------ frame loop
def plan( def plan(
@ -462,7 +473,8 @@ class TagService:
width = float(image_shape[1]) 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] bands = [band for band in bands if (band.box[2] - band.box[0]) >= self.settings.min_band_width_ratio * width]
with self._lock: 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 self._frame_seq += 1
seq = self._frame_seq seq = self._frame_seq
chosen: List[RailBand] = [] chosen: List[RailBand] = []
@ -481,6 +493,8 @@ class TagService:
break break
for band in chosen: for band in chosen:
key = band_key(band, shelf_offset) key = band_key(band, shelf_offset)
band.origin = _origin_of(shelf_offset)
band.key = key
self._last_pass_seq[key] = seq self._last_pass_seq[key] = seq
self._pending_bands.add(key) self._pending_bands.add(key)
return chosen return chosen
@ -492,7 +506,7 @@ class TagService:
independent reads of the same tag are what makes the vote meaningful independent reads of the same tag are what makes the vote meaningful
(identical pixels would produce identical output). (identical pixels would produce identical output).
""" """
key = band_key(band, getattr(self, "_shelf_offset", None)) key = _planned_key(band)
try: try:
x1, y1, x2, y2 = band.box x1, y1, x2, y2 = band.box
crop = image[y1:y2, x1:x2] crop = image[y1:y2, x1:x2]
@ -632,13 +646,19 @@ class TagService:
return all(tag.settled for tag in row_tags) return all(tag.settled for tag in row_tags)
def _absorb(self, candidate: Dict[str, Any], band: RailBand, pass_id: int) -> TagObservation: 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"] box = candidate["box"]
lines = candidate["lines"] lines = candidate["lines"]
fields = candidate["fields"] 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( observation = TagObservation(
box=list(box), box=list(shelf_box),
lines=list(lines), lines=list(lines),
fields=fields, fields=fields,
band_index=band.row_index, band_index=band.row_index,
@ -646,11 +666,11 @@ class TagService:
observed_at=time.time(), observed_at=time.time(),
) )
record = self._match_record(box, shelf_key) record = self._match_record(shelf_box, shelf_key)
if record is None: if record is None:
record = TagRecord( record = TagRecord(
tag_id=f"tag_{uuid.uuid4().hex[:8]}", tag_id=f"tag_{uuid.uuid4().hex[:8]}",
box=list(box), box=list(shelf_box),
band_index=band.row_index, band_index=band.row_index,
shelf_key=shelf_key, shelf_key=shelf_key,
first_seen=observation.observed_at, first_seen=observation.observed_at,
@ -658,7 +678,7 @@ class TagService:
if len(self._tags) >= self.settings.max_tags: if len(self._tags) >= self.settings.max_tags:
return observation return observation
self._tags[record.tag_id] = record self._tags[record.tag_id] = record
record.box = list(box) record.box = list(shelf_box)
record.last_seen = observation.observed_at record.last_seen = observation.observed_at
record.observations.append(observation) record.observations.append(observation)
record.passes += 1 record.passes += 1
@ -758,52 +778,47 @@ class TagService:
# ------------------------------------------------------------------ attach # ------------------------------------------------------------------ attach
def 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]]: ) -> Tuple[Dict[int, Dict[str, Any]], List[Dict[str, Any]], Dict[str, int]]:
"""Give each track its tag for this frame. """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 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 never read gets state 'unreadable' when a tag was found near it, and
'absent' when nothing tag-shaped was seen there. 'absent' when nothing tag-shaped was seen there.
""" """
with self._lock: with self._lock:
tags = [record.as_dict() for record in self._tags.values()] origin = _origin_of(shelf_offset if shelf_offset is not None else self._view_origin)
records = list(self._tags.values()) 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]] = {} by_track: Dict[int, Dict[str, Any]] = {}
used: set = set() 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: for track in tracks:
box = track.get("box") or track.get("raw_box") box = track.get("box") or track.get("raw_box")
if not box: if not box:
continue continue
record = _tag_below(box, records, used) record = _tag_below(box, records, used, screen_of)
if record is not None: if record is not None:
used.add(record.tag_id) used.add(record.tag_id)
payload = record.as_dict() payload = _screen_dict(record, screen_of[record.tag_id])
payload["associated"] = True payload["associated"] = True
by_track[int(track["track_id"])] = payload by_track[int(track["track_id"])] = payload
elif self._band_had_readable(record=None, box=box, records=records): elif self._band_had_readable(record=None, box=box, records=records, screen_of=screen_of):
by_track[int(track["track_id"])] = { by_track[int(track["track_id"])] = _empty_tag(TAG_STATE_UNREADABLE)
"id": None,
"box": None,
"state": TAG_STATE_UNREADABLE,
"price": None,
"name": None,
"confidence": 0.0,
"observations": 0,
"settled": False,
}
else: else:
by_track[int(track["track_id"])] = { by_track[int(track["track_id"])] = _empty_tag(TAG_STATE_ABSENT)
"id": None,
"box": None,
"state": TAG_STATE_ABSENT,
"price": None,
"name": None,
"confidence": 0.0,
"observations": 0,
"settled": False,
}
counts = { counts = {
TAG_STATE_READABLE: 0, TAG_STATE_READABLE: 0,
@ -815,13 +830,20 @@ class TagService:
counts[payload["state"]] = counts.get(payload["state"], 0) + 1 counts[payload["state"]] = counts.get(payload["state"], 0) + 1
return by_track, tags, counts 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)?""" """Was there any tag-shaped evidence near this facing (readable or not)?"""
x1, y1, x2, y2 = box x1, y1, x2, y2 = box
width = max(1, x2 - x1) width = max(1, x2 - x1)
for candidate in records: for candidate in records:
cx = (candidate.box[0] + candidate.box[2]) / 2.0 shown = (screen_of or {}).get(candidate.tag_id, candidate.box)
cy = (candidate.box[1] + candidate.box[3]) / 2.0 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: if x1 - width * 0.3 <= cx <= x2 + width * 0.3 and y1 <= cy <= y2 + (y2 - y1) * 0.5:
return True return True
return False return False
@ -858,7 +880,8 @@ class TagService:
self._pending_bands.clear() self._pending_bands.clear()
self._pass_id = 0 self._pass_id = 0
self._frame_seq = 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: 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 return inter / union if union > 0 else 0.0
def _tag_below(box: Sequence[int], records: Sequence[TagRecord], used: set) -> Optional[TagRecord]: def _tag_below(
"""The tag that belongs to this facing: below it, horizontally overlapping.""" 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 x1, y1, x2, y2 = box
width = max(1, x2 - x1) width = max(1, x2 - x1)
height = max(1, y2 - y1) 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: for record in records:
if record.tag_id in used: if record.tag_id in used:
continue 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 center_x = (rx1 + rx2) / 2.0
overlap = max(0, min(x2, rx2) - max(x1, rx1)) overlap = max(0, min(x2, rx2) - max(x1, rx1))
overlap_ratio = overlap / max(1, rx2 - 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 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> if __name__ == "__main__": # smoke: python code/tags.py <image>
import argparse import argparse

View file

@ -196,6 +196,31 @@ class RestContractTests(unittest.TestCase):
self.assertEqual(sent[0]["status"], 413) self.assertEqual(sent[0]["status"], 413)
self.assertIn(b"PAYLOAD_TOO_LARGE", sent[1]["body"]) 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): def test_classify_and_inspect_roundtrip(self):
recognized = self.client.post( recognized = self.client.post(
"/v1/recognize?classify=false", content=jpeg_bytes(), "/v1/recognize?classify=false", content=jpeg_bytes(),
@ -360,6 +385,16 @@ class WebSocketProtocolTests(unittest.TestCase):
finally: finally:
engine.shutdown() 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): class LegacyRouteTests(unittest.TestCase):
def test_legacy_sessions_still_respond(self): def test_legacy_sessions_still_respond(self):

View file

@ -21,7 +21,13 @@ if os.path.exists(os.path.join(project_root, "stock")):
if current_dir not in sys.path: if current_dir not in sys.path:
sys.path.insert(0, current_dir) 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): def a_gallery(views, threshold=0.55):
@ -108,5 +114,35 @@ class EnrollmentTests(unittest.TestCase):
self.assertEqual(len(gallery._views_for("sku_ab")), 1) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -385,44 +385,6 @@ class ClassificationBudgetTests(unittest.TestCase):
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
session.inspect(999) 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): def test_close_stops_the_loop(self):
session, engine, _, _ = make_session() session, engine, _, _ = make_session()

View file

@ -335,6 +335,56 @@ class TagServiceTests(unittest.TestCase):
self.assertGreaterEqual(service.stats()["passes_run"], 1) self.assertGreaterEqual(service.stats()["passes_run"], 1)
self.assertIsNone(service.stats()["last_pass_error"]) 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): class StorePolicyTests(unittest.TestCase):
def test_a_missing_file_uses_the_defaults(self): def test_a_missing_file_uses_the_defaults(self):

View file

@ -104,7 +104,10 @@ class BodyLimit:
A handler that checks Content-Length, or reads request.body(), runs after A handler that checks Content-Length, or reads request.body(), runs after
the server has already buffered the stream. This counts bytes at receive 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. not HTTP bodies and are left alone.
""" """
@ -116,10 +119,13 @@ class BodyLimit:
if scope.get("type") != "http": if scope.get("type") != "http":
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
class TooLarge(Exception): class TooLarge(Exception):
pass pass
seen = 0 seen = 0
started = False
async def limited() -> Dict[str, Any]: async def limited() -> Dict[str, Any]:
nonlocal seen nonlocal seen
message = await receive() message = await receive()
@ -128,12 +134,24 @@ class BodyLimit:
body = message.get("body") or b"" body = message.get("body") or b""
seen += len(body) seen += len(body)
if seen > self.max_bytes: if seen > self.max_bytes:
# The app must not see this chunk or any later one.
raise TooLarge raise TooLarge
return message 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: try:
await self.app(scope, limited, send) await self.app(scope, limited, tracked)
except TooLarge: 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( await send(
{ {
"type": "http.response.start", "type": "http.response.start",