From 024b8558382691bbf9e4bc19c0ce3258273e28b3 Mon Sep 17 00:00:00 2001 From: paulruelle Date: Fri, 21 Aug 2026 09:16:03 +0200 Subject: [PATCH] feat(LAB-4612): support polygon annotations in PDF projects Make a PDF polygon label round-trip through the SDK: parse without raising, expose the same attributes as a PDF bounding box, and export to pixel coordinates, without changing what today's PDF bbox exports produce. Two defects already live in the field are fixed on the way, since polygon would inherit both: - PDF object detection labels raised AttributeNotCompatibleWithJobError on .annotations, .polys and .page_number_array, because the page-level geometry only existed on EntityInPdfAnnotation, whose compatible mlTask is NAMED_ENTITIES_RECOGNITION. The property bodies move to a _BasePdfAnnotation mixin, and a new PdfObjectDetectionAnnotation registers them for object detection jobs. They are guarded on inputType so an image polygon keeps rejecting .polys. - A "type" key on the inner page-level annotation crashed BoundingPoly.normalized_vertices, which used the presence of type_of_tool as its proxy for "vertices are flat" even though PDF nests them. The nesting is now inferred from the data, and the vertex count is checked per ring. PDF labels only carry a "type" key when the tool is not the rectangle, so the type property reads its absence as "rectangle" rather than raising. The inference never reaches an export: serialization reads the raw json data, which the property does not write to. Reading the nested layer no longer corrupts the label either. It cast the inner boundingPoly in place on every access while as_dict() returned the raw list, so to_dict() broke once .annotations had been read. The list is now cast once and un-cast on serialization, the same way job_response.py already does it. Claude-Session: https://claude.ai/code/session_016dcoQidK7T4JZd7MyCuoNQ --- docs/sdk/label_parsing.md | 39 +++ .../services/label_data_parsing/annotation.py | 96 +++++-- .../label_data_parsing/bounding_poly.py | 44 ++-- tests/unit/services/export/test_kili.py | 107 ++++++++ .../parsing/test_resp_parsing.py | 243 +++++++++++++++++- 5 files changed, 495 insertions(+), 34 deletions(-) diff --git a/docs/sdk/label_parsing.md b/docs/sdk/label_parsing.md index a334624b8..63d5bc50e 100644 --- a/docs/sdk/label_parsing.md +++ b/docs/sdk/label_parsing.md @@ -150,6 +150,45 @@ label.jobs["BBOX_JOB"].annotations.content label.jobs["BBOX_JOB"].bounding_poly_annotations.content ``` +#### Object detection in PDFs + +In a PDF project, the geometry of a bounding box or of a polygon is not on the annotation itself but one level deeper, in a nested `.annotations` list: a PDF page has its own coordinate system, so the vertices are normalized to the page named by `.page_number_array`. + +##### `.type` + +Returns the tool used to draw the annotation, `"rectangle"` or `"polygon"`. + +```python +label.jobs["DETECTION_JOB"].annotations[0].type +``` + +PDF labels only carry the tool when it is not the rectangle, so an annotation without it is reported as a `"rectangle"`. This is a read-time default only: it is never written back, and exporting a label leaves it unchanged. + +##### `.annotations` + +Returns the page-level positions of the annotation. A polygon always has exactly one, since a polygon lives on a single page. + +```python +label.jobs["DETECTION_JOB"].annotations[0].annotations[0] +``` + +##### `.polys` and `.bounding_poly` + +Return the vertices of the shape. For a polygon both hold the same ring of `N >= 3` vertices. + +```python +label.jobs["DETECTION_JOB"].annotations[0].annotations[0].polys +label.jobs["DETECTION_JOB"].annotations[0].annotations[0].bounding_poly[0].normalized_vertices +``` + +##### `.page_number_array` + +Returns the page the annotation is drawn on, 1-indexed. + +```python +label.jobs["DETECTION_JOB"].annotations[0].annotations[0].page_number_array +``` + #### Point detection ##### `.point` diff --git a/src/kili/services/label_data_parsing/annotation.py b/src/kili/services/label_data_parsing/annotation.py index 51fd4e859..bcce38fc2 100644 --- a/src/kili/services/label_data_parsing/annotation.py +++ b/src/kili/services/label_data_parsing/annotation.py @@ -5,6 +5,7 @@ from collections.abc import Iterator, Sequence from typing import Literal, Optional, Union +from kili_formats.types import Job from typeguard import typechecked from kili.services.label_data_parsing import category as category_module @@ -17,6 +18,11 @@ from .utils import get_children_job_names +def _is_pdf_object_detection(project_info: Project, job_interface: Job) -> bool: + """Return whether the annotation belongs to an object detection job of a PDF project.""" + return project_info["inputType"] == "PDF" and job_interface["mlTask"] == "OBJECT_DETECTION" + + class _BaseAnnotation: """Class for parsing the "annotations" key of a job response. @@ -73,8 +79,14 @@ def as_dict(self) -> dict: ret = { k: v for k, v in self._json_data.items() - if k not in ("categories", "children", "boundingPoly", "points") + if k not in ("categories", "children", "boundingPoly", "points", "annotations") } + if "annotations" in self._json_data: + ret["annotations"] = ( + self._json_data["annotations"] + if isinstance(self._json_data["annotations"], list) + else self._json_data["annotations"].as_list() + ) if "categories" in self._json_data: ret["categories"] = ( self._json_data["categories"] @@ -224,7 +236,18 @@ class _BaseAnnotationWithTool(_BaseAnnotation): def type( self, ) -> Literal["rectangle", "polygon", "semantic", "marker", "vector", "polyline", "pose"]: - """Returns the tool of the annotation.""" + """Returns the tool of the annotation. + + A PDF object detection annotation only carries a "type" key when the tool is not the + rectangle: labels produced before the polygon tool existed, and labels produced by the + current backend, both omit it for rectangles. The absence of the key is therefore read + as "rectangle" instead of raising. This never reaches an export, because serialization + reads the raw json data, which this property does not write to. + """ + if "type" not in self._json_data and _is_pdf_object_detection( + self._project_info, self._job_interface + ): + return "rectangle" return self._json_data["type"] @@ -283,41 +306,81 @@ def bounding_poly(self) -> BoundingPolyList: return self._json_data["boundingPoly"] -class EntityInPdfAnnotation(_BaseNamedEntityRecognitionAnnotation, _BaseAnnotationWithBoundingPoly): - """Class for parsing the "annotations" key of a job response for named entities recognition in PDFs.""" +class _BasePdfAnnotation(_BaseAnnotationWithBoundingPoly): + """Base class for the page-level geometry of a PDF annotation. - @staticmethod - def _get_compatible_ml_task() -> Literal["NAMED_ENTITIES_RECOGNITION"]: - return "NAMED_ENTITIES_RECOGNITION" + In a PDF project the geometry is not on the annotation itself but one level deeper, in a + nested "annotations" list, because a PDF page has its own coordinate system. This layout is + shared by named entities recognition and object detection. + + The compatibility machinery of `Annotation` matches on the mlTask and the tool, neither of + which distinguishes a PDF project from an image one. The input type is therefore checked + here, so that an image polygon keeps rejecting `.polys` the way it does today. + """ + + def _assert_input_type_is_pdf(self, attribute_name: str) -> None: + if self._project_info["inputType"] != "PDF": + raise AttributeNotCompatibleWithJobError(attribute_name) @property def annotations(self) -> "AnnotationList": - """Return the tist of positions of the annotation. + """Return the list of positions of the annotation. For NER, when an annotation spans multiple lines, there will be multiple polys and a single boundingPoly. """ - return AnnotationList( - job_name=self._job_name, - project_info=self._project_info, - annotations_list=self._json_data["annotations"], - ) + self._assert_input_type_is_pdf("annotations") + if not isinstance(self._json_data["annotations"], AnnotationList): + self._json_data["annotations"] = AnnotationList( + job_name=self._job_name, + project_info=self._project_info, + annotations_list=self._json_data["annotations"], + ) + return self._json_data["annotations"] @property def polys( self, ) -> list[dict[Literal["normalizedVertices"], list[list[NormalizedVertex]]]]: - """Return the coordinates from the different rectangles in the annotation. + """Return the coordinates of the shapes in the annotation. - An annotation can have several rectangles (for example if the annotation covers more than one line). + An annotation can have several shapes (for example if a NER annotation covers more than + one line). A polygon always has exactly one. """ + self._assert_input_type_is_pdf("polys") return self._json_data["polys"] @property def page_number_array(self) -> list[int]: """Return the pages where the annotation appears.""" + self._assert_input_type_is_pdf("page_number_array") return self._json_data["pageNumberArray"] +class EntityInPdfAnnotation(_BaseNamedEntityRecognitionAnnotation, _BasePdfAnnotation): + """Class for parsing the "annotations" key of a job response for named entities recognition in PDFs.""" + + @staticmethod + def _get_compatible_ml_task() -> Literal["NAMED_ENTITIES_RECOGNITION"]: + return "NAMED_ENTITIES_RECOGNITION" + + +class PdfObjectDetectionAnnotation(_BasePdfAnnotation): + """Class for parsing the "annotations" key of a job response for object detection in PDFs. + + Registers the page-level geometry attributes for object detection jobs, which is what makes + a PDF bounding box or polygon reachable through `.annotations`, `.polys` and + `.page_number_array`. + """ + + @staticmethod + def _get_compatible_ml_task() -> Literal["OBJECT_DETECTION"]: + return "OBJECT_DETECTION" + + @staticmethod + def _get_compatible_type_of_tools() -> Sequence[Literal["rectangle", "polygon", "semantic"]]: + return ("rectangle", "polygon", "semantic") + + class _Base2DAnnotation(_BaseAnnotationWithTool, _BaseAnnotationWithBoundingPoly): """Base class for 2D annotations.""" @@ -344,7 +407,7 @@ def add_bounding_poly( bounding_poly_list=[], project_info=self._project_info, job_name=self._job_name, - type_of_tool=self._json_data["type"], + type_of_tool=self._json_data.get("type"), ) if "boundingPoly" not in self._json_data else self._json_data["boundingPoly"] @@ -526,6 +589,7 @@ class Annotation( PointAnnotation, PolyLineAnnotation, EntityInPdfAnnotation, + PdfObjectDetectionAnnotation, BoundingPolyAnnotation, VideoAnnotation, PoseEstimationAnnotation, diff --git a/src/kili/services/label_data_parsing/bounding_poly.py b/src/kili/services/label_data_parsing/bounding_poly.py index 6a6feca2b..da353981f 100644 --- a/src/kili/services/label_data_parsing/bounding_poly.py +++ b/src/kili/services/label_data_parsing/bounding_poly.py @@ -57,26 +57,36 @@ def normalized_vertices( """Sets the normalized vertices of the bounding polygon. Args: - normalized_vertices: List of normalized vertices for object detection tasks. - Or a list of a list of normalized vertices for NER in PDF task. + normalized_vertices: A flat list of normalized vertices, or a list of rings + (a list of lists of normalized vertices). Both shapes exist in production: + image annotations are flat, PDF annotations nest their vertices one level + deeper. The nesting is inferred from the data itself, not from the tool, + because a PDF annotation is nested whatever tool drew it. """ + rings = ( + normalized_vertices + if normalized_vertices and isinstance(normalized_vertices[0], list) + else [normalized_vertices] + ) + # for object detection tasks type of tool is defined if self._type_of_tool: - nb_vertices = len(normalized_vertices) - - if self._type_of_tool == "rectangle" and nb_vertices != 4: - raise InvalidMutationError( - f"Bounding polygon with {nb_vertices} vertices is not a rectangle." - ) - - if self._type_of_tool == "polygon" and nb_vertices < 3: - raise InvalidMutationError( - f"Bounding polygon with {nb_vertices} vertices is not a polygon." - ) - - vertices = normalized_vertices if self._type_of_tool else normalized_vertices[0] - for vertex in vertices: - assert isinstance(vertex, dict), f"Vertex {vertex} is not a dict." + for ring in rings: + nb_vertices = len(ring) + + if self._type_of_tool == "rectangle" and nb_vertices != 4: + raise InvalidMutationError( + f"Bounding polygon with {nb_vertices} vertices is not a rectangle." + ) + + if self._type_of_tool == "polygon" and nb_vertices < 3: + raise InvalidMutationError( + f"Bounding polygon with {nb_vertices} vertices is not a polygon." + ) + + for ring in rings: + for vertex in ring: + assert isinstance(vertex, dict), f"Vertex {vertex} is not a dict." self._json_data["normalizedVertices"] = normalized_vertices diff --git a/tests/unit/services/export/test_kili.py b/tests/unit/services/export/test_kili.py index cde21c37c..ea650fe3b 100644 --- a/tests/unit/services/export/test_kili.py +++ b/tests/unit/services/export/test_kili.py @@ -197,6 +197,113 @@ def test_kili_exporter_convert_to_pixel_coords_pdf(mocker: pytest_mock.MockerFix } +def test_kili_exporter_convert_to_pixel_coords_pdf_polygon(mocker: pytest_mock.MockerFixture): + """A PDF polygon scales to pixels against the dimensions of its own page. + + Self-contained on purpose: `fakes/pdf_project_assets.json` is left untouched so that the + bounding box expectations keep proving that nothing regressed for existing PDF projects. + """ + mocker.patch.object(KiliExporter, "__init__", return_value=None) + exporter = KiliExporter() # type: ignore # pylint: disable=no-value-for-parameter + exporter.normalized_coordinates = None + + project: ProjectDict = { + "id": "fake_project_id", + "title": "Fake Project Title", + "description": "Fake Project Description", + "organizationId": "fake_organization_id", + "inputType": "PDF", + "jsonInterface": { + "jobs": { + "OBJECT_DETECTION_JOB": { + "content": { + "categories": { + "A": {"children": [], "color": "#472CED", "name": "A"}, + "B": {"children": [], "name": "B", "color": "#5CE7B7"}, + }, + "input": "radio", + }, + "instruction": "Polygon", + "mlTask": "OBJECT_DETECTION", + "required": 1, + "tools": ["polygon"], + "isChild": False, + } + } + }, + } + normalized_vertices = [ + {"x": 0.1, "y": 0.1}, + {"x": 0.3, "y": 0.05}, + {"x": 0.4, "y": 0.3}, + {"x": 0.25, "y": 0.45}, + {"x": 0.08, "y": 0.3}, + ] + # page 2 is landscape, so scaling against page 1 would give a different result + page_resolutions = [ + {"pageNumber": 1, "height": 842, "width": 595, "rotation": 0}, + {"pageNumber": 2, "height": 595, "width": 842, "rotation": 0}, + ] + asset = { + "latestLabel": { + "author": { + "id": "user-feat1-1", + "email": "test+admin+1@kili-technology.com", + "firstname": "Feat1", + "lastname": "Test Admin", + "name": "Feat1 Test Admin", + }, + "jsonResponse": { + "OBJECT_DETECTION_JOB": { + "annotations": [ + { + "children": {}, + "annotations": [ + { + "boundingPoly": [{"normalizedVertices": [normalized_vertices]}], + "pageNumberArray": [2], + "polys": [{"normalizedVertices": [normalized_vertices]}], + } + ], + "categories": [{"confidence": 100, "name": "A"}], + "content": "", + "mid": "20230703112327217-43948", + "type": "polygon", + }, + ] + } + }, + "createdAt": "2023-07-03T12:18:08.825Z", + "isLatestLabelForUser": True, + "labelType": "DEFAULT", + "modelName": None, + }, + "pageResolutions": page_resolutions, + "content": "https://", + "jsonContent": "https://", + } + + scaled_asset = convert_to_pixel_coords(asset, project) + + expected_vertices = [ + [{"x": vertex["x"] * 842, "y": vertex["y"] * 595} for vertex in normalized_vertices] + ] + scaled_page_annotation = scaled_asset["latestLabel"]["jsonResponse"]["OBJECT_DETECTION_JOB"][ + "annotations" + ][0]["annotations"][0] + + for key in ("polys", "boundingPoly"): + assert scaled_page_annotation[key] == [ + { + "normalizedVertices": [normalized_vertices], + "vertices": expected_vertices, + } + ] + assert scaled_page_annotation["pageNumberArray"] == [2] + # the polygon keeps its five vertices, it is not reduced to a bounding rectangle + assert len(scaled_page_annotation["polys"][0]["vertices"][0]) == 5 + + def test_kili_export_labels_non_normalized_pdf(mocker: pytest_mock.MockerFixture): get_project_return_val = { "inputType": "PDF", diff --git a/tests/unit/services/label_data_parsing/parsing/test_resp_parsing.py b/tests/unit/services/label_data_parsing/parsing/test_resp_parsing.py index aca2cac6e..819a679a6 100644 --- a/tests/unit/services/label_data_parsing/parsing/test_resp_parsing.py +++ b/tests/unit/services/label_data_parsing/parsing/test_resp_parsing.py @@ -9,7 +9,11 @@ ) from kili.services.label_data_parsing.bounding_poly import BoundingPoly from kili.services.label_data_parsing.category import Category, CategoryList -from kili.services.label_data_parsing.exceptions import FrameIndexError +from kili.services.label_data_parsing.exceptions import ( + AttributeNotCompatibleWithJobError, + FrameIndexError, + InvalidMutationError, +) from kili.services.label_data_parsing.job_response import JobPayload from kili.services.label_data_parsing.json_response import ParsedJobs from kili.services.label_data_parsing.types import Project @@ -1651,6 +1655,243 @@ def test_parsing_ner_in_pdf_2(): assert annotation_1.annotations[0].page_number_array == [1, 1, 1] +def _object_detection_in_pdf_json_interface(tools: list) -> dict: + return { + "JOB_0": { + "content": { + "categories": { + "OBJECT_A": {"children": [], "name": "Object A", "color": "#733AFB"}, + "OBJECT_B": {"children": [], "name": "Object B", "color": "#3CD876"}, + }, + "input": "radio", + }, + "instruction": "What objects can you identify?", + "isChild": False, + "tools": tools, + "mlTask": "OBJECT_DETECTION", + "models": {}, + "isVisible": True, + "required": 1, + } + } + + +POLYGON_VERTICES = [ + {"x": 0.1, "y": 0.1}, + {"x": 0.3, "y": 0.05}, + {"x": 0.4, "y": 0.3}, + {"x": 0.25, "y": 0.45}, + {"x": 0.08, "y": 0.3}, +] + +RECTANGLE_VERTICES = [ + {"x": 0.47, "y": 0.1}, + {"x": 0.47, "y": 0.23}, + {"x": 0.67, "y": 0.23}, + {"x": 0.67, "y": 0.1}, +] + + +def _object_detection_in_pdf_json_resp( + vertices: list, page_number: int, type_of_tool, nested: bool = True +) -> dict: + """Build a PDF object detection response, with the geometry on the nested page annotation.""" + normalized_vertices = [vertices] if nested else vertices + # boundingPoly and polys hold the same ring but must not share the same list object + annotation = { + "children": {}, + "annotations": [ + { + "boundingPoly": [{"normalizedVertices": deepcopy(normalized_vertices)}], + "pageNumberArray": [page_number], + "polys": [{"normalizedVertices": deepcopy(normalized_vertices)}], + } + ], + "categories": [{"confidence": 100, "name": "OBJECT_A"}], + "content": "", + "mid": "20230703112327217-43948", + } + if type_of_tool is not None: + annotation["type"] = type_of_tool + return {"JOB_0": {"annotations": [annotation]}} + + +def test_parsing_polygon_in_pdf(): + json_interface = _object_detection_in_pdf_json_interface(["polygon"]) + json_resp = _object_detection_in_pdf_json_resp(POLYGON_VERTICES, 2, "polygon") + + project_info = Project(jsonInterface=json_interface, inputType="PDF") # type: ignore + parsed_jobs = ParsedJobs(project_info=project_info, json_response=deepcopy(json_resp)) + + annotation = parsed_jobs["JOB_0"].annotations[0] + assert annotation.type == "polygon" + assert annotation.category.name == "OBJECT_A" + + page_annotation = annotation.annotations[0] + assert page_annotation.polys == [{"normalizedVertices": [POLYGON_VERTICES]}] + assert page_annotation.page_number_array == [2] + assert page_annotation.bounding_poly[0].normalized_vertices == [POLYGON_VERTICES] + + # reading the nested layer must not corrupt the label + assert parsed_jobs.to_dict() == json_resp + + +def test_parsing_polygon_with_a_hole_in_pdf(): + """A polygon can carry several rings: the outer contour and its holes.""" + hole_vertices = [ + {"x": 0.15, "y": 0.15}, + {"x": 0.2, "y": 0.12}, + {"x": 0.22, "y": 0.25}, + ] + json_interface = _object_detection_in_pdf_json_interface(["polygon"]) + json_resp = _object_detection_in_pdf_json_resp(POLYGON_VERTICES, 1, "polygon") + for key in ("boundingPoly", "polys"): + json_resp["JOB_0"]["annotations"][0]["annotations"][0][key][0]["normalizedVertices"].append( + hole_vertices + ) + + project_info = Project(jsonInterface=json_interface, inputType="PDF") # type: ignore + parsed_jobs = ParsedJobs(project_info=project_info, json_response=deepcopy(json_resp)) + + page_annotation = parsed_jobs["JOB_0"].annotations[0].annotations[0] + assert page_annotation.bounding_poly[0].normalized_vertices == [ + POLYGON_VERTICES, + hole_vertices, + ] + assert parsed_jobs.to_dict() == json_resp + + +def test_parsing_rectangle_in_pdf_job_configured_with_the_polygon_tool(): + """A rectangle drawn before the job was switched to the polygon tool must still parse. + + It carries no "type" key, since PDF labels only emit one for the polygon, so it is not + rejected as a tool the job does not declare. + """ + json_interface = _object_detection_in_pdf_json_interface(["polygon"]) + json_resp = _object_detection_in_pdf_json_resp(RECTANGLE_VERTICES, 1, None) + + project_info = Project(jsonInterface=json_interface, inputType="PDF") # type: ignore + parsed_jobs = ParsedJobs(project_info=project_info, json_response=deepcopy(json_resp)) + + annotation = parsed_jobs["JOB_0"].annotations[0] + assert annotation.type == "rectangle" + assert annotation.annotations[0].bounding_poly[0].normalized_vertices == [RECTANGLE_VERTICES] + assert parsed_jobs.to_dict() == json_resp + + +def test_parsing_polygon_in_pdf_keeps_vertices_outside_the_page(): + """The SDK does not clip on import: clipping would silently rewrite user data.""" + out_of_page_vertices = [ + {"x": -0.2, "y": 0.1}, + {"x": 0.3, "y": -0.05}, + {"x": 1.4, "y": 0.3}, + ] + json_interface = _object_detection_in_pdf_json_interface(["polygon"]) + json_resp = _object_detection_in_pdf_json_resp(out_of_page_vertices, 1, "polygon") + + project_info = Project(jsonInterface=json_interface, inputType="PDF") # type: ignore + parsed_jobs = ParsedJobs(project_info=project_info, json_response=deepcopy(json_resp)) + + page_annotation = parsed_jobs["JOB_0"].annotations[0].annotations[0] + assert page_annotation.polys == [{"normalizedVertices": [out_of_page_vertices]}] + assert parsed_jobs.to_dict() == json_resp + + +@pytest.mark.parametrize( + ("test_name", "type_of_tool", "nested"), + [ + # labels stored as a raw jsonResponse before the polygon tool existed + ("legacy: no type, nested vertices", None, True), + # labels produced by the current backend + ("current: type, flat vertices", "rectangle", False), + ("no type, flat vertices", None, False), + ("type, nested vertices", "rectangle", True), + ], +) +def test_parsing_rectangle_in_pdf(test_name, type_of_tool, nested): + """Every rectangle shape found in production must parse and round-trip unchanged.""" + json_interface = _object_detection_in_pdf_json_interface(["rectangle"]) + json_resp = _object_detection_in_pdf_json_resp( + RECTANGLE_VERTICES, 1, type_of_tool, nested=nested + ) + + project_info = Project(jsonInterface=json_interface, inputType="PDF") # type: ignore + parsed_jobs = ParsedJobs(project_info=project_info, json_response=deepcopy(json_resp)) + + annotation = parsed_jobs["JOB_0"].annotations[0] + # a PDF object detection annotation without a "type" key is a rectangle + assert annotation.type == "rectangle" + + expected_vertices = [RECTANGLE_VERTICES] if nested else RECTANGLE_VERTICES + page_annotation = annotation.annotations[0] + assert page_annotation.polys == [{"normalizedVertices": expected_vertices}] + assert page_annotation.page_number_array == [1] + assert page_annotation.bounding_poly[0].normalized_vertices == expected_vertices + + assert parsed_jobs.to_dict() == json_resp + + +def test_parsing_polygon_in_pdf_job_without_the_polygon_tool_raises(): + json_interface = _object_detection_in_pdf_json_interface(["rectangle"]) + json_resp = _object_detection_in_pdf_json_resp(POLYGON_VERTICES, 1, "polygon") + + project_info = Project(jsonInterface=json_interface, inputType="PDF") # type: ignore + + with pytest.raises(InvalidMutationError): + ParsedJobs(project_info=project_info, json_response=deepcopy(json_resp)) + + +def test_parsing_polygon_in_image_still_rejects_pdf_attributes(): + """The page level attributes are PDF only: an image polygon must keep rejecting them.""" + json_interface = _object_detection_in_pdf_json_interface(["polygon"]) + json_resp = { + "JOB_0": { + "annotations": [ + { + "children": {}, + "boundingPoly": [{"normalizedVertices": POLYGON_VERTICES}], + "categories": [{"confidence": 100, "name": "OBJECT_A"}], + "mid": "20230703112327217-43948", + "type": "polygon", + } + ] + } + } + + project_info = Project(jsonInterface=json_interface, inputType="IMAGE") # type: ignore + parsed_jobs = ParsedJobs(project_info=project_info, json_response=deepcopy(json_resp)) + + annotation = parsed_jobs["JOB_0"].annotations[0] + assert annotation.bounding_poly[0].normalized_vertices == POLYGON_VERTICES + + for attribute_name in ("annotations", "polys", "page_number_array"): + with pytest.raises(AttributeNotCompatibleWithJobError): + getattr(annotation, attribute_name) + + +def test_parsing_object_detection_in_image_without_type_still_raises(): + """The rectangle fallback is PDF only: an image annotation must keep raising.""" + json_interface = _object_detection_in_pdf_json_interface(["rectangle"]) + json_resp = { + "JOB_0": { + "annotations": [ + { + "children": {}, + "boundingPoly": [{"normalizedVertices": RECTANGLE_VERTICES}], + "categories": [{"confidence": 100, "name": "OBJECT_A"}], + "mid": "20230703112327217-43948", + } + ] + } + } + + project_info = Project(jsonInterface=json_interface, inputType="IMAGE") # type: ignore + parsed_jobs = ParsedJobs(project_info=project_info, json_response=deepcopy(json_resp)) + + with pytest.raises(KeyError): + _ = parsed_jobs["JOB_0"].annotations[0].type + + def test_pose_estimation_1(): json_interface = { "jobs": {