Validating Shelf Position Tolerances in Retail

This walkthrough sits under Position Validation Algorithms for Planograms and solves one precise task: turning the graded in_position / shifted / misplaced states that the parent stage emits into defensible, per-fixture tolerance bands that a category manager will actually trust. The parent page owns the homography projection and the global slot assignment; this page owns the question that comes right after — how far is too far? Rigid coordinate matching breaks the moment it meets a live aisle: shoppers nudge facings, mounts drift a degree, and gondola depth varies bay to bay. If your tolerance is a single hard-coded number you get false vacancies on a slightly shifted hero product and silent passes on a genuinely misplaced one. The fix is a multi-axis tolerance model — horizontal, vertical, depth, and rotation, each with an absolute floor and a size-relative band — wired into a validator that consumes the metric centroid offsets already produced upstream. This page builds that validator step by step, and each step is independently verifiable.

Shelf position tolerance: concentric distance bands, a rotation wedge, and the in_position / shifted / misplaced decision The distance panel centres on a planogram anchor slot wrapped by two concentric dashed boundaries: an inner absolute floor of about three centimetres and an outer size-relative band of about twelve percent of catalog width. The validator takes the looser of the two per item. A detected centroid sitting inside both boundaries passes on distance. Horizontal and vertical band measures are marked along the axes. The rotation panel is an independent axis: a faint plus-or-minus fifteen degree wedge surrounds the expected zero-degree facing, and a detection rotated thirty-five degrees falls outside the wedge, forcing a misplaced grade regardless of distance. The decision strip states the three outcomes — in_position when the correct SKU is inside every band and within the wedge, shifted when the correct SKU drifts past a horizontal or vertical band but stays within the wedge, and misplaced when the SKU is wrong or rotation exceeds the wedge. Distance axes — concentric tolerance Rotation gate — independent axis Grade the state planogram anchor slot size-relative band ≈ 12% of width absolute floor ≈ 3 cm detected centroid inside bands → OK looser of the two wins per axis, per item horizontal band ±h vertical band ±v ±15° tolerance wedge expected 0° 35° detected facing 35° > 15° wedge → misplaced in_position correct SKU, inside every distance band, and within the rotation wedge shifted correct SKU drifts past a horizontal or vertical band, but stays within the wedge misplaced wrong SKU, or rotation past the ±15° wedge — independent of distance

Prerequisites & Context Jump to heading

Before applying this page, confirm the following are already in place. The tolerance check runs on metric offsets, not pixels — it assumes the camera has already been removed by the projection stage described in the parent page.

  • Runtime: Python 3.11+ with numpy on the scoring host; no GPU is required for this stage.
  • Projected detections: each detection must arrive as a centroid in fixture millimetre coordinates plus its bounding-box width, height, and minimum-rectangle rotation angle — the output of the homography pass the parent stage runs. If your offsets are still in pixels, fix projection first; a tolerance in millimetres applied to pixel distance is meaningless.
  • Planogram slot geometry: every slot_id with its anchor coordinate, the SKU it is reserved for, and the slot rectangle, expressed in the same fixture millimetre frame.
  • SKU catalog: packaging width and height per sku, so relative tolerance bands scale against catalog dimensions rather than guesses. This is the same catalog that Bounding Box Extraction & SKU Localization resolves detections against.
  • A reconciliation target: a small labelled set of ground-truth audits so the bands you choose here can be checked against a human verdict, which is the loop Threshold Tuning for Compliance Accuracy formalizes.

A note on terms: a tolerance band is the maximum deviation on one axis that still counts as compliant. The goal is bands that agree with an auditor — not bands that are merely tight.

Step 1 — Model Tolerance as a Multi-Axis Constraint, Not a Number Jump to heading

Positional tolerance is not a scalar. It is a per-axis constraint set that independently evaluates horizontal placement, vertical tier alignment, depth penetration, and rotational skew, because each axis fails for a different physical reason. Horizontal drift comes from shopper handling and faces left-to-right; vertical error comes from shelf-lip height and stacking variance; depth error comes from forward or rearward placement that triggers occlusion or shadow in overhead arrays; rotation comes from a browsed-and-replaced product. Collapsing all four into one Euclidean number hides which one actually failed.

For each axis, define two thresholds and take the looser of the two per item:

  • Absolute floor — a fixed centimetre value that holds in standardized modular shelving with consistent camera distance. Horizontal floors typically sit between 2.5 cm and 5.0 cm.
  • Size-relative band — a percentage of the SKU’s catalog dimension, typically 10%–15% of width or height. This grants a 30 cm cereal box proportional slack while holding a 4 cm snack bar tight.

Taking the larger of the two prevents disproportionate penalties on mixed-assortment shelves. Without relative scaling, a 3 cm deviation penalizes a narrow facing and a wide box identically, skewing the score and triggering needless reset tasks.

from dataclasses import dataclass, field


@dataclass(frozen=True)
class ToleranceConfig:
    """Per-axis tolerance: an absolute floor and a size-relative band."""
    horizontal_abs_cm: float = 3.0
    vertical_abs_cm: float = 2.5
    depth_abs_cm: float = 4.0
    horizontal_rel_pct: float = 0.12
    vertical_rel_pct: float = 0.10
    depth_rel_pct: float = 0.15
    rotation_tolerance_deg: float = 15.0

    def __post_init__(self) -> None:
        for name in ("horizontal_abs_cm", "vertical_abs_cm", "depth_abs_cm"):
            if getattr(self, name) <= 0:
                raise ValueError(f"{name} must be positive")
        if not 0 <= self.rotation_tolerance_deg <= 90:
            raise ValueError("rotation_tolerance_deg out of range")

Step 2 — Compute Dynamic Per-Item Thresholds Jump to heading

With the model defined, resolve concrete thresholds for each planogram item by combining its catalog dimensions with the config. This is the function the whole validator leans on, so keep it pure and typed.

import numpy as np


@dataclass(frozen=True)
class PlanogramItem:
    sku_id: str
    target_x_cm: float          # anchor, fixture-metric frame
    target_y_cm: float
    width_cm: float
    height_cm: float
    expected_rotation_deg: float = 0.0


@dataclass(frozen=True)
class DetectedItem:
    sku_id: str
    centroid_x_cm: float        # already projected to the fixture frame
    centroid_y_cm: float
    depth_offset_cm: float = 0.0
    rotation_deg: float = 0.0


def dynamic_thresholds(item: PlanogramItem, cfg: ToleranceConfig) -> tuple[float, float]:
    """Looser of the absolute floor and the size-relative band, per axis."""
    h = max(cfg.horizontal_abs_cm, item.width_cm * cfg.horizontal_rel_pct)
    v = max(cfg.vertical_abs_cm, item.height_cm * cfg.vertical_rel_pct)
    return round(h, 2), round(v, 2)

Because every threshold derives from the same config and catalog, the numbers stay reproducible across stores — there is no per-camera retuning once offsets are metric.

Step 3 — Evaluate Each Axis and Grade the State Jump to heading

Now score each detection against its slot. The grading mirrors the parent stage’s enum so the records compose cleanly: a SKU inside every band is in_position; the correct SKU drifting past the horizontal or vertical band is shifted; rotation past the wedge or the wrong SKU is misplaced. Keep the axes separate in the output so downstream reporting can weight a horizontal shift differently from a rotation.

from typing import Literal

PositionState = Literal["in_position", "shifted", "misplaced", "vacant"]


def grade_axis_compliance(
    item: PlanogramItem, det: DetectedItem, cfg: ToleranceConfig
) -> dict:
    """Return a per-axis compliance record for one matched pair."""
    h_thresh, v_thresh = dynamic_thresholds(item, cfg)
    dx = abs(det.centroid_x_cm - item.target_x_cm)
    dy = abs(det.centroid_y_cm - item.target_y_cm)
    dz = abs(det.depth_offset_cm)
    rot_dev = abs(det.rotation_deg - item.expected_rotation_deg)

    h_ok = dx <= h_thresh
    v_ok = dy <= v_thresh
    z_ok = dz <= cfg.depth_abs_cm
    rot_ok = rot_dev <= cfg.rotation_tolerance_deg

    if det.sku_id != item.sku_id or not rot_ok:
        state: PositionState = "misplaced"
    elif h_ok and v_ok and z_ok:
        state = "in_position"
    else:
        state = "shifted"

    return {
        "sku_id": item.sku_id,
        "position_state": state,
        "horizontal_dev_cm": round(dx, 2),
        "vertical_dev_cm": round(dy, 2),
        "depth_dev_cm": round(dz, 2),
        "rotation_dev_deg": round(rot_dev, 2),
        "h_threshold_cm": h_thresh,
        "v_threshold_cm": v_thresh,
    }

Rotation deserves its own flag: a product turned beyond ±15 degrees almost always signals browsing or an improper facing, and it is independent of where the centroid sits. Compute the minimum bounding-rectangle angle upstream and compare it here, rather than folding it into the distance metric where it would be invisible.

Step 4 — Wrap It in a Validator With a Tier Override Layer Jump to heading

Eye-level slots carry the most revenue leverage, so they warrant a tighter horizontal band than a churned-up bottom shelf. Express that as a versioned override map rather than branching code, and let the validator pick the config per slot tier. Promotional fixtures never use these bands at all — endcaps route through Promotional Display Alignment Checks, which loads its own per-campaign schema.

class ShelfToleranceValidator:
    def __init__(
        self, base: ToleranceConfig,
        tier_overrides: dict[str, ToleranceConfig] | None = None,
    ) -> None:
        self.base = base
        self.tier_overrides = tier_overrides or {}

    def _config_for(self, tier: str) -> ToleranceConfig:
        return self.tier_overrides.get(tier, self.base)

    def validate(
        self,
        matched: list[tuple[PlanogramItem, DetectedItem, str]],
    ) -> list[dict]:
        """Grade pre-matched (planogram, detection, tier) triples.

        Matching is owned by the parent assignment stage; this validator only
        evaluates tolerance on pairs it is handed, so the two concerns stay
        independently testable.
        """
        records: list[dict] = []
        for item, det, tier in matched:
            cfg = self._config_for(tier)
            records.append({**grade_axis_compliance(item, det, cfg), "tier": tier})
        return records


if __name__ == "__main__":
    base = ToleranceConfig(horizontal_abs_cm=3.0, horizontal_rel_pct=0.12)
    validator = ShelfToleranceValidator(
        base=base,
        tier_overrides={"eye_level": ToleranceConfig(horizontal_abs_cm=2.0)},
    )
    pairs = [
        (PlanogramItem("SKU-101", 15.0, 10.0, 8.5, 12.0),
         DetectedItem("SKU-101", 16.2, 10.8, 0.5, 2.0), "eye_level"),
        (PlanogramItem("SKU-102", 28.0, 10.0, 15.0, 20.0),
         DetectedItem("SKU-101", 31.5, 11.0, 1.0, 5.0), "bottom"),
    ]
    for r in validator.validate(pairs):
        print(f"{r['sku_id']}: {r['position_state']} "
              f"| H {r['horizontal_dev_cm']}/{r['h_threshold_cm']}cm "
              f"| rot {r['rotation_dev_deg']}deg")

Verification & Testing Jump to heading

Confirm each rule deterministically rather than eyeballing a dashboard:

  1. The looser threshold wins. Pass a 40 cm-wide item with a 12% relative band and assert dynamic_thresholds returns 4.8 cm, not the 3.0 cm floor; pass a 4 cm item and assert it returns the floor.
  2. Each axis grades independently. Feed a correct SKU with dx inside its band but dy past it, and assert the state is shifted with vertical_dev_cm above v_threshold_cm while horizontal_dev_cm stays under.
  3. Rotation forces misplaced. Hold the centroid dead-on but set rotation_deg to 20; assert the state is misplaced even though every distance band passes.
  4. Wrong SKU is never in_position. Hand the validator a matched pair whose det.sku_id differs from item.sku_id and assert misplaced regardless of how small the offset is.
  5. Tier override tightens, not loosens by accident. Score the same offset under eye_level and bottom configs and assert the eye-level slot flips to shifted while the bottom slot stays in_position.

A healthy run shows the in_position share tracking the auditor’s pass rate within a few points, with misplaced records dominated by genuine wrong-SKU and rotation cases rather than borderline shifts.

Troubleshooting Jump to heading

Symptom Likely root cause Remediation
A slightly nudged hero product reads as a false vacancy Horizontal floor too tight for an eye-level tier Raise the eye_level floor toward 2.5 cm or widen horizontal_rel_pct; confirm offsets are metric, not pixels
Wide boxes always pass, narrow facings always fail Only the absolute floor is in play; relative band never applied Verify dynamic_thresholds takes max() of floor and band, and that catalog width_cm is populated per SKU
Every facing on one bay grades shifted in the same direction Upstream homography drifted; offsets are biased before they reach this stage Fix calibration in the parent projection stage, not here — loosening bands only masks the drift
Browsed products pass despite being turned sideways Rotation not computed, or folded into the distance metric Emit the minimum-rectangle angle upstream and assert rotation_dev_deg appears in records; keep it a separate flag
Bands agree with audits in one store, disagree in another A single global config across fixture classes Add tier_overrides (and a curved_endcap fixture override) and reconcile per class against ground truth
Back to top