계수, 히트맵 및 속도 추정
추적을 바탕으로 라인 계수기, 영역 점유율, 히트맵, 속도 추정치를 만들고, 정확하게 보정합니다.
대부분의 프로덕션 비전 시스템은 model.predict()이 아닙니다. 추적기에 간단한 기하 계산을 더한 형태입니다. 객체가 통과하는 선, 객체가 점유하는 영역, 픽셀을 미터로 환산하는 척도가 그 예입니다. 결과가 잘못되는 원인은 기하 계산에 있습니다. 이 강의의 데모 클립에서 속도 추정기가 보행자의 속도를 실제 속도의 4분의 1인 시속 1km로 보고하는데, 이는 추측한 숫자 하나 때문입니다.
실습형
추적과 기하 계산#
ultralytics.solutions의 각 솔루션 클래스는 내부적으로 추적 모드를 실행한 다음, 트랙 이력에 자체 기하 계산을 적용합니다. pip install "ultralytics[solutions]"로 추가 패키지를 한 번에 설치하세요.
| 클래스 | 기하 형태 | 주요 결과 |
|---|---|---|
ObjectCounter | 점 2개 = 선, 점 3개 이상 = 다각형 | in_count, out_count, classwise_count |
RegionCounter | 이름이 지정된 다각형 하나 이상 | region_counts |
Heatmap | 시간에 따른 박스 영역 누적 | plot_im(영역이 주어진 경우 계수도 수행) |
SpeedEstimator | 트랙 이동 거리 × meter_per_pixel | speed_dict(트랙 ID별 km/h) |
솔루션은 기본적으로 tracker="tracktrack.yaml"을 사용합니다. 이전 강의에서 검증한 추적기를 전달하세요.
라인 통과 계수하기#

계수선은 선분입니다. 객체는 두 프레임 사이에서 박스 중심이 선을 통과할 때 한 번 계산되며, 어느 쪽에서 왔는지에 따라 IN 또는 OUT이 결정됩니다. 따라서 위 컨베이어 예시에는 추적이 필요합니다. ID가 없다면 상자 #015가 선에 닿는 매 프레임마다 다시 계산되기 때문입니다.
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}}클립의 2초 동안 네 명이 광장 중앙을 가로질렀으며, 양방향으로 두 명씩 이동했습니다. 기본 TrackTrack 구성으로 같은 스크립트를 실행하면 in=0 out=1을 보고합니다. 트랙 수가 적으면 통과 횟수도 적기 때문입니다. counted.mp4을 열고 수치를 신뢰하기 전에 각 통과 장면을 눈으로 확인하세요.

도로에서는 동일한 선으로 교통량을 방향별로 나눕니다. 위쪽으로 선을 통과하는 차량은 IN, 아래쪽으로 통과하는 차량은 OUT으로 계산합니다. 객체가 선을 깨끗하게 직각으로 통과하는 위치에 선을 배치하고, 프레임 가장자리, 가림 객체, 박스가 작아지는 도로 끝부분은 피하세요.
영역 내 점유 수 계산하기#
다각형 영역을 사용하면 "현재 안에 객체가 몇 개 있나요?"라는 질문에 답할 수 있습니다. 영역 계수 가이드에는 바로 사용할 수 있는 RegionCounter이 포함되어 있습니다. 내부적으로는 추적된 각 박스에 대해 점-다각형 포함 여부를 한 번 검사합니다.
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]은 각 박스의 중심입니다. 대기열과 체류 시간을 계산하려면 영역에 들어온 시각을 ID별 타임스탬프로 저장하세요.
속도 추정하기#

위의 각 레이블은 차량 뒤의 궤적에서 계산됩니다. 즉, 알려진 프레임 수 동안 트랙이 이동한 픽셀 거리입니다. 속도 = 픽셀 거리 × 픽셀당 미터 ÷ 경과 시간입니다. 두 입력값에 따라 결과의 정확도가 결정됩니다.
- 픽셀당 미터. 장면에서 알려진 길이(바닥 타일, 차선 표시, 주차 공간 등)를 객체가 움직이는 깊이에서 측정하세요. 카메라가 기울어져 있다면 대신 네 점 호모그래피로 지면을 매핑하세요.
- 실제 캡처 프레임률. 속도는 FPS에 비례합니다. 영상을 재인코딩하거나 느리게 재생하면 모든 속도 값도 같은 비율로 달라집니다.
이 루프는 각 박스의 하단 중앙(객체와 지면이 맞닿는 지점)을 기준으로 측정하고, 각 트랙의 값을 10프레임에 걸쳐 평균 냅니다:
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/h사람은 4–5 km/h로 걸으므로, 2.1 km/h라는 값은 이 장면에 적용한 예시 스케일이 잘못되었음을 보여줍니다. pixels_per_meter 값은 여기서 측정한 값이 아닙니다. 중앙값을 알고 있는 속도와 비교하는 이 타당성 검사가 중요한 습관입니다.
solutions.SpeedEstimator은 같은 아이디어를 프로덕션용으로 구현합니다. 각 트랙의 박스 중심을 max_hist 프레임에 걸쳐 측정하고, meter_per_pixel를 사용해 변환한 뒤, ID마다 하나의 속도 값으로 고정합니다(상한은 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/h같은 이유로 다시 약 4배 차이가 납니다. 신체 크기를 기준으로 스케일을 추정했기 때문입니다. 이와 같은 탑다운 카메라에서는 박스 중심을 사용해도 괜찮지만, 비스듬한 각도의 카메라에서는 하단 중앙 루프나 호모그래피를 사용하는 것이 좋습니다.
히트맵 누적하기#

히트맵은 매 프레임에서 추적 중인 각 박스 아래에 열을 누적하므로, 교통량이 가장 많은 차선이나 통로가 가장 밝게 표시됩니다. region을 지정하면 위 고속도로 예시처럼 객체 수도 셉니다:
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) # 누적된 히트 오버레이가 표시된 마지막 프레임여러 시간 분량의 영상에서 같은 오버레이를 통해 혼잡한 통로, 사용되지 않는 구역, 그리고 사람들이 실제로 이동하는 경로를 확인할 수 있습니다. 운동 모니터링과 Streamlit 실시간 추론 UI도 같은 구성 요소로 만들 수 있습니다.
주의할 점#
증상: 카운터 파이프라인의 FPS가 예상치의 절반입니다. 해결 방법: ObjectCounter, Heatmap, SpeedEstimator는 내부적으로 탐지와 추적을 수행합니다. 이러한 함수를 자체 model.track() 루프로 감싸 r.orig_img를 전달하지 말고, 원본 프레임을 입력하세요.
증상: 보행자가 시속 1km로 이동하거나 차량이 시속 300km로 이동하는 것으로 표시됩니다. 해결 방법: 객체가 움직이는 구간에서 알려진 길이를 기준으로 meter_per_pixel을 측정하고, 카메라의 실제 FPS를 확인한 다음, 중앙값을 알려진 속도(보행 속도 또는 제한 속도)와 비교합니다.
객체가 카메라에 가까워지고 박스가 커지면 기하학적 중심은 위로 이동합니다. 하단 중앙은 객체가 지면에 닿는 위치를 근사하므로, 하단 중앙에서 측정한 거리는 실제 움직임과 훨씬 더 가깝게 대응합니다.
정답 보기
프레임에서 직접 측정할 수 있는 대상을 기준으로 보정한 다음, 타당성을 확인하세요:
# 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.0300pixels_per_meter을 speed_loop.py에 설정하거나, meter_per_pixel를 SpeedEstimator에 설정하고 다시 실행하세요. 보행자의 속도가 여전히 4–5 km/h와 크게 다르면 스케일을 잘못된 깊이에서 측정했거나 FPS가 잘못된 것입니다. 클립을 절반 속도로 느리게 재생하면 모든 측정값도 절반으로 줄어듭니다. 데모 클립에서는 두 스크립트 모두 같은 추정 스케일(픽셀당 0.03 m, 즉 미터당 33픽셀)을 사용했고, 실제보다 2–4배 낮은 값을 얻었습니다. 따라서 이 영상의 픽셀 하나가 추정치보다 더 넓은 지면을 나타내거나, 클립의 실제 캡처 프레임률이 30 FPS가 아닐 수 있습니다. 어느 쪽인지는 장면에서 직접 측정해야만 알 수 있습니다.