diff --git a/aperturedb/CommonLibrary.py b/aperturedb/CommonLibrary.py index ab91794a..7631c6ac 100644 --- a/aperturedb/CommonLibrary.py +++ b/aperturedb/CommonLibrary.py @@ -16,6 +16,7 @@ from aperturedb.ConnectorRest import ConnectorRest from aperturedb.types import Blobs, CommandResponses, Commands from aperturedb.LoggingUtils import censor_tokens +from aperturedb.Constants import BLOB_FIND_COMMANDS logger = logging.getLogger(__name__) @@ -383,9 +384,7 @@ def map_response_to_handler(handler, query, query_blobs, response, response_blo if is_list: for req, resp in zip(query[start:end], response[start:end]): for k in req: - blob_returning_commands = ["FindImage", "FindBlob", "FindVideo", - "FindDescriptor", "FindBoundingBox"] - if k in blob_returning_commands and "blobs" in req[k] and req[k]["blobs"]: + if k in BLOB_FIND_COMMANDS and "blobs" in req[k] and req[k]["blobs"]: count = resp[k]["returned"] b_count += count diff --git a/aperturedb/Constants.py b/aperturedb/Constants.py new file mode 100644 index 00000000..7e5003c9 --- /dev/null +++ b/aperturedb/Constants.py @@ -0,0 +1,33 @@ +""" +Shared constants for the ApertureDB Python SDK. +""" + +BLOB_ADD_COMMANDS = frozenset({ + "AddImage", + "AddDescriptor", + "AddVideo", + "AddBlob", + "AddFrame" +}) + +BLOB_FIND_COMMANDS = frozenset({ + "FindImage", + "FindDescriptor", + "FindVideo", + "FindBlob", + "FindFrame", + "FindBoundingBox" +}) + +OPENCV_DECODE_FIND_COMMANDS = frozenset({ + "FindImage", + "FindFrame" +}) + +PROPERTY_ADD_COMMANDS = frozenset({ + "AddImage", + "AddVideo", + "AddBoundingBox", + "AddPolygon", + "AddFrame" +}) diff --git a/aperturedb/DataModels.py b/aperturedb/DataModels.py index d5fcc51b..969bd483 100644 --- a/aperturedb/DataModels.py +++ b/aperturedb/DataModels.py @@ -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..2f055a93 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,13 @@ 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.** - - Frames in ApertureDB are quite similar to images and so - are modeled in python as a subclass. - +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}") - 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(set(list(globals().keys()) + ["Frames"])) diff --git a/aperturedb/MLCroissant.py b/aperturedb/MLCroissant.py index 71e8862c..41db4537 100644 --- a/aperturedb/MLCroissant.py +++ b/aperturedb/MLCroissant.py @@ -14,7 +14,14 @@ from aperturedb.Query import QueryBuilder from aperturedb.DataModels import IdentityDataModel from aperturedb.Query import generate_add_query +from aperturedb.Constants import BLOB_ADD_COMMANDS +# Commands for which we do not want to automatically create entity indices. +# AddDescriptor is intentionally excluded from this skip list because descriptor +# indices are explicitly required for similarity search and must be created. +_CROISSANT_SKIP_INDEX_COMMANDS = frozenset( + cmd for cmd in BLOB_ADD_COMMANDS if cmd != "AddDescriptor" +) logger = logging.getLogger(__name__) @@ -273,7 +280,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 _CROISSANT_SKIP_INDEX_COMMANDS: 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..ffa57383 100644 --- a/aperturedb/PyTorchDataset.py +++ b/aperturedb/PyTorchDataset.py @@ -1,6 +1,7 @@ import math import numpy as np import cv2 +from aperturedb.Constants import BLOB_FIND_COMMANDS, OPENCV_DECODE_FIND_COMMANDS import logging from torch.utils import data @@ -8,7 +9,6 @@ from aperturedb.CommonLibrary import execute_query from aperturedb.Connector import Connector - logger = logging.getLogger(__name__) @@ -17,7 +17,7 @@ class ApertureDBDataset(data.Dataset): This class implements a PyTorch Dataset for ApertureDB. It is used to load blobs returned by a `Find*` command from ApertureDB into a PyTorch model. It can be initialized with a query that will be used to retrieve - the blobs from ApertureDB. Note that only `FindImage` blobs are decoded via OpenCV. + the blobs from ApertureDB. Note that only `FindImage` and `FindFrame` blobs are decoded via OpenCV. """ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, command_idx=None): @@ -35,24 +35,19 @@ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, comm self.batch_end = 0 self.label_prop = label_prop - allowed_find_commands = { - "FindImage", "FindVideo", "FindBlob", - "FindDescriptor", "FindBoundingBox" - } - if self.command_idx is not None: if not (0 <= self.command_idx < len(query)): raise ValueError( f"command_idx {self.command_idx} is out of range.") self.command_name = list(query[self.command_idx].keys())[0] - if self.command_name not in allowed_find_commands: + if self.command_name not in BLOB_FIND_COMMANDS: raise ValueError( f"Command at index {self.command_idx} is " f"{self.command_name}, which is not a supported blob-returning Find* command.") else: for i in range(len(query)): name = list(query[i].keys())[0] - if name in allowed_find_commands: + if name in BLOB_FIND_COMMANDS: if self.command_idx is not None: logger.warning( "Multiple Find commands found. Selected %s at index %s.", self.command_name, self.command_idx) @@ -61,7 +56,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) @@ -77,7 +72,7 @@ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, comm for i in range(len(self.query)): name = list(self.query[i].keys())[0] - if name in allowed_find_commands and i != self.command_idx: + if name in BLOB_FIND_COMMANDS and i != self.command_idx: self.query[i][name]["blobs"] = False self.query[self.command_idx][self.command_name]["batch"] = {} @@ -111,7 +106,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 OPENCV_DECODE_FIND_COMMANDS: 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..b7688a20 100644 --- a/aperturedb/TensorFlowDataset.py +++ b/aperturedb/TensorFlowDataset.py @@ -5,6 +5,7 @@ from aperturedb.CommonLibrary import execute_query from aperturedb.Connector import Connector +from aperturedb.Constants import BLOB_FIND_COMMANDS, OPENCV_DECODE_FIND_COMMANDS logger = logging.getLogger(__name__) @@ -14,7 +15,7 @@ class ApertureDBTensorFlowDataset: This class implements a TensorFlow Dataset for ApertureDB. It is used to load blobs returned by a `Find*` command from ApertureDB into a TensorFlow model. It can be initialized with a query that will be used to retrieve - the blobs from ApertureDB. Note that only `FindImage` blobs are decoded via OpenCV. + the blobs from ApertureDB. Note that only `FindImage` and `FindFrame` blobs are decoded via OpenCV. """ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, command_idx=None): @@ -33,10 +34,7 @@ def __init__(self, client: Connector, query, label_prop=None, batch_size=1, comm self.label_prop = label_prop self.label_type = None - allowed_find_commands = { - "FindImage", "FindVideo", "FindBlob", - "FindDescriptor", "FindBoundingBox" - } + allowed_find_commands = BLOB_FIND_COMMANDS if self.command_idx is not None: if not (0 <= self.command_idx < len(query)): @@ -59,7 +57,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 +175,7 @@ def generator(self): blob = self.batch_blobs[idx] label = self.batch_labels[idx] - if self.command_name == "FindImage": + if self.command_name in OPENCV_DECODE_FIND_COMMANDS: nparr = np.frombuffer(blob, dtype=np.uint8) blob = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if blob is None: @@ -226,7 +224,7 @@ def get_dataset(self): else: self.label_type = tf.string - if self.command_name == "FindImage": + if self.command_name in OPENCV_DECODE_FIND_COMMANDS: 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..bfc45055 100644 --- a/aperturedb/transformers/clip_pytorch_embeddings.py +++ b/aperturedb/transformers/clip_pytorch_embeddings.py @@ -1,3 +1,4 @@ +from aperturedb.Constants import BLOB_ADD_COMMANDS import hashlib import logging from aperturedb.Subscriptable import Subscriptable @@ -95,7 +96,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 BLOB_ADD_COMMANDS: blob_index += 1 x[0].extend(new_descriptors) diff --git a/aperturedb/transformers/common_properties.py b/aperturedb/transformers/common_properties.py index 682b1795..a148ff9d 100644 --- a/aperturedb/transformers/common_properties.py +++ b/aperturedb/transformers/common_properties.py @@ -1,5 +1,6 @@ from aperturedb.Subscriptable import Subscriptable from aperturedb.transformers.transformer import Transformer +from aperturedb.Constants import PROPERTY_ADD_COMMANDS import logging @@ -49,7 +50,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 PROPERTY_ADD_COMMANDS: 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..85b1e53e 100644 --- a/aperturedb/transformers/facenet_pytorch_embeddings.py +++ b/aperturedb/transformers/facenet_pytorch_embeddings.py @@ -1,3 +1,4 @@ +from aperturedb.Constants import BLOB_ADD_COMMANDS import hashlib import logging from aperturedb.Subscriptable import Subscriptable @@ -100,7 +101,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 BLOB_ADD_COMMANDS: blob_index += 1 x[0].extend(new_descriptors) diff --git a/aperturedb/transformers/image_properties.py b/aperturedb/transformers/image_properties.py index bea3e181..c800bc39 100644 --- a/aperturedb/transformers/image_properties.py +++ b/aperturedb/transformers/image_properties.py @@ -1,3 +1,4 @@ +from aperturedb.Constants import BLOB_ADD_COMMANDS from aperturedb.transformers.transformer import Transformer from aperturedb.Subscriptable import Subscriptable @@ -34,7 +35,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 BLOB_ADD_COMMANDS: 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 +67,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 BLOB_ADD_COMMANDS: blob_index += 1 return x diff --git a/aperturedb/transformers/transformer.py b/aperturedb/transformers/transformer.py index 47a29af4..c08950ab 100644 --- a/aperturedb/transformers/transformer.py +++ b/aperturedb/transformers/transformer.py @@ -1,3 +1,4 @@ +from aperturedb.Constants import BLOB_ADD_COMMANDS from aperturedb.Subscriptable import Subscriptable from aperturedb.CommonLibrary import create_connector from aperturedb.Utils import Utils @@ -67,7 +68,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 BLOB_ADD_COMMANDS: 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..a8c72d95 100644 --- a/aperturedb/transformers/video_properties.py +++ b/aperturedb/transformers/video_properties.py @@ -1,3 +1,4 @@ +from aperturedb.Constants import BLOB_ADD_COMMANDS from aperturedb.transformers.transformer import Transformer from aperturedb.Subscriptable import Subscriptable @@ -32,7 +33,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 BLOB_ADD_COMMANDS: 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 +60,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 BLOB_ADD_COMMANDS: blob_index += 1 return x diff --git a/test/run_test_container.sh b/test/run_test_container.sh index 2f031ee5..087d0eac 100755 --- a/test/run_test_container.sh +++ b/test/run_test_container.sh @@ -7,9 +7,13 @@ cd "${SCRIPT_DIR}" function check_containers_networks(){ echo "Running containers and networks cleanup" - docker ps + if ! command -v docker >/dev/null 2>&1; then + echo "Warning: docker command not found. Skipping docker cleanup steps." + return 0 + fi + docker ps || true echo "Existing networks" - docker network ls + docker network ls || true } function get_sudo() { @@ -66,7 +70,9 @@ function teardown() { docker network rm "${RUNNER_NAME}_non_http_default" || true fi echo "Cleaning up generated volumes..." - $(get_sudo) rm -rf "${SCRIPT_DIR}/aperturedb" + if [ -d "${SCRIPT_DIR}/aperturedb" ]; then + $(get_sudo) rm -rf "${SCRIPT_DIR}/aperturedb" || echo "Warning: Failed to delete ${SCRIPT_DIR}/aperturedb" + fi } trap teardown EXIT 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..d018d2c9 --- /dev/null +++ b/test/test_Frames.py @@ -0,0 +1,22 @@ +from aperturedb.Frames import Frames +from aperturedb.Query import ObjectType +from unittest.mock import MagicMock + + +def test_Frames_init(): + client = MagicMock() + 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_Images.py b/test/test_Images.py index 6f275ee8..16ce0c75 100644 --- a/test/test_Images.py +++ b/test/test_Images.py @@ -133,7 +133,8 @@ def test_Images_get_np_image_by_index(): # Create a small valid jpeg or png mock blob import cv2 fake_np = np.zeros((10, 10, 3), dtype=np.uint8) - _, fake_blob = cv2.imencode('.jpg', fake_np) + is_success, fake_blob = cv2.imencode('.jpg', fake_np) + assert is_success, "Failed to encode image" mock_execute.return_value = (0, [], [fake_blob.tobytes()]) client.last_query_ok = lambda: True diff --git a/test/test_cli_ingest.py b/test/test_cli_ingest.py new file mode 100644 index 00000000..e833f8a1 --- /dev/null +++ b/test/test_cli_ingest.py @@ -0,0 +1,28 @@ +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_with( + mock_data, + sample_count=5, + module_name="dummy.csv", + batchsize=1, + num_workers=1, + stats=True, + debug=False + ) diff --git a/test/test_tf_connector.py b/test/test_tf_connector.py index ecbe803d..cf162946 100644 --- a/test/test_tf_connector.py +++ b/test/test_tf_connector.py @@ -117,7 +117,8 @@ def side_effect_int(*args, **kwargs): entities = [{"prop": 42}] # int r = [{"FindImage": {"batch": batch_dict, "entities": entities}}] img = np.zeros((10, 10, 3), dtype=np.uint8) - _, b_img = cv2.imencode('.jpg', img) + is_success, b_img = cv2.imencode('.jpg', img) + assert is_success, "Failed to encode image" b = [b_img.tobytes()] return None, r, b @@ -132,7 +133,8 @@ def side_effect_float(*args, **kwargs): entities = [{"prop": 3.14}] # float r = [{"FindImage": {"batch": batch_dict, "entities": entities}}] img = np.zeros((10, 10, 3), dtype=np.uint8) - _, b_img = cv2.imencode('.jpg', img) + is_success, b_img = cv2.imencode('.jpg', img) + assert is_success, "Failed to encode image" b = [b_img.tobytes()] return None, r, b @@ -177,14 +179,14 @@ def get_last_response_str(self): query = [{"FindVideo": {"results": {"list": ["prop"]}}}] with patch('aperturedb.TensorFlowDataset.execute_query') as mock_exec: - def side_effect(*args, **kwargs): + def execute_query_side_effect(*args, **kwargs): batch_dict = {"total_elements": 1} entities = [{"prop": 1}] r = [{"FindVideo": {"batch": batch_dict, "entities": entities}}] b = [b"mock_video_bytes"] return None, r, b - mock_exec.side_effect = side_effect + mock_exec.side_effect = execute_query_side_effect dataset_wrapper = ApertureDBTensorFlowDataset( DummyClient(), query, label_prop="prop") dataset = dataset_wrapper.get_dataset() @@ -199,3 +201,48 @@ def side_effect(*args, **kwargs): assert label.numpy() == 1 count += 1 assert count == 1 + + def test_find_frame_mocked(self): + from unittest.mock import patch + import tensorflow as tf + import numpy as np + import cv2 + + class DummyClient: + def clone(self): + return self + + def get_last_response_str(self): + return "" + + query = [{"FindFrame": {"results": {"list": ["prop"]}}}] + + with patch('aperturedb.TensorFlowDataset.execute_query') as mock_exec: + def execute_query_side_effect(*args, **kwargs): + batch_dict = {"total_elements": 1} + entities = [{"prop": 1}] + r = [{"FindFrame": {"batch": batch_dict, "entities": entities}}] + img = np.zeros((10, 10, 3), dtype=np.uint8) + img[0, 0] = [255, 0, 0] # BGR format for OpenCV + is_success, b_img = cv2.imencode('.png', img) + assert is_success, "Failed to encode image" + b = [b_img.tobytes()] + return None, r, b + + mock_exec.side_effect = execute_query_side_effect + dataset_wrapper = ApertureDBTensorFlowDataset( + DummyClient(), query, label_prop="prop") + dataset = dataset_wrapper.get_dataset() + + assert dataset.element_spec[1].dtype == tf.int32 + # Since FindFrame behaves like FindImage, it should decode to a tensor (not string) + assert dataset.element_spec[0].dtype == tf.uint8 + + count = 0 + for data, label in dataset: + assert tuple(data.shape) == (10, 10, 3) + assert np.array_equal(data[0, 0].numpy(), [ + 0, 0, 255]), "Expected RGB color conversion" + assert label.numpy() == 1 + count += 1 + assert count == 1 diff --git a/test/test_torch_connector.py b/test/test_torch_connector.py index f33d64a3..e70c143c 100644 --- a/test/test_torch_connector.py +++ b/test/test_torch_connector.py @@ -157,14 +157,14 @@ def get_last_response_str(self): query = [{"FindVideo": {"results": {"list": ["prop"]}}}] with patch('aperturedb.PyTorchDataset.execute_query') as mock_exec: - def side_effect(*args, **kwargs): + def execute_query_side_effect(*args, **kwargs): batch_dict = {"total_elements": 1} entities = [{"prop": 1}] r = [{"FindVideo": {"batch": batch_dict, "entities": entities}}] b = [b"mock_video_bytes"] return None, r, b - mock_exec.side_effect = side_effect + mock_exec.side_effect = execute_query_side_effect dataset = PyTorchDataset.ApertureDBDataset( DummyClient(), query, label_prop="prop") @@ -174,3 +174,43 @@ def side_effect(*args, **kwargs): assert blob == b"mock_video_bytes" assert label == 1 break + + def test_find_frame_mocked(self): + from unittest.mock import patch + import numpy as np + import cv2 + + class DummyClient: + def clone(self): + return self + + def get_last_response_str(self): + return "" + + query = [{"FindFrame": {"results": {"list": ["prop"]}}}] + + with patch('aperturedb.PyTorchDataset.execute_query') as mock_exec: + def execute_query_side_effect(*args, **kwargs): + batch_dict = {"total_elements": 1} + entities = [{"prop": 1}] + r = [{"FindFrame": {"batch": batch_dict, "entities": entities}}] + img = np.zeros((10, 10, 3), dtype=np.uint8) + img[0, 0] = [255, 0, 0] # BGR format for OpenCV + is_success, buffer = cv2.imencode(".png", img) + assert is_success, "Failed to encode image" + b = [buffer.tobytes()] + return None, r, b + + mock_exec.side_effect = execute_query_side_effect + dataset = PyTorchDataset.ApertureDBDataset( + DummyClient(), query, label_prop="prop") + + assert len(dataset) == 1 + for blob, label in dataset: + assert isinstance(blob, np.ndarray) + assert blob.shape == (10, 10, 3) + assert np.array_equal( + blob[0, 0], [0, 0, 255]), "Expected RGB color conversion" + assert isinstance(label, int) + assert label == 1 + break