Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions addon/src/main/NativeCamera.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
)


Expand Down
26 changes: 25 additions & 1 deletion addon/src/main/model/FeedRequest.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down Expand Up @@ -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
Expand All @@ -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();
}
}
Loading
Loading