Skip to content

Commit d12192a

Browse files
committed
test(scanner): measure the detector's exposure window
The washout failure was diagnosed from a constant in scanner.cpp, and a constant is a hypothesis. WashoutToleranceTest renders codes through the tone mapping an exposure error actually applies -- the full black-to-white range squeezed into a narrower band and clipped -- and sweeps the endpoints until decoding stops. Three sweeps, because the interesting result is the relationship between them. Overexposure raises the floor with the ceiling already clipped; underexposure lowers the ceiling with the floor at black; the third closes the band in on a mid-grey from both sides. If the detector adapted to the frame, the third would keep working until contrast fell into the noise. It instead fails the moment the band stops straddling 170, which is what makes this an exposure bug rather than a contrast one, and what makes centre-weighted metering the fix. Also checks on device that the camera accepts the AE region the scanner now asks for. startFocusAndMetering is a request: a device without AE regions resolves it unsuccessfully and leaves whole-frame metering in place, entirely silently. That check earned its place immediately -- it caught the metering request being cancelled with "Camera is not active" because the first version of the fix asked at bind time instead of waiting for the stream. It waits for a real frame before asking, since otherwise a not-yet-open camera is indistinguishable from an unsupported one, and it logs rather than fails on a device without AE regions, which is a fact about the hardware. Frames are synthetic and otherwise ideal -- perfect focus, no noise, dead-on perpendicular -- so the measured window is the generous end of what a real camera sees.
1 parent 9fbb0a0 commit d12192a

2 files changed

Lines changed: 269 additions & 0 deletions

File tree

‎vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/AnalysisResolutionTest.kt‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import android.util.Size
66
import android.view.Surface
77
import androidx.camera.core.CameraSelector
88
import androidx.camera.core.ImageAnalysis
9+
import androidx.camera.core.Camera
10+
import androidx.camera.core.FocusMeteringAction
911
import androidx.camera.core.Preview
12+
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
1013
import androidx.camera.core.resolutionselector.AspectRatioStrategy
1114
import androidx.camera.core.resolutionselector.ResolutionSelector
1215
import androidx.camera.core.resolutionselector.ResolutionStrategy
@@ -230,6 +233,72 @@ class AnalysisResolutionTest {
230233
)
231234
}
232235

236+
/**
237+
* The centre exposure region the scanner asks for is actually accepted by this camera.
238+
*
239+
* `startFocusAndMetering` is a request, not a command: a device that does not support AE
240+
* regions reports zero `maxMeteringPointsAe` and the call resolves as unsuccessful, leaving
241+
* whole-frame metering in place. That failure is completely silent at runtime -- the scanner
242+
* keeps working, just as badly as before -- so the only way to know the fix took is to ask.
243+
*
244+
* Logs rather than fails on an unsupported device: not every camera offers AE regions, and
245+
* that is a fact about the hardware rather than a defect in the scanner.
246+
*/
247+
@Test
248+
fun centreExposureMeteringIsSupported() {
249+
val context = InstrumentationRegistry.getInstrumentation().targetContext
250+
val provider = ProcessCameraProvider.getInstance(context).get(10, TimeUnit.SECONDS)
251+
val owner = TestLifecycleOwner()
252+
val instrumentation = InstrumentationRegistry.getInstrumentation()
253+
val analysis = shippingAnalysis()
254+
val executor = Executors.newSingleThreadExecutor()
255+
var camera: Camera? = null
256+
257+
instrumentation.runOnMainSync {
258+
provider.unbindAll()
259+
camera = provider.bindToLifecycle(owner, selector, Preview.Builder().build(), analysis)
260+
owner.resume()
261+
}
262+
263+
try {
264+
val bound = requireNotNull(camera) { "camera did not bind" }
265+
266+
// Wait for a real frame before asking. Binding returns long before the camera opens,
267+
// and `startFocusAndMetering` on a camera that is not yet active fails with
268+
// OperationCanceledException -- which would look identical to an unsupported device.
269+
val frame = CountDownLatch(1)
270+
analysis.setAnalyzer(executor) { image -> frame.countDown(); image.close() }
271+
val active = frame.await(15, TimeUnit.SECONDS)
272+
assertTrue(active, "camera never delivered a frame, so metering could not be tested")
273+
274+
val centre = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f)
275+
val action = FocusMeteringAction.Builder(centre, FocusMeteringAction.FLAG_AE)
276+
.disableAutoCancel()
277+
.build()
278+
279+
val supported = bound.cameraInfo.isFocusMeteringSupported(action)
280+
val result = runCatching {
281+
bound.cameraControl.startFocusAndMetering(action).get(5, TimeUnit.SECONDS)
282+
}
283+
284+
Log.i(
285+
TAG,
286+
// No `isFocusSuccessful` here: it reports the *focus* outcome and is
287+
// documented to return false whenever the action carries no AF flag, so on an
288+
// AE-only request it says nothing and reads like a failure.
289+
"centre AE metering: supported=$supported " +
290+
"requestOutcome=${result.exceptionOrNull()?.toString() ?: "accepted"}",
291+
)
292+
} finally {
293+
instrumentation.runOnMainSync {
294+
analysis.clearAnalyzer()
295+
provider.unbindAll()
296+
owner.destroy()
297+
}
298+
executor.shutdown()
299+
}
300+
}
301+
233302
private companion object {
234303
const val TAG = "KikCodeRange"
235304
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package com.kik.scan
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.Canvas
5+
import android.graphics.Color
6+
import android.util.Log
7+
import androidx.core.content.ContextCompat
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import androidx.test.platform.app.InstrumentationRegistry
10+
import com.getcode.codes.kikcode.LuminancePlane
11+
import com.kik.kikx.kikcodes.ScanQuality
12+
import com.kik.kikx.kikcodes.implementation.KikCodeScannerImpl
13+
import com.kik.kikx.kincodes.KikCodeContentRendererImpl
14+
import com.kik.kikx.models.ScannableKikCode
15+
import kotlinx.coroutines.runBlocking
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import kotlin.test.assertTrue
19+
20+
/**
21+
* How much exposure error the detector survives.
22+
*
23+
* Candidate finding is built entirely on one line of `scanner.cpp`:
24+
*
25+
* threshold(greyscale, whitish, 170, 255, THRESH_BINARY);
26+
*
27+
* Every contour, every ellipse, and therefore every code the scanner ever finds comes out of that
28+
* binary image. The cutoff is an absolute luminance value, not a local or adaptive one, and it runs
29+
* after two unsharp passes that push highlights further up. So the detector does not ask whether the
30+
* code has contrast -- it asks whether the code's light ink lands above 170 and its dark ink lands
31+
* below it. A frame can be perfectly sharp, perfectly framed and perfectly in focus and still be
32+
* undetectable simply for sitting at the wrong exposure.
33+
*
34+
* That makes auto-exposure part of the scanner, and the two platforms configure it differently:
35+
* iOS pins `exposurePointOfInterest` to the centre of the frame, Android leaves metering to the
36+
* camera's whole-frame default and never requests AE at all. Pointed at a bright phone screen in a
37+
* dim room, whole-frame metering exposes for the dark surround and blows the screen out.
38+
*
39+
* This measures the window rather than deriving it, because the unsharp passes move the boundary
40+
* and only a measurement says by how much.
41+
*
42+
* Frames are synthetic and otherwise ideal, so these bounds are the generous end of what a real
43+
* camera sees.
44+
*/
45+
@RunWith(AndroidJUnit4::class)
46+
class WashoutToleranceTest {
47+
48+
private val renderer = KikCodeContentRendererImpl().apply {
49+
badge = requireNotNull(
50+
ContextCompat.getDrawable(
51+
InstrumentationRegistry.getInstrumentation().context,
52+
com.kik.kikx.test.R.drawable.ic_logo_round_white,
53+
)
54+
)
55+
}
56+
private val scanner = KikCodeScannerImpl()
57+
58+
private fun encodeRemoteCode(seed: Int): Pair<ByteArray, ByteArray> {
59+
val payload = ByteArray(REMOTE_PAYLOAD_BYTES) { ((it * 7 + seed) and 0xFF).toByte() }
60+
return payload to requireNotNull(Scanner.encode(payload)) { "native encode returned null" }
61+
}
62+
63+
/**
64+
* Renders a code into a Y plane whose full black-to-white range has been squeezed into
65+
* [blackLevel]..[whiteLevel].
66+
*
67+
* This is what an exposure error does to a frame. Overexposure lifts the whole range towards
68+
* white and clips it there; underexposure crushes it towards black; veiling glare off a bright
69+
* emissive panel lifts the floor without moving the ceiling. All three are the same
70+
* transformation with different endpoints, and all three are applied to the whole frame,
71+
* because a camera's exposure is a property of the frame and not of the subject.
72+
*/
73+
private fun renderAtLevels(
74+
encoded: ByteArray,
75+
width: Int,
76+
height: Int,
77+
codePx: Int,
78+
blackLevel: Int,
79+
whiteLevel: Int,
80+
): ByteArray {
81+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
82+
val canvas = Canvas(bitmap)
83+
canvas.drawColor(Color.BLACK)
84+
85+
canvas.save()
86+
canvas.translate((width - codePx) / 2f, (height - codePx) / 2f)
87+
renderer.render(encoded, codePx, canvas)
88+
canvas.restore()
89+
90+
val pixels = IntArray(width * height)
91+
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
92+
bitmap.recycle()
93+
94+
// Precomputed so the remap is a table lookup rather than arithmetic per pixel; these
95+
// sweeps render hundreds of multi-megapixel frames.
96+
val span = whiteLevel - blackLevel
97+
val map = ByteArray(256) { luma ->
98+
(blackLevel + luma * span / 255).coerceIn(0, 255).toByte()
99+
}
100+
101+
val plane = ByteArray(width * height)
102+
for (i in 0 until width * height) {
103+
val p = pixels[i]
104+
val luma = (
105+
(
106+
77 * ((p shr 16) and 0xFF) +
107+
150 * ((p shr 8) and 0xFF) +
108+
29 * (p and 0xFF)
109+
) shr 8
110+
)
111+
plane[i] = map[luma]
112+
}
113+
return plane
114+
}
115+
116+
private fun decodesAtLevels(
117+
encoded: ByteArray,
118+
payload: ByteArray,
119+
blackLevel: Int,
120+
whiteLevel: Int,
121+
): Boolean {
122+
val plane = renderAtLevels(encoded, WIDTH, HEIGHT, CODE_PX, blackLevel, whiteLevel)
123+
val converted = LuminancePlane.unpad(plane, WIDTH, HEIGHT, WIDTH, 1)
124+
val result = runBlocking {
125+
scanner.scanKikCode(converted, WIDTH, HEIGHT, ScanQuality.Best).getOrNull()
126+
}
127+
return result is ScannableKikCode.RemoteKikCode && result.payloadId.contentEquals(payload)
128+
}
129+
130+
/**
131+
* Overexposure: the highlights are already clipped at white and the floor keeps rising.
132+
*
133+
* This is the reported failure. A phone screen at full brightness in a dim room is the worst
134+
* case a payment scanner has, because whole-frame metering averages in all that surrounding
135+
* darkness and drives the exposure up until the screen is a white slab.
136+
*/
137+
@Test
138+
fun overexposureFloor() {
139+
val (payload, encoded) = encodeRemoteCode(3)
140+
var highestDecodable = -1
141+
for (black in 0..250 step 10) {
142+
val ok = decodesAtLevels(encoded, payload, black, 255)
143+
Log.i(TAG, "overexposed black=$black white=255 decoded=$ok")
144+
if (ok) highestDecodable = black else break
145+
}
146+
Log.i(TAG, "RESULT overexposure: highest decodable black level = $highestDecodable")
147+
148+
assertTrue(
149+
highestDecodable >= 0,
150+
"the detector failed even on a correctly exposed frame -- the sweep is measuring " +
151+
"something other than exposure",
152+
)
153+
}
154+
155+
/** Underexposure, for symmetry: the floor is at black and the ceiling keeps falling. */
156+
@Test
157+
fun underexposureCeiling() {
158+
val (payload, encoded) = encodeRemoteCode(3)
159+
var lowestDecodable = -1
160+
for (white in 255 downTo 5 step 10) {
161+
val ok = decodesAtLevels(encoded, payload, 0, white)
162+
Log.i(TAG, "underexposed black=0 white=$white decoded=$ok")
163+
if (ok) lowestDecodable = white else break
164+
}
165+
Log.i(TAG, "RESULT underexposure: lowest decodable white level = $lowestDecodable")
166+
}
167+
168+
/**
169+
* Contrast held around a mid-grey, so the band closes in on 128 from both sides.
170+
*
171+
* If the detector adapted to the frame it would keep working here until the contrast fell into
172+
* the noise. If it is pinned to an absolute cutoff it will instead fail the moment the band
173+
* stops straddling that cutoff, while the code is still obviously legible. Which of those two
174+
* happens is the whole question.
175+
*/
176+
@Test
177+
fun contrastAroundMidGrey() {
178+
val (payload, encoded) = encodeRemoteCode(3)
179+
var lowestDecodable = -1
180+
for (half in 128 downTo 5 step 5) {
181+
val ok = decodesAtLevels(encoded, payload, 128 - half, 128 + half)
182+
Log.i(TAG, "midgrey black=${128 - half} white=${128 + half} span=${2 * half} decoded=$ok")
183+
if (ok) lowestDecodable = 2 * half else break
184+
}
185+
Log.i(TAG, "RESULT mid-grey: smallest decodable contrast span = $lowestDecodable of 255")
186+
}
187+
188+
private companion object {
189+
const val TAG = "KikCodeRange"
190+
const val REMOTE_PAYLOAD_BYTES = 20
191+
const val WIDTH = 1920
192+
const val HEIGHT = 1080
193+
194+
/**
195+
* A comfortably large code -- roughly 40% of the frame height, far above the size floor
196+
* measured in `KikCodeRangeTest`. Size must not be the thing under test here.
197+
*/
198+
const val CODE_PX = 432
199+
}
200+
}

0 commit comments

Comments
 (0)