From 3f896286372a8213933b3f08eae5446c2a0181e3 Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 12 Mar 2026 06:35:25 -0300 Subject: [PATCH 1/5] add pascalvoc export --- src/labelformat/formats/__init__.py | 2 + .../semantic_segmentation/pascalvoc.py | 198 +++++++++++++++++- .../semantic_segmentation/test_pascalvoc.py | 188 ++++++++++++++++- 3 files changed, 386 insertions(+), 2 deletions(-) diff --git a/src/labelformat/formats/__init__.py b/src/labelformat/formats/__init__.py index c667f7f..844a330 100644 --- a/src/labelformat/formats/__init__.py +++ b/src/labelformat/formats/__init__.py @@ -29,6 +29,7 @@ ) from labelformat.formats.semantic_segmentation.pascalvoc import ( PascalVOCSemanticSegmentationInput, + PascalVOCSemanticSegmentationOutput, ) from labelformat.formats.yolov5 import ( YOLOv5ObjectDetectionInput, @@ -89,6 +90,7 @@ "PascalVOCObjectDetectionInput", "PascalVOCObjectDetectionOutput", "PascalVOCSemanticSegmentationInput", + "PascalVOCSemanticSegmentationOutput", "RTDETRObjectDetectionInput", "RTDETRObjectDetectionOutput", "RTDETRv2ObjectDetectionInput", diff --git a/src/labelformat/formats/semantic_segmentation/pascalvoc.py b/src/labelformat/formats/semantic_segmentation/pascalvoc.py index 3da65ba..0878663 100644 --- a/src/labelformat/formats/semantic_segmentation/pascalvoc.py +++ b/src/labelformat/formats/semantic_segmentation/pascalvoc.py @@ -1,4 +1,4 @@ -"""Pascal VOC semantic segmentation input. +"""Pascal VOC semantic segmentation input and output. Assumptions: - Masks live under a separate directory mirroring the images directory structure. @@ -8,6 +8,7 @@ from __future__ import annotations +import json from argparse import ArgumentParser from collections.abc import Iterable, Mapping from dataclasses import dataclass @@ -16,15 +17,20 @@ import numpy as np from numpy.typing import NDArray from PIL import Image as PILImage +from PIL import ImageDraw from labelformat import utils +from labelformat.cli.registry import Task, cli_register +from labelformat.model.binary_mask_segmentation import BinaryMaskSegmentation from labelformat.model.category import Category from labelformat.model.image import Image from labelformat.model.instance_segmentation import ( ImageInstanceSegmentation, InstanceSegmentationInput, + InstanceSegmentationOutput, SingleInstanceSegmentation, ) +from labelformat.model.multipolygon import MultiPolygon from labelformat.model.semantic_segmentation import SemanticSegmentationMask """TODO(Malte, 11/2025): @@ -150,6 +156,196 @@ def _get_mask(self, image_filepath: str) -> SemanticSegmentationMask: return SemanticSegmentationMask.from_array(array=mask_np) +@cli_register(format="pascalvoc", task=Task.INSTANCE_SEGMENTATION) +class PascalVOCSemanticSegmentationOutput(InstanceSegmentationOutput): + """Pascal VOC semantic segmentation output format. + + Saves one semantic PNG mask per image to + ``//...`` and stores the class mapping as JSON in + ``/``. + """ + + @staticmethod + def add_cli_arguments(parser: ArgumentParser) -> None: + parser.add_argument( + "--output-folder", + type=Path, + required=True, + help="Output folder for Pascal VOC semantic segmentation files.", + ) + parser.add_argument( + "--masks-folder-name", + type=str, + default="SegmentationClass", + help="Subfolder name where semantic masks are written.", + ) + parser.add_argument( + "--class-map-filename", + type=str, + default="class_id_to_name.json", + help="JSON filename for class ID to name mapping.", + ) + parser.add_argument( + "--background-class-id", + type=int, + default=0, + help="Class ID used for unlabeled/background pixels.", + ) + + def __init__( + self, + output_folder: Path, + masks_folder_name: str = "SegmentationClass", + class_map_filename: str = "class_id_to_name.json", + background_class_id: int = 0, + ) -> None: + if background_class_id < 0: + raise ValueError("background_class_id must be >= 0 for Pascal VOC export.") + if background_class_id > 255: + raise ValueError( + "background_class_id must be <= 255 for Pascal VOC export." + ) + self._output_folder = output_folder + self._masks_folder_name = masks_folder_name + self._class_map_filename = class_map_filename + self._background_class_id = background_class_id + + def save(self, label_input: InstanceSegmentationInput) -> None: + category_id_to_name = _get_category_id_to_name( + categories=list(label_input.get_categories()), + background_class_id=self._background_class_id, + ) + + masks_dir = self._output_folder / self._masks_folder_name + masks_dir.mkdir(parents=True, exist_ok=True) + + for image_label in label_input.get_labels(): + # Initialize an (H, W) mask where every pixel starts as background. + mask = np.full( + (image_label.image.height, image_label.image.width), + fill_value=self._background_class_id, + dtype=np.int_, + ) + for obj in image_label.objects: + if obj.category.id not in category_id_to_name: + raise ValueError( + f"Category id {obj.category.id} is used in labels but " + "missing from categories." + ) + obj_mask = _segmentation_to_binary_mask( + segmentation=obj.segmentation, image=image_label.image + ) + positive_pixels = obj_mask > 0 + if not positive_pixels.any(): + continue + mask[positive_pixels] = obj.category.id + + mask_path = (masks_dir / image_label.image.filename).with_suffix(".png") + mask_path.parent.mkdir(parents=True, exist_ok=True) + _save_mask(mask_path=mask_path, mask=mask) + + class_map_path = self._output_folder / self._class_map_filename + with class_map_path.open("w") as f: + json.dump( + {str(k): v for k, v in sorted(category_id_to_name.items())}, + f, + indent=2, + ) + + +def _get_category_id_to_name( + categories: list[Category], background_class_id: int +) -> dict[int, str]: + """Build class-id mapping and validate duplicates.""" + category_id_to_name: dict[int, str] = {} + for category in categories: + if category.id < 0: + raise ValueError( + f"Category id must be >= 0 for Pascal VOC export. Got: {category.id}" + ) + if category.id > 255: + raise ValueError( + "Pascal VOC semantic segmentation export only supports class IDs " + f"in the range [0, 255]. Got: {category.id}" + ) + existing_name = category_id_to_name.get(category.id) + if existing_name is not None and existing_name != category.name: + raise ValueError( + "Conflicting names for category id " + f"{category.id}: '{existing_name}' vs '{category.name}'." + ) + category_id_to_name[category.id] = category.name + + if background_class_id not in category_id_to_name: + category_id_to_name[background_class_id] = "background" + return category_id_to_name + + +def _segmentation_to_binary_mask( + segmentation: BinaryMaskSegmentation | MultiPolygon, image: Image +) -> NDArray[np.uint8]: + if isinstance(segmentation, BinaryMaskSegmentation): + binary_mask = segmentation.get_binary_mask().astype(np.uint8, copy=False) + elif isinstance(segmentation, MultiPolygon): + binary_mask = _multipolygon_to_binary_mask( + multipolygon=segmentation, + width=image.width, + height=image.height, + ) + else: + raise ValueError(f"Unsupported segmentation type: {type(segmentation)}") + + expected_shape = (image.height, image.width) + if binary_mask.shape != expected_shape: + raise ValueError( + f"Segmentation mask shape must match image dimensions for " + f"'{image.filename}': got {binary_mask.shape}, expected {expected_shape}." + ) + return binary_mask + + +def _multipolygon_to_binary_mask( + multipolygon: MultiPolygon, width: int, height: int +) -> NDArray[np.uint8]: + mask_img = PILImage.new(mode="L", size=(width, height), color=0) + draw = ImageDraw.Draw(mask_img) + for polygon in multipolygon.polygons: + if len(polygon) < 3: + raise ValueError( + f"Polygon must contain at least 3 points, got {len(polygon)}." + ) + draw.polygon(xy=polygon, fill=1, outline=1) + return np.asarray(mask_img, dtype=np.uint8) + + +def _pascal_voc_palette() -> list[int]: + """Build the standard Pascal VOC palette (256 colors, RGB triples).""" + palette = [0] * (256 * 3) + for class_id in range(256): + label = class_id + red = 0 + green = 0 + blue = 0 + bit_index = 0 + while label: + red |= ((label >> 0) & 1) << (7 - bit_index) + green |= ((label >> 1) & 1) << (7 - bit_index) + blue |= ((label >> 2) & 1) << (7 - bit_index) + bit_index += 1 + label >>= 3 + palette[(class_id * 3) : (class_id * 3 + 3)] = [red, green, blue] + return palette + + +_PASCAL_VOC_PALETTE = _pascal_voc_palette() + + +def _save_mask(mask_path: Path, mask: NDArray[np.int_]) -> None: + mask_img = PILImage.fromarray(mask.astype(np.uint8), mode="P") + mask_img.putpalette(_PASCAL_VOC_PALETTE) + mask_img.save(mask_path) + + def _validate_mask( image_obj: Image, mask_np: NDArray[np.int_], valid_class_ids: set[int] ) -> None: diff --git a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py index be77318..9d7c39f 100644 --- a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py +++ b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py @@ -1,8 +1,9 @@ from __future__ import annotations import json +from argparse import ArgumentParser from pathlib import Path -from typing import Dict +from typing import Dict, Iterable import cv2 import numpy as np @@ -12,9 +13,17 @@ from labelformat.formats.semantic_segmentation import pascalvoc as pascalvoc_module from labelformat.formats.semantic_segmentation.pascalvoc import ( PascalVOCSemanticSegmentationInput, + PascalVOCSemanticSegmentationOutput, ) from labelformat.model.binary_mask_segmentation import BinaryMaskSegmentation +from labelformat.model.category import Category from labelformat.model.image import Image +from labelformat.model.instance_segmentation import ( + ImageInstanceSegmentation, + InstanceSegmentationInput, + SingleInstanceSegmentation, +) +from labelformat.model.multipolygon import MultiPolygon from tests.unit.test_utils import FIXTURES_DIR FIXTURES_ROOT_PASCALVOC = FIXTURES_DIR / "semantic_segmentation/pascalvoc" @@ -29,6 +38,117 @@ def _load_class_mapping_int_keys() -> Dict[int, str]: return {int(k): str(v) for k, v in data.items()} +class _SimplePolygonInput(InstanceSegmentationInput): + @staticmethod + def add_cli_arguments(parser: ArgumentParser) -> None: + raise NotImplementedError() + + def __init__(self) -> None: + self._image = Image(id=0, filename="nested/example.jpg", width=6, height=5) + + def get_categories(self) -> Iterable[Category]: + return [ + Category(id=1, name="car"), + Category(id=2, name="person"), + ] + + def get_images(self) -> Iterable[Image]: + return [self._image] + + def get_labels(self) -> Iterable[ImageInstanceSegmentation]: + return [ + ImageInstanceSegmentation( + image=self._image, + objects=[ + SingleInstanceSegmentation( + category=Category(id=1, name="car"), + segmentation=MultiPolygon( + polygons=[ + [ + (1.0, 1.0), + (1.0, 3.0), + (3.0, 3.0), + (3.0, 1.0), + ] + ] + ), + ), + SingleInstanceSegmentation( + category=Category(id=2, name="person"), + segmentation=MultiPolygon( + polygons=[ + [ + (2.0, 2.0), + (2.0, 4.0), + (4.0, 4.0), + (4.0, 2.0), + ] + ] + ), + ), + ], + ) + ] + + +class _BadMaskShapeInput(InstanceSegmentationInput): + @staticmethod + def add_cli_arguments(parser: ArgumentParser) -> None: + raise NotImplementedError() + + def get_categories(self) -> Iterable[Category]: + return [Category(id=0, name="background"), Category(id=1, name="foreground")] + + def get_images(self) -> Iterable[Image]: + return [Image(id=0, filename="image.jpg", width=4, height=3)] + + def get_labels(self) -> Iterable[ImageInstanceSegmentation]: + return [ + ImageInstanceSegmentation( + image=Image(id=0, filename="image.jpg", width=4, height=3), + objects=[ + SingleInstanceSegmentation( + category=Category(id=1, name="foreground"), + segmentation=BinaryMaskSegmentation.from_rle( + rle_row_wise=[0, 4], + width=2, + height=2, + ), + ) + ], + ) + ] + + +class _OutOfRangeCategoryInput(InstanceSegmentationInput): + @staticmethod + def add_cli_arguments(parser: ArgumentParser) -> None: + raise NotImplementedError() + + def get_categories(self) -> Iterable[Category]: + return [Category(id=256, name="too_large")] + + def get_images(self) -> Iterable[Image]: + return [Image(id=0, filename="image.jpg", width=2, height=2)] + + def get_labels(self) -> Iterable[ImageInstanceSegmentation]: + return [ + ImageInstanceSegmentation( + image=Image(id=0, filename="image.jpg", width=2, height=2), + objects=[ + SingleInstanceSegmentation( + category=Category(id=256, name="too_large"), + segmentation=BinaryMaskSegmentation.from_rle( + rle_row_wise=[0, 4], + width=2, + height=2, + ), + ) + ], + ) + ] + + class TestPascalVOCSemanticSegmentationInput: def test_from_dirs__builds_categories_and_images(self) -> None: mapping = _load_class_mapping_int_keys() @@ -158,6 +278,72 @@ def test_get_labels(self, tmp_path: Path) -> None: ] +class TestPascalVOCSemanticSegmentationOutput: + def test_save__writes_fixture_masks_and_class_mapping(self, tmp_path: Path) -> None: + mapping = _load_class_mapping_int_keys() + label_input = PascalVOCSemanticSegmentationInput.from_dirs( + images_dir=IMAGES_DIR, + masks_dir=MASKS_DIR, + class_id_to_name=mapping, + ) + + PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( + label_input=label_input + ) + + class_map_json = json.loads((tmp_path / "class_id_to_name.json").read_text()) + class_map = {int(k): str(v) for k, v in class_map_json.items()} + assert class_map == mapping + + for image in label_input.get_images(): + rel_mask_path = Path(image.filename).with_suffix(".png") + class_mask_path = tmp_path / "SegmentationClass" / rel_mask_path + with PILImage.open(class_mask_path) as class_mask_img: + actual_mask = np.asarray(class_mask_img, dtype=np.int_) + with PILImage.open(MASKS_DIR / rel_mask_path) as expected_mask_img: + expected_mask = np.asarray(expected_mask_img, dtype=np.int_) + assert np.array_equal(actual_mask, expected_mask) + + def test_save__rasterizes_polygon_masks_and_adds_background_class( + self, tmp_path: Path + ) -> None: + PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( + label_input=_SimplePolygonInput() + ) + + mask = np.asarray( + PILImage.open(tmp_path / "SegmentationClass" / "nested/example.png"), + dtype=np.int_, + ) + expected_mask = np.array( + [ + [0, 0, 0, 0, 0, 0], + [0, 1, 1, 1, 0, 0], + [0, 1, 2, 2, 2, 0], + [0, 1, 2, 2, 2, 0], + [0, 0, 2, 2, 2, 0], + ], + dtype=np.int_, + ) + assert np.array_equal(mask, expected_mask) + + class_map_json = json.loads((tmp_path / "class_id_to_name.json").read_text()) + class_map = {int(k): str(v) for k, v in class_map_json.items()} + assert class_map == {0: "background", 1: "car", 2: "person"} + + def test_save__mask_shape_mismatch_raises(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match=r"Segmentation mask shape must match"): + PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( + label_input=_BadMaskShapeInput() + ) + + def test_save__category_id_above_255_raises(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match=r"range \[0, 255\]"): + PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( + label_input=_OutOfRangeCategoryInput() + ) + + def test__validate_mask__unknown_class_value_raises() -> None: # Arrange: simple image and a mask with out-of-vocabulary value img = Image(id=0, filename="foo.jpg", width=4, height=3) From cd6e79c1bfbbe4c33f3d69e85b4034898b5796cf Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 12 Mar 2026 09:28:29 -0300 Subject: [PATCH 2/5] add missing tests --- .../semantic_segmentation/pascalvoc.py | 8 +- .../semantic_segmentation/test_pascalvoc.py | 99 ++++++++++++++++++- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/labelformat/formats/semantic_segmentation/pascalvoc.py b/src/labelformat/formats/semantic_segmentation/pascalvoc.py index 0878663..7991757 100644 --- a/src/labelformat/formats/semantic_segmentation/pascalvoc.py +++ b/src/labelformat/formats/semantic_segmentation/pascalvoc.py @@ -236,8 +236,6 @@ def save(self, label_input: InstanceSegmentationInput) -> None: segmentation=obj.segmentation, image=image_label.image ) positive_pixels = obj_mask > 0 - if not positive_pixels.any(): - continue mask[positive_pixels] = obj.category.id mask_path = (masks_dir / image_label.image.filename).with_suffix(".png") @@ -259,11 +257,7 @@ def _get_category_id_to_name( """Build class-id mapping and validate duplicates.""" category_id_to_name: dict[int, str] = {} for category in categories: - if category.id < 0: - raise ValueError( - f"Category id must be >= 0 for Pascal VOC export. Got: {category.id}" - ) - if category.id > 255: + if not 0 <= category.id <= 255: raise ValueError( "Pascal VOC semantic segmentation export only supports class IDs " f"in the range [0, 255]. Got: {category.id}" diff --git a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py index 9d7c39f..b467be3 100644 --- a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py +++ b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py @@ -91,6 +91,49 @@ def get_labels(self) -> Iterable[ImageInstanceSegmentation]: ] +class _SimpleRLEInput(InstanceSegmentationInput): + @staticmethod + def add_cli_arguments(parser: ArgumentParser) -> None: + raise NotImplementedError() + + def __init__(self) -> None: + self._image = Image(id=0, filename="nested/rle_example.jpg", width=5, height=4) + + def get_categories(self) -> Iterable[Category]: + return [ + Category(id=1, name="car"), + Category(id=2, name="person"), + ] + + def get_images(self) -> Iterable[Image]: + return [self._image] + + def get_labels(self) -> Iterable[ImageInstanceSegmentation]: + return [ + ImageInstanceSegmentation( + image=self._image, + objects=[ + SingleInstanceSegmentation( + category=Category(id=1, name="car"), + segmentation=BinaryMaskSegmentation.from_rle( + rle_row_wise=[1, 2, 3, 1, 13], + width=5, + height=4, + ), + ), + SingleInstanceSegmentation( + category=Category(id=2, name="person"), + segmentation=BinaryMaskSegmentation.from_rle( + rle_row_wise=[13, 2, 3, 1, 1], + width=5, + height=4, + ), + ), + ], + ) + ] + + class _BadMaskShapeInput(InstanceSegmentationInput): @staticmethod def add_cli_arguments(parser: ArgumentParser) -> None: @@ -125,8 +168,11 @@ class _OutOfRangeCategoryInput(InstanceSegmentationInput): def add_cli_arguments(parser: ArgumentParser) -> None: raise NotImplementedError() + def __init__(self, category_id: int) -> None: + self._category_id = category_id + def get_categories(self) -> Iterable[Category]: - return [Category(id=256, name="too_large")] + return [Category(id=self._category_id, name="out_of_range")] def get_images(self) -> Iterable[Image]: return [Image(id=0, filename="image.jpg", width=2, height=2)] @@ -137,7 +183,7 @@ def get_labels(self) -> Iterable[ImageInstanceSegmentation]: image=Image(id=0, filename="image.jpg", width=2, height=2), objects=[ SingleInstanceSegmentation( - category=Category(id=256, name="too_large"), + category=Category(id=self._category_id, name="out_of_range"), segmentation=BinaryMaskSegmentation.from_rle( rle_row_wise=[0, 4], width=2, @@ -331,19 +377,64 @@ def test_save__rasterizes_polygon_masks_and_adds_background_class( class_map = {int(k): str(v) for k, v in class_map_json.items()} assert class_map == {0: "background", 1: "car", 2: "person"} + def test_save__writes_rle_masks_and_adds_background_class( + self, tmp_path: Path + ) -> None: + PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( + label_input=_SimpleRLEInput() + ) + + mask = np.asarray( + PILImage.open(tmp_path / "SegmentationClass" / "nested/rle_example.png"), + dtype=np.int_, + ) + expected_mask = np.array( + [ + [0, 1, 1, 0, 0], + [0, 1, 0, 0, 0], + [0, 0, 0, 2, 2], + [0, 0, 0, 2, 0], + ], + dtype=np.int_, + ) + assert np.array_equal(mask, expected_mask) + + class_map_json = json.loads((tmp_path / "class_id_to_name.json").read_text()) + class_map = {int(k): str(v) for k, v in class_map_json.items()} + assert class_map == {0: "background", 1: "car", 2: "person"} + def test_save__mask_shape_mismatch_raises(self, tmp_path: Path) -> None: with pytest.raises(ValueError, match=r"Segmentation mask shape must match"): PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( label_input=_BadMaskShapeInput() ) + def test_save__category_id_below_0_raises(self, tmp_path: Path) -> None: + with pytest.raises( + ValueError, match=r"range \[0, 255\].*Got: -1" + ): + PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( + label_input=_OutOfRangeCategoryInput(category_id=-1) + ) + def test_save__category_id_above_255_raises(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match=r"range \[0, 255\]"): + with pytest.raises( + ValueError, match=r"range \[0, 255\].*Got: 256" + ): PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( - label_input=_OutOfRangeCategoryInput() + label_input=_OutOfRangeCategoryInput(category_id=256) ) +def test__multipolygon_to_binary_mask__polygon_with_less_than_3_points_raises() -> None: + multipolygon = MultiPolygon(polygons=[[(1.0, 1.0), (2.0, 2.0)]]) + + with pytest.raises(ValueError, match=r"Polygon must contain at least 3 points"): + pascalvoc_module._multipolygon_to_binary_mask( + multipolygon=multipolygon, width=4, height=3 + ) + + def test__validate_mask__unknown_class_value_raises() -> None: # Arrange: simple image and a mask with out-of-vocabulary value img = Image(id=0, filename="foo.jpg", width=4, height=3) From 65ba43801e6a4d3aeae5ce341ef04ee202102f17 Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 12 Mar 2026 09:47:59 -0300 Subject: [PATCH 3/5] format files --- .../unit/formats/semantic_segmentation/test_pascalvoc.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py index b467be3..6fa7ec1 100644 --- a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py +++ b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py @@ -410,17 +410,13 @@ def test_save__mask_shape_mismatch_raises(self, tmp_path: Path) -> None: ) def test_save__category_id_below_0_raises(self, tmp_path: Path) -> None: - with pytest.raises( - ValueError, match=r"range \[0, 255\].*Got: -1" - ): + with pytest.raises(ValueError, match=r"range \[0, 255\].*Got: -1"): PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( label_input=_OutOfRangeCategoryInput(category_id=-1) ) def test_save__category_id_above_255_raises(self, tmp_path: Path) -> None: - with pytest.raises( - ValueError, match=r"range \[0, 255\].*Got: 256" - ): + with pytest.raises(ValueError, match=r"range \[0, 255\].*Got: 256"): PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( label_input=_OutOfRangeCategoryInput(category_id=256) ) From c5089e885ce37952bdf353f428bee9d98bc6ab6f Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Thu, 12 Mar 2026 09:54:36 -0300 Subject: [PATCH 4/5] fix tests --- .../semantic_segmentation/test_pascalvoc.py | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py index 6fa7ec1..12b8b0e 100644 --- a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py +++ b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py @@ -195,6 +195,41 @@ def get_labels(self) -> Iterable[ImageInstanceSegmentation]: ] +class _InvalidPolygonInput(InstanceSegmentationInput): + @staticmethod + def add_cli_arguments(parser: ArgumentParser) -> None: + raise NotImplementedError() + + def __init__(self) -> None: + self._image = Image(id=0, filename="invalid_polygon.jpg", width=4, height=3) + + def get_categories(self) -> Iterable[Category]: + return [Category(id=1, name="car")] + + def get_images(self) -> Iterable[Image]: + return [self._image] + + def get_labels(self) -> Iterable[ImageInstanceSegmentation]: + return [ + ImageInstanceSegmentation( + image=self._image, + objects=[ + SingleInstanceSegmentation( + category=Category(id=1, name="car"), + segmentation=MultiPolygon( + polygons=[ + [ + (1.0, 1.0), + (2.0, 2.0), + ] + ] + ), + ) + ], + ) + ] + + class TestPascalVOCSemanticSegmentationInput: def test_from_dirs__builds_categories_and_images(self) -> None: mapping = _load_class_mapping_int_keys() @@ -421,14 +456,13 @@ def test_save__category_id_above_255_raises(self, tmp_path: Path) -> None: label_input=_OutOfRangeCategoryInput(category_id=256) ) - -def test__multipolygon_to_binary_mask__polygon_with_less_than_3_points_raises() -> None: - multipolygon = MultiPolygon(polygons=[[(1.0, 1.0), (2.0, 2.0)]]) - - with pytest.raises(ValueError, match=r"Polygon must contain at least 3 points"): - pascalvoc_module._multipolygon_to_binary_mask( - multipolygon=multipolygon, width=4, height=3 - ) + def test_save__polygon_with_less_than_3_points_raises(self, tmp_path: Path) -> None: + with pytest.raises( + ValueError, match=r"Polygon must contain at least 3 points, got 2" + ): + PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( + label_input=_InvalidPolygonInput() + ) def test__validate_mask__unknown_class_value_raises() -> None: From 8f03e825aad25f6870d9ca875364282eeb999526 Mon Sep 17 00:00:00 2001 From: LeonardoRosaa Date: Fri, 13 Mar 2026 10:58:38 -0300 Subject: [PATCH 5/5] test helper functions --- .../semantic_segmentation/pascalvoc.py | 18 +-- .../semantic_segmentation/test_pascalvoc.py | 149 +++++------------- 2 files changed, 46 insertions(+), 121 deletions(-) diff --git a/src/labelformat/formats/semantic_segmentation/pascalvoc.py b/src/labelformat/formats/semantic_segmentation/pascalvoc.py index 7991757..025ccf6 100644 --- a/src/labelformat/formats/semantic_segmentation/pascalvoc.py +++ b/src/labelformat/formats/semantic_segmentation/pascalvoc.py @@ -199,12 +199,11 @@ def __init__( class_map_filename: str = "class_id_to_name.json", background_class_id: int = 0, ) -> None: - if background_class_id < 0: - raise ValueError("background_class_id must be >= 0 for Pascal VOC export.") - if background_class_id > 255: + if background_class_id < 0 or background_class_id > 255: raise ValueError( - "background_class_id must be <= 255 for Pascal VOC export." + "background_class_id must be in [0,255] for Pascal VOC export." ) + self._output_folder = output_folder self._masks_folder_name = masks_folder_name self._class_map_filename = class_map_filename @@ -212,7 +211,7 @@ def __init__( def save(self, label_input: InstanceSegmentationInput) -> None: category_id_to_name = _get_category_id_to_name( - categories=list(label_input.get_categories()), + categories=label_input.get_categories(), background_class_id=self._background_class_id, ) @@ -235,8 +234,7 @@ def save(self, label_input: InstanceSegmentationInput) -> None: obj_mask = _segmentation_to_binary_mask( segmentation=obj.segmentation, image=image_label.image ) - positive_pixels = obj_mask > 0 - mask[positive_pixels] = obj.category.id + mask[obj_mask] = obj.category.id mask_path = (masks_dir / image_label.image.filename).with_suffix(".png") mask_path.parent.mkdir(parents=True, exist_ok=True) @@ -252,7 +250,7 @@ def save(self, label_input: InstanceSegmentationInput) -> None: def _get_category_id_to_name( - categories: list[Category], background_class_id: int + categories: Iterable[Category], background_class_id: int ) -> dict[int, str]: """Build class-id mapping and validate duplicates.""" category_id_to_name: dict[int, str] = {} @@ -277,7 +275,7 @@ def _get_category_id_to_name( def _segmentation_to_binary_mask( segmentation: BinaryMaskSegmentation | MultiPolygon, image: Image -) -> NDArray[np.uint8]: +) -> NDArray[np.bool_]: if isinstance(segmentation, BinaryMaskSegmentation): binary_mask = segmentation.get_binary_mask().astype(np.uint8, copy=False) elif isinstance(segmentation, MultiPolygon): @@ -295,7 +293,7 @@ def _segmentation_to_binary_mask( f"Segmentation mask shape must match image dimensions for " f"'{image.filename}': got {binary_mask.shape}, expected {expected_shape}." ) - return binary_mask + return binary_mask > 0 def _multipolygon_to_binary_mask( diff --git a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py index 12b8b0e..aa2a04f 100644 --- a/tests/unit/formats/semantic_segmentation/test_pascalvoc.py +++ b/tests/unit/formats/semantic_segmentation/test_pascalvoc.py @@ -134,102 +134,6 @@ def get_labels(self) -> Iterable[ImageInstanceSegmentation]: ] -class _BadMaskShapeInput(InstanceSegmentationInput): - @staticmethod - def add_cli_arguments(parser: ArgumentParser) -> None: - raise NotImplementedError() - - def get_categories(self) -> Iterable[Category]: - return [Category(id=0, name="background"), Category(id=1, name="foreground")] - - def get_images(self) -> Iterable[Image]: - return [Image(id=0, filename="image.jpg", width=4, height=3)] - - def get_labels(self) -> Iterable[ImageInstanceSegmentation]: - return [ - ImageInstanceSegmentation( - image=Image(id=0, filename="image.jpg", width=4, height=3), - objects=[ - SingleInstanceSegmentation( - category=Category(id=1, name="foreground"), - segmentation=BinaryMaskSegmentation.from_rle( - rle_row_wise=[0, 4], - width=2, - height=2, - ), - ) - ], - ) - ] - - -class _OutOfRangeCategoryInput(InstanceSegmentationInput): - @staticmethod - def add_cli_arguments(parser: ArgumentParser) -> None: - raise NotImplementedError() - - def __init__(self, category_id: int) -> None: - self._category_id = category_id - - def get_categories(self) -> Iterable[Category]: - return [Category(id=self._category_id, name="out_of_range")] - - def get_images(self) -> Iterable[Image]: - return [Image(id=0, filename="image.jpg", width=2, height=2)] - - def get_labels(self) -> Iterable[ImageInstanceSegmentation]: - return [ - ImageInstanceSegmentation( - image=Image(id=0, filename="image.jpg", width=2, height=2), - objects=[ - SingleInstanceSegmentation( - category=Category(id=self._category_id, name="out_of_range"), - segmentation=BinaryMaskSegmentation.from_rle( - rle_row_wise=[0, 4], - width=2, - height=2, - ), - ) - ], - ) - ] - - -class _InvalidPolygonInput(InstanceSegmentationInput): - @staticmethod - def add_cli_arguments(parser: ArgumentParser) -> None: - raise NotImplementedError() - - def __init__(self) -> None: - self._image = Image(id=0, filename="invalid_polygon.jpg", width=4, height=3) - - def get_categories(self) -> Iterable[Category]: - return [Category(id=1, name="car")] - - def get_images(self) -> Iterable[Image]: - return [self._image] - - def get_labels(self) -> Iterable[ImageInstanceSegmentation]: - return [ - ImageInstanceSegmentation( - image=self._image, - objects=[ - SingleInstanceSegmentation( - category=Category(id=1, name="car"), - segmentation=MultiPolygon( - polygons=[ - [ - (1.0, 1.0), - (2.0, 2.0), - ] - ] - ), - ) - ], - ) - ] - - class TestPascalVOCSemanticSegmentationInput: def test_from_dirs__builds_categories_and_images(self) -> None: mapping = _load_class_mapping_int_keys() @@ -438,30 +342,53 @@ def test_save__writes_rle_masks_and_adds_background_class( class_map = {int(k): str(v) for k, v in class_map_json.items()} assert class_map == {0: "background", 1: "car", 2: "person"} - def test_save__mask_shape_mismatch_raises(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match=r"Segmentation mask shape must match"): - PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( - label_input=_BadMaskShapeInput() + @pytest.mark.parametrize("background_class_id", [-1, 256]) + def test_init__background_class_id_out_of_range_raises( + self, tmp_path: Path, background_class_id: int + ) -> None: + with pytest.raises( + ValueError, + match=r"background_class_id must be in \[0,255\] for Pascal VOC export\.", + ): + PascalVOCSemanticSegmentationOutput( + output_folder=tmp_path, + background_class_id=background_class_id, ) - def test_save__category_id_below_0_raises(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match=r"range \[0, 255\].*Got: -1"): - PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( - label_input=_OutOfRangeCategoryInput(category_id=-1) + def test__segmentation_to_binary_mask__shape_mismatch_raises(self) -> None: + image = Image(id=0, filename="image.jpg", width=4, height=3) + bad_shape_segmentation = BinaryMaskSegmentation.from_rle( + rle_row_wise=[0, 4], + width=2, + height=2, + ) + with pytest.raises(ValueError, match=r"Segmentation mask shape must match"): + pascalvoc_module._segmentation_to_binary_mask( + segmentation=bad_shape_segmentation, + image=image, ) - def test_save__category_id_above_255_raises(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match=r"range \[0, 255\].*Got: 256"): - PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( - label_input=_OutOfRangeCategoryInput(category_id=256) + @pytest.mark.parametrize("category_id", [-1, 256]) + def test__get_category_id_to_name__category_id_out_of_range_raises( + self, category_id: int + ) -> None: + with pytest.raises(ValueError, match=rf"range \[0, 255\].*Got: {category_id}"): + pascalvoc_module._get_category_id_to_name( + categories=[Category(id=category_id, name="out_of_range")], + background_class_id=0, ) - def test_save__polygon_with_less_than_3_points_raises(self, tmp_path: Path) -> None: + def test__segmentation_to_binary_mask__polygon_with_less_than_3_points_raises( + self, + ) -> None: + image = Image(id=0, filename="invalid_polygon.jpg", width=4, height=3) + invalid_polygon = MultiPolygon(polygons=[[(1.0, 1.0), (2.0, 2.0)]]) with pytest.raises( ValueError, match=r"Polygon must contain at least 3 points, got 2" ): - PascalVOCSemanticSegmentationOutput(output_folder=tmp_path).save( - label_input=_InvalidPolygonInput() + pascalvoc_module._segmentation_to_binary_mask( + segmentation=invalid_polygon, + image=image, )