Viewing is open to everyone. Downloading and starring need a GitHub sign-in.
Sign in with GitHub
ckey_bench.cpp
v0.1.0-build.85
Latest
Commit ede38f5
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | // CKey optical benchmark harness (specification §5, §6). |
| 2 | // |
| 3 | // Generate -> degrade -> decode -> measure, with the real CKey engine and |
| 4 | // the same frames for every symbology. For each payload, acquisition |
| 5 | // condition and printed side (in camera pixels, including each symbology's |
| 6 | // quiet zone, so symbols are compared at equal area) it measures: |
| 7 | // - payload recovery rate (exact bytes), |
| 8 | // - corrected Reed-Solomon bytes (a bit-error proxy), |
| 9 | // - per-frame latency (find borders -> refine -> decode), as percentiles, |
| 10 | // - the smallest side that decodes in >= 90% of trials, and the payload |
| 11 | // bytes recoverable per 1,000 px^2 at that size, |
| 12 | // plus a false-positive count on symbol-free clutter frames. |
| 13 | // |
| 14 | // Built with -DCKEY_BENCH_ZXING=ON and zxing-cpp installed, the same frames |
| 15 | // are also generated and read as QR Code and Data Matrix for comparison. |
| 16 | // All frames are synthetic; results are not real-camera measurements. |
| 17 | // |
| 18 | // ckey-bench [--quick] [--json out.json] [--md out.md] |
| 19 | "ckey/ckey.h" |
| 20 | |
| 21 | |
| 22 | <ZXing/BitMatrix.h> |
| 23 | <ZXing/MultiFormatWriter.h> |
| 24 | <ZXing/ReadBarcode.h> |
| 25 | |
| 26 | |
| 27 | <algorithm> |
| 28 | <chrono> |
| 29 | <cmath> |
| 30 | <cstdio> |
| 31 | <cstring> |
| 32 | <fstream> |
| 33 | <functional> |
| 34 | <iostream> |
| 35 | <memory> |
| 36 | <sstream> |
| 37 | <string> |
| 38 | <vector> |
| 39 | |
| 40 | namespace { |
| 41 | |
| 42 | struct Rng { uint32_t s; uint32_t next() { s = s * 1664525u + 1013904223u; return s; } double unit() { return (next() >> 8) / 16777216.0; } |
| 43 | double gauss() { const double u1 = unit() + 1e-9, u2 = unit(); return std::sqrt(-2 * std::log(u1)) * std::cos(6.283185307179586 * u2); } }; |
| 44 | |
| 45 | struct Condition { const char *name; double blur, noise, rotation_deg, keystone, glare; }; |
| 46 | struct Image { int side = 0; std::vector<uint8_t> px; }; // square symbol raster, quiet zone included |
| 47 | struct Outcome { bool ok = false; double ms = 0; int corrected = 0; }; |
| 48 | |
| 49 | struct Symbology { |
| 50 | std::string name; |
| 51 | std::function<bool(const std::string &, int side, Image &)> render; // false: payload does not fit |
| 52 | std::function<Outcome(const std::vector<uint8_t> &, int, int, const std::string &)> scan; |
| 53 | }; |
| 54 | |
| 55 | double now_ms() { return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now().time_since_epoch()).count(); } |
| 56 | |
| 57 | void solve(const double src[8], const double dst[8], double h[8]) { |
| 58 | double a[8][9]{}; |
| 59 | for (int i = 0; i < 4; ++i) { |
| 60 | const double x = src[2 * i], y = src[2 * i + 1], u = dst[2 * i], v = dst[2 * i + 1]; |
| 61 | const double r0[9] = {x, y, 1, 0, 0, 0, -u * x, -u * y, u}, r1[9] = {0, 0, 0, x, y, 1, -v * x, -v * y, v}; |
| 62 | for (int j = 0; j < 9; ++j) { a[2 * i][j] = r0[j]; a[2 * i + 1][j] = r1[j]; } |
| 63 | } |
| 64 | for (int c = 0; c < 8; ++c) { |
| 65 | int p = c; for (int r = c + 1; r < 8; ++r) if (std::abs(a[r][c]) > std::abs(a[p][c])) p = r; |
| 66 | for (int j = 0; j < 9; ++j) std::swap(a[p][j], a[c][j]); |
| 67 | for (int j = 8; j >= c; --j) a[c][j] /= a[c][c]; |
| 68 | for (int r = 0; r < 8; ++r) if (r != c) { const double f = a[r][c]; for (int j = c; j < 9; ++j) a[r][j] -= f * a[c][j]; } |
| 69 | } |
| 70 | for (int i = 0; i < 8; ++i) h[i] = a[i][8]; |
| 71 | } |
| 72 | |
| 73 | void blur(std::vector<double> &img, int w, int h, double sigma) { |
| 74 | if (sigma <= 0.05) return; |
| 75 | const int r = std::max(1, static_cast<int>(std::ceil(3 * sigma))); |
| 76 | std::vector<double> k(static_cast<size_t>(2 * r + 1)); double sum = 0; |
| 77 | for (int i = -r; i <= r; ++i) sum += k[static_cast<size_t>(i + r)] = std::exp(-0.5 * i * i / (sigma * sigma)); |
| 78 | for (auto &v : k) v /= sum; |
| 79 | std::vector<double> t(img.size()); |
| 80 | for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) { double a = 0; for (int i = -r; i <= r; ++i) a += k[static_cast<size_t>(i + r)] * img[static_cast<size_t>(y) * w + std::clamp(x + i, 0, w - 1)]; t[static_cast<size_t>(y) * w + x] = a; } |
| 81 | for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) { double a = 0; for (int i = -r; i <= r; ++i) a += k[static_cast<size_t>(i + r)] * t[static_cast<size_t>(std::clamp(y + i, 0, h - 1)) * w + x]; img[static_cast<size_t>(y) * w + x] = a; } |
| 82 | } |
| 83 | |
| 84 | // A camera frame: lit gradient, clutter rectangles, the symbol warped by |
| 85 | // rotation and keystone, then glare, defocus and sensor noise. |
| 86 | std::vector<uint8_t> make_frame(const Image *sym, int W, int H, const Condition &c, uint32_t seed) { |
| 87 | Rng rng{seed}; |
| 88 | std::vector<double> f(static_cast<size_t>(W) * H); |
| 89 | for (int y = 0; y < H; ++y) for (int x = 0; x < W; ++x) f[static_cast<size_t>(y) * W + x] = 0.35 + 0.25 * x / W + 0.15 * y / H; |
| 90 | for (int k = 0; k < 16; ++k) { |
| 91 | const int x0 = static_cast<int>(rng.unit() * W), y0 = static_cast<int>(rng.unit() * H), w = 10 + static_cast<int>(rng.unit() * W / 5), h = 10 + static_cast<int>(rng.unit() * H / 5); |
| 92 | const double v = rng.unit(); |
| 93 | for (int y = y0; y < std::min(H, y0 + h); ++y) for (int x = x0; x < std::min(W, x0 + w); ++x) f[static_cast<size_t>(y) * W + x] = v; |
| 94 | } |
| 95 | if (sym) { |
| 96 | const int S = sym->side; |
| 97 | const double cx = W / 2.0 + (rng.unit() - .5) * W * 0.2, cy = H / 2.0 + (rng.unit() - .5) * H * 0.2, half = S / 2.0, a = c.rotation_deg * 3.14159265358979 / 180; |
| 98 | double dst[8]; const double corner[4][2] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}}; |
| 99 | for (int i = 0; i < 4; ++i) { |
| 100 | double x = corner[i][0] * half, y = corner[i][1] * half; |
| 101 | x *= 1.0 - c.keystone * (corner[i][1] < 0 ? 1 : 0); |
| 102 | dst[2 * i] = cx + x * std::cos(a) - y * std::sin(a); dst[2 * i + 1] = cy + x * std::sin(a) + y * std::cos(a); |
| 103 | } |
| 104 | const double src[8] = {0, 0, static_cast<double>(S), 0, static_cast<double>(S), static_cast<double>(S), 0, static_cast<double>(S)}; |
| 105 | double inv[8]; solve(dst, src, inv); |
| 106 | for (int y = 0; y < H; ++y) for (int x = 0; x < W; ++x) { |
| 107 | const double d = inv[6] * x + inv[7] * y + 1, u = (inv[0] * x + inv[1] * y + inv[2]) / d - .5, v = (inv[3] * x + inv[4] * y + inv[5]) / d - .5; |
| 108 | if (u < 0 || v < 0 || u >= S - 1 || v >= S - 1) continue; |
| 109 | const int iu = static_cast<int>(u), iv = static_cast<int>(v); const double fu = u - iu, fv = v - iv; |
| 110 | const auto at = [&](int xx, int yy) { return sym->px[static_cast<size_t>(yy) * S + xx] / 255.0; }; |
| 111 | f[static_cast<size_t>(y) * W + x] = (at(iu, iv) * (1 - fu) + at(iu + 1, iv) * fu) * (1 - fv) + (at(iu, iv + 1) * (1 - fu) + at(iu + 1, iv + 1) * fu) * fv; |
| 112 | } |
| 113 | } |
| 114 | for (auto &v : f) v = v * (1 - c.glare) + c.glare; |
| 115 | blur(f, W, H, c.blur); |
| 116 | std::vector<uint8_t> out(f.size()); |
| 117 | for (size_t i = 0; i < f.size(); ++i) out[i] = static_cast<uint8_t>(std::lround(std::clamp(f[i] + rng.gauss() * c.noise, 0.0, 1.0) * 255)); |
| 118 | return out; |
| 119 | } |
| 120 | |
| 121 | // ---- CKey (Profile 0, level M, default style) ---- |
| 122 | |
| 123 | bool render_ckey(const std::string &text, int side, Image &img) { |
| 124 | auto s = std::make_unique<ckey_scalable_symbol>(); ckey_diagnostics d{}; |
| 125 | if (ckey_encode_scalable(reinterpret_cast<const uint8_t *>(text.data()), text.size(), 0, CKEY_ECC_M, CKEY_LAYOUT_STANDARD, s.get(), &d) != CKEY_OK) return false; |
| 126 | ckey_raster_options o; ckey_raster_options_default(&o); |
| 127 | o.size_px = static_cast<uint32_t>(std::max(16, side)); o.format = CKEY_PIXELS_GRAY8; |
| 128 | img.side = static_cast<int>(o.size_px); img.px.resize(static_cast<size_t>(img.side) * img.side); size_t n = 0; |
| 129 | return ckey_render_scalable_raster(s.get(), nullptr, &o, img.px.data(), img.px.size(), &n) == CKEY_OK; |
| 130 | } |
| 131 | |
| 132 | Outcome scan_ckey(const std::vector<uint8_t> &frame, int w, int h, const std::string &expected) { |
| 133 | Outcome r; const double t0 = now_ms(); |
| 134 | double borders[6 * 8]; |
| 135 | const size_t n = ckey_find_borders_gray8(frame.data(), w, h, w, borders, 6); |
| 136 | static std::vector<uint8_t> out(65535); |
| 137 | for (size_t i = 0; i < n && !r.ok; ++i) { |
| 138 | double q[8]; size_t len = 0; uint32_t version = 0; ckey_diagnostics d{}; |
| 139 | if (ckey_refine_quad_gray8(frame.data(), w, h, w, borders + 8 * i, CKEY_FIELD_SCALABLE, q) > 0 && |
| 140 | ckey_decode_scalable_gray8(frame.data(), w, h, w, q, out.data(), out.size(), &len, &version, nullptr, &d) == CKEY_OK) { |
| 141 | r.ok = len == expected.size() && std::memcmp(out.data(), expected.data(), len) == 0; |
| 142 | r.corrected = static_cast<int>(d.corrected_symbols); |
| 143 | if (!r.ok) r.corrected = -1; // decoded something else: a false positive |
| 144 | } |
| 145 | } |
| 146 | r.ms = now_ms() - t0; |
| 147 | return r; |
| 148 | } |
| 149 | |
| 150 | |
| 151 | // zxing-cpp 2.x. Quiet zones: 4 modules (QR), 2 modules (Data Matrix). |
| 152 | bool render_zxing(ZXing::BarcodeFormat format, int quiet, const std::string &text, int side, Image &img) { |
| 153 | try { |
| 154 | ZXing::MultiFormatWriter writer(format); |
| 155 | writer.setMargin(0); |
| 156 | if (format == ZXing::BarcodeFormat::QRCode) writer.setEccLevel(4); // zxing-cpp maps 0..8 onto L/M/Q/H; 4 = M |
| 157 | const ZXing::BitMatrix m = writer.encode(text, 0, 0); |
| 158 | const int modules = m.width() + 2 * quiet; |
| 159 | img.side = side; img.px.assign(static_cast<size_t>(side) * side, 255); |
| 160 | const int ss = 3; |
| 161 | for (int y = 0; y < side; ++y) for (int x = 0; x < side; ++x) { |
| 162 | int ink = 0; |
| 163 | for (int sy = 0; sy < ss; ++sy) for (int sx = 0; sx < ss; ++sx) { |
| 164 | const int mx = static_cast<int>(std::floor((x + (sx + .5) / ss) * modules / side)) - quiet, my = static_cast<int>(std::floor((y + (sy + .5) / ss) * modules / side)) - quiet; |
| 165 | ink += mx >= 0 && my >= 0 && mx < m.width() && my < m.height() && m.get(mx, my); |
| 166 | } |
| 167 | img.px[static_cast<size_t>(y) * side + x] = static_cast<uint8_t>(255 - 255 * ink / (ss * ss)); |
| 168 | } |
| 169 | return true; |
| 170 | } catch (...) { return false; } |
| 171 | } |
| 172 | |
| 173 | Outcome scan_zxing(ZXing::BarcodeFormat format, const std::vector<uint8_t> &frame, int w, int h, const std::string &expected) { |
| 174 | Outcome r; const double t0 = now_ms(); |
| 175 | ZXing::DecodeHints hints; hints.setFormats(format); hints.setTryHarder(true); |
| 176 | const auto result = ZXing::ReadBarcode(ZXing::ImageView(frame.data(), w, h, ZXing::ImageFormat::Lum), hints); |
| 177 | r.ms = now_ms() - t0; |
| 178 | if (result.isValid()) { r.ok = result.text() == expected; if (!r.ok) r.corrected = -1; } |
| 179 | return r; |
| 180 | } |
| 181 | |
| 182 | |
| 183 | std::string payload_text(size_t n, uint32_t seed) { |
| 184 | static const char *alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-._~"; |
| 185 | std::string s = "https://openckey.org/"; |
| 186 | Rng r{seed}; while (s.size() < n) s += alphabet[r.next() % 40]; |
| 187 | s.resize(n); return s; |
| 188 | } |
| 189 | |
| 190 | double pct(std::vector<double> v, double q) { if (v.empty()) return 0; std::sort(v.begin(), v.end()); return v[std::min(v.size() - 1, static_cast<size_t>(q * (v.size() - 1) + 0.5))]; } |
| 191 | |
| 192 | } // namespace |
| 193 | |
| 194 | int main(int argc, char **argv) { |
| 195 | bool quick = false; std::string json_path, md_path; |
| 196 | for (int i = 1; i < argc; ++i) { |
| 197 | const std::string a = argv[i]; |
| 198 | if (a == "--quick") quick = true; |
| 199 | else if (a == "--json" && i + 1 < argc) json_path = argv[++i]; |
| 200 | else if (a == "--md" && i + 1 < argc) md_path = argv[++i]; |
| 201 | else { std::cerr << "usage: ckey-bench [--quick] [--json out.json] [--md out.md]\n"; return 2; } |
| 202 | } |
| 203 | const int W = 960, H = 720, trials = quick ? 4 : 10, fp_frames = quick ? 60 : 300; |
| 204 | const std::vector<size_t> payloads = quick ? std::vector<size_t>{33, 120} : std::vector<size_t>{24, 33, 80, 120, 300}; |
| 205 | const std::vector<int> sides = quick ? std::vector<int>{70, 100, 140, 200, 280} : std::vector<int>{60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340}; |
| 206 | const std::vector<Condition> conditions = { |
| 207 | {"clean", 0.0, 0.01, 0, 0, 0.0}, |
| 208 | {"camera", 1.0, 0.03, 15, 0.06, 0.15}, |
| 209 | {"hard", 1.8, 0.06, 30, 0.10, 0.30}, |
| 210 | }; |
| 211 | std::vector<Symbology> syms = {{"CKey P0 (M)", render_ckey, scan_ckey}}; |
| 212 | |
| 213 | syms.push_back({"QR Code (M)", [](const std::string &t, int s, Image &i) { return render_zxing(ZXing::BarcodeFormat::QRCode, 4, t, s, i); }, |
| 214 | [](const std::vector<uint8_t> &f, int w, int h, const std::string &e) { return scan_zxing(ZXing::BarcodeFormat::QRCode, f, w, h, e); }}); |
| 215 | syms.push_back({"Data Matrix", [](const std::string &t, int s, Image &i) { return render_zxing(ZXing::BarcodeFormat::DataMatrix, 2, t, s, i); }, |
| 216 | [](const std::vector<uint8_t> &f, int w, int h, const std::string &e) { return scan_zxing(ZXing::BarcodeFormat::DataMatrix, f, w, h, e); }}); |
| 217 | |
| 218 | std::ostringstream md, js; |
| 219 | md << "# CKey optical benchmark (synthetic)\n\nGenerated by `ckey-bench" << (quick ? " --quick" : "") << "` with core " << ckey_get_software_version() |
| 220 | << ". **Synthetic frames, not real-camera measurements.** Frames are " << W << "×" << H << " px with clutter; sides include each symbology's quiet zone, " |
| 221 | << "so symbols are compared at equal area. " << trials << " trials per cell.\n\n"; |
| 222 | md << "| Condition | blur σ px | noise σ | rotation | keystone | glare |\n|---|---|---|---|---|---|\n"; |
| 223 | for (const auto &c : conditions) md << "| " << c.name << " | " << c.blur << " | " << c.noise << " | " << c.rotation_deg << "° | " << c.keystone * 100 << "% | " << c.glare << " |\n"; |
| 224 | js << "{\n \"core\": \"" << ckey_get_software_version() << "\", \"synthetic\": true, \"frame\": [" << W << ", " << H << "], \"trials\": " << trials << ",\n \"results\": [\n"; |
| 225 | bool first = true; |
| 226 | for (const auto &sym : syms) { |
| 227 | md << "\n## " << sym.name << "\n\n| Payload | Condition | Smallest side ≥ 90% | Bytes per 1,000 px² | Success by side (px) | Mean RS bytes corrected | Latency p50 / p90 / p99 ms |\n|---|---|---|---|---|---|---|\n"; |
| 228 | for (size_t bytes : payloads) { |
| 229 | const std::string text = payload_text(bytes, static_cast<uint32_t>(bytes)); |
| 230 | for (const auto &c : conditions) { |
| 231 | std::vector<double> latency; std::ostringstream by_side; int best = -1; double corrected = 0; int corrected_n = 0; |
| 232 | for (int side : sides) { |
| 233 | Image img; |
| 234 | if (!sym.render(text, side, img)) { by_side << side << ":n/a "; continue; } |
| 235 | int ok = 0; |
| 236 | for (int t = 0; t < trials; ++t) { |
| 237 | const auto frame = make_frame(&img, W, H, c, static_cast<uint32_t>(side * 1000 + t * 7 + bytes)); |
| 238 | const Outcome o = sym.scan(frame, W, H, text); |
| 239 | latency.push_back(o.ms); |
| 240 | if (o.ok) { ++ok; corrected += o.corrected; ++corrected_n; } |
| 241 | } |
| 242 | by_side << side << ":" << 100 * ok / trials << "% "; |
| 243 | if (best < 0 && ok * 10 >= trials * 9) best = side; |
| 244 | } |
| 245 | const double density = best > 0 ? 1000.0 * bytes / (static_cast<double>(best) * best) : 0; |
| 246 | md << "| " << bytes << " B | " << c.name << " | " << (best > 0 ? std::to_string(best) + " px" : "none") << " | "; |
| 247 | if (best > 0) { char b[16]; std::snprintf(b, sizeof(b), "%.2f", density); md << b; } else md << "–"; |
| 248 | char lat[64]; std::snprintf(lat, sizeof(lat), "%.1f / %.1f / %.1f", pct(latency, .5), pct(latency, .9), pct(latency, .99)); |
| 249 | char cor[16]; std::snprintf(cor, sizeof(cor), "%.1f", corrected_n ? corrected / corrected_n : 0.0); |
| 250 | md << " | " << by_side.str() << "| " << cor << " | " << lat << " |\n"; |
| 251 | js << (first ? "" : ",\n") << " {\"symbology\": \"" << sym.name << "\", \"payload_bytes\": " << bytes << ", \"condition\": \"" << c.name |
| 252 | << "\", \"min_side_px_90\": " << best << ", \"bytes_per_kpx2\": " << density << ", \"latency_ms_p50\": " << pct(latency, .5) |
| 253 | << ", \"latency_ms_p90\": " << pct(latency, .9) << ", \"latency_ms_p99\": " << pct(latency, .99) << "}"; |
| 254 | first = false; |
| 255 | std::cerr << sym.name << " " << bytes << "B " << c.name << ": min side " << best << '\n'; |
| 256 | } |
| 257 | } |
| 258 | // False positives: symbol-free clutter frames. |
| 259 | int fp = 0; std::vector<double> fp_latency; |
| 260 | for (int i = 0; i < fp_frames; ++i) { |
| 261 | const auto frame = make_frame(nullptr, W, H, conditions[static_cast<size_t>(i % 3)], 0xF00D0000u + static_cast<uint32_t>(i)); |
| 262 | const Outcome o = sym.scan(frame, W, H, "\x01never"); |
| 263 | fp_latency.push_back(o.ms); |
| 264 | if (o.ok || o.corrected < 0) ++fp; |
| 265 | } |
| 266 | char lat[64]; std::snprintf(lat, sizeof(lat), "%.1f / %.1f ms", pct(fp_latency, .5), pct(fp_latency, .99)); |
| 267 | md << "\nFalse positives on " << fp_frames << " symbol-free clutter frames: **" << fp << "** (scan latency p50 / p99 " << lat << ").\n"; |
| 268 | js << ",\n {\"symbology\": \"" << sym.name << "\", \"false_positive_frames\": " << fp_frames << ", \"false_positives\": " << fp << "}"; |
| 269 | } |
| 270 | |
| 271 | md << "\nQR Code and Data Matrix columns need zxing-cpp: configure with `-DCKEY_BENCH_ZXING=ON` (CI's `benchmarks` job does).\n"; |
| 272 | |
| 273 | js << "\n ]\n}\n"; |
| 274 | std::cout << md.str(); |
| 275 | if (!md_path.empty()) std::ofstream(md_path) << md.str(); |
| 276 | if (!json_path.empty()) std::ofstream(json_path) << js.str(); |
| 277 | return 0; |
| 278 | } |