How to Perform Time-in-Zone Analytics with PolygonZone and LineZone

You can measure how long objects stay inside polygon regions or track line crossings by combining Supervision's PolygonZone and LineZone classes with a tracker like ByteTrack and timer utilities to calculate per-object dwell times.

Time-in-zone analytics measure object persistence in defined areas or duration to cross boundaries in video streams. The roboflow/supervision library provides specialized zone detection tools in src/supervision/detection/tools/polygon_zone.py and src/supervision/detection/line_zone.py that integrate with tracking and timing utilities to enable precise per-object analytics.

Core Components for Zone Analytics

PolygonZone

The PolygonZone class, implemented in src/supervision/detection/tools/polygon_zone.py, defines a closed polygon region and tests whether detection anchor points fall inside it. The trigger() method accepts a Detections object and returns a boolean mask indicating which detections are currently inside the zone. You can customize the anchor point via the triggering_anchors parameter, using values like sv.Position.BOTTOM_CENTER (default) or sv.Position.CENTER.

LineZone

The LineZone class, found in src/supervision/detection/line_zone.py, represents a directed line segment between two sv.Point coordinates. Its trigger() method returns two boolean masks—crossed_in and crossed_out—indicating objects that moved across the line in either direction. The class automatically maintains in_count, out_count, and per-class counters as attributes.

Trackers and Timers

Per-object timing requires stable identifiers across frames. The sv.ByteTrack class assigns a persistent tracker_id to each detection via update_with_detections(). To convert frame counts into seconds, Supervision provides timer utilities in examples/time_in_zone/utils/timers.py:

  • FPSBasedTimer: Calculates duration using the video's frame rate (constant FPS).
  • ClockBasedTimer: Calculates duration using system clock time (variable frame rates).

Measuring Polygon Zone Dwell Time

To calculate how long each object stays inside a polygonal region, create a zone for each region and a corresponding timer. Trigger the zone to filter detections, then pass those detections to the timer's tick() method.

import cv2
import numpy as np
import supervision as sv
from ultralytics import YOLO
from utils.general import load_zones_config, find_in_list
from utils.timers import FPSBasedTimer

# Initialize model and tracker

model = YOLO("yolov8s.pt")
tracker = sv.ByteTrack(minimum_matching_threshold=0.5)

# Configure video source

video_info = sv.VideoInfo.from_video_path("traffic.mp4")
frames_generator = sv.get_video_frames_generator("traffic.mp4")

# Load polygon definitions from JSON

polygons = load_zones_config("zones.json")
zones = [
    sv.PolygonZone(polygon=np.array(p), triggering_anchors=(sv.Position.CENTER,))
    for p in polygons
]

# Create one timer per zone

timers = [FPSBasedTimer(video_info.fps) for _ in zones]

# Initialize annotators

palette = sv.ColorPalette.from_hex(["#E6194B", "#3CB44B", "#FFE119", "#3C76D1"])
color_annotator = sv.ColorAnnotator(color=palette)
label_annotator = sv.LabelAnnotator(color=palette, text_color=sv.Color.from_hex("#000000"))

for frame in frames_generator:
    # Inference and tracking

    results = model(frame, verbose=False)[0]
    detections = sv.Detections.from_ultralytics(results)
    detections = detections[find_in_list(detections.class_id, [])]  # Optional filter

    detections = tracker.update_with_detections(detections)

    annotated = frame.copy()

    # Process each polygon zone

    for idx, zone in enumerate(zones):
        # Draw zone boundary

        annotated = sv.draw_polygon(annotated, zone.polygon, palette.by_idx(idx))

        # Filter detections inside this zone

        mask = zone.trigger(detections)
        detections_in_zone = detections[mask]

        # Calculate dwell time in seconds for each tracked object

        time_in_zone = timers[idx].tick(detections_in_zone)

        # Color detections by zone index

        custom_lookup = np.full(detections_in_zone.class_id.shape, idx)
        annotated = color_annotator.annotate(
            annotated, detections_in_zone, custom_color_lookup=custom_lookup
        )

        # Create labels: "#<tracker_id> mm:ss"

        labels = [
            f"#{tid} {int(t // 60):02d}:{int(t % 60):02d}"
            for tid, t in zip(detections_in_zone.tracker_id, time_in_zone)
        ]
        annotated = label_annotator.annotate(
            annotated, detections_in_zone, labels=labels, 
            custom_color_lookup=custom_lookup
        )

    cv2.imshow("Time-in-Zone", annotated)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break

cv2.destroyAllWindows()

Key implementation details:

  • zone.trigger() returns a boolean array aligned with the Detections container.
  • timer.tick() accepts the filtered detections and returns an array of elapsed seconds per tracker_id.
  • The label format #<tracker_id> MM:SS is the standard convention for displaying dwell time in Supervision annotations.

Tracking Line Zone Crossings

For counting objects crossing a boundary—such as entering or exiting a building—use LineZone with LineZoneAnnotator for visualization.

import cv2
import supervision as sv
from ultralytics import YOLO

model = YOLO("yolov8s.pt")
tracker = sv.ByteTrack()

# Define line across the frame (start -> end)

line = sv.LineZone(
    start=sv.Point(x=0, y=500),
    end=sv.Point(x=1280, y=500),
    triggering_anchors=(
        sv.Position.TOP_LEFT,
        sv.Position.TOP_RIGHT,
        sv.Position.BOTTOM_LEFT,
        sv.Position.BOTTOM_RIGHT,
    ),
)

annotator = sv.LineZoneAnnotator()

frames = sv.get_video_frames_generator("traffic.mp4")
for frame in frames:
    detections = sv.Detections.from_ultralytics(model(frame)[0])
    detections = tracker.update_with_detections(detections)

    # Get crossing masks and update internal counters

    crossed_in, crossed_out = line.trigger(detections)
    
    # Access counts via line.in_count and line.out_count

    frame = annotator.annotate(frame, line)

    cv2.imshow("Line-zone", frame)
    if cv2.waitKey(1) & 0xFF == ord("q"):
        break
        
cv2.destroyAllWindows()

Key implementation details:

  • LineZone.trigger() returns two boolean masks indicating directional crossings.
  • Internal counters line.in_count and line.out_count increment automatically when crossings are detected.
  • By default, LineZone checks all four corners of bounding boxes; customize this via triggering_anchors.

Combining Multiple Zone Types

You can mix polygon and line zones in a single pipeline by maintaining parallel lists of zones and timers, then iterating through them each frame:

zones = [poly_zone_1, poly_zone_2, line_zone]
timers = [FPSBasedTimer(fps), FPSBasedTimer(fps), None]  # Line zones don't use timers

for frame in frames:
    # ... inference and tracking ...

    
    for i, zone in enumerate(zones):
        if isinstance(zone, sv.PolygonZone):
            mask = zone.trigger(detections)
            seconds = timers[i].tick(detections[mask])
            # Annotate dwell time

        else:
            in_mask, out_mask = zone.trigger(detections)
            # Access zone.in_count and zone.out_count for display

            # Annotate with LineZoneAnnotator

This pattern allows simultaneous measurement of dwell time in parking spots (polygon) and traffic flow through entry lines (line) within the same frame processing loop.

Summary

Frequently Asked Questions

What is the difference between FPSBasedTimer and ClockBasedTimer?

FPSBasedTimer calculates elapsed time by dividing frame counts by the video's constant frame rate, making it ideal for recorded video files with stable FPS. ClockBasedTimer uses the system clock (time.time()) to measure actual seconds between calls, which is necessary for real-time streams or variable frame-rate sources where wall-clock time differs from frame count.

How does PolygonZone determine if an object is inside the zone?

PolygonZone tests specific anchor points of each bounding box against the polygon mask. By default, it uses sv.Position.BOTTOM_CENTER, but you can specify alternative anchors (e.g., sv.Position.CENTER or a tuple of positions) via the triggering_anchors constructor argument. The trigger() method returns a boolean mask where True indicates the anchor point lies inside the polygon.

Can LineZone track bidirectional movement?

Yes. LineZone tracks movement in both directions simultaneously. The trigger() method returns two separate boolean masks: crossed_in for objects moving from the outside to the inside (relative to the line's direction), and crossed_out for objects moving from inside to outside. The class automatically updates line.in_count and line.out_count accordingly.

How do I visualize the dwell time on video frames?

Supervision provides specialized annotators for zone visualization. Use sv.draw_polygon to render polygon boundaries, sv.ColorAnnotator to color-code detections by zone, and sv.LabelAnnotator to overlay text labels. Construct labels using the pattern f"#{tracker_id} {minutes:02d}:{seconds:02d}" to display the elapsed time calculated by your timer's tick() method, as demonstrated in the dwell-time example above.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →