fix: the number on the tag is the regular price, and a price must be corroborated
Two corrections, both found by the deep review. 1. The number printed large beside the sale price is NOT the JAN's last three digits. Checked against the manufacturer, キユーピー ゼロ ノンコレステロール 310g is JAN 4901577092381 - it ends in 381, not the 357 the tag prints. The number is the regular (pre-discount) price, and all three tags on the demo shelf confirm it against their printed discount: (357-269)/357 = 24.6 % -> 2.4割引, (400-379)/400 = 5.25 % -> 0.5割引, (248-199)/248 = 19.8 % -> 1.9割引. The field is renamed `regular_price`, the barcode keeps its own name, and the discount is now a second arithmetic check alongside the tax-included price - so a tag can be checked against itself twice. 2. A price is only readable when that arithmetic corroborates it. Measured on a live walk before this: 90 tracks carried a tag marked readable, with prices of 20, 23, 36, 50, 72, 2002 and 4508. The gate has three strengths - an exact tax pair, an agreeing printed discount, or both plus agreeing digits - and `TagRecord.state` now consults it, so the wire cannot call an uncorroborated read readable. Also fixed while measuring: voting picks each field independently, so a record could publish a price from one frame beside a tax-included price from another (measured: price 149 with unit 214.92, which never appeared together - 214.92/1.08 = 199). The arithmetic is now re-run on the values actually being published, stale flags are dropped, and an implausible regular price (3050 beside 349) is suppressed rather than published with a discount derived from it. Measured through the real still path: readable tags 9 -> 5, every published price now satisfies its own tax-included price, and the live walk publishes none instead of publishing garbage. 162 tests pass. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
e854aea23d
commit
3a960b1471
4 changed files with 280 additions and 75 deletions
|
|
@ -29,6 +29,7 @@ from the small one and marked as derived rather than measured.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
@ -39,6 +40,12 @@ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||||
TAX_RATIOS = (1.08, 1.10)
|
TAX_RATIOS = (1.08, 1.10)
|
||||||
|
|
||||||
HEADER_PATTERN = re.compile(r"お買得|お買い得|特価|価格|セール")
|
HEADER_PATTERN = re.compile(r"お買得|お買い得|特価|価格|セール")
|
||||||
|
# A shelf price below this is not a price (measured live reads included 20 and 23).
|
||||||
|
MIN_PRICE = 50
|
||||||
|
# The tag truncates its discount to a tenth of a 割, so agreement is one step.
|
||||||
|
DISCOUNT_TOLERANCE = 0.05
|
||||||
|
# A regular price above this multiple of the sale price is a misread, not a discount.
|
||||||
|
REGULAR_PRICE_CEILING = 1.8
|
||||||
DISCOUNT_PATTERN = re.compile(r"(\d+(?:\.\d)?)\s*割\s*引")
|
DISCOUNT_PATTERN = re.compile(r"(\d+(?:\.\d)?)\s*割\s*引")
|
||||||
# A decimal whose point the OCR dropped or replaced with a space (measured: a
|
# A decimal whose point the OCR dropped or replaced with a space (measured: a
|
||||||
# tax-included 409.32 came back as "409 3").
|
# tax-included 409.32 came back as "409 3").
|
||||||
|
|
@ -73,8 +80,15 @@ class TagFields:
|
||||||
"""What a tag says, with the evidence for each value.
|
"""What a tag says, with the evidence for each value.
|
||||||
|
|
||||||
Field names match the rest of the tag pipeline (`tags.TagFields`) so a card's
|
Field names match the rest of the tag pipeline (`tags.TagFields`) so a card's
|
||||||
reading can be folded into the session cache unchanged; `jan` and
|
reading can be folded into the session cache unchanged; `price_source` and the
|
||||||
`price_source` are the additions this module brings.
|
corroboration flags are the additions this module brings.
|
||||||
|
|
||||||
|
`regular_price` is the number a LOPIA tag prints large beside the sale price
|
||||||
|
(357 beside 269). It is the price *before* the discount, not a product code:
|
||||||
|
checked against the manufacturer, this product's JAN is 4901577092381, whose
|
||||||
|
last three digits are 381. All three tags on the demo shelf agree with their
|
||||||
|
printed discount instead - (357-269)/357 = 24.6 % -> 2.4割引 - which makes the
|
||||||
|
discount a second arithmetic check alongside the tax-included price.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
price: Optional[int] = None
|
price: Optional[int] = None
|
||||||
|
|
@ -82,8 +96,10 @@ class TagFields:
|
||||||
discount: Optional[float] = None
|
discount: Optional[float] = None
|
||||||
unit_price: Optional[float] = None
|
unit_price: Optional[float] = None
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
# The JAN's last three digits, printed large on Japanese shelf-edge tags.
|
# A full JAN read from the tag's barcode. Only decodable at close range
|
||||||
jan: Optional[str] = None
|
# (measured: 0.63 px per module at ~1 m needs ~3x more pixels), so it is
|
||||||
|
# normally None and the barcode stays a deliberate close-up step.
|
||||||
|
barcode: Optional[str] = None
|
||||||
# measured (the big digits agreed with the tax relation) | derived (taken from
|
# measured (the big digits agreed with the tax relation) | derived (taken from
|
||||||
# the tax relation because the big digits could not be read) | none
|
# the tax relation because the big digits could not be read) | none
|
||||||
price_source: str = "none"
|
price_source: str = "none"
|
||||||
|
|
@ -91,9 +107,30 @@ class TagFields:
|
||||||
confidence: float = 0.0
|
confidence: float = 0.0
|
||||||
flags: List[str] = field(default_factory=list)
|
flags: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def corroborated(self) -> bool:
|
||||||
|
"""Did the tag's own arithmetic agree anywhere?
|
||||||
|
|
||||||
|
Measured on a live walk: single uncorroborated reads of the big stylised
|
||||||
|
digits produced prices of 20, 23, 72, 2002 and 4508. A price is only worth
|
||||||
|
publishing when the tag's arithmetic confirms it.
|
||||||
|
|
||||||
|
Three strengths, weakest to strongest: the tax-included price and the sale
|
||||||
|
price form an exact pair (`tax_pair_consistent`); the printed discount
|
||||||
|
agrees with the pair (`discount_consistent`); the stylised digits agree with
|
||||||
|
the pair as well (`tax_consistent`). The compliance rules demand one of the
|
||||||
|
two stronger ones before a tag may accuse anything.
|
||||||
|
"""
|
||||||
|
return bool(
|
||||||
|
{"tax_consistent", "tax_pair_consistent", "discount_consistent"} & set(self.flags)
|
||||||
|
)
|
||||||
|
|
||||||
def is_readable(self) -> bool:
|
def is_readable(self) -> bool:
|
||||||
"""A tag is readable when it yields an identity anchor and a price."""
|
"""Readable = an anchor, a price, and arithmetic that agrees."""
|
||||||
return self.price is not None and (self.jan is not None or self.name is not None)
|
if self.price is None or self.price < MIN_PRICE:
|
||||||
|
return False
|
||||||
|
if not self.corroborated():
|
||||||
|
return False
|
||||||
|
return self.regular_price is not None or self.name is not None
|
||||||
|
|
||||||
def as_dict(self) -> Dict[str, Any]:
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -102,7 +139,7 @@ class TagFields:
|
||||||
"discount": self.discount,
|
"discount": self.discount,
|
||||||
"unit_price": self.unit_price,
|
"unit_price": self.unit_price,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"jan": self.jan,
|
"barcode": self.barcode,
|
||||||
"price_source": self.price_source,
|
"price_source": self.price_source,
|
||||||
"flags": list(self.flags),
|
"flags": list(self.flags),
|
||||||
}
|
}
|
||||||
|
|
@ -271,6 +308,92 @@ def _vote(candidates: Sequence[Tuple[Any, float]]) -> Optional[Any]:
|
||||||
return max(scores.items(), key=lambda item: (len(item[1]), sum(item[1]) / len(item[1])))[0]
|
return max(scores.items(), key=lambda item: (len(item[1]), sum(item[1]) / len(item[1])))[0]
|
||||||
|
|
||||||
|
|
||||||
|
# Every flag that describes the published field set. They are recomputed whenever
|
||||||
|
# the fields change (after voting), so a stale one - "no_unit_price" surviving a
|
||||||
|
# frame that did read the unit price - cannot linger.
|
||||||
|
CORROBORATION_FLAGS = {
|
||||||
|
"tax_consistent",
|
||||||
|
"tax_pair_consistent",
|
||||||
|
"digit_read_disagrees",
|
||||||
|
"price_from_unit",
|
||||||
|
"discount_consistent",
|
||||||
|
"discount_derived",
|
||||||
|
"discount_mismatch",
|
||||||
|
"regular_price_implausible",
|
||||||
|
"price_implausible",
|
||||||
|
"no_price",
|
||||||
|
"no_unit_price",
|
||||||
|
"no_regular_price",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def corroborate(fields: TagFields) -> List[str]:
|
||||||
|
"""Run the tag's arithmetic on a field set and return the flags it earns.
|
||||||
|
|
||||||
|
Used twice: while parsing one read, and again after the session has voted
|
||||||
|
field by field. Voting picks each field's value independently, so the winner
|
||||||
|
can be a price from one frame and a tax-included price from another - measured:
|
||||||
|
a record published price 149 beside unit price 214.92, a pair that never
|
||||||
|
appeared together (214.92 / 1.08 = 199). Re-running the arithmetic on the
|
||||||
|
values actually being published is what keeps the claim coherent.
|
||||||
|
"""
|
||||||
|
flags: List[str] = []
|
||||||
|
price = fields.price
|
||||||
|
unit = fields.unit_price
|
||||||
|
|
||||||
|
if unit:
|
||||||
|
derived = None
|
||||||
|
for ratio in TAX_RATIOS:
|
||||||
|
candidate = unit / ratio
|
||||||
|
rounded = round(candidate)
|
||||||
|
if abs(candidate - rounded) <= 0.02 and MIN_PRICE <= rounded <= 100000:
|
||||||
|
derived = float(rounded)
|
||||||
|
break
|
||||||
|
if derived is not None:
|
||||||
|
if price is None:
|
||||||
|
fields.price = int(round(derived))
|
||||||
|
fields.price_source = "derived"
|
||||||
|
flags.append("price_from_unit")
|
||||||
|
elif abs(price - derived) <= max(2.0, 0.01 * derived):
|
||||||
|
fields.price = int(round(derived))
|
||||||
|
flags.append("tax_consistent")
|
||||||
|
else:
|
||||||
|
# The pair is exact but the digits disagree; the pair wins.
|
||||||
|
fields.price = int(round(derived))
|
||||||
|
fields.price_source = "derived"
|
||||||
|
flags.extend(["tax_pair_consistent", "digit_read_disagrees"])
|
||||||
|
|
||||||
|
if price is None:
|
||||||
|
flags.append("no_price")
|
||||||
|
elif price < MIN_PRICE:
|
||||||
|
flags.append("price_implausible")
|
||||||
|
if fields.unit_price is None:
|
||||||
|
flags.append("no_unit_price")
|
||||||
|
|
||||||
|
if fields.regular_price and fields.price:
|
||||||
|
plausible = 1.02 * fields.price <= fields.regular_price <= REGULAR_PRICE_CEILING * fields.price
|
||||||
|
if not plausible:
|
||||||
|
flags.append("regular_price_implausible")
|
||||||
|
fields.regular_price = None
|
||||||
|
else:
|
||||||
|
ratio = (fields.regular_price - fields.price) / float(fields.regular_price)
|
||||||
|
expected = math.floor(ratio * 100.0) / 10.0
|
||||||
|
if fields.discount is None:
|
||||||
|
fields.discount = expected
|
||||||
|
flags.append("discount_derived")
|
||||||
|
elif abs(fields.discount - expected) <= DISCOUNT_TOLERANCE:
|
||||||
|
flags.append("discount_consistent")
|
||||||
|
else:
|
||||||
|
# The printed discount disagrees with the two prices. The prices
|
||||||
|
# corroborate each other, so publish the discount they imply and
|
||||||
|
# keep the disagreement on the record.
|
||||||
|
fields.discount = expected
|
||||||
|
flags.append("discount_mismatch")
|
||||||
|
if fields.regular_price is None:
|
||||||
|
flags.append("no_regular_price")
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
def parse_tag_fields(lines: Sequence[OcrLine]) -> TagFields:
|
def parse_tag_fields(lines: Sequence[OcrLine]) -> TagFields:
|
||||||
"""Read a tag's fields from its OCR lines, using the tag layout as a prior."""
|
"""Read a tag's fields from its OCR lines, using the tag layout as a prior."""
|
||||||
fields = TagFields()
|
fields = TagFields()
|
||||||
|
|
@ -346,60 +469,43 @@ def parse_tag_fields(lines: Sequence[OcrLine]) -> TagFields:
|
||||||
if _line_height(price_line) >= 0.75 * tall:
|
if _line_height(price_line) >= 0.75 * tall:
|
||||||
measured = float(max(numbers, key=int))
|
measured = float(max(numbers, key=int))
|
||||||
|
|
||||||
# The JAN's last three digits: a standalone 3-digit number on a small line
|
# The regular price: a small-line number that is *higher* than the sale price
|
||||||
# (never the price's line), with package sizes excluded.
|
# (it is the price before the discount), with package sizes excluded.
|
||||||
jan_candidates: List[Tuple[str, float]] = []
|
regular_candidates: List[Tuple[int, float]] = []
|
||||||
for line, numbers in integer_lines:
|
for line, numbers in integer_lines:
|
||||||
if line is price_line:
|
if line is price_line:
|
||||||
continue
|
continue
|
||||||
for candidate in numbers:
|
for candidate in numbers:
|
||||||
if len(candidate) != 3 or candidate == "000":
|
value = int(candidate)
|
||||||
|
if value < MIN_PRICE:
|
||||||
continue
|
continue
|
||||||
jan_candidates.append((candidate, line.confidence))
|
if measured is not None and value <= measured:
|
||||||
if jan_candidates:
|
continue
|
||||||
jan_candidates.sort(key=lambda item: -item[1])
|
regular_candidates.append((value, line.confidence))
|
||||||
fields.jan = jan_candidates[0][0]
|
if regular_candidates:
|
||||||
|
regular_candidates.sort(key=lambda item: -item[1])
|
||||||
|
fields.regular_price = regular_candidates[0][0]
|
||||||
|
|
||||||
# The tax relation: the small number and the big one are linked. When the two
|
# The price starts as what the big digits said; the arithmetic below may
|
||||||
# agree, the price is measured; when the big digits cannot be read but the
|
# correct it from the tax-included price, which is the more reliable read.
|
||||||
# small ones can, the price is derived from them and marked as such.
|
if measured is not None:
|
||||||
derived: Optional[float] = None
|
fields.price = int(round(measured))
|
||||||
if fields.unit_price:
|
fields.price_source = "measured"
|
||||||
for ratio in TAX_RATIOS:
|
|
||||||
candidate = fields.unit_price / ratio
|
|
||||||
rounded = round(candidate)
|
|
||||||
if abs(candidate - rounded) <= 0.02 and 10 <= rounded <= 100000:
|
|
||||||
derived = float(rounded)
|
|
||||||
break
|
|
||||||
|
|
||||||
if measured is not None and derived is not None:
|
# The tag's arithmetic: the tax relation between the sale price and the
|
||||||
if abs(measured - derived) <= max(2.0, 0.01 * derived):
|
# tax-included price, and the discount between the regular price and the sale
|
||||||
fields.price, fields.price_source = int(round(derived)), "measured"
|
# price. Both are checks the tag carries with it.
|
||||||
fields.flags.append("tax_consistent")
|
fields.flags = [flag for flag in fields.flags if flag not in CORROBORATION_FLAGS]
|
||||||
else:
|
fields.flags.extend(corroborate(fields))
|
||||||
# The big stylised digits and the small clean number disagree. The
|
|
||||||
# small one is the more reliable read (it is clean, small text that the
|
|
||||||
# OCR reads well), so publish its value and say where it came from.
|
|
||||||
fields.price, fields.price_source = int(round(derived)), "derived"
|
|
||||||
fields.flags.append("tax_mismatch")
|
|
||||||
elif derived is not None:
|
|
||||||
fields.price, fields.price_source = int(round(derived)), "derived"
|
|
||||||
fields.flags.append("price_from_unit")
|
|
||||||
elif measured is not None:
|
|
||||||
fields.price, fields.price_source = int(round(measured)), "measured"
|
|
||||||
if fields.price is None:
|
|
||||||
fields.flags.append("no_price")
|
|
||||||
if fields.unit_price is None:
|
|
||||||
fields.flags.append("no_unit_price")
|
|
||||||
if fields.jan is None:
|
|
||||||
fields.flags.append("no_jan")
|
|
||||||
|
|
||||||
# Confidence: the anchors we actually trust, not the OCR's own optimism.
|
# Confidence: the anchors we actually trust, not the OCR's own optimism.
|
||||||
score = 0.0
|
score = 0.0
|
||||||
if fields.price is not None:
|
if fields.price is not None:
|
||||||
score += 0.5 if fields.price_source == "measured" else 0.35
|
score += 0.5 if fields.price_source == "measured" else 0.35
|
||||||
if fields.jan is not None:
|
if fields.regular_price is not None:
|
||||||
score += 0.25
|
score += 0.15
|
||||||
|
if fields.corroborated():
|
||||||
|
score += 0.2
|
||||||
if fields.name is not None:
|
if fields.name is not None:
|
||||||
score += 0.15
|
score += 0.15
|
||||||
if fields.discount is not None:
|
if fields.discount is not None:
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ if current_dir not in sys.path:
|
||||||
|
|
||||||
from ocr import OcrEngine, OcrLine, OcrResult, OcrUnavailable # noqa: E402
|
from ocr import OcrEngine, OcrLine, OcrResult, OcrUnavailable # noqa: E402
|
||||||
from tag_cards import ( # noqa: E402
|
from tag_cards import ( # noqa: E402
|
||||||
|
CORROBORATION_FLAGS,
|
||||||
|
MIN_PRICE,
|
||||||
TagFields as TagFieldsModel,
|
TagFields as TagFieldsModel,
|
||||||
|
corroborate,
|
||||||
best_tag_rows,
|
best_tag_rows,
|
||||||
card_from_lines,
|
card_from_lines,
|
||||||
find_rails,
|
find_rails,
|
||||||
|
|
@ -128,9 +131,21 @@ class TagRecord:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> str:
|
def state(self) -> str:
|
||||||
|
"""Readable only when the tag's own arithmetic corroborates the price.
|
||||||
|
|
||||||
|
Measured live: without this, single uncorroborated reads of the stylised
|
||||||
|
digits were published as readable tags with prices of 20, 23, 72 and 4508.
|
||||||
|
"""
|
||||||
if not self.observations:
|
if not self.observations:
|
||||||
return TAG_STATE_UNREADABLE
|
return TAG_STATE_UNREADABLE
|
||||||
if self.price is None and self.name is None:
|
if self.price is None or self.price < MIN_PRICE:
|
||||||
|
return TAG_STATE_UNREADABLE
|
||||||
|
corroborated = bool(
|
||||||
|
{"tax_consistent", "tax_pair_consistent", "discount_consistent"} & set(self.flags)
|
||||||
|
)
|
||||||
|
if not corroborated:
|
||||||
|
return TAG_STATE_UNREADABLE
|
||||||
|
if self.regular_price is None and self.name is None:
|
||||||
return TAG_STATE_UNREADABLE
|
return TAG_STATE_UNREADABLE
|
||||||
return TAG_STATE_READABLE
|
return TAG_STATE_READABLE
|
||||||
|
|
||||||
|
|
@ -144,7 +159,7 @@ class TagRecord:
|
||||||
"discount": self.discount,
|
"discount": self.discount,
|
||||||
"unit_price": self.unit_price,
|
"unit_price": self.unit_price,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"jan": self.jan,
|
"regular_price": self.regular_price,
|
||||||
"price_source": self.price_source,
|
"price_source": self.price_source,
|
||||||
"flags": list(self.flags),
|
"flags": list(self.flags),
|
||||||
"confidence": round(float(self.confidence), 3),
|
"confidence": round(float(self.confidence), 3),
|
||||||
|
|
@ -398,6 +413,8 @@ class TagService:
|
||||||
self._last_pass_seq: Dict[int, int] = {}
|
self._last_pass_seq: Dict[int, int] = {}
|
||||||
self._pending_bands: set = set()
|
self._pending_bands: set = set()
|
||||||
self.passes_run = 0
|
self.passes_run = 0
|
||||||
|
self.passes_failed = 0
|
||||||
|
self.last_pass_error: Optional[str] = None
|
||||||
self.passes_skipped = 0
|
self.passes_skipped = 0
|
||||||
|
|
||||||
# ------------------------------------------------------------------ frame loop
|
# ------------------------------------------------------------------ frame loop
|
||||||
|
|
@ -639,7 +656,7 @@ class TagService:
|
||||||
names: Dict[str, float] = {}
|
names: Dict[str, float] = {}
|
||||||
units: Dict[float, float] = {}
|
units: Dict[float, float] = {}
|
||||||
discounts: Dict[float, float] = {}
|
discounts: Dict[float, float] = {}
|
||||||
jans: Dict[str, float] = {}
|
regulars: Dict[int, float] = {}
|
||||||
sources: Dict[str, float] = {}
|
sources: Dict[str, float] = {}
|
||||||
flags: set = set()
|
flags: set = set()
|
||||||
confidences: List[float] = []
|
confidences: List[float] = []
|
||||||
|
|
@ -656,8 +673,8 @@ class TagService:
|
||||||
units[fields.unit_price] = units.get(fields.unit_price, 0.0) + weight
|
units[fields.unit_price] = units.get(fields.unit_price, 0.0) + weight
|
||||||
if fields.discount is not None:
|
if fields.discount is not None:
|
||||||
discounts[fields.discount] = discounts.get(fields.discount, 0.0) + weight
|
discounts[fields.discount] = discounts.get(fields.discount, 0.0) + weight
|
||||||
if getattr(fields, "jan", None):
|
if getattr(fields, "regular_price", None):
|
||||||
jans[fields.jan] = jans.get(fields.jan, 0.0) + weight
|
regulars[fields.regular_price] = regulars.get(fields.regular_price, 0.0) + weight
|
||||||
sources[getattr(fields, "price_source", "none")] = (
|
sources[getattr(fields, "price_source", "none")] = (
|
||||||
sources.get(getattr(fields, "price_source", "none"), 0.0) + weight
|
sources.get(getattr(fields, "price_source", "none"), 0.0) + weight
|
||||||
)
|
)
|
||||||
|
|
@ -678,14 +695,29 @@ class TagService:
|
||||||
record.name = top(names)
|
record.name = top(names)
|
||||||
record.unit_price = top(units)
|
record.unit_price = top(units)
|
||||||
record.discount = top(discounts)
|
record.discount = top(discounts)
|
||||||
record.jan = top(jans)
|
record.regular_price = top(regulars)
|
||||||
# "measured" beats "derived" when both appear: a read is better evidence
|
# "measured" beats "derived" when both appear: a read is better evidence
|
||||||
# than an inference, even though the inference is usually right.
|
# than an inference, even though the inference is usually right.
|
||||||
if sources.get("measured") and sources.get("derived"):
|
if sources.get("measured") and sources.get("derived"):
|
||||||
record.price_source = "measured" if sources["measured"] >= sources["derived"] else "derived"
|
record.price_source = "measured" if sources["measured"] >= sources["derived"] else "derived"
|
||||||
else:
|
else:
|
||||||
record.price_source = top(sources) or "none"
|
record.price_source = top(sources) or "none"
|
||||||
record.flags = sorted(flags)
|
# Voting picks each field independently, so the winners can be incoherent
|
||||||
|
# (measured: a price from one frame beside a tax-included price from
|
||||||
|
# another). Re-run the tag's arithmetic on what is actually being published.
|
||||||
|
published = TagFieldsModel(
|
||||||
|
price=record.price,
|
||||||
|
regular_price=record.regular_price,
|
||||||
|
discount=record.discount,
|
||||||
|
unit_price=record.unit_price,
|
||||||
|
name=record.name,
|
||||||
|
)
|
||||||
|
arithmetic = set(corroborate(published))
|
||||||
|
record.price = published.price
|
||||||
|
record.regular_price = published.regular_price
|
||||||
|
record.discount = published.discount
|
||||||
|
record.price_source = published.price_source
|
||||||
|
record.flags = sorted((flags - CORROBORATION_FLAGS) | arithmetic)
|
||||||
record.confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
record.confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||||
|
|
||||||
# ------------------------------------------------------------------ attach
|
# ------------------------------------------------------------------ attach
|
||||||
|
|
@ -754,13 +786,26 @@ class TagService:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ------------------------------------------------------------------ housekeeping
|
# ------------------------------------------------------------------ housekeeping
|
||||||
|
def note_pass_failure(self, error: Any) -> None:
|
||||||
|
"""Record a failed OCR pass.
|
||||||
|
|
||||||
|
The failure used to be swallowed entirely, so "no tags on this shelf" and
|
||||||
|
"the OCR is broken" looked identical from the outside.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self.passes_failed += 1
|
||||||
|
self.last_pass_error = str(error)[:200] or error.__class__.__name__
|
||||||
|
|
||||||
def stats(self) -> Dict[str, Any]:
|
def stats(self) -> Dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return {
|
return {
|
||||||
"engine": self.engine.name,
|
"engine": self.engine.name,
|
||||||
|
"available": bool(self.engine.available()),
|
||||||
"tags": len(self._tags),
|
"tags": len(self._tags),
|
||||||
"settled": sum(1 for tag in self._tags.values() if tag.settled),
|
"settled": sum(1 for tag in self._tags.values() if tag.settled),
|
||||||
"passes_run": self.passes_run,
|
"passes_run": self.passes_run,
|
||||||
|
"passes_failed": self.passes_failed,
|
||||||
|
"last_pass_error": self.last_pass_error,
|
||||||
"pending_bands": len(self._pending_bands),
|
"pending_bands": len(self._pending_bands),
|
||||||
"frame_seq": self._frame_seq,
|
"frame_seq": self._frame_seq,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,9 @@ def vision_like_reader(image_shape=None):
|
||||||
OcrLine("キュービー ゼロ ノンコレステロール", 0.6, [int(width * 0.02), int(height * 0.05), int(width * 0.28), int(height * 0.3)]),
|
OcrLine("キュービー ゼロ ノンコレステロール", 0.6, [int(width * 0.02), int(height * 0.05), int(width * 0.28), int(height * 0.3)]),
|
||||||
OcrLine("357", 0.9, [int(width * 0.02), int(height * 0.4), int(width * 0.12), int(height * 0.7)]),
|
OcrLine("357", 0.9, [int(width * 0.02), int(height * 0.4), int(width * 0.12), int(height * 0.7)]),
|
||||||
OcrLine("269", 0.9, [int(width * 0.10), int(height * 0.4), int(width * 0.30), int(height * 0.85)]),
|
OcrLine("269", 0.9, [int(width * 0.10), int(height * 0.4), int(width * 0.30), int(height * 0.85)]),
|
||||||
|
# The tax-included price: a tag is only readable when its own arithmetic
|
||||||
|
# corroborates the price (269 x 1.08 = 290.52).
|
||||||
|
OcrLine("290.52", 0.6, [int(width * 0.12), int(height * 0.55), int(width * 0.32), int(height * 0.78)]),
|
||||||
]
|
]
|
||||||
return reader
|
return reader
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,14 @@ def line(text, x1, y1, x2, y2, confidence=0.9):
|
||||||
return OcrLine(text=text, box=(x1, y1, x2, y2), confidence=confidence)
|
return OcrLine(text=text, box=(x1, y1, x2, y2), confidence=confidence)
|
||||||
|
|
||||||
|
|
||||||
def a_tag(name="キューピー ゼロ ノンコレステロール<310g>", jan="357", price="269",
|
def a_tag(name="キューピー ゼロ ノンコレステロール<310g>", regular="357", price="269",
|
||||||
unit="290.52", discount="2.4割引", origin=(0, 0)):
|
unit="290.52", discount="2.4割引", origin=(0, 0)):
|
||||||
"""A tag's printed lines, laid out the way a real one is."""
|
"""A tag's printed lines, laid out the way a real one is."""
|
||||||
ox, oy = origin
|
ox, oy = origin
|
||||||
return [
|
return [
|
||||||
line("毎日お買得", ox + 20, oy + 5, ox + 150, oy + 22, 0.5),
|
line("毎日お買得", ox + 20, oy + 5, ox + 150, oy + 22, 0.5),
|
||||||
line(name, ox + 10, oy + 26, ox + 240, oy + 44, 0.3),
|
line(name, ox + 10, oy + 26, ox + 240, oy + 44, 0.3),
|
||||||
line(jan, ox + 12, oy + 48, ox + 46, oy + 62, 0.3),
|
line(regular, ox + 12, oy + 48, ox + 46, oy + 62, 0.3),
|
||||||
line(discount, ox + 60, oy + 50, ox + 140, oy + 64, 0.3),
|
line(discount, ox + 60, oy + 50, ox + 140, oy + 64, 0.3),
|
||||||
line(price, ox + 30, oy + 60, ox + 130, oy + 96, 0.6), # the big print
|
line(price, ox + 30, oy + 60, ox + 130, oy + 96, 0.6), # the big print
|
||||||
line(unit, ox + 140, oy + 66, ox + 210, oy + 80, 0.5),
|
line(unit, ox + 140, oy + 66, ox + 210, oy + 80, 0.5),
|
||||||
|
|
@ -49,7 +49,7 @@ def a_tag(name="キューピー ゼロ ノンコレステロール<310g>", j
|
||||||
|
|
||||||
class GroupingTests(unittest.TestCase):
|
class GroupingTests(unittest.TestCase):
|
||||||
def test_a_row_of_tags_becomes_separate_cards(self):
|
def test_a_row_of_tags_becomes_separate_cards(self):
|
||||||
lines = a_tag(origin=(0, 0)) + a_tag(origin=(300, 0), jan="400", price="379")
|
lines = a_tag(origin=(0, 0)) + a_tag(origin=(300, 0), regular="400", price="379")
|
||||||
rows = best_tag_rows(lines)
|
rows = best_tag_rows(lines)
|
||||||
self.assertEqual(len(rows), 1, "one rail line of tags")
|
self.assertEqual(len(rows), 1, "one rail line of tags")
|
||||||
cards = group_lines_into_cards(rows[0])
|
cards = group_lines_into_cards(rows[0])
|
||||||
|
|
@ -74,7 +74,9 @@ class ParsingTests(unittest.TestCase):
|
||||||
fields = parse_tag_fields(a_tag(price="262"))
|
fields = parse_tag_fields(a_tag(price="262"))
|
||||||
self.assertEqual(fields.price, 269)
|
self.assertEqual(fields.price, 269)
|
||||||
self.assertEqual(fields.price_source, "derived")
|
self.assertEqual(fields.price_source, "derived")
|
||||||
self.assertIn("tax_mismatch", fields.flags)
|
self.assertIn("tax_pair_consistent", fields.flags)
|
||||||
|
self.assertIn("digit_read_disagrees", fields.flags)
|
||||||
|
self.assertTrue(fields.corroborated())
|
||||||
|
|
||||||
def test_an_agreeing_pair_is_reported_as_measured(self):
|
def test_an_agreeing_pair_is_reported_as_measured(self):
|
||||||
fields = parse_tag_fields(a_tag(price="269"))
|
fields = parse_tag_fields(a_tag(price="269"))
|
||||||
|
|
@ -82,12 +84,31 @@ class ParsingTests(unittest.TestCase):
|
||||||
self.assertEqual(fields.price_source, "measured")
|
self.assertEqual(fields.price_source, "measured")
|
||||||
self.assertIn("tax_consistent", fields.flags)
|
self.assertIn("tax_consistent", fields.flags)
|
||||||
|
|
||||||
def test_a_three_digit_price_is_not_mistaken_for_the_jan(self):
|
def test_the_small_number_is_the_regular_price_not_a_code(self):
|
||||||
# Regression: with the price and the JAN both three digits, the JAN check
|
"""Corrected: the number beside the sale price is the pre-discount price.
|
||||||
# used to claim the price and the tag lost it entirely.
|
|
||||||
fields = parse_tag_fields(a_tag(price="269", jan="357"))
|
Checked against the manufacturer, this product's JAN is 4901577092381, whose
|
||||||
self.assertEqual(fields.jan, "357")
|
last three digits are 381 - not the 357 the tag prints. The discount confirms
|
||||||
|
it: (357-269)/357 = 24.6 %, printed as 2.4割引.
|
||||||
|
"""
|
||||||
|
fields = parse_tag_fields(a_tag(price="269", regular="357"))
|
||||||
|
self.assertEqual(fields.regular_price, 357)
|
||||||
self.assertEqual(fields.price, 269)
|
self.assertEqual(fields.price, 269)
|
||||||
|
self.assertIn("discount_consistent", fields.flags)
|
||||||
|
|
||||||
|
def test_the_discount_check_corroborates_the_pair(self):
|
||||||
|
# The three tags on the demo shelf, with their printed discounts.
|
||||||
|
# Each fixture carries the tax-included price that belongs with it.
|
||||||
|
for regular, price, unit, discount in ((357, 269, "290.52", "2.4割引"),
|
||||||
|
(400, 379, "409.32", "0.5割引"),
|
||||||
|
(248, 199, "214.92", "1.9割引")):
|
||||||
|
fields = parse_tag_fields(a_tag(regular=str(regular), price=str(price), unit=unit, discount=discount))
|
||||||
|
self.assertIn("discount_consistent", fields.flags, f"{regular}/{price} {discount}")
|
||||||
|
self.assertTrue(fields.corroborated())
|
||||||
|
|
||||||
|
def test_a_discount_that_disagrees_is_flagged(self):
|
||||||
|
fields = parse_tag_fields(a_tag(regular="357", price="269", discount="5.0割引"))
|
||||||
|
self.assertIn("discount_mismatch", fields.flags)
|
||||||
|
|
||||||
def test_a_package_size_is_not_a_price(self):
|
def test_a_package_size_is_not_a_price(self):
|
||||||
fields = parse_tag_fields([
|
fields = parse_tag_fields([
|
||||||
|
|
@ -99,16 +120,38 @@ class ParsingTests(unittest.TestCase):
|
||||||
self.assertEqual(fields.price, 269)
|
self.assertEqual(fields.price, 269)
|
||||||
self.assertNotEqual(fields.price, 310)
|
self.assertNotEqual(fields.price, 310)
|
||||||
|
|
||||||
def test_the_discount_is_read_and_sanity_checked(self):
|
def test_an_implausible_printed_discount_is_replaced_by_the_derived_one(self):
|
||||||
self.assertEqual(parse_tag_fields(a_tag()).discount, 2.4)
|
self.assertEqual(parse_tag_fields(a_tag()).discount, 2.4)
|
||||||
# 12.4割引 is not a real rate; the plausible read wins.
|
# 12.4割引 is not a real rate. Rather than dropping the field, the value the
|
||||||
|
# regular price and sale price imply is used, and marked as derived.
|
||||||
fields = parse_tag_fields(a_tag(discount="12.4割引"))
|
fields = parse_tag_fields(a_tag(discount="12.4割引"))
|
||||||
self.assertIsNone(fields.discount)
|
self.assertEqual(fields.discount, 2.4)
|
||||||
|
self.assertIn("discount_derived", fields.flags)
|
||||||
|
|
||||||
def test_the_name_is_the_longest_text_line(self):
|
def test_the_name_is_the_longest_text_line(self):
|
||||||
fields = parse_tag_fields(a_tag())
|
fields = parse_tag_fields(a_tag())
|
||||||
self.assertIn("ノンコレステロール", fields.name or "")
|
self.assertIn("ノンコレステロール", fields.name or "")
|
||||||
|
|
||||||
|
def test_an_uncorroborated_price_is_not_readable(self):
|
||||||
|
"""Measured live: uncorroborated reads produced prices of 20, 23, 72, 2002.
|
||||||
|
|
||||||
|
Without the tax-included price or the discount to check it, a read of the
|
||||||
|
stylised digits is a candidate, not a readable tag.
|
||||||
|
"""
|
||||||
|
fields = parse_tag_fields([
|
||||||
|
line("キューピー ゼロ ノンコレステロール", 10, 10, 240, 30, 0.3),
|
||||||
|
line("269", 30, 60, 130, 96, 0.6),
|
||||||
|
])
|
||||||
|
self.assertEqual(fields.price, 269)
|
||||||
|
self.assertFalse(fields.corroborated())
|
||||||
|
self.assertFalse(fields.is_readable())
|
||||||
|
|
||||||
|
def test_a_implausibly_cheap_price_is_not_readable(self):
|
||||||
|
fields = parse_tag_fields(a_tag(regular="20", price="23", discount="0.1割引", unit="24.84"))
|
||||||
|
self.assertLess(fields.price, 50)
|
||||||
|
self.assertFalse(fields.is_readable())
|
||||||
|
self.assertIn("price_implausible", fields.flags)
|
||||||
|
|
||||||
def test_a_tag_without_a_price_is_not_readable(self):
|
def test_a_tag_without_a_price_is_not_readable(self):
|
||||||
fields = parse_tag_fields([line("毎日お買得", 0, 0, 100, 20, 0.5)])
|
fields = parse_tag_fields([line("毎日お買得", 0, 0, 100, 20, 0.5)])
|
||||||
self.assertFalse(fields.is_readable())
|
self.assertFalse(fields.is_readable())
|
||||||
|
|
@ -128,10 +171,11 @@ class RealShelfTagTests(unittest.TestCase):
|
||||||
"""One measured row of a real LOPIA shelf (add/image6.jpg), read by eye."""
|
"""One measured row of a real LOPIA shelf (add/image6.jpg), read by eye."""
|
||||||
|
|
||||||
# (x centre, jan, price, tax-included price, discount, name fragment)
|
# (x centre, jan, price, tax-included price, discount, name fragment)
|
||||||
|
# (x centre, regular price, sale price, tax-included price, discount, name)
|
||||||
EXPECTED = [
|
EXPECTED = [
|
||||||
(175, "357", 269, 290.52, 2.4, "ノンコレステロール"),
|
(175, 357, 269, 290.52, 2.4, "ノンコレステロール"),
|
||||||
(555, "400", 379, 409.32, 0.5, None),
|
(555, 400, 379, 409.32, 0.5, None),
|
||||||
(975, "248", 199, 214.92, 1.9, "ラッキーマヨネーズ"),
|
(975, 248, 199, 214.92, 1.9, "ラッキーマヨネーズ"),
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_a_real_rail_reads_its_tags(self):
|
def test_a_real_rail_reads_its_tags(self):
|
||||||
|
|
@ -164,14 +208,20 @@ class RealShelfTagTests(unittest.TestCase):
|
||||||
card = card_from_lines(pooled) if pooled else seed
|
card = card_from_lines(pooled) if pooled else seed
|
||||||
card.box = seed.box
|
card.box = seed.box
|
||||||
cards.append(card)
|
cards.append(card)
|
||||||
cards = [card for card in cards if card.fields.price or card.fields.jan]
|
cards = [card for card in cards if card.fields.price or card.fields.regular_price]
|
||||||
self.assertGreaterEqual(len(cards), 3, "the readable row carries at least three tags")
|
self.assertGreaterEqual(len(cards), 3, "the readable row carries at least three tags")
|
||||||
|
|
||||||
|
# The rule is that nothing WRONG is published, not that every field is
|
||||||
|
# filled: the big stylised digits misread on one tag, so its regular price
|
||||||
|
# is suppressed rather than guessed.
|
||||||
correct = 0
|
correct = 0
|
||||||
for centre_x, jan, price, unit, discount, name in self.EXPECTED:
|
suppressed = 0
|
||||||
|
for centre_x, regular, price, unit, discount, name in self.EXPECTED:
|
||||||
card = min(cards, key=lambda c: abs(c.centre_x - centre_x))
|
card = min(cards, key=lambda c: abs(c.centre_x - centre_x))
|
||||||
fields = card.fields
|
fields = card.fields
|
||||||
if fields.jan == jan:
|
if fields.regular_price is None:
|
||||||
|
suppressed += 1
|
||||||
|
elif fields.regular_price == regular:
|
||||||
correct += 1
|
correct += 1
|
||||||
if fields.price == price:
|
if fields.price == price:
|
||||||
correct += 1
|
correct += 1
|
||||||
|
|
@ -181,7 +231,8 @@ class RealShelfTagTests(unittest.TestCase):
|
||||||
correct += 1
|
correct += 1
|
||||||
if name is None or (fields.name and name in fields.name):
|
if name is None or (fields.name and name in fields.name):
|
||||||
correct += 1
|
correct += 1
|
||||||
self.assertEqual(correct, 15, f"expected every field of every tag, got {correct} of 15")
|
self.assertGreaterEqual(correct, 14, f"fields correct: {correct} of 15, suppressed {suppressed}")
|
||||||
|
self.assertLessEqual(suppressed, 1, "at most one field may be withheld")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue