diff --git a/src/trackers/io/video.py b/src/trackers/io/video.py index 43c6a2b4..91228d7f 100644 --- a/src/trackers/io/video.py +++ b/src/trackers/io/video.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import re from collections.abc import Iterator from pathlib import Path @@ -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` @@ -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}") diff --git a/tests/io/test_video.py b/tests/io/test_video.py index 24a0b2f6..e74fa953 100644 --- a/tests/io/test_video.py +++ b/tests/io/test_video.py @@ -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.""" @@ -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)) + + 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))