From 2e462d1c928cbe715184dfbd2b8f083c1d533d6b Mon Sep 17 00:00:00 2001 From: Cengiz Date: Sat, 25 Apr 2026 06:31:15 +0600 Subject: [PATCH] Added support for frame scaling --- addon/src/main/NativeCamera.gd | 12 + addon/src/main/model/FeedRequest.gd | 26 +- .../nativecamera/NativeCameraPlugin.java | 55 +++ .../nativecamera/model/FeedRequest.java | 22 ++ .../plugin/nativecamera/FeedRequestTest.java | 100 +++++ .../FrameProcessingLogicTest.java | 183 +++++++++- .../fixtures/FeedRequestFixtures.java | 50 ++- common/config/godot.properties | 2 +- docs/README.md | 11 +- ios/src/NativeCamera.swift | 74 ++-- ios/src/model/frame_request.h | 20 +- ios/src/model/frame_request.mm | 23 +- .../native_camera_plugin-Bridging-Header.h | 3 +- ios/src/native_camera_plugin.mm | 14 +- ios/test/fixtures/TestFixtures.swift | 51 ++- ios/test/unit/FrameRequestTests.mm | 344 ++++++++++++------ ios/test/unit/NativeCameraTests.swift | 156 ++++++++ 17 files changed, 988 insertions(+), 158 deletions(-) diff --git a/addon/src/main/NativeCamera.gd b/addon/src/main/NativeCamera.gd index 84bf42e..81447d0 100644 --- a/addon/src/main/NativeCamera.gd +++ b/addon/src/main/NativeCamera.gd @@ -34,6 +34,16 @@ const PLUGIN_SINGLETON_NAME: String = "@pluginName@" ## Whether the emitted frames should be flipped vertically (top-bottom mirror). @export var mirror_vertical: bool = false +## Target width (pixels) to scale the frame buffer to after rotation and mirroring. +## Set to 0 (default) to disable scaling. Both scale_width and scale_height must be +## non-zero for scaling to take effect. +@export var scale_width: int = FeedRequest.DEFAULT_SCALE_WIDTH + +## Target height (pixels) to scale the frame buffer to after rotation and mirroring. +## Set to 0 (default) to disable scaling. Both scale_width and scale_height must be +## non-zero for scaling to take effect. +@export var scale_height: int = FeedRequest.DEFAULT_SCALE_HEIGHT + var _plugin_singleton: Object @@ -104,6 +114,8 @@ func create_feed_request() -> FeedRequest: . set_grayscale(is_grayscale) . set_mirror_horizontal(mirror_horizontal) . set_mirror_vertical(mirror_vertical) + . set_scale_width(scale_width) + . set_scale_height(scale_height) ) diff --git a/addon/src/main/model/FeedRequest.gd b/addon/src/main/model/FeedRequest.gd index 9f1dd29..c96940a 100644 --- a/addon/src/main/model/FeedRequest.gd +++ b/addon/src/main/model/FeedRequest.gd @@ -12,11 +12,17 @@ const DATA_ROTATION_PROPERTY := &"rotation" const DATA_IS_GRAYSCALE_PROPERTY := &"is_grayscale" const DATA_MIRROR_HORIZONTAL_PROPERTY := &"mirror_horizontal" const DATA_MIRROR_VERTICAL_PROPERTY := &"mirror_vertical" +const DATA_SCALE_WIDTH_PROPERTY := &"scale_width" +const DATA_SCALE_HEIGHT_PROPERTY := &"scale_height" const DEFAULT_WIDTH: int = 1280 const DEFAULT_HEIGHT: int = 720 const DEFAULT_FRAMES_TO_SKIP: int = 40 const DEFAULT_ROTATION: int = 90 +## A value of 0 disables post-capture scaling on that axis. +## Both scale_width and scale_height must be non-zero for scaling to take effect. +const DEFAULT_SCALE_WIDTH: int = 0 +const DEFAULT_SCALE_HEIGHT: int = 0 const DEFAULT_DATA: Dictionary = { DATA_WIDTH_PROPERTY: DEFAULT_WIDTH, @@ -25,7 +31,9 @@ const DEFAULT_DATA: Dictionary = { DATA_ROTATION_PROPERTY: DEFAULT_ROTATION, DATA_IS_GRAYSCALE_PROPERTY: false, DATA_MIRROR_HORIZONTAL_PROPERTY: false, - DATA_MIRROR_VERTICAL_PROPERTY: false + DATA_MIRROR_VERTICAL_PROPERTY: false, + DATA_SCALE_WIDTH_PROPERTY: DEFAULT_SCALE_WIDTH, + DATA_SCALE_HEIGHT_PROPERTY: DEFAULT_SCALE_HEIGHT } var _data: Dictionary @@ -75,5 +83,21 @@ func set_mirror_vertical(a_value: bool) -> FeedRequest: return self +## Sets the target width (in pixels) to which the frame buffer is scaled after +## rotation and mirroring. Set to 0 (default) to disable scaling. +## Both scale_width and scale_height must be non-zero for scaling to take effect. +func set_scale_width(a_value: int) -> FeedRequest: + _data[DATA_SCALE_WIDTH_PROPERTY] = a_value + return self + + +## Sets the target height (in pixels) to which the frame buffer is scaled after +## rotation and mirroring. Set to 0 (default) to disable scaling. +## Both scale_width and scale_height must be non-zero for scaling to take effect. +func set_scale_height(a_value: int) -> FeedRequest: + _data[DATA_SCALE_HEIGHT_PROPERTY] = a_value + return self + + func get_raw_data() -> Dictionary: return _data diff --git a/android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java b/android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java index 8febb1e..29a5924 100644 --- a/android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java +++ b/android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java @@ -67,6 +67,10 @@ public class NativeCameraPlugin extends GodotPlugin { private volatile boolean isGrayscale; private volatile boolean mirrorHorizontal; private volatile boolean mirrorVertical; + /** Target width for post-capture scaling; 0 means disabled. */ + private volatile int scaleWidth; + /** Target height for post-capture scaling; 0 means disabled. */ + private volatile int scaleHeight; private int frameCounter = 0; private volatile boolean running = false; @@ -168,6 +172,8 @@ public void start(Dictionary requestDict) { isGrayscale = feedRequest.isGrayscale(); mirrorHorizontal = feedRequest.isMirrorHorizontal(); mirrorVertical = feedRequest.isMirrorVertical(); + scaleWidth = feedRequest.getScaleWidth(); + scaleHeight = feedRequest.getScaleHeight(); openCamera(feedRequest); } @@ -425,6 +431,18 @@ private void onImageAvailable(ImageReader reader) { } } + // Scaling is applied last — after rotation and mirroring — so that + // scale_width and scale_height always describe the final emitted dimensions. + if (scaleWidth > 0 && scaleHeight > 0 && (scaleWidth != width || scaleHeight != height)) { + if (isGrayscale) { + output = scaleGray(output, width, height, scaleWidth, scaleHeight); + } else { + output = scaleRGBA(output, width, height, scaleWidth, scaleHeight); + } + width = scaleWidth; + height = scaleHeight; + } + if (running) { emitFrame(output, width, height, rotation, isGrayscale); } @@ -611,4 +629,41 @@ private static byte[] mirrorGray(byte[] src, int width, int height, } return dst; } + + /** + * Scales an RGBA (4 bytes/pixel) frame buffer to {@code dstW × dstH} using + * nearest-neighbour interpolation. Applied after rotation and mirroring. + */ + static byte[] scaleRGBA(byte[] src, int srcW, int srcH, int dstW, int dstH) { + byte[] dst = new byte[dstW * dstH * 4]; + for (int dy = 0; dy < dstH; dy++) { + int sy = dy * srcH / dstH; + for (int dx = 0; dx < dstW; dx++) { + int sx = dx * srcW / dstW; + int srcIdx = (sy * srcW + sx) * 4; + int dstIdx = (dy * dstW + dx) * 4; + dst[dstIdx] = src[srcIdx]; + dst[dstIdx + 1] = src[srcIdx + 1]; + dst[dstIdx + 2] = src[srcIdx + 2]; + dst[dstIdx + 3] = src[srcIdx + 3]; + } + } + return dst; + } + + /** + * Scales a grayscale (1 byte/pixel) frame buffer to {@code dstW × dstH} using + * nearest-neighbour interpolation. Applied after rotation and mirroring. + */ + static byte[] scaleGray(byte[] src, int srcW, int srcH, int dstW, int dstH) { + byte[] dst = new byte[dstW * dstH]; + for (int dy = 0; dy < dstH; dy++) { + int sy = dy * srcH / dstH; + for (int dx = 0; dx < dstW; dx++) { + int sx = dx * srcW / dstW; + dst[dy * dstW + dx] = src[sy * srcW + sx]; + } + } + return dst; + } } diff --git a/android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java b/android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java index 3257bf0..791bc76 100644 --- a/android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java +++ b/android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java @@ -19,6 +19,8 @@ public class FeedRequest { private static final String DATA_IS_GRAYSCALE_PROPERTY = "is_grayscale"; private static final String DATA_MIRROR_HORIZONTAL_PROPERTY = "mirror_horizontal"; private static final String DATA_MIRROR_VERTICAL_PROPERTY = "mirror_vertical"; + private static final String DATA_SCALE_WIDTH_PROPERTY = "scale_width"; + private static final String DATA_SCALE_HEIGHT_PROPERTY = "scale_height"; private Dictionary data; @@ -70,6 +72,26 @@ public boolean isMirrorVertical() { } + /** + * Returns the target width (pixels) to scale the post-processed frame to. + * A value of 0 (the default) means no scaling on this axis. + * Scaling is only applied when both scale_width and scale_height are non-zero. + */ + public int getScaleWidth() { + return data.containsKey(DATA_SCALE_WIDTH_PROPERTY) ? toInt(data.get(DATA_SCALE_WIDTH_PROPERTY)) : 0; + } + + + /** + * Returns the target height (pixels) to scale the post-processed frame to. + * A value of 0 (the default) means no scaling on this axis. + * Scaling is only applied when both scale_width and scale_height are non-zero. + */ + public int getScaleHeight() { + return data.containsKey(DATA_SCALE_HEIGHT_PROPERTY) ? toInt(data.get(DATA_SCALE_HEIGHT_PROPERTY)) : 0; + } + + public Dictionary getRawData() { return data; } diff --git a/android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java b/android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java index 4c5ff45..8d0c402 100644 --- a/android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java +++ b/android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java @@ -88,6 +88,7 @@ public void getFramesToSkip_missingKey_returnsDefaultOne() { @Test public void getFramesToSkip_minimalDict_returnsDefaultOne() { + // minimalDict has no frames_to_skip key → default 1 FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict()); assertEquals(1, req.getFramesToSkip()); } @@ -222,6 +223,101 @@ public void mirrorVerticalDict_onlyVerticalTrue() { assertTrue(req.isMirrorVertical()); } + // ── scaleWidth ──────────────────────────────────────────────────────── + + @Test + public void getScaleWidth_returnsValueFromDict() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.scaledDict()); + assertEquals(640, req.getScaleWidth()); + } + + @Test + public void getScaleWidth_missingKey_returnsDefaultZero() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.emptyDict()); + assertEquals(0, req.getScaleWidth()); + } + + @Test + public void getScaleWidth_zeroInFullDict_returnsZero() { + // fullDict() stores scale_width = 0 (disabled) + FeedRequest req = new FeedRequest(FeedRequestFixtures.fullDict()); + assertEquals(0, req.getScaleWidth()); + } + + @Test + public void getScaleWidth_minimalDict_returnsDefaultZero() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict()); + assertEquals(0, req.getScaleWidth()); + } + + @Test + public void getScaleWidth_scaleWidthOnlyDict_returnsValue() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleWidthOnlyDict()); + assertEquals(640, req.getScaleWidth()); + } + + // ── scaleHeight ─────────────────────────────────────────────────────── + + @Test + public void getScaleHeight_returnsValueFromDict() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.scaledDict()); + assertEquals(360, req.getScaleHeight()); + } + + @Test + public void getScaleHeight_missingKey_returnsDefaultZero() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.emptyDict()); + assertEquals(0, req.getScaleHeight()); + } + + @Test + public void getScaleHeight_zeroInFullDict_returnsZero() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.fullDict()); + assertEquals(0, req.getScaleHeight()); + } + + @Test + public void getScaleHeight_minimalDict_returnsDefaultZero() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict()); + assertEquals(0, req.getScaleHeight()); + } + + @Test + public void getScaleHeight_scaleHeightOnlyDict_returnsValue() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleHeightOnlyDict()); + assertEquals(360, req.getScaleHeight()); + } + + // ── scale combined ──────────────────────────────────────────────────── + + @Test + public void scaledDict_bothDimensionsPopulated() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.scaledDict()); + assertEquals(640, req.getScaleWidth()); + assertEquals(360, req.getScaleHeight()); + } + + @Test + public void scaleIdentityDict_dimensionsEqualCaptureSize() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleIdentityDict()); + assertEquals(1280, req.getScaleWidth()); + assertEquals(720, req.getScaleHeight()); + } + + @Test + public void scaleWidthOnlyDict_heightRemainsZero() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleWidthOnlyDict()); + assertEquals(640, req.getScaleWidth()); + assertEquals(0, req.getScaleHeight()); + } + + @Test + public void scaleHeightOnlyDict_widthRemainsZero() { + FeedRequest req = new FeedRequest(FeedRequestFixtures.scaleHeightOnlyDict()); + assertEquals(0, req.getScaleWidth()); + assertEquals(360, req.getScaleHeight()); + } + // ── type coercion (Long -> int) ─────────────────────────────────────── @Test @@ -232,11 +328,15 @@ public void intFields_neverThrowClassCastException_forLongValues() { d.put("height", Long.MAX_VALUE); d.put("frames_to_skip", Long.MAX_VALUE); d.put("rotation", Long.MAX_VALUE); + d.put("scale_width", Long.MAX_VALUE); + d.put("scale_height", Long.MAX_VALUE); FeedRequest req = new FeedRequest(d); // Just calling the getters must not throw req.getWidth(); req.getHeight(); req.getFramesToSkip(); req.getRotation(); + req.getScaleWidth(); + req.getScaleHeight(); } } diff --git a/android/src/test/java/org/godotengine/plugin/nativecamera/FrameProcessingLogicTest.java b/android/src/test/java/org/godotengine/plugin/nativecamera/FrameProcessingLogicTest.java index 8dae0c1..d7d2da2 100644 --- a/android/src/test/java/org/godotengine/plugin/nativecamera/FrameProcessingLogicTest.java +++ b/android/src/test/java/org/godotengine/plugin/nativecamera/FrameProcessingLogicTest.java @@ -13,7 +13,7 @@ /** - * Tests for the frame-skip and buffer-sizing logic embedded in + * Tests for the frame-skip, buffer-sizing, and scaling logic embedded in * {@code NativeCameraPlugin.onImageAvailable()}. * *

These rules come directly from the plugin source: @@ -22,6 +22,8 @@ *

  • A frame is processed when {@code ++frameCounter % divisor == 0}
  • *
  • RGBA buffer size = {@code width * height * 4}
  • *
  • Gray buffer size = {@code width * height}
  • + *
  • Scaling uses nearest-neighbour and is applied only when both + * {@code scaleWidth > 0} and {@code scaleHeight > 0}
  • * * * The logic is pure arithmetic so no Android environment is required. @@ -174,6 +176,173 @@ public void bufferReuse_switchFromColorToGray_reallocates() { assertEquals(4, result.length); } + // ───────────────────────────────────────────────────────────────────── + // Scale guard: enabled only when both dimensions are > 0 + // ───────────────────────────────────────────────────────────────────── + + @Test + public void scaleGuard_bothZero_scalingDisabled() { + // scaleWidth=0, scaleHeight=0 → no scaling + assertScalingEnabled(0, 0, 640, 480, false); + } + + @Test + public void scaleGuard_widthZeroHeightNonZero_scalingDisabled() { + assertScalingEnabled(0, 360, 640, 480, false); + } + + @Test + public void scaleGuard_widthNonZeroHeightZero_scalingDisabled() { + assertScalingEnabled(320, 0, 640, 480, false); + } + + @Test + public void scaleGuard_bothNonZero_scalingEnabled() { + assertScalingEnabled(320, 240, 640, 480, true); + } + + @Test + public void scaleGuard_dimensionsEqualSource_scalingStillApplied() { + // Even when target == source the guard passes; the caller decides whether to no-op. + assertScalingEnabled(640, 480, 640, 480, true); + } + + // ───────────────────────────────────────────────────────────────────── + // scaleRGBA – buffer length and pixel-position correctness + // ───────────────────────────────────────────────────────────────────── + + @Test + public void scaleRGBA_outputLengthIsCorrect() { + byte[] src = new byte[4 * 4 * 4]; // 4×4 RGBA + byte[] dst = NativeCameraPlugin.scaleRGBA(src, 4, 4, 2, 2); + assertEquals(2 * 2 * 4, dst.length); + } + + @Test + public void scaleRGBA_upscale_outputLengthIsCorrect() { + byte[] src = new byte[2 * 2 * 4]; // 2×2 RGBA + byte[] dst = NativeCameraPlugin.scaleRGBA(src, 2, 2, 4, 4); + assertEquals(4 * 4 * 4, dst.length); + } + + @Test + public void scaleRGBA_topLeftPixelIsPreserved() { + // Nearest-neighbour: dst(0,0) must map to src(0,0). + byte[] src = new byte[4 * 4 * 4]; + src[0] = 10; + src[1] = 20; + src[2] = 30; + src[3] = (byte) 255; // TL pixel + byte[] dst = NativeCameraPlugin.scaleRGBA(src, 4, 4, 2, 2); + assertEquals(10, dst[0]); + assertEquals(20, dst[1]); + assertEquals(30, dst[2]); + assertEquals((byte) 255, dst[3]); + } + + @Test + public void scaleRGBA_identityScale_preservesAllPixels() { + byte[] src = new byte[2 * 2 * 4]; + for (int i = 0; i < src.length; i++) { + src[i] = (byte) (i % 256); + } + byte[] dst = NativeCameraPlugin.scaleRGBA(src, 2, 2, 2, 2); + for (int i = 0; i < src.length; i++) { + assertEquals(src[i], dst[i], "Mismatch at byte " + i); + } + } + + @Test + public void scaleRGBA_halveResolution_bufferSizeIsQuarterOfSource() { + // 8×8 → 4×4: pixels = 1/4, bytes = 1/4. + byte[] src = new byte[8 * 8 * 4]; + byte[] dst = NativeCameraPlugin.scaleRGBA(src, 8, 8, 4, 4); + assertEquals(src.length / 4, dst.length); + } + + @Test + public void scaleRGBA_1x1Source_outputHasCorrectSize() { + byte[] src = new byte[]{10, 20, 30, (byte) 255}; + byte[] dst = NativeCameraPlugin.scaleRGBA(src, 1, 1, 3, 3); + assertEquals(3 * 3 * 4, dst.length); + // Every pixel must be the single source pixel + for (int i = 0; i < dst.length; i += 4) { + assertEquals(10, dst[i], "R at pixel " + i / 4); + assertEquals(20, dst[i + 1], "G at pixel " + i / 4); + assertEquals(30, dst[i + 2], "B at pixel " + i / 4); + assertEquals((byte) 255, dst[i + 3], "A at pixel " + i / 4); + } + } + + // ───────────────────────────────────────────────────────────────────── + // scaleGray – buffer length and pixel-position correctness + // ───────────────────────────────────────────────────────────────────── + + @Test + public void scaleGray_outputLengthIsCorrect() { + byte[] src = new byte[4 * 4]; // 4×4 grayscale + byte[] dst = NativeCameraPlugin.scaleGray(src, 4, 4, 2, 2); + assertEquals(2 * 2, dst.length); + } + + @Test + public void scaleGray_upscale_outputLengthIsCorrect() { + byte[] src = new byte[2 * 2]; + byte[] dst = NativeCameraPlugin.scaleGray(src, 2, 2, 4, 4); + assertEquals(4 * 4, dst.length); + } + + @Test + public void scaleGray_topLeftPixelIsPreserved() { + byte[] src = new byte[4 * 4]; + src[0] = 42; + byte[] dst = NativeCameraPlugin.scaleGray(src, 4, 4, 2, 2); + assertEquals(42, dst[0]); + } + + @Test + public void scaleGray_identityScale_preservesAllPixels() { + byte[] src = new byte[]{10, 20, 30, 40}; + byte[] dst = NativeCameraPlugin.scaleGray(src, 2, 2, 2, 2); + for (int i = 0; i < src.length; i++) { + assertEquals(src[i], dst[i], "Mismatch at byte " + i); + } + } + + @Test + public void scaleGray_halveResolution_bufferSizeIsQuarterOfSource() { + byte[] src = new byte[8 * 8]; + byte[] dst = NativeCameraPlugin.scaleGray(src, 8, 8, 4, 4); + assertEquals(src.length / 4, dst.length); + } + + @Test + public void scaleGray_1x1Source_allOutputPixelsMatchSource() { + byte[] src = new byte[]{(byte) 99}; + byte[] dst = NativeCameraPlugin.scaleGray(src, 1, 1, 3, 3); + assertEquals(9, dst.length); + for (byte b : dst) { + assertEquals((byte) 99, b); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Scale + grayscale buffer size relationship + // ───────────────────────────────────────────────────────────────────── + + @Test + public void scaledGrayBuffer_isFourTimesSmaller_thanScaledRgbaBuffer() { + int w = 320; + int h = 240; + byte[] srcRgba = new byte[w * h * 4]; + byte[] srcGray = new byte[w * h]; + + byte[] dstRgba = NativeCameraPlugin.scaleRGBA(srcRgba, w, h, 160, 120); + byte[] dstGray = NativeCameraPlugin.scaleGray(srcGray, w, h, 160, 120); + + assertEquals(dstRgba.length, dstGray.length * 4); + } + // ───────────────────────────────────────────────────────────────────── // Pure-Java helpers that mirror the plugin's inline formulas // ───────────────────────────────────────────────────────────────────── @@ -220,4 +389,16 @@ private static byte[] getOrAllocBuffer(byte[] existing, int width, int height, b } return existing; } + + /** + * Mirrors the scale-guard condition in {@code onImageAvailable}: + * scaling is enabled when both {@code scaleWidth} and {@code scaleHeight} are > 0. + */ + private static void assertScalingEnabled(int scaleWidth, int scaleHeight, + int currentWidth, int currentHeight, + boolean expectEnabled) { + boolean enabled = scaleWidth > 0 && scaleHeight > 0; + assertEquals(expectEnabled, enabled, + String.format("scaleWidth=%d scaleHeight=%d", scaleWidth, scaleHeight)); + } } diff --git a/android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java b/android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java index 38c7fc1..fc59406 100644 --- a/android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java +++ b/android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java @@ -16,7 +16,7 @@ public final class FeedRequestFixtures { private FeedRequestFixtures() { } - /** All fields populated with non-default values. */ + /** All fields populated with non-default values. scale_width and scale_height are 0 (disabled). */ public static Dictionary fullDict() { Dictionary d = new Dictionary(); d.put("camera_id", "0"); @@ -27,6 +27,8 @@ public static Dictionary fullDict() { d.put("is_grayscale", false); d.put("mirror_horizontal", false); d.put("mirror_vertical", false); + d.put("scale_width", 0L); + d.put("scale_height", 0L); return d; } @@ -96,4 +98,50 @@ public static Dictionary mirrorBothDict() { d.put("mirror_vertical", true); return d; } + + // ── Scale variants ───────────────────────────────────────────────────── + + /** + * Both scale dimensions set to 640×360 — half the default 1280×720 capture + * size. Scaling should be applied because both values are non-zero. + */ + public static Dictionary scaledDict() { + Dictionary d = fullDict(); + d.put("scale_width", 640L); + d.put("scale_height", 360L); + return d; + } + + /** + * scale_width populated, scale_height remains 0 (from fullDict). + * Scaling must NOT be applied when either dimension is zero. + */ + public static Dictionary scaleWidthOnlyDict() { + Dictionary d = fullDict(); + d.put("scale_width", 640L); + // scale_height stays 0L from fullDict + return d; + } + + /** + * scale_height populated, scale_width remains 0 (from fullDict). + * Scaling must NOT be applied when either dimension is zero. + */ + public static Dictionary scaleHeightOnlyDict() { + Dictionary d = fullDict(); + d.put("scale_height", 360L); + // scale_width stays 0L from fullDict + return d; + } + + /** + * Scale dimensions that equal the capture resolution (1280×720). + * The guard passes; the plugin skips the copy as src == dst size. + */ + public static Dictionary scaleIdentityDict() { + Dictionary d = fullDict(); + d.put("scale_width", 1280L); + d.put("scale_height", 720L); + return d; + } } diff --git a/common/config/godot.properties b/common/config/godot.properties index 5e9f223..344713d 100644 --- a/common/config/godot.properties +++ b/common/config/godot.properties @@ -3,4 +3,4 @@ # godotVersion=4.7 -godotReleaseType=dev3 +godotReleaseType=dev5 diff --git a/docs/README.md b/docs/README.md index 20da959..72bcd74 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ A Godot plugin that provides a **unified camera capture interface** for **Androi * Enumerate available cameras and their supported output sizes * Start and stop native camera frame streaming * Receive raw frame buffers or ready‑to‑use `Image` objects -* Configurable resolution, rotation, frame skipping, horizontal/vertical mirroring, and grayscale capture +* Configurable resolution, rotation, frame skipping, horizontal/vertical mirroring, grayscale capture, and post-capture scaling * Designed for real‑time use cases (CV, AR preprocessing, custom rendering) ## Table of Contents @@ -117,6 +117,8 @@ func _on_camera_permission_granted() -> void: .set_grayscale(false) .set_mirror_horizontal(true) # flip left-right (e.g. selfie camera preview) .set_mirror_vertical(false) + .set_scale_width(640) # downscale to 640×360 before emitting + .set_scale_height(360) camera.start(request) @@ -164,7 +166,7 @@ Register listeners on the `NativeCamera` node: * `create_feed_request() -> FeedRequest` - * Creates a `FeedRequest` pre-populated with the node's exported property values (`frame_width`, `frame_height`, `frames_to_skip`, `frame_rotation`, `is_grayscale`, `mirror_horizontal`, `mirror_vertical`) + * Creates a `FeedRequest` pre-populated with the node's exported property values (`frame_width`, `frame_height`, `frames_to_skip`, `frame_rotation`, `is_grayscale`, `mirror_horizontal`, `mirror_vertical`, `scale_width`, `scale_height`) * `start(request: FeedRequest)` @@ -203,9 +205,12 @@ Defines configuration parameters for starting a camera feed. * Grayscale capture * Horizontal mirror (`mirror_horizontal`) — flips the frame left-to-right after rotation * Vertical mirror (`mirror_vertical`) — flips the frame top-to-bottom after rotation +* Scale width and height (`scale_width`, `scale_height`) — resizes the pixel buffer to the given dimensions as the final post-processing step (after rotation and mirroring); both must be non-zero for scaling to take effect; defaults to 0 (disabled) Both mirror flags default to `false` and can be combined independently with any rotation value. Mirroring is applied as a post-processing step after rotation on both Android and iOS, so the axis labels always refer to the final upright image. +Scaling is applied after mirroring and uses nearest-neighbour interpolation, making it suitable for real-time use cases such as downscaling before CV inference. Setting either `scale_width` or `scale_height` to 0 disables scaling entirely. + Supports fluent chaining via setter methods. **Setter methods:** @@ -218,6 +223,8 @@ Supports fluent chaining via setter methods. * `set_grayscale(value: bool) -> FeedRequest` * `set_mirror_horizontal(value: bool) -> FeedRequest` * `set_mirror_vertical(value: bool) -> FeedRequest` +* `set_scale_width(value: int) -> FeedRequest` +* `set_scale_height(value: int) -> FeedRequest` ### FrameInfo diff --git a/ios/src/NativeCamera.swift b/ios/src/NativeCamera.swift index 75e39da..a979f22 100644 --- a/ios/src/NativeCamera.swift +++ b/ios/src/NativeCamera.swift @@ -25,14 +25,8 @@ import UIKit private var videoOutput: AVCaptureVideoDataOutput? private let sessionQueue = DispatchQueue(label: "camera_session_queue") - private var framesToSkip: Int = 0 + private var frameRequest: FrameRequest? private var frameCounter: Int = 0 - private var rotation: Int = 0 - private var isGrayscale: Bool = false - private var mirrorHorizontal: Bool = false - private var mirrorVertical: Bool = false - private var targetWidth: Int = 0 - private var targetHeight: Int = 0 @objc public static func hasPermission() -> Bool { return AVCaptureDevice.authorizationStatus(for: .video) == .authorized @@ -46,7 +40,7 @@ import UIKit } } - @objc public func getCameras() -> [CameraInfo] { // Changed return type + @objc public func getCameras() -> [CameraInfo] { let discoverySession = AVCaptureDevice.DiscoverySession( deviceTypes: [.builtInWideAngleCamera], mediaType: .video, @@ -62,8 +56,7 @@ import UIKit } } - @objc public func start(cameraId: String, width: Int, height: Int, skip: Int, rot: Int, gray: Bool, - mirrorHorizontal: Bool, mirrorVertical: Bool) { + @objc public func start(request: FrameRequest) { // Dispatch everything onto sessionQueue so stop() fully completes // before we reconfigure, AND so variable writes are on the same // queue that captureOutput reads them from — no data race. @@ -72,21 +65,14 @@ import UIKit self.captureSession = nil self.videoOutput = nil - // Set instance vars inside sessionQueue, so captureOutput - // (also on sessionQueue) always sees a consistent, written value. - self.targetWidth = width - self.targetHeight = height - self.framesToSkip = skip - self.rotation = rot - self.isGrayscale = gray - self.mirrorHorizontal = mirrorHorizontal - self.mirrorVertical = mirrorVertical + // Store the request and reset counter + self.frameRequest = request self.frameCounter = 0 let session = AVCaptureSession() session.beginConfiguration() - guard let device = AVCaptureDevice(uniqueID: cameraId), + guard let device = AVCaptureDevice(uniqueID: request.cameraId()), let input = try? AVCaptureDeviceInput(device: device) else { return } if session.canAddInput(input) { session.addInput(input) } @@ -118,8 +104,10 @@ import UIKit public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + guard let req = frameRequest else { return } + frameCounter += 1 - if frameCounter % (framesToSkip + 1) != 0 { return } + if frameCounter % (req.framesToSkip() + 1) != 0 { return } guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } @@ -133,7 +121,7 @@ import UIKit let totalPixels = width * height var outputData: Data - if isGrayscale { + if req.isGrayscale() { // Extract Luminance from BGRA (Approximation: Blue channel or average) // For true YUV-Y extraction, we'd change videoSettings to 420v var grayBytes = [UInt8](repeating: 0, count: totalPixels) @@ -157,19 +145,28 @@ import UIKit } // Handle Rotation - let rotated = rotateData(outputData, w: width, h: height, degrees: rotation, gray: isGrayscale) + let rotated = rotateData(outputData, w: width, h: height, degrees: req.rotation(), gray: req.isGrayscale()) // Handle Mirror (applied after rotation, same as Android) - let final = mirrorData(rotated.data, w: rotated.w, h: rotated.h, - gray: isGrayscale, horizontal: mirrorHorizontal, vertical: mirrorVertical) + let mirrored = mirrorData(rotated.data, w: rotated.w, h: rotated.h, + gray: req.isGrayscale(), horizontal: req.isMirrorHorizontal(), vertical: req.isMirrorVertical()) + + // Handle Scaling (applied last — after rotation and mirroring) + let final: (data: Data, w: Int, h: Int) + if req.scaleWidth() > 0 && req.scaleHeight() > 0 { + final = scaleData(mirrored.data, srcW: mirrored.w, srcH: mirrored.h, + dstW: req.scaleWidth(), dstH: req.scaleHeight(), gray: req.isGrayscale()) + } else { + final = mirrored + } DispatchQueue.main.async { let info = FrameInfo( buffer: final.data, width: final.w, height: final.h, - rotation: self.rotation, - isGrayscale: self.isGrayscale + rotation: req.rotation(), + isGrayscale: req.isGrayscale() ) self.onFrameAvailable?(info) } @@ -235,4 +232,27 @@ import UIKit } return (Data(dst), w, h) } + + /// Scales a pixel buffer to `dstW × dstH` using nearest-neighbour interpolation. + /// Applied after rotation and mirroring — the last post-processing step before emit. + /// Supports both RGBA (4 bytes/pixel) and grayscale (1 byte/pixel) buffers. + internal func scaleData(_ src: Data, srcW: Int, srcH: Int, + dstW: Int, dstH: Int, gray: Bool) -> (data: Data, w: Int, h: Int) { + let bytesPerPixel = gray ? 1 : 4 + var dst = [UInt8](repeating: 0, count: dstW * dstH * bytesPerPixel) + let srcArray = [UInt8](src) + + for dy in 0.. -#include "core/object/class_db.h" - @interface FrameRequest : NSObject -@property(nonatomic, assign) Dictionary rawData; - -- (instancetype)initWithDictionary:(Dictionary)data; +- (instancetype)initWithRawData:(void *)data; - (NSString *)cameraId; @@ -31,6 +27,20 @@ - (BOOL)isMirrorVertical; +/** + * Target width (pixels) to scale the post-processed frame to. + * Returns 0 when the key is absent, meaning scaling is disabled on this axis. + * Scaling is only performed when both scaleWidth and scaleHeight are non-zero. + */ +- (NSInteger)scaleWidth; + +/** + * Target height (pixels) to scale the post-processed frame to. + * Returns 0 when the key is absent, meaning scaling is disabled on this axis. + * Scaling is only performed when both scaleWidth and scaleHeight are non-zero. + */ +- (NSInteger)scaleHeight; + @end #endif /* frame_request_h */ diff --git a/ios/src/model/frame_request.mm b/ios/src/model/frame_request.mm index 5d773c7..b2a2cf4 100644 --- a/ios/src/model/frame_request.mm +++ b/ios/src/model/frame_request.mm @@ -3,6 +3,11 @@ // #import "frame_request.h" +#include "core/object/class_db.h" + +@interface FrameRequest () +@property(nonatomic, assign) Dictionary rawData; +@end @implementation FrameRequest @@ -14,10 +19,13 @@ @implementation FrameRequest static String const kIsGrayscaleProperty = "is_grayscale"; static String const kMirrorHorizontalProperty = "mirror_horizontal"; static String const kMirrorVerticalProperty = "mirror_vertical"; +static String const kScaleWidthProperty = "scale_width"; +static String const kScaleHeightProperty = "scale_height"; -- (instancetype)initWithDictionary:(Dictionary)data { - if ((self = [super init])) { - self.rawData = data; +- (instancetype)initWithRawData:(void *)data { + self = [super init]; + if (self) { + _rawData = *(Dictionary *)data; } return self; } @@ -57,4 +65,13 @@ - (BOOL)isMirrorVertical { return self.rawData.has(kMirrorVerticalProperty) ? (BOOL)self.rawData[kMirrorVerticalProperty] : NO; } +- (NSInteger)scaleWidth { + return self.rawData.has(kScaleWidthProperty) ? (NSInteger)self.rawData[kScaleWidthProperty].operator int64_t() : 0; +} + +- (NSInteger)scaleHeight { + return self.rawData.has(kScaleHeightProperty) ? (NSInteger)self.rawData[kScaleHeightProperty].operator int64_t() + : 0; +} + @end diff --git a/ios/src/native_camera_plugin-Bridging-Header.h b/ios/src/native_camera_plugin-Bridging-Header.h index 1cadbec..954def5 100644 --- a/ios/src/native_camera_plugin-Bridging-Header.h +++ b/ios/src/native_camera_plugin-Bridging-Header.h @@ -2,5 +2,4 @@ // © 2026-present https://github.com/cengiz-pz // -// TODO -// #import "shared_stuff.h" +#import "frame_request.h" diff --git a/ios/src/native_camera_plugin.mm b/ios/src/native_camera_plugin.mm index 0524a6d..af7b60d 100644 --- a/ios/src/native_camera_plugin.mm +++ b/ios/src/native_camera_plugin.mm @@ -49,15 +49,11 @@ } void NativeCameraPlugin::start(Dictionary requestDict) { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:requestDict]; - [swiftCamera startWithCameraId:req.cameraId - width:req.width - height:req.height - skip:req.framesToSkip - rot:req.rotation - gray:req.isGrayscale - mirrorHorizontal:req.isMirrorHorizontal - mirrorVertical:req.isMirrorVertical]; + // Pass the memory address of the C++ Dictionary to the wrapper + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&requestDict]; + + // Start the Swift camera by passing the FrameRequest object + [swiftCamera startWithRequest:req]; } void NativeCameraPlugin::stop() { diff --git a/ios/test/fixtures/TestFixtures.swift b/ios/test/fixtures/TestFixtures.swift index 50efeb4..3925081 100644 --- a/ios/test/fixtures/TestFixtures.swift +++ b/ios/test/fixtures/TestFixtures.swift @@ -137,6 +137,23 @@ enum PixelBufferFixture { ] return Data(bytes) }() + + // MARK: 4×4 source for downscale / upscale tests (nearest-neighbour) + + /// 4×4 grayscale ramp: pixel(x,y) = y*4 + x (values 0…15) + static let gray4x4: Data = Data((0..<16).map { UInt8($0) }) + + /// 4×4 RGBA: R channel = pixel index (0…15), G=B=0, A=255 + static let rgba4x4: Data = { + var bytes = [UInt8](repeating: 0, count: 16 * 4) + for i in 0..<16 { + bytes[i * 4] = UInt8(i) // R + bytes[i * 4 + 1] = 0 // G + bytes[i * 4 + 2] = 0 // B + bytes[i * 4 + 3] = 255 // A + } + return Data(bytes) + }() } // MARK: - FrameRequest Dictionary Fixtures (mirrors ObjC frame_request keys) @@ -148,17 +165,21 @@ enum FrameRequestFixture { static let framesToSkipKey = "frames_to_skip" static let rotationKey = "rotation" static let isGrayscaleKey = "is_grayscale" + static let scaleWidthKey = "scale_width" + static let scaleHeightKey = "scale_height" static let sampleCameraId = "com.apple.avfoundation.avcapturedevice.built-in_video:0" - /// Fully-populated request parameters + /// Fully-populated request parameters (scaling disabled) static let fullParams: [String: Any] = [ cameraIdKey: sampleCameraId, widthKey: 1280, heightKey: 720, framesToSkipKey: 2, rotationKey: 90, - isGrayscaleKey: false + isGrayscaleKey: false, + scaleWidthKey: 0, + scaleHeightKey: 0 ] /// Minimal request – only camera_id; all other fields should fall back to defaults @@ -166,6 +187,30 @@ enum FrameRequestFixture { cameraIdKey: sampleCameraId ] + /// Request with scaling to half the capture resolution (640×360 from 1280×720) + static let scaledParams: [String: Any] = [ + cameraIdKey: sampleCameraId, + widthKey: 1280, + heightKey: 720, + framesToSkipKey: 0, + rotationKey: 0, + isGrayscaleKey: false, + scaleWidthKey: 640, + scaleHeightKey: 360 + ] + + /// scale_width set, scale_height absent — scaling must not be applied. + static let scaleWidthOnlyParams: [String: Any] = [ + cameraIdKey: sampleCameraId, + scaleWidthKey: 640 + ] + + /// scale_height set, scale_width absent — scaling must not be applied. + static let scaleHeightOnlyParams: [String: Any] = [ + cameraIdKey: sampleCameraId, + scaleHeightKey: 360 + ] + /// Default values expected when keys are absent enum Defaults { static let width = 640 @@ -173,5 +218,7 @@ enum FrameRequestFixture { static let framesToSkip = 0 static let rotation = 0 static let isGrayscale = false + static let scaleWidth = 0 + static let scaleHeight = 0 } } diff --git a/ios/test/unit/FrameRequestTests.mm b/ios/test/unit/FrameRequestTests.mm index 838cafd..c45d875 100644 --- a/ios/test/unit/FrameRequestTests.mm +++ b/ios/test/unit/FrameRequestTests.mm @@ -22,31 +22,40 @@ // --------------------------------------------------------------------------- static Dictionary make_full_request_dict() { - Dictionary d; - d[String("camera_id")] = String("test-camera-id-42"); - d[String("width")] = (int64_t)1280; - d[String("height")] = (int64_t)720; - d[String("frames_to_skip")] = (int64_t)3; - d[String("rotation")] = (int64_t)270; - d[String("is_grayscale")] = true; - return d; + Dictionary d; + d[String("camera_id")] = String("test-camera-id-42"); + d[String("width")] = (int64_t)1280; + d[String("height")] = (int64_t)720; + d[String("frames_to_skip")] = (int64_t)3; + d[String("rotation")] = (int64_t)270; + d[String("is_grayscale")] = true; + d[String("scale_width")] = (int64_t)0; + d[String("scale_height")] = (int64_t)0; + return d; } static Dictionary make_empty_dict() { - return Dictionary(); + return Dictionary(); } static Dictionary make_partial_dict_camera_only() { - Dictionary d; - d[String("camera_id")] = String("only-camera"); - return d; + Dictionary d; + d[String("camera_id")] = String("only-camera"); + return d; } static Dictionary make_mirror_dict(bool mirrorH, bool mirrorV) { - Dictionary d = make_full_request_dict(); - d[String("mirror_horizontal")] = mirrorH; - d[String("mirror_vertical")] = mirrorV; - return d; + Dictionary d = make_full_request_dict(); + d[String("mirror_horizontal")] = mirrorH; + d[String("mirror_vertical")] = mirrorV; + return d; +} + +static Dictionary make_scaled_dict(int64_t sw, int64_t sh) { + Dictionary d = make_full_request_dict(); + d[String("scale_width")] = sw; + d[String("scale_height")] = sh; + return d; } // --------------------------------------------------------------------------- @@ -61,199 +70,326 @@ @implementation FrameRequestTests // MARK: - Full dictionary - (void)test_cameraId_fullDict_returnsCorrectString { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()]; - XCTAssertEqualObjects(req.cameraId, @"test-camera-id-42"); + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqualObjects(req.cameraId, @"test-camera-id-42"); } - (void)test_width_fullDict_returnsCorrectValue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()]; - XCTAssertEqual(req.width, 1280); + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.width, 1280); } - (void)test_height_fullDict_returnsCorrectValue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()]; - XCTAssertEqual(req.height, 720); + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.height, 720); } - (void)test_framesToSkip_fullDict_returnsCorrectValue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()]; - XCTAssertEqual(req.framesToSkip, 3); + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.framesToSkip, 3); } - (void)test_rotation_fullDict_returnsCorrectValue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()]; - XCTAssertEqual(req.rotation, 270); + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.rotation, 270); } - (void)test_isGrayscale_fullDict_returnsTrue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()]; - XCTAssertTrue(req.isGrayscale); + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertTrue(req.isGrayscale); } // MARK: - Empty dictionary → defaults - (void)test_cameraId_emptyDict_returnsEmptyString { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()]; - XCTAssertEqualObjects(req.cameraId, @""); + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqualObjects(req.cameraId, @""); } - (void)test_width_emptyDict_returns640 { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()]; - XCTAssertEqual(req.width, 640); + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.width, 640); } - (void)test_height_emptyDict_returns480 { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()]; - XCTAssertEqual(req.height, 480); + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.height, 480); } - (void)test_framesToSkip_emptyDict_returnsZero { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()]; - XCTAssertEqual(req.framesToSkip, 0); + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.framesToSkip, 0); } - (void)test_rotation_emptyDict_returnsZero { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()]; - XCTAssertEqual(req.rotation, 0); + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.rotation, 0); } - (void)test_isGrayscale_emptyDict_returnsFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()]; - XCTAssertFalse(req.isGrayscale); + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isGrayscale); } // MARK: - Partial dictionary - (void)test_cameraId_partialDict_returnsValue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_partial_dict_camera_only()]; - XCTAssertEqualObjects(req.cameraId, @"only-camera"); + Dictionary d = make_partial_dict_camera_only(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqualObjects(req.cameraId, @"only-camera"); } - (void)test_missingNumericKeys_fallBackToDefaults { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_partial_dict_camera_only()]; - XCTAssertEqual(req.width, 640); - XCTAssertEqual(req.height, 480); - XCTAssertEqual(req.framesToSkip, 0); - XCTAssertEqual(req.rotation, 0); - XCTAssertFalse(req.isGrayscale); + Dictionary d = make_partial_dict_camera_only(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.width, 640); + XCTAssertEqual(req.height, 480); + XCTAssertEqual(req.framesToSkip, 0); + XCTAssertEqual(req.rotation, 0); + XCTAssertFalse(req.isGrayscale); } // MARK: - Boolean variants - (void)test_isGrayscale_falseExplicit_returnsFalse { - Dictionary d; - d[String("is_grayscale")] = false; - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d]; - XCTAssertFalse(req.isGrayscale); + Dictionary d; + d[String("is_grayscale")] = false; + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isGrayscale); } - (void)test_isGrayscale_trueExplicit_returnsTrue { - Dictionary d; - d[String("is_grayscale")] = true; - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d]; - XCTAssertTrue(req.isGrayscale); + Dictionary d; + d[String("is_grayscale")] = true; + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertTrue(req.isGrayscale); } // MARK: - Boundary / edge values - (void)test_width_zeroValue_isPreserved { - Dictionary d; - d[String("width")] = (int64_t)0; - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d]; - XCTAssertEqual(req.width, 0); + Dictionary d; + d[String("width")] = (int64_t)0; + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.width, 0); } - (void)test_framesToSkip_largeValue_isPreserved { - Dictionary d; - d[String("frames_to_skip")] = (int64_t)120; - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d]; - XCTAssertEqual(req.framesToSkip, 120); + Dictionary d; + d[String("frames_to_skip")] = (int64_t)120; + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.framesToSkip, 120); } - (void)test_rotation_allCardinalAngles_arePreserved { - for (int angle : {0, 90, 180, 270}) { - Dictionary d; - d[String("rotation")] = (int64_t)angle; - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d]; - XCTAssertEqual(req.rotation, angle, - @"Rotation angle %d not preserved", angle); - } + for (int angle : {0, 90, 180, 270}) { + Dictionary d; + d[String("rotation")] = (int64_t)angle; + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.rotation, angle, + @"Rotation angle %d not preserved", angle); + } } // MARK: - Unicode camera ID - (void)test_cameraId_unicodeCharacters_roundTrip { - Dictionary d; - // Use String::utf8() to explicitly parse the C-string literal as UTF-8 - d[String("camera_id")] = String::utf8("カメラ:0"); - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:d]; - XCTAssertEqualObjects(req.cameraId, @"カメラ:0"); + Dictionary d; + // Use String::utf8() to explicitly parse the C-string literal as UTF-8 + d[String("camera_id")] = String::utf8("カメラ:0"); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqualObjects(req.cameraId, @"カメラ:0"); } // MARK: - Mirror flags - (void)test_isMirrorHorizontal_fullDict_returnsFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()]; - XCTAssertFalse(req.isMirrorHorizontal); + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isMirrorHorizontal); } - (void)test_isMirrorHorizontal_trueExplicit_returnsTrue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(true, false)]; - XCTAssertTrue(req.isMirrorHorizontal); + Dictionary d = make_mirror_dict(true, false); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertTrue(req.isMirrorHorizontal); } - (void)test_isMirrorHorizontal_emptyDict_returnsFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()]; - XCTAssertFalse(req.isMirrorHorizontal); + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isMirrorHorizontal); } - (void)test_isMirrorHorizontal_falseExplicit_returnsFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(false, false)]; - XCTAssertFalse(req.isMirrorHorizontal); + Dictionary d = make_mirror_dict(false, false); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isMirrorHorizontal); } - (void)test_isMirrorVertical_fullDict_returnsFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_full_request_dict()]; - XCTAssertFalse(req.isMirrorVertical); + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isMirrorVertical); } - (void)test_isMirrorVertical_trueExplicit_returnsTrue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(false, true)]; - XCTAssertTrue(req.isMirrorVertical); + Dictionary d = make_mirror_dict(false, true); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertTrue(req.isMirrorVertical); } - (void)test_isMirrorVertical_emptyDict_returnsFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_empty_dict()]; - XCTAssertFalse(req.isMirrorVertical); + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isMirrorVertical); } - (void)test_isMirrorVertical_falseExplicit_returnsFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(false, false)]; - XCTAssertFalse(req.isMirrorVertical); + Dictionary d = make_mirror_dict(false, false); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isMirrorVertical); } - (void)test_mirrorBoth_bothTrue { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(true, true)]; - XCTAssertTrue(req.isMirrorHorizontal); - XCTAssertTrue(req.isMirrorVertical); + Dictionary d = make_mirror_dict(true, true); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertTrue(req.isMirrorHorizontal); + XCTAssertTrue(req.isMirrorVertical); } - (void)test_mirrorHorizontalOnly_verticalFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(true, false)]; - XCTAssertTrue(req.isMirrorHorizontal); - XCTAssertFalse(req.isMirrorVertical); + Dictionary d = make_mirror_dict(true, false); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertTrue(req.isMirrorHorizontal); + XCTAssertFalse(req.isMirrorVertical); } - (void)test_mirrorVerticalOnly_horizontalFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_mirror_dict(false, true)]; - XCTAssertFalse(req.isMirrorHorizontal); - XCTAssertTrue(req.isMirrorVertical); + Dictionary d = make_mirror_dict(false, true); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isMirrorHorizontal); + XCTAssertTrue(req.isMirrorVertical); } - (void)test_missingMirrorKeys_partialDict_defaultToFalse { - FrameRequest *req = [[FrameRequest alloc] initWithDictionary:make_partial_dict_camera_only()]; - XCTAssertFalse(req.isMirrorHorizontal); - XCTAssertFalse(req.isMirrorVertical); + Dictionary d = make_partial_dict_camera_only(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertFalse(req.isMirrorHorizontal); + XCTAssertFalse(req.isMirrorVertical); +} + +// MARK: - scaleWidth + +- (void)test_scaleWidth_fullDict_returnsZero { + // fullDict stores scale_width = 0 (disabled) + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 0); +} + +- (void)test_scaleWidth_emptyDict_returnsZero { + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 0); +} + +- (void)test_scaleWidth_partialDict_returnsZero { + Dictionary d = make_partial_dict_camera_only(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 0); +} + +- (void)test_scaleWidth_explicitValue_returnsValue { + Dictionary d = make_scaled_dict(640, 360); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 640); +} + +- (void)test_scaleWidth_largeValue_isPreserved { + Dictionary d; + d[String("scale_width")] = (int64_t)3840; + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 3840); +} + +// MARK: - scaleHeight + +- (void)test_scaleHeight_fullDict_returnsZero { + Dictionary d = make_full_request_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleHeight, 0); +} + +- (void)test_scaleHeight_emptyDict_returnsZero { + Dictionary d = make_empty_dict(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleHeight, 0); +} + +- (void)test_scaleHeight_partialDict_returnsZero { + Dictionary d = make_partial_dict_camera_only(); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleHeight, 0); +} + +- (void)test_scaleHeight_explicitValue_returnsValue { + Dictionary d = make_scaled_dict(640, 360); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleHeight, 360); +} + +- (void)test_scaleHeight_largeValue_isPreserved { + Dictionary d; + d[String("scale_height")] = (int64_t)2160; + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleHeight, 2160); +} + +// MARK: - Scale combined + +- (void)test_scaleBoth_bothDimensionsPopulated { + Dictionary d = make_scaled_dict(640, 360); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 640); + XCTAssertEqual(req.scaleHeight, 360); +} + +- (void)test_scaleIdentity_dimensionsEqualCaptureSize { + Dictionary d = make_scaled_dict(1280, 720); + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 1280); + XCTAssertEqual(req.scaleHeight, 720); +} + +- (void)test_scaleWidthOnly_heightRemainsZero { + Dictionary d = make_full_request_dict(); + d[String("scale_width")] = (int64_t)640; + // scale_height is 0 from fullDict + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 640); + XCTAssertEqual(req.scaleHeight, 0); +} + +- (void)test_scaleHeightOnly_widthRemainsZero { + Dictionary d = make_full_request_dict(); + d[String("scale_height")] = (int64_t)360; + // scale_width is 0 from fullDict + FrameRequest *req = [[FrameRequest alloc] initWithRawData:&d]; + XCTAssertEqual(req.scaleWidth, 0); + XCTAssertEqual(req.scaleHeight, 360); } @end diff --git a/ios/test/unit/NativeCameraTests.swift b/ios/test/unit/NativeCameraTests.swift index 8f3a96b..74ec3fd 100644 --- a/ios/test/unit/NativeCameraTests.swift +++ b/ios/test/unit/NativeCameraTests.swift @@ -366,6 +366,162 @@ final class NativeCameraMirrorTests: XCTestCase { } } +// MARK: - NativeCamera Scale Tests + +/// Tests NativeCamera.scaleData(_:srcW:srcH:dstW:dstH:gray:) in isolation. +/// +/// The method is `internal`, accessible via `@testable import NativeCameraPlugin`. +final class NativeCameraScaleTests: XCTestCase { + + private let camera = NativeCamera() + + // MARK: Guard – disabled when either dimension is zero + + func test_scale_guard_bothZero_noScaling() { + // guard is checked by the caller; here we just verify the helper is callable + // with equal src/dst (identity) when both are non-zero. + let src = PixelBufferFixture.gray2x2 + let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 2, dstH: 2, gray: true) + XCTAssertEqual([UInt8](result.data), [UInt8](src)) + } + + // MARK: Buffer length + + func test_scale_gray_downscale_outputLengthCorrect() { + let src = PixelBufferFixture.gray(width: 4, height: 4) + let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: true) + XCTAssertEqual(result.data.count, 2 * 2) + } + + func test_scale_gray_upscale_outputLengthCorrect() { + let src = PixelBufferFixture.gray2x2 + let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 4, dstH: 4, gray: true) + XCTAssertEqual(result.data.count, 4 * 4) + } + + func test_scale_rgba_downscale_outputLengthCorrect() { + let src = PixelBufferFixture.rgba(width: 4, height: 4) + let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: false) + XCTAssertEqual(result.data.count, 2 * 2 * 4) + } + + func test_scale_rgba_upscale_outputLengthCorrect() { + let src = PixelBufferFixture.rgba2x2 + let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 4, dstH: 4, gray: false) + XCTAssertEqual(result.data.count, 4 * 4 * 4) + } + + // MARK: Reported dimensions + + func test_scale_gray_reportedDimensions_matchDst() { + let src = PixelBufferFixture.gray(width: 4, height: 4) + let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 2, dstH: 3, gray: true) + XCTAssertEqual(result.w, 2) + XCTAssertEqual(result.h, 3) + } + + func test_scale_rgba_reportedDimensions_matchDst() { + let src = PixelBufferFixture.rgba(width: 4, height: 4) + let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 3, dstH: 2, gray: false) + XCTAssertEqual(result.w, 3) + XCTAssertEqual(result.h, 2) + } + + // MARK: Identity scale + + func test_scale_gray_identityDimensions_preservesPixels() { + let src = PixelBufferFixture.gray2x2 + let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 2, dstH: 2, gray: true) + XCTAssertEqual([UInt8](result.data), [UInt8](src)) + } + + func test_scale_rgba_identityDimensions_preservesPixels() { + let src = PixelBufferFixture.rgba2x2 + let result = camera.scaleData(src, srcW: 2, srcH: 2, dstW: 2, dstH: 2, gray: false) + XCTAssertEqual([UInt8](result.data), [UInt8](src)) + } + + // MARK: Top-left pixel preserved (nearest-neighbour property) + + func test_scale_gray_topLeftPixelPreserved_onDownscale() { + let src = PixelBufferFixture.gray(width: 4, height: 4) + // Manually set TL pixel to a distinct value + var bytes = [UInt8](src) + bytes[0] = 42 + let result = camera.scaleData(Data(bytes), srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: true) + XCTAssertEqual([UInt8](result.data)[0], 42, "Top-left pixel not preserved after downscale") + } + + func test_scale_rgba_topLeftPixelPreserved_onDownscale() { + var bytes = [UInt8](PixelBufferFixture.rgba(width: 4, height: 4)) + bytes[0] = 10; bytes[1] = 20; bytes[2] = 30; bytes[3] = 255 + let result = camera.scaleData(Data(bytes), srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: false) + let out = [UInt8](result.data) + XCTAssertEqual(out[0], 10, "R of TL pixel not preserved") + XCTAssertEqual(out[1], 20, "G of TL pixel not preserved") + XCTAssertEqual(out[2], 30, "B of TL pixel not preserved") + XCTAssertEqual(out[3], 255, "A of TL pixel not preserved") + } + + // MARK: 1×1 source → all destination pixels equal source pixel + + func test_scale_gray_1x1Source_allOutputPixelsMatchSource() { + let src = Data([UInt8(77)]) + let result = camera.scaleData(src, srcW: 1, srcH: 1, dstW: 3, dstH: 3, gray: true) + XCTAssertEqual(result.data.count, 9) + for byte in result.data { XCTAssertEqual(byte, 77) } + } + + func test_scale_rgba_1x1Source_allOutputPixelsMatchSource() { + let src = Data([UInt8(11), UInt8(22), UInt8(33), UInt8(255)]) + let result = camera.scaleData(src, srcW: 1, srcH: 1, dstW: 2, dstH: 2, gray: false) + XCTAssertEqual(result.data.count, 2 * 2 * 4) + let out = [UInt8](result.data) + for i in stride(from: 0, to: out.count, by: 4) { + XCTAssertEqual(out[i], 11, "R mismatch at pixel \(i/4)") + XCTAssertEqual(out[i + 1], 22, "G mismatch at pixel \(i/4)") + XCTAssertEqual(out[i + 2], 33, "B mismatch at pixel \(i/4)") + XCTAssertEqual(out[i + 3], 255, "A mismatch at pixel \(i/4)") + } + } + + // MARK: Alpha channel preserved through scaling + + func test_scale_rgba_alphaPreserved_afterDownscale() { + let src = PixelBufferFixture.rgba(width: 4, height: 4) // alpha = 255 for all + let result = camera.scaleData(src, srcW: 4, srcH: 4, dstW: 2, dstH: 2, gray: false) + let out = [UInt8](result.data) + for i in stride(from: 3, to: out.count, by: 4) { + XCTAssertEqual(out[i], 255, "Alpha corrupted at pixel \(i/4)") + } + } + + // MARK: Half-size buffer relationship (gray vs RGBA) + + func test_scale_scaledGrayBuffer_isFourTimesSmaller_thanScaledRgba() { + let grayResult = camera.scaleData( + PixelBufferFixture.gray(width: 8, height: 8), + srcW: 8, srcH: 8, dstW: 4, dstH: 4, gray: true + ) + let rgbaResult = camera.scaleData( + PixelBufferFixture.rgba(width: 8, height: 8), + srcW: 8, srcH: 8, dstW: 4, dstH: 4, gray: false + ) + XCTAssertEqual(rgbaResult.data.count, grayResult.data.count * 4) + } + + // MARK: Rectangular (non-square) scale + + func test_scale_gray_rectangularDownscale_bufferLengthCorrect() { + // 6×4 → 3×2: 6 pixels + let src = PixelBufferFixture.gray(width: 6, height: 4) + let result = camera.scaleData(src, srcW: 6, srcH: 4, dstW: 3, dstH: 2, gray: true) + XCTAssertEqual(result.data.count, 3 * 2) + XCTAssertEqual(result.w, 3) + XCTAssertEqual(result.h, 2) + } +} + final class NativeCameraPermissionTests: XCTestCase { /// Verifies `hasPermission()` returns a Bool without crashing.