Skip to content

fix(detection): stop LineZone counting sub-threshold flicker as a crossing - #2600

Merged
Borda merged 11 commits into
roboflow:developfrom
Souptik96:fix/2598-line-zone-flicker-spurious-crossing
Sep 21, 2026
Merged

Borda merged 11 commits into
roboflow:developfrom
Souptik96:fix/2598-line-zone-flicker-spurious-crossing

Conversation

@Souptik96

Copy link
Copy Markdown
Contributor

Description

LineZone.trigger counted a spurious crossing in the opposite direction when a tracked object flickered to the far side of the line for fewer than minimum_crossing_threshold frames.

A crossing was confirmed by only two conditions: the per-tracker history deque was full, and the oldest entry differed from every other entry (crossing_history.count(oldest_state) == 1). Nothing verified that the oldest state had itself been held for minimum_crossing_threshold frames.

With minimum_crossing_threshold=2 (history length 3) and the side sequence A,A,A,B,A,A,A, the deque at frame 6 is [B,A,A]: the oldest B occurs exactly once, the guard passes, and a crossing into A is counted — the side the object never left. Counts drift by one per flicker, in the wrong direction. Because the history is only threshold + 1 deep, it structurally cannot distinguish "settled on B" from "flickered to B for one frame".

Minimal repro:

import numpy as np
import supervision as sv

ABOVE = [2.0, -6.0, 3.0, -4.0]  # TOP_LEFT anchor above the line
BELOW = [2.0, 4.0, 3.0, 6.0]  # TOP_LEFT anchor below the line


def counts(sides, threshold=2):
    zone = sv.LineZone(
        start=sv.Point(0, 0),
        end=sv.Point(10, 0),
        triggering_anchors=[sv.Position.TOP_LEFT],
        minimum_crossing_threshold=threshold,
    )
    for side in sides:
        box = ABOVE if side == "A" else BELOW
        zone.trigger(
            sv.Detections(
                xyxy=np.array([box], dtype=np.float32),
                tracker_id=np.array([0]),
            )
        )
    return zone.in_count, zone.out_count


print(counts("AAAAAA"))           # no flicker
print(counts("AAABAAA"))          # one sub-threshold flicker
print(counts("AAABAAABAAABAAA"))  # three separated flickers
print(counts("AAABBB"))           # genuine sustained crossing

On develop:

(0, 0)
(1, 0)   <- spurious: object never left side A
(3, 0)   <- one spurious count per flicker
(0, 1)

With this PR:

(0, 0)
(0, 0)
(0, 0)
(0, 1)   <- genuine sustained crossing still counted, on the same frame as before

Motivation and Context

A single frame of bounding-box jitter near the line silently corrupts the counts, and in the wrong direction, which is worse than missing a crossing: totals drift monotonically over a long video and in_count/out_count stop being trustworthy. This is the exact failure mode minimum_crossing_threshold exists to prevent.

Closes #2598

Changes Made

  • LineZone now tracks the last side of the line each tracker was confirmed on (_confirmed_crossing_side), seeded from the tracker's first observed side.
  • A crossing is counted only when the current side differs from the confirmed side and has been held for the full minimum_crossing_threshold consecutive frames. The side is then promoted to confirmed. A sub-threshold excursion never becomes confirmed, so settling back on the original side produces no count.
  • The confirmed side is dropped alongside the crossing history in _evict_stale_crossing_history, so a reused tracker ID starts fresh instead of inheriting a stale reference side.
  • Clarified the minimum_crossing_threshold docstring to state that excursions shorter than the threshold count neither as a crossing nor as a return crossing.
  • Added TestLineZoneSubThresholdFlicker to tests/detection/test_line_counter.py (8 parametrized sequences + per-tracker isolation + eviction), extending the existing file rather than adding a new one.

Deliberately out of scope:

  • crossing_history_length, crossing_state_history and the tracker-absence eviction tolerance are unchanged. crossing_history_length doubles as the ByteTrack-coasting absence tolerance, so deepening the history to distinguish sustained from flickering states would have changed unrelated behaviour. Comparing against a confirmed side is exact rather than heuristic and needs no extra history depth.
  • No change to the vectorized anchor-side computation.

Testing

$ pytest -q
3915 passed, 19 skipped in 84.35s

Baseline on develop before the change: 3905 passed, 19 skipped — the delta is exactly the 10 new tests, and no previously passing test fails.

New tests failing on develop and passing here — reverting only src/supervision/detection/line_zone.py while keeping the new tests:

$ pytest tests/detection/test_line_counter.py::TestLineZoneSubThresholdFlicker -q
FAILED ...[one-frame-flicker] - assert (1, 0) == (0, 0)
FAILED ...[three-separated-flickers] - assert (3, 0) == (0, 0)
FAILED ...[two-frame-flicker-under-threshold] - assert (1, 0) == (0, 0)
FAILED ...[crossing-after-flicker] - assert (1, 1) == (0, 1)
FAILED ...::test_flicker_does_not_leak_between_trackers - assert (1, 1) == (0, 1)
5 failed, 5 passed

With the fix restored:

$ pytest tests/detection/test_line_counter.py::TestLineZoneSubThresholdFlicker -q
10 passed

The 5 cases that pass either way are guards that the fix is not over-correcting: sustained crossings, a crossing followed by a genuine return, a genuine crossing occurring after an earlier flicker, and minimum_crossing_threshold=1 (the default) behaviour.

Linting and types:

$ pre-commit run --files src/supervision/detection/line_zone.py \
    tests/detection/test_line_counter.py docs/changelog.md
ruff check.........Passed    docformatter......Passed    mypy........Passed
ruff format........Passed    mdformat (mkdocs).Passed    codespell...Passed
check doctest fences...Passed

The prettier hook could not run in my local environment (its npm registry needs authentication). It matches only *.yaml/*.toml, and this PR changes neither, so it is a no-op here; CI covers it.

  • Added/updated tests, and the full suite passes locally
  • Updated docstrings for the changed public behaviour
  • Added a changelog entry in docs/changelog.md under Unreleased

Additional Notes

Behaviour deliberately preserved, and covered by tests:

  • Genuine sustained crossings are still counted, on the same frame as before — the existing four minimum_crossing_threshold parametrize cases in test_line_zone_one_detection_long_horizon pass unchanged.
  • minimum_crossing_threshold=1 (the default) is unchanged.
  • Per-tracker state stays isolated: one tracker's flicker cannot affect another's real crossing.

Two subtleties a reviewer may want to look at:

  • The confirmed side is seeded on the tracker's first observed frame, before the history-fullness check. Seeding it later swallows the first legitimate crossing when minimum_crossing_threshold=1 — I hit exactly that while developing.
  • The required sustained run is expressed as crossing_history_length - 1, which equals minimum_crossing_threshold and is inherently clamped to at least 1, so the threshold does not need to be stored separately.
  • The len(crossing_history) < self.crossing_history_length guard is now strictly redundant given the seeding rule, but is kept because it makes the "a full window must exist" precondition explicit. Happy to remove it if you prefer.

One correction to the issue's repro: reproducing the 3-flicker count requires the flickers to be separated by a settled run (AAAB × 3). Three consecutive alternations (AAABABABAAA) produce only one spurious count on develop, because alternating states keep count(oldest) > 1.

Per AGENTS.md §6 the changelog entry should link this PR; I will add the link once this PR has a number.

AI-assistance disclosure: this change was developed with the assistance of an AI coding agent (Claude). All code, tests, and the before/after measurements reported above were reviewed and verified locally against the full test suite.

…rossing

`LineZone.trigger` confirmed a crossing whenever the oldest entry of its
`minimum_crossing_threshold + 1` frame history differed from every later
entry. That guard never checked that the tracker had actually been settled
on the side it supposedly came from, so a single sub-threshold excursion was
read as a crossing back into the side the tracker never left.

With `minimum_crossing_threshold=2` the side sequence `A,A,A,B,A,A,A` left
`[B,A,A]` in the history: the oldest `B` occurred once, the guard passed,
and a crossing into `A` was counted. Counts drifted by one per flicker, in
the wrong direction; three separated flickers gave `in_count=3` instead of
`0`. A history only `threshold + 1` deep structurally cannot tell "settled
on B" from "flickered to B for one frame".

Track the last side each tracker was confirmed on and measure crossings
against that instead of against the oldest history entry. A side is only
promoted to confirmed once it has been held for the full threshold, so a
flicker that reverts early never becomes the reference and produces no
count. The confirmed side is dropped alongside the history on eviction so a
reused tracker ID starts fresh.

Sustained crossings, `minimum_crossing_threshold=1` (the default), and
per-tracker isolation are unchanged.

Fixes roboflow#2598
@Souptik96
Souptik96 requested a review from SkalskiP as a code owner September 18, 2026 09:54
@Borda
Borda requested a balanced review from Copilot September 18, 2026 10:13
@Borda Borda added the bug Something isn't working label Sep 18, 2026
@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91%. Comparing base (3169ae5) to head (0f06a41).

Additional details and impacted files
@@           Coverage Diff           @@
##           develop   #2600   +/-   ##
=======================================
  Coverage       91%     91%           
=======================================
  Files           78      78           
  Lines        11455   11462    +7     
=======================================
+ Hits         10461   10469    +8     
+ Misses         994     993    -1     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The changelog needs its required PR link, and the threshold documentation should accurately describe valid observations rather than consecutive frames.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes LineZone false crossings caused by brief side flicker.

Changes:

  • Tracks each tracker’s last confirmed side.
  • Adds flicker, crossing, isolation, and eviction tests.
  • Documents behavior and updates the changelog.
File summaries
File Description
src/supervision/detection/line_zone.py Implements confirmed-side crossing logic.
tests/detection/test_line_counter.py Adds regression and state-isolation tests.
docs/changelog.md Records the bug fix.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/changelog.md Outdated
Comment thread src/supervision/detection/line_zone.py Outdated
Souptik96 and others added 10 commits September 18, 2026 16:11
AGENTS.md section 6 requires each changelog entry to link the PR rather
than the source issue. The entry was added before this PR had a number,
so the link is appended now, in the same trailing style as the roboflow#2582 and
roboflow#2588 entries.
[resolve No.2] Review by Copilot + foundry:doc-scribe (PR roboflow#2600):
"[docs] 'Consecutive frames' docstring wording (src/supervision/detection..."
Challenge: evidence=VALID suggestion=VALID resolution=as-suggested

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
[resolve No.3] Review by foundry:doc-scribe (PR roboflow#2600):
"[docs] A-1: narrow an overstated guarantee. Three artifacts currently as..."
Challenge: evidence=VALID suggestion=VALID resolution=as-suggested

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
…stain gate

[resolve No.4] Review by foundry:qa-specialist (PR roboflow#2600):
"T-1: all ten existing TestLineZoneSubThresholdFlicker parametrized cases..."
Challenge: evidence=VALID suggestion=REJECT resolution=self-resolved (straddle case, not trivial reuse)

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
[resolve No.5] Review by foundry:sw-engineer (PR roboflow#2600):
"[code] A-2: the gate at line_zone.py:219 (len(crossing_history) < self.c..."
Challenge: evidence=VALID suggestion=VALID resolution=as-suggested

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
[resolve No.6] Review by foundry:perf-optimizer (PR roboflow#2600):
"[perf] A-4/P-1: at line_zone.py:228, list(crossing_history)[-sustained_f..."
Challenge: evidence=VALID suggestion=VALID resolution=as-suggested

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
[resolve No.7] Review by foundry:linting-expert (PR roboflow#2600):
"[code] S-1: _confirmed_crossing_side: dict[int, bool] at line_zone.py:12..."
Challenge: evidence=VALID suggestion=VALID resolution=as-suggested

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
…tion test

[resolve No.8] Review by foundry:linting-expert (PR roboflow#2600):
"test_flicker_does_not_leak_between_trackers zips two side-sequence strin..."
Challenge: evidence=VALID suggestion=VALID resolution=as-suggested

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
… dropped-frame excursion, and concurrent flicker isolation

[resolve No.9] Review by foundry:qa-specialist (PR roboflow#2600):
"T-2..T-5 bundle: mirror-direction flicker, sustained crossing at thresho..."
Challenge: evidence=VALID suggestion=VALID resolution=as-suggested (T-4 needed the noted test-loop extension)

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@Borda
Borda merged commit a29aa3a into roboflow:develop Sep 21, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: LineZone: sub-threshold flicker produces a spurious crossing in the opposite direction

3 participants