From c30110ec696a92b20918a741bf45ad1068b33fad Mon Sep 17 00:00:00 2001 From: ad-claw000 Date: Mon, 18 May 2026 16:57:05 -0700 Subject: [PATCH 01/13] fix: update pre-commit CI to use python 3.12 (#676) --- .github/workflows/checks.yml | 2 +- .github/workflows/pr.yaml | 2 +- aperturedb/CSVWriter.py | 24 +++++++++---- aperturedb/CommonLibrary.py | 16 ++++++--- aperturedb/ParallelQuery.py | 3 +- aperturedb/ParallelQuerySet.py | 6 ++-- aperturedb/Query.py | 6 ++-- aperturedb/Utils.py | 34 ++++++++++++++++--- aperturedb/cli/configure.py | 3 +- examples/CelebADataKaggle.py | 8 ++++- .../loading_with_models/get_tl_embeddings.py | 2 +- test/conftest.py | 5 ++- test/docker-compose.yml | 6 ++-- test/run_test_container.sh | 7 +++- test/test_Datawizard.py | 2 +- test/test_Server.py | 8 +++-- test/test_Stats.py | 6 ++-- 17 files changed, 106 insertions(+), 34 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fcaf731b..70fe31c1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/setup-python@v3 with: - python-version: '3.10' + python-version: '3.12' - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 284be9dc..6a0cc1e5 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -18,7 +18,7 @@ jobs: steps: - name: Cleanup previous run - run: docker run --rm -v ${{ github.workspace }}:/workspace alpine sh -c "rm -rf /workspace/test/aperturedb/db*" + run: docker run --rm -v ${{ github.workspace }}:/workspace alpine sh -c "rm -rf /workspace/test/aperturedb/db* /workspace/test/aperturedb/logs*" continue-on-error: true - uses: actions/checkout@v3 diff --git a/aperturedb/CSVWriter.py b/aperturedb/CSVWriter.py index dd5721d4..38b6ed48 100644 --- a/aperturedb/CSVWriter.py +++ b/aperturedb/CSVWriter.py @@ -30,7 +30,9 @@ def convert_entity_data(input, entity_class: str, unique_key: Optional[str] = No df = pd.DataFrame(input) df.insert(0, 'EntityClass', entity_class) if unique_key: - assert unique_key in df.columns, f"unique_key {unique_key} not found in the input data" + assert unique_key in df.columns, ( + f"unique_key {unique_key} not found in the input data" + ) df[f"constraint_{unique_key}"] = df[unique_key] return df @@ -66,7 +68,9 @@ def convert_image_data(input, source_column: str, source_type: Optional[str] = N """ df = pd.DataFrame(input) - assert source_column in df.columns, f"source_column {source_column} not found in the input data" + assert source_column in df.columns, ( + f"source_column {source_column} not found in the input data" + ) if source_type is None: source_type = source_column @@ -82,7 +86,9 @@ def convert_image_data(input, source_column: str, source_type: Optional[str] = N df.insert(0, source_type, df[source_column]) if unique_key is not None: - assert unique_key in df.columns, f"unique_key {unique_key} not found in the input data" + assert unique_key in df.columns, ( + f"unique_key {unique_key} not found in the input data" + ) df[f"constraint_{unique_key}"] = df[unique_key] if format is not None: @@ -140,11 +146,15 @@ def convert_connection_data(input, if source_column is None: source_column = source_property - assert source_column in df.columns, f"source_column {source_column} not found in the input data" + assert source_column in df.columns, ( + f"source_column {source_column} not found in the input data" + ) if destination_column is None: destination_column = destination_property - assert destination_column in df.columns, f"destination_column {destination_column} not found in the input data" + assert destination_column in df.columns, ( + f"destination_column {destination_column} not found in the input data" + ) df.insert(0, 'ConnectionClass', connection_class) df.insert(1, f"{source_class}@{source_property}", df[source_column]) @@ -152,7 +162,9 @@ def convert_connection_data(input, df[destination_column]) if unique_key: - assert unique_key in df.columns, f"unique_key {unique_key} not found in the input data" + assert unique_key in df.columns, ( + f"unique_key {unique_key} not found in the input data" + ) df[f"constraint_{unique_key}"] = df[unique_key] return df diff --git a/aperturedb/CommonLibrary.py b/aperturedb/CommonLibrary.py index 2ffd665a..49b562a0 100644 --- a/aperturedb/CommonLibrary.py +++ b/aperturedb/CommonLibrary.py @@ -83,9 +83,15 @@ def _create_configuration_from_json(config: Union[Dict, str], clean_config = {k: v for k, v in config.items() if k != "password"} # These fields are required. - assert "host" in config, f"host is required in the configuration: {clean_config}" - assert "username" in config, f"username is required in the configuration: {clean_config}" - assert "password" in config, f"password is required in the configuration: {clean_config}" + assert "host" in config, ( + f"host is required in the configuration: {clean_config}" + ) + assert "username" in config, ( + f"username is required in the configuration: {clean_config}" + ) + assert "password" in config, ( + f"password is required in the configuration: {clean_config}" + ) # These fields have no default in the Configuration class. if 'port' not in config: @@ -95,7 +101,9 @@ def _create_configuration_from_json(config: Union[Dict, str], config["name"] = name # will overwrite the name in the config if name_required: - assert "name" in config, f"name is required in the configuration: {clean_config}" + assert "name" in config, ( + f"name is required in the configuration: {clean_config}" + ) elif 'name' not in config: config["name"] = "from_json" diff --git a/aperturedb/ParallelQuery.py b/aperturedb/ParallelQuery.py index 82cdfe68..31fb840e 100644 --- a/aperturedb/ParallelQuery.py +++ b/aperturedb/ParallelQuery.py @@ -308,7 +308,8 @@ def query(self, generator, batchsize: int = 1, numthreads: int = 4, stats: bool f"Could not determine query structure from:\n{generator[0]}") logger.error(type(generator[0])) logger.info( - f"Commands per query = {self.commands_per_query}, Blobs per query = {self.blobs_per_query}" + f"Commands per query = {self.commands_per_query}, " + f"Blobs per query = {self.blobs_per_query}" ) self.batched_run(generator, batchsize, numthreads, stats) diff --git a/aperturedb/ParallelQuerySet.py b/aperturedb/ParallelQuerySet.py index 6f12e2c8..de1fb81b 100644 --- a/aperturedb/ParallelQuerySet.py +++ b/aperturedb/ParallelQuerySet.py @@ -152,11 +152,13 @@ def first_only_blobs(all_blobs, strike_list, set_nm): blobs_this_set = len(blob_filter(blob_set, [], i)) expected_blobs = blobs_per_query[i] * batch_size logger.info( - f"Set {i}: Commands per query = {commands_per_query[i]}, Blobs per query = {blobs_per_query[i]}" + f"Set {i}: Commands per query = {commands_per_query[i]}, " + f"Blobs per query = {blobs_per_query[i]}" ) if blobs_this_set != expected_blobs: logger.error( - f"Set {i}: Expected {expected_blobs} blobs, but filter is returning {blobs_this_set}" + f"Set {i}: Expected {expected_blobs} blobs, " + f"but filter is returning {blobs_this_set}" ) # now we determine if the executing set has a constraint diff --git a/aperturedb/Query.py b/aperturedb/Query.py index b067760e..60fe02de 100644 --- a/aperturedb/Query.py +++ b/aperturedb/Query.py @@ -87,8 +87,10 @@ def get_specific(obj: BaseModel) -> dict: start, stop = obj.start, obj.stop if obj.range_type == RangeType.TIME: start, stop = int(start), int(stop) - start = f"{start//3600:0>2}:{start//60:0>2}:{start%60:0>2}" - stop = f"{stop//3600:0>2}:{stop//60:0>2}:{stop%60:0>2}" + start = "{:0>2}:{:0>2}:{:0>2}".format( + start // 3600, (start // 60) % 60, start % 60) + stop = "{:0>2}:{:0>2}:{:0>2}".format( + stop // 3600, (stop // 60) % 60, stop % 60) elif obj.range_type == RangeType.FRAME: start = int(obj.start) stop = int(obj.stop) diff --git a/aperturedb/Utils.py b/aperturedb/Utils.py index 8be817d0..c062e991 100644 --- a/aperturedb/Utils.py +++ b/aperturedb/Utils.py @@ -182,7 +182,17 @@ def visualize_schema(self, filename: str = None, format: str = "png") -> Source: {entity} ({matched:,}) ''' for prop, (matched, indexed, typ) in properties.items(): - table += f'{prop.strip()} {matched:,} {"Indexed" if indexed else "Unindexed"}, {typ}' + bg = colors["property_background"] + fg = colors["property_foreground"] + idx_str = "Indexed" if indexed else "Unindexed" + table += ( + f'' + f'{prop.strip()} ' + f'' + f'{matched:,} ' + f'' + f'{idx_str}, {typ}' + ) for connection, data in connections.items(): data_list = [data] if isinstance(data, dict) else data for data in data_list: @@ -190,10 +200,26 @@ def visualize_schema(self, filename: str = None, format: str = "png") -> Source: matched = data["matched"] # dictionary from name to (matched, indexed, type) properties = data["properties"] - table += f'{connection} ({matched:,})' + c_bg = colors["connection_background"] + c_fg = colors["connection_foreground"] + table += ( + '' + '{} ({:,})' + ).format(c_bg, connection, c_fg, connection, matched) if properties: for prop, (matched, indexed, typ) in properties.items(): - table += f'{prop.strip()} {matched:,} {"Indexed" if indexed else "Unindexed"}, {typ}' + cp_bg = colors["connection_property_background"] + cp_fg = colors["connection_property_foreground"] + idx_str = "Indexed" if indexed else "Unindexed" + table += ( + '' + '{} ' + '' + '{} ' + '' + '{}, {}' + ).format(cp_bg, cp_fg, prop.strip(), cp_bg, cp_fg, f"{matched:,}", cp_bg, cp_fg, idx_str, typ) table += '>' dot.node(entity, label=table) @@ -243,7 +269,7 @@ def _object_summary(self, name, object): w = "!" if "id" in k and not p[k][1] else w print(f"{i} {w} {p[k][2].ljust(8)} |" f" {k.ljust(max)} | {str(p[k][0]).rjust(9)} " - f"({int(p[k][0]/total_elements*100.0)}%)") + f"({int(p[k][0] / total_elements * 100.0)}%)") return total_elements diff --git a/aperturedb/cli/configure.py b/aperturedb/cli/configure.py index c0ae58e4..5afd2ba6 100644 --- a/aperturedb/cli/configure.py +++ b/aperturedb/cli/configure.py @@ -193,7 +193,8 @@ def create( def check_for_overwrite(name): if name in configs and not overwrite: console.log( - f"Configuration named '{name}' already exists. Use --overwrite to overwrite.", + "Configuration named '{}' already exists. Use --overwrite to overwrite.".format( + name), style="bold yellow") raise typer.Exit(code=2) diff --git a/examples/CelebADataKaggle.py b/examples/CelebADataKaggle.py index a995e669..73195dd9 100644 --- a/examples/CelebADataKaggle.py +++ b/examples/CelebADataKaggle.py @@ -62,7 +62,13 @@ def generate_query(self, idx: int) -> Tuple[List[dict], List[bytes]]: } } ] - q[0]["AddImage"]["properties"]["keypoints"] = f"10 {p['lefteye_x']} {p['lefteye_y']} {p['righteye_x']} {p['righteye_y']} {p['nose_x']} {p['nose_y']} {p['leftmouth_x']} {p['leftmouth_y']} {p['rightmouth_x']} {p['rightmouth_y']}" + q[0]["AddImage"]["properties"]["keypoints"] = ( + f"10 {p['lefteye_x']} {p['lefteye_y']} " + f"{p['righteye_x']} {p['righteye_y']} " + f"{p['nose_x']} {p['nose_y']} " + f"{p['leftmouth_x']} {p['leftmouth_y']} " + f"{p['rightmouth_x']} {p['rightmouth_y']}" + ) image_file_name = os.path.join( self.workdir, diff --git a/examples/loading_with_models/get_tl_embeddings.py b/examples/loading_with_models/get_tl_embeddings.py index 5ae5bd02..5b76149c 100644 --- a/examples/loading_with_models/get_tl_embeddings.py +++ b/examples/loading_with_models/get_tl_embeddings.py @@ -59,7 +59,7 @@ def generate_text_embeddings(text: str): print(f"Generated {len(embeddings)} embeddings for the video") for i, emb in enumerate(embeddings): - print(f"Embedding {i+1}:") + print(f"Embedding {i + 1}:") print(f" Scope: {emb['embedding_scope']}") print( f" Time range: {emb['start_offset_sec']} - {emb['end_offset_sec']} seconds") diff --git a/test/conftest.py b/test/conftest.py index f5144220..1a602d1a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -145,7 +145,10 @@ def insert_data_from_csv(in_csv_file, rec_count=-1, expected_error_count=0, load for tp, classes in expected_indices.items(): for cls, props in classes.items(): for prop in props: - err_msg = f"Index {prop} not found for {cls}, {expected_indices=}, {observed_indices=}" + err_msg = ( + f"Index {prop} not found for {cls}, " + f"{expected_indices=}, {observed_indices=}" + ) assert prop in observed_indices[tp][cls], err_msg assert loader.error_counter == 0 assert len(data) - \ diff --git a/test/docker-compose.yml b/test/docker-compose.yml index a1395ad1..2ae566a3 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -25,7 +25,7 @@ services: condition: service_started image: $LENZ_REPO:$LENZ_TAG ports: - - $GATEWAY:55556:55551 + - "$GATEWAY:0:55551" restart: always environment: LNZ_HEALTH_PORT: 58085 @@ -65,8 +65,8 @@ services: image: nginx restart: always ports: - - $GATEWAY:8087:80 - - $GATEWAY:8443:443 + - "$GATEWAY:0:80" + - "$GATEWAY:0:443" configs: - source: nginx.conf target: /etc/nginx/conf.d/default.conf diff --git a/test/run_test_container.sh b/test/run_test_container.sh index 99e23725..eb2536c4 100755 --- a/test/run_test_container.sh +++ b/test/run_test_container.sh @@ -25,7 +25,12 @@ function run_aperturedb_instance(){ docker network create ${TAG}_host_default GATEWAY=$(docker network inspect ${TAG}_host_default | jq -r .[0].IPAM.Config[0].Gateway) GATEWAY=$GATEWAY RUNNER_NAME=$TAG docker compose -f docker-compose.yml up -d - echo "$GATEWAY" + if [ "$TAG" == "${RUNNER_NAME}_http" ]; then + PORT=$(RUNNER_NAME=$TAG docker compose -f docker-compose.yml port nginx 80 | cut -d: -f2) + else + PORT=$(RUNNER_NAME=$TAG docker compose -f docker-compose.yml port lenz 55551 | cut -d: -f2) + fi + echo "$GATEWAY:$PORT" } IP_REGEX='[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}' diff --git a/test/test_Datawizard.py b/test/test_Datawizard.py index 932b60b9..f956e90c 100644 --- a/test/test_Datawizard.py +++ b/test/test_Datawizard.py @@ -123,7 +123,7 @@ def make_hand(side: Side) -> Hand: people = [] for i in range(10): - person = Person(name=f"adam{i+1}") + person = Person(name=f"adam{i + 1}") left_hand = make_hand(Side.LEFT) right_hand = make_hand(Side.RIGHT) person.hands.extend([left_hand, right_hand]) diff --git a/test/test_Server.py b/test/test_Server.py index 1ca18cda..38bcf3c7 100644 --- a/test/test_Server.py +++ b/test/test_Server.py @@ -62,8 +62,12 @@ def test_response_half_non_unique(a: Connector, query, blobs): "entity": {"_Image": {"id"}}}) input_data = pd.read_csv("./input/images.adb.csv") data, loader = insert_data_from_csv( - in_csv_file = "./input/images.adb.csv", expected_error_count = len(input_data)) - assert loader.error_counter == 0, f"Error counter: {loader.error_counter=}" + in_csv_file="./input/images.adb.csv", + expected_error_count=len(input_data) + ) + assert loader.error_counter == 0, ( + f"Error counter: {loader.error_counter=}" + ) assert loader.get_succeeded_queries( ) == 0, f"Queries: {loader.get_succeeded_queries()=}" assert loader.get_succeeded_commands( diff --git a/test/test_Stats.py b/test/test_Stats.py index 483c758a..e9785db8 100644 --- a/test/test_Stats.py +++ b/test/test_Stats.py @@ -31,8 +31,10 @@ def validate_stats(self, out, assertions): first, second = line.split(":") print(first, second) if first in assertions: - assert assertions[first.strip()](second.strip()) == True, \ - f"Assertion failed for '{first}' with value {second}" + assert assertions[first.strip()](second.strip()) is True, ( + f"Assertion failed for '{first}' " + f"with value {second}" + ) def test_stats_all_errors_non_equal_last_batch(self, db, utils): utils.remove_all_objects() From db3be0a475dd39ccf5c25e1078f954e648c0bf5c Mon Sep 17 00:00:00 2001 From: ad-claw000 Date: Mon, 18 May 2026 19:03:29 -0700 Subject: [PATCH 02/13] test: add initial suite of tests for Images.py (#674) --- test/test_Images.py | 116 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 test/test_Images.py diff --git a/test/test_Images.py b/test/test_Images.py new file mode 100644 index 00000000..43e54dac --- /dev/null +++ b/test/test_Images.py @@ -0,0 +1,116 @@ +import numpy as np +from aperturedb.Images import Images, resolve, rotate +from unittest.mock import patch + + +def test_rotate(): + points = np.array([(10, 10), (20, 20)]) + rotated = rotate(points, 90, c_x=10, c_y=10) + assert len(rotated) == 2 + assert rotated[0][0] == 10 and rotated[0][1] == 10 + assert rotated[1][0] == 0 and rotated[1][1] == 20 + + +def test_resolve_resize(): + points = np.array([[10, 10], [20, 20]], dtype=float) + meta = {"adb_image_width": 100, "adb_image_height": 100} + operations = [{"type": "resize", "width": 50, "height": 50}] + resolved = resolve(points, meta, operations) + assert resolved[0][0] == 5 + assert resolved[0][1] == 5 + assert resolved[1][0] == 10 + assert resolved[1][1] == 10 + + +def test_resolve_rotate(): + points = np.array([[10, 10]], dtype=float) + meta = {"adb_image_width": 100, "adb_image_height": 100} + operations = [{"type": "rotate", "angle": 90}] + resolved = resolve(points, meta, operations) + assert len(resolved) == 1 + # Note: 9 instead of 10 due to float truncation in .astype(int) + assert resolved[0][0] == 90 and resolved[0][1] == 9 + + +class MockClient: + def __init__(self): + self.responses = [] + self.queries = [] + + def query(self, q, blobs=None): + if blobs is None: + blobs = [] + self.queries.append(q) + return self.responses.pop(0) if self.responses else ([{}], []) + + def last_query_ok(self): + return True + + +def test_Images_init(): + client = MockClient() + img = Images(client) + assert img.client == client + assert img.db_object.value == "_Image" + + +def test_Images_search(): + client = MockClient() + with patch('aperturedb.Images.execute_query') as mock_execute: + mock_execute.return_value = ( + 0, [{"FindImage": {"entities": [{"_uniqueid": "123"}, {"_uniqueid": "456"}]}}], []) + img = Images(client) + img.search(limit=2) + assert "123" in img.images_ids + assert "456" in img.images_ids + mock_execute.assert_called_once() + query_passed = mock_execute.call_args[1][ + "query"] if "query" in mock_execute.call_args[1] else mock_execute.call_args[0][1] + assert "FindImage" in query_passed[0] + assert query_passed[0]["FindImage"]["results"]["limit"] == 2 + + +def test_Images_search_by_property(): + client = MockClient() + with patch('aperturedb.Images.execute_query') as mock_execute: + mock_execute.return_value = ( + 0, [{"FindImage": {"entities": [{"_uniqueid": "789"}]}}], []) + img = Images(client) + img.search_by_property("label", ["test_label"]) + assert "789" in img.images_ids + query_passed = mock_execute.call_args[1][ + "query"] if "query" in mock_execute.call_args[1] else mock_execute.call_args[0][1] + assert "constraints" in query_passed[0]["FindImage"] + + +def test_Images_get_image_by_index(): + client = MockClient() + img = Images(client) + img.images_ids = ["111"] + + with patch('aperturedb.Images.execute_query') as mock_execute: + mock_execute.return_value = (0, [], [b'fakeimageblob']) + # Override last_query_ok since MockClient does that + client.last_query_ok = lambda: True + + res = img.get_image_by_index(0) + assert res == b'fakeimageblob' + assert "111" in img.images + + +def test_Images_get_np_image_by_index(): + client = MockClient() + img = Images(client) + img.images_ids = ["111"] + + with patch('aperturedb.Images.execute_query') as mock_execute: + # 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) + + mock_execute.return_value = (0, [], [fake_blob.tobytes()]) + client.last_query_ok = lambda: True + + res = img.get_np_image_by_index(0) + assert res.shape == (10, 10, 3) From 0ced2ff56974d0832f267f0cef63b9b668f51e3d Mon Sep 17 00:00:00 2001 From: claw Date: Tue, 19 May 2026 14:21:45 +0000 Subject: [PATCH 03/13] fix: Update operations on DW Operations. Fixes aperture-data/aperturedb-python#160 --- aperturedb/Operations.py | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/aperturedb/Operations.py b/aperturedb/Operations.py index 88482dda..111b9dbe 100644 --- a/aperturedb/Operations.py +++ b/aperturedb/Operations.py @@ -15,13 +15,17 @@ def __init__(self): def get_operations_arr(self): return self.operations_arr - def resize(self, width: int, height: int) -> Operations: + def resize(self, width: int = None, height: int = None, scale: float = None) -> Operations: op = { - "type": "resize", - "width": width, - "height": height, + "type": "resize" } + if width is not None: + op["width"] = width + if height is not None: + op["height"] = height + if scale is not None: + op["scale"] = scale self.operations_arr.append(op) return self @@ -71,3 +75,30 @@ def interval(self, start: int, stop: int, step: int) -> Operations: self.operations_arr.append(op) return self + + def threshold(self, value: int) -> Operations: + + op = { + "type": "threshold", + "value": value, + } + + self.operations_arr.append(op) + return self + + def preview(self, max_frame_count: int = None, max_time_fraction: float = None, max_time_offset: str = None, max_size_mb: float = None) -> Operations: + + op = { + "type": "preview" + } + if max_frame_count is not None: + op["max_frame_count"] = max_frame_count + if max_time_fraction is not None: + op["max_time_fraction"] = max_time_fraction + if max_time_offset is not None: + op["max_time_offset"] = max_time_offset + if max_size_mb is not None: + op["max_size_mb"] = max_size_mb + + self.operations_arr.append(op) + return self From 5fc0bb2d9c05fec9adbbf221a4f1f4af085e8863 Mon Sep 17 00:00:00 2001 From: claw Date: Tue, 19 May 2026 21:00:59 +0000 Subject: [PATCH 04/13] test: add tests for DW Operations --- test/test_Operations.py | 56 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/test_Operations.py diff --git a/test/test_Operations.py b/test/test_Operations.py new file mode 100644 index 00000000..61cdf46f --- /dev/null +++ b/test/test_Operations.py @@ -0,0 +1,56 @@ +import pytest +from aperturedb.Operations import Operations + + +class TestOperations: + def test_resize_width_height(self): + op = Operations().resize(width=100, height=200) + assert op.get_operations_arr() == [ + {"type": "resize", "width": 100, "height": 200}] + + def test_resize_scale(self): + op = Operations().resize(scale=0.5) + assert op.get_operations_arr() == [{"type": "resize", "scale": 0.5}] + + def test_rotate(self): + op = Operations().rotate(angle=90) + assert op.get_operations_arr() == [ + {"type": "rotate", "angle": 90, "resize": False}] + + def test_rotate_resize(self): + op = Operations().rotate(angle=90, resize=True) + assert op.get_operations_arr() == [ + {"type": "rotate", "angle": 90, "resize": True}] + + def test_flip(self): + op = Operations().flip(code="horizontal") + assert op.get_operations_arr() == [ + {"type": "flip", "code": "horizontal"}] + + def test_crop(self): + op = Operations().crop(x=10, y=20, width=100, height=200) + assert op.get_operations_arr() == [ + {"type": "crop", "x": 10, "y": 20, "width": 100, "height": 200}] + + def test_interval(self): + op = Operations().interval(start=0, stop=100, step=2) + assert op.get_operations_arr() == [ + {"type": "interval", "start": 0, "stop": 100, "step": 2}] + + def test_threshold(self): + op = Operations().threshold(value=128) + assert op.get_operations_arr() == [{"type": "threshold", "value": 128}] + + def test_preview(self): + op = Operations().preview(max_frame_count=10, max_time_fraction=0.5) + assert op.get_operations_arr() == [ + {"type": "preview", "max_frame_count": 10, "max_time_fraction": 0.5}] + + def test_chained_operations(self): + op = Operations().resize(width=100).rotate( + angle=90).crop(x=0, y=0, width=50, height=50) + assert op.get_operations_arr() == [ + {"type": "resize", "width": 100}, + {"type": "rotate", "angle": 90, "resize": False}, + {"type": "crop", "x": 0, "y": 0, "width": 50, "height": 50} + ] From 23c49d2a451fe0bfbfae3fda9216e6da5631f5f9 Mon Sep 17 00:00:00 2001 From: claw Date: Wed, 20 May 2026 02:58:01 +0000 Subject: [PATCH 05/13] fix: address review comments on Operations PR --- aperturedb/Operations.py | 5 +++-- test/test_Operations.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aperturedb/Operations.py b/aperturedb/Operations.py index 111b9dbe..4a06d8ee 100644 --- a/aperturedb/Operations.py +++ b/aperturedb/Operations.py @@ -1,4 +1,5 @@ from __future__ import annotations +from typing import Optional class Operations(object): @@ -15,7 +16,7 @@ def __init__(self): def get_operations_arr(self): return self.operations_arr - def resize(self, width: int = None, height: int = None, scale: float = None) -> Operations: + def resize(self, width: Optional[int] = None, height: Optional[int] = None, scale: Optional[float] = None) -> Operations: op = { "type": "resize" @@ -86,7 +87,7 @@ def threshold(self, value: int) -> Operations: self.operations_arr.append(op) return self - def preview(self, max_frame_count: int = None, max_time_fraction: float = None, max_time_offset: str = None, max_size_mb: float = None) -> Operations: + def preview(self, max_frame_count: Optional[int] = None, max_time_fraction: Optional[float] = None, max_time_offset: Optional[str] = None, max_size_mb: Optional[float] = None) -> Operations: op = { "type": "preview" diff --git a/test/test_Operations.py b/test/test_Operations.py index 61cdf46f..ca50b0ca 100644 --- a/test/test_Operations.py +++ b/test/test_Operations.py @@ -1,4 +1,3 @@ -import pytest from aperturedb.Operations import Operations From fde49d70e1639579749ebb059d215ea3da4ff7ec Mon Sep 17 00:00:00 2001 From: claw Date: Wed, 20 May 2026 03:08:30 +0000 Subject: [PATCH 06/13] fix(ops): validate resize arguments and update resolve logic --- aperturedb/Images.py | 12 ++++++++---- aperturedb/Operations.py | 8 +++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/aperturedb/Images.py b/aperturedb/Images.py index 55d91d83..13945997 100644 --- a/aperturedb/Images.py +++ b/aperturedb/Images.py @@ -100,12 +100,16 @@ def resolve(points: np.array, image_meta, operations) -> np.array: image_meta_height = image_meta["adb_image_height"] for operation in operations: if operation["type"] == "resize": - x_ratio = operation["width"] / image_meta_width - y_ratio = operation["height"] / image_meta_height + if "scale" in operation: + x_ratio = operation["scale"] + y_ratio = operation["scale"] + else: + x_ratio = operation["width"] / image_meta_width + y_ratio = operation["height"] / image_meta_height np.multiply(resolved, [x_ratio, y_ratio], out=resolved, casting="unsafe") - image_meta_width = operation["width"] - image_meta_height = operation["height"] + image_meta_width *= x_ratio + image_meta_height *= y_ratio if operation["type"] == "rotate": angle = operation["angle"] resolved = rotate( diff --git a/aperturedb/Operations.py b/aperturedb/Operations.py index 4a06d8ee..a96b5ddd 100644 --- a/aperturedb/Operations.py +++ b/aperturedb/Operations.py @@ -18,12 +18,18 @@ def get_operations_arr(self): def resize(self, width: Optional[int] = None, height: Optional[int] = None, scale: Optional[float] = None) -> Operations: + if scale is not None: + if width is not None or height is not None: + raise ValueError("Provide either 'scale' or both 'width' and 'height', but not a mix.") + else: + if width is None or height is None: + raise ValueError("Provide either 'scale' or both 'width' and 'height'.") + op = { "type": "resize" } if width is not None: op["width"] = width - if height is not None: op["height"] = height if scale is not None: op["scale"] = scale From 7c6c77259702b8540ea4c2570f7ff621198ce3ce Mon Sep 17 00:00:00 2001 From: claw Date: Wed, 20 May 2026 03:34:22 +0000 Subject: [PATCH 07/13] chore: auto-format via pre-commit --- aperturedb/Operations.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/aperturedb/Operations.py b/aperturedb/Operations.py index a96b5ddd..e96ca592 100644 --- a/aperturedb/Operations.py +++ b/aperturedb/Operations.py @@ -20,10 +20,12 @@ def resize(self, width: Optional[int] = None, height: Optional[int] = None, scal if scale is not None: if width is not None or height is not None: - raise ValueError("Provide either 'scale' or both 'width' and 'height', but not a mix.") + raise ValueError( + "Provide either 'scale' or both 'width' and 'height', but not a mix.") else: if width is None or height is None: - raise ValueError("Provide either 'scale' or both 'width' and 'height'.") + raise ValueError( + "Provide either 'scale' or both 'width' and 'height'.") op = { "type": "resize" From 3d5be27ac0bcacc34e7c939b305ee3f587cc4ac3 Mon Sep 17 00:00:00 2001 From: claw Date: Wed, 20 May 2026 08:03:18 +0000 Subject: [PATCH 08/13] test: update chained ops and add negative tests for resize --- test/test_Operations.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/test_Operations.py b/test/test_Operations.py index ca50b0ca..5da92f95 100644 --- a/test/test_Operations.py +++ b/test/test_Operations.py @@ -1,3 +1,4 @@ +import pytest from aperturedb.Operations import Operations @@ -46,10 +47,18 @@ def test_preview(self): {"type": "preview", "max_frame_count": 10, "max_time_fraction": 0.5}] def test_chained_operations(self): - op = Operations().resize(width=100).rotate( + op = Operations().resize(width=100, height=100).rotate( angle=90).crop(x=0, y=0, width=50, height=50) assert op.get_operations_arr() == [ - {"type": "resize", "width": 100}, + {"type": "resize", "width": 100, "height": 100}, {"type": "rotate", "angle": 90, "resize": False}, {"type": "crop", "x": 0, "y": 0, "width": 50, "height": 50} ] + + def test_resize_invalid_args(self): + with pytest.raises(ValueError, match="Provide either 'scale' or both 'width' and 'height'"): + Operations().resize() + with pytest.raises(ValueError, match="Provide either 'scale' or both 'width' and 'height'"): + Operations().resize(width=100) + with pytest.raises(ValueError, match="Provide either 'scale' or both 'width' and 'height', but not a mix"): + Operations().resize(width=100, height=100, scale=0.5) From 1e60b94ddfad395cf2e0b6d947e68e291d67ed94 Mon Sep 17 00:00:00 2001 From: claw Date: Wed, 20 May 2026 12:54:30 +0000 Subject: [PATCH 09/13] test: add test for Images.resolve with resize scale --- test/test_Images.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/test_Images.py b/test/test_Images.py index 43e54dac..55051567 100644 --- a/test/test_Images.py +++ b/test/test_Images.py @@ -22,6 +22,17 @@ def test_resolve_resize(): assert resolved[1][1] == 10 +def test_resolve_resize_scale(): + points = np.array([[10, 10], [20, 20]], dtype=float) + meta = {"adb_image_width": 100, "adb_image_height": 100} + operations = [{"type": "resize", "scale": 0.5}] + resolved = resolve(points, meta, operations) + assert resolved[0][0] == 5 + assert resolved[0][1] == 5 + assert resolved[1][0] == 10 + assert resolved[1][1] == 10 + + def test_resolve_rotate(): points = np.array([[10, 10]], dtype=float) meta = {"adb_image_width": 100, "adb_image_height": 100} From 501e730d23fa009419d64a071a8c8dc99c6879ec Mon Sep 17 00:00:00 2001 From: ad-claw000 Date: Thu, 21 May 2026 06:26:00 +0000 Subject: [PATCH 10/13] test: add missing tests for operations based on review --- test/test_Images.py | 14 ++++++++++++++ test/test_Operations.py | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/test/test_Images.py b/test/test_Images.py index 55051567..b2a550b9 100644 --- a/test/test_Images.py +++ b/test/test_Images.py @@ -43,6 +43,20 @@ def test_resolve_rotate(): assert resolved[0][0] == 90 and resolved[0][1] == 9 +def test_resolve_ignored_operations(): + points = np.array([[10, 10], [20, 20]], dtype=float) + meta = {"adb_image_width": 100, "adb_image_height": 100} + operations = [ + {"type": "flip", "code": "horizontal"}, + {"type": "crop", "x": 10, "y": 10, "width": 50, "height": 50}, + {"type": "interval", "start": 0, "stop": 10, "step": 1}, + {"type": "threshold", "value": 128}, + {"type": "preview"} + ] + resolved = resolve(points, meta, operations) + assert np.array_equal(resolved, points) + + class MockClient: def __init__(self): self.responses = [] diff --git a/test/test_Operations.py b/test/test_Operations.py index 5da92f95..46ad8c32 100644 --- a/test/test_Operations.py +++ b/test/test_Operations.py @@ -46,6 +46,11 @@ def test_preview(self): assert op.get_operations_arr() == [ {"type": "preview", "max_frame_count": 10, "max_time_fraction": 0.5}] + def test_preview_all_args(self): + op = Operations().preview(max_frame_count=10, max_time_fraction=0.5, max_time_offset="00:00:10", max_size_mb=10.5) + assert op.get_operations_arr() == [ + {"type": "preview", "max_frame_count": 10, "max_time_fraction": 0.5, "max_time_offset": "00:00:10", "max_size_mb": 10.5}] + def test_chained_operations(self): op = Operations().resize(width=100, height=100).rotate( angle=90).crop(x=0, y=0, width=50, height=50) From 19b0999e4823bc623918746b72ffad3a0429f10c Mon Sep 17 00:00:00 2001 From: ad-claw000 Date: Thu, 21 May 2026 06:25:34 +0000 Subject: [PATCH 11/13] Address PR 688 review comments --- aperturedb/Images.py | 12 ++++++++---- test/run_test_container.sh | 7 +------ test/test_Images.py | 3 +-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/aperturedb/Images.py b/aperturedb/Images.py index 13945997..1c5cc3a0 100644 --- a/aperturedb/Images.py +++ b/aperturedb/Images.py @@ -69,7 +69,7 @@ def rotate(points, angle, c_x=0, c_y=0): points: The rotated points as a NumPy array of shape (n,2) and type int """ ANGLE = np.deg2rad(angle) - return np.array( + return np.round(np.array( [ [ c_x + np.cos(ANGLE) * (px - c_x) - np.sin(ANGLE) * (py - c_y), @@ -77,7 +77,7 @@ def rotate(points, angle, c_x=0, c_y=0): ] for px, py in points ] - ).astype(int) + )).astype(int) def resolve(points: np.array, image_meta, operations) -> np.array: @@ -108,8 +108,12 @@ def resolve(points: np.array, image_meta, operations) -> np.array: y_ratio = operation["height"] / image_meta_height np.multiply(resolved, [x_ratio, y_ratio], out=resolved, casting="unsafe") - image_meta_width *= x_ratio - image_meta_height *= y_ratio + if "scale" in operation: + image_meta_width *= x_ratio + image_meta_height *= y_ratio + else: + image_meta_width = operation["width"] + image_meta_height = operation["height"] if operation["type"] == "rotate": angle = operation["angle"] resolved = rotate( diff --git a/test/run_test_container.sh b/test/run_test_container.sh index eb2536c4..99e23725 100755 --- a/test/run_test_container.sh +++ b/test/run_test_container.sh @@ -25,12 +25,7 @@ function run_aperturedb_instance(){ docker network create ${TAG}_host_default GATEWAY=$(docker network inspect ${TAG}_host_default | jq -r .[0].IPAM.Config[0].Gateway) GATEWAY=$GATEWAY RUNNER_NAME=$TAG docker compose -f docker-compose.yml up -d - if [ "$TAG" == "${RUNNER_NAME}_http" ]; then - PORT=$(RUNNER_NAME=$TAG docker compose -f docker-compose.yml port nginx 80 | cut -d: -f2) - else - PORT=$(RUNNER_NAME=$TAG docker compose -f docker-compose.yml port lenz 55551 | cut -d: -f2) - fi - echo "$GATEWAY:$PORT" + echo "$GATEWAY" } IP_REGEX='[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}' diff --git a/test/test_Images.py b/test/test_Images.py index b2a550b9..01458451 100644 --- a/test/test_Images.py +++ b/test/test_Images.py @@ -39,8 +39,7 @@ def test_resolve_rotate(): operations = [{"type": "rotate", "angle": 90}] resolved = resolve(points, meta, operations) assert len(resolved) == 1 - # Note: 9 instead of 10 due to float truncation in .astype(int) - assert resolved[0][0] == 90 and resolved[0][1] == 9 + assert resolved[0][0] == 90 and resolved[0][1] == 10 def test_resolve_ignored_operations(): From d830c30baa7d176649f0d13604b159406d30b746 Mon Sep 17 00:00:00 2001 From: ad-claw000 Date: Thu, 21 May 2026 11:30:33 +0000 Subject: [PATCH 12/13] test: add test for empty operations --- test/test_Operations.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_Operations.py b/test/test_Operations.py index 46ad8c32..7acb4a12 100644 --- a/test/test_Operations.py +++ b/test/test_Operations.py @@ -3,6 +3,10 @@ class TestOperations: + def test_empty_operations(self): + op = Operations() + assert op.get_operations_arr() == [] + def test_resize_width_height(self): op = Operations().resize(width=100, height=200) assert op.get_operations_arr() == [ From 38cd6b5eced5896b400eb99fe4960d54c6028bd9 Mon Sep 17 00:00:00 2001 From: Luis Remis Date: Sat, 23 May 2026 01:06:43 +0000 Subject: [PATCH 13/13] style: auto-format via pre-commit --- test/test_Images.py | 1 + test/test_Operations.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test_Images.py b/test/test_Images.py index 68e39513..7c41a26c 100644 --- a/test/test_Images.py +++ b/test/test_Images.py @@ -32,6 +32,7 @@ def test_resolve_resize_scale(): assert resolved[1][0] == 10 assert resolved[1][1] == 10 + def test_resolve_rotate(): points = np.array([[10, 10]], dtype=float) meta = {"adb_image_width": 100, "adb_image_height": 100} diff --git a/test/test_Operations.py b/test/test_Operations.py index 7acb4a12..66a4907c 100644 --- a/test/test_Operations.py +++ b/test/test_Operations.py @@ -51,7 +51,8 @@ def test_preview(self): {"type": "preview", "max_frame_count": 10, "max_time_fraction": 0.5}] def test_preview_all_args(self): - op = Operations().preview(max_frame_count=10, max_time_fraction=0.5, max_time_offset="00:00:10", max_size_mb=10.5) + op = Operations().preview(max_frame_count=10, max_time_fraction=0.5, + max_time_offset="00:00:10", max_size_mb=10.5) assert op.get_operations_arr() == [ {"type": "preview", "max_frame_count": 10, "max_time_fraction": 0.5, "max_time_offset": "00:00:10", "max_size_mb": 10.5}]