Skip to content

Commit eff0329

Browse files
authored
Fix camera frame conversion and native preview sizing (#5955)
* Fix camera frame conversion and native preview layout * Add required license headers to camera regression fixtures
1 parent b499a12 commit eff0329

20 files changed

Lines changed: 994 additions & 41 deletions

File tree

‎CodenameOne/src/com/codename1/ai/vision/VisionCameraView.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ public void start() {
230230
"No camera is available on this device");
231231
}
232232
opened = Camera.open(info, new CameraSessionOptions()
233-
.frameFormat(FrameFormat.JPEG)
233+
.frameFormat(FrameFormat.NV21)
234234
.frameMaxFps(maxFps)
235235
.captureAudio(false));
236236
} catch (Throwable error) {

‎CodenameOne/src/com/codename1/camera/CameraFrame.java‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,39 @@
2929
/// framework may reuse or release the underlying buffers. Clone any data you
3030
/// need to keep.
3131
public final class CameraFrame {
32-
private final byte[] jpegBytes;
32+
private byte[] jpegBytes;
33+
private JpegEncoder jpegEncoder;
3334
private final byte[] rawBytes;
3435
private final int width;
3536
private final int height;
3637
private final int rotationDegrees;
3738
private final long timestampNanos;
3839
private final FrameFormat format;
3940

41+
/// Encodes a frame on demand. Implementations must own the input bytes;
42+
/// they must not retain a native image that is closed after frame delivery.
43+
public interface JpegEncoder {
44+
byte[] encode();
45+
}
46+
4047
/// Used by platform implementations.
4148
public CameraFrame(byte[] jpegBytes, byte[] rawBytes,
4249
int width, int height,
4350
int rotationDegrees, long timestampNanos,
4451
FrameFormat format) {
52+
this(jpegBytes, rawBytes, width, height, rotationDegrees,
53+
timestampNanos, format, null);
54+
}
55+
56+
/// Used by ports that can supply raw frames without eagerly encoding JPEG.
57+
/// The encoder is invoked at most once after a successful encoding, when
58+
/// `#getJpegBytes()` is first called. Existing eager callers need no encoder.
59+
public CameraFrame(byte[] jpegBytes, byte[] rawBytes,
60+
int width, int height, int rotationDegrees,
61+
long timestampNanos, FrameFormat format,
62+
JpegEncoder jpegEncoder) {
4563
this.jpegBytes = jpegBytes;
64+
this.jpegEncoder = jpegEncoder;
4665
this.rawBytes = rawBytes;
4766
this.width = width;
4867
this.height = height;
@@ -54,8 +73,17 @@ public CameraFrame(byte[] jpegBytes, byte[] rawBytes,
5473
/// JPEG-encoded bytes for this frame. Always non-null regardless of the
5574
/// requested `FrameFormat`; use this when encoded image data is needed.
5675
/// `VisionImage#fromCameraFrame(CameraFrame)` instead selects the raw
57-
/// buffer for NV21 and RGBA8888 frames.
58-
public byte[] getJpegBytes() {
76+
/// buffer for NV21 and RGBA8888 frames. A raw frame may encode its JPEG
77+
/// lazily on the first call; subsequent calls return the cached bytes.
78+
public synchronized byte[] getJpegBytes() {
79+
if (jpegBytes == null && jpegEncoder != null) {
80+
byte[] encoded = jpegEncoder.encode();
81+
if (encoded == null) {
82+
throw new IllegalStateException("Camera frame JPEG encoding failed");
83+
}
84+
jpegBytes = encoded;
85+
jpegEncoder = null;
86+
}
5987
return jpegBytes;
6088
}
6189

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
* This code is free software; you can redistribute it and/or modify it
5+
* under the terms of the GNU General Public License version 2 only, as
6+
* published by the Free Software Foundation. Codename One designates this
7+
* particular file as subject to the "Classpath" exception as provided
8+
* by Oracle in the LICENSE file that accompanied this code.
9+
*
10+
* This code is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13+
* version 2 for more details (a copy is included in the LICENSE file that
14+
* accompanied this code).
15+
*
16+
* You should have received a copy of the GNU General Public License version
17+
* 2 along with this work; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
19+
*
20+
* Please contact Codename One through http://www.codenameone.com/ if you
21+
* need additional information or have any questions.
22+
*/
23+
package com.codename1.impl.android;
24+
25+
import java.nio.ByteBuffer;
26+
27+
/** Packs camera YUV_420_888 planes without assuming a device-specific layout. */
28+
final class AndroidCameraFrameConverter {
29+
private AndroidCameraFrameConverter() {
30+
}
31+
32+
static byte[] toNV21(int width, int height,
33+
ByteBuffer y, int yRowStride, int yPixelStride,
34+
ByteBuffer u, int uRowStride, int uPixelStride,
35+
ByteBuffer v, int vRowStride, int vPixelStride) {
36+
long pixels = (long) width * height;
37+
if (width <= 0 || height <= 0 || (width & 1) != 0 || (height & 1) != 0
38+
|| pixels + pixels / 2 > Integer.MAX_VALUE) {
39+
throw new IllegalArgumentException("Invalid YUV dimensions");
40+
}
41+
byte[] result = new byte[(int) (pixels + pixels / 2)];
42+
copyPlane(y, yRowStride, yPixelStride, width, height, result, 0, 1);
43+
// NV21 interleaves V then U. Planes can be planar, overlapping, padded,
44+
// sliced, or read-only; absolute reads preserve their positions.
45+
copyPlane(v, vRowStride, vPixelStride, width / 2, height / 2,
46+
result, (int) pixels, 2);
47+
copyPlane(u, uRowStride, uPixelStride, width / 2, height / 2,
48+
result, (int) pixels + 1, 2);
49+
return result;
50+
}
51+
52+
private static void copyPlane(ByteBuffer plane, int rowStride, int pixelStride,
53+
int width, int height, byte[] out,
54+
int offset, int step) {
55+
long rowBytes = (long) (width - 1) * pixelStride + 1;
56+
long end = (long) plane.position() + (long) (height - 1) * rowStride + rowBytes;
57+
if (rowStride <= 0 || pixelStride <= 0 || rowStride < rowBytes
58+
|| end > plane.limit()) {
59+
throw new IllegalArgumentException("Truncated or invalid YUV plane");
60+
}
61+
int base = plane.position();
62+
for (int row = 0; row < height; row++) {
63+
int input = base + row * rowStride;
64+
for (int col = 0; col < width; col++) {
65+
out[offset] = plane.get(input + col * pixelStride);
66+
offset += step;
67+
}
68+
}
69+
}
70+
}

‎Ports/Android/src/com/codename1/impl/android/AndroidCameraImpl.java‎

Lines changed: 45 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ public class AndroidCameraImpl extends CameraImpl {
120120
private final AtomicBoolean listenerBusy = new AtomicBoolean();
121121
private volatile FrameListener frameListener;
122122
private volatile FrameFormat frameFormat = FrameFormat.JPEG;
123+
private volatile long frameIntervalNanos;
124+
private long lastFrameNanos;
123125
private CameraInfo info;
124126
private CameraSessionOptions options;
125127
private ExecutorService cameraExecutor;
@@ -422,8 +424,9 @@ public void stopVideoRecording(AsyncResource<String> result) {
422424

423425
@Override
424426
public void setFrameListener(FrameListener listener, FrameFormat format, int maxFps) {
425-
this.frameListener = listener;
426427
this.frameFormat = format == null ? FrameFormat.JPEG : format;
428+
this.frameIntervalNanos = maxFps > 0 ? 1000000000L / maxFps : 0;
429+
this.frameListener = listener;
427430
}
428431

429432
private void installFrameAnalyzer() {
@@ -459,25 +462,23 @@ private void onImageProxy(Object imageProxy) {
459462
return;
460463
}
461464
try {
462-
// Convert ImageProxy -> Image -> JPEG bytes
463-
byte[] jpeg = null;
464-
int w = 0, h = 0;
465-
int rotation = 0;
465+
long now = System.nanoTime();
466+
if (lastFrameNanos != 0 && now - lastFrameNanos < frameIntervalNanos) {
467+
return;
468+
}
469+
CameraFrame frame;
466470
try {
467471
Image img = (Image) imageProxy.getClass().getMethod("getImage").invoke(imageProxy);
468472
Object imageInfo = imageProxy.getClass().getMethod("getImageInfo").invoke(imageProxy);
469-
rotation = (Integer) imageInfo.getClass().getMethod("getRotationDegrees").invoke(imageInfo);
470-
if (img != null) {
471-
w = img.getWidth();
472-
h = img.getHeight();
473-
jpeg = yuvImageToJpeg(img);
474-
}
473+
int rotation = (Integer) imageInfo.getClass().getMethod("getRotationDegrees").invoke(imageInfo);
474+
frame = img == null ? null : createFrame(img, rotation, now, frameFormat);
475475
} catch (Throwable t) {
476476
Log.w(TAG, "Frame conversion failed: " + t.getMessage());
477+
return;
477478
}
478-
if (jpeg != null) {
479-
l.onFrame(new CameraFrame(jpeg, null, w, h, rotation,
480-
System.nanoTime(), frameFormat));
479+
if (frame != null) {
480+
lastFrameNanos = now;
481+
l.onFrame(frame);
481482
}
482483
} finally {
483484
listenerBusy.set(false);
@@ -491,31 +492,39 @@ private static void closeImageProxy(Object imageProxy) {
491492
} catch (Throwable ignored) { }
492493
}
493494

494-
private static byte[] yuvImageToJpeg(Image image) {
495+
private static CameraFrame createFrame(Image image, int rotation, long timestamp,
496+
FrameFormat requested) {
497+
final int w = image.getWidth();
498+
final int h = image.getHeight();
495499
if (image.getFormat() == ImageFormat.JPEG) {
496-
ByteBuffer buf = image.getPlanes()[0].getBuffer();
497-
byte[] out = new byte[buf.remaining()];
498-
buf.get(out);
499-
return out;
500+
ByteBuffer buf = image.getPlanes()[0].getBuffer().duplicate();
501+
byte[] jpeg = new byte[buf.remaining()];
502+
buf.get(jpeg);
503+
return new CameraFrame(jpeg, null, w, h, rotation, timestamp, requested);
504+
}
505+
if (image.getFormat() != ImageFormat.YUV_420_888) {
506+
throw new IllegalArgumentException("Unsupported camera image format");
500507
}
501-
// YUV_420_888 -> NV21 -> JPEG via YuvImage
502-
int w = image.getWidth();
503-
int h = image.getHeight();
504508
Image.Plane[] planes = image.getPlanes();
505-
ByteBuffer y = planes[0].getBuffer();
506-
ByteBuffer u = planes[1].getBuffer();
507-
ByteBuffer v = planes[2].getBuffer();
508-
int ySize = y.remaining();
509-
int uSize = u.remaining();
510-
int vSize = v.remaining();
511-
byte[] nv21 = new byte[ySize + uSize + vSize];
512-
y.get(nv21, 0, ySize);
513-
v.get(nv21, ySize, vSize);
514-
u.get(nv21, ySize + vSize, uSize);
515-
YuvImage yuv = new YuvImage(nv21, ImageFormat.NV21, w, h, null);
516-
ByteArrayOutputStream baos = new ByteArrayOutputStream();
517-
yuv.compressToJpeg(new Rect(0, 0, w, h), 80, baos);
518-
return baos.toByteArray();
509+
final byte[] nv21 = AndroidCameraFrameConverter.toNV21(w, h,
510+
planes[0].getBuffer(), planes[0].getRowStride(), planes[0].getPixelStride(),
511+
planes[1].getBuffer(), planes[1].getRowStride(), planes[1].getPixelStride(),
512+
planes[2].getBuffer(), planes[2].getRowStride(), planes[2].getPixelStride());
513+
CameraFrame.JpegEncoder encoder = new CameraFrame.JpegEncoder() {
514+
public byte[] encode() {
515+
YuvImage yuv = new YuvImage(nv21, ImageFormat.NV21, w, h, null);
516+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
517+
if (!yuv.compressToJpeg(new Rect(0, 0, w, h), 80, baos)) {
518+
throw new IllegalStateException("Could not encode camera frame");
519+
}
520+
return baos.toByteArray();
521+
}
522+
};
523+
if (requested == FrameFormat.NV21) {
524+
return new CameraFrame(null, nv21, w, h, rotation, timestamp, requested, encoder);
525+
}
526+
// JPEG remains the fallback for raw formats this port cannot expose.
527+
return new CameraFrame(encoder.encode(), null, w, h, rotation, timestamp, requested);
519528
}
520529

521530
@Override

‎Ports/iOSPort/nativeSources/CN1Camera.m‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,42 @@
2727
#if defined(INCLUDE_CN1_CAMERA) && !TARGET_OS_TV
2828
#import "java_lang_String.h"
2929
#import "com_codename1_impl_ios_IOSCameraImpl.h"
30+
#import <QuartzCore/QuartzCore.h>
3031

3132
// Defined in IOSNative.m; converts an NSData into a Java byte[]. It pulls the
3233
// thread state via getThreadLocalData() internally, so it takes no thread arg.
3334
extern JAVA_OBJECT nsDataToByteArr(NSData *data);
3435

36+
// The Java peer resizes its native view after creation and on every layout.
37+
// CALayer sublayers do not automatically follow that view's bounds on iOS.
38+
@interface CN1CameraPreviewView : CN1View
39+
@end
40+
41+
@implementation CN1CameraPreviewView
42+
- (void)layoutCameraLayer {
43+
[CATransaction begin];
44+
[CATransaction setDisableActions:YES];
45+
for (CALayer *layer in self.layer.sublayers) {
46+
if ([layer isKindOfClass:[AVCaptureVideoPreviewLayer class]]) {
47+
layer.frame = self.bounds;
48+
}
49+
}
50+
[CATransaction commit];
51+
}
52+
53+
#if TARGET_OS_OSX
54+
- (void)layout {
55+
[super layout];
56+
[self layoutCameraLayer];
57+
}
58+
#else
59+
- (void)layoutSubviews {
60+
[super layoutSubviews];
61+
[self layoutCameraLayer];
62+
}
63+
#endif
64+
@end
65+
3566
@interface CN1Camera ()
3667
@property (nonatomic, copy) NSString *pendingPhotoFilePath;
3768
@property (nonatomic, assign) int pendingPhotoCallbackId;
@@ -196,7 +227,7 @@ - (BOOL)openWithCameraId:(NSString *)cameraId
196227

197228
- (CN1View *)createPreviewView {
198229
if (self.previewView) return self.previewView;
199-
CN1View *v = [[CN1View alloc] initWithFrame:CGRectMake(0, 0, 320, 480)];
230+
CN1View *v = [[CN1CameraPreviewView alloc] initWithFrame:CGRectMake(0, 0, 320, 480)];
200231
#ifndef CN1_USE_ARC
201232
// Autoreleased so the previewView property below is the only owner. The
202233
// alloc's +1 was never balanced here either: this method does not begin

‎maven/core-unittests/src/test/java/com/codename1/ai/vision/VisionCameraViewTest.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,22 @@ void theScannerNeverAsksForTheMicrophone() {
103103
assertFalse(camera.captureAudio);
104104
}
105105

106+
@Test
107+
void liveAnalysisRequestsRawFramesWithoutForcingJpegEncoding() {
108+
view = new VisionCameraView<Barcode[]>(new BarcodeScanner());
109+
view.start();
110+
assertEquals(com.codename1.camera.FrameFormat.NV21,
111+
view.getSession().getOptions().getFrameFormat());
112+
final int[] encodings = {0};
113+
byte[] raw = new byte[6];
114+
camera.frameListener.onFrame(new com.codename1.camera.CameraFrame(
115+
null, raw, 2, 2, 90, 1, com.codename1.camera.FrameFormat.NV21,
116+
() -> { encodings[0]++; return new byte[] {1}; }));
117+
flushSerialCalls();
118+
assertEquals(1, vision.analyzeCount);
119+
assertEquals(0, encodings[0]);
120+
}
121+
106122
@Test
107123
void facingSelectsTheCamera() {
108124
view = new VisionCameraView<Barcode[]>(new BarcodeScanner());

0 commit comments

Comments
 (0)