Tuesday, July 28, 2026

 

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