From 3c1d84a46ed9b0ac9ab460a05e01319deda72af6 Mon Sep 17 00:00:00 2001 From: Arthur Alglave Date: Fri, 28 Nov 2025 10:06:09 +0000 Subject: [PATCH 1/2] Add bitmask tests --- encord/objects/ontology_labels_impl.py | 24 +- tests/objects/test_bitmask_validation.py | 343 +++++++++++++++++++++++ 2 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 tests/objects/test_bitmask_validation.py diff --git a/encord/objects/ontology_labels_impl.py b/encord/objects/ontology_labels_impl.py index b85896d9d..5e2388af0 100644 --- a/encord/objects/ontology_labels_impl.py +++ b/encord/objects/ontology_labels_impl.py @@ -1384,7 +1384,7 @@ def frame(self) -> int: @property def width(self) -> int: - """Get the width of the image or image group. + """Get the width of the frame. Returns: int: The width of the image or image group. @@ -1392,8 +1392,16 @@ def width(self) -> int: Raises: LabelRowError: If the width is not set for the data type. """ - if self._label_row.data_type in [DataType.IMG_GROUP]: + if self._label_row.data_type == DataType.IMG_GROUP: return self._frame_level_data().width + elif self._label_row.data_type == DataType.DICOM: + frame_metadata = self._label_row._frame_metadata[self._frame] + if frame_metadata is not None: + return frame_metadata.width + elif self._label_row_read_only_data.width is not None: + return self._label_row_read_only_data.width + else: + raise LabelRowError(f"Width is expected but not set for the data type {self._label_row.data_type}") elif self._label_row_read_only_data.width is not None: return self._label_row_read_only_data.width else: @@ -1401,7 +1409,7 @@ def width(self) -> int: @property def height(self) -> int: - """Get the height of the image or image group. + """Get the height of the frame. Returns: int: The height of the image or image group. @@ -1409,8 +1417,16 @@ def height(self) -> int: Raises: LabelRowError: If the height is not set for the data type. """ - if self._label_row.data_type in [DataType.IMG_GROUP]: + if self._label_row.data_type == DataType.IMG_GROUP: return self._frame_level_data().height + elif self._label_row.data_type == DataType.DICOM: + frame_metadata = self._label_row._frame_metadata[self._frame] + if frame_metadata is not None: + return frame_metadata.height + elif self._label_row_read_only_data.height is not None: + return self._label_row_read_only_data.height + else: + raise LabelRowError(f"Height is expected but not set for the data type {self._label_row.data_type}") elif self._label_row_read_only_data.height is not None: return self._label_row_read_only_data.height else: diff --git a/tests/objects/test_bitmask_validation.py b/tests/objects/test_bitmask_validation.py new file mode 100644 index 000000000..5382ab2e2 --- /dev/null +++ b/tests/objects/test_bitmask_validation.py @@ -0,0 +1,343 @@ +from datetime import datetime +from unittest.mock import Mock, PropertyMock + +import numpy as np +import pytest + +from encord.objects import LabelRowV2, Object, ObjectInstance, Shape +from encord.objects.bitmask import BitmaskCoordinates +from encord.orm.label_row import AnnotationTaskStatus, LabelRowMetadata, LabelStatus + +bitmask_object = Object( + uid=1, name="Mask", color="#D33115", shape=Shape.BITMASK, feature_node_hash="bitmask123", attributes=[] +) +get_child_by_hash = PropertyMock(return_value=bitmask_object) +ontology_structure = Mock(get_child_by_hash=get_child_by_hash) +ontology = Mock(structure=ontology_structure) + + +def test_image_bitmask_dimension_validation(): + # Create label row metadata with specific dimensions (512x512) + metadata = LabelRowMetadata( + label_hash="test_label", + branch_name="main", + created_at=datetime.now(), + last_edited_at=datetime.now(), + data_hash="test_data", + data_title="Test Image", + data_type="IMAGE", + data_link="", + dataset_hash="test_dataset", + dataset_title="Test Dataset", + label_status=LabelStatus.NOT_LABELLED, + annotation_task_status=AnnotationTaskStatus.QUEUED, + workflow_graph_node=None, + is_shadow_data=False, + duration=None, + frames_per_second=None, + number_of_frames=1, + height=512, + width=512, + audio_codec=None, + audio_sample_rate=None, + audio_num_channels=None, + audio_bit_depth=None, + ) + + # Create empty labels dict + empty_labels = { + "label_hash": "test_label", + "branch_name": "main", + "created_at": "Thu, 09 Feb 2023 14:12:03 UTC", + "last_edited_at": "Thu, 09 Feb 2023 14:12:03 UTC", + "data_hash": "test_data", + "annotation_task_status": "QUEUED", + "is_shadow_data": False, + "dataset_hash": "test_dataset", + "dataset_title": "Test Dataset", + "data_title": "Test Image", + "data_type": "image", + "data_units": { + "test_data": { + "data_hash": "test_data", + "data_title": "Test Image", + "data_link": "", + "data_type": "image/png", + "data_sequence": "0", + "width": 512, + "height": 512, + "labels": {"objects": [], "classifications": []}, + } + }, + "object_answers": {}, + "classification_answers": {}, + "object_actions": {}, + "label_status": "LABEL_IN_PROGRESS", + } + + # Correct dimensions (512x512) should succeed + label_row = LabelRowV2(metadata, Mock(), ontology) + label_row.from_labels_dict(empty_labels) + + correct_bitmask_coords = BitmaskCoordinates(np.zeros((512, 512), dtype=bool)) + correct_obj_instance = ObjectInstance(bitmask_object) + correct_obj_instance.set_for_frames(coordinates=correct_bitmask_coords, frames=0) + label_row.add_object_instance(correct_obj_instance) + assert len(label_row.get_object_instances()) == 1 + label_row.to_encord_dict() # Serialization should also succeed + + # Incorrect dimensions (256x256) should raise ValueError + incorrect_bitmask_coords = BitmaskCoordinates(np.zeros((256, 256), dtype=bool)) + incorrect_obj_instance = ObjectInstance(bitmask_object) + incorrect_obj_instance.set_for_frames(coordinates=incorrect_bitmask_coords, frames=0) + label_row.add_object_instance(incorrect_obj_instance) + assert len(label_row.get_object_instances()) == 2 + + with pytest.raises(ValueError, match="Bitmask dimensions don't match the media dimensions"): + label_row.to_encord_dict() + + +def test_image_group_bitmask_dimension_validation(): + metadata = LabelRowMetadata( + label_hash="test_label", + branch_name="main", + created_at=datetime.now(), + last_edited_at=datetime.now(), + data_hash="test_data", + data_title="Test Image Group", + data_type="IMG_GROUP", + data_link="", + dataset_hash="test_dataset", + dataset_title="Test Dataset", + label_status=LabelStatus.NOT_LABELLED, + annotation_task_status=AnnotationTaskStatus.QUEUED, + workflow_graph_node=None, + is_shadow_data=False, + duration=None, + frames_per_second=None, + number_of_frames=2, + height=None, + width=None, + audio_codec=None, + audio_sample_rate=None, + audio_num_channels=None, + audio_bit_depth=None, + ) + + # Create empty labels dict for image group with different dimensions per frame + empty_labels = { + "label_hash": "test_label", + "branch_name": "main", + "created_at": "Thu, 09 Feb 2023 14:12:03 UTC", + "last_edited_at": "Thu, 09 Feb 2023 14:12:03 UTC", + "data_hash": "test_data", + "annotation_task_status": "QUEUED", + "is_shadow_data": False, + "dataset_hash": "test_dataset", + "dataset_title": "Test Dataset", + "data_title": "Test Image Group", + "data_type": "img_group", + "data_units": { + "frame_0_hash": { + "data_hash": "frame_0_hash", + "data_title": "Frame 0", + "data_link": "", + "data_type": "image/png", + "data_sequence": "0", + "width": 512, + "height": 512, + "labels": {"objects": [], "classifications": []}, + }, + "frame_1_hash": { + "data_hash": "frame_1_hash", + "data_title": "Frame 1", + "data_link": "", + "data_type": "image/png", + "data_sequence": "1", + "width": 1024, + "height": 768, + "labels": {"objects": [], "classifications": []}, + }, + }, + "object_answers": {}, + "classification_answers": {}, + "object_actions": {}, + "label_status": "LABEL_IN_PROGRESS", + } + + label_row = LabelRowV2(metadata, Mock(), ontology) + label_row.from_labels_dict(empty_labels) + + # Add bitmask with correct dimensions for frame 0 (512x512) + frame_0_correct_mask = BitmaskCoordinates(np.zeros((512, 512), dtype=bool)) + frame_0_correct_instance = ObjectInstance(bitmask_object) + frame_0_correct_instance.set_for_frames(coordinates=frame_0_correct_mask, frames=0) + label_row.add_object_instance(frame_0_correct_instance) + + # Add bitmask with correct dimensions for frame 1 (1024x768) + frame_1_correct_mask = BitmaskCoordinates(np.zeros((768, 1024), dtype=bool)) + frame_1_correct_instance = ObjectInstance(bitmask_object) + frame_1_correct_instance.set_for_frames(coordinates=frame_1_correct_mask, frames=1) + label_row.add_object_instance(frame_1_correct_instance) + assert len(label_row.get_object_instances()) == 2 + label_row.to_encord_dict() # Both correct dimensions should serialize successfully + + # Add bitmask with incorrect dimensions for frame 0 (256x256 instead of 512x512) + frame_0_incorrect_mask = BitmaskCoordinates(np.zeros((256, 256), dtype=bool)) + frame_0_incorrect_instance = ObjectInstance(bitmask_object) + frame_0_incorrect_instance.set_for_frames(coordinates=frame_0_incorrect_mask, frames=0, overwrite=True) + label_row.add_object_instance(frame_0_incorrect_instance, force=True) + + # Should fail on serialization + with pytest.raises(ValueError, match="Bitmask dimensions don't match the media dimensions"): + label_row.to_encord_dict() + + # Fix frame 0, then add incorrect dimensions for frame 1 + label_row.remove_object(frame_0_incorrect_instance) + label_row.to_encord_dict() # back to a valid serializable state + + frame_1_incorrect_mask = BitmaskCoordinates(np.zeros((512, 512), dtype=bool)) + frame_1_incorrect_instance = ObjectInstance(bitmask_object) + frame_1_incorrect_instance.set_for_frames(coordinates=frame_1_incorrect_mask, frames=1, overwrite=True) + label_row.add_object_instance(frame_1_incorrect_instance, force=True) + + # Should fail on serialization due to frame 1 + with pytest.raises(ValueError, match="Bitmask dimensions don't match the media dimensions"): + label_row.to_encord_dict() + + +def test_dicom_bitmask_dimension_validation(): + # Create label row metadata for DICOM with series-level dimensions (512x512) + metadata = LabelRowMetadata( + label_hash="test_label", + branch_name="main", + created_at=datetime.now(), + last_edited_at=datetime.now(), + data_hash="test_dicom_hash", + data_title="Test DICOM", + data_type="DICOM", + data_link="", + dataset_hash="test_dataset", + dataset_title="Test Dataset", + label_status=LabelStatus.NOT_LABELLED, + annotation_task_status=AnnotationTaskStatus.QUEUED, + workflow_graph_node=None, + is_shadow_data=False, + duration=None, + frames_per_second=None, + number_of_frames=2, + height=512, + width=512, + audio_codec=None, + audio_sample_rate=None, + audio_num_channels=None, + audio_bit_depth=None, + ) + + dicom_labels = { + "label_hash": "test_label", + "branch_name": "main", + "created_at": "Thu, 09 Feb 2023 14:12:03 UTC", + "last_edited_at": "Thu, 09 Feb 2023 14:12:03 UTC", + "data_hash": "test_dicom_hash", + "annotation_task_status": "QUEUED", + "is_shadow_data": False, + "dataset_hash": "test_dataset", + "dataset_title": "Test Dataset", + "data_title": "Test DICOM", + "data_type": "dicom", + "data_units": { + "test_dicom_hash": { + "data_hash": "test_dicom_hash", + "data_title": "Test DICOM", + "data_type": "application/dicom", + "data_sequence": 0, + "labels": { + "0": { + "objects": [], + "classifications": [], + "metadata": { + "dicom_instance_uid": "1.2.3.4.5.6.7.8.9.0", + "multiframe_frame_number": None, + "file_uri": "test/slice_0", + "width": 512, + "height": 512, + }, + }, + "1": { + "objects": [], + "classifications": [], + "metadata": { + "dicom_instance_uid": "1.2.3.4.5.6.7.8.9.1", + "multiframe_frame_number": None, + "file_uri": "test/slice_1", + "width": 10, + "height": 10, + }, + }, + }, + "metadata": { + "patient_id": "test_patient", + "study_uid": "1.2.3.4.5", + "series_uid": "1.2.3.4.6", + }, + "data_links": ["test/slice_0", "test/slice_1"], + "width": 512, + "height": 512, + } + }, + "object_answers": {}, + "classification_answers": {}, + "object_actions": {}, + "label_status": "LABEL_IN_PROGRESS", + } + + label_row = LabelRowV2(metadata, Mock(), ontology) + label_row.from_labels_dict(dicom_labels) + + slice_0_metadata = label_row.get_frame_view(0).metadata + assert slice_0_metadata is not None + assert slice_0_metadata.model_dump() == { + "width": 512, + "height": 512, + "dicom_instance_uid": "1.2.3.4.5.6.7.8.9.0", + "multiframe_frame_number": None, + "file_uri": "test/slice_0", + } + + slice_1_metadata = label_row.get_frame_view(1).metadata + assert slice_1_metadata is not None + assert slice_1_metadata.model_dump() == { + "width": 10, + "height": 10, + "dicom_instance_uid": "1.2.3.4.5.6.7.8.9.1", + "multiframe_frame_number": None, + "file_uri": "test/slice_1", + } + + # Add bitmask with correct dimensions for slice 0 + slice_0_correct_mask = BitmaskCoordinates(np.zeros((512, 512), dtype=bool)) + slice_0_correct_instance = ObjectInstance(bitmask_object) + slice_0_correct_instance.set_for_frames(coordinates=slice_0_correct_mask, frames=0) + label_row.add_object_instance(slice_0_correct_instance) + assert len(label_row.get_object_instances()) == 1 + + # Add bitmask with correct dimensions for slice 1 + slice_1_correct_mask = BitmaskCoordinates(np.zeros((10, 10), dtype=bool)) + slice_1_correct_instance = ObjectInstance(bitmask_object) + slice_1_correct_instance.set_for_frames(coordinates=slice_1_correct_mask, frames=1) + label_row.add_object_instance(slice_1_correct_instance) + assert len(label_row.get_object_instances()) == 2 + + # Both correct dimensions should serialize successfully + label_row.to_encord_dict() + + # Add bitmask with incorrect dimensions for slice 0 (256x256 instead of 512x512) + slice_0_incorrect_mask = BitmaskCoordinates(np.zeros((256, 256), dtype=bool)) + slice_0_incorrect_instance = ObjectInstance(bitmask_object) + slice_0_incorrect_instance.set_for_frames(coordinates=slice_0_incorrect_mask, frames=0, overwrite=True) + label_row.add_object_instance(slice_0_incorrect_instance, force=True) + + # Should fail on serialization + with pytest.raises(ValueError, match="Bitmask dimensions don't match the media dimensions"): + label_row.to_encord_dict() From 2e2fbc2f3d4e8b0bd4211511003e3844710c4dea Mon Sep 17 00:00:00 2001 From: Arthur Alglave Date: Fri, 28 Nov 2025 14:38:17 +0000 Subject: [PATCH 2/2] actually not --- encord/objects/ontology_labels_impl.py | 40 ------------------------ tests/objects/test_bitmask_validation.py | 12 +++---- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/encord/objects/ontology_labels_impl.py b/encord/objects/ontology_labels_impl.py index 5e2388af0..c4dcbc1c2 100644 --- a/encord/objects/ontology_labels_impl.py +++ b/encord/objects/ontology_labels_impl.py @@ -1264,59 +1264,25 @@ def __init__(self, images_data: LabelRowV2.LabelRowReadOnlyDataImagesDataEntry): @property def title(self) -> str: return self._image_data.title - """ - Get the title of the image. - - Returns: - str: The image title. - """ @property def file_type(self) -> str: return self._image_data.file_type - """ - Get the file type of the image. - - Returns: - str: The image file type. - """ @property def width(self) -> int: return self._image_data.width - """ - Get the width of the image. - - Returns: - int: The image width. - """ @property def height(self) -> int: return self._image_data.height - """ - Get the height of the image. - - Returns: - int: The image height. - """ @property def image_hash(self) -> str: - """Get the hash of the image. - - Returns: - str: The image hash. - """ return self._image_data.image_hash @property def frame_number(self) -> int: - """Get the frame number. - - Returns: - int: The frame number. - """ return self._image_data.index class FrameView: @@ -1386,9 +1352,6 @@ def frame(self) -> int: def width(self) -> int: """Get the width of the frame. - Returns: - int: The width of the image or image group. - Raises: LabelRowError: If the width is not set for the data type. """ @@ -1411,9 +1374,6 @@ def width(self) -> int: def height(self) -> int: """Get the height of the frame. - Returns: - int: The height of the image or image group. - Raises: LabelRowError: If the height is not set for the data type. """ diff --git a/tests/objects/test_bitmask_validation.py b/tests/objects/test_bitmask_validation.py index 5382ab2e2..ad994d3ad 100644 --- a/tests/objects/test_bitmask_validation.py +++ b/tests/objects/test_bitmask_validation.py @@ -185,8 +185,8 @@ def test_image_group_bitmask_dimension_validation(): # Add bitmask with incorrect dimensions for frame 0 (256x256 instead of 512x512) frame_0_incorrect_mask = BitmaskCoordinates(np.zeros((256, 256), dtype=bool)) frame_0_incorrect_instance = ObjectInstance(bitmask_object) - frame_0_incorrect_instance.set_for_frames(coordinates=frame_0_incorrect_mask, frames=0, overwrite=True) - label_row.add_object_instance(frame_0_incorrect_instance, force=True) + frame_0_incorrect_instance.set_for_frames(coordinates=frame_0_incorrect_mask, frames=0) + label_row.add_object_instance(frame_0_incorrect_instance) # Should fail on serialization with pytest.raises(ValueError, match="Bitmask dimensions don't match the media dimensions"): @@ -198,8 +198,8 @@ def test_image_group_bitmask_dimension_validation(): frame_1_incorrect_mask = BitmaskCoordinates(np.zeros((512, 512), dtype=bool)) frame_1_incorrect_instance = ObjectInstance(bitmask_object) - frame_1_incorrect_instance.set_for_frames(coordinates=frame_1_incorrect_mask, frames=1, overwrite=True) - label_row.add_object_instance(frame_1_incorrect_instance, force=True) + frame_1_incorrect_instance.set_for_frames(coordinates=frame_1_incorrect_mask, frames=1) + label_row.add_object_instance(frame_1_incorrect_instance) # Should fail on serialization due to frame 1 with pytest.raises(ValueError, match="Bitmask dimensions don't match the media dimensions"): @@ -335,8 +335,8 @@ def test_dicom_bitmask_dimension_validation(): # Add bitmask with incorrect dimensions for slice 0 (256x256 instead of 512x512) slice_0_incorrect_mask = BitmaskCoordinates(np.zeros((256, 256), dtype=bool)) slice_0_incorrect_instance = ObjectInstance(bitmask_object) - slice_0_incorrect_instance.set_for_frames(coordinates=slice_0_incorrect_mask, frames=0, overwrite=True) - label_row.add_object_instance(slice_0_incorrect_instance, force=True) + slice_0_incorrect_instance.set_for_frames(coordinates=slice_0_incorrect_mask, frames=0) + label_row.add_object_instance(slice_0_incorrect_instance) # Should fail on serialization with pytest.raises(ValueError, match="Bitmask dimensions don't match the media dimensions"):