Skip to content

Commit 26530cb

Browse files
authored
fix(scanner): take the luminance fast path for packed camera frames (#1303)
1 parent 60403f7 commit 26530cb

5 files changed

Lines changed: 663 additions & 21 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.getcode.codes.kikcode
2+
3+
/**
4+
* The contract between a camera frame's luminance (Y) plane and the native code scanner.
5+
*
6+
* `kikCodeScan` does `memcpy(greyscale.data, image, height * width)` — it reads exactly
7+
* [scannedByteCount] bytes from the front of whatever buffer it is handed, and assumes those bytes
8+
* are a tightly packed `width * height` greyscale image. Cameras do not always hand us that: rows
9+
* are commonly padded out to a hardware alignment, so a plane's row stride can exceed its width.
10+
*
11+
* Both platforms got this wrong in different directions, which is why the rule lives here:
12+
* - Android guarded the unpadding with `pixelStride != -1`, which is vacuously true for a
13+
* YUV_420_888 Y plane, so it ran an O(width * height) per-pixel copy on *every* frame — ~1.5 ms
14+
* at 1080p — even when the plane was already packed.
15+
* - iOS never unpadded at all, and read its stride with `CVPixelBufferGetBytesPerRow` (which
16+
* reports a whole-buffer value for planar formats) instead of
17+
* `CVPixelBufferGetBytesPerRowOfPlane(_, 0)`. It survives only because the 1080p capture width
18+
* happens to be 64-aligned and therefore unpadded.
19+
*
20+
* ## Why this shares the decision and not the bytes
21+
*
22+
* [unpad] is deliberately *not* part of the iOS-facing surface. Handing a plane across the
23+
* Kotlin/Native bridge would convert `Data` to `ByteArray`, copying the whole ~2 MB frame — far
24+
* worse than the copy this is meant to avoid. Callers ask [isTightlyPacked] whether a copy is
25+
* needed and then move the bytes in their own native code, so the shared piece stays branch-only
26+
* and allocation-free.
27+
*/
28+
object LuminancePlane {
29+
30+
/**
31+
* Whether the plane can be handed to the scanner as-is.
32+
*
33+
* When true the first [scannedByteCount] bytes are already the image the scanner expects, so the
34+
* buffer can be passed through with no copy. When false the caller must repack it — see [unpad]
35+
* for the reference implementation.
36+
*
37+
* A YUV_420_888 Y plane always reports a [pixelStride] of 1 (the format guarantees the Y plane
38+
* is never interleaved), so in practice only [rowStride] decides this; the pixel-stride term is
39+
* kept so the rule stays correct if that guarantee ever loosens.
40+
*/
41+
fun isTightlyPacked(width: Int, rowStride: Int, pixelStride: Int): Boolean =
42+
rowStride == width && pixelStride == 1
43+
44+
/** How many bytes the scanner reads for a `width x height` frame. */
45+
fun scannedByteCount(width: Int, height: Int): Int = width * height
46+
47+
/**
48+
* Repacks a padded or interleaved plane into a tightly packed `width * height` buffer.
49+
*
50+
* Returns [data] untouched when [isTightlyPacked] already holds, so the common case costs
51+
* nothing. Note the returned array may be *longer* than [scannedByteCount] — the scanner only
52+
* reads the front of it.
53+
*
54+
* This is the JVM/Android path. iOS repacks in Swift over the raw plane pointer rather than
55+
* calling this, to keep the frame out of the Kotlin/Native bridge.
56+
*/
57+
fun unpad(
58+
data: ByteArray,
59+
width: Int,
60+
height: Int,
61+
rowStride: Int,
62+
pixelStride: Int,
63+
): ByteArray {
64+
if (isTightlyPacked(width, rowStride, pixelStride)) return data
65+
66+
val cleanData = ByteArray(scannedByteCount(width, height))
67+
for (y in 0 until height) {
68+
for (x in 0 until width) {
69+
cleanData[y * width + x] = data[y * rowStride + x * pixelStride]
70+
}
71+
}
72+
return cleanData
73+
}
74+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package com.getcode.codes.kikcode
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertContentEquals
5+
import kotlin.test.assertEquals
6+
import kotlin.test.assertFalse
7+
import kotlin.test.assertSame
8+
import kotlin.test.assertTrue
9+
10+
/**
11+
* Covers the packing rule every analyzed camera frame goes through, on both platforms.
12+
*
13+
* Android's guard used to read `pixelStride != -1`, which is vacuously true for a YUV_420_888 Y
14+
* plane (whose pixel stride is always 1), so the `||` short-circuited to always-true and the
15+
* per-pixel copy ran on every frame even for tightly packed buffers. iOS had the opposite bug: it
16+
* never unpadded at all. These tests run on both targets so the rule cannot drift again.
17+
*/
18+
class LuminancePlaneTest {
19+
20+
/** Android's pre-fix guard, kept verbatim so we can assert the two agree on output. */
21+
private fun legacyUnpad(
22+
data: ByteArray,
23+
width: Int,
24+
height: Int,
25+
rowStride: Int,
26+
pixelStride: Int,
27+
): ByteArray {
28+
if (width != rowStride || pixelStride != -1) {
29+
val cleanData = ByteArray(width * height)
30+
for (y in 0 until height) {
31+
for (x in 0 until width) {
32+
cleanData[y * width + x] = data[y * rowStride + x * pixelStride]
33+
}
34+
}
35+
return cleanData
36+
}
37+
return data
38+
}
39+
40+
private fun plane(height: Int, rowStride: Int): ByteArray =
41+
ByteArray(rowStride * height) { (it % 251).toByte() }
42+
43+
@Test
44+
fun `a plane is tightly packed only when the row stride matches the width`() {
45+
assertTrue(LuminancePlane.isTightlyPacked(width = 1920, rowStride = 1920, pixelStride = 1))
46+
// 64-byte row alignment, the usual source of padding on both platforms
47+
assertFalse(LuminancePlane.isTightlyPacked(width = 1440, rowStride = 1472, pixelStride = 1))
48+
assertFalse(LuminancePlane.isTightlyPacked(width = 1000, rowStride = 1024, pixelStride = 1))
49+
// interleaved planes are never packed, even when the arithmetic happens to line up
50+
assertFalse(LuminancePlane.isTightlyPacked(width = 64, rowStride = 64, pixelStride = 2))
51+
}
52+
53+
@Test
54+
fun `the scanner reads exactly width times height bytes`() {
55+
assertEquals(1920 * 1080, LuminancePlane.scannedByteCount(1920, 1080))
56+
}
57+
58+
@Test
59+
fun `tightly packed plane takes the fast path and avoids a copy`() {
60+
val width = 640
61+
val height = 480
62+
val data = plane(height, rowStride = width)
63+
64+
val result = LuminancePlane.unpad(data, width, height, rowStride = width, pixelStride = 1)
65+
66+
assertSame(data, result, "tightly packed plane should be returned without copying")
67+
}
68+
69+
@Test
70+
fun `padded plane still strips row padding`() {
71+
val width = 640
72+
val height = 480
73+
val rowStride = 768 // 128 bytes of row padding
74+
val data = plane(height, rowStride)
75+
76+
val result = LuminancePlane.unpad(data, width, height, rowStride, pixelStride = 1)
77+
78+
assertEquals(width * height, result.size)
79+
for (y in 0 until height) {
80+
for (x in 0 until width) {
81+
assertEquals(data[y * rowStride + x], result[y * width + x], "mismatch at ($x,$y)")
82+
}
83+
}
84+
}
85+
86+
@Test
87+
fun `interleaved plane still honours pixel stride`() {
88+
val width = 32
89+
val height = 16
90+
val pixelStride = 2
91+
val rowStride = width * pixelStride
92+
val data = plane(height, rowStride)
93+
94+
val result = LuminancePlane.unpad(data, width, height, rowStride, pixelStride)
95+
96+
assertEquals(width * height, result.size)
97+
for (y in 0 until height) {
98+
for (x in 0 until width) {
99+
assertEquals(data[y * rowStride + x * pixelStride], result[y * width + x])
100+
}
101+
}
102+
}
103+
104+
/**
105+
* The Android fix is a pure speedup: for every plane geometry the camera can hand us, the bytes
106+
* the scanner sees must be byte-identical to what the old code produced.
107+
*/
108+
@Test
109+
fun `output is byte-identical to the pre-fix implementation`() {
110+
val geometries = listOf(
111+
Triple(640, 480, 640),
112+
Triple(640, 480, 768),
113+
Triple(1280, 720, 1280),
114+
Triple(1280, 720, 1408),
115+
Triple(1920, 1080, 1920),
116+
Triple(1920, 1080, 2048),
117+
)
118+
119+
for ((width, height, rowStride) in geometries) {
120+
val data = plane(height, rowStride)
121+
val fixed = LuminancePlane.unpad(data, width, height, rowStride, pixelStride = 1)
122+
val legacy = legacyUnpad(data, width, height, rowStride, pixelStride = 1)
123+
124+
// The fast path hands back the backing array, which is longer than width*height when
125+
// the buffer is over-allocated; the scanner only reads the first width*height.
126+
val scanned = LuminancePlane.scannedByteCount(width, height)
127+
assertTrue(fixed.size >= scanned, "${width}x$height/$rowStride too small")
128+
assertContentEquals(
129+
legacy.copyOf(scanned),
130+
fixed.copyOf(scanned),
131+
"content drift at ${width}x$height rowStride=$rowStride",
132+
)
133+
}
134+
}
135+
}

‎vendor/kik/scanner/build.gradle.kts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,10 @@ dependencies {
3636
api(project(":libs:codes:kikcode"))
3737
implementation(project(":libs:encryption:ed25519"))
3838
implementation(project(":vendor:opencv:sdk"))
39+
40+
androidTestImplementation(libs.junit)
41+
androidTestImplementation(libs.androidx.junit)
42+
androidTestImplementation(libs.androidx.test.runner)
43+
androidTestImplementation(libs.kotlin.test.junit)
44+
androidTestImplementation(libs.kotlinx.coroutines.core)
3945
}

0 commit comments

Comments
 (0)