Automating Facings vs Actuals Validation for Retail Shelf Analytics
Within the Planogram Sync & SKU Mapping Strategies section, facings-versus-actuals validation is the stage that turns slot-mapped detections into the single number a category manager actually argues about: how many facings of each SKU are physically on the shelf, versus how many the planogram demanded. Every upstream component — capture, detection, SKU resolution, spatial assignment — exists to make this comparison trustworthy, because the divergence between planned facings and shelf reality is the primary driver of revenue leakage, out-of-stock events, and margin erosion in live retail. Manual audits cannot keep pace: they are economically unscalable, temporally lagging, and statistically inconsistent across auditors. This page defines the data contract this stage enforces, the counting-and-variance architecture that runs underneath it, the tolerance bands that tune it, the failure modes you will actually hit on tightly packed shelves and pusher-spring fixtures, and the throughput you need to design against to validate a chain at scale.
Concept & Data Contract Jump to heading
This component sits between spatial assignment and compliance reporting, so it has two hard boundaries. At the inbound boundary it consumes slot-mapped detections — the output of the bipartite assignment described in Position Validation Algorithms for Planograms, where each resolved detection already carries a canonical sku, the slot_id it was matched to, a normalized centroid, and a box confidence. Alongside the detections it reads the authoritative planogram facing specification: the expected_facings per slot, the standardized unit width, and the velocity and margin tier of each SKU. At the outbound boundary it produces a facing-variance record: a per-slot reconciliation of expected against actual facings, a signed variance, a velocity-weighted compliance state, and the provenance needed to make the score auditable months later.
A facing-variance record looks like this:
{
"planogram_id": "PG-2026-GROCERY-A14",
"fixture_id": "BAY-014-SHELF-03",
"registry_revision": 184,
"capture_timestamp": "2026-06-28T07:42:11Z",
"compliance_percentage": 88.6,
"slots": [
{
"slot_id": "BAY-014-SHELF-03-POS-07",
"sku": "00012345678905",
"expected_facings": 3,
"actual_facings": 1,
"facing_variance": -2,
"variance_pct": 66.7,
"velocity_tier": "high",
"compliance_state": "under_faced"
}
],
"out_of_stock_flags": ["BAY-014-SHELF-03-POS-11"],
"misplaced_sku_list": ["00098765432107"],
"price_tag_mismatch_count": 2
}The compliance_state enum is deliberately graded rather than binary — compliant, under_faced, over_faced, out_of_stock, and blocked — because a binary pass/fail output is operationally useless to space planners who need to distinguish a slow-moving SKU drifting one facing low from a high-velocity hero product that has emptied out. Every record references the registry_revision and capture_timestamp it was scored against, so a planogram reset can never retroactively rewrite yesterday’s audit. The actual_facings field is the load-bearing value on the page, and the entire architecture below exists to make it correct under occlusion, packing density, and mechanical shelf hardware.
Implementation Architecture Jump to heading
The naive implementation — count bounding boxes per slot and subtract — fails immediately in production, because raw single-frame box counts systematically underestimate facings on dense shelves where products are tightly packed, partially occluded by neighbors, or angled from shopper interaction. A correct stage decomposes into three steps: aggregate detections into a stable per-slot tally, derive an actual facing count from physical geometry rather than raw box count, then compute velocity-weighted variance. The tally step prefers temporal aggregation over a sliding window of burst frames so that a unit hidden in one frame surfaces in another; the bounding boxes themselves arrive from the localization stage detailed in Bounding Box Extraction & SKU Localization.
Facings are computed from the horizontal span a SKU occupies divided by its standardized unit width, not from a literal box count, which makes the estimate robust to merged or split detections:
from dataclasses import dataclass
@dataclass(frozen=True)
class SlotSpec:
slot_id: str
sku: str
expected_facings: int
unit_width_mm: float
velocity_tier: str # "high" | "medium" | "low"
@dataclass(frozen=True)
class SlotVariance:
slot_id: str
sku: str
expected_facings: int
actual_facings: int
facing_variance: int
variance_pct: float
velocity_tier: str
compliance_state: str
def count_facings(
detection_spans_mm: list[tuple[float, float]],
unit_width_mm: float,
pusher_gap_mm: float = 18.0,
) -> int:
"""Derive actual facings from occupied horizontal span, not box count.
Merges adjacent detections into contiguous runs, compensates for the
mechanical pusher-spring gap, and divides the occupied run by the
standardized unit width. Robust to over- and under-segmentation.
"""
if not detection_spans_mm or unit_width_mm <= 0:
return 0
spans = sorted(detection_spans_mm)
occupied = 0.0
run_start, run_end = spans[0]
for start, end in spans[1:]:
if start <= run_end + pusher_gap_mm:
run_end = max(run_end, end)
else:
occupied += run_end - run_start
run_start, run_end = start, end
occupied += run_end - run_start
return max(0, round(occupied / unit_width_mm))The variance step is where velocity weighting enters. A signed facing_variance preserves direction so over-facing (a SKU encroaching on a neighbor’s slot) is distinguishable from under-facing, and the compliance state is decided against a tolerance band that tightens for high-margin, low-velocity items and loosens for fast movers whose counts fluctuate naturally between captures:
def evaluate_slot(spec: SlotSpec, actual: int, tolerance: dict[str, float]) -> SlotVariance:
variance = actual - spec.expected_facings
variance_pct = round(abs(variance) / spec.expected_facings * 100, 1) if spec.expected_facings else 0.0
band = tolerance.get(spec.velocity_tier, 0.0)
allowed = spec.expected_facings * band
if actual == 0 and spec.expected_facings > 0:
state = "out_of_stock"
elif abs(variance) <= allowed:
state = "compliant"
elif variance < 0:
state = "under_faced"
else:
state = "over_faced"
return SlotVariance(
slot_id=spec.slot_id,
sku=spec.sku,
expected_facings=spec.expected_facings,
actual_facings=actual,
facing_variance=variance,
variance_pct=variance_pct,
velocity_tier=spec.velocity_tier,
compliance_state=state,
)For batch reconciliation across thousands of bays, the per-slot evaluation is best vectorized with pandas rather than looped — the Calculating Facing Discrepancies with Python walkthrough shows the merge-on-composite-key and rolling-window pattern that keeps a chain-wide pass running in seconds rather than minutes. The fixture-level compliance_percentage is then a velocity- and margin-weighted aggregation across slots, not a flat ratio of compliant slots, so a single out-of-stock hero SKU moves the score more than three drifting tail items.
Production Configuration & Tuning Jump to heading
Tolerance bands, not detection weights, are what make this stage agree with a human auditor, and they belong in versioned configuration rather than code so they can be reconciled against ground-truth audits without a deploy. The reconciliation methodology itself is owned by Threshold Tuning for Compliance Accuracy; this stage simply consumes the bands it produces. A typical configuration scopes tolerance by velocity tier and overrides per category:
facings_validation:
pusher_gap_mm: 18.0
temporal_window_frames: 5 # burst frames aggregated per capture event
min_consecutive_scans_for_alert: 2 # suppress single-frame noise before flagging
tolerance_band: # fraction of expected_facings tolerated
high: 0.25 # fast movers fluctuate; loosen
medium: 0.15
low: 0.05 # high-margin tail items; strict
category_overrides:
tobacco: { low: 0.0 } # regulated, zero drift tolerated
seasonal: { high: 0.40 } # transient displays, wide band
weighting:
velocity: 0.6
margin: 0.4The temporal_window_frames value of 5 is the single most impactful setting for count accuracy on dense shelves: too small and occluded units stay hidden, too large and the window straddles a restock event and double-counts. The min_consecutive_scans_for_alert gate of 2 exists because a facing shortfall on a single capture is usually a shopper standing in front of the shelf, not a genuine out-of-stock — requiring the condition to persist across two scans collapses associate alert fatigue dramatically. Promotional zones must be excluded from these bands entirely: endcaps, clip strips, and secondary displays operate outside standard bay logic and are routed through Promotional Display Alignment Checks before their results are merged into reporting, so a campaign display never contaminates the baseline compliance_percentage.
Failure Modes & Debugging Workflow Jump to heading
When actual_facings looks wrong, the root cause is almost always one of five recurring problems. Work them in this order:
- Occlusion undercount on dense shelves. Symptom: high-velocity SKUs read one or two facings low on busy stores, worse at peak hours. Reproduce by replaying a burst sequence with a shopper hand crossing the frame. Fix: raise
temporal_window_framesso a unit hidden in the worst frame is visible in another, and confirm the span-merge logic incount_facingsis fusing the contiguous run rather than counting visible fragments. - Pusher-spring false out-of-stock. Symptom: a slot flags
out_of_stockwhile inventory is physically present but sitting behind an un-advanced pusher plate. Reproduce on any spring-loaded fixture photographed before the last unit advances. Fix: tunepusher_gap_mmto the fixture’s mechanical gap so the detector does not read the empty pusher channel as a missing facing. - Catalog mismatch inflating phantom violations. Symptom: a SKU shows expected facings against a slot where the real product changed, producing a slot-wide variance spike across many stores at once. Root cause is upstream identifier drift, not counting — resolve it at the SKU resolution layer per Planogram Sync & SKU Mapping Strategies and confirm the
registry_revisionon the record matches the planogram that was effective atcapture_timestamp. - OCR glare disambiguation failure. Symptom: visually similar private-label or flavor variants get counted into the wrong slot, so one slot over-faces while its neighbor under-faces by the same amount. Reproduce under overhead fluorescent glare on glossy packaging. Fix: route the borderline detections back through multi-frame consensus and the embedding-versus-barcode fallback described in Error Handling in Computer Vision Pipelines rather than tuning the facing count.
- Backstock double-count from stacked depth. Symptom: actual facings exceed expected on shelves with deep slots, because a second row behind the front row is being counted. Reproduce on any fixture with two-deep stacking. Fix: gate counting on front-row depth using mask aspect ratio or, where stereo or LiDAR capture exists, a depth map that segments the front row before
count_facingsruns.
Keep a labeled error repository categorized by these five root causes; quarterly review of which bucket dominates tells you whether to retrain the detector, re-tune the bands, or fix a fixture, and it is the only way to stop the same misclassification from recurring across resets.
Scaling & Performance Benchmarks Jump to heading
Facing validation is cheap relative to detection — it is array arithmetic over already-resolved detections — so the scaling constraint is fan-out, not per-record compute. A single CPU worker reconciles a fixture of roughly 40 slots in well under 15 ms, and a vectorized pandas pass over an entire 60-bay store completes in a few hundred milliseconds, which means the stage should never be the bottleneck if it is fed in capture-event batches rather than per-detection. Partition the validation queue by store_id (or fixture_id for flagship locations) to preserve per-fixture ordering, because variance is computed per capture event and out-of-order frames corrupt the consecutive-scan alert logic.
Design against a soft latency SLA of one minute from capture to a category manager’s dashboard during a morning reset window, and watch consumer lag on the validation partition rather than CPU: a backlog here usually signals a burst of captures from a chain-wide reset, not a compute shortfall, and autoscaling more validation workers clears it linearly. Cost optimization follows the same edge discipline as the rest of the platform — run the variance pass on the store-level server that already hosts inference and sync only the typed facing-variance record upstream, never raw frames. At chain scale that keeps the validation tier a rounding error on the analytics bill while still emitting a per-slot, per-capture audit trail across thousands of locations.
Frequently Asked Questions Jump to heading
Why derive facings from occupied span instead of just counting bounding boxes? Because raw box counts break in both directions on real shelves: a single tightly packed product can generate multiple overlapping boxes (over-count) while occlusion hides units entirely (under-count). Dividing the merged occupied horizontal span by the standardized unit width is robust to both over- and under-segmentation, and it is the only count that stays stable as detector confidence fluctuates between captures. The box count is an input signal, not the answer.
How do I stop pusher-spring fixtures from firing false out-of-stock alerts?
Set pusher_gap_mm to the fixture’s mechanical gap between the pusher plate and the last visible unit. Spring-loaded shelving leaves an empty channel at the front when inventory has not yet advanced, and without compensation the detector reads that channel as a missing facing. The span-merge logic treats any gap up to pusher_gap_mm as contiguous product, so a stocked-but-unadvanced shelf scores as present rather than empty.
What tolerance band should a high-velocity SKU use versus a slow mover?
Loosen for fast movers and tighten for the tail. A practical starting point is 0.25 of expected facings for high velocity, 0.15 for medium, and 0.05 for low — fast movers fluctuate naturally between captures as shoppers pull stock, so a strict band on them only generates noise, while a high-margin slow mover drifting even one facing low is a genuine execution failure worth a task. Regulated categories like tobacco override to 0.0.
Why require two consecutive scans before flagging a shortfall?
Because a facing shortfall on a single capture is most often a shopper standing in front of the shelf, not a real out-of-stock. Requiring min_consecutive_scans_for_alert of 2 filters the dominant source of false positives, which is what collapses associate alert fatigue. The tradeoff is a small detection latency on genuine outages, which is acceptable because the corrective task still reaches the associate within the capture cadence.
How are promotional endcaps kept from skewing the compliance score?
They are segmented out before scoring. Endcaps, clip strips, and secondary displays do not follow standard bay geometry or baseline facing specs, so validating them against the standard tolerance bands would contaminate the fixture compliance_percentage. They route through Promotional Display Alignment Checks against campaign-specific rules, and only their separately computed results merge back into reporting.
Related Jump to heading
- Calculating Facing Discrepancies with Python — the vectorized pandas implementation of the variance engine described here
- Position Validation Algorithms for Planograms — the spatial assignment that produces the slot-mapped detections this stage consumes
- Threshold Tuning for Compliance Accuracy — how the tolerance bands are reconciled against ground-truth audits
- Promotional Display Alignment Checks — validating endcaps and secondary displays outside standard bay logic
- Planogram Sync & SKU Mapping Strategies — the parent layer that binds detections to authoritative catalog identifiers