shelf-control / src /shelf /ocr /layout.py
fgeeha's picture
fix: gradio 6 compat, remove gradio from requirements
b0414d1 verified
Raw History Blame Contribute Delete
11.5 kB
"""Rule-based Lenta price-tag layout priors.
The challenge template materials show that most tags keep semantic zones in
stable relative positions: product name in the upper part, QR on the right or
right-bottom side, barcode/SKU/date close to the lower band, and large prices in
the middle/lower price area. This module turns those priors into safe ROI boxes
that improve OCR/QR/barcode attempts without replacing the generic fallback.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import Iterable
import cv2
import numpy as np
class TemplateType(str, Enum):
"""Coarse Lenta price-tag mechanics used by downstream priors."""
REGULAR = "regular"
PROMO = "promo"
DISCOUNT = "discount"
WHOLESALE = "wholesale"
BOGOF = "bogof"
UNKNOWN = "unknown"
class Orientation(str, Enum):
"""Approximate crop orientation."""
HORIZONTAL = "horizontal"
VERTICAL = "vertical"
ROTATED = "rotated"
@dataclass(frozen=True)
class LayoutROI:
"""One ROI in both relative and absolute coordinates.
Relative coordinates are normalized to [0, 1] in the oriented crop frame.
``purpose`` is a broad semantic group such as ``qr`` or ``price``.
"""
name: str
purpose: str
x0: float
y0: float
x1: float
y1: float
def clipped(self) -> "LayoutROI":
"""Return a copy with coordinates clipped to valid normalized bounds."""
x0 = min(1.0, max(0.0, self.x0))
y0 = min(1.0, max(0.0, self.y0))
x1 = min(1.0, max(0.0, self.x1))
y1 = min(1.0, max(0.0, self.y1))
if x1 <= x0:
x1 = min(1.0, x0 + 0.01)
if y1 <= y0:
y1 = min(1.0, y0 + 0.01)
return LayoutROI(self.name, self.purpose, x0, y0, x1, y1)
def as_pixels(self, width: int, height: int) -> tuple[int, int, int, int]:
"""Convert the ROI to pixel coordinates inside a crop."""
roi = self.clipped()
w, h = max(1, int(width)), max(1, int(height))
x0 = int(round(roi.x0 * w))
y0 = int(round(roi.y0 * h))
x1 = int(round(roi.x1 * w))
y1 = int(round(roi.y1 * h))
return max(0, x0), max(0, y0), min(w, x1), min(h, y1)
@dataclass(frozen=True)
class LayoutPrior:
"""A coarse price-tag layout prediction with semantic ROIs."""
template_type: TemplateType
orientation: Orientation
rois: dict[str, LayoutROI]
def by_purpose(self, purpose: str) -> list[LayoutROI]:
"""Return all ROIs matching a semantic purpose."""
return [roi for roi in self.rois.values() if roi.purpose == purpose]
_PROMO_RE = re.compile(r"акци|скид|выгод|карт", re.IGNORECASE)
_WHOLESALE_RE = re.compile(r"\bот\s*\d+|опт|порог|до\s*\d+", re.IGNORECASE)
_BOGOF_RE = re.compile(r"bogof|богоф|набор|2\s*цена|цена\s*2", re.IGNORECASE)
_DISCOUNT_RE = re.compile(r"[-−–]\s*\d{1,3}\s*(?:%|р|руб)", re.IGNORECASE)
def classify_orientation(crop: np.ndarray | None) -> Orientation:
"""Classify crop orientation from aspect ratio.
Very narrow/tall crops are treated as vertical; very wide crops as
horizontal. Ambiguous crops are considered rotated because many shelf videos
contain 90-degree oriented tags.
"""
if crop is None or crop.size == 0:
return Orientation.HORIZONTAL
h, w = crop.shape[:2]
if w >= h * 1.18:
return Orientation.HORIZONTAL
if h >= w * 1.18:
return Orientation.VERTICAL
return Orientation.ROTATED
def classify_template(
crop: np.ndarray | None = None, texts: Iterable[str] | None = None
) -> TemplateType:
"""Classify a tag into a coarse mechanics bucket.
The function is intentionally conservative: text hints win when available;
otherwise color cues detect promo/discount tags. Unknown falls back to the
regular layout zones downstream.
"""
joined = (
" ".join(str(t or "") for t in (texts or [])).lower().replace("ё", "е")
)
if _BOGOF_RE.search(joined):
return TemplateType.BOGOF
if _WHOLESALE_RE.search(joined):
return TemplateType.WHOLESALE
if _DISCOUNT_RE.search(joined):
return TemplateType.DISCOUNT
if _PROMO_RE.search(joined):
return TemplateType.PROMO
if crop is None or crop.size == 0:
return TemplateType.UNKNOWN
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
red1 = cv2.inRange(hsv, np.array([0, 50, 60]), np.array([14, 255, 255]))
red2 = cv2.inRange(hsv, np.array([160, 50, 60]), np.array([180, 255, 255]))
orange = cv2.inRange(hsv, np.array([8, 45, 70]), np.array([42, 255, 255]))
red_frac = float(((red1 | red2).mean()) / 255.0)
orange_frac = float(orange.mean() / 255.0)
if red_frac > 0.035:
return TemplateType.DISCOUNT
if orange_frac > 0.07:
return TemplateType.PROMO
return TemplateType.REGULAR
def get_layout_prior(
crop: np.ndarray | None = None, texts: Iterable[str] | None = None
) -> LayoutPrior:
"""Return template/orientation-aware semantic ROIs for a crop.
ROIs are broad by design: they should increase the chance of reading tiny QR
and text without excluding fallback full-crop OCR/decoding.
"""
orientation = classify_orientation(crop)
template = classify_template(crop, texts)
rois = _base_rois(orientation)
rois.update(_template_overrides(template, orientation))
rois = {name: roi.clipped() for name, roi in rois.items()}
return LayoutPrior(
template_type=template, orientation=orientation, rois=rois
)
def roi_crops(
crop: np.ndarray,
purposes: set[str] | None = None,
*,
min_size: int = 12,
) -> list[tuple[str, np.ndarray]]:
"""Extract named crops for requested purposes from a price-tag crop."""
if crop is None or crop.size == 0:
return []
prior = get_layout_prior(crop)
h, w = crop.shape[:2]
out: list[tuple[str, np.ndarray]] = []
for name, roi in prior.rois.items():
if purposes is not None and roi.purpose not in purposes:
continue
x0, y0, x1, y1 = roi.as_pixels(w, h)
if x1 - x0 < min_size or y1 - y0 < min_size:
continue
out.append((name, crop[y0:y1, x0:x1]))
return out
def _base_rois(orientation: Orientation) -> dict[str, LayoutROI]:
if orientation == Orientation.VERTICAL:
return {
"name_roi": LayoutROI("name_roi", "name", 0.02, 0.02, 0.98, 0.34),
"qr_roi": LayoutROI("qr_roi", "qr", 0.58, 0.58, 0.99, 0.99),
"qr_top_roi": LayoutROI("qr_top_roi", "qr", 0.58, 0.00, 0.99, 0.34),
"barcode_roi": LayoutROI(
"barcode_roi", "barcode", 0.10, 0.72, 0.92, 0.99
),
"price_card_roi": LayoutROI(
"price_card_roi", "price", 0.05, 0.43, 0.96, 0.82
),
"price_default_roi": LayoutROI(
"price_default_roi", "price", 0.05, 0.31, 0.96, 0.58
),
"sku_roi": LayoutROI("sku_roi", "sku", 0.02, 0.68, 0.72, 0.93),
"datetime_roi": LayoutROI(
"datetime_roi", "datetime", 0.02, 0.82, 0.72, 0.99
),
"code_roi": LayoutROI("code_roi", "code", 0.02, 0.56, 0.78, 0.78),
"symbol_roi": LayoutROI(
"symbol_roi", "symbol", 0.00, 0.30, 0.38, 0.58
),
"info_roi": LayoutROI("info_roi", "info", 0.02, 0.25, 0.98, 0.55),
}
if orientation == Orientation.ROTATED:
return {
"name_roi": LayoutROI("name_roi", "name", 0.02, 0.02, 0.98, 0.42),
"qr_roi": LayoutROI("qr_roi", "qr", 0.58, 0.42, 0.99, 0.99),
"barcode_roi": LayoutROI(
"barcode_roi", "barcode", 0.08, 0.62, 0.94, 0.99
),
"price_card_roi": LayoutROI(
"price_card_roi", "price", 0.18, 0.43, 0.98, 0.93
),
"price_default_roi": LayoutROI(
"price_default_roi", "price", 0.18, 0.28, 0.96, 0.63
),
"sku_roi": LayoutROI("sku_roi", "sku", 0.02, 0.55, 0.72, 0.85),
"datetime_roi": LayoutROI(
"datetime_roi", "datetime", 0.02, 0.76, 0.72, 0.99
),
"code_roi": LayoutROI("code_roi", "code", 0.02, 0.47, 0.75, 0.70),
"symbol_roi": LayoutROI(
"symbol_roi", "symbol", 0.00, 0.28, 0.35, 0.58
),
"info_roi": LayoutROI("info_roi", "info", 0.02, 0.22, 0.98, 0.55),
}
return {
"name_roi": LayoutROI("name_roi", "name", 0.02, 0.02, 0.72, 0.42),
"name_wide_roi": LayoutROI(
"name_wide_roi", "name", 0.02, 0.02, 0.98, 0.34
),
"qr_roi": LayoutROI("qr_roi", "qr", 0.68, 0.02, 0.99, 0.55),
"qr_bottom_right_roi": LayoutROI(
"qr_bottom_right_roi", "qr", 0.66, 0.48, 0.99, 0.99
),
"barcode_roi": LayoutROI(
"barcode_roi", "barcode", 0.18, 0.68, 0.96, 0.99
),
"barcode_bottom_right_roi": LayoutROI(
"barcode_bottom_right_roi", "barcode", 0.48, 0.62, 0.99, 0.99
),
"price_card_roi": LayoutROI(
"price_card_roi", "price", 0.38, 0.42, 0.98, 0.96
),
"price_default_roi": LayoutROI(
"price_default_roi", "price", 0.33, 0.28, 0.98, 0.60
),
"sku_roi": LayoutROI("sku_roi", "sku", 0.02, 0.62, 0.62, 0.94),
"datetime_roi": LayoutROI(
"datetime_roi", "datetime", 0.02, 0.78, 0.64, 0.99
),
"code_roi": LayoutROI("code_roi", "code", 0.02, 0.50, 0.64, 0.75),
"symbol_roi": LayoutROI("symbol_roi", "symbol", 0.00, 0.34, 0.24, 0.66),
"info_roi": LayoutROI("info_roi", "info", 0.02, 0.30, 0.62, 0.66),
}
def _template_overrides(
template: TemplateType, orientation: Orientation
) -> dict[str, LayoutROI]:
# Discount/promo tags often have the discount badge on the left and QR on
# the lower-right, so protect those areas with dedicated broad ROIs.
if template in {TemplateType.PROMO, TemplateType.DISCOUNT}:
if orientation == Orientation.VERTICAL:
return {
"discount_badge_roi": LayoutROI(
"discount_badge_roi", "discount", 0.00, 0.00, 0.48, 0.28
),
"promo_price_roi": LayoutROI(
"promo_price_roi", "price", 0.02, 0.35, 0.98, 0.90
),
}
return {
"discount_badge_roi": LayoutROI(
"discount_badge_roi", "discount", 0.00, 0.00, 0.32, 0.45
),
"promo_price_roi": LayoutROI(
"promo_price_roi", "price", 0.25, 0.32, 0.98, 0.96
),
}
if template == TemplateType.WHOLESALE:
return {
"wholesale_info_roi": LayoutROI(
"wholesale_info_roi", "info", 0.00, 0.35, 0.55, 0.75
),
"wholesale_price_roi": LayoutROI(
"wholesale_price_roi", "price", 0.28, 0.40, 0.98, 0.97
),
}
if template == TemplateType.BOGOF:
return {
"bogof_info_roi": LayoutROI(
"bogof_info_roi", "info", 0.00, 0.25, 0.98, 0.75
),
"bogof_price_roi": LayoutROI(
"bogof_price_roi", "price", 0.28, 0.45, 0.98, 0.98
),
}
return {}