From 0454c52ed5a3aea26fbf9ee430cb87f94950c76e Mon Sep 17 00:00:00 2001 From: rikunosuke Date: Sun, 17 May 2026 02:36:33 +0900 Subject: [PATCH 1/6] chore: bump opencv-python to >=4.10.0 and add cv2 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #273 関連。numpy 2.x への移行に向けた事前準備として、opencv-python の下限を引き上げ、numpy 2 対応ビルドを取り込めるようにする。 あわせて cv2 を使用している箇所のテストを追加し、今回のバージョン アップおよび後続の numpy 移行のセーフティネットとする。 Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 3 +- requirements.txt | 2 +- tests/conftest.py | 50 ++++++++++++++ tests/test_converters_video.py | 115 +++++++++++++++++++++++++++++++++ tests/test_lerobot_v3_video.py | 61 +++++++++++++++++ tests/test_mask_image_util.py | 101 +++++++++++++++++++++++++++++ tests/test_utils.py | 14 ++++ 7 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_converters_video.py create mode 100644 tests/test_lerobot_v3_video.py create mode 100644 tests/test_mask_image_util.py create mode 100644 tests/test_utils.py diff --git a/pyproject.toml b/pyproject.toml index f5420d4..e940b57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "geojson>=2.0.0,<4.0", "xmltodict==0.12.0", "Pillow>=10.0.0,<11.0.0", - "opencv-python>=4.0.0,<5.0.0", + "opencv-python>=4.10.0,<5.0.0", "aiohttp>=3.8.5" ] @@ -24,6 +24,7 @@ dynamic = ["version"] [project.optional-dependencies] robotics = ["pandas>=2.0.0", "pyarrow>=14.0.0"] +dev = ["pytest>=7.0.0"] [tool.setuptools] include-package-data = true diff --git a/requirements.txt b/requirements.txt index d193759..86dceb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,5 @@ numpy>=1.26.0,<2.0.0 geojson>=2.0.0,<4.0 xmltodict==0.12.0 Pillow>=10.0.0,<11.0.0 -opencv-python>=4.0.0,<5.0.0 +opencv-python>=4.10.0,<5.0.0 aiohttp>=3.8.5 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7e18f89 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import cv2 +import numpy as np +import pytest + + +def _write_synthetic_video( + path: Path, + num_frames: int = 10, + width: int = 64, + height: int = 48, + fps: int = 10, + fourcc_code: str = "mp4v", +) -> Path: + fourcc = cv2.VideoWriter_fourcc(*fourcc_code) + writer = cv2.VideoWriter(str(path), fourcc, fps, (width, height)) + if not writer.isOpened(): + pytest.skip(f"cv2.VideoWriter could not open {path} with codec {fourcc_code}") + try: + for i in range(num_frames): + frame = np.full((height, width, 3), i * 20 % 255, dtype=np.uint8) + writer.write(frame) + finally: + writer.release() + if not path.exists() or path.stat().st_size == 0: + pytest.skip("Synthetic video could not be created (codec unavailable).") + return path + + +@pytest.fixture +def synthetic_video(tmp_path): + def _factory( + name: str = "video.mp4", + num_frames: int = 10, + width: int = 64, + height: int = 48, + fps: int = 10, + fourcc_code: str = "mp4v", + ) -> Path: + return _write_synthetic_video( + tmp_path / name, + num_frames=num_frames, + width=width, + height=height, + fps=fps, + fourcc_code=fourcc_code, + ) + + return _factory diff --git a/tests/test_converters_video.py b/tests/test_converters_video.py new file mode 100644 index 0000000..5c5eaad --- /dev/null +++ b/tests/test_converters_video.py @@ -0,0 +1,115 @@ +import os + +import cv2 +import pytest + +from fastlabel import converters +from fastlabel.exceptions import FastLabelInvalidException + + +class TestVideoCapture: + def test_yields_open_capture_and_releases(self, synthetic_video): + video_path = synthetic_video(name="sample.mp4", num_frames=5) + + with converters.VideoCapture(str(video_path)) as cap: + assert cap.isOpened() + ret, frame = cap.read() + assert ret is True + assert frame is not None + + def test_releases_capture_on_exit(self, synthetic_video): + video_path = synthetic_video(name="sample.mp4") + + with converters.VideoCapture(str(video_path)) as cap: + captured = cap + + # After release, reading should fail or return falsy ret + ret, _ = captured.read() + assert ret is False + + def test_releases_capture_on_exception(self, synthetic_video): + video_path = synthetic_video(name="sample.mp4") + + captured = None + with pytest.raises(RuntimeError): + with converters.VideoCapture(str(video_path)) as cap: + captured = cap + raise RuntimeError("boom") + + ret, _ = captured.read() + assert ret is False + + +class TestExportImageFilesForVideoFile: + def test_writes_one_jpg_per_frame(self, synthetic_video, tmp_path): + num_frames = 7 + video_path = synthetic_video(name="sample.mp4", num_frames=num_frames) + output_dir = tmp_path / "frames" + + names = converters._export_image_files_for_video_file( + file_path=str(video_path), + output_dir_path=str(output_dir), + basename="sample", + ) + + assert len(names) == num_frames + for name in names: + assert name.endswith(".jpg") + assert name.startswith("sample_") + assert (output_dir / name).is_file() + + def test_zero_padding_matches_total_frame_digits(self, synthetic_video, tmp_path): + num_frames = 12 + video_path = synthetic_video(name="sample.mp4", num_frames=num_frames) + output_dir = tmp_path / "frames" + + names = converters._export_image_files_for_video_file( + file_path=str(video_path), + output_dir_path=str(output_dir), + basename="vid", + ) + + # 12 frames -> 2 digit zero padding ("00".."11") + assert names[0] == "vid_00.jpg" + assert names[-1] == f"vid_{num_frames - 1:02d}.jpg" + + def test_written_frames_are_readable_images(self, synthetic_video, tmp_path): + video_path = synthetic_video( + name="sample.mp4", num_frames=3, width=64, height=48 + ) + output_dir = tmp_path / "frames" + + names = converters._export_image_files_for_video_file( + file_path=str(video_path), + output_dir_path=str(output_dir), + basename="frame", + ) + + for name in names: + img = cv2.imread(str(output_dir / name)) + assert img is not None + assert img.shape == (48, 64, 3) + + def test_unopenable_file_raises(self, tmp_path): + bogus = tmp_path / "not_a_video.mp4" + bogus.write_bytes(b"not a real video") + + with pytest.raises(FastLabelInvalidException): + converters._export_image_files_for_video_file( + file_path=str(bogus), + output_dir_path=str(tmp_path / "frames"), + basename="x", + ) + + def test_creates_output_directory_if_missing(self, synthetic_video, tmp_path): + video_path = synthetic_video(name="sample.mp4", num_frames=2) + output_dir = tmp_path / "does" / "not" / "exist" + + assert not output_dir.exists() + converters._export_image_files_for_video_file( + file_path=str(video_path), + output_dir_path=str(output_dir), + basename="frame", + ) + assert output_dir.is_dir() + assert len(os.listdir(output_dir)) == 2 diff --git a/tests/test_lerobot_v3_video.py b/tests/test_lerobot_v3_video.py new file mode 100644 index 0000000..b3be063 --- /dev/null +++ b/tests/test_lerobot_v3_video.py @@ -0,0 +1,61 @@ +import cv2 +import pytest + +from fastlabel.exceptions import FastLabelInvalidException +from fastlabel.lerobot import v3 + + +class TestExtractVideoSegment: + def test_extracts_requested_number_of_frames(self, synthetic_video, tmp_path): + source = synthetic_video(name="src.mp4", num_frames=20, width=64, height=48) + output = tmp_path / "segment.mp4" + + v3._extract_video_segment( + video_path=source, + start_frame=5, + num_frames=8, + output_path=output, + ) + + assert output.is_file() + cap = cv2.VideoCapture(str(output)) + try: + count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + finally: + cap.release() + + assert count == 8 + assert (width, height) == (64, 48) + + def test_stops_when_source_ends(self, synthetic_video, tmp_path): + source = synthetic_video(name="src.mp4", num_frames=10) + output = tmp_path / "segment.mp4" + + v3._extract_video_segment( + video_path=source, + start_frame=8, + num_frames=50, + output_path=output, + ) + + cap = cv2.VideoCapture(str(output)) + try: + count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + finally: + cap.release() + + assert count == 2 + + def test_unopenable_file_raises(self, tmp_path): + bogus = tmp_path / "not_a_video.mp4" + bogus.write_bytes(b"garbage") + + with pytest.raises(FastLabelInvalidException): + v3._extract_video_segment( + video_path=bogus, + start_frame=0, + num_frames=1, + output_path=tmp_path / "out.mp4", + ) diff --git a/tests/test_mask_image_util.py b/tests/test_mask_image_util.py new file mode 100644 index 0000000..063809a --- /dev/null +++ b/tests/test_mask_image_util.py @@ -0,0 +1,101 @@ +import cv2 +import numpy as np + +from fastlabel.utils import mask_image_util + + +def _make_rect_mask( + width: int, height: int, x1: int, y1: int, x2: int, y2: int +) -> np.ndarray: + mask = np.zeros((height, width), dtype=np.uint8) + mask[y1:y2, x1:x2] = 255 + return mask + + +def _make_rect_mask_with_hole( + width: int, height: int, outer: tuple, hole: tuple +) -> np.ndarray: + mask = np.zeros((height, width), dtype=np.uint8) + ox1, oy1, ox2, oy2 = outer + hx1, hy1, hx2, hy2 = hole + mask[oy1:oy2, ox1:ox2] = 255 + mask[hy1:hy2, hx1:hx2] = 0 + return mask + + +class TestMaskToPolygon: + def test_empty_mask_returns_empty(self): + mask = np.zeros((50, 50), dtype=np.uint8) + assert mask_image_util.mask_to_polygon(mask) == [] + + def test_single_rect_returns_flat_int_list(self): + mask = _make_rect_mask(100, 100, 20, 30, 60, 70) + points = mask_image_util.mask_to_polygon(mask) + + assert isinstance(points, list) + assert len(points) >= 6 + assert len(points) % 2 == 0 + assert all(isinstance(p, int) for p in points) + + xs = points[0::2] + ys = points[1::2] + assert min(xs) >= 19 and max(xs) <= 60 + assert min(ys) >= 29 and max(ys) <= 70 + + def test_accepts_file_path(self, tmp_path): + mask = _make_rect_mask(80, 80, 10, 10, 50, 50) + path = tmp_path / "mask.png" + cv2.imwrite(str(path), mask) + + points = mask_image_util.mask_to_polygon(str(path)) + assert isinstance(points, list) + assert len(points) >= 6 + + +class TestMaskToSegmentation: + def test_empty_mask_returns_empty(self): + mask = np.zeros((50, 50), dtype=np.uint8) + assert mask_image_util.mask_to_segmentation(mask) == [] + + def test_single_rect_returns_one_polygon(self): + mask = _make_rect_mask(100, 100, 20, 30, 60, 70) + result = mask_image_util.mask_to_segmentation(mask) + + assert isinstance(result, list) + assert len(result) == 1 + polygon = result[0][0] + assert len(polygon) >= 10 + assert len(polygon) % 2 == 0 + assert all(isinstance(v, (int, np.integer)) for v in polygon) + + def test_two_separate_rects_returns_two_polygons(self): + mask = np.zeros((100, 200), dtype=np.uint8) + mask[10:40, 10:40] = 255 + mask[60:90, 120:160] = 255 + + result = mask_image_util.mask_to_segmentation(mask) + assert len(result) == 2 + + def test_rect_with_hole_includes_inner_contour(self): + mask = _make_rect_mask_with_hole( + 100, 100, outer=(10, 10, 80, 80), hole=(30, 30, 60, 60) + ) + result = mask_image_util.mask_to_segmentation(mask) + + assert len(result) >= 1 + assert len(result[0]) >= 2 + + def test_accepts_file_path(self, tmp_path): + mask = _make_rect_mask(80, 80, 10, 10, 50, 50) + path = tmp_path / "mask.png" + cv2.imwrite(str(path), mask) + + result = mask_image_util.mask_to_segmentation(str(path)) + assert len(result) == 1 + + +class TestMaskToFastlabelSegmentationPointsAtUtils: + def test_module_level_import(self): + from fastlabel.utils import mask_to_segmentation + + assert mask_to_segmentation is mask_image_util.mask_to_segmentation diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..80fe8e1 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,14 @@ +from fastlabel import utils + + +class TestIsVideoSupportedCodec: + def test_h264_returns_true(self, synthetic_video): + video_path = synthetic_video(name="sample.mp4", fourcc_code="h264") + + assert utils.get_video_fourcc(str(video_path)) == "h264" + assert utils.is_video_supported_codec(str(video_path)) is True + + def test_mp4v_returns_false(self, synthetic_video): + video_path = synthetic_video(name="sample.mp4", fourcc_code="mp4v") + + assert utils.is_video_supported_codec(str(video_path)) is False From 38c3ea89e2589a1b208d9999b796c848c10687cf Mon Sep 17 00:00:00 2001 From: rikunosuke Date: Mon, 18 May 2026 15:02:03 +0900 Subject: [PATCH 2/6] ci: split lint/test jobs and run pytest on python 3.9/3.12 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6c97a2d..cf08943 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ on: - main jobs: - sdk-test: + lint: runs-on: ubuntu-latest steps: @@ -22,10 +22,6 @@ jobs: cache: pip python-version: "3.10.11" - - name: Install dependencies - run: | - pip install -r requirements.txt - - name: Install Python tools run: | pip install black==22.10.0 flake8==5.0.4 isort==5.11.5 @@ -37,4 +33,30 @@ jobs: run: flake8 . - name: Run isort - run: isort --check . \ No newline at end of file + run: isort --check . + + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5 + with: + cache: pip + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -e ".[dev]" + + - name: Run pytest + run: pytest From 30ef08fbb71cbcb20d44a83a588fa1e1be133a16 Mon Sep 17 00:00:00 2001 From: rikunosuke Date: Mon, 18 May 2026 16:00:27 +0900 Subject: [PATCH 3/6] ci: expand test matrix to python 3.8-3.14 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf08943..41dabd0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout From 8e73695489df528a6c88c3f1598601fffaad850b Mon Sep 17 00:00:00 2001 From: rikunosuke Date: Mon, 18 May 2026 16:47:07 +0900 Subject: [PATCH 4/6] ci: drop python 3.8 from test matrix numpy>=1.26 requires python>=3.9, so 3.8 cannot install the SDK. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41dabd0..f3f4570 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout From 819957f463ee8bb117bf2abe6e5c560f1d7701a9 Mon Sep 17 00:00:00 2001 From: rikunosuke Date: Mon, 18 May 2026 17:16:11 +0900 Subject: [PATCH 5/6] ci: drop python 3.13 and 3.14 from test matrix Pillow<11.0.0 has no wheels for python 3.13+, and source build fails on 3.14. Re-add once Pillow constraint is bumped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f3f4570..d58eb62 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - name: Checkout From 60f5d874d673a18140d01e7b0113d931cab212f1 Mon Sep 17 00:00:00 2001 From: rikunosuke Date: Mon, 18 May 2026 17:22:57 +0900 Subject: [PATCH 6/6] chore: drop unused aiohttp dependency aiohttp is not imported anywhere in the codebase. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 3 +-- requirements.txt | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e940b57..5978ed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,7 @@ dependencies = [ "geojson>=2.0.0,<4.0", "xmltodict==0.12.0", "Pillow>=10.0.0,<11.0.0", - "opencv-python>=4.10.0,<5.0.0", - "aiohttp>=3.8.5" + "opencv-python>=4.10.0,<5.0.0" ] dynamic = ["version"] diff --git a/requirements.txt b/requirements.txt index 86dceb7..76513bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,4 @@ numpy>=1.26.0,<2.0.0 geojson>=2.0.0,<4.0 xmltodict==0.12.0 Pillow>=10.0.0,<11.0.0 -opencv-python>=4.10.0,<5.0.0 -aiohttp>=3.8.5 \ No newline at end of file +opencv-python>=4.10.0,<5.0.0 \ No newline at end of file