diff --git a/aperturedb/Images.py b/aperturedb/Images.py index 55d91d83..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: @@ -100,12 +100,20 @@ 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"] + 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/aperturedb/Operations.py b/aperturedb/Operations.py index 88482dda..e96ca592 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,13 +16,25 @@ def __init__(self): def get_operations_arr(self): return self.operations_arr - def resize(self, width: int, height: int) -> Operations: + 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", - "width": width, - "height": height, + "type": "resize" } + if width is not None: + op["width"] = width + op["height"] = height + if scale is not None: + op["scale"] = scale self.operations_arr.append(op) return self @@ -71,3 +84,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: 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" + } + 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 diff --git a/test/test_Images.py b/test/test_Images.py index 20026e5b..7c41a26c 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} @@ -32,6 +43,20 @@ def test_resolve_rotate(): assert resolved[0][0] == 90 and abs(resolved[0][1] - 10) <= 1 +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 new file mode 100644 index 00000000..66a4907c --- /dev/null +++ b/test/test_Operations.py @@ -0,0 +1,74 @@ +import pytest +from aperturedb.Operations import Operations + + +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() == [ + {"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_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) + assert op.get_operations_arr() == [ + {"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)