""" Crop a labelled shelf photo into a stock directory. 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. 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 ".previous", not deleted. """ from __future__ import annotations 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 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())