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..abf3d7aa5 100644 --- a/tests/unit/services/export/test_kili.py +++ b/tests/unit/services/export/test_kili.py @@ -197,6 +197,129 @@ 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}, + ] + enclosing_rectangle = [ + {"x": 0.08, "y": 0.05}, + {"x": 0.08, "y": 0.45}, + {"x": 0.4, "y": 0.45}, + {"x": 0.4, "y": 0.05}, + ] + # 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": [ + { + # the backend derives boundingPoly as the enclosing rectangle, + # so only polys carries the polygon's own five vertices + "boundingPoly": [ + {"normalizedVertices": [enclosing_rectangle]} + ], + "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) + + def to_pixels(vertices): + return [[{"x": vertex["x"] * 842, "y": vertex["y"] * 595} for vertex in vertices]] + + scaled_page_annotation = scaled_asset["latestLabel"]["jsonResponse"]["OBJECT_DETECTION_JOB"][ + "annotations" + ][0]["annotations"][0] + + assert scaled_page_annotation["polys"] == [ + { + "normalizedVertices": [normalized_vertices], + "vertices": to_pixels(normalized_vertices), + } + ] + assert scaled_page_annotation["boundingPoly"] == [ + { + "normalizedVertices": [enclosing_rectangle], + "vertices": to_pixels(enclosing_rectangle), + } + ] + assert scaled_page_annotation["pageNumberArray"] == [2] + # the polygon keeps its five vertices in polys, while boundingPoly stays the 4-vertex box + assert len(scaled_page_annotation["polys"][0]["vertices"][0]) == 5 + assert len(scaled_page_annotation["boundingPoly"][0]["vertices"][0]) == 4 + + 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..877919d54 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,270 @@ 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 _enclosing_rectangle(vertices: list) -> list: + """The 4-vertex box the backend derives for boundingPoly, in the vertex order it writes. + + For a rectangle this is the shape itself, which is why the two coincide there. + """ + xs = [vertex["x"] for vertex in vertices] + ys = [vertex["y"] for vertex in vertices] + x_min, x_max, y_min, y_max = min(xs), max(xs), min(ys), max(ys) + return [ + {"x": x_min, "y": y_min}, + {"x": x_min, "y": y_max}, + {"x": x_max, "y": y_max}, + {"x": x_max, "y": y_min}, + ] + + +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. + + Only polys carries the shape's own ring. boundingPoly is the enclosing rectangle the backend + recomputes, so a polygon exports 4 vertices there whatever its vertex count -- reading it + back as the polygon would silently hand out a box. + """ + polys_vertices = [vertices] if nested else vertices + enclosing_rectangle = _enclosing_rectangle(vertices) + bounding_poly_vertices = [enclosing_rectangle] if nested else enclosing_rectangle + annotation = { + "children": {}, + "annotations": [ + { + "boundingPoly": [{"normalizedVertices": deepcopy(bounding_poly_vertices)}], + "pageNumberArray": [page_number], + "polys": [{"normalizedVertices": deepcopy(polys_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] + # the ring lives in polys; boundingPoly is the derived 4-vertex box, not the polygon + assert page_annotation.bounding_poly[0].normalized_vertices == [ + _enclosing_rectangle(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") + json_resp["JOB_0"]["annotations"][0]["annotations"][0]["polys"][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.polys == [ + {"normalizedVertices": [POLYGON_VERTICES, hole_vertices]} + ] + # the hole does not widen the enclosing box, which stays a single 4-vertex ring + assert page_annotation.bounding_poly[0].normalized_vertices == [ + _enclosing_rectangle(POLYGON_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": {