Skip to content

Commit efbd16c

Browse files
committed
test(scanner): measure scan range and guard the analysis resolution
The resolution bug was invisible by inspection -- the only symptom was that scanning felt short-ranged -- so pin it with a test that asserts on what the camera delivers rather than on what the code asks for. AnalysisResolutionTest binds the same use cases CodeScanner binds and requires a full-width 16:9 stream with a short side of at least 1080; both halves matter, since the short side sets how small a code can be and the aspect ratio decides how much of the lens is in the frame at all. It also records what the alternatives negotiate, so the choice can be re-checked on other hardware rather than taken on faith. KikCodeRangeTest answers the question that motivated the change: how small a code can get before the detector stops seeing it. It bisects the floor at each candidate resolution and converts the result to angular size and range, which is what makes "raise the resolution" visibly the wrong fix -- the pixel floor climbs almost as fast as the frame, so the gain above 1080p is nil. A throughput pass alongside it bounds the cost: worst case 11.5ms a frame against a 30fps camera. Both are measurements on synthetic, ideal frames -- perfect focus, no motion blur, no sensor noise -- taken on a Solana Seeker. The ratios between resolutions are the trustworthy part; real-world range is strictly worse than the absolute numbers, and another camera may negotiate differently.
1 parent 9466618 commit efbd16c

4 files changed

Lines changed: 582 additions & 0 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,7 @@ dependencies {
4242
androidTestImplementation(libs.androidx.test.runner)
4343
androidTestImplementation(libs.kotlin.test.junit)
4444
androidTestImplementation(libs.kotlinx.coroutines.core)
45+
androidTestImplementation(libs.androidx.camerax.camera2)
46+
androidTestImplementation(libs.androidx.camerax.lifecycle)
47+
androidTestImplementation(libs.testing.androidx.test.rules)
4548
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+
<!--
5+
The range harness binds the same CameraX use cases the scanner does, to record which
6+
resolution the device actually negotiates rather than which one the app asks for.
7+
-->
8+
<uses-permission android:name="android.permission.CAMERA" />
9+
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
10+
11+
</manifest>
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package com.kik.scan
2+
3+
import android.Manifest
4+
import android.util.Log
5+
import android.util.Size
6+
import android.view.Surface
7+
import androidx.camera.core.CameraSelector
8+
import androidx.camera.core.ImageAnalysis
9+
import androidx.camera.core.Preview
10+
import androidx.camera.core.resolutionselector.AspectRatioStrategy
11+
import androidx.camera.core.resolutionselector.ResolutionSelector
12+
import androidx.camera.core.resolutionselector.ResolutionStrategy
13+
import androidx.camera.lifecycle.ProcessCameraProvider
14+
import androidx.lifecycle.Lifecycle
15+
import androidx.lifecycle.LifecycleOwner
16+
import androidx.lifecycle.LifecycleRegistry
17+
import androidx.test.ext.junit.runners.AndroidJUnit4
18+
import androidx.test.platform.app.InstrumentationRegistry
19+
import androidx.test.rule.GrantPermissionRule
20+
import org.junit.Rule
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import java.util.concurrent.CountDownLatch
24+
import java.util.concurrent.Executors
25+
import java.util.concurrent.TimeUnit
26+
import kotlin.math.sqrt
27+
import kotlin.test.assertNotNull
28+
import kotlin.test.assertTrue
29+
30+
/**
31+
* Which analysis resolution the device actually hands the scanner.
32+
*
33+
* `CodeScanner` asks for 1920x1080 through the deprecated `setTargetResolution`, which is a
34+
* *request*: CameraX resolves it against the supported sizes, the target rotation, and the
35+
* surface-combination limits of whatever else is bound. What comes back decides scan range, because
36+
* the native detector's minimum feature sizes are all normalised to `MIN(rows, cols) / 480` -- so
37+
* asking for 1080p and getting less tightens every threshold in angular terms.
38+
*
39+
* This binds the same use cases the app binds and records the negotiated size rather than the
40+
* requested one, then does the same for the candidate replacements so the fix can be chosen from
41+
* measurements instead of from the documentation.
42+
*/
43+
@RunWith(AndroidJUnit4::class)
44+
class AnalysisResolutionTest {
45+
46+
@get:Rule
47+
val cameraPermission: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.CAMERA)
48+
49+
/** Minimal lifecycle owner parked in RESUMED, so `bindToLifecycle` opens the camera. */
50+
private class TestLifecycleOwner : LifecycleOwner {
51+
private val registry = LifecycleRegistry(this)
52+
override val lifecycle: Lifecycle get() = registry
53+
fun resume() = registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
54+
fun destroy() = registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
55+
}
56+
57+
private val selector = CameraSelector.Builder()
58+
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
59+
.build()
60+
61+
/**
62+
* Binds [analysis] alongside a preview, waits for a real frame, and logs what arrived.
63+
* Returns the size the camera actually delivered, or null if no frame arrived in time.
64+
*/
65+
private fun measure(label: String, analysis: ImageAnalysis): Size? {
66+
val context = InstrumentationRegistry.getInstrumentation().targetContext
67+
val provider = ProcessCameraProvider.getInstance(context).get(10, TimeUnit.SECONDS)
68+
val preview = Preview.Builder().build()
69+
val owner = TestLifecycleOwner()
70+
val instrumentation = InstrumentationRegistry.getInstrumentation()
71+
val executor = Executors.newSingleThreadExecutor()
72+
73+
instrumentation.runOnMainSync {
74+
provider.unbindAll()
75+
provider.bindToLifecycle(owner, selector, preview, analysis)
76+
owner.resume()
77+
}
78+
79+
try {
80+
val frame = CountDownLatch(1)
81+
var frameSize: Size? = null
82+
var rowStride = -1
83+
var pixelStride = -1
84+
analysis.setAnalyzer(executor) { image ->
85+
if (frameSize == null) {
86+
frameSize = Size(image.width, image.height)
87+
rowStride = image.planes[0].rowStride
88+
pixelStride = image.planes[0].pixelStride
89+
frame.countDown()
90+
}
91+
image.close()
92+
}
93+
val gotFrame = frame.await(15, TimeUnit.SECONDS)
94+
val size = frameSize ?: analysis.resolutionInfo?.resolution
95+
96+
val detail = if (size != null) {
97+
val shortSide = minOf(size.width, size.height)
98+
val scalingRate = shortSide / 480.0
99+
val minArea = 220 * scalingRate
100+
val discPx = 2 * sqrt(minArea / Math.PI)
101+
// The centre disc is INNER_RING_RATIO (0.32) of the whole code graphic.
102+
val codePx = discPx / 0.32
103+
" scaling_rate=${"%.2f".format(scalingRate)}" +
104+
" minCentreDisc=${"%.1f".format(discPx)}px" +
105+
" impliedMinCode=${"%.0f".format(codePx)}px" +
106+
" (${"%.1f".format(codePx / size.width.toDouble() * 100)}% of frame width)"
107+
} else {
108+
" <no frame>"
109+
}
110+
111+
Log.i(
112+
TAG,
113+
"$label -> negotiated=${analysis.resolutionInfo?.resolution} frame=$size " +
114+
"rowStride=$rowStride pixelStride=$pixelStride packed=${rowStride == size?.width} " +
115+
"gotFrame=$gotFrame preview=${preview.resolutionInfo?.resolution}$detail",
116+
)
117+
return size
118+
} finally {
119+
instrumentation.runOnMainSync {
120+
analysis.clearAnalyzer()
121+
provider.unbindAll()
122+
owner.destroy()
123+
}
124+
executor.shutdown()
125+
}
126+
}
127+
128+
/** The analysis configuration `CodeScanner` builds. Kept in step with it by hand. */
129+
private fun shippingAnalysis(): ImageAnalysis {
130+
val resolution = ResolutionSelector.Builder()
131+
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
132+
.setResolutionStrategy(
133+
ResolutionStrategy(
134+
Size(1920, 1080),
135+
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER,
136+
)
137+
)
138+
.build()
139+
140+
return ImageAnalysis.Builder()
141+
.setResolutionSelector(resolution)
142+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
143+
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
144+
.build()
145+
}
146+
147+
/**
148+
* The scanner must be handed a full-width 16:9 stream, and this is the only place that is
149+
* checked.
150+
*
151+
* The bug this guards against is invisible by inspection: the code asked for 1920x1080 for
152+
* years and got a 720x720 square, because `setTargetResolution` is a hint the camera is free to
153+
* resolve however it likes. Nothing in the source read wrong -- the request and the reality
154+
* simply differed, and the only symptom was that scanning felt short-ranged.
155+
*
156+
* Both halves of the assertion matter. The short side sets how small a code can be and still
157+
* clear the detector's thresholds; the aspect ratio decides how much of the lens's field of
158+
* view is in the frame at all, since a square crop off a 4:3 sensor silently discards a quarter
159+
* of the width.
160+
*/
161+
@Test
162+
fun shippingConfigurationGetsFullWidthSixteenByNine() {
163+
val negotiated = measure("shipping (ResolutionSelector 16:9 1080p)", shippingAnalysis())
164+
165+
assertNotNull(negotiated, "camera delivered no frame")
166+
val shortSide = minOf(negotiated.width, negotiated.height)
167+
val aspect = maxOf(negotiated.width, negotiated.height).toDouble() / shortSide
168+
169+
assertTrue(
170+
shortSide >= 1080,
171+
"analysis stream is $negotiated -- short side $shortSide is below 1080, which raises " +
172+
"the smallest decodable code and costs scan range",
173+
)
174+
assertTrue(
175+
aspect >= 1.6,
176+
"analysis stream is $negotiated -- aspect ratio ${"%.2f".format(aspect)} is narrower " +
177+
"than 16:9, so the frame is cropped in from the sensor's full width and the " +
178+
"scanner cannot see the edges of the lens's field of view",
179+
)
180+
}
181+
182+
/**
183+
* Not a guard -- a record of what the alternatives negotiate on this device, so the choice
184+
* above can be re-checked rather than taken on faith. Logs only; a device that resolves these
185+
* differently is informative, not broken.
186+
*/
187+
@Test
188+
fun alternativeConfigurationsForComparison() {
189+
measure(
190+
"deprecated setTargetResolution(1920x1080)",
191+
ImageAnalysis.Builder()
192+
.setTargetResolution(Size(1920, 1080))
193+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
194+
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
195+
.build(),
196+
)
197+
198+
// The same request with the rotation pinned, to rule out the target-rotation frame as the
199+
// reason the deprecated path lands where it does.
200+
measure(
201+
"deprecated setTargetResolution(1920x1080)+ROTATION_0",
202+
ImageAnalysis.Builder()
203+
.setTargetResolution(Size(1920, 1080))
204+
.setTargetRotation(Surface.ROTATION_0)
205+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
206+
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
207+
.build(),
208+
)
209+
210+
// Above the chosen resolution. Reachable on this device, but the range sweep in
211+
// KikCodeRangeTest shows it decodes no smaller an angular target while costing four times
212+
// the per-frame work.
213+
val fourK = ResolutionSelector.Builder()
214+
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
215+
.setResolutionStrategy(
216+
ResolutionStrategy(
217+
Size(3840, 2160),
218+
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER,
219+
)
220+
)
221+
.setAllowedResolutionMode(ResolutionSelector.PREFER_HIGHER_RESOLUTION_OVER_CAPTURE_RATE)
222+
.build()
223+
measure(
224+
"ResolutionSelector 16:9 2160p",
225+
ImageAnalysis.Builder()
226+
.setResolutionSelector(fourK)
227+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
228+
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
229+
.build(),
230+
)
231+
}
232+
233+
private companion object {
234+
const val TAG = "KikCodeRange"
235+
}
236+
}

0 commit comments

Comments
 (0)