test(qr): add the phase-0 decode corpus and its device-free baseline

The plan's phase 0 was meant to gate the decoder swap on a measurement rather
than an argument, and it had not been done: there is no camera or emulator in
this environment, so the corpus was never built.

Most of it does not need one. QrCorpus generates the fixtures deterministically
in pure java.awt from the same ZXing encoder QrCodeDrawer uses, degrading each
payload the way the real world does -- out of focus, tilted, off-axis, dim,
inverted, glared, noisy, photographed off another screen, and too far away.
QrCorpusBaselineTest then measures ZXing-Java, the decoder the old scanner used,
over that corpus on the JVM, and records the result per category.

The "too far away" fixtures are sized by pixels-per-module rather than absolute
pixels, because that ratio is what decides whether a symbol is resolvable at
all -- and it is why a long nprofile is harder to scan than a short npub at the
same physical size, which is the open ECC-level question in the plan.

The instrumented half, QrDecodeCorpusTest, runs zxing-cpp over the exported
images and fails if it reads fewer than ZXing-Java in any category. That is the
gate; it needs a device, so it is written and ready rather than run.

The baseline is deliberately generous to the old decoder -- full frame, with an
inverted retry, where the shipped scanner cropped to a viewfinder rect and
alternated inversion across frames. A win measured here is a floor on the
real-world difference, not the whole of it.

Export is opt-in (-Dqr.corpus.export=true) so an ordinary run never dirties the
working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134jvyriixNTHST4WRbbqbX
This commit is contained in:
Claude
2026-09-15 19:26:09 +00:00
parent c3cecf1e9f
commit 57121fdf34
3 changed files with 682 additions and 0 deletions
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
/**
* Runs zxing-cpp over the committed [QrCorpus] images and asserts it reads at least as many as
* ZXing-Java did, category by category.
*
* This is the gate the plan's phase 0 asks for: the justification for replacing the decoder is
* supposed to be a measurement, not an argument. The baseline it compares against was produced on
* the JVM by `QrCorpusBaselineTest` from the same images.
*
* Regenerate both with:
* ```
* ./gradlew :amethyst:testFdroidDebugUnitTest --tests '*QrCorpusBaselineTest*' -Dqr.corpus.export=true
* ```
*/
@RunWith(AndroidJUnit4::class)
class QrDecodeCorpusTest {
private val assets = InstrumentationRegistry.getInstrumentation().context.assets
private val decoder = ZxingCppBarcodeDecoder()
@Test
fun readsEveryCleanCode() {
fixtures().filter { it.category == "clean" }.forEach {
assertTrue("clean fixture ${it.file} must decode", decode(it))
}
}
@Test
fun beatsTheOldDecoderInEveryCategory() {
val baseline = baseline()
assertTrue("baseline.tsv missing - regenerate the corpus", baseline.isNotEmpty())
val results = fixtures().groupBy { it.category }.mapValues { (_, list) -> list.count { decode(it) } }
val regressions = mutableListOf<String>()
val report =
buildString {
appendLine()
appendLine("category zxing-cpp ZXing-Java")
baseline.keys.sorted().forEach { category ->
val old = baseline.getValue(category)
val new = results[category] ?: 0
appendLine(" %-20s %d/%d %d/%d".format(category, new, old.total, old.passed, old.total))
if (new < old.passed) regressions += "$category: $new < ${old.passed}"
}
}
println(report)
if (regressions.isNotEmpty()) {
fail("zxing-cpp read fewer codes than ZXing-Java in: ${regressions.joinToString("; ")}$report")
}
}
private data class Fixture(
val file: String,
val category: String,
val expected: String,
)
private data class Baseline(
val passed: Int,
val total: Int,
)
private fun decode(fixture: Fixture): Boolean {
val bitmap =
assets.open("$ASSET_DIR/${fixture.file}").use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 })
} ?: return false
return try {
decoder.decode(bitmap, DecodeEffort.Thorough).any { it.text == fixture.expected }
} finally {
bitmap.recycle()
}
}
private fun fixtures(): List<Fixture> =
assets.open("$ASSET_DIR/expected.tsv").bufferedReader().useLines { lines ->
lines
.filter { it.isNotBlank() }
.map { line ->
val (file, category, expected) = line.split('\t', limit = 3)
Fixture(file, category, expected)
}.toList()
}
private fun baseline(): Map<String, Baseline> =
assets.open("$ASSET_DIR/baseline.tsv").bufferedReader().useLines { lines ->
lines.filter { it.isNotBlank() }.associate { line ->
val (category, passed, total) = line.split('\t', limit = 3)
category to Baseline(passed.toInt(), total.toInt())
}
}
companion object {
private const val ASSET_DIR = "qr"
}
}
@@ -0,0 +1,367 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder
import java.awt.Color
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.awt.image.ConvolveOp
import java.awt.image.Kernel
import java.awt.image.RescaleOp
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
/**
* Deterministic corpus of degraded QR codes, for measuring how well a decoder actually reads the
* codes people point phones at.
*
* Why it lives in the JVM test source set: it is pure `java.awt`, no Android, so the corpus can be
* generated and the *old* decoder (ZXing-Java) measured on any machine, with no device or
* emulator. The new decoder is native and Android-only, so it is measured by the instrumented
* `QrDecodeCorpusTest` against the exact same images, exported from here.
*
* Everything is seeded, so two runs produce byte-identical images and the two measurements are
* comparable.
*/
object QrCorpus {
/** A throwaway pubkey. Never a real key — these images get committed. */
private const val PUBKEY = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
/**
* The payload lengths that matter, shortest to longest. Length drives the QR version, which
* drives module size at a fixed physical size — and small modules, far more than error
* correction, are what defeats a camera at arm's length.
*/
private val PAYLOADS =
listOf(
"npub" to "nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq",
"nprofile" to "nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhkummn9ekx7mqpz4mhxue69uhkummnw3ezummcw3ezuer9wchsz9thwden5te0wfjkccte9ehx7um5wghxyctwvshsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvshszxrhwden5te0wfjkccte9ehx7um5wghxyctwvshsqgxvxz9jkth8dgc6dyckt3jmg5kvthdjtcn6q9lc39ahq5dpjznuwq",
"nevent" to "nostr:nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ezuamfdejsygzhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8psgqqqqqqspp4mhxue69uhkummn9ekx7mq",
"njump" to "https://njump.to/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqdhpvhq",
)
/** One generated image plus what it should decode to and which hazard it represents. */
data class Fixture(
val name: String,
val category: String,
val expected: String,
val image: BufferedImage,
)
/**
* Every fixture, in a stable order.
*
* Categories are the *reasons* scans fail in the field, not arbitrary transforms: a code seen
* out of focus, off-axis, on a dim or glossy surface, photographed off another screen, or
* simply too far away to resolve.
*/
fun all(): List<Fixture> {
val fixtures = mutableListOf<Fixture>()
PAYLOADS.forEach { (label, payload) ->
val (base, modules) = renderWithModuleCount(payload, moduleSize = 6)
fun add(
category: String,
image: BufferedImage,
) = fixtures.add(Fixture("$category-$label", category, payload, image))
add("clean", base)
add("blur", blur(base, radius = 3))
add("blur-heavy", blur(base, radius = 6))
add("tilt15", rotate(base, degrees = 15.0))
add("tilt30", rotate(base, degrees = 30.0))
add("tilt45", rotate(base, degrees = 45.0))
add("perspective", perspective(base, strength = 0.28))
add("low-contrast", contrast(base, scale = 0.30f))
add("very-low-contrast", contrast(base, scale = 0.12f))
add("inverted", invert(base))
add("glare", glare(base))
add("noise", noise(base, sigma = 46.0))
add("moire", moire(base))
// Sized by pixels-per-module, not absolute pixels: that ratio is what decides
// whether a code is resolvable at all, and it is the whole reason a long payload is
// harder to scan than a short one at the same physical size.
add("far-3px", shrinkInFrame(base, modules, pxPerModule = 3.0))
add("far-2px", shrinkInFrame(base, modules, pxPerModule = 2.0))
add("far-1.5px", shrinkInFrame(base, modules, pxPerModule = 1.5))
}
return fixtures
}
/** Renders [payload] exactly the way `QrCodeDrawer` does: ECC level Q, 4-module quiet zone. */
fun render(
payload: String,
moduleSize: Int,
): BufferedImage = renderWithModuleCount(payload, moduleSize).first
/** As [render], plus the symbol's module count — what pixels-per-module is measured against. */
fun renderWithModuleCount(
payload: String,
moduleSize: Int,
): Pair<BufferedImage, Int> {
val code =
Encoder.encode(
payload,
ErrorCorrectionLevel.Q,
mapOf(
EncodeHintType.CHARACTER_SET to "UTF-8",
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q,
),
)
val matrix = code.matrix!!
val quiet = 4
val side = (matrix.width + quiet * 2) * moduleSize
val image = BufferedImage(side, side, BufferedImage.TYPE_INT_RGB)
val g = image.createGraphics()
g.color = Color.WHITE
g.fillRect(0, 0, side, side)
g.color = Color.BLACK
for (y in 0 until matrix.height) {
for (x in 0 until matrix.width) {
if (matrix[x, y] == 1.toByte()) {
g.fillRect((x + quiet) * moduleSize, (y + quiet) * moduleSize, moduleSize, moduleSize)
}
}
}
g.dispose()
return image to matrix.width
}
// ------------------------------------------------------------------
// degradations
// ------------------------------------------------------------------
/** Out of focus — the single most common reason a frame fails to decode. */
private fun blur(
source: BufferedImage,
radius: Int,
): BufferedImage {
val size = radius * 2 + 1
val weight = 1f / (size * size)
val kernel = Kernel(size, size, FloatArray(size * size) { weight })
val padded = pad(source, radius)
val out = BufferedImage(padded.width, padded.height, BufferedImage.TYPE_INT_RGB)
ConvolveOp(kernel, ConvolveOp.EDGE_NO_OP, null).filter(padded, out)
return out
}
/** Held at an angle. */
private fun rotate(
source: BufferedImage,
degrees: Double,
): BufferedImage {
val radians = Math.toRadians(degrees)
val cos = abs(cos(radians))
val sin = abs(sin(radians))
val w = (source.width * cos + source.height * sin).roundToInt()
val h = (source.width * sin + source.height * cos).roundToInt()
val out = BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
val g = out.createGraphics()
g.color = Color.WHITE
g.fillRect(0, 0, w, h)
g.setRenderingHint(
RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR,
)
g.translate(w / 2.0, h / 2.0)
g.rotate(radians)
g.translate(-source.width / 2.0, -source.height / 2.0)
g.drawImage(source, 0, 0, null)
g.dispose()
return out
}
/**
* Seen off-axis — a code on a wall photographed from the side. A true projective warp, which
* [java.awt.geom.AffineTransform] cannot express, so it is mapped by hand.
*/
private fun perspective(
source: BufferedImage,
strength: Double,
): BufferedImage {
val w = source.width
val h = source.height
val out = BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
for (y in 0 until h) {
// Rows further "away" sample a narrower slice, which is what makes modules shrink
// toward one edge exactly as a real off-axis photo does.
val t = y.toDouble() / (h - 1)
val squeeze = 1.0 - strength * (1.0 - t)
for (x in 0 until w) {
val centered = x - w / 2.0
val sourceX = (centered / squeeze + w / 2.0).roundToInt()
val rgb =
if (sourceX in 0 until w) source.getRGB(sourceX, y) else WHITE_RGB
out.setRGB(x, y, rgb)
}
}
return out
}
/** A dim screen, or e-ink, or a washed-out print: black and white move toward each other. */
private fun contrast(
source: BufferedImage,
scale: Float,
): BufferedImage {
val offset = 255f * (1f - scale) / 2f
val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB)
RescaleOp(scale, offset, null).filter(source, out)
return out
}
/** Light-on-dark, as a dark-mode client or an inverted print produces. */
private fun invert(source: BufferedImage): BufferedImage {
val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB)
for (y in 0 until source.height) {
for (x in 0 until source.width) {
out.setRGB(x, y, source.getRGB(x, y).inv() and 0xFFFFFF)
}
}
return out
}
/** A highlight burning out one corner — glass, gloss, or a ceiling light. */
private fun glare(source: BufferedImage): BufferedImage {
val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB)
val cx = source.width * 0.68
val cy = source.height * 0.30
val radius = min(source.width, source.height) * 0.30
for (y in 0 until source.height) {
for (x in 0 until source.width) {
val distance = hypot(x - cx, y - cy)
val lift = if (distance >= radius) 0.0 else (1.0 - distance / radius) * 235.0
out.setRGB(x, y, liftPixel(source.getRGB(x, y), lift))
}
}
return out
}
/** Sensor noise in poor light. */
private fun noise(
source: BufferedImage,
sigma: Double,
): BufferedImage {
val random = Random(SEED)
val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB)
for (y in 0 until source.height) {
for (x in 0 until source.width) {
val delta = gaussian(random) * sigma
out.setRGB(x, y, liftPixel(source.getRGB(x, y), delta))
}
}
return out
}
/** Photographed off another screen: a faint scanline beat over the modules. */
private fun moire(source: BufferedImage): BufferedImage {
val out = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB)
for (y in 0 until source.height) {
val band = sin(y * 0.9) * 34.0
for (x in 0 until source.width) {
out.setRGB(x, y, liftPixel(source.getRGB(x, y), band))
}
}
return out
}
/**
* Too far away: the symbol is scaled until each module is [pxPerModule] pixels across,
* then centred in a full-size frame.
*
* This is the category the old scanner could do nothing about, because it had no zoom — and
* the one auto-zoom exists for.
*/
private fun shrinkInFrame(
source: BufferedImage,
modules: Int,
pxPerModule: Double,
): BufferedImage {
val targetPx = (modules * pxPerModule).roundToInt()
val frame = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_RGB)
val g = frame.createGraphics()
g.color = Color.WHITE
g.fillRect(0, 0, frame.width, frame.height)
g.setRenderingHint(
RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR,
)
val x = (frame.width - targetPx) / 2
val y = (frame.height - targetPx) / 2
g.drawImage(source, x, y, targetPx, targetPx, null)
g.dispose()
return frame
}
// ------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------
private const val SEED = 20260915L
private const val WHITE_RGB = 0xFFFFFF
private fun pad(
source: BufferedImage,
margin: Int,
): BufferedImage {
val out = BufferedImage(source.width + margin * 2, source.height + margin * 2, BufferedImage.TYPE_INT_RGB)
val g = out.createGraphics()
g.color = Color.WHITE
g.fillRect(0, 0, out.width, out.height)
g.drawImage(source, margin, margin, null)
g.dispose()
return out
}
private fun liftPixel(
rgb: Int,
delta: Double,
): Int {
fun channel(shift: Int): Int {
val value = (rgb shr shift) and 0xFF
return max(0, min(255, (value + delta).roundToInt()))
}
return (channel(16) shl 16) or (channel(8) shl 8) or channel(0)
}
/** Box-Muller, so the noise is normally distributed rather than uniform. */
private fun gaussian(random: Random): Double {
val u1 = random.nextDouble().coerceAtLeast(1e-12)
val u2 = random.nextDouble()
return sqrt(-2.0 * ln(u1)) * cos(2.0 * Math.PI * u2)
}
}
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.scanner
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.LuminanceSource
import com.google.zxing.MultiFormatReader
import com.google.zxing.common.HybridBinarizer
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
/**
* Measures ZXing-Java — the decoder the old zxing-android-embedded scanner used — against
* [QrCorpus], and records the result as the baseline the new decoder has to beat.
*
* This is the half of the plan's phase 0 that needs no device. The other half
* (`QrDecodeCorpusTest`, instrumented) runs zxing-cpp over the exported images and asserts it
* does at least as well, category by category.
*
* **What this is and is not.** It measures *decoder quality on a still image*, and it is
* deliberately generous to the old decoder: it decodes the full uncropped image and retries
* inverted, where the shipped scanner cropped to a viewfinder rect and alternated inversion
* across frames. So a win here is a floor on the real-world improvement, not the whole of it.
*/
class QrCorpusBaselineTest {
@Test
fun cleanCodesAllDecode() {
// The one hard assertion. If a pristine, generously-sized code fails, the corpus itself
// is broken and every other number in this file is meaningless.
val clean = QrCorpus.all().filter { it.category == "clean" }
assertEquals("corpus should contain one clean fixture per payload", 4, clean.size)
clean.forEach {
assertEquals("clean fixture ${it.name} must decode", it.expected, decode(it.image))
}
}
@Test
fun reportsTheBaselinePerCategory() {
val byCategory = QrCorpus.all().groupBy { it.category }
val rows =
byCategory.map { (category, fixtures) ->
val passed = fixtures.count { decode(it.image) == it.expected }
Row(category, passed, fixtures.size)
}
println(render(rows))
// Recorded, not asserted: these numbers are the yardstick, and pinning them here would
// just mean a ZXing bump breaks the build instead of informing it.
if (System.getProperty(EXPORT_PROPERTY) == "true") {
writeBaseline(rows)
}
}
/**
* Writes the corpus and its baseline into the instrumented test's assets.
*
* Opt-in via `-D$EXPORT_PROPERTY=true` so an ordinary test run never dirties the working
* tree. The generator is deterministic, so re-exporting an unchanged corpus is a no-op.
*/
@Test
fun exportsTheCorpusForTheInstrumentedTest() {
assumeTrue("set -D$EXPORT_PROPERTY=true to regenerate the corpus", System.getProperty(EXPORT_PROPERTY) == "true")
val dir = File(ASSET_DIR)
dir.mkdirs()
QrCorpus.all().forEach { fixture ->
ImageIO.write(fixture.image, "png", File(dir, "${fixture.name}.png"))
}
File(dir, "expected.tsv").writeText(
QrCorpus.all().joinToString("\n", postfix = "\n") { "${it.name}.png\t${it.category}\t${it.expected}" },
)
}
private data class Row(
val category: String,
val passed: Int,
val total: Int,
)
private fun render(rows: List<Row>): String =
buildString {
appendLine()
appendLine("ZXing-Java (the old scanner's decoder) over QrCorpus:")
rows.sortedBy { it.category }.forEach {
appendLine(" %-20s %d/%d".format(it.category, it.passed, it.total))
}
val passed = rows.sumOf { it.passed }
val total = rows.sumOf { it.total }
appendLine(" %-20s %d/%d".format("TOTAL", passed, total))
}
private fun writeBaseline(rows: List<Row>) {
File(ASSET_DIR).mkdirs()
File(ASSET_DIR, BASELINE_FILE).writeText(
rows.sortedBy { it.category }.joinToString("\n", postfix = "\n") { "${it.category}\t${it.passed}\t${it.total}" },
)
}
/** ZXing-Java, full frame, with an inverted retry — a deliberately generous baseline. */
private fun decode(image: BufferedImage): String? {
val source = GrayLuminanceSource(image)
return tryDecode(source) ?: tryDecode(source.invert())
}
private fun tryDecode(source: LuminanceSource): String? =
try {
MultiFormatReader()
.apply { setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE))) }
.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
.text
} catch (e: Exception) {
null
}
/**
* A [LuminanceSource] over a [BufferedImage].
*
* Hand-rolled rather than pulling in `com.google.zxing:javase` for twenty lines — a new
* dependency, even a permissive one, is not worth it here.
*/
private class GrayLuminanceSource(
image: BufferedImage,
) : LuminanceSource(image.width, image.height) {
private val luminances =
ByteArray(image.width * image.height).also { out ->
for (y in 0 until image.height) {
for (x in 0 until image.width) {
val rgb = image.getRGB(x, y)
val r = (rgb shr 16) and 0xFF
val g = (rgb shr 8) and 0xFF
val b = rgb and 0xFF
// ITU-R BT.601 in integer arithmetic, matching ZXing's own conversion.
out[y * image.width + x] = ((r * 306 + g * 601 + b * 117) shr 10).toByte()
}
}
}
override fun getRow(
y: Int,
row: ByteArray?,
): ByteArray {
val out = if (row != null && row.size >= width) row else ByteArray(width)
System.arraycopy(luminances, y * width, out, 0, width)
return out
}
override fun getMatrix(): ByteArray = luminances
override fun isRotateSupported(): Boolean = false
override fun isCropSupported(): Boolean = false
}
companion object {
private const val EXPORT_PROPERTY = "qr.corpus.export"
private const val ASSET_DIR = "src/androidTest/assets/qr"
private const val BASELINE_FILE = "baseline.tsv"
}
}