diff --git a/aperturedb/CommonLibrary.py b/aperturedb/CommonLibrary.py index ab91794a..45970450 100644 --- a/aperturedb/CommonLibrary.py +++ b/aperturedb/CommonLibrary.py @@ -384,7 +384,7 @@ def map_response_to_handler(handler, query, query_blobs, response, response_blo for req, resp in zip(query[start:end], response[start:end]): for k in req: blob_returning_commands = ["FindImage", "FindBlob", "FindVideo", - "FindDescriptor", "FindBoundingBox"] + "FindDescriptor", "FindBoundingBox", "FindFrame"] if k in blob_returning_commands and "blobs" in req[k] and req[k]["blobs"]: count = resp[k]["returned"] b_count += count diff --git a/aperturedb/DataModels.py b/aperturedb/DataModels.py index d5fcc51b..7464521d 100644 --- a/aperturedb/DataModels.py +++ b/aperturedb/DataModels.py @@ -4,7 +4,7 @@ from __future__ import annotations from pydantic import BaseModel, Field from typing_extensions import Annotated, List -from typing import ClassVar +from typing import ClassVar, Optional from uuid import uuid4 from aperturedb.Query import ObjectType, PropertyType, RangeType @@ -70,7 +70,7 @@ class PolygonDataModel(IdentityDataModel): type = ObjectType.POLYGON -class FrameDataModel(IdentityDataModel): +class FrameDataModel(BlobDataModel): """Frame data model for ApertureDB. """ type = ObjectType.FRAME diff --git a/aperturedb/Entities.py b/aperturedb/Entities.py index 389a0acd..5d824e44 100644 --- a/aperturedb/Entities.py +++ b/aperturedb/Entities.py @@ -278,6 +278,7 @@ def get_blob(self, entity) -> Any: def load_entities_registry(custom_entities: List[str] = None) -> dict: from aperturedb.Polygons import Polygons from aperturedb.Images import Images + from aperturedb.Frames import Frames from aperturedb.Blobs import Blobs from aperturedb.BoundingBoxes import BoundingBoxes from aperturedb.Videos import Videos @@ -287,6 +288,7 @@ def load_entities_registry(custom_entities: List[str] = None) -> dict: known_entities = { ObjectType.POLYGON.value: Polygons, ObjectType.IMAGE.value: Images, + ObjectType.FRAME.value: Frames, ObjectType.VIDEO.value: Videos, ObjectType.BOUNDING_BOX.value: BoundingBoxes, ObjectType.BLOB.value: Blobs, diff --git a/aperturedb/FrameDataCSV.py b/aperturedb/FrameDataCSV.py new file mode 100644 index 00000000..9f012a9d --- /dev/null +++ b/aperturedb/FrameDataCSV.py @@ -0,0 +1,19 @@ +from aperturedb.ImageDataCSV import ImageDataCSV +from aperturedb.Query import ObjectType + + +class FrameDataCSV(ImageDataCSV): + """ + **Helper class to ingest Frame data from a CSV file.** + + This class extends ImageDataCSV and sets the insertion command to "AddFrame", + allowing frame files to be batch ingested from CSVs just like images. + """ + command = "AddFrame" + + def get_indices(self): + return { + "entity": { + ObjectType.FRAME.value: self.get_indexed_properties() + } + } diff --git a/aperturedb/Frames.py b/aperturedb/Frames.py new file mode 100644 index 00000000..060655ae --- /dev/null +++ b/aperturedb/Frames.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from aperturedb.Images import Images +from aperturedb.Query import ObjectType + + +class Frames(Images): + """ + **The python wrapper of frame images in ApertureDB.** + + Frames in ApertureDB are quite similar to images and so + are modeled in python as a subclass. + + + Args: + client: The database connector, perhaps as returned by `CommonLibrary.create_connector` + """ + db_object = ObjectType.FRAME + + def __init__(self, client, batch_size=100, response=None, **kwargs): + super().__init__( + client, batch_size=batch_size, response=response, **kwargs) diff --git a/aperturedb/ImageDataCSV.py b/aperturedb/ImageDataCSV.py index bbfaa797..edd56f18 100644 --- a/aperturedb/ImageDataCSV.py +++ b/aperturedb/ImageDataCSV.py @@ -171,6 +171,7 @@ class ImageDataCSV(CSVParser.CSVParser, ImageDataProcessor): id would be only inserted if it does not already exist in the database. ::: """ + command = "AddImage" def __init__(self, filename: str, check_image: bool = True, n_download_retries: int = 3, **kwargs): @@ -199,8 +200,6 @@ def __init__(self, filename: str, check_image: bool = True, n_download_retries: self.relative_path_prefix = os.path.dirname(self.filename) \ if self.source_type == HEADER_PATH and self.blobs_relative_to_csv else "" - self.command = "AddImage" - def getitem(self, idx): idx = self.df.index.start + idx diff --git a/aperturedb/Images.py b/aperturedb/Images.py index 1c5cc3a0..055a5058 100644 --- a/aperturedb/Images.py +++ b/aperturedb/Images.py @@ -3,7 +3,10 @@ """ from __future__ import annotations -from typing import Any, Dict, Iterable, List, Tuple, Union +from typing import Any, Dict, Iterable, List, Tuple, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from aperturedb.Frames import Frames import cv2 import math import numpy as np @@ -1003,18 +1006,24 @@ def get_properties(self, prop_list: Iterable[str] = []) -> Dict[str, Any]: return return_dictionary -class Frames(Images): - """ - **The python wrapper of frame images in ApertureDB.** +# Shim for backward compatibility +def __getattr__(name: str): + if name == "Frames": + from aperturedb.Frames import Frames + globals()[name] = Frames + return Frames + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - Frames in ApertureDB are quite similar to images and so - are modeled in python as a subclass. +__all__ = [ + "Any", "BytesIO", "Constraints", "DataFrame", "Dict", "Entities", + "Frames", "HTML", "Image", "Images", "Iterable", "List", + "ObjectType", "QueryBuilder", "TYPE_CHECKING", "Tuple", "Union", + "Utils", "base64", "class_entity", "cv2", "display", + "execute_query", "image_to_bytes", "logger", "logging", "math", + "np", "np_arr_img_to_bytes", "plt", "resolve", "rotate", "widgets" +] - Args: - client: The database connector, perhaps as returned by `CommonLibrary.create_connector` - """ - db_object = ObjectType.FRAME - def __init__(self, client, batch_size=100, response=None, **kwargs): - super().__init__(client, batch_size=batch_size, response=response, **kwargs) +def __dir__(): + return sorted(list(globals().keys()) + ["Frames"]) diff --git a/aperturedb/MLCroissant.py b/aperturedb/MLCroissant.py index 71e8862c..27d1eee2 100644 --- a/aperturedb/MLCroissant.py +++ b/aperturedb/MLCroissant.py @@ -273,7 +273,7 @@ def getitem(self, subscript): indexes_to_create = [] for command in q: cmd = list(command.keys())[-1] - if cmd in ["AddImage", "AddBlob", "AddVideo"]: + if cmd in ["AddImage", "AddBlob", "AddVideo", "AddFrame"]: continue indexable_entity = command[list(command.keys())[-1]]["class"] if indexable_entity not in self.indexed_entities: diff --git a/aperturedb/PyTorchDataset.py b/aperturedb/PyTorchDataset.py index b7e4fdff..070757a0 100644 --- a/aperturedb/PyTorchDataset.py +++ b/aperturedb/PyTorchDataset.py @@ -37,7 +37,7 @@ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, comm allowed_find_commands = { "FindImage", "FindVideo", "FindBlob", - "FindDescriptor", "FindBoundingBox" + "FindDescriptor", "FindBoundingBox", "FindFrame" } if self.command_idx is not None: @@ -61,7 +61,7 @@ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, comm self.command_name = name if self.command_idx is None: - msg = "Query error. The query must contain at least one supported blob-returning Find command (e.g., FindImage, FindVideo, FindBlob). The first one encountered will be used." + msg = "Query error. The query must contain at least one supported blob-returning Find command (e.g., FindImage, FindVideo, FindBlob, FindFrame). The first one encountered will be used." logger.error(msg) raise ValueError(msg) @@ -111,7 +111,7 @@ def __getitem__(self, index): blob = self.batch_blobs[idx] label = self.batch_labels[idx] - if self.command_name == "FindImage": + if self.command_name in ("FindImage", "FindFrame"): nparr = np.frombuffer(blob, dtype=np.uint8) blob = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if blob is None: diff --git a/aperturedb/Query.py b/aperturedb/Query.py index 03923c5b..113a18f7 100644 --- a/aperturedb/Query.py +++ b/aperturedb/Query.py @@ -227,7 +227,7 @@ def generate_add_query( params.pop("properties", None) query.append( QueryBuilder.find_command(obj.type.value, params=params)) - if obj.type in [ObjectType.IMAGE, ObjectType.VIDEO, ObjectType.BLOB]: + if obj.type in [ObjectType.IMAGE, ObjectType.VIDEO, ObjectType.BLOB, ObjectType.FRAME]: # Do not send blob, if Node has been added to set of commands. if obj.id not in cached: if obj.url: diff --git a/aperturedb/TensorFlowDataset.py b/aperturedb/TensorFlowDataset.py index 7ff595c1..9600c4a7 100644 --- a/aperturedb/TensorFlowDataset.py +++ b/aperturedb/TensorFlowDataset.py @@ -35,7 +35,7 @@ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, comm allowed_find_commands = { "FindImage", "FindVideo", "FindBlob", - "FindDescriptor", "FindBoundingBox" + "FindDescriptor", "FindBoundingBox", "FindFrame" } if self.command_idx is not None: @@ -59,7 +59,7 @@ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, comm self.command_name = name if self.command_idx is None: - msg = "Query error. The query must contain at least one supported blob-returning Find command (e.g., FindImage, FindVideo, FindBlob). The first one encountered will be used." + msg = "Query error. The query must contain at least one supported blob-returning Find command (e.g., FindImage, FindVideo, FindBlob, FindFrame). The first one encountered will be used." logger.error(msg) raise ValueError(msg) @@ -177,7 +177,7 @@ def generator(self): blob = self.batch_blobs[idx] label = self.batch_labels[idx] - if self.command_name == "FindImage": + if self.command_name in ("FindImage", "FindFrame"): nparr = np.frombuffer(blob, dtype=np.uint8) blob = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if blob is None: @@ -226,7 +226,7 @@ def get_dataset(self): else: self.label_type = tf.string - if self.command_name == "FindImage": + if self.command_name in ("FindImage", "FindFrame"): tensor_shape = (None, None, 3) tensor_dtype = tf.uint8 else: diff --git a/aperturedb/cli/ingest.py b/aperturedb/cli/ingest.py index fc953056..13de9f12 100644 --- a/aperturedb/cli/ingest.py +++ b/aperturedb/cli/ingest.py @@ -194,6 +194,7 @@ def from_csv(filepath: Annotated[str, typer.Argument( Ingest data from a pre generated CSV file. """ from aperturedb.ImageDataCSV import ImageDataCSV + from aperturedb.FrameDataCSV import FrameDataCSV from aperturedb.BBoxDataCSV import BBoxDataCSV from aperturedb.EntityDataCSV import EntityDataCSV from aperturedb.BlobDataCSV import BlobDataCSV @@ -210,6 +211,7 @@ def from_csv(filepath: Annotated[str, typer.Argument( IngestType.DESCRIPTOR: DescriptorDataCSV, IngestType.DESCRIPTORSET: DescriptorSetDataCSV, IngestType.ENTITY: EntityDataCSV, + IngestType.FRAME: FrameDataCSV, IngestType.IMAGE: ImageDataCSV, IngestType.POLYGON: PolygonDataCSV, IngestType.VIDEO: VideoDataCSV diff --git a/aperturedb/transformers/clip_pytorch_embeddings.py b/aperturedb/transformers/clip_pytorch_embeddings.py index 62dedbaa..8829358a 100644 --- a/aperturedb/transformers/clip_pytorch_embeddings.py +++ b/aperturedb/transformers/clip_pytorch_embeddings.py @@ -95,7 +95,7 @@ def getitem(self, subscript): except Exception as e: logger.warning( f"Failed to generate embedding or descriptor: {e}", exc_info=True) - if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob"]: + if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob", "AddFrame"]: blob_index += 1 x[0].extend(new_descriptors) diff --git a/aperturedb/transformers/common_properties.py b/aperturedb/transformers/common_properties.py index 682b1795..bdc02325 100644 --- a/aperturedb/transformers/common_properties.py +++ b/aperturedb/transformers/common_properties.py @@ -49,7 +49,7 @@ def getitem(self, subscript): if isinstance(cmd_dict, dict) and len(cmd_dict) > 0: cmd_name = next(iter(cmd_dict.keys())) - if cmd_name in ["AddImage", "AddVideo", "AddBoundingBox", "AddPolygon"]: + if cmd_name in ["AddImage", "AddVideo", "AddBoundingBox", "AddPolygon", "AddFrame"]: src_properties = cmd_dict[cmd_name].setdefault( "properties", {}) self._apply_common_properties(src_properties) diff --git a/aperturedb/transformers/facenet_pytorch_embeddings.py b/aperturedb/transformers/facenet_pytorch_embeddings.py index 35fc00ba..014eddff 100644 --- a/aperturedb/transformers/facenet_pytorch_embeddings.py +++ b/aperturedb/transformers/facenet_pytorch_embeddings.py @@ -100,7 +100,7 @@ def getitem(self, subscript): except Exception as e: logger.warning( f"Failed to generate embedding or descriptor: {e}", exc_info=True) - if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob"]: + if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob", "AddFrame"]: blob_index += 1 x[0].extend(new_descriptors) diff --git a/aperturedb/transformers/image_properties.py b/aperturedb/transformers/image_properties.py index bea3e181..fadcece9 100644 --- a/aperturedb/transformers/image_properties.py +++ b/aperturedb/transformers/image_properties.py @@ -34,7 +34,7 @@ def getitem(self, subscript): if isinstance(cmd_dict, dict) and len(cmd_dict) > 0: cmd_name = next(iter(cmd_dict.keys())) - if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob"]: + if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob", "AddFrame"]: if blob_index >= len(x[1]): logger.warning( "Missing blob for command %s (expected at index %d), stopping property processing for this transaction.", @@ -66,7 +66,7 @@ def getitem(self, subscript): logger.exception( "Error applying image properties", stack_info=True) - if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob"]: + if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob", "AddFrame"]: blob_index += 1 return x diff --git a/aperturedb/transformers/transformer.py b/aperturedb/transformers/transformer.py index 47a29af4..669557ac 100644 --- a/aperturedb/transformers/transformer.py +++ b/aperturedb/transformers/transformer.py @@ -67,7 +67,7 @@ def __init__(self, data: Subscriptable, client=None, **kwargs) -> None: command = None if isinstance(c, dict) and len(c) > 0: command = next(iter(c.keys())) - if command in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob"]: + if command in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob", "AddFrame"]: self._blob_index.append(i) bc += 1 # Kept for backward compatibility diff --git a/aperturedb/transformers/video_properties.py b/aperturedb/transformers/video_properties.py index ebd5325d..992747f8 100644 --- a/aperturedb/transformers/video_properties.py +++ b/aperturedb/transformers/video_properties.py @@ -32,7 +32,7 @@ def getitem(self, subscript): if isinstance(cmd_dict, dict) and len(cmd_dict) > 0: cmd_name = next(iter(cmd_dict.keys())) - if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob"]: + if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob", "AddFrame"]: if blob_index >= len(x[1]): logger.warning( "Missing blob for command %s (expected at index %d), stopping property processing for this transaction.", @@ -59,7 +59,7 @@ def getitem(self, subscript): logger.exception( "Error applying video properties", stack_info=True) - if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob"]: + if cmd_name in ["AddImage", "AddDescriptor", "AddVideo", "AddBlob", "AddFrame"]: blob_index += 1 return x diff --git a/test/test_DataModels.py b/test/test_DataModels.py new file mode 100644 index 00000000..72ccc550 --- /dev/null +++ b/test/test_DataModels.py @@ -0,0 +1,16 @@ +from pydantic import ValidationError +from aperturedb.DataModels import FrameDataModel +from aperturedb.Query import ObjectType +import pytest + + +def test_FrameDataModel(): + # url is required, so instantiating without it should raise a ValidationError + with pytest.raises(ValidationError): + FrameDataModel() + + # Verify that when url is provided, the model instantiates correctly + frame = FrameDataModel(url="http://example.com/frame.jpg") + assert frame.url == "http://example.com/frame.jpg" + assert frame.type == ObjectType.FRAME + assert frame.id is not None # Should have a default UUID generated diff --git a/test/test_Entities.py b/test/test_Entities.py new file mode 100644 index 00000000..0666c304 --- /dev/null +++ b/test/test_Entities.py @@ -0,0 +1,9 @@ +from aperturedb.Entities import load_entities_registry +from aperturedb.Query import ObjectType +from aperturedb.Frames import Frames + + +def test_load_entities_registry_frames(): + registry = load_entities_registry() + assert ObjectType.FRAME.value in registry + assert registry[ObjectType.FRAME.value] is Frames diff --git a/test/test_FrameDataCSV.py b/test/test_FrameDataCSV.py new file mode 100644 index 00000000..17c2a561 --- /dev/null +++ b/test/test_FrameDataCSV.py @@ -0,0 +1,26 @@ +import tempfile +import os +from aperturedb.FrameDataCSV import FrameDataCSV +from aperturedb.Query import ObjectType + + +def test_FrameDataCSV_command(): + with tempfile.NamedTemporaryFile( + suffix=".csv", mode="w", delete=False + ) as f: + f.write("url,id\nhttp://example.com/frame.jpg,1\n") + + try: + # We don't actually need the image since check_image=False + frame_data = FrameDataCSV(f.name, check_image=False) + + cmd = frame_data.command + assert cmd == "AddFrame", f"Expected AddFrame, got {cmd}" + + indices = frame_data.get_indices() + assert "entity" in indices + frame_type = ObjectType.FRAME.value + assert frame_type in indices["entity"] + assert indices["entity"][frame_type] == frame_data.get_indexed_properties() + finally: + os.remove(f.name) diff --git a/test/test_Frames.py b/test/test_Frames.py new file mode 100644 index 00000000..1390b3ac --- /dev/null +++ b/test/test_Frames.py @@ -0,0 +1,26 @@ +from aperturedb.Frames import Frames +from aperturedb.Query import ObjectType + + +class MockClient: + def __init__(self): + pass + + +def test_Frames_init(): + client = MockClient() + frames = Frames(client) + assert frames.client == client + assert frames.db_object == ObjectType.FRAME + + +def test_Frames_backward_compatibility_import(): + # Verify that importing Frames from aperturedb.Images resolves correctly + import aperturedb.Images + from aperturedb.Images import Frames as ImagesFrames + + assert ImagesFrames is Frames + + # Verify it is cached in the module's globals + assert "Frames" in aperturedb.Images.__dict__ + assert aperturedb.Images.__dict__["Frames"] is Frames diff --git a/test/test_cli_ingest.py b/test/test_cli_ingest.py new file mode 100644 index 00000000..eeaf7769 --- /dev/null +++ b/test/test_cli_ingest.py @@ -0,0 +1,20 @@ +from unittest.mock import patch, MagicMock +from aperturedb.cli.ingest import from_csv, IngestType + + +def test_from_csv_frame_type(): + import aperturedb.FrameDataCSV + + with patch("aperturedb.cli.ingest._process_data") as mock_process_data: + mock_process_data.return_value = None + + with patch.object(aperturedb.FrameDataCSV, "FrameDataCSV") as mock_csv_class: + mock_data = MagicMock() + mock_csv_class.return_value = mock_data + + from_csv(filepath="dummy.csv", + ingest_type=IngestType.FRAME, sample_count=5) + + mock_csv_class.assert_called_once_with( + "dummy.csv", use_dask=False, blobs_relative_to_csv=True) + mock_process_data.assert_called_once()