Object detectors undercount the moment things overlap. The occluded fraction of a scene is invisible to the detector and silently missing from the raw count. This library corrects the count from box geometry and a calibrated detectability model, and reports the result as an interval with a measured, not assumed, coverage property, instead of a bare number that implies a certainty occlusion has already destroyed.
amodal-counting codebase, and are browsed
client-side here. No product source ships in this Space. See the FAQ for why.PYTHONPATH=src python -m pytest tests/ -q) printed 46 passed in 2.24s,
and amodal.cli bench --seed 0 reproduced the synthetic table below digit for digit.Picture a platform camera watching a boarding queue at rush hour, or a checkpoint camera counting people through a gate. As the crowd thickens, people start standing behind each other: a shoulder here, a partial torso there, someone almost fully hidden by the person directly in front. A standard object detector draws a box around every person it can actually see, and the count of boxes becomes the crowd number that everything downstream, capacity alerts, density thresholds, dashboards, treats as the truth. The moment two people meaningfully overlap in the camera's flat 2D view, the person behind becomes partially or fully invisible to the detector. The detector is not malfunctioning; the pixels it would need to see that person simply are not there.
This kind of undercount does not announce itself. The raw number looks confident and precise, no error bars, no caveat, and it is tempting to treat it as ground truth. But the gap between what is drawn and what is actually present grows exactly when it matters most: the denser the crowd, the more occlusion, and the more the raw count silently underestimates. A safety or capacity system built on that bare number alone will systematically under-react during the busiest, highest-risk moments, and it will do so quietly, with no signal that anything is wrong.
Training a bigger or more accurate detector does not remove this problem, it only pushes the same failure to a higher density, because occlusion is a geometric fact of a flat projection, not a gap in model capacity. What is needed instead is a way to reason about which detections were even possible to make, given how much of each object was actually visible, use that to correct the count, and state honestly how much to trust the correction. That is what Amodal Counting does.
1. Visibility from box geometry. Visibility of a detection is the fraction of its box not covered by occluders, not covered by other detections in front of it, and not truncated by the image border. A missed occluding object can only ever make this look less occluded than reality, so the bias runs one direction, which keeps the downstream correction conservative by construction.
2. Calibrated detectability curve. A logistic curve fit by maximum likelihood from
labeled (visibility, was-detected) pairs, carrying its own calibrated support: a query outside the
range actually seen during fitting is clamped and flagged extrapolation_limited, never
silently extrapolated. A multivariate variant extends this to box scale, local crowd density, and
image y-position, not just geometric visibility, when those features are genuinely predictive.
3. Horvitz-Thompson interval counting. Each detection is weighted
1 / p(detect); weights sum to the corrected estimate. Three honesty gates are in the
code, not just the docs: the corrected count can never drop below the raw detection count (unless the
two-sided estimator below is deliberately enabled), every weight is capped so a single deep-occlusion
detection cannot conjure a crowd, and any detection outside calibrated support flags the whole
estimate.
4. Conformal interval calibration. The analytic variance is a textbook approximation and is measured to under-cover; a calibration pass on labeled scenes fits a multiplier so the declared coverage claim is empirical, not assumed.
5. Two-sided estimator. Standard reweighting can only push a count up. A second
fitted model, the probability a detection is real versus a tiling duplicate or artifact, allows the
correction to also push down, weighting each detection by p_true / p_detect instead of
1 / p_detect. This is the change that lets the estimator address a detector whose error
is a two-sided mix of undercount and overcount, at the cost of dropping the never-below-raw-count
floor by design, documented in code as an intentional, narrow relaxation.
6. Track-based occlusion persistence. A track that vanishes mid-frame away from an exit region is treated as probably occluded, with a survival probability that decays over hidden time, fit from observed reappearance-gap statistics, not hand-picked.
The bench --seed 0 run reproduced in this session fit, at crowding 0.3, a
detectability curve with k=7.5703 and v0=0.3347 from 720 calibration
samples. Under that curve, a detection that is 90% visible has p(detect)=0.986 and
contributes a weight of 1.014, essentially just itself. A detection that is 60% visible has
p(detect)=0.882 and contributes 1.134. A detection that is only 30% visible has
p(detect)=0.435 and contributes 2.300: the estimator infers that for every such
barely-visible object the detector did catch, roughly 1.3 similar ones were missed entirely. Summing
these weights across the scene, instead of counting boxes, is the whole correction. The conformal
calibration step then fit z_scale=8.2022 at this crowding level, an honest admission
that the textbook variance formula was too optimistic by roughly 8x on these worlds, and the reported
interval is widened accordingly before any coverage claim is made.
Diagram reflects the one-directional path (steps 1 to 4). The two-sided
estimator (mechanism step 5, above) swaps step 3's weight for p_true / p_detect, using a
second precision model fit alongside the detectability curve; see the real-data results below for
when each path wins.
Each example below was generated by running the real amodal-counting
visibility-estimation, detectability-curve calibration, and Horvitz-Thompson interval-counting code
against a synthetic scene, offline, once, with the same private codebase whose evidence trail this
page cites. Red
boxes are ground truth (hidden from the algorithm, darker means less visible); blue boxes are what
the simulated detector reported.
Synthetic scene
Naive vs. corrected count
Command run fresh in this session: PYTHONPATH=src python -m amodal.cli bench --seed 0.
The detectability curve and interval scale are calibrated on 60 training worlds and evaluated fresh
on 40 held-out worlds per crowding level; no number here is computed on data the calibration saw.
evidence/a4_realdata/RESULTS.md), run on rented GPU hardware with real CrowdHuman
crowd photos and a SAHI-tiled YOLOv8s detector. It was not independently re-run in this
session (no GPU, no CrowdHuman download, no ultralytics/sahi in
this environment); the numbers below are read directly from that file, not recomputed.
| Crowding | Naive MAE | Corrected MAE | 90% CI coverage |
|---|---|---|---|
| 0.0 | 2.80 | 2.40 | 90.0% |
| 0.3 | 2.60 | 1.97 | 90.0% |
| 0.6 | 2.85 | 2.34 | 97.5% |
| 0.8 | 3.73 | 2.46 | 97.5% |
Reproduced this session: PYTHONPATH=src .venv/bin/python -m amodal.cli
bench --seed 0, digit-for-digit match with the values recorded in README.md. 60 training
worlds, 40 held-out eval worlds per crowding level, seed 0, no calibration-set reuse.
| Stage | Recall @ IoU≥0.5 | Recall @ IoU≥0.3 | Precision @ IoU≥0.5 |
|---|---|---|---|
| Prior baseline (stock YOLOv8@640, not re-run for this page) | ~16% | not reported | not reported |
| SAHI-tiled YOLOv8s, initial fix | 56.25% | 60.14% | 57.53% |
| SAHI-tiled YOLOv8s, tiling-merge fix (GREEDYNMM, IOU, threshold 0.3, confidence 0.15) | 61.89% | 66.12% | 61.82% |
Source: evidence/a4_realdata/RESULTS.md, sections "Measured
recall (honest, the whole point of this run)" and "B4: fixing the detector's precision". Commands:
detect_and_build.py then detect_final.py --postprocess_type GREEDYNMM
--match_metric IOU --match_threshold 0.3 --conf 0.15 --overlap 0.2. Not re-run this session,
GPU and CrowdHuman not available in this environment; read from the file as recorded.
| Variant | MAE | Coverage (90% target) | Beats naive? |
|---|---|---|---|
| Naive count | 11.767 | not applicable | reference |
| One-directional, 1-D visibility curve | 22.475 | 39.33% | no |
| One-directional, multivariate curve | 28.228 | 40.67% | no |
| Two-sided estimator (min_p_detect=0.70, honestly selected) | 11.031 | 90.67% | yes |
Source: evidence/a4_realdata/RESULTS.md, sections "Real-data
result: naive got a lot better, corrected still does not beat it" (B4) and "Real-data result:
two-sided beats naive, with coverage near nominal" (B5). Commands: amodal calibrate,
amodal calibrate-intervals, amodal eval against the fixed detector's
cached output. Not re-run this session; read from the file as recorded.
| Slice | n | Naive MAE | Two-sided MAE | Coverage | Beats naive? |
|---|---|---|---|---|---|
| Full eval split | 150 | 11.767 | 11.031 | 90.67% | yes |
| Occlusion-heavy top 30% (mean occlusion fraction 0.877 vs 0.689) | 45 | 18.867 | 17.222 | 86.67% | yes |
| Rest (bottom 70%) | 105 | 8.724 | 8.378 | 92.38% | yes |
| Densest-crowd top 20 (by true count, up to 227 people) | 20 | 40.000 | 46.171 | 50.00% | no |
Source: evidence/a4_realdata/RESULTS.md, section "Where it wins,
and where the known limitation remains" (B5). Script: occlusion_subset_check.py. Slices
pre-specified before results were checked (occlusion split on CrowdHuman's own extra.occ
GT flag; density split on true count). Not re-run this session; read from the file as recorded.
| min_p_detect | z_scale_two_sided | MAE, interval split (n=40) |
|---|---|---|
| 0.30 (old one-directional default) | 4.505 | 14.527 |
| 0.50 | 5.011 | 12.459 |
| 0.60 | 5.268 | 11.937 |
| 0.65 | 5.413 | 11.846 |
| 0.70 (selected) | 5.568 | 11.807 |
| 0.75 | 5.744 | 11.839 |
| 0.80 | 5.943 | 11.965 |
| 0.90 | 6.621 | 12.432 |
Source: evidence/a4_realdata/RESULTS.md, section "Real-data
result: two-sided beats naive, with coverage near nominal" (B5). Script:
select_min_p.py / min_p_selection.json. Naive MAE on this same interval
split is 12.025 (n=40). The value used to report the headline eval-split result (0.70) was chosen on
this interval split alone; the eval split above was touched exactly once, after selection, per the
repository's own no-peeking rule. Not re-run this session; read from the file as recorded.
extrapolation_limited on 137 of 150 eval
scenes (91.3%): the estimator is querying past its calibrated support on most of the scenes the
90.67% coverage number above was measured on, and it states this per scene rather than hiding it.Two separate regimes, kept explicitly apart on this page. The synthetic bench numbers (crowding 0.0 to 0.8) come from generated worlds with a known ground-truth detectability curve, used to verify that the correction math itself is right. The real-data numbers come from 270 CrowdHuman validation images (6,809 ground-truth person boxes) run through a real SAHI-tiled YOLOv8s detector on rented GPU hardware.
Both, and each section says which claim applies to it. The synthetic table demonstrates the correction mechanism works when its own assumptions hold; it is not a claim about real-world detector behavior. The CrowdHuman section is the real-world test, and it is reported with its complete mixed result, including the specific case (the densest 20 scenes) where correction loses to naive counting.
This library corrects an existing detector's own output; it does not ship a detector, weights, or a training pipeline of its own. The code is proprietary and not publicly installable. Contact dhi-tech.com to discuss access or a partnership, including running the correction against your own detector's box output.
Through dhi-tech.com. The dataset and evidence trail linked in the footer are public; the implementation itself is not, and access is arranged directly.
Hugging Face requires a paid Team or Enterprise plan to host a live-compute (Gradio) Space under an
organization on the free tier. The 12 browsable scenes on this page were generated once, offline,
by the private amodal-counting codebase, and are browsed client-side here instead of
computed on request. The product source itself is proprietary and deliberately not included in this
Space; only the precomputed images, the summary JSON, and this page are.
Every number on this page traces to a file in the repository's own evidence trail, or to a command re-run in this update, cited next to the number. Where a result is negative, one-directional correction losing to naive counting on real data, the two-sided estimator still losing on the densest 20 scenes, it is reported in the same place and with the same weight as the wins, not omitted, softened, or buried in a footnote.