Skip to content

Commit abca1c1

Browse files
authored
Added option to auto-upright requested frame (#35)
1 parent 144d27c commit abca1c1

25 files changed

Lines changed: 1494 additions & 192 deletions

.github/config/.rubocop.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
AllCops:
66
TargetRubyVersion: 3.2
7-
NewCops: pending
7+
NewCops: enable
88
Exclude:
99
- 'vendor/**/*'
1010

@@ -31,9 +31,6 @@ Style/FrozenStringLiteralComment:
3131

3232
Style/Documentation:
3333
Enabled: false
34-
35-
AllCops:
36-
NewCops: enable
3734
Gemspec/AddRuntimeDependency: # new in 1.65
3835
Enabled: true
3936
Gemspec/AttributeAssignment: # new in 1.77

addon/src/main/NativeCamera.gd

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const PLUGIN_SINGLETON_NAME: String = "@pluginName@"
2323
@export var frames_to_skip: int = FeedRequest.DEFAULT_FRAMES_TO_SKIP
2424

2525
## The rotation to be applied to the frame in degrees. Valid values are 0, 90, 180, and 270.
26+
## Ignored when [member auto_upright] is enabled.
2627
@export var frame_rotation: int = FeedRequest.DEFAULT_ROTATION
2728

2829
## Whether the emitted frames should be grayscale or colored.
@@ -44,6 +45,11 @@ const PLUGIN_SINGLETON_NAME: String = "@pluginName@"
4445
## non-zero for scaling to take effect.
4546
@export var scale_height: int = FeedRequest.DEFAULT_SCALE_HEIGHT
4647

48+
## When enabled, each frame is automatically rotated to be upright by combining
49+
## the camera sensor orientation with the current device orientation.
50+
## When active, [member frame_rotation] is ignored.
51+
@export var auto_upright: bool = FeedRequest.DEFAULT_AUTO_UPRIGHT
52+
4753
var _plugin_singleton: Object
4854

4955

@@ -116,6 +122,7 @@ func create_feed_request() -> FeedRequest:
116122
. set_mirror_vertical(mirror_vertical)
117123
. set_scale_width(scale_width)
118124
. set_scale_height(scale_height)
125+
. set_auto_upright(auto_upright)
119126
)
120127

121128

addon/src/main/model/FeedRequest.gd

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const DATA_MIRROR_HORIZONTAL_PROPERTY := &"mirror_horizontal"
1414
const DATA_MIRROR_VERTICAL_PROPERTY := &"mirror_vertical"
1515
const DATA_SCALE_WIDTH_PROPERTY := &"scale_width"
1616
const DATA_SCALE_HEIGHT_PROPERTY := &"scale_height"
17+
const DATA_AUTO_UPRIGHT_PROPERTY := &"auto_upright"
1718

1819
const DEFAULT_WIDTH: int = 1280
1920
const DEFAULT_HEIGHT: int = 720
@@ -23,6 +24,10 @@ const DEFAULT_ROTATION: int = 90
2324
## Both scale_width and scale_height must be non-zero for scaling to take effect.
2425
const DEFAULT_SCALE_WIDTH: int = 0
2526
const DEFAULT_SCALE_HEIGHT: int = 0
27+
## When true the plugin automatically corrects the frame orientation based on
28+
## the camera sensor and the live device orientation. The manual [code]rotation[/code]
29+
## field is ignored while this flag is active.
30+
const DEFAULT_AUTO_UPRIGHT: bool = false
2631

2732
const DEFAULT_DATA: Dictionary = {
2833
DATA_WIDTH_PROPERTY: DEFAULT_WIDTH,
@@ -33,7 +38,8 @@ const DEFAULT_DATA: Dictionary = {
3338
DATA_MIRROR_HORIZONTAL_PROPERTY: false,
3439
DATA_MIRROR_VERTICAL_PROPERTY: false,
3540
DATA_SCALE_WIDTH_PROPERTY: DEFAULT_SCALE_WIDTH,
36-
DATA_SCALE_HEIGHT_PROPERTY: DEFAULT_SCALE_HEIGHT
41+
DATA_SCALE_HEIGHT_PROPERTY: DEFAULT_SCALE_HEIGHT,
42+
DATA_AUTO_UPRIGHT_PROPERTY: DEFAULT_AUTO_UPRIGHT,
3743
}
3844

3945
var _data: Dictionary
@@ -99,5 +105,14 @@ func set_scale_height(a_value: int) -> FeedRequest:
99105
return self
100106

101107

108+
## When enabled the plugin automatically computes the rotation required to
109+
## produce an upright image by combining the camera sensor orientation with the
110+
## live device orientation. The manual [code]rotation[/code] value is ignored
111+
## while this flag is active. Defaults to [code]false[/code].
112+
func set_auto_upright(a_value: bool) -> FeedRequest:
113+
_data[DATA_AUTO_UPRIGHT_PROPERTY] = a_value
114+
return self
115+
116+
102117
func get_raw_data() -> Dictionary:
103118
return _data

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

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import android.graphics.ImageFormat;
1212
import android.hardware.camera2.CameraAccessException;
1313
import android.hardware.camera2.CameraCaptureSession;
14+
import android.hardware.camera2.CameraCharacteristics;
1415
import android.hardware.camera2.CameraDevice;
1516
import android.hardware.camera2.CameraManager;
1617
import android.hardware.camera2.CaptureRequest;
@@ -22,6 +23,8 @@
2223
import android.os.Handler;
2324
import android.os.HandlerThread;
2425
import android.util.Log;
26+
import android.view.Surface;
27+
import android.view.WindowManager;
2528

2629
import androidx.core.app.ActivityCompat;
2730
import androidx.core.content.ContextCompat;
@@ -71,6 +74,29 @@ public class NativeCameraPlugin extends GodotPlugin {
7174
private volatile int scaleWidth;
7275
/** Target height for post-capture scaling; 0 means disabled. */
7376
private volatile int scaleHeight;
77+
78+
/**
79+
* When true, the rotation applied to each frame is computed automatically
80+
* from the camera sensor orientation and the live device orientation instead
81+
* of using the fixed {@link #rotation} value supplied by the caller.
82+
*/
83+
private volatile boolean autoUpright;
84+
85+
/**
86+
* Clockwise angle (0 / 90 / 180 / 270) that the camera sensor image must
87+
* be rotated to be upright when the device is in its natural (portrait)
88+
* orientation. Populated from {@link CameraCharacteristics#SENSOR_ORIENTATION}
89+
* each time {@link #start} is called.
90+
*/
91+
private volatile int sensorOrientation;
92+
93+
/**
94+
* True when the active camera is front-facing. Used by
95+
* {@link #computeUprightRotation()} to mirror-compensate the rotation
96+
* formula for selfie cameras.
97+
*/
98+
private volatile boolean isFrontFacingCamera;
99+
74100
private int frameCounter = 0;
75101

76102
private volatile boolean running = false;
@@ -174,6 +200,7 @@ public void start(Dictionary requestDict) {
174200
mirrorVertical = feedRequest.isMirrorVertical();
175201
scaleWidth = feedRequest.getScaleWidth();
176202
scaleHeight = feedRequest.getScaleHeight();
203+
autoUpright = feedRequest.isAutoUpright();
177204
openCamera(feedRequest);
178205
}
179206

@@ -219,6 +246,21 @@ private void openCamera(FeedRequest request) {
219246

220247
CameraManager manager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE);
221248
try {
249+
// Read sensor orientation and lens facing so that computeUprightRotation()
250+
// has the information it needs without touching the (potentially slow)
251+
// CameraCharacteristics API on every frame.
252+
CameraCharacteristics characteristics = manager.getCameraCharacteristics(request.getCameraId());
253+
254+
Integer sensorOrientationValue = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION);
255+
sensorOrientation = (sensorOrientationValue != null) ? sensorOrientationValue : 0;
256+
257+
Integer lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING);
258+
isFrontFacingCamera = (lensFacing != null && lensFacing == CameraCharacteristics.LENS_FACING_FRONT);
259+
260+
Log.d(LOG_TAG, String.format(
261+
"openCamera(): sensorOrientation=%d, isFrontFacing=%b, autoUpright=%b",
262+
sensorOrientation, isFrontFacingCamera, autoUpright));
263+
222264
reader = ImageReader.newInstance(request.getWidth(), request.getHeight(), ImageFormat.YUV_420_888, 2);
223265
reader.setOnImageAvailableListener(this::onImageAvailable, bgHandler);
224266

@@ -228,6 +270,59 @@ private void openCamera(FeedRequest request) {
228270
}
229271
}
230272

273+
/**
274+
* Computes the clockwise rotation (in degrees) required to produce an upright
275+
* image for the currently active camera and the current device orientation.
276+
*
277+
* <p>The algorithm follows the standard Camera2 guidance:
278+
* <ul>
279+
* <li>Back-facing: {@code (sensorOrientation − deviceDegrees + 360) % 360}</li>
280+
* <li>Front-facing: {@code (sensorOrientation + deviceDegrees + 360) % 360}<br>
281+
* The extra compensation accounts for the horizontal mirror inherent to
282+
* front cameras — the sensor rotation and the device rotation add rather
283+
* than subtract.</li>
284+
* </ul>
285+
*
286+
* <p>This method is called once per processed frame and is intentionally
287+
* lightweight: the only dynamic read is {@link android.view.Display#getRotation()}.
288+
*/
289+
private int computeUprightRotation() {
290+
Activity activity = getActivity();
291+
292+
int surfaceRotation;
293+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
294+
android.view.Display display = activity.getDisplay();
295+
surfaceRotation = (display != null) ? display.getRotation() : Surface.ROTATION_0;
296+
} else {
297+
WindowManager wm = (WindowManager) activity.getSystemService(Context.WINDOW_SERVICE);
298+
//noinspection deprecation
299+
surfaceRotation = wm.getDefaultDisplay().getRotation();
300+
}
301+
302+
int deviceDegrees;
303+
switch (surfaceRotation) {
304+
case Surface.ROTATION_90: deviceDegrees = 90; break;
305+
case Surface.ROTATION_180: deviceDegrees = 180; break;
306+
case Surface.ROTATION_270: deviceDegrees = 270; break;
307+
default: deviceDegrees = 0; break;
308+
}
309+
310+
int uprightRotation;
311+
if (isFrontFacingCamera) {
312+
// Front cameras are horizontally mirrored: sensor and device rotations add.
313+
uprightRotation = (sensorOrientation + deviceDegrees + 360) % 360;
314+
} else {
315+
// Back cameras: sensor rotation minus device rotation.
316+
uprightRotation = (sensorOrientation - deviceDegrees + 360) % 360;
317+
}
318+
319+
Log.v(LOG_TAG, String.format(
320+
"computeUprightRotation(): deviceDegrees=%d, sensorOrientation=%d, result=%d",
321+
deviceDegrees, sensorOrientation, uprightRotation));
322+
323+
return uprightRotation;
324+
}
325+
231326
void emitFrame(byte[] buffer, int width, int height, int rotation, boolean isGrayscale) {
232327
Activity activity = getActivity();
233328

@@ -410,12 +505,17 @@ private void onImageAvailable(ImageReader reader) {
410505
}
411506
}
412507

413-
if (rotation != 0) {
508+
// When auto_upright is enabled, derive the required rotation from the
509+
// camera sensor orientation and the live device orientation rather than
510+
// using the fixed value set by the caller.
511+
int effectiveRotation = autoUpright ? computeUprightRotation() : rotation;
512+
513+
if (effectiveRotation != 0) {
414514
RotationResult result;
415515
if (isGrayscale) {
416-
result = rotateGray(output, width, height, rotation);
516+
result = rotateGray(output, width, height, effectiveRotation);
417517
} else {
418-
result = rotateRGBA(output, width, height, rotation);
518+
result = rotateRGBA(output, width, height, effectiveRotation);
419519
}
420520

421521
output = result.buffer;
@@ -444,7 +544,7 @@ private void onImageAvailable(ImageReader reader) {
444544
}
445545

446546
if (running) {
447-
emitFrame(output, width, height, rotation, isGrayscale);
547+
emitFrame(output, width, height, effectiveRotation, isGrayscale);
448548
}
449549

450550
image.close();

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class FeedRequest {
2121
private static final String DATA_MIRROR_VERTICAL_PROPERTY = "mirror_vertical";
2222
private static final String DATA_SCALE_WIDTH_PROPERTY = "scale_width";
2323
private static final String DATA_SCALE_HEIGHT_PROPERTY = "scale_height";
24+
private static final String DATA_AUTO_UPRIGHT_PROPERTY = "auto_upright";
2425

2526
private Dictionary data;
2627

@@ -92,6 +93,18 @@ public int getScaleHeight() {
9293
}
9394

9495

96+
/**
97+
* When true the plugin will automatically compute the rotation needed to
98+
* produce an upright image, taking into account both the camera sensor
99+
* orientation and the current device orientation. The manual {@code rotation}
100+
* field is ignored while this flag is active.
101+
*/
102+
public boolean isAutoUpright() {
103+
return data.containsKey(DATA_AUTO_UPRIGHT_PROPERTY) ?
104+
(boolean) data.get(DATA_AUTO_UPRIGHT_PROPERTY) : false;
105+
}
106+
107+
95108
public Dictionary getRawData() {
96109
return data;
97110
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
@ExtendWith(MockitoExtension.class)
3232
public class CameraInfoTest {
3333

34-
// ── helpers ───────────────────────────────────────────────────────────
34+
// -- helpers -----------------------------------------------------------
3535

3636
private static Size size(int w, int h) {
3737
Size s = mock(Size.class);
@@ -81,7 +81,7 @@ private CameraCharacteristics mockCharacteristicsNullFacing(Size[] outputSizes)
8181
return chars;
8282
}
8383

84-
// ── camera_id ─────────────────────────────────────────────────────────
84+
// -- camera_id ---------------------------------------------------------
8585

8686
@Test
8787
public void buildRawData_cameraIdBackCamera() {
@@ -97,7 +97,7 @@ public void buildRawData_cameraIdFrontCamera() {
9797
assertEquals("1", info.buildRawData().get("camera_id"));
9898
}
9999

100-
// ── is_front_facing ───────────────────────────────────────────────────
100+
// -- is_front_facing ---------------------------------------------------
101101

102102
@Test
103103
public void buildRawData_backCamera_isFrontFacingFalse() {
@@ -120,7 +120,7 @@ public void buildRawData_nullFacing_isFrontFacingFalse() {
120120
assertFalse((Boolean) info.buildRawData().get("is_front_facing"));
121121
}
122122

123-
// ── output_sizes ──────────────────────────────────────────────────────
123+
// -- output_sizes ------------------------------------------------------
124124

125125
@Test
126126
public void buildRawData_noOutputSizes_returnsEmptyArray() {
@@ -169,7 +169,7 @@ public void buildRawData_nullOutputSizesArray_returnsEmptyArray() {
169169
assertEquals(0, result.length);
170170
}
171171

172-
// ── key completeness ─────────────────────────────────────────────────
172+
// -- key completeness -------------------------------------------------
173173

174174
@Test
175175
public void buildRawData_containsAllExpectedKeys() {

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,67 @@ public void scaleHeightOnlyDict_widthRemainsZero() {
318318
assertEquals(360, req.getScaleHeight());
319319
}
320320

321+
// ── autoUpright ───────────────────────────────────────────────────────
322+
323+
@Test
324+
public void isAutoUpright_falseInFullDict() {
325+
// fullDict() explicitly sets auto_upright = false
326+
FeedRequest req = new FeedRequest(FeedRequestFixtures.fullDict());
327+
assertFalse(req.isAutoUpright());
328+
}
329+
330+
@Test
331+
public void isAutoUpright_trueInAutoUprightDict() {
332+
FeedRequest req = new FeedRequest(FeedRequestFixtures.autoUprightDict());
333+
assertTrue(req.isAutoUpright());
334+
}
335+
336+
@Test
337+
public void isAutoUpright_missingKey_defaultsFalse() {
338+
// Missing key must default to false — auto_upright is opt-in.
339+
FeedRequest req = new FeedRequest(FeedRequestFixtures.emptyDict());
340+
assertFalse(req.isAutoUpright());
341+
}
342+
343+
@Test
344+
public void isAutoUpright_minimalDict_defaultsFalse() {
345+
FeedRequest req = new FeedRequest(FeedRequestFixtures.minimalDict());
346+
assertFalse(req.isAutoUpright());
347+
}
348+
349+
@Test
350+
public void isAutoUpright_frontCameraAutoUprightDict_trueAndCorrectCameraId() {
351+
// Verify that auto_upright and camera_id are independent fields.
352+
FeedRequest req = new FeedRequest(FeedRequestFixtures.frontCameraAutoUprightDict());
353+
assertTrue(req.isAutoUpright());
354+
assertEquals("1", req.getCameraId());
355+
}
356+
357+
@Test
358+
public void isAutoUpright_explicitFalseInDict_returnsFalse() {
359+
// Explicit false must not be treated as missing.
360+
Dictionary d = new Dictionary();
361+
d.put("auto_upright", false);
362+
FeedRequest req = new FeedRequest(d);
363+
assertFalse(req.isAutoUpright());
364+
}
365+
366+
@Test
367+
public void isAutoUpright_explicitTrueInDict_returnsTrue() {
368+
Dictionary d = new Dictionary();
369+
d.put("auto_upright", true);
370+
FeedRequest req = new FeedRequest(d);
371+
assertTrue(req.isAutoUpright());
372+
}
373+
374+
@Test
375+
public void isAutoUpright_doesNotAffectRotationGetter() {
376+
// Enabling auto_upright must leave the stored rotation value untouched;
377+
// the plugin (not FeedRequest) decides which rotation to apply at runtime.
378+
FeedRequest req = new FeedRequest(FeedRequestFixtures.autoUprightDict());
379+
assertEquals(90, req.getRotation()); // fullDict rotation is 90
380+
}
381+
321382
// ── type coercion (Long -> int) ───────────────────────────────────────
322383

323384
@Test

0 commit comments

Comments
 (0)