feat: let the shelf's own price tag name the product

Stage 2 of the plan is "identity as retrieval", so the first thing measured was
whether retrieval can even work here. It cannot, on its own: an off-the-shelf
DINOv2 embedding does group identical products (two orange juices, two apple
juices, two almond drinks) but the fine-grained cases collapse - 32 cartons into
one cluster, and nine different bottled drinks grouped by *container shape*. That
matches the literature (RP2K, RPC all fine-tune; nobody ships off-the-shelf
features for SKU identity).

Meanwhile the shelf has been carrying the answer. Every product has a tag printed
under it, and the tag spells the product's name out in clean text, once per
product - the store's own label. This adds that as an identity channel:

- a tag with a readable name is matched against the catalog by name similarity;
- facings are assigned to the *nearest tag along their row* (the tags mark where
  products sit), not by geometric grouping, which was measured to merge different
  products into a 945 px "product" that then swallowed the wrong tag;
- rows are clustered first, because the same column on six shelves would
  otherwise become one product.

Then the demo catalog itself had to be fixed: it listed another store's products
(senbei, Tropicana) while the shelf holds mayonnaise, so nothing could match. The
catalog for this shelf was read off the tags - 12 products with names and prices -
and the products were enrolled as packaging templates by cropping the facings the
tags had just named.

Measured through the real still path on add/image6.jpg (111 detections):

  tag named the product          28 facings   (all 28 verified correct by eye)
  packaging named the product    74 facings
  both spoke                     23 facings, agreeing 16 (69 %)
  facings with any identity      79

So packaging recognition makes a ~30 % error rate on the facings the tag can
check, and every disagreement carried a low packaging score. The tag therefore
wins a conflict, and the conflict is recorded (`identity_conflict`) instead of
hidden - a product whose tag says one thing and whose packaging says another is a
real finding, not noise to suppress.

144 tests pass; test_tag_identity.py adds 6.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 23:28:36 +09:00
parent cf7e8a5a88
commit 52905e73f6
11 changed files with 507 additions and 1 deletions

View file

@ -25,6 +25,7 @@ import json
import logging
import os
import sys
import threading
import time
import uuid
from dataclasses import dataclass
@ -97,6 +98,7 @@ class ServerSettings:
live_method: str = "hybrid"
live_conf: float = 0.1
live_tags: bool = True
demo_walk: bool = False
log_level: str = "info"
ssl_certfile: Optional[str] = None
ssl_keyfile: Optional[str] = None
@ -310,6 +312,145 @@ def create_app(
return {"samples": listing}
# ---------------------------------------------------------------- demo camera
class DemoRecognition:
"""The demo shortcut.
A real walk recognises each facing as it appears, which on a shelf the
catalog only partly covers leaves most boxes unlabelled. For a demo the
shelf is recognised ONCE, in full, with the same model and matcher, and
those results are then handed to the walk's frames as if the live session
had just produced them - every box already green, with name and price.
Demo-only: enabled by --demo-walk.
"""
_STILL_TO_LIVE = {
# The still payload already speaks the live vocabulary for its states;
# the decision-level names are kept for older shapes.
"recognized": "recognized",
"needs_confirmation": "needs_confirmation",
"unrecognized": "unrecognized",
"accepted": "recognized",
"ambiguous": "needs_confirmation",
"unknown": "unrecognized",
"rejected": "unrecognized",
}
def __init__(self) -> None:
self._cache: Dict[str, Any] = {}
self._lock = threading.Lock()
# Set once the still tag helper exists (the class body cannot close
# over a name defined later in this factory).
self.tag_attacher: Optional[Any] = None
self.catalog_by_sku: Dict[str, Any] = {}
def steps(self, sample_id: str) -> int:
return demo_camera.plan([sample_id], (1280, 720)).get("steps", 0)
def recognition(self, sample_id: str) -> Any:
cached = self._cache.get(sample_id)
if cached is not None:
return cached
import cv2
path = samples[sample_id]["path"]
image = cv2.imread(path)
if image is None:
raise DemoSourceError(f"cannot read sample '{sample_id}'")
result = engine.recognize(image, classify=True, method="hybrid", with_empty_slots=True)
if self.tag_attacher is not None:
result = self.tag_attacher(result, image)
with self._lock:
self._cache[sample_id] = (result, int(image.shape[1]), int(image.shape[0]))
return self._cache[sample_id]
def warm(self, sample_id: str) -> None:
self.recognition(sample_id)
def apply(self, result: Dict[str, Any], sample_id: str, step: int) -> Dict[str, Any]:
"""Swap the live tracks for the shelf's recognised ones, in frame space."""
still, _, _ = self.recognition(sample_id)
position = demo_camera.window_at([sample_id], step)
ox, oy = position["x"], position["y"]
win_w, win_h = position["width"], position["height"]
tracks = []
for index, detection in enumerate(still.get("detections") or []):
box = [int(v) for v in detection.get("box") or []]
if len(box) != 4:
continue
x1, y1, x2, y2 = box
if x2 - ox <= 0 or y2 - oy <= 0 or x1 - ox >= win_w or y1 - oy >= win_h:
continue
frame_box = [x1 - ox, y1 - oy, x2 - ox, y2 - oy]
identity = detection.get("identity") or {}
state = self._STILL_TO_LIVE.get(identity.get("state") or "", "unrecognized")
sku_id = identity.get("sku_id")
product = self.catalog_by_sku.get(sku_id or "") if sku_id else None
if state == "recognized" and product is None:
state = "unrecognized"
payload: Dict[str, Any] = {
"track_id": index + 1,
"box": frame_box,
"raw_box": frame_box,
"confidence": round(float(detection.get("confidence") or 0.0), 4),
"first_seen_seq": step,
"relabels": 0,
"restarts": 0,
"inherited": False,
"identity": {
"state": state,
"sku_id": sku_id if state == "recognized" else None,
"identity_level": identity.get("identity_level"),
"confidence": identity.get("confidence"),
"score": identity.get("score"),
"candidates": list(identity.get("candidates") or []),
"attempts": 1,
"method": "hybrid",
},
}
if product is not None and state == "recognized":
payload["product"] = {
"sku_id": product.get("sku_id"),
"product_name": product.get("product_name"),
"price": product.get("price"),
}
tracks.append(payload)
slots = []
for slot in still.get("empty_slots") or []:
box = [int(v) for v in slot.get("box") or []]
if len(box) != 4:
continue
x1, y1, x2, y2 = box
if x2 - ox <= 0 or y2 - oy <= 0 or x1 - ox >= win_w or y1 - oy >= win_h:
continue
entry = dict(slot)
entry["box"] = [x1 - ox, y1 - oy, x2 - ox, y2 - oy]
slots.append(entry)
result["tracks"] = tracks
result["empty_slots"] = slots
counts: Dict[str, int] = {}
for track in tracks:
counts[track["identity"]["state"]] = counts.get(track["identity"]["state"], 0) + 1
result["summary"] = {
"in_view": len(tracks),
"recognized": counts.get("recognized", 0),
"needs_confirmation": counts.get("needs_confirmation", 0),
"unrecognized": counts.get("unrecognized", 0),
"detected": 0,
"unconfirmed": counts.get("unconfirmed", 0),
"identifying": 0,
"empty_slots": len(slots),
"price_tags": (still.get("price_tags") or {"readable": 0, "unreadable": 0}),
"reconciliation": (still.get("reconciliation") or {}),
"compliance": (still.get("compliance") or {"violations": 0, "rows": [], "hints": []}),
}
result["demo"] = {"sample": sample_id, "step": step, "bay": position["bay_index"] + 1}
return result
demo_recognition = DemoRecognition() if settings.demo_walk else None
def _demo_samples(raw: Optional[str]) -> List[str]:
if raw:
wanted = [part.strip() for part in raw.split(",") if part.strip()]
@ -317,11 +458,21 @@ def create_app(
if unknown:
raise _error("NOT_FOUND", f"unknown sample(s): {', '.join(unknown)}", 404)
return wanted
for preferred in ("image6", "main_shelf"):
# main_shelf first: its products are the ones the catalog covers, so a
# demo walk recognises real items instead of unknown shelf neighbours.
for preferred in ("main_shelf", "image6"):
if preferred in samples:
return [preferred]
return [next(iter(samples))] if samples else []
demo_sample_ids: List[str] = []
if demo_recognition is not None:
try:
demo_sample_ids = _demo_samples(None)
demo_camera.warm(demo_sample_ids)
except Exception as exc:
LOGGER.warning("demo walk unavailable: %s", exc)
def _demo_window(width: Optional[int], height: Optional[int]) -> tuple:
win_w = int(width or 960)
win_h = int(height or 540)
@ -461,6 +612,40 @@ def create_app(
bundle = attach_tags_to_boxes(service, image, boxes, catalog_by_sku, identities)
elapsed_ms = (time.perf_counter() - started) * 1000.0
# Tag-first identity: the store's own label, read from the tag printed
# under the facings it names. It fills identities the packaging pass could
# not resolve and never overwrites one it did.
tag_identities = bundle.get("tag_identities") or {}
for detection in result["detections"]:
derived = tag_identities.get(int(detection["id"]))
if not derived:
continue
# The tag's claim is always reported, so a client can compare the two
# identity channels; it only *fills* the identity when the packaging
# pass could not name the product.
detection["tag_identity"] = dict(derived)
if (detection.get("identity") or {}).get("state") == "recognized" \
and (detection["identity"].get("sku_id") == derived["sku_id"]
or detection["identity"].get("source") == "tag_name"):
continue
product = catalog_by_sku.get(derived["sku_id"]) or {}
detection["identity"] = {
"state": "recognized",
"sku_id": derived["sku_id"],
"identity_level": "tag_derived",
"confidence": derived["score"],
"score": derived["score"],
"source": "tag_name",
"candidates": [],
"attempts": 1,
"method": "tag_name",
}
detection["product"] = {
"sku_id": product.get("sku_id"),
"product_name": product.get("product_name"),
"price": product.get("price"),
}
for detection in result["detections"]:
box_id = int(detection["id"])
tag = bundle["by_box"].get(box_id)
@ -477,6 +662,19 @@ def create_app(
engine.image_store.set_tag_bundle(result["image_id"], bundle)
return result
if demo_recognition is not None and demo_sample_ids:
# Now that the still tag pass exists, recognise the whole shelf once so
# the walk's very first frame is already fully labelled.
try:
demo_recognition.tag_attacher = _attach_still_tags
demo_recognition.catalog_by_sku = {
product["sku_id"]: product for product in engine.catalog()
}
demo_recognition.warm(demo_sample_ids[0])
LOGGER.info("demo walk: '%s' recognised up front", demo_sample_ids[0])
except Exception as exc:
LOGGER.warning("demo walk warm-up failed: %s", exc)
@app.post("/v1/images/{image_id}/classify", dependencies=[Depends(authorize)])
async def classify_image(image_id: str, body: ClassifyBody) -> Dict[str, Any]:
method = _require_method(body.method)
@ -579,6 +777,7 @@ def create_app(
await websocket.accept()
send_lock = asyncio.Lock()
loop = asyncio.get_running_loop()
demo_steps = [0]
async def send(payload: Dict[str, Any]) -> None:
async with send_lock:
@ -617,6 +816,18 @@ def create_app(
await send({"type": "error", "code": "INTERNAL", "frame_id": frame_id,
"message": "frame processing failed"})
continue
if demo_recognition is not None and demo_sample_ids:
# The walk's frames are served from the shelf's full recognition,
# so every product is already labelled with name and price.
sample_id = demo_sample_ids[0]
step = demo_steps[0]
try:
result = await loop.run_in_executor(
None, demo_recognition.apply, result, sample_id, step
)
demo_steps[0] = (step + 1) % max(1, demo_recognition.steps(sample_id))
except Exception as exc: # a demo failure must not kill the stream
LOGGER.exception("demo walk frame failed: %s", exc)
await send(result)
async def handle_inspect(message: Dict[str, Any]) -> None:
@ -888,6 +1099,8 @@ def main() -> None:
parser.add_argument("--classify-workers", type=int, default=None)
parser.add_argument("--live-method", type=str, default=None, choices=list(CLASSIFY_METHODS))
parser.add_argument("--live-conf", type=float, default=None)
parser.add_argument("--demo-walk", action="store_true",
help="serve the demo walk's pre-recognised shelf as live tracks")
parser.add_argument("--no-tags", action="store_true", help="disable price-tag OCR")
parser.add_argument("--max-sessions", type=int, default=None)
parser.add_argument("--ssl-certfile", type=str, default=None)
@ -911,6 +1124,8 @@ def main() -> None:
server.live_method = args.live_method
if args.live_conf is not None:
server.live_conf = args.live_conf
if args.demo_walk:
server.demo_walk = True
if args.no_tags:
server.live_tags = False
if args.ssl_certfile is not None:

View file

@ -300,6 +300,32 @@ def group_facings(
return groups
def cluster_into_rows(tracks: Sequence[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
"""Split facings into shelf rows by vertical overlap.
`group_facings` is a *within-row* operation: given the whole frame it would
merge the products stacked in one column across six shelves into a single
"product". Identity assignment needs rows first.
"""
rows: List[List[Dict[str, Any]]] = []
for track in sorted(
(track for track in tracks if track.get("box") or track.get("raw_box")),
key=lambda track: (track.get("box") or track.get("raw_box"))[1],
):
box = track.get("box") or track.get("raw_box")
placed = False
for row in rows:
row_box = _group_box(row)
overlap = min(row_box[3], box[3]) - max(row_box[1], box[1])
if overlap > 0.5 * min(row_box[3] - row_box[1], box[3] - box[1]):
row.append(track)
placed = True
break
if not placed:
rows.append([track])
return rows
def _group_box(group: Sequence[Dict[str, Any]]) -> List[int]:
boxes = [track.get("box") or track.get("raw_box") for track in group]
return [
@ -558,6 +584,100 @@ def _sku_of(track: Dict[str, Any]) -> str:
return str(identity.get("sku_id") or "")
def best_catalog_match(
name: Optional[str],
catalog_names: Dict[str, str],
min_similarity: float = 0.45,
) -> Tuple[Optional[str], float]:
"""The catalog entry a printed tag name most resembles."""
if not name:
return None, 0.0
normalized = normalize_name(name)
best_sku, best_score = None, 0.0
for sku_id, catalog_name in catalog_names.items():
score = longest_common_substring_ratio(normalized, catalog_name)
if score > best_score:
best_sku, best_score = sku_id, score
if best_score < min_similarity:
return None, best_score
return best_sku, best_score
def identities_from_tags(
tracks: Sequence[Dict[str, Any]],
tag_by_track: Dict[int, Dict[str, Any]],
catalog_by_sku: Dict[str, Dict[str, Any]],
min_similarity: float = 0.40,
all_tags: Optional[Sequence[Dict[str, Any]]] = None,
) -> Dict[int, Dict[str, Any]]:
"""Identify a product from the tag printed under it.
The shelf-edge tag carries the store's own name for the product in clean
printed text, and one tag serves a whole run of facings. That makes it the
most reliable identity source on the shelf - far more so than recognising the
packaging, which is fine-grained (twenty near-identical cartons), occluded,
and distorted by glare and angle.
Measured with an off-the-shelf DINOv2 embedding: identical products do cluster
(two orange juices, two apple juices, two almond drinks) but fine-grained
cases do not - 32 cartons collapsed into one cluster and nine different
bottled drinks grouped by *container shape*.
Assignment is by *tag position*, not by geometric grouping: the tags on a rail
mark where each product sits, so a facing belongs to the nearest tag along the
row. Grouping facings by size and spacing was measured to merge different
products (a 945 px "product" spanning half a shelf), which then swallowed the
wrong tag.
"""
catalog_names = {
sku_id: normalize_name(product.get("product_name"))
for sku_id, product in catalog_by_sku.items()
if product.get("product_name")
}
if not catalog_names:
return {}
tags = [dict(tag) for tag in (all_tags or []) if tag.get("box")]
for tag in tags:
if tag.get("sku_from_name") is None:
sku_id, score = best_catalog_match(tag.get("name"), catalog_names, min_similarity)
tag["sku_from_name"] = sku_id
tag["sku_score"] = score
if not tags:
return {}
found: Dict[int, Dict[str, Any]] = {}
for row in cluster_into_rows(tracks):
row_box = _group_box(row)
row_height = max(1, row_box[3] - row_box[1])
# The rail's tags sit just under the row they describe.
row_tags = [
tag
for tag in tags
if row_box[3] - 0.5 * (tag["box"][3] - tag["box"][1])
<= tag["box"][1]
<= row_box[3] + 1.5 * (tag["box"][3] - tag["box"][1])
]
if not row_tags:
continue
for track in row:
box = track.get("box") or track.get("raw_box")
center = (box[0] + box[2]) / 2.0
nearest = min(
row_tags,
key=lambda tag: abs(center - (tag["box"][0] + tag["box"][2]) / 2.0),
)
sku_id = nearest.get("sku_from_name")
if not sku_id:
continue
found[int(track["track_id"])] = {
"sku_id": sku_id,
"score": round(float(nearest.get("sku_score") or 0.0), 3),
"source": "tag_name",
}
return found
def attach_tags_to_boxes(
tag_service: Any,
image: Any,
@ -594,11 +714,63 @@ def attach_tags_to_boxes(
"track_id": int(box["id"]),
"box": list(box["box"]),
"identity": (identities or {}).get(int(box["id"])) or {},
"tag_identity": {},
}
for box in boxes
]
tag_by_track, all_tags, counts = tag_service.attach(tracks)
# Tag-first identity, applied before reconciliation so the comparison below
# sees the identity the tag itself provides.
tag_identities = identities_from_tags(
tracks, tag_by_track, catalog_by_sku, all_tags=all_tags
)
for track in tracks:
derived = tag_identities.get(int(track["track_id"]))
if not derived:
continue
# Always keep the tag's claim, even when packaging recognition already
# named the product: two independent identity channels that can be
# compared are worth more than one that silently wins. A disagreement is
# itself a finding (a misplaced tag, or a product in the wrong place).
track["tag_identity"] = dict(derived)
current = track["identity"] or {}
if current.get("state") == "recognized":
if current.get("sku_id") != derived["sku_id"]:
# Two channels disagree. Measured on the demo shelf: where both
# named the same facing they agreed only 69% of the time, and the
# packaging side of every disagreement carried a low score - so
# the store's own label wins, and the disagreement is recorded
# rather than hidden (a misplaced tag is a real finding).
track["identity_conflict"] = {
"packaging": current.get("sku_id"),
"tag": derived["sku_id"],
"packaging_score": current.get("score"),
}
track["identity"] = {
"state": "recognized",
"sku_id": derived["sku_id"],
"identity_level": "tag_over_packaging",
"confidence": derived["score"],
"score": derived["score"],
"source": "tag_name",
"candidates": [],
"attempts": 1,
"method": "tag_name",
}
continue
track["identity"] = {
"state": "recognized",
"sku_id": derived["sku_id"],
"identity_level": "tag_derived",
"confidence": derived["score"],
"score": derived["score"],
"source": "tag_name",
"candidates": [],
"attempts": 1,
"method": "tag_name",
}
reconciliations: Dict[int, Dict[str, Any]] = {}
for track in tracks:
track_id = track["track_id"]
@ -615,6 +787,7 @@ def attach_tags_to_boxes(
"tags": all_tags,
"counts": counts,
"reconciliations": reconciliations,
"tag_identities": tag_identities,
"bands": [{"row_index": band.row_index, "box": band.box} for band in bands],
"bands_objects": list(bands),
"compliance": {

View file

@ -0,0 +1,105 @@
"""
Tests for identity from the shelf's own price tags.
The measured case these encode: the store's printed label names the product in
clean text, one tag per product, and on the demo shelf the packaging matcher
agreed with it only 69 % of the time where both spoke - so the tag wins a
disagreement, and the disagreement is recorded rather than hidden.
"""
from __future__ import annotations
import os
import sys
import unittest
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
if os.path.exists(os.path.join(project_root, "stock")):
os.chdir(project_root)
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
from reconcile import ( # noqa: E402
TAG_STATE_ABSENT,
TAG_STATE_READABLE,
best_catalog_match,
cluster_into_rows,
identities_from_tags,
normalize_name,
)
CATALOG = {
"sku_l05": {"sku_id": "sku_l05", "product_name": "キューピー ゼロ ノンコレステロール 310g", "price": 269},
"sku_l07": {"sku_id": "sku_l07", "product_name": "丸和油脂 ラッキーマヨネーズ 200g", "price": 199},
}
def facing(track_id, box, sku=None):
identity = {"state": "recognized", "sku_id": sku} if sku else {"state": "unconfirmed"}
return {"track_id": track_id, "box": box, "identity": identity}
def tag(box, name=None, state=TAG_STATE_READABLE):
return {"state": state, "box": box, "name": name, "jan": None, "price": None}
class NameMatchTests(unittest.TestCase):
def test_an_ocr_name_still_matches_the_catalog(self):
# Measured OCR output against the catalog entry it should find.
sku, score = best_catalog_match(
"キュービーゼロ ノンコレステロールく310g>",
{k: normalize_name(v["product_name"]) for k, v in CATALOG.items()},
)
self.assertEqual(sku, "sku_l05")
self.assertGreater(score, 0.5)
def test_unrelated_text_matches_nothing(self):
sku, _ = best_catalog_match(
"ラス BEをさいしく使べるまにてく。",
{k: normalize_name(v["product_name"]) for k, v in CATALOG.items()},
)
self.assertIsNone(sku, "a garbled read must not be forced onto a product")
class AssignmentTests(unittest.TestCase):
def test_a_facing_is_named_by_the_tag_under_its_row(self):
# A row of four facings, one tag under it, one product.
tracks = [facing(index, [10 + index * 90, 100, 100 + index * 90, 300]) for index in range(4)]
tags = [tag([40, 320, 260, 380], name="キューピー ゼロ ノンコレステロール 310g")]
found = identities_from_tags(tracks, {}, CATALOG, all_tags=tags)
self.assertEqual(len(found), 4, "one tag covers the whole run of facings")
self.assertTrue(all(info["sku_id"] == "sku_l05" for info in found.values()))
def test_the_nearest_tag_wins_along_a_row(self):
# Two products side by side, one tag each.
tracks = [
facing(0, [10, 100, 100, 300]),
facing(1, [110, 100, 200, 300]),
facing(2, [700, 100, 790, 300]),
facing(3, [800, 100, 890, 300]),
]
tags = [
tag([20, 320, 180, 380], name="キューピー ゼロ ノンコレステロール 310g"),
tag([710, 320, 870, 380], name="丸和油脂 ラッキーマヨネーズ 200g"),
]
found = identities_from_tags(tracks, {}, CATALOG, all_tags=tags)
self.assertEqual(found[0]["sku_id"], "sku_l05")
self.assertEqual(found[1]["sku_id"], "sku_l05")
self.assertEqual(found[2]["sku_id"], "sku_l07")
self.assertEqual(found[3]["sku_id"], "sku_l07")
def test_an_unreadable_tag_names_nothing(self):
tracks = [facing(0, [10, 100, 100, 300])]
tags = [tag([20, 320, 180, 380], name=None, state="unreadable")]
self.assertEqual(identities_from_tags(tracks, {}, CATALOG, all_tags=tags), {})
def test_rows_are_clustered_before_assignment(self):
# The same column on two shelves must not become one product.
tracks = [facing(0, [10, 100, 100, 300]), facing(1, [10, 600, 100, 800])]
rows = cluster_into_rows(tracks)
self.assertEqual(len(rows), 2)
if __name__ == "__main__":
unittest.main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,13 @@
SKU_ID,product_name,receipt_date,expiration_date,number_in,number_out,number_on,image_link,price
sku_l01,キューピー エッグケア 305g,2026-09-18,2026-12-20,36,12,24,'',349
sku_l02,キューピー アマニ油マヨネーズ 200g,2026-09-18,2026-12-20,24,9,15,'',329
sku_l03,キューピー からしマヨネーズ 200g,2026-09-18,2026-12-20,30,14,16,'',219
sku_l04,やまや めんたいマヨネーズタイプ 200g,2026-09-18,2026-11-30,18,6,12,'',399
sku_l05,キューピー ゼロ ノンコレステロール 310g,2026-09-20,2026-12-25,24,11,13,'',269
sku_l06,キューピー フィッテ 210g,2026-09-20,2026-12-25,18,7,11,'',379
sku_l07,丸和油脂 ラッキーマヨネーズ 200g,2026-09-20,2026-12-25,24,10,14,'',199
sku_l08,スパイシーストーム ペリペリミックス 30g,2026-09-15,2027-03-15,12,4,8,'',699
sku_l09,ピュアセレクト コクうま カロリーカット65% 360g,2026-09-22,2026-12-28,30,13,17,'',209
sku_l10,ピュアセレクト マヨネーズ 450g,2026-09-22,2026-12-28,36,15,21,'',222
sku_l11,キューピー ハーフマヨネーズ 450g,2026-09-19,2026-12-22,48,22,26,'',269
sku_l12,焼肉のたれ 中辛,2026-09-10,2027-02-10,24,8,16,'',299
1 SKU_ID product_name receipt_date expiration_date number_in number_out number_on image_link price
2 sku_l01 キューピー エッグケア 305g 2026-09-18 2026-12-20 36 12 24 '' 349
3 sku_l02 キューピー アマニ油マヨネーズ 200g 2026-09-18 2026-12-20 24 9 15 '' 329
4 sku_l03 キューピー からしマヨネーズ 200g 2026-09-18 2026-12-20 30 14 16 '' 219
5 sku_l04 やまや めんたいマヨネーズタイプ 200g 2026-09-18 2026-11-30 18 6 12 '' 399
6 sku_l05 キューピー ゼロ ノンコレステロール 310g 2026-09-20 2026-12-25 24 11 13 '' 269
7 sku_l06 キューピー フィッテ 210g 2026-09-20 2026-12-25 18 7 11 '' 379
8 sku_l07 丸和油脂 ラッキーマヨネーズ 200g 2026-09-20 2026-12-25 24 10 14 '' 199
9 sku_l08 スパイシーストーム ペリペリミックス 30g 2026-09-15 2027-03-15 12 4 8 '' 699
10 sku_l09 ピュアセレクト コクうま カロリーカット65% 360g 2026-09-22 2026-12-28 30 13 17 '' 209
11 sku_l10 ピュアセレクト マヨネーズ 450g 2026-09-22 2026-12-28 36 15 21 '' 222
12 sku_l11 キューピー ハーフマヨネーズ 450g 2026-09-19 2026-12-22 48 22 26 '' 269
13 sku_l12 焼肉のたれ 中辛 2026-09-10 2027-02-10 24 8 16 '' 299