diff --git a/recognization/code/app.py b/recognization/code/app.py index ddfacfb..6424764 100644 --- a/recognization/code/app.py +++ b/recognization/code/app.py @@ -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: diff --git a/recognization/code/reconcile.py b/recognization/code/reconcile.py index 271cee7..49986d9 100644 --- a/recognization/code/reconcile.py +++ b/recognization/code/reconcile.py @@ -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": { diff --git a/recognization/code/test_tag_identity.py b/recognization/code/test_tag_identity.py new file mode 100644 index 0000000..69bdd21 --- /dev/null +++ b/recognization/code/test_tag_identity.py @@ -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() diff --git a/recognization/stock-lopia/sku_l01.jpeg b/recognization/stock-lopia/sku_l01.jpeg new file mode 100644 index 0000000..5a079a9 Binary files /dev/null and b/recognization/stock-lopia/sku_l01.jpeg differ diff --git a/recognization/stock-lopia/sku_l02.jpeg b/recognization/stock-lopia/sku_l02.jpeg new file mode 100644 index 0000000..e2e95d5 Binary files /dev/null and b/recognization/stock-lopia/sku_l02.jpeg differ diff --git a/recognization/stock-lopia/sku_l03.jpeg b/recognization/stock-lopia/sku_l03.jpeg new file mode 100644 index 0000000..8b4b0fa Binary files /dev/null and b/recognization/stock-lopia/sku_l03.jpeg differ diff --git a/recognization/stock-lopia/sku_l04.jpeg b/recognization/stock-lopia/sku_l04.jpeg new file mode 100644 index 0000000..e3392e2 Binary files /dev/null and b/recognization/stock-lopia/sku_l04.jpeg differ diff --git a/recognization/stock-lopia/sku_l05.jpeg b/recognization/stock-lopia/sku_l05.jpeg new file mode 100644 index 0000000..25df422 Binary files /dev/null and b/recognization/stock-lopia/sku_l05.jpeg differ diff --git a/recognization/stock-lopia/sku_l07.jpeg b/recognization/stock-lopia/sku_l07.jpeg new file mode 100644 index 0000000..27d2a98 Binary files /dev/null and b/recognization/stock-lopia/sku_l07.jpeg differ diff --git a/recognization/stock-lopia/sku_l09.jpeg b/recognization/stock-lopia/sku_l09.jpeg new file mode 100644 index 0000000..14fe787 Binary files /dev/null and b/recognization/stock-lopia/sku_l09.jpeg differ diff --git a/recognization/stock-lopia/stock.csv b/recognization/stock-lopia/stock.csv new file mode 100644 index 0000000..4e021a0 --- /dev/null +++ b/recognization/stock-lopia/stock.csv @@ -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