devmandan commited on
Commit
5020d28
·
verified ·
1 Parent(s): 8f9a9c4

QR Scan Difficulty: first public version

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
README.md CHANGED
@@ -1,10 +1,185 @@
1
  ---
2
- title: Qr Scan Difficulty
3
- emoji: 🏃
4
- colorFrom: yellow
5
  colorTo: red
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: QR Scan Difficulty
3
+ emoji: 📷
4
+ colorFrom: green
5
  colorTo: red
6
  sdk: static
7
  pinned: false
8
+ license: other
9
+ short_description: Not every phone reads the same QR codes, measured
10
  ---
11
 
12
+ # Not every phone reads the same QR codes
13
+
14
+ A one-page answer to "will this QR code scan?", for people who know why QR codes matter but don't
15
+ have time for a dashboard. Every number comes from measured decodes.
16
+
17
+ Every iPhone, about 1 in 4 smartphones in use, has the same scanner: Apple's, which read 69% of our
18
+ 6,499 test codes. On the other 3 in 4, more than 3 billion phones, the scanner depends on the maker
19
+ and the app, and the scanners Android phones come with read from 42% to 68%:
20
+
21
+ - **Samsung's own**, in its Camera app ([Samsung's guide](https://www.samsung.com/us/support/answer/ANS10001925/)),
22
+ on more than a billion phones: about 59%, measured on a real Galaxy A57 from a sample of the codes. It
23
+ misses about 15 of every 100 codes an iPhone reads.
24
+ - **Google's ML Kit**, which Android's own QR button (lock screen and Quick Settings) opens, as on Pixel phones
25
+ ([Android's source](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java):
26
+ "On the eligible devices this activity is provided by GMS core"): 61%.
27
+ - **Huawei's Scan Kit**, built into Huawei phones
28
+ ([HarmonyOS](https://developer.huawei.com/consumer/en/doc/harmonyos-guides/scan-introduction),
29
+ [HMS Core](https://developer.huawei.com/consumer/en/doc/development/HMSCore-Guides/service-introduction-0000001050041994)): 68%.
30
+ - **ZXing**, which [Android's Settings app](https://cs.android.com/android/_/android/platform/frameworks/base/+/2ecfdb1d2dd832c637aacfa15c257822b94f10b1:packages/SettingsLib/src/com/android/settingslib/qrcode/QrCamera.java;bpv=1;bpt=0)
31
+ uses for Wi-Fi codes only: 42%.
32
+ - **Apps** bring their own; the four open-source scanners apps are built on read 27% to 67%.
33
+
34
+ The built-in scanners of more than 1 in 3 phones, from Xiaomi, OPPO, vivo, Transsion and Honor,
35
+ could not be measured.
36
+
37
+ Phones in use: [Counterpoint Research](https://counterpointresearch.com/en/insights/Active-Installed-Base-8-Smartphone-OEMs-Top-200-mn-Nearly-1-in-4-is-an-iPhone),
38
+ active smartphones in 2025: "nearly one in four active smartphones is an iPhone, while nearly one in five
39
+ is a Samsung"; only Apple and Samsung pass a billion, and eight makers have more than 200 million each.
40
+ People: at the end of 2025, 4.6 billion, 56% of the world, went online with a smartphone of their own
41
+ ([GSMA, State of Mobile Internet Connectivity 2026](https://www.gsma.com/somic/wp-content/uploads/2026/09/The-State-of-Mobile-Internet-Connectivity-2026.pdf), p. 32).
42
+ Web counters such as StatCounter show iPhones higher, about 31% of mobile page views, because they
43
+ count page views, not phones.
44
+
45
+ 1. **Not every phone reads the same QR codes.** Each scanner's share of all the codes, and what the
46
+ gap means for one code.
47
+ 2. **The QR code was invented for messy factories.** Why the gap matters: Denso made the QR code in 1994
48
+ for oily, damaged factory labels, with error correction built in, in the words of its developer,
49
+ Masahiro Hara. When one scanner reads a picture and another misses it, the difference is the software.
50
+ 3. **More than a billion phones read fewer codes than an iPhone.** Which phones use which scanner,
51
+ how many phones that is, how many codes each reads, and how many of every 100 codes an iPhone
52
+ reads it misses (and reads that the iPhone misses).
53
+ 4. **The gap opens on harder codes.** One strip per set of codes (clean, everyday, tough, hardest
54
+ cases) at a chosen image resolution: 1920, 1280 or 640 pixels on the long side. Samsung's estimate
55
+ appears at 1280, the one resolution it was read at.
56
+ 5. **Even everyday prints fail on some scanners.** Every tested code on one scale from easy to
57
+ hard, shaded by how many of the eight scanners read it. Drag along it to see a code from that
58
+ spot, and the mix of shades there; a link under the code opens its exact picture, to try with your
59
+ own phone.
60
+ 6. **What makes a code hard to read.** Six before-and-after pairs (small squares, too much data for
61
+ the size, blur, faded ink, tight curves, a covered corner square), each with the scanners that still
62
+ read it. The data pair shows the same version 21 codes squeezed into a small space and printed
63
+ bigger: more data needs a bigger print, because its squares shrink in the same space.
64
+ 7. **What helps a code scan on every phone.** Five plain rules, each backed by the measurements.
65
+
66
+ Everything else waits behind a "+", for readers who want to master it: each scanner's full profile;
67
+ how a scanner reads a QR code (telling dark from light, finding the corner squares, fitting the grid, reading
68
+ the squares, error correction) and, for each of the 17 factors, the step it breaks; and how the
69
+ measurements were made.
70
+
71
+ ## Decoders
72
+
73
+ | On the page | Decoder | What it is | Version |
74
+ |---|---|---|---|
75
+ | Apple's scanner | [Apple Vision](https://developer.apple.com/documentation/vision/vndetectbarcodesrequest) | Apple's barcode detector, built into iOS; our stand-in for the iPhone, measured on a Mac | macOS 14.3, `VNDetectBarcodesRequest` revision 4 |
76
+ | Google's scanner | [ML Kit](https://developers.google.com/ml-kit) ([barcode scanning](https://developers.google.com/ml-kit/vision/barcode-scanning)) | Google's barcode scanner as Google Play services delivers it; on Google's Android 14 image, Android's QR button is set to Play services' ML Kit barcode scanner | play-services-mlkit-barcode-scanning 18.3.1 with Google Play services 26.41.31 (barcode module 264131001), QR only, on Google's Android 14 emulator image (API 34, arm64) |
77
+ | Samsung's scanner | [Samsung Camera's QR scanner](https://www.samsung.com/us/support/answer/ANS10001925/), with a decoder by [Quram (Quramsoft)](https://www.quramsoft.com/) | the scanner in Samsung's Camera app, which the "Scan QR code" button in Quick Settings opens on Samsung phones; read on a real phone borrowed through Samsung's Remote Test Lab, on a random sample of the codes | Camera 16.5.00.85 on a Galaxy A57 (SM-A576S), One UI 8.5, Android 16; each picture opened from the gallery, at 1280 px only |
78
+ | Huawei's scanner | [Huawei Scan Kit](https://developer.huawei.com/consumer/en/hms/huawei-scankit/) | Huawei's scanning engine, built into HarmonyOS and shipped in HMS Core on Huawei phones; the kit's three models are byte-identical to those in HMS Core 6.16.2.300 | `com.huawei.hms:scanplus` 2.15.0.301 from Huawei's developer repository, photo mode (`decodeWithBitmap`), QR only, on Google's Android 14 emulator image (API 34, arm64) |
79
+ | Android Settings' scanner | [ZXing (Java)](https://github.com/zxing/zxing/releases/tag/zxing-3.5.2) | the scanner Android's Settings app uses for Wi-Fi codes; also inside apps built on zxing-android-embedded | 3.5.2, called as AOSP's `QrCamera` calls it, inverted retry included |
80
+ | WeChat's scanner | [WeChat](https://docs.opencv.org/4.12.0/d5/d04/classcv_1_1wechat__qrcode_1_1WeChatQRCode.html) | WeChat's neural-network scanner, open-sourced in OpenCV (the app's own build may differ) | OpenCV contrib 4.12 with its detector and super-resolution models |
81
+ | zxing-cpp | [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp/releases/tag/v3.1.1) | grew out of ZXing; used by open-source Android scanners such as Binary Eye | 3.1.1, library defaults |
82
+ | OpenCV, newer | [OpenCV ArUco](https://docs.opencv.org/4.12.0/d3/db0/classcv_1_1QRCodeDetectorAruco.html) | OpenCV's ArUco-based QR detector | 4.12 `QRCodeDetectorAruco`, single-code `detectAndDecode` |
83
+ | OpenCV, original | [OpenCV](https://docs.opencv.org/4.12.0/de/dc3/classcv_1_1QRCodeDetector.html) | OpenCV's first QR detector | 4.12 `QRCodeDetector`, single-code `detectAndDecode` |
84
+
85
+ An iPhone has one built-in scanner that the Camera and any app can use. Android ships none for
86
+ everyday codes: its QR button opens one that Google or the phone maker supplies, camera apps use the
87
+ maker's own (Samsung's in its Camera app; Huawei's is Scan Kit), and every other app picks its own, so the same code can
88
+ scan in one app and fail in another.
89
+
90
+ Google's scanner is measured as Google Play services delivers ML Kit, on Google's Android 14 emulator
91
+ image, where the QR button's default is `com.google.android.gms/.mlkit.barcode.ui.PlatformBarcodeScanningActivityProxy`.
92
+ The model lives in Play services' barcode module, which updates with Play services. The image's Play Store
93
+ updated Play services to 26.41.31 (October 2026) by itself; that version was then held for the whole
94
+ measurement, and every result records the versions of Play services and its barcode module. On Google's
95
+ images for each Android version, every up-to-date one from Android 9 to 17 read every code exactly the same;
96
+ older Play services read a little less (57% of codes with 2023's version).
97
+
98
+ Huawei's scanner is measured through Huawei's own developer kit, on the same emulator image, reading
99
+ each picture in the kit's photo mode. The kit's camera mode, meant for live video frames, read a
100
+ little less of these still pictures.
101
+
102
+ Samsung doesn't name the decoder in its scanner, but the phone shows whose it is: the Camera app decodes
103
+ with a library that carries the name of [Quram](https://www.quramsoft.com/), a Korean imaging-software company,
104
+ and the decoding engine names itself in the phone's log. Quram sells a QR and barcode scanner of its own and
105
+ names Samsung among its customers.
106
+
107
+ Samsung's scanner runs only on Samsung phones, so it was read on a real Galaxy A57 borrowed through
108
+ [Samsung's Remote Test Lab](https://developer.samsung.com/remote-test-lab), on a random sample of each set at
109
+ 1280 px. Each picture was opened from the phone's gallery in the scanner, inside a white border, because the
110
+ crop box the gallery shows first keeps only the middle of a picture. A read is the text on its result card, equal
111
+ to the code's content. Some content it never shows as text: it hands contacts, Wi-Fi, calendar, SMS, location,
112
+ email and phone codes and Facebook links straight to an app (its log names each action), and shows binary content and
113
+ JSON text as an empty card. Codes with such content are left out of its comparison, for every scanner. Its
114
+ figures are estimates: its gap to the other scanners on the codes it read, added to their results on all of a
115
+ set's codes. On all codes that comes to about 59% (57% to 61%, 95% confidence).
116
+
117
+ Not yet measured: the scanners other phone makers build into their phones, such as Xiaomi's, OPPO's,
118
+ vivo's, Transsion's and Honor's. Each runs only on its maker's phones, so it can be measured only on real devices
119
+ borrowed from a test lab, far more slowly than the other scanners. Samsung's came first, for its more than a billion
120
+ phones, and even it on a sample of the codes; the others are left for future work.
121
+
122
+ ## Codes
123
+
124
+ - **Made here** (synthetic captures, payloads made up): pristine, everyday and tough tiers, plus
125
+ one-factor ladders: a code all eight scanners read, changed one step at a time, 80 codes per step,
126
+ each code the same at every step but for the factor. One ladder, "More data, printed bigger", takes
127
+ the amount-of-data ladder's own codes and keeps every square at 6 camera pixels, up to version 21.
128
+ Published as
129
+ [QR Scan Difficulty: synthetic codes](https://huggingface.co/datasets/devmandan/qr-scan-difficulty),
130
+ with each code's content, every setting used to make it, and which scanners read it.
131
+ - **Research datasets**, each used under its own terms. Most of their codes are the page's "hardest cases": codes
132
+ researchers collected or built to copy the toughest conditions real codes meet. The other 1,525 are easy, QR-DN1.0's
133
+ clean originals, alone or tiled four to a picture.
134
+ - [BoofCV QR singles](https://huggingface.co/datasets/devmandan/boofcv-qr-singles): Peter Abeles'
135
+ BoofCV QR Code Performance Benchmark photos, published openly without an explicit licence grant.
136
+ - [QR-DN1.0](https://huggingface.co/datasets/devmandan/qr-dn1.0): clean QR codes, the same codes
137
+ tiled four to a picture, and distorted, noisy versions of them; CC BY 4.0 (Mendeley Data,
138
+ doi:10.17632/t2bdr663ms.2).
139
+ - [syn10k](https://huggingface.co/datasets/devmandan/syn10k-huawei-barcodes): Bodnár, Grósz and
140
+ Tóth's barcode images; the page uses 1,000 of its computer-made warped ones. CC BY 4.0.
141
+ - [Broken QR 104](https://huggingface.co/datasets/devmandan/broken-qr-codes-104): real codes that
142
+ fail to decode, collected from public web sources; annotations CC BY 4.0.
143
+
144
+ A code in an image can be read by any phone, so showing an image shows its payload. Images are
145
+ cropped and resized from their datasets; to have one removed, open a discussion on its dataset
146
+ page. The page itself never prints payload text.
147
+
148
+ ## How it was measured
149
+
150
+ Every image is read at seven image resolutions, 1920 down to 224 pixels on the long side: larger
151
+ images are shrunk to each size, smaller ones are read as they are, never enlarged. Most
152
+ research-dataset images are 512 × 512, so only the sizes below 640 shrink them. Every decoder reads
153
+ every image at every size, and a two-parameter item-response model turns the pattern of reads and
154
+ misses into one difficulty per code. The ladders are read at 1280 px.
155
+
156
+ A read counts only when a scanner returns the code's exact content, byte for byte; finding a code
157
+ without reading it is a miss. The content comes with each code: made here, or published by its
158
+ dataset. BoofCV's photos and QR-DN (whose distorted images take the content of their clean
159
+ originals) come without it; there the content is what other decoders read identically, and a code
160
+ is kept only when at least two decoders other than the scanner being scored agree, which leaves out
161
+ 4 photos. On 100 images with no code, made to fool scanners (look-alike corner squares,
162
+ checkerboards, clutter, other barcode types), none of the eight returned anything. Samsung's scanner
163
+ read them on its phone with no false readings; among the other barcode types it read the 4 Data Matrix
164
+ codes, each correctly, as it reads Data Matrix as well as QR codes (the eight, as we ran them, read QR
165
+ codes only).
166
+
167
+ One rule throughout: a code's shade counts the scanners that read it at 1280 px (all eight, most =
168
+ 5 to 7, some = 1 to 4, none). A group of codes, such as a ladder step or a set, counts a scanner when
169
+ it reads at least 9 in 10 of them. The page's "How we measured" section has the details.
170
+
171
+ ## Why we made this
172
+
173
+ To show how unevenly the world's phones read QR codes, and to help everyone who builds a scanner see where
174
+ theirs falls short and improve it. A measurement this broad has become practical only recently, thanks to AI
175
+ models that help write the software behind it.
176
+
177
+ ## Disclosures
178
+
179
+ Independent work, not affiliated with or endorsed by any company or organization named here: Apple, Google,
180
+ Samsung, Quram (Quramsoft), Huawei, Honor, Xiaomi, OPPO, vivo, Transsion, Tencent, Meta, Denso, DENSO WAVE,
181
+ Toyota, GS1, Counterpoint Research, GSMA, ITU, IDC or StatCounter; nor by the open-source projects and dataset
182
+ authors credited above. QR Code is a registered trademark of DENSO WAVE INCORPORATED. Apple and iPhone are
183
+ trademarks of Apple Inc.; Android, Google Play, ML Kit and Pixel of Google LLC; Samsung and Galaxy of Samsung
184
+ Electronics Co., Ltd.; Huawei and HarmonyOS of Huawei Technologies Co., Ltd.; WeChat of Tencent; WhatsApp and
185
+ Instagram of Meta Platforms, Inc. Other names are trademarks of their owners.
app.js ADDED
@@ -0,0 +1,1381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // QR Scan Difficulty. Every number on the page is computed here from data/site.json.
2
+ const SVGNS = 'http://www.w3.org/2000/svg';
3
+ const PILE_LO = -6; // codes every scanner read at every resolution
4
+ const PILE_HI = 106; // codes no scanner read at any resolution
5
+ const X_MIN = -12;
6
+ const X_MAX = 112;
7
+ const RELIABLE = 0.9; // a group of codes counts as read by a scanner that reads 9 in 10 of them
8
+ const BASE_FRAME = 1280; // a typical phone camera image; the default everywhere
9
+ const LADDER_WINDOW = 360; // a factor picture's usual window of the camera image (site.json gives each ladder's own)
10
+ // Android Settings' Wi-Fi QR scanner: QrCamera, which decodes with ZXing (AOSP source)
11
+ const ANDROID_SOURCE = 'https://cs.android.com/android/_/android/platform/frameworks/base/+/2ecfdb1d2dd832c637aacfa15c257822b94f10b1:packages/SettingsLib/src/com/android/settingslib/qrcode/QrCamera.java;bpv=1;bpt=0';
12
+ // Smartphones in the world (not our data; each figure quoted from its publisher, checked 2026-10-27)
13
+ const WORLD_PHONES = {
14
+ // unique people using a smartphone of their own to go online, end of 2025 (p. 32)
15
+ people: { billions: 4.6, share: 56, source: 'GSMA, 2026', url: 'https://www.gsma.com/somic/wp-content/uploads/2026/09/The-State-of-Mobile-Internet-Connectivity-2026.pdf' },
16
+ // Active smartphones by brand, 2025. Its text: "Nearly one in four active smartphones is an iPhone, while nearly one in
17
+ // five is a Samsung"; "Only Apple and Samsung have surpassed the one-billion active devices milestone"; each of the eight
18
+ // leaders (starred in its chart: Apple, Samsung, Xiaomi, OPPO, vivo, Transsion, Huawei, HONOR) has over 200 million.
19
+ // Shares read from its chart: Xiaomi 12%, OPPO 8%, vivo 7%, Transsion 5%, HONOR 4% (36% together), Huawei 4%, Google 0.8%.
20
+ inUse: {
21
+ source: 'Counterpoint Research, 2026', url: 'https://counterpointresearch.com/en/insights/Active-Installed-Base-8-Smartphone-OEMs-Top-200-mn-Nearly-1-in-4-is-an-iPhone',
22
+ apple: 'estimated at over a billion actively used devices', samsung: 'estimated at over a billion actively used devices',
23
+ otherFive: 'each estimated at over 200 million actively used devices', huawei: 'estimated at over 200 million actively used devices',
24
+ },
25
+ // Devices with Google Play, which carries Google's scanner: "we expanded enhanced fraud protection to 185 markets, now covering
26
+ // more than 2.8 billion Android devices" (Google, 19 February 2026); a floor, and it may count tablets
27
+ googlePlay: { billions: 2.8, source: 'Google, 2026', url: 'https://blog.google/security/keeping-google-play-android-app-ecosystem-safe-2025/' },
28
+ // Apple shipped 247.8 million of 1,260.3 million smartphones in 2025
29
+ shipped: { share: 19.7, total: 1.26, url: 'https://www.idc.com/resource-center/press-releases/q425mobilephonetop5/' },
30
+ // "Globally, 82 per cent of individuals 10 years or older own a mobile phone."
31
+ anyPhone: { share: 82, url: 'https://www.itu.int/itu-d/reports/statistics/2025/10/15/ff25-mobile-phone-ownership/' },
32
+ // web page views, not phones: Android 69.31%, iOS 30.66% of mobile page views, September 2026
33
+ web: { ios: 31, url: 'https://gs.statcounter.com/os-market-share/mobile/worldwide' },
34
+ };
35
+ const CAMERAX_DOCS = 'https://developer.android.com/media/camera/camerax/configuration';
36
+ const AOSP_ZXING = 'https://android.googlesource.com/platform/external/zxing/+/refs/heads/main/METADATA';
37
+ // Android's QR button opens the scanner a setting names: "On the eligible devices this activity is provided by GMS core"
38
+ const AOSP_QR_BUTTON = 'https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/com/android/internal/config/sysui/SystemUiDeviceConfigFlags.java';
39
+ // ...and the phone maker's own default for it, empty in Android's code
40
+ const AOSP_QR_DEFAULT = 'https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/res/res/values/config.xml';
41
+ // the scanners' roles on the page
42
+ const APPLE = 'apple-vision'; // Apple's scanner: the iPhone Camera and any app
43
+ const GOOGLE = 'mlkit-gms'; // Google's ML Kit from Google Play services: what Android's QR button opens
44
+ const SETTINGS = 'zxing-java'; // ZXing in Android's Settings app, for Wi-Fi codes
45
+ const HUAWEI = 'hms-scankit'; // Huawei's Scan Kit: the scanner in HarmonyOS and in HMS Core on Huawei phones
46
+ // Huawei's documentation of Scan Kit, for Android phones (HMS Core) and for HarmonyOS
47
+ const HUAWEI_KIT = 'https://developer.huawei.com/consumer/en/doc/development/HMSCore-Guides/service-introduction-0000001050041994';
48
+ const HUAWEI_HARMONY = 'https://developer.huawei.com/consumer/en/doc/harmonyos-guides/scan-introduction';
49
+ // What we measured, credited to its maker: each one's own page (checked 2026-10-28)
50
+ const ML_KIT = 'https://developers.google.com/ml-kit'; // ML Kit's home page
51
+ const ML_KIT_BARCODE = 'https://developers.google.com/ml-kit/vision/barcode-scanning'; // its barcode scanning API
52
+ const SCAN_KIT = 'https://developer.huawei.com/consumer/en/hms/huawei-scankit/'; // Scan Kit's home page
53
+ const APPLE_VISION = 'https://developer.apple.com/documentation/vision/vndetectbarcodesrequest'; // Apple Vision's barcode request
54
+ // Samsung's guide to its "Scan QR code" button, which opens the Gallery too: the way we read each picture. Samsung
55
+ // doesn't name the decoder inside; the phone shows it is Quram's (Quramsoft), credited openly by the user's decision
56
+ // of 2026-10-28: the Camera app decodes with a library carrying Quram's name, and the engine names itself in the log.
57
+ const SAMSUNG_QR = 'https://www.samsung.com/us/support/answer/ANS10001925/';
58
+ const QURAM = 'https://www.quramsoft.com/';
59
+ const SYNTH_DATASET = 'https://huggingface.co/datasets/devmandan/qr-scan-difficulty'; // the codes made for this page
60
+ // Denso Wave's history of the QR code: "it declared that it would not exercise" its patent rights
61
+ const QR_HISTORY = 'https://www.qrcode.com/en/history/';
62
+ // Denso's QR code page (the corner squares' 1:1:3:1:1 ratio, the alignment square) and Denso Wave's error correction page
63
+ const DENSO_QR = 'https://www.denso.com/global/en/business/innovation/qrcode/';
64
+ const QR_EC = 'https://www.qrcode.com/en/about/error_correction.html';
65
+ // Why the page exists, and who it is not affiliated with: every company and organization the page names (the audit checks
66
+ // that each one named in the page's text is listed), and the trademark notice DENSO WAVE asks for when the words "QR Code" are used
67
+ const PURPOSE = 'To show how unevenly the world’s phones read QR codes, and to help everyone who builds a scanner see where theirs falls short and improve it. A measurement this broad has become practical only recently, thanks to AI models that help write the software behind it.';
68
+ const AFFILIATIONS = ['Apple', 'Google', 'Samsung', 'Quram (Quramsoft)', 'Huawei', 'Honor', 'Xiaomi', 'OPPO', 'vivo', 'Transsion', 'Tencent', 'Meta',
69
+ 'Denso', 'DENSO WAVE', 'Toyota', 'GS1', 'Counterpoint Research', 'GSMA', 'ITU', 'IDC', 'StatCounter'];
70
+ const DISCLOSURE = `Independent work, not affiliated with or endorsed by any company or organization named on this page: ${AFFILIATIONS.slice(0, -1).join(', ')} or ${AFFILIATIONS[AFFILIATIONS.length - 1]}; nor by the open-source projects and dataset authors credited above. QR Code is a registered trademark of DENSO WAVE INCORPORATED. Apple and iPhone are trademarks of Apple Inc.; Android, Google Play, ML Kit and Pixel of Google LLC; Samsung and Galaxy of Samsung Electronics Co., Ltd.; Huawei and HarmonyOS of Huawei Technologies Co., Ltd.; WeChat of Tencent; WhatsApp and Instagram of Meta Platforms, Inc. Other names are trademarks of their owners.`;
71
+ // How a scanner reads a code: each factor's "breaks" line in site.json names one of these steps
72
+ const READ_STEPS = ['Telling dark from light', 'Finding the code', 'Fitting the grid', 'Reading the squares', 'Error correction']; // Quram's own site; its Machine Eye page sells a QR and barcode scanner, MI.CODE
73
+ const ZXING = 'https://github.com/zxing/zxing/releases/tag/zxing-3.5.2';
74
+ const ZXING_CPP = 'https://github.com/zxing-cpp/zxing-cpp/releases/tag/v3.1.1';
75
+ const WECHAT_QR = 'https://docs.opencv.org/4.12.0/d5/d04/classcv_1_1wechat__qrcode_1_1WeChatQRCode.html';
76
+ const OPENCV_ARUCO = 'https://docs.opencv.org/4.12.0/d3/db0/classcv_1_1QRCodeDetectorAruco.html';
77
+ const OPENCV_QR = 'https://docs.opencv.org/4.12.0/de/dc3/classcv_1_1QRCodeDetector.html';
78
+ const SCANNER_PAGES = { // each scanner's own pages, for exactly what we measured
79
+ 'apple-vision': [[APPLE_VISION, 'Apple’s documentation of VNDetectBarcodesRequest']],
80
+ 'mlkit-gms': [[ML_KIT_BARCODE, 'ML Kit’s barcode scanning'], [AOSP_QR_BUTTON, 'Android’s source for the QR button']],
81
+ 'hms-scankit': [[SCAN_KIT, 'Scan Kit'], [HUAWEI_KIT, 'its HMS Core guide'], [HUAWEI_HARMONY, 'its HarmonyOS guide']],
82
+ 'zxing-java': [[ZXING, 'ZXing 3.5.2'], [ANDROID_SOURCE, 'QrCamera.java in Android’s Settings library']],
83
+ wechat: [[WECHAT_QR, 'OpenCV’s WeChatQRCode']],
84
+ 'zxing-cpp': [[ZXING_CPP, 'zxing-cpp 3.1.1']],
85
+ 'opencv-aruco': [[OPENCV_ARUCO, 'OpenCV’s QRCodeDetectorAruco']],
86
+ opencv: [[OPENCV_QR, 'OpenCV’s QRCodeDetector']],
87
+ };
88
+ /** "See a, b and c.": a scanner's pages, each a link that opens in a new tab. */
89
+ function see(pages) {
90
+ const sep = (i) => (i === 0 ? '' : i === pages.length - 1 ? ' and ' : ', ');
91
+ return ['See ', ...pages.flatMap(([href, text], i) => [sep(i), el('a', { href, text, target: '_blank', rel: 'noopener' })]), '.'];
92
+ }
93
+ // the same scanner on Google's image for each Android version, and the Play services versions it came in
94
+ function versionsNote() {
95
+ const v = DATA.googleVersions;
96
+ if (!v) return '';
97
+ const now = percent(pctRead(GOOGLE, () => true).rate);
98
+ return `It read the same on each up-to-date Android version we tried, from ${v.androidSame[0]} to ${v.androidSame[1]}: what matters is Google Play services, which updates itself. Older versions read a little less (${percent(v.older.rate)} in ${v.older.year}, ${now} since ${v.sinceYear}).`;
99
+ }
100
+ // ZXing as Android's Settings app runs it reads light-on-dark codes only on its second, inverted pass
101
+ function invertedNote() {
102
+ const inv = DATA.zxingInvertedPass;
103
+ return inv ? `It reads light-on-dark codes only by retrying on an inverted image, as Android’s Settings app does: ${inv.read} of our ${inv.n} light-on-dark codes, and ${inv.read - inv.second} without the retry.` : '';
104
+ }
105
+ // Samsung's scanner: its Camera app's, read on a real phone borrowed through Samsung's Remote Test Lab, on a random
106
+ // sample of each set at one resolution (DATA.samsung), so its figures are estimates
107
+ const SAMSUNG_RTL = 'https://developer.samsung.com/remote-test-lab';
108
+ const WORDS = ['none', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
109
+ // One rule everywhere: how many of the scanners read it. A single code is read or not;
110
+ // a group of codes (a factor step, a set) is read by a scanner that reads 9 in 10 of them.
111
+ let ZONES; // all, most (more than half), some, none: set once the number of scanners is known
112
+ function makeZones(n) {
113
+ const half = Math.floor(n / 2);
114
+ return [
115
+ { min: n, name: `All ${WORDS[n]}`, words: `all ${WORDS[n]}`, tone: '--z-every' },
116
+ { min: half + 1, name: 'Most', words: `most (${half + 1} to ${n - 1})`, range: `${half + 1}–${n - 1}`, tone: '--z-most' },
117
+ { min: 1, name: 'Some', words: `some (1 to ${half})`, range: `1–${half}`, tone: '--z-some' },
118
+ { min: 0, name: 'None', words: 'none', tone: '--z-none' },
119
+ ];
120
+ }
121
+ const allWord = () => WORDS[ORDER.length]; // "seven"
122
+ const mostMin = () => ZONES[1].min; // the fewest scanners that still count as most
123
+ // the image resolution a scanner gets: larger images shrunk to this long side, smaller ones left as they are
124
+ const RESOLUTIONS = [
125
+ { frame: 1920, label: 'High', note: 'Larger images were shrunk to 1920 pixels on the long side, like 1080p video.' },
126
+ { frame: 1280, label: 'Medium', note: 'Larger images were shrunk to 1280 pixels on the long side, like 720p video.' },
127
+ { frame: 640, label: 'Low', note: 'Larger images were shrunk to 640 pixels on the long side. CameraX, Google’s camera library for Android apps, analyses 640 × 480 unless an app asks for more.' },
128
+ ];
129
+ const WORLD = { pristine: 0.2, everyday: 0.5, tough: 0.2, dataset: 0.1 }; // the curve's illustrative mix
130
+ const PLAIN = { // a name anyone can place, then what it is, for the specialist
131
+ 'apple-vision': ['Apple’s scanner', 'Apple Vision, built into iOS'],
132
+ 'mlkit-gms': ['Google’s scanner', 'ML Kit, opened by Android’s QR button'],
133
+ 'hms-scankit': ['Huawei’s scanner', 'Scan Kit, built into Huawei phones'],
134
+ 'zxing-java': ['Android Settings’ scanner', 'ZXing (Java), for Wi-Fi codes'],
135
+ wechat: ['WeChat’s scanner', 'neural network, open-sourced in OpenCV'],
136
+ 'zxing-cpp': ['zxing-cpp', 'grew out of ZXing; in open-source apps'],
137
+ 'opencv-aruco': ['OpenCV, newer', 'OpenCV’s ArUco-based detector'],
138
+ opencv: ['OpenCV, original', 'OpenCV’s first QR detector'],
139
+ };
140
+ const SPOKEN = { // the same names inside sentences
141
+ 'apple-vision': 'Apple’s scanner', 'mlkit-gms': 'Google’s scanner', 'hms-scankit': 'Huawei’s scanner', 'zxing-java': 'Android Settings’ scanner',
142
+ wechat: 'WeChat’s scanner',
143
+ 'zxing-cpp': 'zxing-cpp', 'opencv-aruco': 'OpenCV’s newer detector', opencv: 'OpenCV’s original detector',
144
+ };
145
+ const SETS = [
146
+ { label: 'Clean', noun: 'clean prints', match: (s) => s.key === 'pristine' },
147
+ { label: 'Everyday', noun: 'everyday prints', match: (s) => s.key === 'everyday' },
148
+ { label: 'Tough', noun: 'tough prints', match: (s) => s.key === 'tough' },
149
+ { label: 'Hardest cases', noun: 'hardest cases', match: (s) => s.group === 'dataset' },
150
+ ];
151
+ const SOURCE_PLAIN = { // what an example is; QR-DN1.0's examples carry their kind (site.json), its tiled ones are never shown
152
+ pristine: 'Clean print, simulated for this test',
153
+ everyday: 'Everyday print, simulated for this test',
154
+ tough: 'Tough print, simulated for this test',
155
+ boofcv: 'Real photo, BoofCV research set',
156
+ 'qrdn/clean': 'Clean code, QR-DN1.0 research set',
157
+ 'qrdn/distorted': 'Distorted, noisy code, QR-DN1.0 research set',
158
+ syn10k: 'Computer-made warped code, syn10k research set',
159
+ broken104: 'Real code that fails, collected from the web',
160
+ };
161
+ const PAIRS = [
162
+ { key: 'size', title: 'Squares too small' },
163
+ { key: 'version', title: 'Too much data for the size' },
164
+ { key: 'blur', title: 'Out of focus' },
165
+ { key: 'contrast', title: 'Faded ink' },
166
+ { key: 'curve', title: 'Tight curves' },
167
+ { key: 'corner', title: 'A covered corner square' },
168
+ ];
169
+
170
+ const state = { d: 40, offset: 0, laneFrame: BASE_FRAME, gridFrame: BASE_FRAME, showPct: false, factor: 'size', level: 0 };
171
+ let DATA;
172
+ let RES;
173
+ let NF;
174
+ let N;
175
+ let COUNT; // per code: how many of the scanners read it at BASE_FRAME
176
+ let ORDER;
177
+ let BELL;
178
+ let HIGH_NOTE; // what going above BASE_FRAME does, measured
179
+
180
+ const $ = (id) => document.getElementById(id);
181
+ const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
182
+ // a picture's address carries the build, so a browser never pairs new data with a cached old picture
183
+ const imgUrl = (path) => `img/${path}?v=${DATA.build}`;
184
+
185
+ function el(tag, attrs = {}, children = []) {
186
+ const node = document.createElement(tag);
187
+ for (const [k, v] of Object.entries(attrs)) {
188
+ if (k === 'text') node.textContent = v;
189
+ else if (k === 'class') node.className = v;
190
+ else node.setAttribute(k, v);
191
+ }
192
+ for (const c of [].concat(children)) if (c != null) node.append(c);
193
+ return node;
194
+ }
195
+
196
+ function sv(tag, attrs = {}, parent = null) {
197
+ const node = document.createElementNS(SVGNS, tag);
198
+ for (const [k, v] of Object.entries(attrs)) {
199
+ if (k === 'text') node.textContent = v;
200
+ else node.setAttribute(k, v);
201
+ }
202
+ if (parent) parent.append(node);
203
+ return node;
204
+ }
205
+
206
+ // ---- data -------------------------------------------------------------------------------
207
+
208
+ function decodeResults(data) {
209
+ const F = data.frames.length;
210
+ const n = data.items.s.length;
211
+ const res = new Uint8Array(n * F);
212
+ for (let i = 0; i < n; i++) {
213
+ const s = data.items.r[i];
214
+ for (let f = 0; f < F; f++) res[i * F + f] = parseInt(s.slice(2 * f, 2 * f + 2), 16); // bit k: scanner k read it
215
+ }
216
+ return res;
217
+ }
218
+
219
+ const decIndex = (key) => DATA.decoders.findIndex((d) => d.key === key);
220
+ const decoder = (key) => DATA.decoders[decIndex(key)];
221
+ const reads = (i, fi, k) => (RES[i * NF + fi] >> k) & 1;
222
+ const source = (i) => DATA.sources[DATA.items.s[i]];
223
+ const isAndroid = (key) => decoder(key).platform.startsWith('Android');
224
+ const otherApps = () => ORDER.filter((k) => ![APPLE, GOOGLE, HUAWEI, SETTINGS].includes(k)); // scanners other apps are built on
225
+ const zoneFor = (count) => ZONES.find((z) => count >= z.min);
226
+ const snap = (d) => (d > 103 ? PILE_HI : d > 100 ? 100 : d < -3 ? PILE_LO : d < 0 ? 0 : d); // the piles, or the scale
227
+ const zoneIndex = (count) => ZONES.indexOf(zoneFor(count));
228
+ function luminance(hex) {
229
+ const c = hex.match(/\w\w/g).map((h) => parseInt(h, 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
230
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
231
+ }
232
+ /** Text colour for a label sitting on a tone: whichever of ink and surface contrasts more. */
233
+ function onTone(z) {
234
+ const t = luminance(css(z.tone));
235
+ const ratio = (name) => { const l = luminance(css(name)); return (Math.max(t, l) + 0.05) / (Math.min(t, l) + 0.05); };
236
+ return ratio('--surface') >= ratio('--ink') ? css('--surface') : css('--ink');
237
+ }
238
+
239
+ /** A percent that never rounds onto 0%, 90% (9 in 10) or 100% unless it is really there. */
240
+ function percent(v) {
241
+ const r = Math.round(100 * v);
242
+ if (!((v > 0 && r === 0) || (v < RELIABLE && r === 90) || (v < 1 && r === 100))) return `${r}%`;
243
+ const t = (100 * v).toFixed(1);
244
+ return `${t === '0.0' ? '0.1' : t === '90.0' ? '89.9' : t === '100.0' ? '99.9' : t}%`;
245
+ }
246
+
247
+ /** Scanners that read a group of codes: at least 9 in 10 of them. */
248
+ function readsGroup(items, fi) {
249
+ return ORDER.filter((key) => {
250
+ const k = decIndex(key);
251
+ return items.reduce((s, i) => s + reads(i, fi, k), 0) >= RELIABLE * items.length;
252
+ });
253
+ }
254
+
255
+ /** Scanners that read a ladder step: at least 9 in 10 of its codes, at the ladder frame. */
256
+ const stepScanners = (level) => ORDER.filter((key) => level.reads[decIndex(key)] >= RELIABLE * level.n);
257
+
258
+ /** Index of the last step, counting from the easy end, that at least `min` scanners still read. */
259
+ function lastStep(lad, min) {
260
+ let last = -1;
261
+ for (let li = 0; li < lad.levels.length; li++) {
262
+ if (stepScanners(lad.levels[li]).length < min) break;
263
+ last = li;
264
+ }
265
+ return last;
266
+ }
267
+
268
+ /** Index of the first step that fewer than `min` scanners read, or -1. */
269
+ const firstBelow = (lad, min) => lad.levels.findIndex((lv) => stepScanners(lv).length < min);
270
+
271
+ const ladder = (key) => DATA.ladders.find((l) => l.key === key);
272
+ // the base code's settings a factor sets itself, so the explorer doesn't quote the base value for them
273
+ const OWN_SETTINGS = { size: ['ppm'], version: ['version', 'ppm'], version_big: ['version', 'ppm'], contrast: ['contrast'], tilt: ['tilt'], blur: ['blur'] };
274
+
275
+ /** A pair's two steps: the last one every scanner reads, and the first harder one that one scanner or none reads. */
276
+ function pairSteps(lad) {
277
+ const counts = lad.levels.map((lv) => stepScanners(lv).length);
278
+ const good = Math.max(0, lastStep(lad, ORDER.length));
279
+ const bad = counts.findIndex((c, li) => li > good && c <= 1);
280
+ return { good, bad: bad < 0 ? lad.levels.length - 1 : bad };
281
+ }
282
+
283
+ /** A version-ladder step's codes printed bigger, from the "version_big" ladder (same codes, squares kept the same size). */
284
+ function printedBigger(lv) {
285
+ const big = ladder('version_big');
286
+ const found = big ? big.levels.find((l) => l.short === lv.short) : null;
287
+ return found ? { lad: big, lv: found } : null;
288
+ }
289
+ const pxOf = (lv) => lv.label.match(/([\d.]+) camera pixels/)[1]; // a data step's camera pixels per square, from its label
290
+
291
+ // ---- small shared pieces ----------------------------------------------------------------
292
+
293
+ function showTip(evt, lines) {
294
+ const tip = $('tip');
295
+ tip.replaceChildren(...lines.map(([cls, text]) => el('div', { class: cls, text })));
296
+ tip.hidden = false;
297
+ const w = tip.offsetWidth;
298
+ const h = tip.offsetHeight;
299
+ let x = evt.clientX + 14;
300
+ let y = evt.clientY + 14;
301
+ if (x + w > window.innerWidth - 8) x = evt.clientX - w - 14;
302
+ if (y + h > window.innerHeight - 8) y = evt.clientY - h - 14;
303
+ tip.style.left = `${x}px`;
304
+ tip.style.top = `${y}px`;
305
+ }
306
+ const hideTip = () => { $('tip').hidden = true; };
307
+ // on a touch screen, lifting the finger ends the pointer: the tip stays until the next tap or a scroll
308
+ const leaveTip = (e) => { if (e.pointerType !== 'touch') hideTip(); };
309
+
310
+ const swatch = (z) => el('i', { class: 'swatch', style: `background:${css(z.tone)}` });
311
+
312
+ /** The image-resolution choice: it names what it sets, gives each setting in pixels, and says what
313
+ * the chosen one means. Picking re-renders the section, so focus returns to the chosen button. */
314
+ function resolutionControl(container, current, onPick) {
315
+ const refocus = container.contains(document.activeElement);
316
+ const id = `${container.id}Label`;
317
+ const note = RESOLUTIONS.find((r) => r.frame === current).note;
318
+ container.replaceChildren(
319
+ el('span', { class: 'res-label', id, text: 'Image resolution' }),
320
+ el('div', { class: 'seg', role: 'group', 'aria-labelledby': id }, RESOLUTIONS.map((o) => {
321
+ const b = el('button', { type: 'button', 'aria-pressed': String(o.frame === current) }, [`${o.label} `, el('span', { class: 'px', text: `${o.frame} px` })]);
322
+ b.addEventListener('click', () => onPick(o.frame));
323
+ return b;
324
+ })),
325
+ el('p', { class: 'res-note', text: current === RESOLUTIONS[0].frame ? `${note} ${HIGH_NOTE}` : note }),
326
+ );
327
+ if (refocus) container.querySelector('[aria-pressed="true"]').focus();
328
+ }
329
+
330
+ function countText(n) {
331
+ const all = ORDER.length;
332
+ if (n === all) return `Read by all ${WORDS[all]} scanners`;
333
+ if (n === 0) return `Read by none of the ${WORDS[all]}`;
334
+ return `Read by ${n} of the ${all} scanners`;
335
+ }
336
+
337
+ function groupText(n) {
338
+ const all = ORDER.length;
339
+ if (n === all) return `Read reliably by all ${WORDS[all]} scanners`;
340
+ if (n === 0) return `Read reliably by none of the ${WORDS[all]}`;
341
+ return `Read reliably by ${n} of the ${all} scanners`;
342
+ }
343
+
344
+ /** A shade's swatch that names its shade, for a line that gives the count in words. */
345
+ const namedSwatch = (z) => { const s = swatch(z); s.title = z.name; return s; };
346
+
347
+ /** Scanner names for a sentence; both OpenCV detectors together read as one phrase. */
348
+ function sayNames(keys) {
349
+ const both = keys.includes('opencv-aruco') && keys.includes('opencv');
350
+ const names = keys.filter((k) => !(both && k.startsWith('opencv'))).map((k) => SPOKEN[k]);
351
+ return both ? [...names, 'both OpenCV detectors'] : names;
352
+ }
353
+ const join = (xs) => (xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`);
354
+ const capital = (s) => s.replace(/^./, (c) => c.toUpperCase());
355
+ const pctText = (v) => (v > 0 && v < 0.005 ? '<1%' : `${Math.round(100 * v)}%`);
356
+
357
+ // ---- Apple's scanner, Android's QR button, Android Settings, other apps -----------------
358
+
359
+ function pctRead(key, match, frame = BASE_FRAME) {
360
+ const fi = DATA.frames.indexOf(frame);
361
+ const k = decIndex(key);
362
+ let r = 0;
363
+ let n = 0;
364
+ for (let i = 0; i < N; i++) {
365
+ if (!match(source(i))) continue;
366
+ r += reads(i, fi, k);
367
+ n += 1;
368
+ }
369
+ return { rate: r / Math.max(1, n), n };
370
+ }
371
+
372
+ /** Samsung's scanner read a random sample of each set: its estimated share of the matching sets' codes, each set's
373
+ * estimate weighed by the set's size, as pctRead weighs the other scanners' sets. */
374
+ function samsungRate(match) {
375
+ let r = 0;
376
+ let n = 0;
377
+ for (const s of DATA.sources) {
378
+ const v = DATA.samsung.sets[s.key];
379
+ if (!v || !match(s)) continue;
380
+ r += v.of * v.estimate;
381
+ n += v.of;
382
+ }
383
+ return { rate: r / Math.max(1, n), n };
384
+ }
385
+ const about = (v) => `≈${Math.round(100 * v)}%`; // an estimate, to the nearest percent
386
+
387
+ /** Against the iPhone, on the same codes: of every 100 codes Apple's scanner read, how many this one missed, and how many
388
+ * codes it read that Apple's missed, per 100 of Apple's reads; with the counts behind them (read, missedN). */
389
+ function vsApple(key, match = () => true) {
390
+ const fi = DATA.frames.indexOf(BASE_FRAME);
391
+ const a = decIndex(APPLE);
392
+ const k = decIndex(key);
393
+ let appleRead = 0;
394
+ let missed = 0;
395
+ let extra = 0;
396
+ for (let i = 0; i < N; i++) {
397
+ if (!match(source(i))) continue;
398
+ const ra = reads(i, fi, a);
399
+ const rk = reads(i, fi, k);
400
+ appleRead += ra;
401
+ missed += ra && !rk ? 1 : 0;
402
+ extra += !ra && rk ? 1 : 0;
403
+ }
404
+ return { missed: (100 * missed) / appleRead, extra: (100 * extra) / appleRead, read: appleRead, missedN: missed };
405
+ }
406
+
407
+ /** The same for Samsung's scanner, from its sample: each set's shares, weighed by how many of the set's codes Apple's
408
+ * scanner read (the misses) or by the set's size (the extra reads), as samsungRate weighs its estimates. */
409
+ function samsungVsApple(match = () => true) {
410
+ const fi = DATA.frames.indexOf(BASE_FRAME);
411
+ const a = decIndex(APPLE);
412
+ let appleRead = 0;
413
+ let missed = 0;
414
+ let extra = 0;
415
+ DATA.sources.forEach((s, si) => {
416
+ const v = DATA.samsung.sets[s.key];
417
+ if (!v || !match(s)) return;
418
+ let read = 0;
419
+ let n = 0;
420
+ for (let i = 0; i < N; i++) {
421
+ if (DATA.items.s[i] !== si) continue;
422
+ read += reads(i, fi, a);
423
+ n += 1;
424
+ }
425
+ appleRead += read;
426
+ if (v.vsApple.appleRead) missed += (read * v.vsApple.missed) / v.vsApple.appleRead;
427
+ extra += (n * v.vsApple.extra) / v.n;
428
+ });
429
+ return { missed: (100 * missed) / appleRead, extra: (100 * extra) / appleRead };
430
+ }
431
+ const SAMSUNG_RES = () => RESOLUTIONS.find((r) => r.frame === DATA.samsung.frame)?.label ?? `${DATA.samsung.frame} px`;
432
+
433
+ function renderVersus() {
434
+ const link = (href, text) => el('a', { href, text, target: '_blank', rel: 'noopener' });
435
+ const all = () => true;
436
+ const apple = pctRead(APPLE, all);
437
+ const google = pctRead(GOOGLE, all);
438
+ const samsung = samsungRate(all);
439
+ const huawei = pctRead(HUAWEI, all);
440
+ const zx = pctRead(SETTINGS, all);
441
+ const others = otherApps().map((k) => pctRead(k, all).rate);
442
+ const lo = Math.min(...others);
443
+ const hi = Math.max(...others);
444
+ const builtIn = [google.rate, samsung.rate, huawei.rate, zx.rate]; // the scanners Android phones come with
445
+ const hard = (s) => s.group === 'dataset';
446
+ const count = (pred) => DATA.sources.filter(pred).reduce((acc, s) => acc + s.count, 0);
447
+ const made = count((s) => s.group === 'made' && s.key !== 'ladder');
448
+ const real = count(hard);
449
+ const label = (name, what) => el('span', { class: 'vlabel' }, [el('span', { class: 'name', text: name }), el('span', { class: 'what' }, what)]);
450
+ const bar = (name, what, v, estimate = false) => el('div', { class: 'vbar' }, [
451
+ label(name, what),
452
+ el('span', { class: 'vtrack' }, el('span', { class: estimate ? 'vfill est' : 'vfill', style: `width:${100 * v.rate}%` })),
453
+ el('span', { class: 'vval', text: estimate ? about(v.rate) : percent(v.rate) }),
454
+ ]);
455
+ $('versus').replaceChildren(
456
+ el('p', { class: 'versus-lede' }, [
457
+ `We tested ${apple.n.toLocaleString()} QR codes, most of them deliberately hard, on the scanners phones use. `,
458
+ 'Every iPhone, about ', link(WORLD_PHONES.inUse.url, '1 in 4 smartphones'), `, has Apple’s scanner; it read ${percent(apple.rate)}. `,
459
+ `The other 3 in 4, more than 3 billion phones, use other scanners; those measured so far read ${percent(Math.min(...builtIn))} to ${percent(Math.max(...builtIn))}.`,
460
+ ]),
461
+ el('div', { class: 'vbars' }, [ // the iPhone's, then the scanners Android phones come with, then those inside apps
462
+ bar('iPhone', ['Apple’s scanner (', link(APPLE_VISION, 'Vision'), ')'], apple),
463
+ bar('Samsung phones', ['Samsung’s scanner (', link(SAMSUNG_QR, 'Camera'), ')'], samsung, true),
464
+ bar('Android’s QR button', ['Google’s scanner (', link(ML_KIT, 'ML Kit'), ')'], google),
465
+ bar('Huawei phones', ['Huawei’s scanner (', link(SCAN_KIT, 'Scan Kit'), ')'], huawei),
466
+ bar('Android Settings', [link(ZXING, 'ZXing'), ', for ', link(ANDROID_SOURCE, 'Wi-Fi codes')], zx),
467
+ el('div', { class: 'vbar' }, [
468
+ label('Other apps', `${others.length} scanners apps are built on`),
469
+ el('span', { class: 'vtrack spread' }, [
470
+ el('span', { class: 'vband', style: `left:${100 * lo}%;width:${100 * (hi - lo)}%` }),
471
+ ...others.map((v) => el('span', { class: 'vdot', style: `left:${100 * v}%` })),
472
+ ]),
473
+ el('span', { class: 'vval wide', text: `${percent(lo)}–${percent(hi)}` }),
474
+ ]),
475
+ ]),
476
+ el('p', { class: 'takeaway', text: 'The same code can scan on one phone and fail on the next.' }),
477
+ el('p', { class: 'fine' }, `The codes: ${made.toLocaleString()} prints we simulated, and ${real.toLocaleString()} from research datasets, mostly hardest cases that researchers collected or built to copy the toughest conditions real codes meet. ≈ Samsung’s figure is an estimate.`),
478
+ );
479
+ }
480
+
481
+ /** The intro's closing point, from the reads: a picture one scanner read was readable, so another scanner's miss on
482
+ * it is the software's, not the code's. Then why that matters, and how far one better scanner reaches. */
483
+ function renderIntro() {
484
+ const link = (href, text) => el('a', { href, text, target: '_blank', rel: 'noopener' });
485
+ const split = COUNT.filter((c) => c >= 1 && c < ORDER.length).length;
486
+ $('introNote').textContent = `Codes in daily life meet the same mess: faded signs, spilled-on menus, crumpled receipts. A scanner built only for pristine codes misses why the QR code exists. When one scanner reads a picture that another misses, the difference is the software: that happened with ${split.toLocaleString()} of our codes.`;
487
+ const v = DATA.googleVersions;
488
+ const now = percent(pctRead(GOOGLE, () => true).rate);
489
+ $('introClose').replaceChildren(
490
+ 'The QR code is still the best two-dimensional code for phones: ',
491
+ link(QR_HISTORY, 'free for anyone to use'), ', readable from any direction, and built to repair itself. ',
492
+ 'It doesn’t need replacing; the scanners need to catch up with what it was built for.',
493
+ v ? ` Updates alone can help: Google’s ML Kit scanner, delivered and updated through Google Play services, went from reading ${percent(v.older.rate)} of our codes in ${v.older.year} to ${now} since ${v.sinceYear}, with no new phone needed.` : '',
494
+ );
495
+ }
496
+
497
+ /** Which phones use which scanner, how many phones that is, how many codes each reads, and how many of the codes an
498
+ * iPhone reads each one misses: on everyday prints and on the hardest cases, where the gap is. */
499
+ function renderWho() {
500
+ const link = (href, text) => el('a', { href, text, target: '_blank', rel: 'noopener' });
501
+ const all = () => true;
502
+ const everyday = (s) => s.key === 'everyday';
503
+ const hard = (s) => s.group === 'dataset';
504
+ const { inUse, googlePlay } = WORLD_PHONES;
505
+ const apple = pctRead(APPLE, all).rate;
506
+ const samsung = samsungRate(all).rate;
507
+ const others = otherApps();
508
+ const n = (v) => String(Math.round(v));
509
+ const range = (vals) => (n(Math.min(...vals)) === n(Math.max(...vals)) ? n(vals[0]) : `${n(Math.min(...vals))}–${n(Math.max(...vals))}`);
510
+ const misses = (key) => [vsApple(key, everyday).missed, vsApple(key, hard).missed].map(n);
511
+ const samEvery = samsungVsApple(everyday);
512
+ const samHard = samsungVsApple(hard);
513
+ const appRead = others.map((k) => pctRead(k, all).rate);
514
+ const rows = [ // phones, how many (Counterpoint), their scanner, codes read, misses of every 100 the iPhone reads (everyday, hard)
515
+ ['iPhone', inUse.apple, 'Apple’s', percent(apple), '—', '—'],
516
+ // Google's scanner comes with Google Play services: apps reach it on most Android phones, and Pixel's QR button opens it
517
+ ['Most Android phones, in apps', `over ${googlePlay.billions} billion devices with Google Play, by Google’s count; also Pixel’s QR button`,
518
+ 'Google’s ML Kit, in Google Play services', percent(pctRead(GOOGLE, all).rate), ...misses(GOOGLE)],
519
+ ['Samsung', inUse.samsung, 'Samsung’s own, in its Camera app', about(samsung), `≈${n(samEvery.missed)}`, `≈${n(samHard.missed)}`], // estimates
520
+ ['Xiaomi, OPPO, vivo, Transsion, Honor', inUse.otherFive, 'each maker’s own', 'not yet measured', '—', '—'],
521
+ ['Huawei', inUse.huawei, 'Huawei’s Scan Kit', percent(pctRead(HUAWEI, all).rate), ...misses(HUAWEI)],
522
+ ['Android’s own Settings app', 'for Wi-Fi codes', 'ZXing', percent(pctRead(SETTINGS, all).rate), ...misses(SETTINGS)],
523
+ ['Other apps, on any phone', 'each brings its own', `${capital(WORDS[others.length])} that apps are built on`,
524
+ `${percent(Math.min(...appRead))}–${percent(Math.max(...appRead))}`,
525
+ range(others.map((k) => vsApple(k, everyday).missed)), range(others.map((k) => vsApple(k, hard).missed))],
526
+ ];
527
+ const th = (text, attrs = {}) => el('th', { scope: 'col', text, ...attrs });
528
+ $('who').replaceChildren(
529
+ el('thead', {}, [
530
+ el('tr', {}, [th('Phones', { class: 'left', rowspan: '2' }), th('Their scanner', { class: 'left', rowspan: '2' }),
531
+ th(`Reads, of all ${N.toLocaleString()} codes`, { rowspan: '2' }), th('Misses, of every 100 codes an iPhone reads', { colspan: '2', scope: 'colgroup' })]),
532
+ el('tr', {}, [th('Everyday prints'), th('Hardest cases')]),
533
+ ]),
534
+ el('tbody', {}, rows.map(([phones, many, scanner, read, ev, hd]) => el('tr', {}, [
535
+ el('th', { scope: 'row' }, [el('span', { class: 'name', text: phones }), el('span', { class: 'what', text: many })]),
536
+ el('td', { class: 'left', text: scanner }),
537
+ el('td', { text: read }),
538
+ el('td', { text: ev }),
539
+ el('td', { text: hd }),
540
+ ]))),
541
+ );
542
+ // how the misses are counted, with the real counts behind one row
543
+ const zxEvery = vsApple(SETTINGS, everyday);
544
+ const zxHard = vsApple(SETTINGS, hard);
545
+ $('whoHow').textContent = `How the misses are counted, for Android Settings’ scanner: of the ${zxEvery.read.toLocaleString()} everyday prints an iPhone read, it missed ${zxEvery.missedN.toLocaleString()}, about ${n(zxEvery.missed)} in 100; of the ${zxHard.read.toLocaleString()} hardest cases an iPhone read, it missed ${zxHard.missedN.toLocaleString()}, about ${n(zxHard.missed)} in 100.`;
546
+ $('whoTitle').textContent = samsung < apple ? 'More than a billion phones read fewer codes than an iPhone' : 'Which phones read which codes';
547
+ // the phones' main scanners on everyday prints (Android Settings' reads Wi-Fi codes only; its row and whoHow show it)
548
+ const everydayMost = n(Math.max(vsApple(GOOGLE, everyday).missed, samEvery.missed, vsApple(HUAWEI, everyday).missed));
549
+ const googleHard = vsApple(GOOGLE, hard);
550
+ const both = n(samHard.missed) === n(googleHard.missed)
551
+ ? `Samsung’s and Google’s scanners each miss about ${n(samHard.missed)}`
552
+ : `Samsung’s scanner misses about ${n(samHard.missed)} and Google’s ${n(googleHard.missed)}`;
553
+ $('whoNote').textContent = `On everyday prints, Google’s, Samsung’s and Huawei’s scanners miss ${everydayMost === '0' ? 'almost none' : `at most ${everydayMost} in 100`} of the codes an iPhone reads. On the hardest cases, ${both} of every 100 an iPhone reads. Phones from Xiaomi, OPPO, vivo, Transsion and Honor, together more than a billion, are not yet measured.`;
554
+ const sam = samsungVsApple(all);
555
+ const g = vsApple(GOOGLE);
556
+ const h = vsApple(HUAWEI);
557
+ $('whoSource').replaceChildren(
558
+ `Some scanners also read codes an iPhone misses: for every 100 it reads, Huawei’s read ${n(h.extra)} more, Google’s ${n(g.extra)} and Samsung’s about ${n(sam.extra)}. Samsung’s figures are estimates; why the other makers wait is under How we measured. Phones in use: `,
559
+ link(inUse.url, inUse.source), '; devices with Google Play: ', link(googlePlay.url, googlePlay.source), '.',
560
+ );
561
+ }
562
+
563
+ // ---- the spectrum -----------------------------------------------------------------------
564
+
565
+ /** The illustrative mix over difficulty, split by how many scanners read each code. */
566
+ function bellData() {
567
+ const counts = {};
568
+ for (let i = 0; i < N; i++) counts[source(i).key] = (counts[source(i).key] || 0) + 1;
569
+ const nSets = DATA.sources.filter((s) => s.group === 'dataset' && counts[s.key]).length;
570
+ const weight = (s) => (s.group === 'dataset' ? WORLD.dataset / nSets : WORLD[s.key]) / counts[s.key];
571
+ const dens = ZONES.map(() => new Float64Array(101));
572
+ const piles = { lo: ZONES.map(() => 0), hi: ZONES.map(() => 0) };
573
+ let total = 0;
574
+ for (let i = 0; i < N; i++) {
575
+ const w = weight(source(i));
576
+ const d = DATA.items.d[i];
577
+ const z = zoneIndex(COUNT[i]);
578
+ total += w;
579
+ if (d < 0) piles.lo[z] += w;
580
+ else if (d > 100) piles.hi[z] += w;
581
+ else dens[z][Math.round(d)] += w;
582
+ }
583
+ const sigma = 4.5;
584
+ const medians = {};
585
+ for (const key of ['pristine', 'everyday', 'tough']) {
586
+ const ds = [];
587
+ for (let i = 0; i < N; i++) if (source(i).key === key) ds.push(DATA.items.d[i]);
588
+ ds.sort((a, b) => a - b);
589
+ medians[key] = ds[Math.floor(ds.length / 2)];
590
+ }
591
+ const smooth = dens.map((arr) => Array.from(arr, (_, j) => {
592
+ let s = 0;
593
+ let ws = 0;
594
+ for (let k = Math.max(0, j - 14); k <= Math.min(100, j + 14); k++) {
595
+ const w = Math.exp(-((k - j) ** 2) / (2 * sigma * sigma));
596
+ s += arr[k] * w;
597
+ ws += w;
598
+ }
599
+ return s / ws;
600
+ }));
601
+ return { smooth, piles, total, medians };
602
+ }
603
+
604
+ /** Shares of the four tones among the codes at difficulty d (the same smoothing as the bell). */
605
+ function mixAt(d) {
606
+ let v;
607
+ if (d <= PILE_LO) v = BELL.piles.lo;
608
+ else if (d >= PILE_HI) v = BELL.piles.hi;
609
+ else {
610
+ const j = Math.max(0, Math.min(99, Math.floor(d)));
611
+ v = BELL.smooth.map((s) => s[j] + (s[j + 1] - s[j]) * (d - j));
612
+ }
613
+ const t = v.reduce((a, b) => a + b, 0) || 1;
614
+ return v.map((x) => x / t);
615
+ }
616
+
617
+ function renderKey() {
618
+ $('bellKey').replaceChildren(
619
+ el('span', { class: 'key-title', text: `How many of the ${allWord()} scanners read each code:` }),
620
+ ...ZONES.map((z) => el('span', {}, [swatch(z), z.range ? `${z.name} (${z.range})` : z.name])),
621
+ );
622
+ }
623
+
624
+ function renderBell() {
625
+ const svg = $('bell');
626
+ const W = svg.clientWidth || 700;
627
+ const padTop = 18;
628
+ const plotH = 170;
629
+ const base = padTop + plotH;
630
+ const H = base + 46;
631
+ const x = (d) => 4 + ((d - X_MIN) / (X_MAX - X_MIN)) * (W - 8);
632
+ const xInv = (px) => X_MIN + ((px - 4) / (W - 8)) * (X_MAX - X_MIN);
633
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
634
+ svg.setAttribute('height', H);
635
+ svg.replaceChildren();
636
+
637
+ // stacked: all of them at the bottom, then most, some, none; piles keep the same area scale
638
+ const { smooth, piles } = BELL;
639
+ const cum = [];
640
+ smooth.forEach((s, z) => cum.push(s.map((v, j) => v + (z ? cum[z - 1][j] : 0))));
641
+ const at = (arr, d) => {
642
+ const j = Math.max(0, Math.min(99, Math.floor(d)));
643
+ return arr[j] + (arr[j + 1] - arr[j]) * (d - j);
644
+ };
645
+ const pileH = (p) => p.reduce((a, b) => a + b, 0) / 8;
646
+ const yMax = Math.max(...cum[cum.length - 1], pileH(piles.lo), pileH(piles.hi)) * 1.12;
647
+ const y = (v) => base - (Math.min(v, yMax) / yMax) * plotH;
648
+ ZONES.forEach((z, zi) => {
649
+ let path = '';
650
+ for (let d = 0; d <= 100 + 1e-9; d += 0.5) path += `${path ? 'L' : 'M'}${x(d)},${y(at(cum[zi], d))}`;
651
+ for (let d = 100; d >= -1e-9; d -= 0.5) path += `L${x(d)},${zi ? y(at(cum[zi - 1], d)) : base}`;
652
+ sv('path', { d: `${path}Z`, fill: css(z.tone) }, svg);
653
+ });
654
+ ZONES.slice(0, -1).forEach((_, zi) => { // a thin surface line between tones
655
+ let path = '';
656
+ for (let d = 0; d <= 100 + 1e-9; d += 0.5) path += `${path ? 'L' : 'M'}${x(d)},${y(at(cum[zi], d))}`;
657
+ sv('path', { d: path, fill: 'none', stroke: css('--surface'), 'stroke-width': 1.5 }, svg);
658
+ });
659
+ for (const [c, p, what] of [[PILE_LO, piles.lo, 'Read by every scanner at every resolution'], [PILE_HI, piles.hi, 'Read by no scanner at any resolution']]) {
660
+ let v0 = 0;
661
+ p.forEach((w, zi) => {
662
+ if (!w) return;
663
+ const top = y((v0 + w) / 8);
664
+ sv('rect', { x: x(c - 4), y: top, width: x(c + 4) - x(c - 4), height: y(v0 / 8) - top, fill: css(ZONES[zi].tone) }, svg);
665
+ v0 += w;
666
+ });
667
+ const hit = sv('rect', { x: x(c - 5), y: padTop, width: x(c + 5) - x(c - 5), height: plotH, fill: 'transparent' }, svg);
668
+ const tip = (e) => showTip(e, [['v', `${Math.round((100 * p.reduce((a, b) => a + b, 0)) / BELL.total)}% of the mix`], ['k', what]]);
669
+ hit.addEventListener('pointermove', tip);
670
+ hit.addEventListener('pointerdown', tip);
671
+ hit.addEventListener('pointerleave', leaveTip);
672
+ }
673
+ sv('line', { x1: x(X_MIN), x2: x(X_MAX), y1: base + 0.5, y2: base + 0.5, stroke: css('--rule') }, svg);
674
+
675
+ // a name inside each tone, only where the tone is thick enough under the whole word
676
+ const halfWord = (24 / (W - 8)) * (X_MAX - X_MIN);
677
+ ZONES.forEach((z, zi) => {
678
+ let best = null;
679
+ for (let d = 2 + halfWord; d <= 98 - halfWord; d += 1) {
680
+ const mid = ((zi ? y(at(cum[zi - 1], d)) : base) + y(at(cum[zi], d))) / 2;
681
+ // room left above and below the word, at its two ends and its middle
682
+ const room = Math.min(...[d - halfWord, d, d + halfWord].map((dd) =>
683
+ Math.min(mid - 9 - y(at(cum[zi], dd)), (zi ? y(at(cum[zi - 1], dd)) : base) - (mid + 5))));
684
+ if (!best || room > best.room) best = { d, room, mid };
685
+ }
686
+ if (best && best.room >= 2) {
687
+ sv('text', { x: x(best.d), y: best.mid + 4, 'text-anchor': 'middle', style: `fill:${onTone(z)};font-weight:600`, text: z.name }, svg);
688
+ }
689
+ });
690
+ // the axis: easier and harder at the ends, and the middle code of each set we made
691
+ const placed = [{ cx: x(X_MIN) + 30, w: 64, r: 0 }, { cx: x(X_MAX) - 30, w: 64, r: 0 }];
692
+ sv('text', { x: x(X_MIN), y: base + 20, class: 'muted', text: '← Easier' }, svg);
693
+ sv('text', { x: x(X_MAX), y: base + 20, class: 'muted', 'text-anchor': 'end', text: 'Harder →' }, svg);
694
+ const ticks = [];
695
+ for (const [key, text] of [['pristine', 'clean prints'], ['everyday', 'everyday prints'], ['tough', 'tough prints']]) {
696
+ const cx = x(BELL.medians[key]);
697
+ const w = text.length * 6.4 + 10;
698
+ let r = 0;
699
+ while (placed.some((q) => q.r === r && Math.abs(q.cx - cx) < (q.w + w) / 2)) r += 1;
700
+ placed.push({ cx, w, r });
701
+ ticks.push({ cx, r, text });
702
+ }
703
+ // the lines first; then each label on a patch of the page's color, so a lower label's line passes behind a higher label
704
+ for (const { cx, r } of ticks) sv('line', { x1: cx, x2: cx, y1: base, y2: base + 6 + 15 * r, stroke: css('--ink-3') }, svg);
705
+ for (const { cx, r, text } of ticks) {
706
+ const label = sv('text', { x: cx, y: base + 20 + 15 * r, 'text-anchor': 'middle', class: 'muted', text }, svg);
707
+ const bb = label.getBBox();
708
+ svg.insertBefore(sv('rect', { x: bb.x - 3, y: bb.y, width: bb.width + 6, height: bb.height, fill: css('--surface') }), label);
709
+ }
710
+
711
+ // the movable cursor
712
+ const cx = x(state.d);
713
+ const g = sv('g', { 'pointer-events': 'none' }, svg);
714
+ sv('line', { x1: cx, x2: cx, y1: padTop - 8, y2: base, stroke: css('--accent'), 'stroke-width': 2 }, g);
715
+ sv('circle', { cx, cy: padTop - 8, r: 7, fill: css('--accent'), stroke: css('--surface'), 'stroke-width': 2 }, g);
716
+
717
+ const move = (e) => {
718
+ const rect = svg.getBoundingClientRect();
719
+ const d = xInv(((e.clientX - rect.left) / rect.width) * W);
720
+ setCursor(snap(d));
721
+ };
722
+ svg.onpointerdown = (e) => { svg.setPointerCapture(e.pointerId); move(e); svg.onpointermove = (ev) => { if (ev.buttons) move(ev); }; };
723
+ svg.onpointerup = () => { svg.onpointermove = null; };
724
+ svg.setAttribute('aria-label', `QR codes from easiest to hardest, shaded by how many of the ${allWord()} scanners read each one: all ${allWord()}, most, some or none.`);
725
+ }
726
+
727
+ function renderReadout() {
728
+ const mix = mixAt(state.d);
729
+ let text;
730
+ if (state.d <= PILE_LO) text = 'Codes here were read by every scanner at every image resolution.';
731
+ else if (state.d >= PILE_HI) text = 'Codes here were read by no scanner at any image resolution.';
732
+ else {
733
+ const parts = ZONES.map((z, zi) => [z, mix[zi]]).filter(([, v]) => v > 0);
734
+ text = `Codes here: ${parts.map(([z, v], k) => `${pctText(v)} ${k ? 'by' : 'read by'} ${z.words}`).join(' · ')}.`;
735
+ }
736
+ $('readout').textContent = text;
737
+ }
738
+
739
+ /** The eight example codes nearest this spot; the first one shown has the spot's most common tone. */
740
+ function nearest(d) {
741
+ const c = [];
742
+ for (let i = 0; i < N; i++) {
743
+ if (DATA.items.x[i] < 0) continue;
744
+ c.push([Math.abs(DATA.items.d[i] - d), i]);
745
+ }
746
+ const picks = c.sort((a, b) => a[0] - b[0]).slice(0, 8).map(([, i]) => i);
747
+ const mix = mixAt(d);
748
+ const common = mix.indexOf(Math.max(...mix));
749
+ const first = picks.findIndex((i) => zoneIndex(COUNT[i]) === common);
750
+ return first > 0 ? [picks[first], ...picks.filter((_, k) => k !== first)] : picks;
751
+ }
752
+
753
+ function renderSpecimen() {
754
+ const picks = nearest(state.d);
755
+ const i = picks[state.offset % picks.length];
756
+ const fi = DATA.frames.indexOf(BASE_FRAME);
757
+ const readKeys = ORDER.filter((k) => reads(i, fi, decIndex(k)));
758
+ const z = zoneFor(readKeys.length);
759
+ const yes = (k) => (readKeys.includes(k) ? 'read' : 'missed');
760
+ const others = otherApps();
761
+ const ex = DATA.examples[DATA.items.x[i]];
762
+ const plain = SOURCE_PLAIN[ex.kind ? `${source(i).key}/${ex.kind}` : source(i).key] || source(i).name;
763
+ const next = el('button', { class: 'linklike', type: 'button', text: 'Another code from here' });
764
+ next.addEventListener('click', () => { state.offset += 1; renderSpecimen(); });
765
+ const full = /^https?:/.test(ex.href) ? ex.href : imgUrl(ex.href); // the exact picture the scanners read
766
+ $('specimen').replaceChildren(
767
+ el('img', { src: imgUrl(ex.img), alt: `A QR code from this spot: ${plain}` }),
768
+ el('a', { class: 'scan-it', href: full, target: '_blank', rel: 'noopener', text: 'Open the full picture, and try it with your phone ↗' }),
769
+ el('div', { class: 'big' }, [namedSwatch(z), countText(readKeys.length)]),
770
+ el('div', { class: 'small', text: `Apple’s: ${yes(APPLE)} · Google’s: ${yes(GOOGLE)} · Huawei’s: ${yes(HUAWEI)} · Android Settings’: ${yes(SETTINGS)} · apps’ scanners: ${others.filter((k) => readKeys.includes(k)).length} of ${others.length}` }),
771
+ el('div', { class: 'src', text: plain }),
772
+ next,
773
+ );
774
+ }
775
+
776
+ function setCursor(d) {
777
+ state.d = d;
778
+ state.offset = 0;
779
+ $('cursor').value = String(d);
780
+ renderBell();
781
+ renderReadout();
782
+ renderSpecimen();
783
+ }
784
+
785
+ function renderTakeaway() {
786
+ const fi = DATA.frames.indexOf(BASE_FRAME);
787
+ const every = [];
788
+ for (let i = 0; i < N; i++) if (source(i).key === 'everyday') every.push(i);
789
+ const allRead = every.filter((i) => COUNT[i] === ORDER.length).length / every.length;
790
+ const ok = readsGroup(every, fi);
791
+ const no = [...new Set(ORDER.filter((k) => !ok.includes(k)).map((k) => SPOKEN[k]))];
792
+ const missed = 100 - Math.round(100 * allRead);
793
+ $('spectrumTitle').textContent = missed >= 10 ? 'Even everyday prints fail on some scanners' : 'Easy codes scan everywhere. Hard ones split the phones.';
794
+ $('takeaway').textContent = `Of everyday prints, ${missed}% are missed by at least one of the ${allWord()} scanners` + (no.length
795
+ ? `; ${join(no)} read fewer than 9 in 10 of them.`
796
+ : ', though each reads 9 in 10 or more of them.');
797
+ $('bellNote').textContent = 'The curve’s shape is an illustrative mix of the sets, not a count of them.';
798
+ }
799
+
800
+ // ---- each scanner (inside "See each scanner") ---------------------------------------------
801
+
802
+ function renderLanes() {
803
+ resolutionControl($('laneRes'), state.laneFrame, (f) => { state.laneFrame = f; renderLanes(); });
804
+ const svg = $('lanes');
805
+ const W = svg.clientWidth || 700;
806
+ const stacked = W < 640; // phones: each name above its lane, without the technical line
807
+ const rowH = stacked ? 50 : 34;
808
+ const H = ORDER.length * rowH + 30;
809
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
810
+ svg.setAttribute('height', H);
811
+ svg.replaceChildren();
812
+ const labels = ORDER.flatMap((key, r) => [
813
+ sv('text', { x: 0, y: r * rowH + 15, class: 'strong', text: PLAIN[key][0] }, svg),
814
+ ...(stacked ? [] : [sv('text', { x: 0, y: r * rowH + 29, class: 'muted', style: 'font-size:11px', text: PLAIN[key][1] }, svg)]),
815
+ ]);
816
+ const left = stacked ? 0 : Math.ceil(Math.max(...labels.map((t) => t.getComputedTextLength()))) + 16;
817
+ const barY = stacked ? 22 : 6; // the lane's top, within its row
818
+ const barH = 20;
819
+ const x = (d) => left + ((d - X_MIN) / (X_MAX - X_MIN)) * (W - left - 4);
820
+ $('lanesKey').replaceChildren(
821
+ el('span', {}, [el('i', { class: 'ramp' }), 'Share of the codes read']),
822
+ el('span', {}, [el('i', { class: 'uline' }), 'At least 9 in 10 read']),
823
+ );
824
+ $('lanesNote').textContent = 'Each cell holds codes of about the same difficulty. An orange line under a cell means the scanner read at least 9 in 10 of them; where its line stops and the others go on, it misses codes they read. Hover over or tap a cell for its count.';
825
+ const fi = DATA.frames.indexOf(state.laneFrame);
826
+ const rgb = (hex) => hex.match(/\w\w/g).map((h) => parseInt(h, 16));
827
+ const a = rgb(css('--lane-0'));
828
+ const b = rgb(css('--z-every'));
829
+ const mix = (t) => `rgb(${a.map((c, j) => Math.round(c + (b[j] - c) * t)).join(',')})`;
830
+ const span = (bin) => (bin === 0 ? [-10, -2] : bin === 51 ? [102, 110] : [(bin - 1) * 2, bin * 2]);
831
+ const binOf = (d) => (d < 0 ? 0 : d > 100 ? 51 : 1 + Math.min(49, Math.floor(d / 2)));
832
+ ORDER.forEach((key, r) => {
833
+ const k = decIndex(key);
834
+ const top = r * rowH;
835
+ const bins = Array.from({ length: 52 }, () => [0, 0]);
836
+ for (let i = 0; i < N; i++) {
837
+ const bin = binOf(DATA.items.d[i]);
838
+ bins[bin][0] += reads(i, fi, k);
839
+ bins[bin][1] += 1;
840
+ }
841
+ bins.forEach(([rd, n], bin) => {
842
+ if (!n) return;
843
+ const [from, to] = span(bin);
844
+ sv('rect', { x: x(from), y: top + barY, width: Math.max(1, x(to) - x(from) - 0.5), height: barH, fill: mix(rd / n) }, svg);
845
+ });
846
+ // the page's one rule, cell by cell: one line under each run of cells where it read at least 9 in 10
847
+ let run = null;
848
+ const flush = () => {
849
+ if (run) sv('rect', { x: x(span(run[0])[0]), y: top + barY + barH + 2, width: Math.max(1, x(span(run[1])[1]) - x(span(run[0])[0]) - 0.5), height: 3,
850
+ fill: css('--accent'), class: 'ok', 'data-key': key, 'data-cells': `${run[0]}-${run[1]}` }, svg);
851
+ run = null;
852
+ };
853
+ bins.forEach(([rd, n], bin) => {
854
+ const ok = n > 0 && rd >= RELIABLE * n;
855
+ if (!ok || bin === 1 || bin === 51) flush(); // the end blocks stand apart from the scale
856
+ if (ok) run = run ? [run[0], bin] : [bin, bin];
857
+ });
858
+ flush();
859
+ const hit = sv('rect', { x: x(X_MIN), y: top, width: x(X_MAX) - x(X_MIN), height: rowH, fill: 'transparent' }, svg);
860
+ const tipAt = (e) => {
861
+ const rect = svg.getBoundingClientRect();
862
+ const d = X_MIN + ((((e.clientX - rect.left) / rect.width) * W - left) / (W - left - 4)) * (X_MAX - X_MIN);
863
+ const bin = d > 101 ? 51 : d < -1 ? 0 : binOf(Math.max(0, Math.min(100, d)));
864
+ if (!bins[bin][1]) { hideTip(); return; }
865
+ const [rd, n] = bins[bin];
866
+ showTip(e, [['v', `${percent(rd / n)} read`], ['k', `${PLAIN[key][0]}: ${rd.toLocaleString()} of ${n.toLocaleString()} codes here`]]);
867
+ };
868
+ hit.addEventListener('pointermove', tipAt);
869
+ hit.addEventListener('pointerdown', tipAt);
870
+ hit.addEventListener('pointerleave', leaveTip);
871
+ });
872
+ sv('text', { x: x(X_MIN), y: H - 6, class: 'muted', text: '← Easier' }, svg);
873
+ sv('text', { x: x(X_MAX), y: H - 6, class: 'muted', 'text-anchor': 'end', text: 'Harder →' }, svg);
874
+ }
875
+
876
+ // ---- what makes a code hard -------------------------------------------------------------
877
+
878
+ /** One sentence per pair, every number read off the ladder. */
879
+ function pairRule(key, lad) {
880
+ const all = lastStep(lad, ORDER.length);
881
+ const most = lastStep(lad, mostMin());
882
+ const s = (li) => (li < 0 ? null : lad.levels[li].short);
883
+ const grid = (v) => { const n = Number(v.replace('v', '')); return `version ${n} (${17 + 4 * n} squares across)`; };
884
+ if (all < 0 || most < 0) return 'Even the easiest step did not scan reliably on most scanners.';
885
+ switch (key) {
886
+ case 'size':
887
+ return `What counts is how many camera pixels cover each small square: most scanners need at least ${s(most)}, all ${allWord()} need ${s(all)}.`;
888
+ case 'version': { // the captions carry the numbers; the sentence says why
889
+ const lv = lad.levels[pairSteps(lad).bad];
890
+ const big = printedBigger(lv);
891
+ const squeezed = 'More data means more squares, so in the same space each one shrinks.';
892
+ if (!big) return `${squeezed} At ${grid(lv.short)}, ${stepScanners(lv).length} of ${ORDER.length} scanners read it.`;
893
+ const miss = ORDER.length - stepScanners(big.lv).length;
894
+ return `${squeezed} Printed bigger, the same code reads again ` + (miss
895
+ ? `on all but ${WORDS[miss]} scanner${miss === 1 ? ', which handles' : 's, which handle'} that many squares poorly.`
896
+ : 'on every scanner.');
897
+ }
898
+ case 'blur':
899
+ return all > 0
900
+ ? `A little blur is fine: all ${allWord()} read up to ${s(all)} px of blur, most up to ${s(most)} px, on squares ${DATA.ladderBase.ppm} px wide.`
901
+ : `Only a sharp image read on all ${allWord()}; most read up to ${s(most)} px of blur, on squares ${DATA.ladderBase.ppm} px wide.`;
902
+ case 'contrast':
903
+ return `All ${allWord()} need at least ${s(all)} contrast between ink and paper; most read faded ink down to ${s(most)}.`;
904
+ case 'curve':
905
+ return all > 0
906
+ ? `Wrapped round a curve, the grid bends. All ${allWord()} read down to a bend radius of ${s(all)}× the code’s width; most, down to ${s(most)}×.`
907
+ : `Wrapped round a curve, the grid bends. Most scanners read down to a bend radius of ${s(most)}× the code’s width.`;
908
+ case 'corner': {
909
+ const c = cornerFacts();
910
+ if (!c) return 'Some scanner still read 9 in 10 codes at every step.';
911
+ return `Scanners find a code by its corner squares, which error correction doesn’t protect: covering ${c.step.short} of one, ${c.share} of the whole code, left no scanner reading 9 codes in 10. A ${c.smudge.short} smudge elsewhere still left ${c.smudgeCount} of the ${ORDER.length} reading.`;
912
+ }
913
+ default:
914
+ return '';
915
+ }
916
+ }
917
+
918
+ /** The corner-square story, read off two ladders: the first corner step no scanner reads, its
919
+ * share of the whole code, and the first smudge step (kept off the corners) for comparison. */
920
+ function cornerFacts() {
921
+ const corner = ladder('corner');
922
+ const damage = ladder('damage');
923
+ const li = corner.levels.findIndex((lv) => stepScanners(lv).length === 0);
924
+ if (li < 0) return null;
925
+ const side = 17 + 4 * DATA.ladderBase.version;
926
+ const pct = parseFloat(corner.levels[li].short) / 100;
927
+ const smudge = damage.levels[1];
928
+ return { step: corner.levels[li], share: `${((100 * pct * 49) / (side * side)).toFixed(1)}%`, smudge, smudgeCount: stepScanners(smudge).length };
929
+ }
930
+
931
+ function renderPairs() {
932
+ $('factorsLede').textContent = `Each pair changes one thing about a code all ${allWord()} scanners read. Under each picture: how many still read at least 9 in 10 codes like it.`;
933
+ $('pairs').replaceChildren(...PAIRS.map(({ key, title }) => {
934
+ const lad = ladder(key);
935
+ const { good, bad } = pairSteps(lad);
936
+ const fig = (lv, label = lv.label, from = lad) => {
937
+ const ok = stepScanners(lv);
938
+ return el('figure', {}, [
939
+ el('img', { src: imgUrl(lv.img), alt: `${from.name}: ${label}`, loading: 'lazy' }),
940
+ el('figcaption', {}, [
941
+ el('span', { text: label }),
942
+ el('span', { class: 'count', text: `${ok.length} of ${ORDER.length} scanners`, title: ok.length ? `Read reliably by ${join(ok.map((k) => PLAIN[k][0]))}` : 'Read reliably by none' }),
943
+ ]),
944
+ ]);
945
+ };
946
+ // too much data: the same codes printed bigger beside them squeezed into the space a smaller version reads in
947
+ const big = key === 'version' ? printedBigger(lad.levels[bad]) : null;
948
+ const v = (lv) => lv.short.replace('v', 'Version ');
949
+ const figs = big
950
+ ? [fig(big.lv, `${v(big.lv)}, printed bigger: ${pxOf(big.lv)} camera pixels per square`, big.lad),
951
+ fig(lad.levels[bad], `${v(lad.levels[bad])}, squeezed: ${pxOf(lad.levels[bad])} camera pixels per square`)]
952
+ : [fig(lad.levels[good]), fig(lad.levels[bad])];
953
+ return el('article', { class: 'pair' }, [
954
+ el('h3', { text: title }),
955
+ el('div', { class: 'two' }, figs),
956
+ el('p', { text: pairRule(key, lad) }),
957
+ ]);
958
+ }));
959
+ }
960
+
961
+ function renderExplorer() {
962
+ const box = $('explorer');
963
+ const lad = ladder(state.factor);
964
+ state.level = Math.min(state.level, lad.levels.length - 1);
965
+ const lv = lad.levels[state.level];
966
+ const ok = stepScanners(lv);
967
+ const z = zoneFor(ok.length);
968
+ const need = Math.ceil(RELIABLE * lv.n);
969
+ const pick = (li) => { state.level = li; renderExplorer(); };
970
+ const pills = el('div', { class: 'pills', role: 'group', 'aria-label': 'Factor' }, DATA.ladders.map((l) => {
971
+ const b = el('button', { type: 'button', 'aria-pressed': String(l.key === state.factor), text: l.name });
972
+ b.addEventListener('click', () => { state.factor = l.key; state.level = 0; renderExplorer(); });
973
+ return b;
974
+ }));
975
+ let control;
976
+ if (lad.levels.length <= 3) { // a choice, not a scale: one button per step
977
+ control = el('div', { class: 'seg', role: 'group', 'aria-label': lad.name }, lad.levels.map((l, li) => {
978
+ const b = el('button', { type: 'button', 'aria-pressed': String(li === state.level), text: l.label });
979
+ b.addEventListener('click', () => pick(li));
980
+ return b;
981
+ }));
982
+ } else {
983
+ const slider = el('input', { type: 'range', class: 'range', min: '0', max: String(lad.levels.length - 1), step: '1',
984
+ value: String(state.level), 'aria-label': `${lad.name}: step`, 'aria-valuetext': lv.label });
985
+ slider.addEventListener('input', () => { pick(Number(slider.value)); box.querySelector('input.range')?.focus(); });
986
+ control = el('div', {}, [slider, el('div', { class: 'ends' }, [el('span', { text: 'Easier' }), el('span', { text: `Step ${state.level + 1} of ${lad.levels.length}` }), el('span', { text: 'Harder' })])]);
987
+ }
988
+ const rows = el('div', { class: 'rows' }, ORDER.map((key) => {
989
+ const r = lv.reads[decIndex(key)];
990
+ return el('div', { class: `row${r >= need ? ' ok' : ''}` }, [
991
+ el('span', { class: 'label', text: PLAIN[key][0] }),
992
+ el('span', { class: 'track' }, [el('span', { class: 'fill', style: `width:${(100 * r) / lv.n}%` }), el('span', { class: 'mark', style: `left:${100 * RELIABLE}%` })]),
993
+ el('span', { class: 'val', text: `${r}/${lv.n}` }),
994
+ ]);
995
+ }));
996
+ // a factor's steps, one column each; printed bigger lines up under the same versions of "Amount of data" (-1: none)
997
+ const columns = (l) => (l.key === 'version_big'
998
+ ? ladder('version').levels.map((lv) => l.levels.findIndex((s) => s.short === lv.short))
999
+ : l.levels.map((_, li) => li));
1000
+ const tips = el('div', { class: 'tips' }, [
1001
+ el('h3', { text: 'Every factor, every step' }),
1002
+ ...DATA.ladders.map((l) => el('div', { class: 'tip-row' }, [
1003
+ el('span', { class: 'label', text: l.name }),
1004
+ el('div', { class: 'tip-cells', style: `grid-template-columns:repeat(${columns(l).length}, minmax(0, 1fr))` },
1005
+ columns(l).map((li, col) => {
1006
+ if (li < 0) { // printed bigger, this version would not fit the picture
1007
+ const short = ladder('version').levels[col].short;
1008
+ return el('span', { class: 'tip-none', text: short, title: `${short.replace('v', 'Version ')} at ${pxOf(l.levels[0])} pixels per square doesn’t fit a ${DATA.ladderFrame} × ${DATA.ladderFrame * 3 / 4} picture at every angle` });
1009
+ }
1010
+ const step = l.levels[li];
1011
+ const n = stepScanners(step).length;
1012
+ const zz = zoneFor(n);
1013
+ const b = el('button', { type: 'button', text: step.short, 'aria-label': `${l.name}, ${step.label}: ${n} of ${ORDER.length} scanners`,
1014
+ style: `background:${css(zz.tone)};color:${onTone(zz)}` });
1015
+ const tip = (e) => showTip(e, [['v', `${n} of ${ORDER.length} scanners`], ['k', `${l.name}: ${step.label}`]]);
1016
+ b.addEventListener('pointermove', tip);
1017
+ b.addEventListener('pointerdown', tip);
1018
+ b.addEventListener('pointerleave', leaveTip);
1019
+ b.addEventListener('click', () => { state.factor = l.key; state.level = li; renderExplorer(); box.scrollIntoView({ behavior: 'smooth', block: 'start' }); });
1020
+ return b;
1021
+ })),
1022
+ ])),
1023
+ el('div', { class: 'key' }, ZONES.map((zz) => el('span', {}, [swatch(zz), zz.range ? `${zz.name} (${zz.range})` : zz.name]))),
1024
+ el('p', { class: 'fine', text: `A step counts as read when a scanner reads at least ${need} of its ${lv.n} codes, at a ${DATA.ladderFrame}-px camera image.` }),
1025
+ ]);
1026
+ const b = DATA.ladderBase;
1027
+ const link = (href, text) => el('a', { href, text, target: '_blank', rel: 'noopener' });
1028
+ const primer = el('div', { class: 'primer' }, [ // the steps every factor below breaks one of
1029
+ el('h3', { text: 'How a scanner reads a QR code' }),
1030
+ el('ol', {}, [
1031
+ el('li', {}, [el('strong', { text: `${READ_STEPS[0]}.` })]),
1032
+ el('li', {}, [el('strong', { text: `${READ_STEPS[1]}` }), ' by its three corner squares, whose ', link(DENSO_QR, '1:1:3:1:1 stripes'), ' stand out from other print.']),
1033
+ el('li', {}, [el('strong', { text: `${READ_STEPS[2]}` }), ' between them, helped on bigger codes by smaller ', link(DENSO_QR, 'alignment squares'), '.']),
1034
+ el('li', {}, [el('strong', { text: `${READ_STEPS[3]}` }), ', each at its place in the grid.']),
1035
+ el('li', {}, [el('strong', { text: `${READ_STEPS[4]}` }), ', which repairs damaged squares by the ', link(QR_EC, 'Reed–Solomon method'), '.']),
1036
+ ]),
1037
+ el('p', { class: 'fine', text: 'Each factor names the step it breaks.' }),
1038
+ ]);
1039
+ box.replaceChildren(
1040
+ primer,
1041
+ pills,
1042
+ el('p', { class: 'about', text: lad.about }),
1043
+ ...(lad.breaks ? [el('p', { class: 'about breaks' }, [ // the step it breaks, in bold as in the list of steps
1044
+ el('strong', { text: `${lad.breaks.split(': ')[0]}. ` }), capital(lad.breaks.split(': ').slice(1).join(': '))])] : []),
1045
+ el('div', { class: 'explore' }, [
1046
+ el('figure', { style: 'margin:0' }, [
1047
+ el('img', { src: imgUrl(lv.img), alt: `${lad.name}: ${lv.label}` }),
1048
+ el('div', { class: 'step', text: lv.label }),
1049
+ el('p', { class: 'fine', text: lad.window > LADDER_WINDOW
1050
+ ? `Shown at ${lad.window === 2 * LADDER_WINDOW ? 'half' : `1/${lad.window / LADDER_WINDOW} of`} the camera’s scale, so bigger codes fit: one pixel here is ${WORDS[lad.window / LADDER_WINDOW] ?? lad.window / LADDER_WINDOW} pixels of a ${DATA.ladderFrame}-pixel-wide image.`
1051
+ : `Shown at the camera’s scale: one pixel here is one pixel of a ${DATA.ladderFrame}-pixel-wide image.` }),
1052
+ el('p', { class: 'fine', text: `How it is made: ${lad.how} Everything else stays as the base code: ${join([['version', `version ${b.version}`],
1053
+ ['ppm', `${b.ppm} camera pixels per square`], ['contrast', `contrast ${Math.round(100 * b.contrast)}%`], ['tilt', `tilted ${b.tilt}°`],
1054
+ ['blur', `blur ${b.blur} px`]].filter(([k]) => !(OWN_SETTINGS[lad.key] || []).includes(k)).map(([, text]) => text))}.` }),
1055
+ ]),
1056
+ el('div', {}, [
1057
+ control,
1058
+ el('div', { class: 'headline' }, el('span', { class: 'step' }, [namedSwatch(z), groupText(ok.length)])),
1059
+ rows,
1060
+ el('p', { class: 'fine', text: `Each bar: how many of the ${lv.n} codes the scanner read. Dark bars pass the line at ${need} of ${lv.n}.` }),
1061
+ ]),
1062
+ ]),
1063
+ tips,
1064
+ );
1065
+ }
1066
+
1067
+ // ---- Android: the scanner depends on the app ---------------------------------------------
1068
+
1069
+ /** Each set's codes and every scanner's read rate on them, at frame index fi. */
1070
+ function setRates(fi) {
1071
+ return SETS.map((set) => {
1072
+ const items = [];
1073
+ for (let i = 0; i < N; i++) if (set.match(source(i))) items.push(i);
1074
+ const rate = Object.fromEntries(ORDER.map((k) => [k, items.reduce((acc, i) => acc + reads(i, fi, decIndex(k)), 0) / items.length]));
1075
+ return { ...set, items, rate };
1076
+ });
1077
+ }
1078
+
1079
+ /** Going from the base resolution to the highest, in one sentence: on any set, does a scanner read
1080
+ * at least a point more, or a point fewer? */
1081
+ function highNote() {
1082
+ const top = RESOLUTIONS[0].frame;
1083
+ const high = setRates(DATA.frames.indexOf(top));
1084
+ const base = setRates(DATA.frames.indexOf(BASE_FRAME));
1085
+ const diffs = high.flatMap((set, si) => ORDER.map((k) => set.rate[k] - base[si].rate[k]));
1086
+ const more = diffs.some((d) => d >= 0.01);
1087
+ const fewer = diffs.some((d) => d <= -0.01);
1088
+ const from = `From ${BASE_FRAME} to ${top} pixels`;
1089
+ if (more && fewer) return `${from}, some scanners read more and some read fewer.`;
1090
+ if (more) return `${from}, scanners read as many codes or more.`;
1091
+ if (fewer) return `${from}, scanners read as many codes or fewer.`;
1092
+ return '';
1093
+ }
1094
+
1095
+ function renderAndroid() {
1096
+ resolutionControl($('gridRes'), state.gridFrame, (f) => { state.gridFrame = f; renderAndroid(); });
1097
+ const sets = setRates(DATA.frames.indexOf(state.gridFrame));
1098
+ const sam = state.gridFrame === DATA.samsung.frame; // Samsung's scanner was read at one resolution
1099
+ for (const set of sets) set.samsung = sam ? samsungRate(set.match).rate : null;
1100
+ const android = ORDER.filter(isAndroid);
1101
+ const others = otherApps();
1102
+ const kind = (k) => (k === APPLE ? 'apple' : k === GOOGLE ? 'google' : k === HUAWEI ? 'huawei' : k === SETTINGS ? 'own' : 'other');
1103
+ const mark = (k, set) => el('span', { class: `mark ${kind(k)}`, style: `left:${100 * set.rate[k]}%`,
1104
+ title: `${PLAIN[k][0]}: ${percent(set.rate[k])} of ${set.items.length.toLocaleString()} ${set.noun}` });
1105
+ const samsungMark = (set) => (sam ? [el('span', { class: 'mark samsung', style: `left:${100 * set.samsung}%`,
1106
+ title: `Samsung’s scanner: about ${about(set.samsung).slice(1)} of ${set.noun}, estimated from a sample` })] : []);
1107
+ const spread = (set) => { // the measured Android scanners: Samsung's estimate stays a mark of its own
1108
+ const v = android.map((k) => set.rate[k]);
1109
+ return [Math.min(...v), Math.max(...v)];
1110
+ };
1111
+ $('skey').replaceChildren(
1112
+ el('span', {}, [el('span', { class: 'mark apple' }), 'Apple’s scanner']),
1113
+ el('span', {}, [el('span', { class: 'mark google' }), 'Google’s scanner']),
1114
+ el('span', {}, [el('span', { class: 'mark samsung' }), sam ? 'Samsung’s scanner, estimated' : `Samsung’s scanner: ${SAMSUNG_RES()} only`]),
1115
+ el('span', {}, [el('span', { class: 'mark huawei' }), 'Huawei’s scanner']),
1116
+ el('span', {}, [el('span', { class: 'mark own' }), 'Android Settings’ scanner']),
1117
+ el('span', {}, [el('span', { class: 'mark other' }), `${others.length} scanners other apps are built on`]),
1118
+ );
1119
+ $('strips').replaceChildren(
1120
+ el('div', { class: 'srow shead', 'aria-hidden': 'true' }, [el('span'), el('span'), el('span', { class: 'sval', text: 'Android range' })]),
1121
+ ...sets.map((set) => {
1122
+ const [lo, hi] = spread(set);
1123
+ return el('div', { class: 'srow', role: 'listitem', 'aria-label': `${set.label}: Apple’s scanner ${percent(set.rate[APPLE])}; Google’s ${percent(set.rate[GOOGLE])}; ${sam ? `Samsung’s about ${about(set.samsung).slice(1)}; ` : ''}Huawei’s ${percent(set.rate[HUAWEI])}; Android Settings’ ${percent(set.rate[SETTINGS])}; Android scanners from ${percent(lo)} to ${percent(hi)}` }, [
1124
+ el('span', { class: 'slabel', text: set.label }),
1125
+ el('span', { class: 'strack' }, [
1126
+ el('span', { class: 'sband', style: `left:${100 * lo}%;width:${100 * (hi - lo)}%` }),
1127
+ ...others.map((k) => mark(k, set)),
1128
+ mark(SETTINGS, set),
1129
+ mark(APPLE, set),
1130
+ ...samsungMark(set), // a hollow square: above Apple's ring, below Huawei's and Google's marks
1131
+ mark(HUAWEI, set), // above Apple's ring, so it shows where they meet
1132
+ mark(GOOGLE, set), // last: where it meets Apple's ring it shows inside it
1133
+ ]),
1134
+ el('span', { class: 'sval', text: percent(lo) === percent(hi) ? percent(lo) : `${percent(lo)}–${percent(hi)}` }),
1135
+ ]);
1136
+ }),
1137
+ el('div', { class: 'srow saxis', 'aria-hidden': 'true' }, [el('span'), el('span', { class: 'strack' }, [el('span', { text: '0%' }), el('span', { text: '50%' }), el('span', { text: '100% read' })]), el('span')]),
1138
+ );
1139
+ const [cleanLo] = spread(sets[0]);
1140
+ const [toughLo, toughHi] = spread(sets[2]);
1141
+ const width = (set) => spread(set)[1] - spread(set)[0];
1142
+ const widens = width(sets[0]) < width(sets[1]) && width(sets[1]) < width(sets[2]); // clean, everyday, tough prints
1143
+ $('androidTitle').textContent = widens ? 'The gap opens on harder codes' : 'The gap, set by set';
1144
+ $('stripNote').textContent = `On clean prints every Android scanner read at least ${percent(cleanLo)}. On tough prints an iPhone read ${percent(sets[2].rate[APPLE])}, and Android scanners from ${percent(toughLo)} to ${percent(toughHi)}.`;
1145
+ const head = el('thead', {}, el('tr', {}, [el('th', { scope: 'col', text: '' }), ...sets.map((set) => el('th', { scope: 'col', text: set.label }))]));
1146
+ const samsungRow = el('tr', {}, [
1147
+ el('th', { scope: 'row' }, [el('span', { class: 'name', text: 'Samsung’s scanner' }), el('span', { class: 'what', text: `Samsung’s Camera app; estimated from a sample, ${SAMSUNG_RES()} only` })]),
1148
+ ...sets.map((set) => el('td', sam
1149
+ ? { 'aria-label': `Samsung’s scanner, ${set.noun}: about ${about(set.samsung).slice(1)} read, estimated`, text: about(set.samsung) }
1150
+ : { 'aria-label': `Samsung’s scanner, ${set.noun}: read at ${SAMSUNG_RES()} resolution only`, text: '—' })),
1151
+ ]);
1152
+ const body = el('tbody', {}, ORDER.flatMap((k) => [el('tr', {}, [
1153
+ el('th', { scope: 'row' }, [el('span', { class: 'name', text: PLAIN[k][0] }), el('span', { class: 'what', text: PLAIN[k][1] })]),
1154
+ ...sets.map((set) => el('td', { 'aria-label': `${PLAIN[k][0]}, ${set.noun}: ${percent(set.rate[k])} read`, title: `${percent(set.rate[k])} of ${set.items.length.toLocaleString()} ${set.noun}`, text: percent(set.rate[k]) })),
1155
+ ]), ...(k === GOOGLE ? [samsungRow] : [])]));
1156
+ $('phones').replaceChildren(head, body);
1157
+ $('numbers').hidden = !state.showPct;
1158
+ const toggle = el('button', { class: 'linklike', type: 'button', text: state.showPct ? 'Hide the numbers' : 'Show the numbers' });
1159
+ toggle.addEventListener('click', () => { state.showPct = !state.showPct; renderAndroid(); });
1160
+ $('gridNote').replaceChildren(toggle);
1161
+ }
1162
+
1163
+ // ---- advice -----------------------------------------------------------------------------
1164
+
1165
+ function renderAdvice() {
1166
+ const fi = DATA.frames.indexOf(BASE_FRAME);
1167
+ const size = ladder('size');
1168
+ const colors = ladder('colors');
1169
+ const dots_ = ladder('dots');
1170
+ const pxMost = size.levels[Math.max(0, lastStep(size, mostMin()))].short;
1171
+ const pxAll = size.levels[Math.max(0, lastStep(size, ORDER.length))].short;
1172
+ const lightOk = stepScanners(colors.levels[colors.levels.length - 1]);
1173
+ const lost = sayNames(ORDER.filter((k) => !lightOk.includes(k)));
1174
+ const dotStep = dots_.levels[firstBelow(dots_, ORDER.length)];
1175
+ const dotLost = dotStep ? sayNames(ORDER.filter((k) => !stepScanners(dotStep).includes(k))) : [];
1176
+ const c = cornerFacts();
1177
+ const tough = [];
1178
+ for (let i = 0; i < N; i++) if (source(i).key === 'tough') tough.push(i);
1179
+ const share = (key) => tough.reduce((acc, i) => acc + reads(i, fi, decIndex(key)), 0) / tough.length;
1180
+ const rate = (key) => percent(share(key));
1181
+ const otherRates = otherApps().map(share);
1182
+ const items = [
1183
+ ['Size the print for the data.', 'More data means smaller squares at the same printed size. Print bigger as the data grows, or keep it short, such as with a short GS1 Digital Link domain.'],
1184
+ ['Print it big enough for the distance.', `Most scanners need about ${pxMost} camera pixels per square, and all ${allWord()} need ${pxAll}. Twice the distance needs twice the size.`],
1185
+ ['Keep it dark on light, made of solid squares.', `Light-on-dark codes lost ${join(lost)}.${dotStep ? ` Round dots, even ${dotStep.short} of a square wide, lost ${join(dotLost)}.` : ''}`],
1186
+ ['Keep the three corner squares clean.', c ? `Covering ${c.step.short} of one corner square, ${c.share} of the whole code, left no scanner reading 9 codes in 10.` : 'A covered corner square cost scanners at every step.'],
1187
+ ['Test with several phones and apps, not just an iPhone.', `On our tough prints an iPhone read ${rate(APPLE)}, Samsung’s scanner about ${about(samsungRate((s) => s.key === 'tough').rate).slice(1)}, and some apps’ scanners ${percent(Math.min(...otherRates))}.`],
1188
+ ];
1189
+ $('advice').replaceChildren(...items.map(([head, body]) => el('li', {}, el('div', {}, [el('strong', { text: head }), el('span', { text: body })]))));
1190
+ }
1191
+
1192
+ // ---- how we measured --------------------------------------------------------------------
1193
+
1194
+ /** The false-reading check, from the measured negatives: images with no code at all. */
1195
+ function falseReadsText() {
1196
+ const neg = DATA.checks.negatives;
1197
+ const fooled = ORDER.filter((k) => neg.falseReads[k] > 0);
1198
+ const result = fooled.length
1199
+ ? fooled.map((k) => `${PLAIN[k][0]} returned something on ${neg.falseReads[k]}`).join('; ')
1200
+ : `None of the ${allWord()} returned anything`;
1201
+ const sam = DATA.samsung.negatives;
1202
+ const kinds = sam ? Object.keys(sam.otherBarcodes.read) : [];
1203
+ const other = kinds.reduce((acc, k) => acc + sam.otherBarcodes.read[k], 0);
1204
+ const samsung = sam ? ` Samsung’s scanner read them too, on its phone: ${sam.falseReads ? `${sam.falseReads} false ${sam.falseReads === 1 ? 'reading' : 'readings'}` : 'no false readings'}${other ? `. Among the other barcode types it read the ${other} ${join(kinds)} codes, each correctly: it reads ${join(kinds)} as well as QR codes, while the scanners above, as we ran them, read QR codes only` : ''}.` : '';
1205
+ return `To check for false readings, all ${allWord()} scanners also read ${neg.n} images with no code, made to fool them with look-alike corner squares, checkerboards, clutter and other barcode types. ${result}.${samsung}`;
1206
+ }
1207
+
1208
+ /** How Samsung's scanner was read, and why its figures are estimates. */
1209
+ function samsungMethod() {
1210
+ const s = DATA.samsung;
1211
+ const [lo, hi] = s.range;
1212
+ return [
1213
+ `Samsung’s scanner runs only on Samsung phones, and reading pictures through a borrowed phone is slow, so it read a random sample of each set, ${s.counted.toLocaleString()} codes in all, at ${s.frame} px only, on a real ${s.phone} (Samsung Camera ${s.camera}, One UI ${s.oneUi}, Android ${s.android}) borrowed through Samsung’s Remote Test Lab. Each picture was opened from the phone’s gallery in that scanner, as a person opens a saved photo, inside a white border so that the crop box the gallery shows first keeps all of it. Its figures are estimates: its gap to the other scanners on the codes it read, added to their results on all of a set’s codes. On all codes that comes to about ${percent(s.estimate)}, between ${percent(lo)} and ${percent(hi)} with 95% confidence. Older Samsung phones may run an older version of its scanner.`,
1214
+ `For Samsung’s scanner, a read is the text on its result card, equal to the code’s content, and a miss is its own “Couldn’t read QR code”. Some content it never shows as text: contacts, Wi-Fi, calendar, SMS, location, email and phone codes and Facebook links go straight to an app, and binary content and JSON text show as an empty card. Its log shows it read them, but nothing on screen can be checked against the code, so the ${s.uncheckable} codes holding such content were left out of its comparison, for every scanner.`,
1215
+ ];
1216
+ }
1217
+
1218
+ function renderMethod() {
1219
+ const count = (pred) => DATA.sources.filter(pred).reduce((acc, s) => acc + s.count, 0);
1220
+ const made = count((s) => s.group === 'made' && s.key !== 'ladder');
1221
+ const real = count((s) => s.group === 'dataset');
1222
+ const ladders = count((s) => s.key === 'ladder');
1223
+ const src = (key) => count((s) => s.key === key).toLocaleString();
1224
+ const qrdn = DATA.sources.find((s) => s.key === 'qrdn').parts; // clean, tiled and distorted images
1225
+ const n = DATA.ladders[0].levels[0].n;
1226
+ const total = N * DATA.frames.length * DATA.decoders.length + ladders * DATA.decoders.length;
1227
+ const para = (t) => el('p', { text: t });
1228
+ const a = (href, text) => el('a', { href, text, target: '_blank', rel: 'noopener' });
1229
+ const list = (rows) => el('ul', {}, rows.map((t) => el('li', { text: t })));
1230
+ const strip = sv('svg', { class: 'chart', role: 'img', 'aria-label': 'Where each set of codes sits on the difficulty scale' });
1231
+ const sam = DATA.samsung;
1232
+ const [samHow, samRead] = samsungMethod();
1233
+ $('methodBody').replaceChildren(el('div', { class: 'method' }, [
1234
+ el('h3', { text: 'What we tested' }),
1235
+ para(`${made.toLocaleString()} codes made for this page: clean, everyday and tough prints captured by a simulated phone camera. ${real.toLocaleString()} codes from four public research datasets, most of them hardest cases: ${src('boofcv')} real photos (BoofCV), ${src('broken104')} real codes that fail (Broken QR 104), ${src('syn10k')} computer-made warped codes (syn10k), and ${src('qrdn')} from QR-DN1.0: ${qrdn.distorted.toLocaleString()} distorted, noisy codes, and ${(qrdn.clean + qrdn.tiled).toLocaleString()} easy ones, its clean originals (${qrdn.clean.toLocaleString()}) alone or tiled four to a picture (${qrdn.tiled.toLocaleString()}). And ${ladders.toLocaleString()} codes for the factors: one code, changed one step at a time, ${n} codes per step.`),
1236
+ el('h3', { text: 'How' }),
1237
+ para(`Every code on the scale was read at seven image resolutions, from ${DATA.frames[0]} down to ${DATA.frames[DATA.frames.length - 1]} pixels on the long side: “at ${BASE_FRAME} px” means larger images were shrunk to ${BASE_FRAME}, and smaller ones were read as they are, never enlarged. Most research-dataset images are 512 × 512, so only the settings below 640 shrink them. The factor images were read at ${DATA.ladderFrame}. All ${allWord()} scanners read every image at every setting: ${total.toLocaleString()} results. Smaller images stand in for apps with low-resolution camera feeds, and for codes seen from farther away.`),
1238
+ para(samHow),
1239
+ el('h3', { text: 'What counts as a read' }),
1240
+ para('Only the code’s exact content, byte for byte. Finding a code without reading it, or reading it wrong, is a miss; so is anything slower than 20 seconds.'),
1241
+ para(`The right content comes with each code: we made it, or its dataset published it. Two datasets come without it: BoofCV’s photos, and QR-DN, whose distorted images take the content of their clean originals. There the content is what other decoders read identically, and a code is kept only if at least two decoders other than the scanner being scored agree. That left out ${DATA.checks.dependentTruth} photos.`),
1242
+ para(samRead),
1243
+ para(falseReadsText()),
1244
+ el('h3', { text: 'How many scanners read it' }),
1245
+ para(`One rule throughout. A single code is read by a scanner, or not; its shade counts the scanners that read it at ${BASE_FRAME} px: ${ZONES[0].words}, ${ZONES[1].words}, ${ZONES[2].words} or ${ZONES[3].words}; Samsung’s, read only on a sample, is not among the ${allWord()}. A group of codes, such as a factor step or a set, counts a scanner when it reads at least 9 in 10 of them: for a factor step, ${Math.ceil(RELIABLE * DATA.ladders[0].levels[0].n)} of its ${DATA.ladders[0].levels[0].n} codes.`),
1246
+ el('h3', { text: 'Where a code sits' }),
1247
+ para(`Only the reads decide: a standard item-response model places each code where scanner-and-resolution pairs start to fail, across all seven image resolutions. Codes every pair read sit at the far left; codes none read, at the far right. The curve on the scale is an illustrative mix: half everyday prints, a fifth each clean and tough, a tenth hardest cases.`),
1248
+ strip,
1249
+ el('h3', { text: 'Scanners' }),
1250
+ el('ul', {}, ORDER.map(decoder).flatMap((d) => [
1251
+ el('li', {}, [`${d.name}: ${d.detail}. `, d.key === GOOGLE ? `${versionsNote()} ` : '', d.key === SETTINGS ? `${invertedNote()} ` : '', ...see(SCANNER_PAGES[d.key])]),
1252
+ ...(d.key === GOOGLE ? [el('li', {}, [`Samsung’s scanner: the QR scanner in Samsung’s Camera app ${sam.camera}, which the QR button in Quick Settings opens on Samsung phones, read on a real ${sam.phone} (${sam.model}, One UI ${sam.oneUi}, Android ${sam.android}), each picture opened from its gallery. Samsung doesn’t name its decoder, but the phone shows whose it is: the Camera app decodes with a library that carries the name of Quram, a Korean imaging-software company, and the decoding engine names itself in the phone’s log. Quram sells a QR and barcode scanner of its own and names Samsung among its customers. `,
1253
+ ...see([[SAMSUNG_QR, 'Samsung’s guide to scanning QR codes'], [QURAM, 'Quram (Quramsoft)'], [SAMSUNG_RTL, 'Samsung’s Remote Test Lab, where we borrowed the phone']])])] : []),
1254
+ ])),
1255
+ el('h3', { text: 'Smartphones in the world' }),
1256
+ el('p', {}, [`People: at the end of 2025, ${WORLD_PHONES.people.billions} billion people, ${WORLD_PHONES.people.share}% of the world’s population, were using their own smartphone to go online (`,
1257
+ a(WORLD_PHONES.people.url, 'GSMA, The State of Mobile Internet Connectivity 2026'), ', p. 32). It counts people, not phones or SIM cards. Counting any mobile phone, ',
1258
+ `${WORLD_PHONES.anyPhone.share}% of people aged 10 and over own one (`, a(WORLD_PHONES.anyPhone.url, 'ITU, Facts and Figures 2025'), ').']),
1259
+ el('p', {}, ['Phones in use: “nearly one in four active smartphones is an iPhone” (', a(WORLD_PHONES.inUse.url, 'Counterpoint Research'),
1260
+ ', installed base in 2025, published February 2026); with more than a billion iPhones, the other three in four are more than 3 billion. The rest are almost all Android; a few percent are Huawei phones on HarmonyOS. Devices with Google Play, which carries Google’s scanner: Google says its enhanced fraud protection now covers “more than 2.8 billion Android devices” in 185 markets (',
1261
+ a(WORLD_PHONES.googlePlay.url, 'Google, February 2026'), '). New phones: iPhones were ',
1262
+ `${WORLD_PHONES.shipped.share}% of the ${WORLD_PHONES.shipped.total} billion smartphones shipped in 2025 (`, a(WORLD_PHONES.shipped.url, 'IDC'), ').']),
1263
+ el('p', {}, ['Web counters give iPhones more: ', a(WORLD_PHONES.web.url, 'StatCounter'),
1264
+ `, often quoted, shows about ${WORLD_PHONES.web.ios}% of mobile page views in September 2026. It counts page views on the websites that carry its code, so it measures browsing, not phones.`]),
1265
+ el('h3', { text: 'Sources' }),
1266
+ el('ul', {}, [
1267
+ el('li', {}, `Every read, rate and boundary on this page: our own measurements, ${total.toLocaleString()} results, and ${(sam.measured + (sam.negatives?.n ?? 0)).toLocaleString()} read on a Samsung phone.`),
1268
+ el('li', {}, ['Android’s QR button: on the lock screen and in Quick Settings it opens the scanner a setting names; Android’s own code says that “on the eligible devices this activity is provided by GMS core”, Google Play services (', a(AOSP_QR_BUTTON, 'SystemUiDeviceConfigFlags.java'), '). The phone maker’s default is empty in Android’s code (', a(AOSP_QR_DEFAULT, 'config.xml'), '); on Google’s own Android 14 image it is set to Play services’ ML Kit barcode scanner (com.google.android.gms/.mlkit.barcode.ui.PlatformBarcodeScanningActivityProxy), which we checked on the emulator we measured.']),
1269
+ el('li', {}, [`Samsung’s QR button: on the ${sam.phone} we read, the “Scan QR code” button in Quick Settings belongs to Samsung’s Camera app and opens its scanner, though the phone’s Android setting names Google’s. We checked on the phone, borrowed through `, a(SAMSUNG_RTL, 'Samsung’s Remote Test Lab'), '.']),
1270
+ el('li', {}, ['Android Settings’ scanner: ', a(ANDROID_SOURCE, 'QrCamera.java'), ' in Android’s Settings library, which decodes Wi-Fi QR codes with ZXing; Android ships ZXing 3.5.2 (', a(AOSP_ZXING, 'METADATA'), ').']),
1271
+ el('li', {}, ['Huawei’s scanner: Scan Kit is the scanning service Huawei documents for HarmonyOS and for HMS Core, the services layer of its Android phones (', a(HUAWEI_HARMONY, 'HarmonyOS'), ', ', a(HUAWEI_KIT, 'HMS Core'), '). We checked that the three models in the kit we measured are byte-identical to those in HMS Core 6.16.2.300.']),
1272
+ el('li', {}, ['Image resolution: CameraX analyses frames at 640 × 480 unless an app asks for more (', a(CAMERAX_DOCS, 'Android developer documentation'), ').']),
1273
+ el('li', {}, 'Smartphones in the world: GSMA, ITU, Counterpoint Research, Google and IDC, as linked above, each quoted from the publisher.'),
1274
+ el('li', {}, 'Research datasets: listed under Codes and credits, each used under its own terms.'),
1275
+ ]),
1276
+ el('h3', { text: 'Not included' }),
1277
+ para('Not yet: the scanners other phone makers build into their phones, such as Xiaomi’s, OPPO’s, vivo’s, Transsion’s and Honor’s. Each runs only on its maker’s phones, so it can be measured only on real devices borrowed from a test lab, far more slowly than the scanners above. Samsung’s came first, for its more than a billion phones, and even it was read on a sample of the codes; the others are left for future work. Huawei’s is here because Huawei offers its scanner to developers as a kit.'),
1278
+ para('Never: the text inside the codes. The page doesn’t print it.'),
1279
+ el('h3', { text: 'Codes and credits' }),
1280
+ list(DATA.sources.map((s) => `${s.name}: ${s.count.toLocaleString()} images. ${s.detail}.`)),
1281
+ el('p', {}, ['The codes made for this page, with their contents and every setting used to make them, and which scanners read each one, are published as a dataset: ',
1282
+ a(SYNTH_DATASET, 'QR Scan Difficulty: synthetic codes'), '. Under each example code, a link opens the exact picture the scanners read.']),
1283
+ para('Images from the research datasets are cropped and resized here; a code in an image can be read by any phone. To have an image removed, open a discussion on its dataset page. Payloads of codes made here are invented.'),
1284
+ el('h3', { text: 'Why we made this' }),
1285
+ para(PURPOSE),
1286
+ el('h3', { text: 'Disclosures' }),
1287
+ para(DISCLOSURE),
1288
+ para(`Built ${DATA.built}.`),
1289
+ ]));
1290
+ renderStrip(strip);
1291
+ }
1292
+
1293
+ function renderStrip(svg) {
1294
+ const W = svg.clientWidth || 700;
1295
+ const left = W < 640 ? 112 : 180;
1296
+ const rows = DATA.sources.filter((s) => s.key !== 'ladder');
1297
+ const rowH = 22;
1298
+ const H = rows.length * rowH + 26;
1299
+ const x = (d) => left + ((Math.max(X_MIN, Math.min(X_MAX, d)) - X_MIN) / (X_MAX - X_MIN)) * (W - left - 4);
1300
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
1301
+ svg.setAttribute('height', H);
1302
+ svg.replaceChildren();
1303
+ rows.forEach((s, r) => {
1304
+ const si = DATA.sources.indexOf(s);
1305
+ const ds = DATA.items.d.filter((_, i) => DATA.items.s[i] === si).sort((p, q) => p - q);
1306
+ const q = (f) => ds[Math.round(f * (ds.length - 1))];
1307
+ const yy = r * rowH + rowH / 2;
1308
+ sv('text', { x: 0, y: yy + 4, text: s.name }, svg);
1309
+ sv('line', { x1: x(q(0.1)), x2: x(q(0.9)), y1: yy, y2: yy, stroke: css('--ink-3'), 'stroke-width': 1 }, svg);
1310
+ sv('rect', { x: x(q(0.25)), y: yy - 3, width: Math.max(2, x(q(0.75)) - x(q(0.25))), height: 6, rx: 3, fill: css('--ink-3') }, svg);
1311
+ sv('circle', { cx: x(q(0.5)), cy: yy, r: 4, fill: css('--ink'), stroke: css('--surface'), 'stroke-width': 2 }, svg);
1312
+ });
1313
+ sv('text', { x: x(X_MIN), y: H - 4, class: 'muted', text: '← Easier' }, svg);
1314
+ sv('text', { x: x(X_MAX), y: H - 4, class: 'muted', 'text-anchor': 'end', text: 'Harder →' }, svg);
1315
+ }
1316
+
1317
+ // ---- wiring -----------------------------------------------------------------------------
1318
+
1319
+ function renderLede() {
1320
+ $('lede').textContent = `Every code on one scale, from easy to hard, shaded by how many of ${allWord()} scanners read it: Apple’s, Google’s, Huawei’s, Android Settings’ and ${WORDS[otherApps().length]} that apps use. Move the slider to see a code, and try it with your phone.`;
1321
+ }
1322
+
1323
+ function renderAll() {
1324
+ renderVersus();
1325
+ renderIntro();
1326
+ renderWho();
1327
+ renderLede();
1328
+ renderKey();
1329
+ renderBell();
1330
+ renderReadout();
1331
+ renderSpecimen();
1332
+ renderTakeaway();
1333
+ renderPairs();
1334
+ renderAndroid();
1335
+ renderAdvice();
1336
+ if ($('lanesMore').open) renderLanes();
1337
+ if ($('factorsMore').open) renderExplorer();
1338
+ if ($('method').open) renderMethod();
1339
+ }
1340
+
1341
+ async function main() {
1342
+ const params = new URLSearchParams(location.search);
1343
+ const theme = params.get('theme');
1344
+ if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme;
1345
+ DATA = await (await fetch('data/site.json', { cache: 'no-cache' })).json(); // always ask the server: never a stale copy
1346
+ RES = decodeResults(DATA);
1347
+ NF = DATA.frames.length;
1348
+ N = DATA.items.s.length;
1349
+ ORDER = DATA.displayOrder;
1350
+ ZONES = makeZones(ORDER.length);
1351
+ const fi = DATA.frames.indexOf(BASE_FRAME);
1352
+ COUNT = Uint8Array.from({ length: N }, (_, i) => ORDER.reduce((s, k) => s + reads(i, fi, decIndex(k)), 0));
1353
+ BELL = bellData();
1354
+ HIGH_NOTE = highNote();
1355
+ if (params.has('d') && Number.isFinite(Number(params.get('d')))) state.d = Math.max(PILE_LO, Math.min(PILE_HI, Number(params.get('d'))));
1356
+ if (params.has('factor') && DATA.ladders.some((l) => l.key === params.get('factor'))) state.factor = params.get('factor');
1357
+ if (params.has('step')) state.level = Math.max(0, Number(params.get('step')) || 0);
1358
+ for (const id of ['lanesMore', 'factorsMore', 'method']) if (params.get('open')?.split(',').includes(id)) $(id).open = true;
1359
+ $('cursor').value = String(state.d);
1360
+ $('cursor').addEventListener('input', (e) => setCursor(snap(Number(e.target.value))));
1361
+ $('lanesMore').addEventListener('toggle', () => { if ($('lanesMore').open) renderLanes(); });
1362
+ $('factorsMore').addEventListener('toggle', () => { if ($('factorsMore').open) renderExplorer(); });
1363
+ $('method').addEventListener('toggle', () => { if ($('method').open) renderMethod(); });
1364
+ renderAll();
1365
+ document.addEventListener('pointerdown', (e) => { if (e.pointerType === 'touch') hideTip(); }, true); // before a tapped tip shows
1366
+ window.addEventListener('scroll', hideTip, { passive: true });
1367
+ let raf = 0;
1368
+ new ResizeObserver(() => {
1369
+ cancelAnimationFrame(raf);
1370
+ raf = requestAnimationFrame(() => {
1371
+ renderBell();
1372
+ if ($('lanesMore').open) renderLanes();
1373
+ if ($('method').open) renderMethod();
1374
+ });
1375
+ }).observe(document.querySelector('.page'));
1376
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', renderAll);
1377
+ }
1378
+
1379
+ main().catch((err) => {
1380
+ $('lede').textContent = `Could not load the measurements: ${err.message}`;
1381
+ });
data/site.json ADDED
The diff for this file is too large to render. See raw diff
 
img/e/0000.jpg ADDED
img/e/0001.jpg ADDED
img/e/0002.jpg ADDED
img/e/0003.jpg ADDED
img/e/0004.jpg ADDED
img/e/0005.jpg ADDED
img/e/0006.jpg ADDED
img/e/0007.jpg ADDED
img/e/0008.jpg ADDED
img/e/0009.jpg ADDED
img/e/0010.jpg ADDED
img/e/0011.jpg ADDED
img/e/0012.jpg ADDED
img/e/0013.jpg ADDED
img/e/0014.jpg ADDED
img/e/0015.jpg ADDED
img/e/0016.jpg ADDED
img/e/0017.jpg ADDED
img/e/0018.jpg ADDED
img/e/0019.jpg ADDED
img/e/0020.jpg ADDED
img/e/0021.jpg ADDED
img/e/0022.jpg ADDED
img/e/0023.jpg ADDED
img/e/0024.jpg ADDED
img/e/0025.jpg ADDED
img/e/0026.jpg ADDED
img/e/0027.jpg ADDED
img/e/0028.jpg ADDED
img/e/0029.jpg ADDED
img/e/0030.jpg ADDED
img/e/0031.jpg ADDED
img/e/0032.jpg ADDED
img/e/0033.jpg ADDED
img/e/0034.jpg ADDED
img/e/0035.jpg ADDED
img/e/0036.jpg ADDED
img/e/0037.jpg ADDED
img/e/0038.jpg ADDED
img/e/0039.jpg ADDED
img/e/0040.jpg ADDED
img/e/0041.jpg ADDED
img/e/0042.jpg ADDED
img/e/0043.jpg ADDED
img/e/0044.jpg ADDED
img/e/0045.jpg ADDED
img/e/0046.jpg ADDED