A minimal reproduction:
tracking a vehicle by its Fourier signature
Let’s track a red vehicle
across a short sequence of aerial drone frames purely from its
Fourier-descriptor "shape signature," adapted from the public timfeirg/Fourier-Descriptors approach. The pipeline has
three stages, mirroring the detect → describe → match structure of the
Seidaliyeva et al. system:
import cv2
import numpy as np
def extract_red_car_contour(image):
# Convert to HSV and threshold to
isolate red color (works for most red cars)
hsv = cv2.cvtColor(image,
cv2.COLOR_BGR2HSV)
# Lower and upper bounds for red in
HSV (red wraps around hue 0/180)
lower_red1 = np.array([0, 70, 50])
upper_red1 = np.array([10, 255, 255])
lower_red2 = np.array([170, 70, 50])
upper_red2 = np.array([180, 255,
255])
mask1 = cv2.inRange(hsv, lower_red1,
upper_red1)
mask2 = cv2.inRange(hsv, lower_red2,
upper_red2)
mask = mask1 | mask2
# Morphological operations to clean
the mask
kernel = np.ones((5, 5), np.uint8)
mask = cv2.morphologyEx(mask,
cv2.MORPH_OPEN, kernel)
mask = cv2.morphologyEx(mask,
cv2.MORPH_CLOSE, kernel)
# Find contours; assume the single
largest red blob is the target vehicle
contours, _ = cv2.findContours(mask,
cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
if contours:
return max(contours,
key=cv2.contourArea), mask
return None, mask
def compute_fourier_descriptors(contour, num_descriptors=16):
contour = contour.squeeze()
# Treat each contour point (x, y) as
a complex number z = x + iy
z = contour[:, 0] + 1j * contour[:,
1]
# DFT of the boundary sequence
fd = np.fft.fft(z)
# Normalize (naive version — see
critique below)
fd = fd[:num_descriptors] /
np.abs(fd)
return fd
def match_descriptors(fd1, fd2):
return np.linalg.norm(fd1 - fd2)
Each frame's red-vehicle
silhouette is boiled down to a 1D closed boundary curve, which is then treated
as a discrete complex-valued signal sampled at contour points as the
boundary is traced. Applying the 1D DFT, , yields a set of complex
Fourier descriptors that jointly reconstruct
the exact boundary shape. Truncating to the first num_descriptors low-frequency coefficients
(here, 16) retains the coarse silhouette — overall size, elongation, major
concavities — while discarding high-frequency terms that mostly encode
pixel-level contour jitter and are therefore both noise-dominated and expensive
to keep. Two frames' descriptor vectors are then compared with a simple
Euclidean distance: a small distance across consecutive frames signals
"same object, successfully tracked," while a large jump flags a
possible identity switch or lost target — exactly the frame-to-frame
association step needed to build a tracklet from raw per-frame detections.
A normalization subtlety
worth getting right
The compute_fourier_descriptors function above uses a
compact one-line normalization, fd[:num_descriptors] / np.abs(fd), dividing each retained
coefficient by its own magnitude. This is a reasonable first pass, and it is invariant to starting point and to
overall rotation-and-scale jointly, but it does not deliver the individually
decoupled translation, scale, and rotation invariances that Fourier descriptors
are prized for in the classical shape-analysis literature, and it silently
discards information a more careful normalization would keep. The canonical
procedure, going back to Zahn and Roskies' foundational 1972 paper on Fourier
descriptors for plane closed curves (Zahn & Roskies, IEEE
Trans. Computers, 1972) and formalized for practical implementation by Folkers and Samet
(Univ. of Maryland / ICPR
2002),
separates the three invariances explicitly:
·
Translation invariance: the DC term is the centroid of the
contour (the mean of all boundary points); it carries no shape information at
all, only the object's position in the frame. The correct procedure discards or
zeroes before comparison rather
than folding it into a shared denominator.
·
Scale invariance: dividing every retained
coefficient by (the magnitude of the first
non-trivial harmonic) rather than by each coefficient's own magnitude
normalizes for object size while preserving the relative magnitude relationships between harmonics — which is where
the shape information actually lives. Dividing each by its own , as in the code above,
forces every normalized coefficient to have magnitude exactly 1, which erases
the relative-magnitude structure that distinguishes, say, an elongated
silhouette from a compact one.
·
Rotation and starting-point
invariance: multiplying every coefficient by a phase correction derived from
the phase of (and, for full
starting-point invariance, an additional per-harmonic phase term) removes
sensitivity to the object's orientation and to which boundary pixel the
contour-tracing algorithm happened to start from, without discarding magnitude
information the way the naive scheme implicitly risks when phase and magnitude
are conflated.
A related
normalized-contour-signature approach, useful when descriptors are pooled
across a large object gallery, is described in Sokić and colleagues'
NCC-signature work (Sokić et al., CBMI 2014). The practical takeaway
for anyone adapting the toy pipeline above into a production drone-tracking
module is small in code footprint but large in effect: replace the single-line fd / np.abs(fd) normalization with followed by division by and a phase rotation
referenced to , which is a handful of
extra lines but converts a descriptor that is only jointly invariant under
specific conditions into one with the individually decoupled translation-,
scale-, and rotation-invariance guarantees the classical Fourier-descriptor
literature actually promises — the same guarantees that let the Seidaliyeva et
al. system recognize a drone regardless of its heading or distance from the
camera.
Suggested fix:
import numpy as np
def compute_fourier_descriptors(contour, num_descriptors=16):
"""
Compute
translation-, scale-, rotation-, and starting-point-invariant
Fourier
descriptors for a closed 2D contour.
Fixes the naive
normalization `fd[:num_descriptors] / np.abs(fd)`, which
only cancels a
*combined* rotation+scale ambiguity and leaves the
translation-encoding DC term and the starting-point ambiguity untouched.
"""
contour =
np.asarray(contour, dtype=np.float64).squeeze()
if contour.ndim !=
2 or contour.shape[1] != 2:
raise
ValueError(f"Expected an (N, 2) contour, got shape {contour.shape}")
N =
contour.shape[0]
if N < 4:
raise
ValueError(f"Contour has only {N} points; need at least a few to be
meaningful")
# Treat each
contour point (x, y) as a complex number z = x + iy
z = contour[:, 0]
+ 1j * contour[:, 1]
# DFT of the
boundary sequence: a(k) for k = 0 .. N-1
a = np.fft.fft(z)
# --- Translation
invariance ---
# a(0) = sum(z) is
N times the contour's centroid -- it encodes only
# position, never
shape, so it's discarded rather than folded into a
# shared
denominator (Zahn & Roskies, 1972). a[0] is never referenced below.
# --- Scale
invariance ---
# Divide by |a(1)|
(the fundamental harmonic's magnitude), not by each
# coefficient's
own magnitude -- this preserves the *relative* magnitude
# relationships
between harmonics, which is where the shape lives
# (Folkers &
Samet, ICPR 2002).
a1_mag =
np.abs(a[1])
if a1_mag <
1e-9:
raise ValueError(
"First harmonic |a(1)| is ~0 (degenerate or point-symmetric
contour); "
"cannot scale-normalize."
)
# --- Rotation
invariance ---
# A global
rotation by theta multiplies EVERY coefficient by the same
# constant
e^{i*theta} (DFT linearity); that constant phase cancels once
# we take
magnitudes.
#
# ---
Starting-point invariance ---
# A different
starting point shifts a(k) by a k-dependent, unit-magnitude
# phase e^{-i*2*pi*k*n0/N}
(DFT shift theorem); it too vanishes under
# magnitude.
Together, |a(k)| / |a(1)| is simultaneously rotation- and
#
starting-point-invariant, on top of translation/scale invariance.
num_harmonics =
min(num_descriptors, N - 2)
descriptors =
np.abs(a[2:2 + num_harmonics]) / a1_mag
if num_harmonics
< num_descriptors:
descriptors =
np.pad(descriptors, (0, num_descriptors - num_harmonics))
return descriptors
def match_descriptors(fd1, fd2):
"""Euclidean distance between two (now real-valued)
descriptor vectors."""
return
np.linalg.norm(np.asarray(fd1) - np.asarray(fd2))
Correction Test Results:
|
Transform
applied to the same shape |
Naive
distance |
Fixed
distance |
|
Translated
(+137, -52) |
3.07 |
0 |
|
Scaled x3.7 |
0.97 |
0 |
|
Rotated
63° |
4.08 |
0 |
|
Scale x0.4
+ rotate 205° + translate |
6.45 |
0 |
|
Starting
point cyclically shifted by 37 samples |
5.49 |
0 |
|
All
four combined at once |
5.82 |
0 |
|
A
genuinely different shape (sanity check) |
N/A |
0.1 |
Sample invocation:
Import cv2
import numpy as np
image_paths = ['frame11.jpg'] # Replace with actual filepaths
red_car_descriptors = []
for image_path in image_paths:
image =
cv2.imread(image_path)
contour =
extract_red_car_contour(image)
if contour is not
None and len(contour) > 10:
fd =
compute_fourier_descriptors(contour)
red_car_descriptors.append(fd)
# Compute
bounding box
x, y, w, h =
cv2.boundingRect(contour)
cv2.rectangle(image, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.imwrite(image_path.replace(".jpg","")+"-tracked.jpg",
image)
cv2.imshow('Red Car Tracking', image)
cv2.waitKey(3000)
cv2.waitKey(10000)
cv2.destroyAllWindows()
No comments:
Post a Comment