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.
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+withnumpyon 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_idwith 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.5cm and5.0cm. - Size-relative band — a percentage of the SKU’s catalog dimension, typically
10%–15% of width or height. This grants a30cm cereal box proportional slack while holding a4cm 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:
- The looser threshold wins. Pass a
40cm-wide item with a12% relative band and assertdynamic_thresholdsreturns4.8cm, not the3.0cm floor; pass a4cm item and assert it returns the floor. - Each axis grades independently. Feed a correct SKU with
dxinside its band butdypast it, and assert the state isshiftedwithvertical_dev_cmabovev_threshold_cmwhilehorizontal_dev_cmstays under. - Rotation forces misplaced. Hold the centroid dead-on but set
rotation_degto20; assert the state ismisplacedeven though every distance band passes. - Wrong SKU is never in_position. Hand the validator a matched pair whose
det.sku_iddiffers fromitem.sku_idand assertmisplacedregardless of how small the offset is. - Tier override tightens, not loosens by accident. Score the same offset under
eye_levelandbottomconfigs and assert the eye-level slot flips toshiftedwhile the bottom slot staysin_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 |
Related Jump to heading
- Position Validation Algorithms for Planograms — the parent stage that projects detections to the metric frame and assigns them to slots before tolerance is checked
- Threshold Tuning for Compliance Accuracy — how the tolerance bands here are reconciled against ground-truth audits
- Promotional Display Alignment Checks — the separate tolerance profile for endcaps and secondary displays