Skip to content

Commit 508f701

Browse files
authored
Added mirror support (#29)
1 parent b58fba3 commit 508f701

20 files changed

Lines changed: 555 additions & 52 deletions

File tree

addon/src/main/NativeCamera.gd

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ const PLUGIN_SINGLETON_NAME: String = "@pluginName@"
2828
## Whether the emitted frames should be grayscale or colored.
2929
@export var is_grayscale: bool = false
3030

31+
## Whether the emitted frames should be flipped horizontally (left-right mirror).
32+
@export var mirror_horizontal: bool = false
33+
34+
## Whether the emitted frames should be flipped vertically (top-bottom mirror).
35+
@export var mirror_vertical: bool = false
36+
3137
var _plugin_singleton: Object
3238

3339

@@ -96,6 +102,8 @@ func create_feed_request() -> FeedRequest:
96102
. set_frames_to_skip(frames_to_skip)
97103
. set_rotation(frame_rotation)
98104
. set_grayscale(is_grayscale)
105+
. set_mirror_horizontal(mirror_horizontal)
106+
. set_mirror_vertical(mirror_vertical)
99107
)
100108

101109

addon/src/main/model/FeedRequest.gd

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const DATA_HEIGHT_PROPERTY := &"height"
1010
const DATA_FRAMES_TO_SKIP_PROPERTY := &"frames_to_skip"
1111
const DATA_ROTATION_PROPERTY := &"rotation"
1212
const DATA_IS_GRAYSCALE_PROPERTY := &"is_grayscale"
13+
const DATA_MIRROR_HORIZONTAL_PROPERTY := &"mirror_horizontal"
14+
const DATA_MIRROR_VERTICAL_PROPERTY := &"mirror_vertical"
1315

1416
const DEFAULT_WIDTH: int = 1280
1517
const DEFAULT_HEIGHT: int = 720
@@ -21,7 +23,9 @@ const DEFAULT_DATA: Dictionary = {
2123
DATA_HEIGHT_PROPERTY: DEFAULT_HEIGHT,
2224
DATA_FRAMES_TO_SKIP_PROPERTY: DEFAULT_FRAMES_TO_SKIP,
2325
DATA_ROTATION_PROPERTY: DEFAULT_ROTATION,
24-
DATA_IS_GRAYSCALE_PROPERTY: false
26+
DATA_IS_GRAYSCALE_PROPERTY: false,
27+
DATA_MIRROR_HORIZONTAL_PROPERTY: false,
28+
DATA_MIRROR_VERTICAL_PROPERTY: false
2529
}
2630

2731
var _data: Dictionary
@@ -61,5 +65,15 @@ func set_grayscale(a_value: bool) -> FeedRequest:
6165
return self
6266

6367

68+
func set_mirror_horizontal(a_value: bool) -> FeedRequest:
69+
_data[DATA_MIRROR_HORIZONTAL_PROPERTY] = a_value
70+
return self
71+
72+
73+
func set_mirror_vertical(a_value: bool) -> FeedRequest:
74+
_data[DATA_MIRROR_VERTICAL_PROPERTY] = a_value
75+
return self
76+
77+
6478
func get_raw_data() -> Dictionary:
6579
return _data

android/src/main/java/org/godotengine/plugin/nativecamera/NativeCameraPlugin.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public class NativeCameraPlugin extends GodotPlugin {
6565
private volatile int framesToSkipDivisor;
6666
private volatile int rotation;
6767
private volatile boolean isGrayscale;
68+
private volatile boolean mirrorHorizontal;
69+
private volatile boolean mirrorVertical;
6870
private int frameCounter = 0;
6971

7072
private volatile boolean running = false;
@@ -164,6 +166,8 @@ public void start(Dictionary requestDict) {
164166
framesToSkipDivisor = feedRequest.getFramesToSkip() + 1;
165167
rotation = feedRequest.getRotation(); // degrees
166168
isGrayscale = feedRequest.isGrayscale();
169+
mirrorHorizontal = feedRequest.isMirrorHorizontal();
170+
mirrorVertical = feedRequest.isMirrorVertical();
167171
openCamera(feedRequest);
168172
}
169173

@@ -413,6 +417,14 @@ private void onImageAvailable(ImageReader reader) {
413417
height = result.height;
414418
}
415419

420+
if (mirrorHorizontal || mirrorVertical) {
421+
if (isGrayscale) {
422+
output = mirrorGray(output, width, height, mirrorHorizontal, mirrorVertical);
423+
} else {
424+
output = mirrorRGBA(output, width, height, mirrorHorizontal, mirrorVertical);
425+
}
426+
}
427+
416428
if (running) {
417429
emitFrame(output, width, height, rotation, isGrayscale);
418430
}
@@ -560,4 +572,43 @@ private static RotationResult rotateGray(
560572

561573
return new RotationResult(dst, newWidth, newHeight);
562574
}
575+
576+
/**
577+
* Mirrors an RGBA (4 bytes/pixel) frame buffer horizontally, vertically, or both.
578+
* Dimensions are unchanged; only pixel positions are swapped.
579+
*/
580+
private static byte[] mirrorRGBA(byte[] src, int width, int height,
581+
boolean horizontal, boolean vertical) {
582+
byte[] dst = new byte[src.length];
583+
for (int y = 0; y < height; y++) {
584+
int dy = vertical ? (height - 1 - y) : y;
585+
for (int x = 0; x < width; x++) {
586+
int dx = horizontal ? (width - 1 - x) : x;
587+
int srcIdx = (y * width + x) * 4;
588+
int dstIdx = (dy * width + dx) * 4;
589+
dst[dstIdx] = src[srcIdx];
590+
dst[dstIdx + 1] = src[srcIdx + 1];
591+
dst[dstIdx + 2] = src[srcIdx + 2];
592+
dst[dstIdx + 3] = src[srcIdx + 3];
593+
}
594+
}
595+
return dst;
596+
}
597+
598+
/**
599+
* Mirrors a grayscale (1 byte/pixel) frame buffer horizontally, vertically, or both.
600+
* Dimensions are unchanged; only pixel positions are swapped.
601+
*/
602+
private static byte[] mirrorGray(byte[] src, int width, int height,
603+
boolean horizontal, boolean vertical) {
604+
byte[] dst = new byte[src.length];
605+
for (int y = 0; y < height; y++) {
606+
int dy = vertical ? (height - 1 - y) : y;
607+
for (int x = 0; x < width; x++) {
608+
int dx = horizontal ? (width - 1 - x) : x;
609+
dst[dy * width + dx] = src[y * width + x];
610+
}
611+
}
612+
return dst;
613+
}
563614
}

android/src/main/java/org/godotengine/plugin/nativecamera/model/FeedRequest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public class FeedRequest {
1717
private static final String DATA_FRAMES_TO_SKIP_PROPERTY = "frames_to_skip";
1818
private static final String DATA_ROTATION_PROPERTY = "rotation";
1919
private static final String DATA_IS_GRAYSCALE_PROPERTY = "is_grayscale";
20+
private static final String DATA_MIRROR_HORIZONTAL_PROPERTY = "mirror_horizontal";
21+
private static final String DATA_MIRROR_VERTICAL_PROPERTY = "mirror_vertical";
2022

2123
private Dictionary data;
2224

@@ -56,6 +58,18 @@ public boolean isGrayscale() {
5658
}
5759

5860

61+
public boolean isMirrorHorizontal() {
62+
return data.containsKey(DATA_MIRROR_HORIZONTAL_PROPERTY) ?
63+
(boolean) data.get(DATA_MIRROR_HORIZONTAL_PROPERTY) : false;
64+
}
65+
66+
67+
public boolean isMirrorVertical() {
68+
return data.containsKey(DATA_MIRROR_VERTICAL_PROPERTY) ?
69+
(boolean) data.get(DATA_MIRROR_VERTICAL_PROPERTY) : false;
70+
}
71+
72+
5973
public Dictionary getRawData() {
6074
return data;
6175
}

android/src/test/java/org/godotengine/plugin/nativecamera/FeedRequestTest.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,81 @@ public void getRawData_returnsSameDictInstance() {
147147
assertSame(dict, req.getRawData());
148148
}
149149

150+
// ── mirrorHorizontal ──────────────────────────────────────────────────
151+
152+
@Test
153+
public void isMirrorHorizontal_falseInFullDict() {
154+
FeedRequest req = new FeedRequest(FeedRequestFixtures.fullDict());
155+
assertFalse(req.isMirrorHorizontal());
156+
}
157+
158+
@Test
159+
public void isMirrorHorizontal_trueInMirrorHorizontalDict() {
160+
FeedRequest req = new FeedRequest(FeedRequestFixtures.mirrorHorizontalDict());
161+
assertTrue(req.isMirrorHorizontal());
162+
}
163+
164+
@Test
165+
public void isMirrorHorizontal_missingKey_defaultsFalse() {
166+
FeedRequest req = new FeedRequest(FeedRequestFixtures.emptyDict());
167+
assertFalse(req.isMirrorHorizontal());
168+
}
169+
170+
@Test
171+
public void isMirrorHorizontal_minimalDict_defaultsFalse() {
172+
FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict());
173+
assertFalse(req.isMirrorHorizontal());
174+
}
175+
176+
// ── mirrorVertical ────────────────────────────────────────────────────
177+
178+
@Test
179+
public void isMirrorVertical_falseInFullDict() {
180+
FeedRequest req = new FeedRequest(FeedRequestFixtures.fullDict());
181+
assertFalse(req.isMirrorVertical());
182+
}
183+
184+
@Test
185+
public void isMirrorVertical_trueInMirrorVerticalDict() {
186+
FeedRequest req = new FeedRequest(FeedRequestFixtures.mirrorVerticalDict());
187+
assertTrue(req.isMirrorVertical());
188+
}
189+
190+
@Test
191+
public void isMirrorVertical_missingKey_defaultsFalse() {
192+
FeedRequest req = new FeedRequest(FeedRequestFixtures.emptyDict());
193+
assertFalse(req.isMirrorVertical());
194+
}
195+
196+
@Test
197+
public void isMirrorVertical_minimalDict_defaultsFalse() {
198+
FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict());
199+
assertFalse(req.isMirrorVertical());
200+
}
201+
202+
// ── mirror combined ───────────────────────────────────────────────────
203+
204+
@Test
205+
public void mirrorBothDict_bothFlagsTrue() {
206+
FeedRequest req = new FeedRequest(FeedRequestFixtures.mirrorBothDict());
207+
assertTrue(req.isMirrorHorizontal());
208+
assertTrue(req.isMirrorVertical());
209+
}
210+
211+
@Test
212+
public void mirrorHorizontalDict_onlyHorizontalTrue() {
213+
FeedRequest req = new FeedRequest(FeedRequestFixtures.mirrorHorizontalDict());
214+
assertTrue(req.isMirrorHorizontal());
215+
assertFalse(req.isMirrorVertical());
216+
}
217+
218+
@Test
219+
public void mirrorVerticalDict_onlyVerticalTrue() {
220+
FeedRequest req = new FeedRequest(FeedRequestFixtures.mirrorVerticalDict());
221+
assertFalse(req.isMirrorHorizontal());
222+
assertTrue(req.isMirrorVertical());
223+
}
224+
150225
// ── type coercion (Long -> int) ───────────────────────────────────────
151226

152227
@Test

android/src/test/java/org/godotengine/plugin/nativecamera/fixtures/FeedRequestFixtures.java

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,64 +8,92 @@
88

99

1010
/**
11-
* Centralised test data for {@link org.godotengine.plugin.nativecamera.model.FeedRequest}.
11+
* Factory methods that build {@link Dictionary} instances for use in
12+
* {@link org.godotengine.plugin.nativecamera.model.FeedRequest} unit tests.
1213
*/
1314
public final class FeedRequestFixtures {
1415

1516
private FeedRequestFixtures() {
1617
}
1718

18-
// ── canonical complete request ──────────────────────────────────────────
19-
19+
/** All fields populated with non-default values. */
2020
public static Dictionary fullDict() {
2121
Dictionary d = new Dictionary();
2222
d.put("camera_id", "0");
23-
d.put("width", (long) 1280);
24-
d.put("height", (long) 720);
25-
d.put("frames_to_skip", (long) 2);
26-
d.put("rotation", (long) 90);
23+
d.put("width", 1280L);
24+
d.put("height", 720L);
25+
d.put("frames_to_skip", 2L);
26+
d.put("rotation", 90L);
2727
d.put("is_grayscale", false);
28+
d.put("mirror_horizontal", false);
29+
d.put("mirror_vertical", false);
2830
return d;
2931
}
3032

31-
public static Dictionary grayscaleDict() {
33+
/** No fields at all — every getter should return its safe default. */
34+
public static Dictionary emptyDict() {
35+
return new Dictionary();
36+
}
37+
38+
/**
39+
* Only camera_id + width + height; optional fields absent so that
40+
* their defaults are exercised.
41+
*/
42+
public static Dictionary minimalDict() {
43+
Dictionary d = new Dictionary();
44+
d.put("camera_id", "0");
45+
d.put("width", 1280L);
46+
d.put("height", 720L);
47+
return d;
48+
}
49+
50+
/** Front-facing camera (id = "1"). */
51+
public static Dictionary frontCameraDict() {
3252
Dictionary d = fullDict();
33-
d.put("is_grayscale", true);
34-
d.put("rotation", (long) 0);
53+
d.put("camera_id", "1");
3554
return d;
3655
}
3756

57+
/** Rotation set to 180°. */
3858
public static Dictionary rotated180Dict() {
3959
Dictionary d = fullDict();
40-
d.put("rotation", (long) 180);
60+
d.put("rotation", 180L);
4161
return d;
4262
}
4363

64+
/** Rotation set to 270°. */
4465
public static Dictionary rotated270Dict() {
4566
Dictionary d = fullDict();
46-
d.put("rotation", (long) 270);
67+
d.put("rotation", 270L);
4768
return d;
4869
}
4970

50-
// ── partial / missing-field variants ────────────────────────────────────
51-
52-
/** Only camera_id supplied – every other field should fall back to its default. */
53-
public static Dictionary minimalDict() {
54-
Dictionary d = new Dictionary();
55-
d.put("camera_id", "1");
71+
/** is_grayscale = true, everything else at full-dict values. */
72+
public static Dictionary grayscaleDict() {
73+
Dictionary d = fullDict();
74+
d.put("is_grayscale", true);
5675
return d;
5776
}
5877

59-
/** Completely empty dictionary. */
60-
public static Dictionary emptyDict() {
61-
return new Dictionary();
78+
/** mirror_horizontal = true, everything else at full-dict values. */
79+
public static Dictionary mirrorHorizontalDict() {
80+
Dictionary d = fullDict();
81+
d.put("mirror_horizontal", true);
82+
return d;
6283
}
6384

64-
// ── front-camera variant ────────────────────────────────────────────────
85+
/** mirror_vertical = true, everything else at full-dict values. */
86+
public static Dictionary mirrorVerticalDict() {
87+
Dictionary d = fullDict();
88+
d.put("mirror_vertical", true);
89+
return d;
90+
}
6591

66-
public static Dictionary frontCameraDict() {
92+
/** Both mirror axes enabled. */
93+
public static Dictionary mirrorBothDict() {
6794
Dictionary d = fullDict();
68-
d.put("camera_id", "1");
95+
d.put("mirror_horizontal", true);
96+
d.put("mirror_vertical", true);
6997
return d;
7098
}
7199
}

common/config/godot.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
#
44

55
godotVersion=4.7
6-
godotReleaseType=dev4
6+
godotReleaseType=dev3

demo/Main.gd

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ extends Node
1111
@onready var rotation_slider: HSlider = %RotationHBC/RotationHSlider
1212
@onready var rotation_label: Label = %RotationHBC/ValueLabel
1313
@onready var grayscale_check_button: CheckButton = %GrayscaleCB
14+
@onready var horizontal_mirror_cb: CheckButton = %HorizontalMirrorCB
15+
@onready var vertical_mirror_cb: CheckButton = %VerticalMirrorCB
1416
@onready var frame_skip_slider: HSlider = %FrameSkipHBC/SkipHSlider
1517
@onready var frame_skip_label: Label = %FrameSkipHBC/ValueLabel
1618
@onready var request_permission_button := %PermissionButton as Button
@@ -96,6 +98,8 @@ func _on_start_button_pressed() -> void:
9698
. set_frames_to_skip(int(frame_skip_slider.value))
9799
. set_rotation(int(rotation_slider.value))
98100
. set_grayscale(grayscale_check_button.button_pressed)
101+
. set_mirror_horizontal(horizontal_mirror_cb.button_pressed)
102+
. set_mirror_vertical(vertical_mirror_cb.button_pressed)
99103
)
100104
)
101105
_print_to_screen(

0 commit comments

Comments
 (0)