Comptage, cartes thermiques et estimation de la vitesse
Crée des compteurs de franchissement de ligne, des décomptes d’occupation de zone, des cartes thermiques et des estimations de vitesse à partir du suivi, puis calibre-les avec rigueur.
La plupart des systèmes de vision en production ne sont pas model.predict(). Ils reposent sur un tracker et un peu de géométrie : une ligne que les objets franchissent, une zone qu’ils occupent, une échelle qui convertit les pixels en mètres. C’est dans cette géométrie que les résultats déraillent : l’estimateur de vitesse appliqué à l’extrait de démonstration de cette leçon donne 1 km/h pour des piétons qui marchent, soit un quart de leur vitesse réelle, à cause d’une seule valeur estimée.
Pratique
Suivi et géométrie#
Chaque classe de solution dans ultralytics.solutions exécute le mode Track en interne, puis applique sa propre géométrie à l’historique des pistes. Installe une seule fois les dépendances supplémentaires avec pip install "ultralytics[solutions]".
| Classe | Géométrie | Principaux résultats |
|---|---|---|
ObjectCounter | 2 points = ligne, 3 points ou plus = polygone | in_count, out_count, classwise_count |
RegionCounter | Un ou plusieurs polygones nommés | region_counts |
Heatmap | Accumule les surfaces des boîtes au fil du temps | plot_im (avec les décomptes si une région est fournie) |
SpeedEstimator | Déplacement de la piste × meter_per_pixel | speed_dict (km/h par ID de piste) |
Les solutions utilisent tracker="tracktrack.yaml" par défaut ; passe le tracker que tu as validé dans la leçon précédente.
Compter les franchissements de ligne#

Une ligne de comptage est un segment. Un objet est compté une seule fois, lorsque le centre de sa boîte franchit la ligne entre deux images ; le côté d’où il vient détermine s’il s’agit de IN ou de OUT. C’est pourquoi le convoyeur ci-dessus nécessite un suivi : sans ID, la boîte #015 serait comptée à nouveau à chaque image où elle touche la ligne.
import cv2
from ultralytics import solutions
from ultralytics.utils.downloads import safe_download
video = safe_download("https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4")
cap = cv2.VideoCapture(str(video)) # 640x360, 30 FPS, 62 frames
w, h, fps = (int(cap.get(p)) for p in (cv2.CAP_PROP_FRAME_WIDTH, cv2.CAP_PROP_FRAME_HEIGHT, cv2.CAP_PROP_FPS))
writer = cv2.VideoWriter("counted.mp4", cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
counter = solutions.ObjectCounter(
model="yolo26n.pt",
region=[(0, 180), (640, 180)], # two points: a horizontal line across the middle of the frame
classes=[0], # person
tracker="bytetrack.yaml",
verbose=False,
)
while True:
ok, frame = cap.read()
if not ok:
break
results = counter(frame) # detect + track + count in one call
writer.write(results.plot_im)
cap.release()
writer.release()
print(f"in={results.in_count} out={results.out_count} classwise={results.classwise_count}")in=2 out=2 classwise={'person': {'IN': 2, 'OUT': 2}}Quatre personnes ont traversé le milieu de la place en deux secondes dans l’extrait : deux dans chaque sens. Avec la configuration TrackTrack par défaut, le même script renvoie in=0 out=1, car moins de pistes signifie moins de franchissements. Ouvre counted.mp4 et vérifie visuellement chaque franchissement avant de te fier au résultat.

Sur une route, la même ligne sépare le trafic selon sa direction : les véhicules qui la franchissent vers le haut sont comptés comme IN, ceux qui la franchissent vers le bas comme OUT. Place la ligne là où les objets la franchissent proprement et à angle droit, à l’écart des bords de l’image, des obstacles et du fond de la route où les boîtes sont minuscules.
Compter l’occupation d’une zone#
Un polygone répond à la question « combien d’objets se trouvent à l’intérieur en ce moment ? ». Le guide du comptage par région fournit un RegionCounter prêt à l’emploi ; en dessous, il s’agit d’un test d’appartenance à un polygone pour chaque boîte suivie :
from shapely.geometry import Point, Polygon
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
video = "https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4"
zone = Polygon([(100, 100), (500, 100), (500, 300), (100, 300)]) # pixel coordinates
peak = 0
for r in model.track(video, stream=True, tracker="bytetrack.yaml", classes=[0], verbose=False):
if not r.boxes.is_track:
continue
inside = sum(zone.contains(Point(cx, cy)) for cx, cy in r.boxes.xywh[:, :2].tolist())
peak = max(peak, inside)
print(f"peak occupancy: {peak} people inside the zone")peak occupancy: 10 people inside the zonexywh[:, :2] correspond au centre de chaque boîte. Pour les files d’attente et les durées de présence, conserve un horodatage par ID indiquant quand l’objet est entré dans la zone.
Estimer la vitesse#

Chaque étiquette ci-dessus est calculée à partir de la trajectoire laissée derrière le véhicule : la distance parcourue par sa piste en pixels sur un nombre connu d’images. Vitesse = distance en pixels × mètres par pixel ÷ durée écoulée. Deux paramètres déterminent la justesse du résultat :
- Mètres par pixel. Mesure une longueur connue dans la scène (un carreau de sol, un marquage de voie, une place de stationnement) à la profondeur où les objets se déplacent. Pour les caméras inclinées, établis plutôt une homographie à quatre points du plan du sol.
- Le véritable débit de capture. La vitesse varie proportionnellement au FPS ; une vidéo réencodée ou ralentie fait varier toutes les vitesses dans la même proportion.
Cette boucle mesure le centre inférieur de chaque boîte (là où l’objet touche le sol) et calcule la moyenne de chaque trajectoire sur 10 images :
from collections import defaultdict, deque
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
video = "https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4"
fps = 30 # the real capture rate, not the playback rate
pixels_per_meter = 33 # example: a 1.2 m floor feature spans 40 px; measure your own scene
prev = {} # track ID -> last bottom-center point
window = defaultdict(lambda: deque(maxlen=10)) # about 0.3 s of speeds per track at 30 FPS
for r in model.track(video, stream=True, tracker="bytetrack.yaml", classes=[0], verbose=False):
if not r.boxes.is_track:
continue
for (x1, y1, x2, y2), tid in zip(r.boxes.xyxy.tolist(), r.boxes.id.int().tolist()):
cx, cy = (x1 + x2) / 2, y2 # bottom-center sits on the ground plane
if tid in prev:
px, py = prev[tid]
meters = ((cx - px) ** 2 + (cy - py) ** 2) ** 0.5 / pixels_per_meter
window[tid].append(meters * fps * 3.6) # km/h
prev[tid] = (cx, cy)
smoothed = sorted(sum(s) / len(s) for s in window.values() if len(s) == s.maxlen)
print(f"{len(smoothed)} tracks; median {smoothed[len(smoothed) // 2]:.1f} km/h")29 tracks; median 2.1 km/hLes gens marchent à 4–5 km/h ; la valeur de 2,1 km/h indique donc que l’échelle de l’exemple ne convient pas à cette scène : la valeur pixels_per_meter n’a pas été mesurée ici. Cette vérification rapide, qui consiste à comparer la médiane à une vitesse que tu connais, est le bon réflexe.
solutions.SpeedEstimator reprend la même idée pour la production : il mesure le centre de la boîte de chaque trajectoire sur max_hist images, convertit la mesure avec meter_per_pixel et fixe une vitesse par ID (plafonnée à max_speed) :
import cv2
from ultralytics import solutions
from ultralytics.utils.downloads import safe_download
video = safe_download("https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4")
cap = cv2.VideoCapture(str(video))
speed = solutions.SpeedEstimator(
model="yolo26n.pt",
classes=[0],
tracker="bytetrack.yaml",
fps=30, # the real capture rate, not the playback rate
meter_per_pixel=0.03, # a guess: a ~0.45 m shoulder width spans ~15 px here
max_hist=15, # 0.5 s of history before a speed is locked
verbose=False,
)
speeds = {}
while True:
ok, frame = cap.read()
if not ok:
break
speeds.update(speed(frame).speed_dict) # {track_id: km/h} once locked
cap.release()
kmh = sorted(speeds.values())
print(f"{len(kmh)} tracks; median {kmh[len(kmh) // 2]} km/h, range {kmh[0]}-{kmh[-1]} km/h")25 tracks; median 1 km/h, range 0-4 km/hLà encore, la valeur est environ 4 fois trop faible, pour la même raison : une échelle estimée à partir de la taille du corps. Le centre de la boîte convient aux caméras en vue du dessus comme celle-ci ; avec des caméras inclinées, préfère la boucle utilisant le centre inférieur ou une homographie.
Accumuler une carte thermique#

Une carte thermique ajoute de la chaleur sous chaque boîte suivie à chaque image ; les voies ou les allées les plus fréquentées sont donc les plus lumineuses. Donne-lui un region et elle comptera aussi les objets, comme sur l’autoroute ci-dessus :
import cv2
from ultralytics import solutions
from ultralytics.utils.downloads import safe_download
video = safe_download("https://github.com/ultralytics/assets/releases/download/v0.0.0/solutions_ci_demo.mp4")
cap = cv2.VideoCapture(str(video))
heatmap = solutions.Heatmap(
model="yolo26n.pt",
classes=[0],
tracker="bytetrack.yaml",
colormap=cv2.COLORMAP_PARULA,
verbose=False,
)
while True:
ok, frame = cap.read()
if not ok:
break
results = heatmap(frame)
cap.release()
cv2.imwrite("heatmap.jpg", results.plot_im) # dernière image avec la superposition thermique accumuléeSur plusieurs heures de vidéo, la même superposition révèle les allées encombrées, les zones inutilisées et les trajets réellement empruntés par les gens. Le suivi des entraînements et une interface Streamlit d’inférence en direct sont construits à partir des mêmes composants.
Pièges#
Symptôme : un pipeline de comptage fonctionne à la moitié du FPS attendu. Solution : ObjectCounter, Heatmap et SpeedEstimator effectuent la détection et le suivi en interne. Ne les place pas dans ta propre boucle model.track() et ne leur transmets pas r.orig_img ; fournis-leur des images brutes.
Symptôme : des piétons à 1 km/h ou des voitures à 300 km/h. Solution : mesure meter_per_pixel à partir d'une longueur connue sur le trajet des objets, confirme le véritable FPS de la caméra et compare la médiane à une vitesse connue (allure de marche ou limite affichée).
Le centre géométrique monte lorsque l'objet s'approche de la caméra et que sa boîte s'agrandit. Le bas-centre estime l'endroit où l'objet touche le sol : les distances mesurées à partir de ce point suivent donc bien mieux le déplacement réel.
Afficher la solution
Calibre à partir d’un élément mesurable dans l’image, puis vérifie la cohérence du résultat :
# Two points along a floor feature whose real length you know (read them off a saved frame)
(x1, y1), (x2, y2) = (100, 300), (140, 300)
real_length_m = 1.2 # e.g. measured tile width or lane marking
pixels = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
print(f"pixels_per_meter = {pixels / real_length_m:.1f}, meter_per_pixel = {real_length_m / pixels:.4f}")pixels_per_meter = 33.3, meter_per_pixel = 0.0300Place pixels_per_meter dans speed_loop.py (ou meter_per_pixel dans SpeedEstimator), puis relance le script. Si la vitesse des piétons reste très éloignée de 4–5 km/h, l’échelle a été mesurée à une profondeur incorrecte ou le FPS est erroné : un clip ralenti à la moitié de sa vitesse divise toutes les mesures par deux. Sur le clip de démonstration, les deux scripts ont utilisé la même échelle estimée (0,03 m par pixel, soit 33 pixels par mètre) et ont obtenu des valeurs 2 à 4 fois trop faibles ; un pixel couvre donc peut-être plus de terrain que l’estimation, ou le débit de capture réel du clip n’est pas de 30 FPS. Seule une mesure dans la scène permet de trancher.