Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/trackers/io/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import logging
import re
from collections.abc import Iterator
from pathlib import Path

Expand All @@ -29,7 +30,8 @@ def frames_from_source(
Args:
source: Video file path, RTSP/HTTP stream URL, webcam index, or path to a
directory containing images (`.jpg`, `.jpeg`, `.png`, `.bmp`, `.tif`,
`.tiff`).
`.tiff`). Directory entries are ordered naturally, so unpadded numeric
names such as `2.jpg` and `10.jpg` are read in numeric order.

Returns:
Iterator of `(frame_id, frame)` tuples where `frame_id` is 1-based and `frame`
Expand Down Expand Up @@ -70,12 +72,32 @@ def _iter_capture_frames(
cap.release()


def _natural_sort_key(path: Path) -> tuple[str | int, ...]:
"""Build a sort key that orders embedded digit runs by value rather than as text.

Plain lexicographic sorting puts `10.jpg` before `2.jpg`, which silently feeds an image sequence to a tracker in
the wrong temporal order. Splitting the name into alternating text and digit runs and comparing digit runs as
integers restores numeric order for unpadded names, while leaving zero-padded and non-numeric names unaffected.

Args:
path: Image file path; only the file name participates in the key.

Returns:
Tuple of alternating text and integer parts. `re.split` with a capturing group always yields text at even
positions and digits at odd ones, so keys compare position-wise without mixing types.
"""
return tuple(int(part) if part.isdigit() else part for part in re.split(r"(\d+)", path.name))


def _iter_image_folder_frames(
folder: Path,
*,
extensions: frozenset[str] = IMAGE_EXTENSIONS,
) -> Iterator[tuple[int, np.ndarray]]:
images = sorted(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in extensions)
images = sorted(
(p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in extensions),
key=_natural_sort_key,
)

if not images:
raise ValueError(f"No supported image files found in directory: {folder}")
Expand Down
38 changes: 37 additions & 1 deletion tests/io/test_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ def directory_with_non_image_files(tmp_path: Path) -> Path:
return directory


@pytest.fixture
def directory_with_unpadded_numeric_names(tmp_path: Path) -> Path:
"""Directory of unpadded numeric image names whose lexicographic order differs from numeric order.

Names run `1.png` to `11.png`, which sort lexicographically as 1, 10, 11, 2, 3, ... Each frame is filled with a
distinct multiple of 20 so no two frames share a pixel value.
"""
directory = tmp_path / "unpadded"
directory.mkdir()
for index in range(1, 12):
frame = np.full((FRAME_HEIGHT, FRAME_WIDTH, 3), index * 20, dtype=np.uint8)
cv2.imwrite(str(directory / f"{index}.png"), frame)
return directory


@pytest.fixture
def directory_with_corrupted_image(tmp_path: Path) -> Path:
"""Directory with valid images followed by one corrupted image file."""
Expand Down Expand Up @@ -149,7 +164,28 @@ def test_nonexistent_video_raises_value_error(self) -> None:


class TestFramesFromSourceImageDirectory:
def test_reads_images_in_alphabetical_order(self, image_directory_factory) -> None:
def test_reads_unpadded_numeric_names_in_numeric_order(self, directory_with_unpadded_numeric_names) -> None:
"""Unpadded numeric names are ordered numerically, so `2.png` precedes `10.png`."""
frames = list(frames_from_source(directory_with_unpadded_numeric_names))

assert len(frames) == 11
for frame_id, frame in frames:
assert np.all(frame == frame_id * 20), f"Frame {frame_id} is out of order"

def test_reads_non_numeric_names_alphabetically(self, tmp_path: Path) -> None:
"""Names without digits keep plain alphabetical order."""
directory = tmp_path / "alphabetic"
directory.mkdir()
for index, stem in enumerate(("alpha", "beta", "gamma")):
cv2.imwrite(str(directory / f"{stem}.png"), create_frame(index))

frames = list(frames_from_source(directory))

assert len(frames) == 3
for frame_id, frame in frames:
assert np.all(frame == expected_frame_value(frame_id - 1))
Comment on lines +179 to +186

def test_reads_zero_padded_images_in_order(self, image_directory_factory) -> None:
num_frames = 7
directory = image_directory_factory(n_frames=num_frames, filename_pattern="{:04d}.png")
frames = list(frames_from_source(directory))
Expand Down