

A Simple Detector with Frame Dynamics is a Strong Tracker
최근 러-우 전쟁 등에서 드론의 중요성 및 위험성이 대두되고 있다.
때문에 드론을 추적하는 것이 매우 중요한 Task로 이어지는데, 본 논문은 일반 객체 탐지기를 고성능 적외선 UAV 추적기로 변환하는 방법을 제안한다. 별도의 복잡한 추적 알고리즘을 사용하는 대신, YOLOv11 등의 입력에 Frame Difference이나 Optical Flow 정보를 결합하여 탐지기가 타겟의 움직임 특성을 직접 학습하도록 만들었다.
이러한 단순하고 효율적인 접근 방식을 통해 제 4회 Anti-UAV 챌린지에서 Track 1 우승 및 Track 2 준우승을 차지하며, 적외선 소형 드론 추적에서의 탁월한 성능을 입증했다.
객체 추적(Object Tracking)은 객체 탐지(Object Detection)의 확장판으로, Video Sequence에서 특정 타겟의 위치를 지속적으로 파악하는 기술이다.
이 기술은 Anti-UAV 애플리케이션에 필수적이며, 이 논문은 특히나 적외선 시나리오에서의 단일 UAV 추적에 초점을 맞추고 있다.
기존의 객체 추적기들이 주로 개체 탐지기(샴 네트워크 및 트랜스포머 기반 등)에 기반을 두고 발전해왔으나, 다음과 같은 단점이 존재했다.
1. Crop 방식의 문제
우리가 기존에 수행하던 방식은 템플릿 프레임에서 Target을 따로 Crop하여 사용하는데, 이는 Target이 매우 작거나 초기 위치를 알 수 없는 경우 부적절하다.
2. 시간적 정보 활용의 한계
이전 프레임의 타겟 위치를 기반으로 현재 프레임을 잘라내는 방식은 배경 억제(Background suppression)가 효과적이지 않아 모델이 배경과 타겟을 구분하지 못하게 만들 수 있다.
3. Dynamic Motion Pattern 학습 부재
단순히 위치만 참조할 뿐, 타겟의 동적인 움직임 패턴(Motion Patterns)을 학습하지 못한다.
4. 오류 누적
이전 프레임의 False Positive가 다음 프레임으로 계속 전파되어 추적 실패로 이어질 위험이 크다.
때문에 저자들은 이러한 단점들을 보완하기 위해 세 가지 조건이 필요하다고 한다.
* 배경 억제 (Background Suppression)이란?
복잡한 화면 속에서 내가 찾고자 하는 타겟(UAV) 이외의 모든 정보(건물, 구름, 새 등)을 무시하고 지워버리는 능력이다.
특히 적외선 영상에서는 모든 물체가 온도에 따라 붉게 표시되기 때문에 폭발로 인한 건물이나, 새와 같은 물체들이 드론과 유사하게 보여 구별이 어렵다.
만일 배경 억제가 잘 되지 않으면 드론을 놓치고, 최악의 경우 오인 추적하게 되는데, 다음 프레임에서도 오인한 위치를 기준으로 누적하여 오류가 발생한다.
이를 위해 다음과 같은 방법들을 도입했다.
* Frame Difference?
Frame Difference는 정적인 객체 탐지기가 타겟의 움직임과 시간적 변화를 학습할 수 있도록 입력 데이터를 새롭게 구성하는 핵심 기법이다.
현재 프레임()에 직전 프레임과의 차이와 전전 프레임과의 차이를 채널로 결합한다. 결과적으로 모델은 흑백의 1채널 적외선 이미지 대신, [현재 모습, 1단계 전 변화량, 2단계 전 변화량]을 담은 3채널 이미지를 입력받게 된다.
즉, Frame Difference를 통해 복잡한 배경은 지워지고 움직이는 드론만 하이라이트 되는 효과를 얻을 수 있고, 잡음과 실제 타겟을 쉽게 구별할 수 있게 된다.
* Optical Flow?변화량을 채널로..?
컴퓨터 비전 분야에서 '채널(Channel)'은 색상뿐만 아니라 '정보의 종류'를 담는 그릇으로 폭넓게 사용된다.
- 보통의 RGB이미지 : [빨강 밝기 행렬. 초록 밝기 행렬, 파랑 밝기 행렬]
- 변화량을 넣으면 : [현재 모습 행렬, 1초 전 변화 행렬, 2초 전 변화 행렬]
이렇게 입력 데이터의 Shape만 맞는다면, 그 안에 빨강색이 들어있든, 움직임 정보가 들어있든 상관없이 학습할 수 있다.
적외선 UAV 탐지는 낮은 신호 대 잡음비(SNR), 낮은 대비, 복잡하고 노이즈가 많은 배경 등으로 인해 매우 어렵다.
최근 이러한 분야에서 뛰어난 성능을 보인 모델들이 존재하는데, 대표적으로 Cascade R-CNN과 DINO, RepPoints 등이 있겠다.
대표적으로 Cascade R-CNN을 살펴보자면, IoU 임계값을 점진적으로 높이는 다단계 정제 전략을 사용하여 위치 정확도를 높이고 False Positives를 줄였다.
객체 추적은 탐지의 연속선상에 있는 기술로, 최근 기술 발전의 흐름을 다룬다.
초기에는 TransT나 Stark와 같은 연구들이 Feature Extraction이나 Fusion 영역을 강화했지만, 최근에는 MixFormer, SimTrack, OSTrack과 같이 템플릿과 검색 영역을 결합하여 상호작용하게 하는 one-stream 프레임워크로 발전했다.
* 기존 추적 모델(MixFormer, SimTrack 등)들은 Matching에 초점을 두어, 프레임을 두 가지 역할로 나누어 사용한다.
1. 템플릿 프레임 (Template Frame): 추적 대상이 처음 등장한 프레임이다. 여기서 대상을 잘라내서 모델에게 어떻게 생겼는지 Reference를 준다.
2. 검색 프레임 (Search Frame / Current Frame): 현재 대상을 찾아야 하는 프레임으로, 모델은 템플릿 프레임의 정보와 검색 프레임의 정보를 비교하여 대상을 찾는다.
* 논문에서 제시한 YOLO 기반
이 논문은 기존의 방식이 작은 타겟을 놓치거나 배경과 구분하지 못하는 단점이 있다고 하며 시간적 흐름(Dynamics)으로서의 프레임을 강조한다.
멀리 있는 '템플릿'을 가져오는 것이 아니라, 연속된 프레임 간의 변화 자체를 입력으로 사용한다.
- 현재 프레임() : 지금 분석중인 이미지
- 이전 프레임들() : 바로 직전 순간의 이미지들
이 두가지 프레임의 차이를 계산하여 입력 데이터로 사용한다.음.. 막상 쓰고나니 "두 프레임 차이를 계산" 하는거나, "과거 템플릿과 비교"하는거나, 거기서 거기 같은데, 프레임 차분 (Frame Difference) 방식이랑 템플릿 매칭 (Template Matching) 방식의 차이인데,
요약하자면, "이전 프레임에서 똑같은 것 찾기" 랑, **"이전 프레임에서 움직임이 발생한 곳 찾기""의 차이인 것이다. 다시 말해, 이전까지의 프레임과 '비교' 가 아니라 '시간 흐름에 따른 변화'인 것이다.
논문의 저자들은 YOLOv11-customized, Cascade R-CNN, DION, RepPoints, PAA등을 모델의 후보로 선정했다.
이 모델들은 단일 프레임 내의 객체 탐지는 뛰어나지만, 연속적인 추적을 위해서는 시간적 정보가 부족한 상태이다.
부족한 시간적 정보를 이겨내기 위해 입력 단계에서 시간적 정보를 주입하는 것이 가장 중요하다. 위에서 말했듯, 적외선 정보는 배경과 Target을 구별하기 매우 어려운데, 이를 해결하기 위해 프레임 역학 (Frame Dynamics)가 정보를 생성하여 원본 이미지와 결합하여 모델에 입력한다.
이에 두 가지 방식이 제안되었다.
프레임 차분 (Frame Difference)
- 현재 프레임()에 직전 프레임과의 차이와 전전 프레임과의 차이를 채널로 결합한다.
- 수식 : )
이를 통해 타겟의 단기적인 움직임과 특징 변화를 포착할 수 있다.
광학 흐름 (Optical FLow)
- Farneback 알고리즘을 사용하여 현재 프레임과 이전 프레임 사이의 광학 흐름을 계산한다.
- 현재 프레임()에 수평() 및 수직() 흐름 성분을 결합한다.
이를 통해 전역적 움직임 필드를 추정하여 배경 대비 타겟의 독특한 움직임을 학습한다.
위 두 과정을 통해 2차원 적외선 이미지는 시간 차원이 포함된 3차원 데이터로 변환되어, 탐지기가 추적기처럼 작동하게 된다.

추론 후처리 단계에서는 Trajectory-Constrained Filtering (TC-Filtering) 방식을 사용하여 모델이 탐지한 Bounding Box 중에서 False Positive를 제거한다.
물체의 움직임은 물리적 연속성을 가져야하기 때문에, 현재 프레임의 타겟 위치는 이전 프레임들의 위치를 기반으로 예측 가능한 범위 내에 있어야 한다.
말이 어려워서 그렇지, 이전 두 프레임(t-2, t-1)의 타겟 중심점을 이용하여 순간 속도를 계산하고, 이를 기반으로 현재 프레임()의 위치()를 예측한다는 것이다.
TC-Filtering은 3 단계 수식을 통해 작동한다.
먼저 전전 프레임()와 직전 프레임() 사이의 타겟 중심점 변화를 통해 타겟의 이동 속도()을 계산한다.
: 타겟의 속도 벡터로, 수평 방향()와 수직 방향()를 의미한다.
는 중심 좌표, 는 프레임 간 시간 간격
가로 세로 각각 얼마만큼 이동했는지를 시간으로 나누어 속도 벡터를 만든 것이다.
계산된 속도가 일정하게 유지된다 가정하고, 현재 프레임()에서 타겟이 있을 것이라 예상되는 위치()를 계산한다.
방금 움직인 만큼 똑같이 한 번 더 움직일 것이라는 등속 운동의 원리이다.
왜 2직전위치 - 전전위치 가 될까?
우리가 걷고 있다고 가정해보자.
1. 전전 위치() : 출발점
2. 직전 위치() : 현재 내 디딤발 위치
그럼, 다음 발()은 어디에 내딛게 될까?
물리적으로 갑자기 방향을 틀지 않는다면
방금 걸어온 보폭 만큼 앞으로 한 발 더 디딜 것이다.
- 보폭 : (지금 위치) - (아까 위치) =
- 다음 위치 : (지금 위치) + (보폭)
- 결과 :
이 식을 정리하면, 이 두 개 이므로,
가 된다.
예측된 위치 ()를 중심으로 반지름이 인 동적 제약 윈도우 (Motion Constraint Window)를 설정한다. 그리고 모델이 현재 프레임에서 탐지한 후보 박스들을 확인한다. (*동적 제약 윈도우는 BB가 아니라 원형 )
Frame Dynamics이 입력 단계에서 정보량을 늘려 탐지 능력을 높였다면, TC-Filtering은 출력 단계에서 논리적인 필터를 씌워 정확도를 확정 짓는 역할을 한다.
탐지기가 배경의 구름이나 새를 드론으로 착각하더라도, 그 위치가 예상 경로에서 벗어난다면, 과감하게 버려, 추적의 Robustness가 크게 향상된다.
단일 모델이 아닌 YOLOv11과 MMDetection 프레임워크를 모두 사용하여 앙상블 전략을 취했다. 각 프레임워크의 설정이 매우 디테일하다..
- 고해상도 입력 : YOLO(640)과는 달리 해상도로 훈련하여 미세한 특징을 포착했다.
- Optimizer : SGD 대신 AdamW + Scale 전략을 사용하여 수렴 속도와 성능을 높였다.
단순 탐지 결과만 내놓는 것이 아닌, 정교한 WBF(Weighted Box Fusion)과 TC-Filtering을 거친다.
YOLO 모델들과 MMDetection 모델들의 결과를 합친다.
제 4회 Anti-UAV Challenge에서 Track 1 우승(AOA 73,23), Track 2 준우승 (AOA 57.12)을 차지하였다.

단순 원본 이미지(RAW)만 사용했을 때보다 프레임 차분(FD)나 광학 흐름(OF)를 결합했을 때 AP와 AOA 점수가 일관되게 상승했다.
논문의 저자들은 모델의 성능에 영향을 미친 요소들을 전부 분석해보았다.
모든 프레임을 다 쓰는 것보다 5 프레임마다 하나씩(Stride 5) 건너뛰며 훈련하는 것이 성능이 더 좋았다. (AP 66.5 % 67.1%)
비디오 데이터는 인접 프레임 간 중복이 매우 심하기 때문에, 이를 전부 학습해버리면 모델이 미세한 변화에 과적합하기 때문이다.
보통 탐지 모델은 높은 신뢰도 (0.5 이상)을 요구하지만, 적외선 소형 타겟은 특징이 흐릿하기 때문에 임계값을 0.2로 낮춰 재현율(Recall)을 높이는 것이 중요했다. 박스 수를 5개로 줄인 것은 불필요한 노이즈를 컷하는데에 도움이 되었다.
SGD보다 AdamW + Scale + SyncBN 조합이 더 우수했다.
SyncBN(Synchronized Batch Normalization)은 배치 크기가 작을 때 정규화 편향을 줄여주어, 미세한 소형 타겟 학습에 효과적이었지만, 혼합 정밀도 훈련(AMP)는 오히려 수치적 불안정을 초래하여 성능이 떨어졌다고 한다.

이 작은걸 어떻게 찾아냈을까.......
여기서 P2 Detection Head의 중요성이 나온다.
또한, 스케일링 팩터를 조절하는 것이 중요한데,
즉, 소형 객체의 크기가 P2헤드의 수용 영역과 잘 맞아 떨어지도록 스케일링 팩터를 조절하는 것이 해상도를 무리하게 키우는 것보다 더 효과적임을 증명한다.
Optical Flow로는 Lucas-Kanade를 사용한다.
<환경 변수>
glob.glob을 이용해서 모든 비디오에 대한 검사 수행
각 비디오 내 모든 프레임에 대한 검사 수행
Ground Truth 불러오기
num_frames(검사를 수행할 프레임의 수)의 결정 기준
curr_gray에 현재의 프레임을 저장
track_active가 False인 경우 : Detector(YOLO) 사용해야 함
[else] track_active가 True : 추적 중인 대상이 있음 -> Tracker (SiamFC) 사용
해당 로직에서는 Tracker (SiamFC)를 통해서 Update된 Bounding Box를 받아옴. <->
만약 받아온 pred_bbox의 길이가 1이라는 것은 추적에 실패한 것 -> 환경 변수 초기화
(6, 7)번을 통해 설정된 환경 변수에 따라 prev_gray에 curr_gray를 복사. 이후 p0로 Features 추출해서 저장해놓기 + gmc=True로 초기화
8 이후에 다음 Frame으로 넘어가서 curr_gray에 현재(넘어간) 프레임을 저장.
이 때는 8에서 초기화된 환경 변수에 따라 elif track_active and gmc_initialized로 넘어감 :
def iou(bbox1, bbox2):
bbox1 = [float(x) for x in bbox1]
bbox2 = [float(x) for x in bbox2]
(x0_1, y0_1, w1_1, h1_1) = bbox1
(x0_2, y0_2, w1_2, h1_2) = bbox2
x1_1 = x0_1 + w1_1
x1_2 = x0_2 + w1_2
y1_1 = y0_1 + h1_1
y1_2 = y0_2 + h1_2
overlap_x0 = max(x0_1, x0_2)
overlap_y0 = max(y0_1, y0_2)
overlap_x1 = min(x1_1, x1_2)
overlap_y1 = min(y1_1, y1_2)
if overlap_x1 - overlap_x0 <= 0 or overlap_y1 - overlap_y0 <=0:
return 0
size_1 = (x1_1 - x0_1) * (y1_1 - y0_1)
size_2 = (x1_2 - x0_2) * (y1_2 - y0_2)
size_intersection = (overlap_x1 - overlap_x0) * (overlap_y1 - overlap_y0)
size_union = size_1 + size_2 - size_intersection
return size_intersection / size_union
def not_exist(pred):
return (len(pred) == 1 and pred[0] == 0) or (len(pred) == 0)
def eval(out_res, label_res):
measure_per_frame = []
penalty_measure = []
for _pred, _gt, _exist in zip(out_res, label_res['gt_rect'], label_res['exist']):
# Target 존재 x : _exist==False | Target 존재 O : _exist == True
measure_per_frame.append(not_exist(_pred) if not _exist else iou(_pred, _gt) if len(_pred) > 1 else 0)
if _exist:
if (len(_pred) > 1 and iou(_pred, _gt) > 1e-5):
penalty_measure.append(0)
else:
penalty_measure.append(1)
if len(measure_per_frame) == 0:
measure_per_frame_mean = 0
else:
measure_per_frame_mean = np.mean(measure_per_frame)
if len(penalty_measure) == 0:
penalty_measure_mean = 0
else:
penalty_measure_mean = np.mean(penalty_measure)
return measure_per_frame_mean - 0.2 * (penalty_measure_mean ** 0.3)
import torch
def main(mode='IR', visualization=False):
assert mode in ['IR', 'RGB'], 'Only Support IR or RGB to evaluate'
global drive_path
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
print(f"현재 사용 장치 : {device}")
net_path = os.path.join(drive_path, "Baseline_code", 'model.pth')
tracker = TrackerSiamFC(net_path = net_path)
yolo_model = tracker.initialize_yolo()
yolo_model.to(device)
tracker.net.to(device)
video_paths = glob.glob(os.path.join(os.path.join('/content/data/track1_test'), "*"))
video_num = len(video_paths)
output_dir = os.path.join('results', tracker.name)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
overall_performance = []
for video_id, video_path in enumerate(video_paths, start=1):
video_name = os.path.basename(video_path) # 파일 경로에서 마지막 구성 요소만 추출 \\ 제일 마지막꺼
video_file = os.path.join(video_path, "%s.mp4"%mode)
frame_files = sorted(
[f for f in os.listdir(video_path) if f.endswith(('.jpg', '.jpeg', 'png', '.bmp'))])
# .txt로 굳이 변환하지 않고 json에서 읽어옴
res_file = os.path.join(video_path, 'IR_label.json')
with open(res_file, 'r') as f:
label_res = json.load(f)
if 'exist' not in label_res:
label_res['exist'] = [1] * len(label_res['gt_rect'])
output_file = os.path.join(output_dir, '%s.txt'% video_name)
if os.path.exists(output_file):
with open(output_file, 'r') as file:
content = file.read()
data = json.loads(content)
out_res = data['res']
else:
frame_id = 0
out_res = []
pred_bbox = [0] # no prediction
for frame_file in frame_files:
frame_path = os.path.join(video_path, frame_file)
frame = cv2.imread(frame_path)
im_vis = frame.copy()
if len(pred_bbox) == 1:
pred_bbox, im_vis = tracker.init(frame, yolo_model)
if isinstance(pred_bbox, (np.ndarray, list)):
pred_bbox = list(map(float, pred_bbox))
out_res.append(pred_bbox)
cv2.putText(im_vis, str(frame_id), (40, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
if len(pred_bbox) == 1:
cv2.putText(im_vis, 'Fail to detect the UAV', (100, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
else:
pred_bbox = list(map(int, pred_bbox))
cv2.rectangle(im_vis, (pred_bbox[0], pred_bbox[1]), (pred_bbox[0] + pred_bbox[2], pred_bbox[1] + pred_bbox[3]), (0, 0, 255), 3)
else:
pred_bbox = tracker.update(frame) # tracking
pred_bbox = list(map(int, pred_bbox))
out_res.append(pred_bbox)
cv2.rectangle(im_vis, (pred_bbox[0], pred_bbox[1]), (pred_bbox[0] + pred_bbox[2], pred_bbox[1] + pred_bbox[3]), (0, 0, 255), 3)
cv2.putText(im_vis, str(frame_id), (40, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
if visualization:
cv2.imshow(video_name, im_vis)
cv2.waitKey(1)
frame_id += 1
if visualization:
cv2.destroyAllWindows()
ouptut_file = os.path.join(output_dir, "%s.txt" % video_name)
clean_out_res = []
for bbox in out_res:
if(len(bbox) > 1):
clean_out_res.append([float(x) for x in bbox])
else:
clean_out_res.append(bbox)
with open(output_file, 'w') as f:
json.dump({'res' : clean_out_res} ,f)
mixed_measure = eval(out_res, label_res)
overall_performance.append(mixed_measure)
print("[%03d/%03d] %20s %5s Fixed Measure : %.03f" % (video_id, video_num, video_name, mode, mixed_measure))
print("[Overall] %5s Mixed Measure: %.03f\n" % (mode, np.mean(overall_performance)))
if __name__ == '__main__':
main(mode='IR', visualization=False)
# NMS
with dt[2]:
pred = non_max_suppression(pred, self.cfg.conf_thres, self.cfg.iou_thres, self.cfg.classes,
self.cfg.agnostic_nms, max_det=self.cfg.max_det)
# TC-Filtering
Farneback : Two-Frame Motion Estimation Based on Polynomial Expansion
WBS : Weighted boxes fusion: Ensembling boxes from different object detection models