Handling Multi-Pack SKU Variants in Compliance Scoring
This walkthrough sits under Threshold Tuning for Compliance Accuracy and solves one precise task the tuning layer cannot fix by moving a cut-point: scoring compliance correctly when the same product ships as a single, a multi-pack, and a club-pack, each carrying its own GTIN, yet all looking near-identical on the shelf. The parent page tunes where a confidence cut-point sits; this page decides which GTINs are interchangeable before that cut-point is ever applied. Treat those three GTINs as three unrelated SKUs and a store that legitimately swapped the single for the 6-pack lights up as a misplacement, while the 12-pack club unit either double-counts its facings or fails to count at all. The fix is a variant-equivalence group keyed to a base product: any in-group GTIN is an acceptable fill for the slot, but the facings and pack size are always scored against the planogram’s specified variant, and the visual-confidence floor is raised where variants are visually indistinguishable. This page builds that resolver and scorer step by step, and each step is independently verifiable.
Prerequisites & Context Jump to heading
Before applying this page, confirm the following are already in place. Variant scoring runs after detection and slot assignment — it consumes resolved GTINs, not pixels.
- Runtime: Python
3.11+on the scoring host; no GPU is required for this stage, which is pure dictionary lookup and arithmetic. - A GTIN variant/equivalence group in the catalog: every purchasable GTIN must resolve to a base product, with its
pack_size(units per pack) and packagingwidth_cm/height_cm. This is the authoritative catalog resolved by Planogram Sync & SKU Mapping Strategies; if a GTIN is not registered to a group, this stage cannot know it is a valid substitute. - Resolved detections: each detection arrives already localized and classified to a candidate GTIN with a
visual_confidencescore — the output of Bounding Box Extraction & SKU Localization, which is also where near-identical variants are hardest to tell apart. - Planogram slot spec: every
slot_idwith the specificexpected_gtinthe reset called for and itsexpected_facings. The reset chose a variant deliberately; the score must honour that choice even when a valid cousin is on shelf. - A ground-truth reconciliation target: a small labelled set so the confidence floor you raise here can be checked against a human verdict, the loop the parent Threshold Tuning for Compliance Accuracy formalizes.
A note on terms: a variant-equivalence group is the set of GTINs that satisfy the same shelf intent. A substitution is a valid in-group swap; a violation is a genuinely wrong product. The whole point is to never confuse the two.
Step 1 — Model a Variant-Equivalence Group Keyed to a Base Product Jump to heading
The unit of truth is the base product, not the GTIN. Model an equivalence group as a base-product key mapping to its member variants, each with a distinct pack size, then build a reverse index so any detected GTIN resolves back to its group in constant time. Distinct pack sizes are a validation invariant: two members sharing a pack_size means the catalog has a duplicate, and duplicates are exactly what produce double-counted facings later.
from dataclasses import dataclass
@dataclass(frozen=True)
class VariantSpec:
"""One purchasable GTIN of a base product, with pack size and dimensions."""
gtin: str
pack_size: int # units per pack: 1 single, 6 multi, 12 club
width_cm: float
height_cm: float
@dataclass(frozen=True)
class VariantGroup:
"""An equivalence group keyed to a base product. Every member is a valid
fill for a slot; only one member matches the planogram's specified variant."""
base_product_id: str
members: dict[str, VariantSpec] # gtin -> spec
look_alike: bool = False # variants are near-identical on shelf
def __post_init__(self) -> None:
if not self.members:
raise ValueError(f"group {self.base_product_id} has no members")
sizes = [v.pack_size for v in self.members.values()]
if len(set(sizes)) != len(sizes):
raise ValueError("each member must have a distinct pack_size")
class VariantRegistry:
"""Reverse index from any GTIN to its base-product equivalence group."""
def __init__(self, groups: list[VariantGroup]) -> None:
self._by_gtin: dict[str, VariantGroup] = {}
for group in groups:
for gtin in group.members:
if gtin in self._by_gtin:
raise ValueError(f"GTIN {gtin} registered to two groups")
self._by_gtin[gtin] = group
def group_for(self, gtin: str) -> VariantGroup | None:
return self._by_gtin.get(gtin)Because the registry is keyed off the base product, adding a new club-pack GTIN next quarter is a one-line catalog change, not a re-tune of the scorer.
Step 2 — Accept Any In-Group GTIN, Score Against the Specified Variant Jump to heading
With the group modelled, the slot spec names one deliberate variant while the shelf may hold any member. The gate accepts the in-group GTIN as a valid fill, but the facings are always compared against the planogram’s expected_facings for the specified variant — never the pack size that happens to be on shelf. This is the crux of correct counting: facings are front-facing packs, so a 6-pack occupies one facing exactly like a single, and you never multiply by pack_size to reach a unit count. Multiplying is precisely the bug that double-counts club-packs.
from typing import Literal
ComplianceState = Literal["compliant", "substitution", "violation", "unresolved"]
@dataclass(frozen=True)
class PlanogramSlot:
slot_id: str
expected_gtin: str # the specific variant the reset called for
expected_facings: int
@dataclass(frozen=True)
class Detection:
slot_id: str
gtin: str
visual_confidence: float
observed_facings: int # front-facing packs, NOT unit count
@dataclass(frozen=True)
class SlotResult:
slot_id: str
state: ComplianceState
matched_gtin: str
expected_gtin: str
facing_deficit: int
note: strThe facing deficit is max(0, expected_facings - observed_facings), computed identically for an exact match and an in-group swap, so a valid substitution with a short count is still penalized for the shortfall — just not for being the “wrong” GTIN.
Step 3 — Raise the Visual-Confidence Floor for Look-Alike Variants Jump to heading
A single and its multi-pack often differ only by a shrink-wrap band the camera barely sees, so the detector’s variant call is far less reliable than its base-product call. When a group is flagged look_alike, demand a higher visual_confidence before trusting which member is on shelf; below the floor, return unresolved and let the slot fall through to multi-frame consensus rather than guessing. The base floor of 0.55 is fine for visually distinct products; look-alike groups warrant 0.78 or higher.
@dataclass(frozen=True)
class ScoringConfig:
base_confidence_floor: float = 0.55
lookalike_confidence_floor: float = 0.78
def floor_for(self, group: VariantGroup) -> float:
return (self.lookalike_confidence_floor
if group.look_alike else self.base_confidence_floor)Raising the floor only for look-alike groups keeps distinct products flowing at the normal cut-point while forcing caution exactly where variants blur together, so you buy precision on the ambiguous cases without sacrificing recall everywhere else.
Step 4 — Report Substitution Distinctly from Violation Jump to heading
Now compose the pieces. A detection that resolves to the same base product is a substitution — reportable, but never added to misplaced_sku_list. A detection resolving to a different base product is a violation and a genuine misplacement. An unregistered GTIN or a below-floor look-alike is unresolved and routed for review, not silently graded.
class VariantComplianceScorer:
def __init__(self, registry: VariantRegistry, cfg: ScoringConfig) -> None:
self.registry = registry
self.cfg = cfg
def score(self, slot: PlanogramSlot, det: Detection) -> SlotResult:
expected_group = self.registry.group_for(slot.expected_gtin)
if expected_group is None:
raise KeyError(
f"slot {slot.slot_id} expects unregistered GTIN {slot.expected_gtin}"
)
det_group = self.registry.group_for(det.gtin)
deficit = max(0, slot.expected_facings - det.observed_facings)
if det_group is None:
return SlotResult(slot.slot_id, "unresolved", det.gtin,
slot.expected_gtin, slot.expected_facings,
"detected GTIN missing from any catalog group")
floor = self.cfg.floor_for(expected_group)
if expected_group.look_alike and det.visual_confidence < floor:
return SlotResult(slot.slot_id, "unresolved", det.gtin,
slot.expected_gtin, slot.expected_facings,
f"confidence {det.visual_confidence} below floor {floor}")
if det_group.base_product_id != expected_group.base_product_id:
return SlotResult(slot.slot_id, "violation", det.gtin,
slot.expected_gtin, deficit,
"different base product occupies the slot")
if det.gtin == slot.expected_gtin:
return SlotResult(slot.slot_id, "compliant", det.gtin,
slot.expected_gtin, deficit, "exact specified variant")
return SlotResult(slot.slot_id, "substitution", det.gtin,
slot.expected_gtin, deficit,
"valid in-group variant swap; not misplaced")
if __name__ == "__main__":
group = VariantGroup(
base_product_id="BP-4471",
members={
"00001": VariantSpec("00001", 1, 6.0, 18.0),
"00062": VariantSpec("00062", 6, 22.0, 18.0),
"00121": VariantSpec("00121", 12, 30.0, 20.0),
},
look_alike=True,
)
other = VariantGroup("BP-9002", {"90020": VariantSpec("90020", 1, 6.0, 18.0)})
scorer = VariantComplianceScorer(VariantRegistry([group, other]), ScoringConfig())
slot = PlanogramSlot("SL-14", expected_gtin="00001", expected_facings=4)
cases = [
Detection("SL-14", "00001", 0.91, 4), # exact -> compliant
Detection("SL-14", "00062", 0.88, 3), # in-group multi -> substitution
Detection("SL-14", "90020", 0.90, 4), # other product -> violation
Detection("SL-14", "00062", 0.61, 3), # look-alike, low conf -> unresolved
]
for det in cases:
r = scorer.score(slot, det)
print(f"{det.gtin}: {r.state:12s} deficit={r.facing_deficit} | {r.note}")Reporting consumes the state field directly: only violation contributes to misplaced_sku_list, while substitution flows to a separate assortment-drift channel so a category manager sees “valid cousin on shelf” rather than a phantom misplacement.
Verification & Testing Jump to heading
Confirm each rule deterministically rather than eyeballing a dashboard:
- In-group substitution is not misplaced. Score the specified single against a detected multi-pack of the same base product and assert
state == "substitution", never"violation", and that its GTIN would not be added tomisplaced_sku_list. - Wrong-variant facings are still penalized. Hand the substitution case
observed_facingsbelowexpected_facingsand assertfacing_deficitreflects the shortfall — a valid variant does not exempt a short count. - Facings are not double-counted across pack sizes. Assert
observed_facingsis the pack count and that no code path multiplies bypack_size; a12-pack club unit must contribute one facing, not twelve. - The look-alike floor is raised. Score a look-alike group at
visual_confidence0.61and assertstate == "unresolved"; drop the same detection into a non-look-alike group and assert it grades normally at the0.55floor. - A different base product is a violation. Assert an out-of-group GTIN grades
violation, and that an unregistered GTIN gradesunresolvedwhile an unregistered expected GTIN raisesKeyErrorloudly at load time.
A healthy run shows substitution counts tracking known assortment swaps, violation counts dominated by genuinely wrong products, and the unresolved share small enough to clear through consensus without flooding review.
Troubleshooting Jump to heading
| Symptom | Likely root cause | Remediation |
|---|---|---|
| A valid multi-pack swap is flagged misplaced | The multi-pack GTIN is not registered to the base-product group, so it resolves to a different group | Add the GTIN to the group in the catalog and rebuild the VariantRegistry reverse index; assert group_for returns the same base_product_id |
| Facings double-count on club-pack slots | Facing count multiplies by pack_size to reach a unit count |
Count front-facing packs only; keep observed_facings pack-based and never multiply by pack_size in the scorer |
| Multi-pack repeatedly labelled as the single (or vice versa) | Look-alike variants accepted below a reliable confidence, so the variant call is a guess | Set look_alike=True on the group and raise lookalike_confidence_floor toward 0.78+; route below-floor detections to multi-frame consensus |
unresolved volume spikes on one category |
Catalog is missing the equivalence group entirely, so no GTIN resolves | Register the base-product group in Planogram Sync & SKU Mapping Strategies before scoring; a missing group should alert, not silently pass |
| Substitutions inflate the misplacement rate | Reporting reads any non-exact GTIN as misplaced | Branch reporting on state: only violation enters misplaced_sku_list; send substitution to the assortment-drift channel |
Related Jump to heading
- Threshold Tuning for Compliance Accuracy — the parent tuning layer whose confidence cut-point this stage feeds after variants are resolved
- Planogram Sync & SKU Mapping Strategies — the authoritative catalog and registry that binds each GTIN to its base-product equivalence group
- Bounding Box Extraction & SKU Localization — where near-identical variants are disambiguated and the visual-confidence score originates