Viewing is open to everyone. Downloading and starring need a GitHub sign-in.
Sign in with GitHub
core_internal.inc
v0.1.0-build.85
Latest
Commit ede38f5
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | // Shared internal definitions (RS, CRC, layouts, sampling, style checks). |
| 2 | // Included first by every core translation unit. |
| 3 | "ckey/ckey.h" |
| 4 | |
| 5 | <algorithm> |
| 6 | <array> |
| 7 | <cmath> |
| 8 | <cstdio> |
| 9 | <cstring> |
| 10 | <functional> |
| 11 | <initializer_list> |
| 12 | <limits> |
| 13 | <memory> |
| 14 | <mutex> |
| 15 | <numeric> |
| 16 | <iomanip> |
| 17 | <sstream> |
| 18 | <string> |
| 19 | <utility> |
| 20 | <vector> |
| 21 | |
| 22 | namespace { |
| 23 | constexpr size_t kDataBytes = 26; |
| 24 | constexpr size_t kParityBytes = 51; |
| 25 | constexpr size_t kCodewordBytes = kDataBytes + kParityBytes; |
| 26 | constexpr size_t kBits = kCodewordBytes * 8; |
| 27 | constexpr size_t kTextDataBytes = 210; |
| 28 | constexpr size_t kTextParityBytes = 45; |
| 29 | constexpr size_t kTextCodewordBytes = kTextDataBytes + kTextParityBytes; |
| 30 | static_assert(kTextCodewordBytes == CKEY_TEXT_CODEWORD_BYTES); |
| 31 | constexpr size_t kExtendedBlocks = CKEY_EXTENDED_BLOCK_COUNT; |
| 32 | constexpr size_t kExtendedDataBytes = CKEY_EXTENDED_DATA_BYTES_PER_BLOCK; |
| 33 | constexpr size_t kExtendedParityBytes = CKEY_EXTENDED_PARITY_BYTES_PER_BLOCK; |
| 34 | constexpr size_t kExtendedBlockBytes = CKEY_EXTENDED_CODEWORD_BYTES_PER_BLOCK; |
| 35 | constexpr size_t kExtendedCodewordBytes = CKEY_EXTENDED_CODEWORD_BYTES; |
| 36 | constexpr size_t kExtendedBits = kExtendedCodewordBytes * 8; |
| 37 | constexpr int kExtendedSide = 116; |
| 38 | constexpr std::array<size_t, CKEY_EXTENDED_TIER_COUNT> kExtendedTierCapacities = {256, 384, 512, 768, 1024, 1536, 2048}; |
| 39 | static_assert(kExtendedBlocks * kExtendedBlockBytes == kExtendedCodewordBytes); |
| 40 | constexpr int kSide = 29; |
| 41 | constexpr double kCenter = 14.5; |
| 42 | constexpr double kTau = 6.283185307179586476925286766559; |
| 43 | |
| 44 | struct Gf { |
| 45 | std::array<uint8_t, 512> exp{}; |
| 46 | std::array<uint8_t, 256> log{}; |
| 47 | Gf() { |
| 48 | unsigned x = 1; |
| 49 | for (int i = 0; i < 255; ++i) { |
| 50 | exp[i] = static_cast<uint8_t>(x); log[x] = static_cast<uint8_t>(i); |
| 51 | x <<= 1; if (x & 0x100) x ^= 0x11d; |
| 52 | } |
| 53 | for (int i = 255; i < 512; ++i) exp[i] = exp[i - 255]; |
| 54 | } |
| 55 | uint8_t mul(uint8_t a, uint8_t b) const { return (!a || !b) ? 0 : exp[log[a] + log[b]]; } |
| 56 | uint8_t inv(uint8_t a) const { return a ? exp[255 - log[a]] : 0; } |
| 57 | }; |
| 58 | const Gf gf{}; |
| 59 | |
| 60 | std::array<uint8_t, kParityBytes + 1> rs_generator() { |
| 61 | std::array<uint8_t, kParityBytes + 1> p{}; p[0] = 1; |
| 62 | size_t len = 1; |
| 63 | for (size_t i = 0; i < kParityBytes; ++i) { |
| 64 | std::array<uint8_t, kParityBytes + 1> q{}; |
| 65 | const uint8_t root = gf.exp[i]; |
| 66 | for (size_t j = 0; j < len; ++j) { q[j] ^= p[j]; q[j + 1] ^= gf.mul(p[j], root); } |
| 67 | p = q; ++len; |
| 68 | } |
| 69 | return p; |
| 70 | } |
| 71 | |
| 72 | uint8_t eval_low(const std::array<uint8_t, kParityBytes + 1> &p, size_t degree, uint8_t x) { |
| 73 | uint8_t y = p[degree]; |
| 74 | while (degree) { --degree; y = gf.mul(y, x) ^ p[degree]; } |
| 75 | return y; |
| 76 | } |
| 77 | |
| 78 | std::array<uint8_t, kParityBytes> syndromes(const std::array<uint8_t, kCodewordBytes> &cw) { |
| 79 | std::array<uint8_t, kParityBytes> s{}; |
| 80 | for (size_t j = 0; j < kParityBytes; ++j) { |
| 81 | const uint8_t x = gf.exp[j]; |
| 82 | uint8_t y = 0; |
| 83 | for (uint8_t b : cw) y = gf.mul(y, x) ^ b; |
| 84 | s[j] = y; |
| 85 | } |
| 86 | return s; |
| 87 | } |
| 88 | |
| 89 | bool rs_correct(std::array<uint8_t, kCodewordBytes> &cw, uint32_t &corrected) { |
| 90 | const auto s = syndromes(cw); |
| 91 | if (std::all_of(s.begin(), s.end(), [](uint8_t v) { return v == 0; })) { corrected = 0; return true; } |
| 92 | |
| 93 | // Berlekamp–Massey over the 51 syndromes. Locator coefficients are |
| 94 | // stored low-degree first: Lambda(z)=prod(1-X_i*z). |
| 95 | std::array<uint8_t, kParityBytes + 1> lambda{}, previous{}; |
| 96 | lambda[0] = previous[0] = 1; |
| 97 | size_t degree = 0, shift = 1; |
| 98 | uint8_t last_discrepancy = 1; |
| 99 | for (size_t n = 0; n < kParityBytes; ++n) { |
| 100 | uint8_t discrepancy = s[n]; |
| 101 | for (size_t i = 1; i <= degree; ++i) discrepancy ^= gf.mul(lambda[i], s[n - i]); |
| 102 | if (discrepancy == 0) { ++shift; continue; } |
| 103 | const auto saved = lambda; |
| 104 | const uint8_t factor = gf.mul(discrepancy, gf.inv(last_discrepancy)); |
| 105 | for (size_t i = 0; i + shift <= kParityBytes; ++i) lambda[i + shift] ^= gf.mul(factor, previous[i]); |
| 106 | if (2 * degree <= n) { |
| 107 | degree = n + 1 - degree; |
| 108 | previous = saved; |
| 109 | last_discrepancy = discrepancy; |
| 110 | shift = 1; |
| 111 | } else { |
| 112 | ++shift; |
| 113 | } |
| 114 | } |
| 115 | if (degree == 0 || degree > kParityBytes / 2) return false; |
| 116 | |
| 117 | std::array<size_t, kParityBytes / 2> positions{}; |
| 118 | std::array<uint8_t, kParityBytes / 2> locations{}; |
| 119 | size_t found = 0; |
| 120 | for (size_t pos = 0; pos < cw.size(); ++pos) { |
| 121 | const size_t exponent = cw.size() - 1 - pos; |
| 122 | const uint8_t x = gf.exp[exponent]; |
| 123 | const uint8_t inverse_x = gf.inv(x); |
| 124 | if (eval_low(lambda, degree, inverse_x) == 0) { |
| 125 | if (found >= positions.size()) return false; |
| 126 | positions[found] = pos; locations[found] = x; ++found; |
| 127 | } |
| 128 | } |
| 129 | if (found != degree) return false; |
| 130 | |
| 131 | // Solve the first degree syndrome equations for error magnitudes using a |
| 132 | // small GF(256) Vandermonde system. This avoids a second fragile formula |
| 133 | // convention for Forney magnitudes and is small for a 77-byte codeword. |
| 134 | uint8_t matrix[kParityBytes / 2][kParityBytes / 2 + 1]{}; |
| 135 | for (size_t row = 0; row < degree; ++row) { |
| 136 | for (size_t col = 0; col < degree; ++col) { |
| 137 | uint8_t v = 1; |
| 138 | for (size_t p = 0; p < row; ++p) v = gf.mul(v, locations[col]); |
| 139 | matrix[row][col] = v; |
| 140 | } |
| 141 | matrix[row][degree] = s[row]; |
| 142 | } |
| 143 | for (size_t col = 0; col < degree; ++col) { |
| 144 | size_t pivot = col; |
| 145 | while (pivot < degree && matrix[pivot][col] == 0) ++pivot; |
| 146 | if (pivot == degree) return false; |
| 147 | if (pivot != col) for (size_t j = col; j <= degree; ++j) std::swap(matrix[pivot][j], matrix[col][j]); |
| 148 | const uint8_t inv = gf.inv(matrix[col][col]); |
| 149 | for (size_t j = col; j <= degree; ++j) matrix[col][j] = gf.mul(matrix[col][j], inv); |
| 150 | for (size_t row = 0; row < degree; ++row) if (row != col) { |
| 151 | const uint8_t factor = matrix[row][col]; |
| 152 | for (size_t j = col; j <= degree; ++j) matrix[row][j] ^= gf.mul(factor, matrix[col][j]); |
| 153 | } |
| 154 | } |
| 155 | for (size_t i = 0; i < degree; ++i) cw[positions[i]] ^= matrix[i][degree]; |
| 156 | const auto check = syndromes(cw); |
| 157 | if (!std::all_of(check.begin(), check.end(), [](uint8_t v) { return v == 0; })) return false; |
| 158 | corrected = static_cast<uint32_t>(degree); |
| 159 | return true; |
| 160 | } |
| 161 | |
| 162 | std::vector<uint8_t> rs_generator_generic(size_t parity) { |
| 163 | std::vector<uint8_t> polynomial(1, 1); |
| 164 | for (size_t root = 0; root < parity; ++root) { |
| 165 | std::vector<uint8_t> next(polynomial.size() + 1, 0); |
| 166 | for (size_t i = 0; i < polynomial.size(); ++i) { |
| 167 | next[i] ^= polynomial[i]; |
| 168 | next[i + 1] ^= gf.mul(polynomial[i], gf.exp[root]); |
| 169 | } |
| 170 | polynomial = std::move(next); |
| 171 | } |
| 172 | return polynomial; |
| 173 | } |
| 174 | |
| 175 | void rs_encode_generic(std::vector<uint8_t> &codeword, size_t data_bytes, size_t parity_bytes) { |
| 176 | const auto generator = rs_generator_generic(parity_bytes); |
| 177 | std::vector<uint8_t> work = codeword; |
| 178 | for (size_t i = 0; i < data_bytes; ++i) { |
| 179 | const uint8_t feedback = work[i]; |
| 180 | if (!feedback) continue; |
| 181 | for (size_t j = 1; j <= parity_bytes; ++j) work[i + j] ^= gf.mul(generator[j], feedback); |
| 182 | } |
| 183 | std::copy(work.begin() + static_cast<std::ptrdiff_t>(data_bytes), work.end(), |
| 184 | codeword.begin() + static_cast<std::ptrdiff_t>(data_bytes)); |
| 185 | } |
| 186 | |
| 187 | bool rs_correct_generic(std::vector<uint8_t> &cw, size_t parity_bytes, uint32_t &corrected) { |
| 188 | if (cw.empty() || parity_bytes == 0 || parity_bytes >= cw.size() || parity_bytes > 254) return false; |
| 189 | std::vector<uint8_t> syndromes(parity_bytes, 0); |
| 190 | for (size_t j = 0; j < parity_bytes; ++j) for (uint8_t b : cw) syndromes[j] = gf.mul(syndromes[j], gf.exp[j]) ^ b; |
| 191 | if (std::all_of(syndromes.begin(), syndromes.end(), [](uint8_t v) { return v == 0; })) { corrected = 0; return true; } |
| 192 | |
| 193 | std::vector<uint8_t> lambda(parity_bytes + 1, 0), previous(parity_bytes + 1, 0); |
| 194 | lambda[0] = previous[0] = 1; |
| 195 | size_t degree = 0, shift = 1; |
| 196 | uint8_t last_discrepancy = 1; |
| 197 | for (size_t n = 0; n < parity_bytes; ++n) { |
| 198 | uint8_t discrepancy = syndromes[n]; |
| 199 | for (size_t i = 1; i <= degree; ++i) discrepancy ^= gf.mul(lambda[i], syndromes[n - i]); |
| 200 | if (!discrepancy) { ++shift; continue; } |
| 201 | const auto saved = lambda; |
| 202 | const uint8_t factor = gf.mul(discrepancy, gf.inv(last_discrepancy)); |
| 203 | for (size_t i = 0; i + shift <= parity_bytes; ++i) lambda[i + shift] ^= gf.mul(factor, previous[i]); |
| 204 | if (2 * degree <= n) { degree = n + 1 - degree; previous = saved; last_discrepancy = discrepancy; shift = 1; } |
| 205 | else ++shift; |
| 206 | } |
| 207 | if (degree == 0 || degree > parity_bytes / 2) return false; |
| 208 | std::vector<size_t> positions; std::vector<uint8_t> locations; |
| 209 | for (size_t pos = 0; pos < cw.size(); ++pos) { |
| 210 | const uint8_t location = gf.exp[cw.size() - 1 - pos]; |
| 211 | uint8_t value = lambda[degree]; |
| 212 | for (size_t i = degree; i-- > 0;) value = gf.mul(value, gf.inv(location)) ^ lambda[i]; |
| 213 | if (!value) { positions.push_back(pos); locations.push_back(location); } |
| 214 | } |
| 215 | if (positions.size() != degree) return false; |
| 216 | std::vector<uint8_t> matrix(degree * (degree + 1), 0); |
| 217 | const auto at = [&](size_t row, size_t col) -> uint8_t & { return matrix[row * (degree + 1) + col]; }; |
| 218 | for (size_t row = 0; row < degree; ++row) { |
| 219 | for (size_t col = 0; col < degree; ++col) { |
| 220 | uint8_t value = 1; |
| 221 | for (size_t power = 0; power < row; ++power) value = gf.mul(value, locations[col]); |
| 222 | at(row, col) = value; |
| 223 | } |
| 224 | at(row, degree) = syndromes[row]; |
| 225 | } |
| 226 | for (size_t col = 0; col < degree; ++col) { |
| 227 | size_t pivot = col; while (pivot < degree && at(pivot, col) == 0) ++pivot; |
| 228 | if (pivot == degree) return false; |
| 229 | if (pivot != col) for (size_t j = col; j <= degree; ++j) std::swap(at(pivot, j), at(col, j)); |
| 230 | const uint8_t inverse = gf.inv(at(col, col)); |
| 231 | for (size_t j = col; j <= degree; ++j) at(col, j) = gf.mul(at(col, j), inverse); |
| 232 | for (size_t row = 0; row < degree; ++row) if (row != col) { |
| 233 | const uint8_t factor = at(row, col); |
| 234 | for (size_t j = col; j <= degree; ++j) at(row, j) ^= gf.mul(factor, at(col, j)); |
| 235 | } |
| 236 | } |
| 237 | for (size_t i = 0; i < degree; ++i) cw[positions[i]] ^= at(i, degree); |
| 238 | for (size_t j = 0; j < parity_bytes; ++j) { |
| 239 | uint8_t syndrome = 0; for (uint8_t b : cw) syndrome = gf.mul(syndrome, gf.exp[j]) ^ b; |
| 240 | if (syndrome) return false; |
| 241 | } |
| 242 | corrected = static_cast<uint32_t>(degree); |
| 243 | return true; |
| 244 | } |
| 245 | |
| 246 | uint32_t crc32c(const uint8_t *data, size_t len) { |
| 247 | uint32_t crc = 0xffffffffu; |
| 248 | for (size_t i = 0; i < len; ++i) { |
| 249 | crc ^= data[i]; |
| 250 | for (int b = 0; b < 8; ++b) crc = (crc >> 1) ^ (0x82f63b78u & (0u - (crc & 1u))); |
| 251 | } |
| 252 | return ~crc; |
| 253 | } |
| 254 | |
| 255 | // Normative SplitMix32 fill for unused frame bytes. This is visual decorrelation, |
| 256 | // not encryption or error correction; the complete padded frame remains under RS. |
| 257 | uint8_t padding_byte(uint32_t &state) { |
| 258 | state += 0x9e3779b9u; |
| 259 | uint32_t value = state; |
| 260 | value = (value ^ (value >> 16)) * 0x85ebca6bu; |
| 261 | value = (value ^ (value >> 13)) * 0xc2b2ae35u; |
| 262 | value ^= value >> 16; |
| 263 | return static_cast<uint8_t>(value); |
| 264 | } |
| 265 | |
| 266 | void fill_padding(uint8_t *frame, size_t begin, size_t end, size_t seed_length) { |
| 267 | uint32_t state = crc32c(frame, seed_length); |
| 268 | for (size_t i = begin; i < end; ++i) frame[i] = padding_byte(state); |
| 269 | } |
| 270 | |
| 271 | bool padding_matches(const uint8_t *frame, size_t begin, size_t end, size_t seed_length) { |
| 272 | uint32_t state = crc32c(frame, seed_length); |
| 273 | for (size_t i = begin; i < end; ++i) if (frame[i] != padding_byte(state)) return false; |
| 274 | return true; |
| 275 | } |
| 276 | |
| 277 | size_t extended_tier_index(ckey_extended_tier tier, size_t payload_length) { |
| 278 | if (tier == CKEY_EXTENDED_TIER_AUTO) { |
| 279 | for (size_t i = 0; i < kExtendedTierCapacities.size(); ++i) |
| 280 | if (payload_length <= kExtendedTierCapacities[i]) return i; |
| 281 | return kExtendedTierCapacities.size(); |
| 282 | } |
| 283 | for (size_t i = 0; i < kExtendedTierCapacities.size(); ++i) |
| 284 | if (static_cast<size_t>(tier) == kExtendedTierCapacities[i]) return i; |
| 285 | return kExtendedTierCapacities.size(); |
| 286 | } |
| 287 | |
| 288 | struct Node { bool polar; bool dial; double x, y; int row, col; int ring, slot; }; |
| 289 | |
| 290 | bool in_finder(int r, int c) { |
| 291 | const bool tl = r < 6 && c < 6, tr = r < 6 && c >= kSide - 6; |
| 292 | const bool bl = r >= kSide - 6 && c < 6, br = r >= kSide - 6 && c >= kSide - 6; |
| 293 | return tl || tr || bl || br; |
| 294 | } |
| 295 | bool grid_key(int r, int c) { return (c == 2 || c == 3) && (r == 11 || r == 12); } |
| 296 | bool ray_key(int slot) { return slot == 0 || slot == 54 || slot == 55; } |
| 297 | bool ring_key(int ring, int slot) { return ring == 3 && slot >= 18 && slot <= 25; } |
| 298 | |
| 299 | std::vector<Node> build_data_nodes() { |
| 300 | std::vector<Node> cart, polar, out; |
| 301 | std::array<std::vector<Node>, 72> cart_by_angle; |
| 302 | // Fixed Cartesian field is sampled outside the polar aperture. The four |
| 303 | // corner finders and the left-side Grid Key never carry payload. |
| 304 | for (int r = 1; r < kSide - 1; ++r) for (int c = 1; c < kSide - 1; ++c) { |
| 305 | const double x = c + .5, y = r + .5; |
| 306 | const double dx = x - kCenter, dy = y - kCenter; |
| 307 | if (in_finder(r, c) || grid_key(r, c) || dx * dx + dy * dy <= 10.4 * 10.4) continue; |
| 308 | double angle = std::atan2(dx, -dy); |
| 309 | if (angle < 0) angle += kTau; |
| 310 | const int slot = static_cast<int>(std::lround(angle * 72.0 / kTau)) % 72; |
| 311 | cart_by_angle[slot].push_back({false, false, x, y, r, c, -1, -1}); |
| 312 | } |
| 313 | for (auto &bin : cart_by_angle) std::sort(bin.begin(), bin.end(), [=](const Node &a, const Node &b) { |
| 314 | const double ax = a.x-kCenter, ay = a.y-kCenter, bx = b.x-kCenter, by = b.y-kCenter; |
| 315 | const double ar=ax*ax+ay*ay, br=bx*bx+by*by; |
| 316 | if (ar != br) return ar > br; |
| 317 | if (a.row != b.row) return a.row < b.row; |
| 318 | return a.col < b.col; |
| 319 | }); |
| 320 | for (size_t depth = 0; ; ++depth) { |
| 321 | bool any = false; |
| 322 | for (int step = 0; step < 72; ++step) { |
| 323 | auto &bin = cart_by_angle[(step * 37) % 72]; |
| 324 | if (depth < bin.size()) { cart.push_back(bin[depth]); any = true; } |
| 325 | } |
| 326 | if (!any) break; |
| 327 | } |
| 328 | // Four concentric payload rings occupy the inner field. A fifth layer of |
| 329 | // radial data cells is a narrow annular dial band, never spokes to center. |
| 330 | std::array<std::vector<Node>, 5> polar_layers; |
| 331 | for (int layer = 0; layer < 5; ++layer) for (int step = 0; step < 72; ++step) { |
| 332 | const int slot = (step * 37 + layer * 13) % 72; |
| 333 | if (ray_key(slot)) continue; |
| 334 | if (layer == 0) polar_layers[layer].push_back({true, true, 0, 0, -1, -1, -2, slot}); |
| 335 | else { |
| 336 | const int ring = layer - 1; |
| 337 | if (ring_key(ring, slot)) continue; |
| 338 | const double radius = 3.6 + 0.8 * ring; |
| 339 | const double a = kTau * slot / 72.0; |
| 340 | polar_layers[layer].push_back({true, false, kCenter + radius * std::sin(a), kCenter - radius * std::cos(a), -1, -1, ring, slot}); |
| 341 | } |
| 342 | } |
| 343 | // Put all dial sites first so every one of the 69 length-coded data rays |
| 344 | // is inside the locator codeword, then round-robin the four dot rings. |
| 345 | polar = polar_layers[0]; |
| 346 | for (size_t depth = 0; ; ++depth) { |
| 347 | bool any = false; |
| 348 | for (size_t layer = 1; layer < polar_layers.size(); ++layer) |
| 349 | if (depth < polar_layers[layer].size()) { polar.push_back(polar_layers[layer][depth]); any = true; } |
| 350 | if (!any) break; |
| 351 | } |
| 352 | // Interleave coordinate systems in the placement sequence. Local image |
| 353 | // damage is therefore less likely to erase a consecutive run of bytes. |
| 354 | size_t ci = 0, pi = 0; |
| 355 | while (ci < cart.size() || pi < polar.size()) { |
| 356 | if (ci < cart.size()) out.push_back(cart[ci++]); |
| 357 | if (pi < polar.size()) out.push_back(polar[pi++]); |
| 358 | } |
| 359 | return out; |
| 360 | } |
| 361 | |
| 362 | const std::vector<Node> &data_nodes() { static const std::vector<Node> nodes = build_data_nodes(); return nodes; } |
| 363 | |
| 364 | bool code_bit(const ckey_symbol *s, size_t i) { return ((s->codeword[i / 8] >> (7 - (i % 8))) & 1u) != 0; } |
| 365 | |
| 366 | // --------------------------------------------------------------------------- |
| 367 | // Dense-profile layout (Profile 2 Format 0.25, Profile 3 Format 0.26). |
| 368 | // |
| 369 | // Safe area: a candidate Cartesian module is placed only if its ink (bounded |
| 370 | // by the module's circumscribed disc) keeps a declared clearance from every |
| 371 | // structural mark: the frame stroke, each finder arc including its round |
| 372 | // caps and its concave side, the Grid Key, and the radial alphabet. All |
| 373 | // tests below use only +, *, sqrt, and comparisons on exactly representable |
| 374 | // constants, so every conforming implementation derives the same site set. |
| 375 | // |
| 376 | // No dead space: every site (Cartesian, ring, dial/ray component) carries a |
| 377 | // value. Sites are ordered by a keyed bijective hash of their integer |
| 378 | // identity, so the codeword is spread uniformly over the whole symbol. Sites |
| 379 | // past the codeword carry fill. All site values are XORed with a fixed |
| 380 | // SplitMix32 whitening stream; this is optical modulation (it prevents |
| 381 | // payload-dependent empty or solid regions) and is not error correction. |
| 382 | // --------------------------------------------------------------------------- |
| 383 | struct DenseGeometry { |
| 384 | double frame_center, frame_half, frame_radius, frame_half_stroke, frame_clear; |
| 385 | double finder_inset, finder_radius, finder_half_stroke, finder_clear, side; |
| 386 | double grid_x0, grid_y0, grid_x1, grid_y1, grid_clear; |
| 387 | double polar_center, ray_ink_outer, polar_clear; |
| 388 | double key_ray_r0, key_ray_length, key_ray_half_width; |
| 389 | double key_ray_dir[3][2]; // unit (sin, -cos) for each Ray Key slot |
| 390 | double key_ray_fraction[3]; |
| 391 | double module_half; // half side of a square Cartesian module |
| 392 | }; |
| 393 | |
| 394 | double rounded_square_sd(double x, double y, double center, double half, double radius) { |
| 395 | const double qx = std::abs(x - center) - (half - radius), qy = std::abs(y - center) - (half - radius); |
| 396 | const double ox = std::max(qx, 0.0), oy = std::max(qy, 0.0); |
| 397 | return std::sqrt(ox * ox + oy * oy) + std::min(std::max(qx, qy), 0.0) - radius; |
| 398 | } |
| 399 | |
| 400 | // Distance to a corner finder centerline: the quarter circle around |
| 401 | // (cx, cy) lying in the symbol's outer corner quadrant. |
| 402 | double finder_centerline_distance(double x, double y, double cx, double cy, double r, bool right, bool bottom) { |
| 403 | double dx = x - cx, dy = y - cy; |
| 404 | if (right) dx = -dx; |
| 405 | if (bottom) dy = -dy; |
| 406 | if (dx <= 0 && dy <= 0) return std::abs(std::sqrt(dx * dx + dy * dy) - r); |
| 407 | const double ex = dx + r, ey = dy + r; |
| 408 | return std::min(std::sqrt(ex * ex + dy * dy), std::sqrt(dx * dx + ey * ey)); |
| 409 | } |
| 410 | |
| 411 | double segment_distance(double x, double y, double x0, double y0, double x1, double y1) { |
| 412 | const double vx = x1 - x0, vy = y1 - y0, wx = x - x0, wy = y - y0; |
| 413 | const double t = std::clamp((wx * vx + wy * vy) / (vx * vx + vy * vy), 0.0, 1.0); |
| 414 | const double px = x0 + t * vx - x, py = y0 + t * vy - y; |
| 415 | return std::sqrt(px * px + py * py); |
| 416 | } |
| 417 | |
| 418 | // Signed safe-area slack for a Cartesian module centered at (x, y): positive |
| 419 | // means every structural clearance is met. Exposed for layout tests. |
| 420 | double dense_site_margin(const DenseGeometry &g, double x, double y) { |
| 421 | const double e = g.module_half * 1.4142135623730951; // circumscribed disc |
| 422 | double m = -rounded_square_sd(x, y, g.frame_center, g.frame_half, g.frame_radius) - g.frame_half_stroke - e - g.frame_clear; |
| 423 | const double near = g.finder_inset, far = g.side - g.finder_inset; |
| 424 | const double finder_need = g.finder_half_stroke + e + g.finder_clear; |
| 425 | m = std::min(m, finder_centerline_distance(x, y, near, near, g.finder_radius, false, false) - finder_need); |
| 426 | m = std::min(m, finder_centerline_distance(x, y, far, near, g.finder_radius, true, false) - finder_need); |
| 427 | m = std::min(m, finder_centerline_distance(x, y, near, far, g.finder_radius, false, true) - finder_need); |
| 428 | m = std::min(m, finder_centerline_distance(x, y, far, far, g.finder_radius, true, true) - finder_need); |
| 429 | const double gx = std::max({g.grid_x0 - x, 0.0, x - g.grid_x1}), gy = std::max({g.grid_y0 - y, 0.0, y - g.grid_y1}); |
| 430 | m = std::min(m, std::sqrt(gx * gx + gy * gy) - e - g.grid_clear); |
| 431 | const double dx = x - g.polar_center, dy = y - g.polar_center; |
| 432 | m = std::min(m, std::sqrt(dx * dx + dy * dy) - g.ray_ink_outer - e - g.polar_clear); |
| 433 | for (int k = 0; k < 3; ++k) { |
| 434 | const double r1 = g.key_ray_r0 + g.key_ray_length * g.key_ray_fraction[k]; |
| 435 | const double ux = g.key_ray_dir[k][0], uy = g.key_ray_dir[k][1]; |
| 436 | m = std::min(m, segment_distance(x, y, g.polar_center + g.key_ray_r0 * ux, g.polar_center + g.key_ray_r0 * uy, |
| 437 | g.polar_center + r1 * ux, g.polar_center + r1 * uy) - g.key_ray_half_width - e - g.polar_clear); |
| 438 | } |
| 439 | return m; |
| 440 | } |
| 441 | |
| 442 | // Ray Key directions are 0 deg, 270 deg, and 270 + 1/72 turn (P2: slots 0, 108, |
| 443 | // 110 of 144; P3: slots 0, 432, 440 of 576). The third direction is the only |
| 444 | // non-axis value; no candidate site is within 1e-6 of any clearance boundary |
| 445 | // (checked by tests/layout_tests.cpp), so libm rounding cannot change the set. |
| 446 | DenseGeometry text_geometry() { |
| 447 | const double a = kTau * 110.0 / 144.0; |
| 448 | return {14.5, 14.0, 3.5, 0.325, 0.5, |
| 449 | 4.0, 2.3, 0.4, 0.5, 29.0, |
| 450 | 2.13, 11.13, 3.87, 12.87, 0.5, |
| 451 | kCenter, 7.6 + 1.20 + 0.05, 0.5, |
| 452 | 7.6, 1.9, 0.11, |
| 453 | {{0.0, -1.0}, {-1.0, 0.0}, {std::sin(a), -std::cos(a)}}, |
| 454 | {1.0, 0.5, 0.75}, |
| 455 | 0.17}; |
| 456 | } |
| 457 | |
| 458 | DenseGeometry extended_geometry() { |
| 459 | const double a = kTau * 440.0 / 576.0; |
| 460 | return {58.0, 56.0, 14.0, 1.3, 1.5, |
| 461 | 16.0, 9.2, 1.6, 1.5, 116.0, |
| 462 | 8.08, 45.08, 8.92, 45.92, 0.5, |
| 463 | 58.0, 26.30 + 0.80 + 0.85 * 3 + 0.14, 0.5, |
| 464 | 26.30, 3.6, 0.14, |
| 465 | {{0.0, -1.0}, {-1.0, 0.0}, {std::sin(a), -std::cos(a)}}, |
| 466 | {1.0, 0.5, 0.75}, |
| 467 | 0.17}; |
| 468 | } |
| 469 | |
| 470 | // lowbias32: a bijection on 32-bit words, so distinct site identities never tie. |
| 471 | uint32_t site_hash(uint32_t x) { |
| 472 | x ^= x >> 16; x *= 0x7feb352du; x ^= x >> 15; x *= 0x846ca68bu; x ^= x >> 16; |
| 473 | return x; |
| 474 | } |
| 475 | constexpr uint32_t kTextPlacementKey = 0x434b5032u; // "CKP2" |
| 476 | constexpr uint32_t kExtendedPlacementKey = 0x434b5033u; // "CKP3" |
| 477 | constexpr uint32_t kTextWhiteningSeed = 0x434b5732u; // "CKW2" |
| 478 | constexpr uint32_t kExtendedWhiteningSeed = 0x434b5733u; // "CKW3" |
| 479 | |
| 480 | uint32_t site_identity(uint32_t kind, uint32_t a, uint32_t b) { return (kind << 24) | (a << 12) | b; } |
| 481 | |
| 482 | template <class NodeT> |
| 483 | void order_sites(std::vector<NodeT> &nodes, uint32_t key) { |
| 484 | std::sort(nodes.begin(), nodes.end(), [key](const NodeT &l, const NodeT &r) { |
| 485 | return site_hash(l.id ^ key) < site_hash(r.id ^ key); |
| 486 | }); |
| 487 | } |
| 488 | |
| 489 | std::vector<uint8_t> whitening_bits(uint32_t seed, size_t count) { |
| 490 | std::vector<uint8_t> bits(count); |
| 491 | uint32_t state = seed; uint8_t byte = 0; |
| 492 | for (size_t i = 0; i < count; ++i) { |
| 493 | if (i % 8 == 0) byte = padding_byte(state); |
| 494 | bits[i] = static_cast<uint8_t>((byte >> (7 - i % 8)) & 1u); |
| 495 | } |
| 496 | return bits; |
| 497 | } |
| 498 | |
| 499 | // Normative site counts (Format 0.25 / 0.26); tests/layout_tests.cpp asserts |
| 500 | // that the derived layouts match them exactly. |
| 501 | constexpr size_t kTextSiteCount = 2630, kTextCartesianSites = 1377; |
| 502 | constexpr size_t kExtendedSiteCount = 42645, kExtendedCartesianSites = 31244; |
| 503 | |
| 504 | struct TextNode { bool cart; bool dial; double x, y; int row, col, ring, slot; uint32_t id; }; |
| 505 | |
| 506 | constexpr int kTextGrid = 58, kTextSlots = 144, kTextRings = 8; |
| 507 | bool text_reserved_slot(int slot) { return slot == 0 || slot == 108 || slot == 110; } |
| 508 | |
| 509 | std::vector<TextNode> build_text_nodes() { |
| 510 | constexpr double pitch = 29.0 / kTextGrid; |
| 511 | const DenseGeometry g = text_geometry(); |
| 512 | std::vector<TextNode> nodes; |
| 513 | for (int row = 0; row < kTextGrid; ++row) for (int col = 0; col < kTextGrid; ++col) { |
| 514 | const double x = (col + .5) * pitch, y = (row + .5) * pitch; |
| 515 | if (dense_site_margin(g, x, y) <= 0.0) continue; |
| 516 | nodes.push_back({true, false, x, y, row, col, -1, -1, |
| 517 | site_identity(0, static_cast<uint32_t>(row), static_cast<uint32_t>(col))}); |
| 518 | } |
| 519 | // Every non-key dial slot carries a short (0) or long (1) ray. |
| 520 | for (int slot = 0; slot < kTextSlots; ++slot) |
| 521 | if (!text_reserved_slot(slot)) nodes.push_back({false, true, 0, 0, -1, -1, -1, slot, site_identity(1, 0, static_cast<uint32_t>(slot))}); |
| 522 | for (int ring = 0; ring < kTextRings; ++ring) for (int slot = 0; slot < kTextSlots; ++slot) { |
| 523 | if (text_reserved_slot(slot) || (ring == kTextRings - 1 && slot >= 36 && slot <= 51)) continue; |
| 524 | const double radius = 3.4 + .43 * ring; |
| 525 | const double a = kTau * slot / kTextSlots; |
| 526 | nodes.push_back({false, false, kCenter + radius * std::sin(a), kCenter - radius * std::cos(a), -1, -1, ring, slot, |
| 527 | site_identity(2, static_cast<uint32_t>(ring), static_cast<uint32_t>(slot))}); |
| 528 | } |
| 529 | order_sites(nodes, kTextPlacementKey); |
| 530 | return nodes; |
| 531 | } |
| 532 | |
| 533 | const std::vector<TextNode> &text_nodes() { static const std::vector<TextNode> nodes = build_text_nodes(); return nodes; } |
| 534 | const std::vector<uint8_t> &text_whitening() { static const auto bits = whitening_bits(kTextWhiteningSeed, text_nodes().size()); return bits; } |
| 535 | |
| 536 | bool text_code_bit(const ckey_text_symbol *s, size_t i) { return ((s->codeword[i / 8] >> (7 - (i % 8))) & 1u) != 0; } |
| 537 | // Printed value of site i: codeword bit (or 0 for fill sites) XOR whitening. |
| 538 | bool text_site_value(const ckey_text_symbol *s, size_t i) { |
| 539 | const bool data = i < kTextCodewordBytes * 8 && text_code_bit(s, i); |
| 540 | return data != (text_whitening()[i] != 0); |
| 541 | } |
| 542 | |
| 543 | enum class ExtendedKind : uint8_t { cart, ring, ray }; |
| 544 | struct ExtendedNode { ExtendedKind kind; double x, y; int ring, slot, component; uint32_t id; }; |
| 545 | |
| 546 | constexpr int kExtendedGrid = 232, kExtendedRingSlots = 288, kExtendedRings = 34, kExtendedRaySlots = 576; |
| 547 | // Format 0.26 extends the ring field outward (outer ring 23.96 -> 24.62, |
| 548 | // Ring Key 24.45 -> 25.11) so the Ring Key-to-ray gap is 1.19 units, as in |
| 549 | // Profile 2, instead of a 1.85-unit dark annulus. Rays stay at r=26.30 because |
| 550 | // moving them inward would make adjacent wide rays overlap. |
| 551 | constexpr double kExtendedRingInner = 7.0, kExtendedRingOuter = 24.62; |
| 552 | constexpr double kExtendedRingKeyRadius = 25.11; |
| 553 | // Never printed: between the Ring Key and the ray alphabet's inner end. |
| 554 | constexpr double kExtendedPolarGapRadius = 25.7; |
| 555 | constexpr double kExtendedRingPitch = (kExtendedRingOuter - kExtendedRingInner) / (kExtendedRings - 1); |
| 556 | bool extended_reserved_ray(int slot) { return slot == 0 || slot == 432 || slot == 440; } |
| 557 | |
| 558 | std::vector<ExtendedNode> build_extended_nodes() { |
| 559 | constexpr double pitch = .5, center = 58.0; |
| 560 | const DenseGeometry g = extended_geometry(); |
| 561 | std::vector<ExtendedNode> nodes; |
| 562 | for (int row = 0; row < kExtendedGrid; ++row) for (int col = 0; col < kExtendedGrid; ++col) { |
| 563 | const double x = (col + .5) * pitch, y = (row + .5) * pitch; |
| 564 | if (dense_site_margin(g, x, y) <= 0.0) continue; |
| 565 | nodes.push_back({ExtendedKind::cart, x, y, -1, -1, -1, |
| 566 | site_identity(0, static_cast<uint32_t>(row), static_cast<uint32_t>(col))}); |
| 567 | } |
| 568 | for (int ring = 0; ring < kExtendedRings; ++ring) for (int slot = 0; slot < kExtendedRingSlots; ++slot) { |
| 569 | if (slot == 0 || slot == 216 || slot == 224 || (ring == kExtendedRings - 1 && slot >= 72 && slot <= 79)) continue; |
| 570 | const double radius = kExtendedRingInner + kExtendedRingPitch * ring; |
| 571 | const double a = kTau * slot / kExtendedRingSlots; |
| 572 | nodes.push_back({ExtendedKind::ring, center + radius * std::sin(a), center - radius * std::cos(a), ring, slot, -1, |
| 573 | site_identity(2, static_cast<uint32_t>(ring), static_cast<uint32_t>(slot))}); |
| 574 | } |
| 575 | // Every non-key ray is always visible: two Gray-coded length bits plus |
| 576 | // one width bit form an eight-state alphabet. Each component is a site. |
| 577 | for (int slot = 0; slot < kExtendedRaySlots; ++slot) { |
| 578 | if (extended_reserved_ray(slot)) continue; |
| 579 | for (int component = 0; component < 3; ++component) |
| 580 | nodes.push_back({ExtendedKind::ray, 0, 0, -1, slot, component, |
| 581 | site_identity(3, static_cast<uint32_t>(component), static_cast<uint32_t>(slot))}); |
| 582 | } |
| 583 | order_sites(nodes, kExtendedPlacementKey); |
| 584 | return nodes; |
| 585 | } |
| 586 | |
| 587 | const std::vector<ExtendedNode> &extended_nodes() { static const std::vector<ExtendedNode> nodes = build_extended_nodes(); return nodes; } |
| 588 | const std::vector<uint8_t> &extended_whitening() { static const auto bits = whitening_bits(kExtendedWhiteningSeed, extended_nodes().size()); return bits; } |
| 589 | |
| 590 | bool extended_code_bit(const ckey_extended_symbol *s, size_t i) { |
| 591 | return ((s->codeword[i / 8] >> (7 - (i % 8))) & 1u) != 0; |
| 592 | } |
| 593 | bool extended_site_value(const ckey_extended_symbol *s, size_t i) { |
| 594 | const bool data = i < kExtendedBits && extended_code_bit(s, i); |
| 595 | return data != (extended_whitening()[i] != 0); |
| 596 | } |
| 597 | |
| 598 | void set_status(ckey_diagnostics *d, ckey_status s, const char *m) { |
| 599 | if (!d) return; |
| 600 | d->abi_version = CKEY_ABI_VERSION; d->status = s; |
| 601 | d->corrected_symbols = 0; d->erasures_used = 0; |
| 602 | std::snprintf(d->message, sizeof(d->message), "%s", m); |
| 603 | } |
| 604 | |
| 605 | double polar_dot_radius() { |
| 606 | // A single physical dot size keeps all four data rings and the Ring Key uniform. |
| 607 | return 0.15; |
| 608 | } |
| 609 | |
| 610 | void svg_circle(std::ostringstream &o, double x, double y, double r) { |
| 611 | o << "<circle cx=\"" << x << "\" cy=\"" << y << "\" r=\"" << r << "\" stroke=\"none\"/>"; |
| 612 | } |
| 613 | |
| 614 | // A run of adjacent ring sites from slot position `from` to `to` (in slot |
| 615 | // units), drawn as one round-capped arc. Caps add a dot diameter, so the |
| 616 | // centerline is shortened by one dot radius at each end. Runs spanning more |
| 617 | // than half a turn must set the SVG large-arc flag, or renderers draw the |
| 618 | // complementary short arc on the opposite side of the ring. |
| 619 | void svg_ring_run(std::ostringstream &o, double center, double radius, double from, double to, |
| 620 | double slots, double dot_radius, const char *stroke_width) { |
| 621 | const double cap = dot_radius / radius; |
| 622 | const double a0 = kTau * from / slots + cap, a1 = kTau * to / slots - cap; |
| 623 | o << "<path d=\"M " << center + radius * std::sin(a0) << ' ' << center - radius * std::cos(a0) |
| 624 | << " A " << radius << ' ' << radius << " 0 " << (a1 - a0 > kTau / 2 ? 1 : 0) << " 1 " |
| 625 | << center + radius * std::sin(a1) << ' ' << center - radius * std::cos(a1) |
| 626 | << "\" fill=\"none\" stroke-width=\"" << stroke_width << "\" stroke-linecap=\"round\"/>"; |
| 627 | } |
| 628 | |
| 629 | // CKey's finder follows the rounded corner itself. It is deliberately a |
| 630 | // single broad inset arc rather than a nested set of marks that projects into |
| 631 | // the payload field. Only the top-left arc is interrupted; that clear gap is |
| 632 | // the structural orientation signature. |
| 633 | void svg_corner_finders(std::ostringstream &o, double side, double inset, |
| 634 | const std::array<double, 3> &radii, double thickness, |
| 635 | double visible_split_gap) { |
| 636 | for (int corner = 0; corner < 4; ++corner) { |
| 637 | const std::string transform = |
| 638 | corner == 0 ? "" : |
| 639 | corner == 1 ? "translate(" + std::to_string(side) + " 0) scale(-1 1)" : |
| 640 | corner == 2 ? "translate(" + std::to_string(side) + " " + std::to_string(side) + ") scale(-1 -1)" : |
| 641 | "translate(0 " + std::to_string(side) + ") scale(1 -1)"; |
| 642 | o << "<g transform=\"" << transform << "\" fill=\"none\" stroke-linecap=\"round\">"; |
| 643 | for (double radius : radii) { |
| 644 | if (radius <= 0.0) continue; |
| 645 | if (corner == 0) { |
| 646 | // Include cap diameter in the angular calculation: the clear |
| 647 | // visual gap is exactly visible_split_gap, not merely the |
| 648 | // shorter centerline gap. |
| 649 | const double half_gap = (visible_split_gap + thickness) / (2.0 * radius); |
| 650 | // The outer (top-left) quadrant uses angles 180°..270°. |
| 651 | // Its concave/open side points down-right, toward the polar |
| 652 | // field, and the outermost path meets the frame tangents. |
| 653 | const double middle = 5.0 * kTau / 8.0; |
| 654 | const double a1 = middle - half_gap, a2 = middle + half_gap; |
| 655 | o << "<path d=\"M " << inset - radius << ' ' << inset |
| 656 | << " A " << radius << ' ' << radius << " 0 0 1 " |
| 657 | << inset + radius * std::cos(a1) << ' ' << inset + radius * std::sin(a1) |
| 658 | << " M " << inset + radius * std::cos(a2) << ' ' << inset + radius * std::sin(a2) |
| 659 | << " A " << radius << ' ' << radius << " 0 0 1 " << inset << ' ' << inset - radius |
| 660 | << "\" stroke-width=\"" << thickness << "\"/>"; |
| 661 | } else { |
| 662 | o << "<path d=\"M " << inset - radius << ' ' << inset |
| 663 | << " A " << radius << ' ' << radius << " 0 0 1 " << inset << ' ' << inset - radius |
| 664 | << "\" stroke-width=\"" << thickness << "\"/>"; |
| 665 | } |
| 666 | } |
| 667 | o << "</g>"; |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | struct ImagePoint { double x, y; }; |
| 672 | |
| 673 | bool solve_homography(const double src[8], const double dst[8], double h[8]) { |
| 674 | double a[8][9]{}; |
| 675 | for (int i = 0; i < 4; ++i) { |
| 676 | const double x = src[2*i], y = src[2*i+1], u = dst[2*i], v = dst[2*i+1]; |
| 677 | a[2*i][0]=x; a[2*i][1]=y; a[2*i][2]=1; a[2*i][6]=-u*x; a[2*i][7]=-u*y; a[2*i][8]=u; |
| 678 | a[2*i+1][3]=x; a[2*i+1][4]=y; a[2*i+1][5]=1; a[2*i+1][6]=-v*x; a[2*i+1][7]=-v*y; a[2*i+1][8]=v; |
| 679 | } |
| 680 | for (int col=0; col<8; ++col) { |
| 681 | int pivot=col; |
| 682 | for (int row=col+1; row<8; ++row) if (std::abs(a[row][col])>std::abs(a[pivot][col])) pivot=row; |
| 683 | if (std::abs(a[pivot][col])<1e-12) return false; |
| 684 | if (pivot!=col) for (int j=col; j<9; ++j) std::swap(a[pivot][j],a[col][j]); |
| 685 | const double d=a[col][col]; for (int j=col; j<9; ++j) a[col][j]/=d; |
| 686 | for (int row=0; row<8; ++row) if (row!=col) { const double f=a[row][col]; for (int j=col; j<9; ++j) a[row][j]-=f*a[col][j]; } |
| 687 | } |
| 688 | for (int i=0;i<8;++i) h[i]=a[i][8]; |
| 689 | return true; |
| 690 | } |
| 691 | |
| 692 | bool map_point(const double h[8], double x, double y, ImagePoint &p) { |
| 693 | const double d=h[6]*x+h[7]*y+1.0; |
| 694 | if (!std::isfinite(d) || std::abs(d)<1e-12) return false; |
| 695 | p={(h[0]*x+h[1]*y+h[2])/d,(h[3]*x+h[4]*y+h[5])/d}; |
| 696 | return std::isfinite(p.x) && std::isfinite(p.y); |
| 697 | } |
| 698 | |
| 699 | // Profile 1's original 3x3 support, in canonical units. It suits P1's |
| 700 | // one-unit modules; dense-profile marks are 0.10-0.34 units wide, so their |
| 701 | // samplers pass a support proportional to the feature they read. |
| 702 | constexpr double kLocatorSampleSpread = 0.12; |
| 703 | // P1 keeps its original support for dots and dial rays: narrower supports |
| 704 | // helped synthetic renders but lost the noise averaging the committed iPhone |
| 705 | // camera fixtures depend on. Per-class thresholds recover the thin rays. |
| 706 | constexpr double kLocatorDotSpread = kLocatorSampleSpread; |
| 707 | constexpr double kLocatorRaySpread = kLocatorSampleSpread; |
| 708 | // Between P1's outer dot ring (r <= 6.15) and its dial band (r >= 7.6): |
| 709 | // never printed, unlike the former (1.5, 11.5) reference, which is a |
| 710 | // Cartesian payload module. |
| 711 | constexpr double kLocatorApertureRadius = 6.9; |
| 712 | // A dense decoder samples every site only for orientations whose fixed keys |
| 713 | // agree: at least this fraction of key samples must match the expected print |
| 714 | // pattern. Wrong orientations and non-symbols are rejected cheaply. |
| 715 | constexpr double kDenseKeyGate = 0.8; |
| 716 | constexpr double kModuleSpread = 0.06; // 0.34-unit dense Cartesian modules |
| 717 | constexpr double kDotSpread = 0.025; // 0.15-0.20-unit ring dots and arcs |
| 718 | constexpr double kRaySpread = 0.02; // 0.10-0.16-unit radial strokes |
| 719 | // A point inside the P3 Grid Key clearance: guaranteed unprinted. |
| 720 | constexpr double kExtendedGridClearX = 7.75, kExtendedGridClearY = 45.5; |
| 721 | |
| 722 | double gray_sample(const uint8_t *gray, size_t width, size_t height, size_t stride, |
| 723 | const double h[8], double x, double y, double spread = kLocatorSampleSpread) { |
| 724 | // A small center-weighted 3x3 sample tolerates rasterization and mild blur. |
| 725 | const double offsets[3]={-spread,0.0,spread}; |
| 726 | constexpr double weights[3]={1.0,2.0,1.0}; |
| 727 | double sum=0, weight_sum=0; |
| 728 | for (int iy=0;iy<3;++iy) for (int ix=0;ix<3;++ix) { |
| 729 | ImagePoint p{}; if (!map_point(h,x+offsets[ix],y+offsets[iy],p)) return -1; |
| 730 | if (p.x<0 || p.y<0 || p.x>static_cast<double>(width-1) || p.y>static_cast<double>(height-1)) return -1; |
| 731 | const size_t x0=static_cast<size_t>(p.x), y0=static_cast<size_t>(p.y); |
| 732 | const size_t x1=std::min(x0+1,width-1), y1=std::min(y0+1,height-1); |
| 733 | const double fx=p.x-x0, fy=p.y-y0; |
| 734 | const double top=gray[y0*stride+x0]*(1-fx)+gray[y0*stride+x1]*fx; |
| 735 | const double bottom=gray[y1*stride+x0]*(1-fx)+gray[y1*stride+x1]*fx; |
| 736 | const double w=weights[ix]*weights[iy]; sum+=(top*(1-fy)+bottom*fy)*w; weight_sum+=w; |
| 737 | } |
| 738 | return sum/weight_sum; |
| 739 | } |
| 740 | |
| 741 | double polar_sample(const uint8_t *gray, size_t width, size_t height, size_t stride, |
| 742 | const double h[8], double radius, double slot, double spread = kLocatorSampleSpread) { |
| 743 | const double a=kTau*slot/72.0; |
| 744 | return gray_sample(gray,width,height,stride,h,kCenter+radius*std::sin(a),kCenter-radius*std::cos(a),spread); |
| 745 | } |
| 746 | |
| 747 | double polar_sample_slots(const uint8_t *gray, size_t width, size_t height, size_t stride, |
| 748 | const double h[8], double center, double radius, |
| 749 | double slot, double slot_count, double spread = kLocatorSampleSpread) { |
| 750 | const double a=kTau*slot/slot_count; |
| 751 | return gray_sample(gray,width,height,stride,h,center+radius*std::sin(a),center-radius*std::cos(a),spread); |
| 752 | } |
| 753 | |
| 754 | // Two-cluster (two-means) split of normalized samples (0 = unprinted |
| 755 | // reference, 1 = printed reference). Whitening makes every feature class |
| 756 | // roughly half printed, so a class's own samples calibrate its threshold; |
| 757 | // this absorbs the lower peak contrast of sub-pixel dots and strokes. The |
| 758 | // cut is bounded to [0.05, 0.95]: P3 width coverage legitimately separates |
| 759 | // near 0.85, so a tighter bound would misplace it. `separation` reports the |
| 760 | // distance between the two cluster means. |
| 761 | double two_means_cut(const std::vector<double> &values, double &separation) { |
| 762 | separation = 0; |
| 763 | if (values.size() < 16) return 0.5; |
| 764 | const auto [min_it, max_it] = std::minmax_element(values.begin(), values.end()); |
| 765 | double threshold = std::clamp((*min_it + *max_it) * 0.5, 0.05, 0.95); |
| 766 | for (int iteration = 0; iteration < 32; ++iteration) { |
| 767 | double sum_lo = 0, sum_hi = 0; size_t n_lo = 0, n_hi = 0; |
| 768 | for (double v : values) { if (v > threshold) { sum_hi += v; ++n_hi; } else { sum_lo += v; ++n_lo; } } |
| 769 | if (!n_lo || !n_hi) { separation = 0; break; } |
| 770 | const double lo = sum_lo / n_lo, hi = sum_hi / n_hi; |
| 771 | separation = hi - lo; |
| 772 | const double next = std::clamp((lo + hi) * 0.5, 0.05, 0.95); |
| 773 | if (std::abs(next - threshold) < 1e-6) break; |
| 774 | threshold = next; |
| 775 | } |
| 776 | return threshold; |
| 777 | } |
| 778 | |
| 779 | double class_threshold(const std::vector<double> &values) { |
| 780 | double separation = 0; |
| 781 | return two_means_cut(values, separation); |
| 782 | } |
| 783 | |
| 784 | // Ring sites join into arcs, so under blur an isolated dot peaks far lower |
| 785 | // than a dot inside a run, and a gap between two runs rises. Refine each data |
| 786 | // ring site's decision by its neighbour context (0, 1, or 2 printed ring |
| 787 | // neighbours), with a separate two-means cut per context. Fill sites' |
| 788 | // printed values are known exactly (whitening only), which anchors the |
| 789 | // context. A context keeps the class cut unless its own two clusters are |
| 790 | // clearly separated, so a one-sided context is never split spuriously. |
| 791 | // `grid` maps ring * slots + slot to a site index (-1 for reserved slots). |
| 792 | void refine_ring_context_rings(const std::vector<int> &grid, const std::vector<int> &ring_slots, int stride, size_t data_sites, |
| 793 | const std::vector<uint8_t> &white, const std::vector<double> &level, |
| 794 | double class_cut, std::vector<uint8_t> &printed); |
| 795 | |
| 796 | void refine_ring_context(const std::vector<int> &grid, int rings, int slots, size_t data_sites, |
| 797 | const std::vector<uint8_t> &white, const std::vector<double> &level, |
| 798 | double class_cut, std::vector<uint8_t> &printed) { |
| 799 | refine_ring_context_rings(grid, std::vector<int>(static_cast<size_t>(rings), slots), slots, data_sites, white, level, class_cut, printed); |
| 800 | } |
| 801 | |
| 802 | // grid maps ring * stride + slot -> site index (-1 reserved); ring r has |
| 803 | // ring_slots[r] slots and wraps at that count. |
| 804 | void refine_ring_context_rings(const std::vector<int> &grid, const std::vector<int> &ring_slots, int stride, size_t data_sites, |
| 805 | const std::vector<uint8_t> &white, const std::vector<double> &level, |
| 806 | double class_cut, std::vector<uint8_t> &printed) { |
| 807 | const int rings = static_cast<int>(ring_slots.size()); |
| 808 | constexpr double kMinSeparation = 0.2; |
| 809 | const auto value_at = [&](int ring, int slot) -> int { |
| 810 | const int slots = ring_slots[static_cast<size_t>(ring)]; |
| 811 | const int index = grid[static_cast<size_t>(ring) * stride + static_cast<size_t>((slot + slots) % slots)]; |
| 812 | return index < 0 ? 0 : printed[static_cast<size_t>(index)]; |
| 813 | }; |
| 814 | for (int pass = 0; pass < 2; ++pass) { |
| 815 | std::array<std::vector<double>, 3> by_context; |
| 816 | std::vector<std::pair<size_t, int>> sites; |
| 817 | for (int ring = 0; ring < rings; ++ring) for (int slot = 0; slot < ring_slots[static_cast<size_t>(ring)]; ++slot) { |
| 818 | const int index = grid[static_cast<size_t>(ring) * stride + slot]; |
| 819 | if (index < 0 || static_cast<size_t>(index) >= data_sites) continue; |
| 820 | const int context = value_at(ring, slot - 1) + value_at(ring, slot + 1); |
| 821 | by_context[context].push_back(level[static_cast<size_t>(index)]); |
| 822 | sites.push_back({static_cast<size_t>(index), context}); |
| 823 | } |
| 824 | std::array<double, 3> cut{class_cut, class_cut, class_cut}; |
| 825 | for (int context = 0; context < 3; ++context) { |
| 826 | double separation = 0; |
| 827 | const double candidate = two_means_cut(by_context[context], separation); |
| 828 | if (separation >= kMinSeparation) cut[context] = candidate; |
| 829 | } |
| 830 | for (const auto &[index, context] : sites) printed[index] = level[index] > cut[context] ? 1 : 0; |
| 831 | } |
| 832 | (void)white; |
| 833 | } |
| 834 | |
| 835 | bool is_tiered_extended_format(uint8_t format) { |
| 836 | return format == 17u || format == 20u || format == 21u || format == 24u || format == CKEY_EXTENDED_FORMAT_VERSION; |
| 837 | } |
| 838 | |
| 839 | bool is_text_format(uint8_t format) { |
| 840 | return format == 9u || format == 15u || format == 18u || format == 19u || format == 22u || format == 23u || |
| 841 | format == CKEY_TEXT_FORMAT_VERSION; |
| 842 | } |
| 843 | |
| 844 | bool valid_quad(const double q[8]); |
| 845 | |
| 846 | // Shared argument contract for every grayscale entry point. |
| 847 | bool valid_image_args(const uint8_t *gray, size_t width, size_t height, size_t stride, const double quad[8]) { |
| 848 | if (!gray || !quad || width < 2 || height < 2 || stride < width || |
| 849 | height > std::numeric_limits<size_t>::max() / stride) return false; |
| 850 | for (int i = 0; i < 8; ++i) if (!std::isfinite(quad[i])) return false; |
| 851 | return valid_quad(quad); |
| 852 | } |
| 853 | |
| 854 | bool valid_quad(const double q[8]) { |
| 855 | double area=0; int sign=0; |
| 856 | for (int i=0;i<4;++i) { |
| 857 | const double x1=q[2*i], y1=q[2*i+1], x2=q[2*((i+1)%4)], y2=q[2*((i+1)%4)+1]; |
| 858 | area+=x1*y2-x2*y1; |
| 859 | const double x3=q[2*((i+2)%4)], y3=q[2*((i+2)%4)+1]; |
| 860 | const double cross=(x2-x1)*(y3-y2)-(y2-y1)*(x3-x2); |
| 861 | if (std::abs(cross)<1e-6) return false; |
| 862 | const int current=cross>0?1:-1; |
| 863 | if (sign && sign!=current) return false; |
| 864 | sign=current; |
| 865 | } |
| 866 | return std::abs(area)>4.0; |
| 867 | } |
| 868 | } |
| 869 | |
| 870 | namespace { |
| 871 | // Fixed-key evidence for one candidate homography: contrast between printed |
| 872 | // and never-printed key samples, and the fraction of key samples that agree |
| 873 | // with the expected print pattern. Used by the calibration scores and as a |
| 874 | // cheap per-orientation gate before a dense decoder samples ~2k-21k sites. |
| 875 | struct KeyFit { bool valid = false; double contrast = 0, evidence = 0; }; |
| 876 | |
| 877 | KeyFit text_key_fit(const uint8_t *gray, size_t width, size_t height, size_t stride, const double h[8]) { |
| 878 | std::vector<double> marks, empty; |
| 879 | for (int r : {11,12}) for (int c : {2,3}) |
| 880 | marks.push_back(gray_sample(gray,width,height,stride,h,c+.5,r+.5)); |
| 881 | for (int slot : {36,38,42}) |
| 882 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,kCenter,6.41,slot,144.0,kDotSpread)); |
| 883 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,kCenter,6.41,47.5,144.0,kDotSpread)); |
| 884 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,kCenter,8.8,0.0,144.0,kRaySpread)); |
| 885 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,kCenter,8.4,108.0,144.0,kRaySpread)); |
| 886 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,kCenter,8.8,110.0,144.0,kRaySpread)); |
| 887 | constexpr double finder_inset=4.0, finder_radius=2.3; |
| 888 | const double diagonal=kTau/8.0; |
| 889 | for (const auto &[cx,cy,a] : {std::tuple{25.0,4.0,kTau*315.0/360.0}, |
| 890 | std::tuple{4.0,25.0,kTau*135.0/360.0}, |
| 891 | std::tuple{25.0,25.0,kTau*45.0/360.0}}) |
| 892 | marks.push_back(gray_sample(gray,width,height,stride,h,cx+finder_radius*std::cos(a),cy+finder_radius*std::sin(a))); |
| 893 | for (double a : {kTau*198.0/360.0,kTau*252.0/360.0}) |
| 894 | marks.push_back(gray_sample(gray,width,height,stride,h,finder_inset+finder_radius*std::cos(a),finder_inset+finder_radius*std::sin(a))); |
| 895 | // The top-left finder's split gap (225 deg) is never printed. (The 45 deg |
| 896 | // point used by Profile 1 is a payload site under Profile 2's safe area.) |
| 897 | empty.push_back(gray_sample(gray,width,height,stride,h,finder_inset+finder_radius*std::cos(5.0*diagonal),finder_inset+finder_radius*std::sin(5.0*diagonal))); |
| 898 | empty.push_back(gray_sample(gray,width,height,stride,h,1.5,11.5)); |
| 899 | empty.push_back(polar_sample_slots(gray,width,height,stride,h,kCenter,6.41,40.0,144.0,kDotSpread)); |
| 900 | empty.push_back(polar_sample_slots(gray,width,height,stride,h,kCenter,8.8,108.0,144.0,kRaySpread)); |
| 901 | if (std::any_of(marks.begin(),marks.end(),[](double v){return v<0;}) || std::any_of(empty.begin(),empty.end(),[](double v){return v<0;})) return {}; |
| 902 | const double mark_mean=std::accumulate(marks.begin(),marks.end(),0.0)/marks.size(); |
| 903 | const double empty_mean=std::accumulate(empty.begin(),empty.end(),0.0)/empty.size(); |
| 904 | const double contrast=std::abs(mark_mean-empty_mean), threshold=(mark_mean+empty_mean)*.5; |
| 905 | const bool dark=mark_mean<empty_mean; |
| 906 | const auto marked=[&](double value){return dark ? value<threshold : value>threshold;}; |
| 907 | const size_t evidence=std::count_if(marks.begin(),marks.end(),marked)+ |
| 908 | std::count_if(empty.begin(),empty.end(),[&](double value){return !marked(value);}); |
| 909 | return {true, contrast, static_cast<double>(evidence)/static_cast<double>(marks.size()+empty.size())}; |
| 910 | } |
| 911 | |
| 912 | KeyFit extended_key_fit(const uint8_t *gray, size_t width, size_t height, size_t stride, const double h[8]) { |
| 913 | std::vector<double> marks, empty; |
| 914 | // Grid Key, Ring Key, and Ray Key are all fixed in physical modules. |
| 915 | for (int r=0;r<2;++r) for (int c=0;c<2;++c) marks.push_back(gray_sample(gray,width,height,stride,h,8.25+c*.5,45.25+r*.5,kModuleSpread)); |
| 916 | for (int slot : {72,73,75}) marks.push_back(polar_sample_slots(gray,width,height,stride,h,58.0,kExtendedRingKeyRadius,slot,288.0,kDotSpread)); |
| 917 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,58.0,kExtendedRingKeyRadius,77.5,288.0,kDotSpread)); |
| 918 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,58.0,28.0,0.0,576.0,kRaySpread)); |
| 919 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,58.0,27.9,432.0,576.0,kRaySpread)); |
| 920 | marks.push_back(polar_sample_slots(gray,width,height,stride,h,58.0,28.4,440.0,576.0,kRaySpread)); |
| 921 | // Three whole finder arcs plus both halves of the split orientation |
| 922 | // arc. These are measured on the actual *outer* corner quadrants: a |
| 923 | // finder mouth faces the polar field but its ink follows the frame. |
| 924 | constexpr double finder_radius=9.2; |
| 925 | for (const auto &[cx,cy,a] : {std::tuple{100.0,16.0,kTau*315.0/360.0}, |
| 926 | std::tuple{16.0,100.0,kTau*135.0/360.0}, |
| 927 | std::tuple{100.0,100.0,kTau*45.0/360.0}}) |
| 928 | marks.push_back(gray_sample(gray,width,height,stride,h,cx+finder_radius*std::cos(a),cy+finder_radius*std::sin(a))); |
| 929 | for (double a : {kTau*195.0/360.0,kTau*255.0/360.0}) |
| 930 | marks.push_back(gray_sample(gray,width,height,stride,h,16.0+finder_radius*std::cos(a),16.0+finder_radius*std::sin(a))); |
| 931 | empty.push_back(gray_sample(gray,width,height,stride,h,16.0+finder_radius*std::cos(kTau*225.0/360.0),16.0+finder_radius*std::sin(kTau*225.0/360.0))); |
| 932 | empty.push_back(gray_sample(gray,width,height,stride,h,kExtendedGridClearX,kExtendedGridClearY,kModuleSpread)); |
| 933 | empty.push_back(polar_sample_slots(gray,width,height,stride,h,58.0,kExtendedRingKeyRadius,74.0,288.0,kDotSpread)); |
| 934 | empty.push_back(polar_sample_slots(gray,width,height,stride,h,58.0,kExtendedPolarGapRadius,216.0,576.0,kRaySpread)); |
| 935 | if (std::any_of(marks.begin(),marks.end(),[](double v){return v<0;}) || std::any_of(empty.begin(),empty.end(),[](double v){return v<0;})) return {}; |
| 936 | const double mark_mean=std::accumulate(marks.begin(),marks.end(),0.0)/marks.size(); |
| 937 | const double empty_mean=std::accumulate(empty.begin(),empty.end(),0.0)/empty.size(); |
| 938 | const double contrast=std::abs(mark_mean-empty_mean); const bool dark=mark_mean<empty_mean; |
| 939 | const double threshold=(mark_mean+empty_mean)*.5; |
| 940 | const auto marked=[&](double value){ return dark ? value<threshold : value>threshold; }; |
| 941 | const size_t evidence=std::count_if(marks.begin(),marks.end(),marked)+ |
| 942 | std::count_if(empty.begin(),empty.end(),[&](double value){return !marked(value);}); |
| 943 | return {true, contrast, static_cast<double>(evidence)/static_cast<double>(marks.size()+empty.size())}; |
| 944 | } |
| 945 | |
| 946 | } |
| 947 | |
| 948 | |
| 949 | // --------------------------------------------------------------------------- |
| 950 | // Profile 0 (Format 0.27): the scalable family. All versions share Profile |
| 951 | // 2's skeleton in the same 29-unit canonical field: frame, finders, a polar |
| 952 | // centre whose rings start at r = 3.4, and a dial whose rays start at r = 7.6. |
| 953 | // Version v sets the module pitch p = 29 / G(v); every payload mark, key, and |
| 954 | // clearance scales with p. The fixed integer tables below are normative. |
| 955 | // --------------------------------------------------------------------------- |
| 956 | namespace { |
| 957 | |
| 958 | constexpr int kScalableVersions = static_cast<int>(CKEY_SCALABLE_VERSION_COUNT); |
| 959 | constexpr std::array<int, kScalableVersions> kScalableGrid = {29, 31, 33, 36, 38, 41, 44, 47, 50, 54, 58, 62, 67, 71, 77, 82, 88, 94, 101, 108, 116, 124, 133, 143, 153, 164, 176, 188, 202, 216, 232}; |
| 960 | constexpr std::array<int, kScalableVersions> kScalableSlots = {72, 80, 80, 88, 96, 104, 112, 120, 128, 136, 144, 152, 168, 176, 192, 200, 216, 232, 248, 272, 288, 304, 328, 352, 376, 408, 440, 464, 504, 536, 576}; |
| 961 | constexpr std::array<int, kScalableVersions> kScalableRings = {3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 8, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 19, 20, 22, 23, 25, 27, 29, 32, 34, 37}; |
| 962 | constexpr std::array<int, kScalableVersions> kScalableGridCol = {2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 7, 7, 8, 9, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 21, 23}; |
| 963 | constexpr std::array<int, kScalableVersions> kScalableGridRow = {10, 12, 13, 14, 15, 16, 17, 18, 20, 21, 23, 25, 27, 28, 31, 33, 35, 38, 41, 44, 47, 50, 54, 58, 62, 67, 72, 77, 83, 88, 95}; |
| 964 | constexpr std::array<int, 4> kScalableParityPercent = {14, 28, 44, 60}; // L, M, Q, H |
| 965 | constexpr double kScalableRingInner = 3.4, kScalableDialInner = 7.6; |
| 966 | constexpr size_t kScalableHeaderBytes = 8, kScalableOverheadBytes = 12; // header + CRC-32C |
| 967 | |
| 968 | struct ScalableNode { uint8_t kind; double x, y; int ring, slot; uint32_t id; }; // kind: 0 Cartesian, 1 dial, 2 ring |
| 969 | |
| 970 | // Layout families. 0 = standard (Format 0.27). 1 = print (Format 0.28): every |
| 971 | // mark and gap is at least about one module, for small printed sizes. |
| 972 | constexpr int kScalableFamilies = 2; |
| 973 | constexpr std::array<int, kScalableVersions> kScalablePrintRings = {2, 3, 3, 3, 4, 4, 4, 5, 5, 6, 7, 7, 8, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 19, 20, 22, 24, 25, 27, 29, 32}; |
| 974 | |
| 975 | struct ScalableLayout { |
| 976 | int version = 0, family = 0, grid = 0, rings = 0, col0 = 0, row0 = 0; |
| 977 | int dial_slots = 0; // dial ray slots (Ray Keys use these) |
| 978 | int ring_stride = 0; // max ring slots (ring_grid row length) |
| 979 | std::vector<int> ring_slots; // slots on each ring |
| 980 | double pitch = 0; |
| 981 | double ring_pitch = 0, dot = 0, ray_width = 0, long_len = 0, short_len = 0, dial_sample = 0; |
| 982 | int ring_key_span = 16; |
| 983 | DenseGeometry geom{}; |
| 984 | std::vector<ScalableNode> nodes; |
| 985 | std::vector<uint8_t> white; |
| 986 | std::vector<int> ring_grid; // ring * ring_stride + slot -> site index, -1 reserved |
| 987 | size_t codeword_bytes = 0; |
| 988 | uint8_t format = CKEY_SCALABLE_FORMAT_VERSION; |
| 989 | double ring_radius(int k) const { return kScalableRingInner + ring_pitch * k; } |
| 990 | int ray_key_half() const { return 3 * dial_slots / 4; } // 9:00, half length |
| 991 | int ray_key_three() const { return 3 * dial_slots / 4 - 2; } // two slots before, 3/4 length |
| 992 | int ring_key_slot() const { return ring_slots.back() / 4; } // 3:00, on the outermost ring |
| 993 | bool reserved_ray(int slot) const { return slot == 0 || slot == ray_key_half() || slot == ray_key_three(); } |
| 994 | double long_ray() const { return long_len; } |
| 995 | double short_ray() const { return short_len; } |
| 996 | double key_ray() const { return 3.4 * pitch; } |
| 997 | // Ring Key on the outermost ring, in slots relative to ring_key_slot(): |
| 998 | // standard 16-slot pattern (dots 0, 2, 6; run 7.5..15.5), print 8-slot |
| 999 | // pattern (dots 0, 2; run 3.5..7.5). |
| 1000 | std::vector<int> ring_key_dots() const { return family == 0 ? std::vector<int>{0, 2, 6} : std::vector<int>{0, 2}; } |
| 1001 | std::pair<double, double> ring_key_run() const { return family == 0 ? std::pair{7.5, 15.5} : std::pair{3.5, 7.5}; } |
| 1002 | std::vector<double> ring_key_marks() const { return family == 0 ? std::vector<double>{0, 2, 6, 11.5} : std::vector<double>{0, 2, 5.5}; } |
| 1003 | std::vector<double> ring_key_empties() const { return family == 0 ? std::vector<double>{4} : std::vector<double>{1, 3}; } |
| 1004 | }; |
| 1005 | |
| 1006 | DenseGeometry scalable_geometry(int col0, int row0, double p, int slots, double ray_ink_outer) { |
| 1007 | const double a = kTau * (3.0 * slots / 4.0 - 2.0) / slots; |
| 1008 | const double clear = std::max(0.375, p); |
| 1009 | return {14.5, 14.0, 3.5, 0.325, clear, |
| 1010 | 4.0, 2.3, 0.4, clear, 29.0, |
| 1011 | col0 * p + 0.16 * p, row0 * p + 0.16 * p, (col0 + 2) * p - 0.16 * p, (row0 + 2) * p - 0.16 * p, p, |
| 1012 | kCenter, ray_ink_outer, p, |
| 1013 | kScalableDialInner, 3.4 * p, 0.22 * p, |
| 1014 | {{0.0, -1.0}, {-1.0, 0.0}, {std::sin(a), -std::cos(a)}}, |
| 1015 | {1.0, 0.5, 0.75}, |
| 1016 | 0.34 * p}; |
| 1017 | } |
| 1018 | |
| 1019 | // Equal-arc slot count: the largest multiple of 8 whose arc spacing at |
| 1020 | // radius r is at least one module (print family). |
| 1021 | int equal_arc_slots(double r, double p) { return 8 * static_cast<int>(std::floor(kTau * r / (8.0 * p))); } |
| 1022 | |
| 1023 | std::unique_ptr<ScalableLayout> build_scalable_layout(int v, int family) { |
| 1024 | auto L = std::make_unique<ScalableLayout>(); |
| 1025 | const size_t i = static_cast<size_t>(v - 1); |
| 1026 | L->version = v; L->family = family; L->grid = kScalableGrid[i]; |
| 1027 | L->col0 = kScalableGridCol[i]; L->row0 = kScalableGridRow[i]; L->pitch = 29.0 / L->grid; |
| 1028 | const double p = L->pitch; |
| 1029 | if (family == 0) { |
| 1030 | L->rings = kScalableRings[i]; L->dial_slots = kScalableSlots[i]; |
| 1031 | L->ring_slots.assign(static_cast<size_t>(L->rings), L->dial_slots); |
| 1032 | L->ring_pitch = 0.86 * p; L->dot = 0.2 * p; L->ray_width = 0.2 * p; L->long_len = 2.2 * p; L->short_len = 1.0 * p; |
| 1033 | L->dial_sample = 1.6 * p; L->ring_key_span = 16; L->format = CKEY_SCALABLE_FORMAT_VERSION; |
| 1034 | } else { |
| 1035 | L->rings = kScalablePrintRings[i]; L->ring_pitch = 1.0 * p; |
| 1036 | L->dial_slots = equal_arc_slots(kScalableDialInner, p); |
| 1037 | for (int k = 0; k < L->rings; ++k) L->ring_slots.push_back(equal_arc_slots(L->ring_radius(k), p)); |
| 1038 | L->dot = 0.3 * p; L->ray_width = 0.35 * p; L->long_len = 2.4 * p; L->short_len = 1.0 * p; |
| 1039 | L->dial_sample = 1.7 * p; L->ring_key_span = 8; L->format = CKEY_SCALABLE_PRINT_FORMAT_VERSION; |
| 1040 | if (v == 1) L->row0 = 9; // its longer rays need the v1 Grid Key one row higher for a pitch of clearance |
| 1041 | } |
| 1042 | L->ring_stride = *std::max_element(L->ring_slots.begin(), L->ring_slots.end()); |
| 1043 | L->geom = scalable_geometry(L->col0, L->row0, p, L->dial_slots, kScalableDialInner + L->long_len + L->ray_width / 2); |
| 1044 | for (int row = 0; row < L->grid; ++row) for (int col = 0; col < L->grid; ++col) { |
| 1045 | const double x = (col + .5) * p, y = (row + .5) * p; |
| 1046 | if (dense_site_margin(L->geom, x, y) <= 0.0) continue; |
| 1047 | L->nodes.push_back({0, x, y, -1, -1, site_identity(0, static_cast<uint32_t>(row), static_cast<uint32_t>(col))}); |
| 1048 | } |
| 1049 | for (int slot = 0; slot < L->dial_slots; ++slot) |
| 1050 | if (!L->reserved_ray(slot)) L->nodes.push_back({1, 0, 0, -1, slot, site_identity(1, 0, static_cast<uint32_t>(slot))}); |
| 1051 | const int key = L->ring_key_slot(); |
| 1052 | for (int ring = 0; ring < L->rings; ++ring) { |
| 1053 | const int S = L->ring_slots[static_cast<size_t>(ring)]; |
| 1054 | for (int slot = 0; slot < S; ++slot) { |
| 1055 | // Standard family: ring slots are also cleared on the Ray Key bearings (inherited from Profile 2). |
| 1056 | if ((family == 0 && L->reserved_ray(slot)) || (ring == L->rings - 1 && slot >= key && slot < key + L->ring_key_span)) continue; |
| 1057 | const double r = L->ring_radius(ring), a = kTau * slot / S; |
| 1058 | L->nodes.push_back({2, kCenter + r * std::sin(a), kCenter - r * std::cos(a), ring, slot, |
| 1059 | site_identity(2, static_cast<uint32_t>(ring), static_cast<uint32_t>(slot))}); |
| 1060 | } |
| 1061 | } |
| 1062 | const uint32_t placement = family == 0 ? 0x434b5030u : 0x434b5031u; // "CKP0" / "CKP1" |
| 1063 | const uint32_t whitening = family == 0 ? 0x434b5730u : 0x434b5731u; // "CKW0" / "CKW1" |
| 1064 | order_sites(L->nodes, site_hash(placement ^ static_cast<uint32_t>(v))); |
| 1065 | L->white = whitening_bits(whitening + static_cast<uint32_t>(v), L->nodes.size()); |
| 1066 | L->codeword_bytes = L->nodes.size() / 8; |
| 1067 | L->ring_grid.assign(static_cast<size_t>(L->rings) * L->ring_stride, -1); |
| 1068 | for (size_t n = 0; n < L->nodes.size(); ++n) |
| 1069 | if (L->nodes[n].kind == 2) L->ring_grid[static_cast<size_t>(L->nodes[n].ring) * L->ring_stride + L->nodes[n].slot] = static_cast<int>(n); |
| 1070 | return L; |
| 1071 | } |
| 1072 | |
| 1073 | const ScalableLayout &scalable_layout(int v, int family = 0) { |
| 1074 | static std::array<std::once_flag, kScalableVersions * kScalableFamilies> once; |
| 1075 | static std::array<std::unique_ptr<ScalableLayout>, kScalableVersions * kScalableFamilies> cache; |
| 1076 | const size_t i = static_cast<size_t>(family * kScalableVersions + v - 1); |
| 1077 | std::call_once(once[i], [&] { cache[i] = build_scalable_layout(v, family); }); |
| 1078 | return *cache[i]; |
| 1079 | } |
| 1080 | |
| 1081 | struct RsBlock { size_t n, k; }; |
| 1082 | |
| 1083 | // The codeword is split into ceil(C/255) near-equal RS blocks (longer ones |
| 1084 | // first); each keeps 2*round(pct*n/200) parity bytes (integer arithmetic). |
| 1085 | std::vector<RsBlock> scalable_blocks(size_t codeword_bytes, int level) { |
| 1086 | std::vector<RsBlock> blocks; |
| 1087 | const size_t count = (codeword_bytes + 254) / 255; |
| 1088 | for (size_t b = 0; b < count; ++b) { |
| 1089 | const size_t n = codeword_bytes / count + (b < codeword_bytes % count ? 1 : 0); |
| 1090 | const size_t parity = std::max<size_t>(2, 2 * ((static_cast<size_t>(kScalableParityPercent[static_cast<size_t>(level)]) * n + 100) / 200)); |
| 1091 | blocks.push_back({n, n - std::min(parity, n - 1)}); |
| 1092 | } |
| 1093 | return blocks; |
| 1094 | } |
| 1095 | |
| 1096 | size_t scalable_data_bytes(size_t codeword_bytes, int level) { |
| 1097 | size_t k = 0; for (const auto &b : scalable_blocks(codeword_bytes, level)) k += b.k; return k; |
| 1098 | } |
| 1099 | |
| 1100 | size_t scalable_capacity(int v, int level, int family = 0) { |
| 1101 | if (v < 1 || v > kScalableVersions || level < 0 || level > 3 || family < 0 || family >= kScalableFamilies) return 0; |
| 1102 | const size_t k = scalable_data_bytes(scalable_layout(v, family).codeword_bytes, level); |
| 1103 | return k > kScalableOverheadBytes ? std::min<size_t>(k - kScalableOverheadBytes, 65535) : 0; |
| 1104 | } |
| 1105 | |
| 1106 | bool scalable_site_value(const ScalableLayout &L, const ckey_scalable_symbol *s, size_t i) { |
| 1107 | const bool data = i < L.codeword_bytes * 8 && ((s->codeword[i / 8] >> (7 - i % 8)) & 1u) != 0; |
| 1108 | return data != (L.white[i] != 0); |
| 1109 | } |
| 1110 | |
| 1111 | // Contrast/agreement fit of fixed samples (marks expected printed, empties |
| 1112 | // expected clear); invalid if any sample leaves the image. |
| 1113 | KeyFit sample_fit(const std::vector<double> &marks, const std::vector<double> &empty) { |
| 1114 | if (marks.empty() || empty.empty() || std::any_of(marks.begin(), marks.end(), [](double v) { return v < 0; }) || |
| 1115 | std::any_of(empty.begin(), empty.end(), [](double v) { return v < 0; })) return {}; |
| 1116 | const double mark_mean = std::accumulate(marks.begin(), marks.end(), 0.0) / marks.size(); |
| 1117 | const double empty_mean = std::accumulate(empty.begin(), empty.end(), 0.0) / empty.size(); |
| 1118 | const bool dark = mark_mean < empty_mean; const double threshold = (mark_mean + empty_mean) * .5; |
| 1119 | const auto marked = [&](double v) { return dark ? v < threshold : v > threshold; }; |
| 1120 | const size_t evidence = std::count_if(marks.begin(), marks.end(), marked) + |
| 1121 | std::count_if(empty.begin(), empty.end(), [&](double v) { return !marked(v); }); |
| 1122 | return {true, std::abs(mark_mean - empty_mean), static_cast<double>(evidence) / static_cast<double>(marks.size() + empty.size())}; |
| 1123 | } |
| 1124 | |
| 1125 | // Version- and style-independent skeleton: the frame stroke and both arms of |
| 1126 | // all four finders, sampled near the arm ends (common to every finder style: |
| 1127 | // arc, pill, squircle, bracket), against the frame moat and the outer quiet |
| 1128 | // margin. It is symmetric under the 8 orientations on purpose: finder styles |
| 1129 | // change the top-left split's shape, and a logo may occupy the centre, so |
| 1130 | // orientation comes from the asymmetric Grid, Ring, and Ray Keys instead. |
| 1131 | KeyFit scalable_skeleton_fit(const uint8_t *gray, size_t width, size_t height, size_t stride, const double h[8]) { |
| 1132 | std::vector<double> marks, empty; |
| 1133 | for (const auto &[x, y] : {std::pair{14.5, 0.5}, {28.5, 14.5}, {14.5, 28.5}, {0.5, 14.5}, {8.0, 0.5}, {21.0, 0.5}, {0.5, 21.0}, {28.5, 8.0}}) |
| 1134 | marks.push_back(gray_sample(gray, width, height, stride, h, x, y)); |
| 1135 | // Arm points at 198 and 252 degrees of the canonical arc (radius 2.3 about |
| 1136 | // the corner inset 4), mirrored into each corner. |
| 1137 | const double arm[2][2] = {{2.3 * std::cos(kTau * 198.0 / 360.0), 2.3 * std::sin(kTau * 198.0 / 360.0)}, |
| 1138 | {2.3 * std::cos(kTau * 252.0 / 360.0), 2.3 * std::sin(kTau * 252.0 / 360.0)}}; |
| 1139 | for (const auto &[cx, cy, mx, my] : {std::tuple{4.0, 4.0, 1.0, 1.0}, {25.0, 4.0, -1.0, 1.0}, {4.0, 25.0, 1.0, -1.0}, {25.0, 25.0, -1.0, -1.0}}) |
| 1140 | for (const auto &o : arm) marks.push_back(gray_sample(gray, width, height, stride, h, cx + mx * o[0], cy + my * o[1])); |
| 1141 | // Frame moat (payload ink starts at least 0.825 + 0.375 from each edge) |
| 1142 | // and the mandatory outer quiet margin (-1..0). |
| 1143 | for (const auto &[x, y] : {std::pair{14.5, 1.0}, {28.0, 14.5}, {14.5, 28.0}, {1.0, 14.5}, |
| 1144 | {14.5, -0.4}, {29.4, 14.5}, {14.5, 29.4}, {-0.4, 14.5}}) |
| 1145 | empty.push_back(gray_sample(gray, width, height, stride, h, x, y, 0.05)); |
| 1146 | return sample_fit(marks, empty); |
| 1147 | } |
| 1148 | |
| 1149 | // Version-specific keys at the version's own module and slot pitch: the 2x2 |
| 1150 | // Grid Key on the payload lattice, the Ring Key's dot/gap/run pattern on the |
| 1151 | // outermost ring, and the three Ray Keys. A wrong version misses them. |
| 1152 | KeyFit scalable_version_fit(const ScalableLayout &L, const uint8_t *gray, size_t width, size_t height, size_t stride, const double h[8]) { |
| 1153 | const double p = L.pitch, rk = L.ring_radius(L.rings - 1), S = L.ring_slots.back(), D = L.dial_slots, ds = 0.25 * L.dot; |
| 1154 | std::vector<double> marks, empty; |
| 1155 | for (int r = 0; r < 2; ++r) for (int c = 0; c < 2; ++c) |
| 1156 | marks.push_back(gray_sample(gray, width, height, stride, h, (L.col0 + c + .5) * p, (L.row0 + r + .5) * p, 0.12 * p)); |
| 1157 | const int k = L.ring_key_slot(); |
| 1158 | for (double slot : L.ring_key_marks()) marks.push_back(polar_sample_slots(gray, width, height, stride, h, kCenter, rk, k + slot, S, ds)); |
| 1159 | marks.push_back(polar_sample_slots(gray, width, height, stride, h, kCenter, kScalableDialInner + 3.0 * p, 0, D, 0.04 * p)); |
| 1160 | marks.push_back(polar_sample_slots(gray, width, height, stride, h, kCenter, kScalableDialInner + 1.3 * p, L.ray_key_half(), D, 0.04 * p)); |
| 1161 | marks.push_back(polar_sample_slots(gray, width, height, stride, h, kCenter, kScalableDialInner + 2.3 * p, L.ray_key_three(), D, 0.04 * p)); |
| 1162 | empty.push_back(gray_sample(gray, width, height, stride, h, (L.col0 - 1 + .5) * p, (L.row0 + .5) * p, 0.12 * p)); |
| 1163 | for (double slot : L.ring_key_empties()) empty.push_back(polar_sample_slots(gray, width, height, stride, h, kCenter, rk, k + slot, S, ds)); |
| 1164 | empty.push_back(polar_sample_slots(gray, width, height, stride, h, kCenter, kScalableDialInner + 2.6 * p, L.ray_key_half(), D, 0.04 * p)); |
| 1165 | return sample_fit(marks, empty); |
| 1166 | } |
| 1167 | |
| 1168 | void orientation_homography(const double quad[8], int reverse, int start, double h[8], bool &ok) { |
| 1169 | constexpr double canonical[8] = {0, 0, 29, 0, 29, 29, 0, 29}; |
| 1170 | double dst[8]; |
| 1171 | for (int i = 0; i < 4; ++i) { const int q = (start + (reverse ? -i : i) + 8) % 4; dst[2 * i] = quad[2 * q]; dst[2 * i + 1] = quad[2 * q + 1]; } |
| 1172 | ok = solve_homography(canonical, dst, h); |
| 1173 | } |
| 1174 | |
| 1175 | // Sub-module alignment from the frame stroke's two edges. For points along |
| 1176 | // the straight middle of all four sides, compare samples just inside the |
| 1177 | // stroke with samples just outside it, at both its outer (0.175) and inner |
| 1178 | // (0.825) edge. The sum peaks sharply when every edge is exactly aligned and, |
| 1179 | // averaged over 64 points per edge, resolves well below a module even at |
| 1180 | // Profile 0's finest pitch. The frame is symmetric, so no orientation search. |
| 1181 | // eps_px is the half-separation of each edge's sample pair, in pixels. The |
| 1182 | // score is flat within about +/-eps of alignment, so callers shrink eps with |
| 1183 | // their search step (coarse-to-fine): wide to capture, sub-pixel to finish. |
| 1184 | double frame_edge_score(const uint8_t *gray, size_t width, size_t height, size_t stride, const double quad[8], double eps_px) { |
| 1185 | constexpr double canonical[8] = {0, 0, 29, 0, 29, 29, 0, 29}; |
| 1186 | double h[8]; if (!solve_homography(canonical, quad, h)) return 0; |
| 1187 | const double side_px = (std::hypot(quad[2] - quad[0], quad[3] - quad[1]) + std::hypot(quad[6] - quad[0], quad[7] - quad[1])) / 2; |
| 1188 | const double eps = std::clamp(eps_px * 29.0 / std::max(side_px, 1.0), 0.004, 0.3); |
| 1189 | double centre = 0, clear = 0, edges = 0; int n = 0; |
| 1190 | for (int side = 0; side < 4; ++side) for (int i = 0; i < 16; ++i) { |
| 1191 | const double t = 5.0 + 19.0 * i / 15.0; |
| 1192 | // Point on the stroke centreline (inset 0.5) and outward normal. |
| 1193 | double x, y, nx, ny; |
| 1194 | if (side == 0) { x = t; y = 0.5; nx = 0; ny = -1; } |
| 1195 | else if (side == 1) { x = 28.5; y = t; nx = 1; ny = 0; } |
| 1196 | else if (side == 2) { x = 29 - t; y = 28.5; nx = 0; ny = 1; } |
| 1197 | else { x = 0.5; y = 29 - t; nx = -1; ny = 0; } |
| 1198 | const auto at = [&](double d) { return gray_sample(gray, width, height, stride, h, x + nx * d, y + ny * d, 0.0); }; |
| 1199 | const double c = at(0), out_in = at(0.325 - eps), out_out = at(0.325 + eps), in_in = at(-0.325 + eps), in_out = at(-0.325 - eps); |
| 1200 | if (c < 0 || out_in < 0 || out_out < 0 || in_in < 0 || in_out < 0) return 0; |
| 1201 | centre += c; clear += (out_out + in_out) / 2; edges += (out_in - out_out) + (in_in - in_out); ++n; |
| 1202 | } |
| 1203 | const double polarity = centre > clear ? 1.0 : -1.0; // light-on-dark or dark-on-light |
| 1204 | return std::max(0.0, polarity * edges / n); |
| 1205 | } |
| 1206 | |
| 1207 | double scalable_skeleton_score(const uint8_t *gray, size_t width, size_t height, size_t stride, const double quad[8]) { |
| 1208 | double best = 0; |
| 1209 | for (int reverse = 0; reverse < 2; ++reverse) for (int start = 0; start < 4; ++start) { |
| 1210 | double h[8]; bool ok; orientation_homography(quad, reverse, start, h, ok); if (!ok) continue; |
| 1211 | const KeyFit fit = scalable_skeleton_fit(gray, width, height, stride, h); |
| 1212 | if (fit.valid) best = std::max(best, fit.contrast * fit.evidence); |
| 1213 | } |
| 1214 | return best; |
| 1215 | } |
| 1216 | |
| 1217 | // Combined skeleton + version-key score; the version terms dominate once the |
| 1218 | // skeleton is aligned, which is what pins the module pitch. |
| 1219 | double scalable_version_score(const ScalableLayout &L, const uint8_t *gray, size_t width, size_t height, size_t stride, const double quad[8]) { |
| 1220 | double best = 0; |
| 1221 | for (int reverse = 0; reverse < 2; ++reverse) for (int start = 0; start < 4; ++start) { |
| 1222 | double h[8]; bool ok; orientation_homography(quad, reverse, start, h, ok); if (!ok) continue; |
| 1223 | const KeyFit skeleton = scalable_skeleton_fit(gray, width, height, stride, h); |
| 1224 | const KeyFit keys = scalable_version_fit(L, gray, width, height, stride, h); |
| 1225 | if (skeleton.valid && keys.valid) best = std::max(best, skeleton.contrast * skeleton.evidence + 2.0 * keys.contrast * keys.evidence * keys.evidence); |
| 1226 | } |
| 1227 | return best; |
| 1228 | } |
| 1229 | |
| 1230 | int best_scalable_version(const uint8_t *gray, size_t width, size_t height, size_t stride, const double quad[8], double &score, int *family = nullptr) { |
| 1231 | int best_v = 0, best_f = 0; score = 0; |
| 1232 | for (int f = 0; f < kScalableFamilies; ++f) for (int v = 1; v <= kScalableVersions; ++v) { |
| 1233 | const double s = scalable_version_score(scalable_layout(v, f), gray, width, height, stride, quad); |
| 1234 | if (s > score) { score = s; best_v = v; best_f = f; } |
| 1235 | } |
| 1236 | if (family) *family = best_f; |
| 1237 | return best_v; |
| 1238 | } |
| 1239 | |
| 1240 | std::string fixed4(double v) { char b[32]; std::snprintf(b, sizeof(b), "%.4f", v); return b; } |
| 1241 | |
| 1242 | } // namespace |
| 1243 | |
| 1244 | |
| 1245 | |
| 1246 | namespace { |
| 1247 | |
| 1248 | std::string hex_colour(ckey_rgb c) { char b[8]; std::snprintf(b, sizeof(b), "#%02x%02x%02x", c.r, c.g, c.b); return b; } |
| 1249 | bool same_colour(ckey_rgb a, ckey_rgb b) { return a.r == b.r && a.g == b.g && a.b == b.b; } |
| 1250 | |
| 1251 | // WCAG relative luminance and contrast ratio: the reader decodes luminance, |
| 1252 | // so every ink colour must stand clear of every background colour. |
| 1253 | double relative_luminance(ckey_rgb c) { |
| 1254 | const auto channel = [](uint8_t v) { const double x = v / 255.0; return x <= 0.04045 ? x / 12.92 : std::pow((x + 0.055) / 1.055, 2.4); }; |
| 1255 | return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b); |
| 1256 | } |
| 1257 | double contrast_ratio(ckey_rgb a, ckey_rgb b) { |
| 1258 | const double la = relative_luminance(a), lb = relative_luminance(b); |
| 1259 | return (std::max(la, lb) + 0.05) / (std::min(la, lb) + 0.05); |
| 1260 | } |
| 1261 | |
| 1262 | std::string xml_escape(const char *text) { |
| 1263 | std::string out; |
| 1264 | for (const char *c = text; *c; ++c) { |
| 1265 | if (*c == '&') out += "&"; else if (*c == '<') out += "<"; else if (*c == '>') out += ">"; |
| 1266 | else if (*c == '"') out += """; else out += *c; |
| 1267 | } |
| 1268 | return out; |
| 1269 | } |
| 1270 | |
| 1271 | // The empty centre (inside ring 0's ink) holds a logo without hiding marks. |
| 1272 | double logo_quiet_radius(const ScalableLayout &L) { return kScalableRingInner - L.dot - 0.15; } |
| 1273 | // Keep the outermost ring (which carries the Ring Key) fully visible. |
| 1274 | double logo_max_radius(const ScalableLayout &L) { return L.ring_radius(L.rings - 1) - L.dot - 0.25; } |
| 1275 | |
| 1276 | double logo_load(const ScalableLayout &L, int level, double radius) { |
| 1277 | if (radius <= logo_quiet_radius(L)) return 0.0; |
| 1278 | const auto blocks = scalable_blocks(L.codeword_bytes, level); |
| 1279 | std::vector<size_t> block_of(L.codeword_bytes); size_t pos = 0; |
| 1280 | for (size_t j = 0; j < blocks.front().n; ++j) for (size_t b = 0; b < blocks.size(); ++b) if (j < blocks[b].n) block_of[pos++] = b; |
| 1281 | std::vector<uint8_t> hidden(L.codeword_bytes, 0); |
| 1282 | for (size_t i = 0; i < L.codeword_bytes * 8; ++i) { |
| 1283 | const auto &n = L.nodes[i]; |
| 1284 | if (n.kind != 2) continue; // only ring marks can lie under an allowed logo |
| 1285 | if (std::hypot(n.x - kCenter, n.y - kCenter) - L.dot <= radius + 0.05) hidden[i / 8] = 1; |
| 1286 | } |
| 1287 | std::vector<size_t> per_block(blocks.size(), 0); |
| 1288 | for (size_t byte = 0; byte < hidden.size(); ++byte) per_block[block_of[byte]] += hidden[byte]; |
| 1289 | double worst = 0; |
| 1290 | for (size_t b = 0; b < blocks.size(); ++b) worst = std::max(worst, per_block[b] / std::max(1.0, (blocks[b].n - blocks[b].k) / 2.0)); |
| 1291 | return worst; |
| 1292 | } |
| 1293 | |
| 1294 | // The default style (spec §4: black on white). Shared so that libraries |
| 1295 | // other than ckey_render (the .ckey reader in ckey_codec) need not link it. |
| 1296 | void fill_default_style(ckey_style &st) { |
| 1297 | std::memset(&st, 0, sizeof(st)); |
| 1298 | st.background = st.background_edge = {0xff, 0xff, 0xff}; |
| 1299 | st.ink = st.ink_shade = {0, 0, 0}; |
| 1300 | st.modules = CKEY_MODULE_ROUNDED; st.rings = CKEY_RING_JOINED; st.rays = CKEY_RAY_ROUND; |
| 1301 | st.policy = CKEY_POLICY_STRICT; |
| 1302 | } |
| 1303 | |
| 1304 | // Values a renderer cannot draw at all, whatever the policy. |
| 1305 | std::string structural_problem(const ScalableLayout &L, const ckey_style &st) { |
| 1306 | const auto finite01 = [](double v) { return std::isfinite(v) && v >= 0 && v <= 1; }; |
| 1307 | // A caller can store any integer in an enum field. Read the raw bytes so an |
| 1308 | // out-of-range value is refused without loading it as the enum type. |
| 1309 | const auto raw = [](const auto &field) { |
| 1310 | using T = std::remove_cvref_t<decltype(field)>; |
| 1311 | if constexpr (std::is_enum_v<T>) { |
| 1312 | std::underlying_type_t<T> v; |
| 1313 | std::memcpy(&v, &field, sizeof v); |
| 1314 | return static_cast<long long>(v); |
| 1315 | } else { |
| 1316 | return static_cast<long long>(field); |
| 1317 | } |
| 1318 | }; |
| 1319 | if (raw(st.policy) < CKEY_POLICY_STRICT || raw(st.policy) > CKEY_POLICY_OVERRIDE) return "unknown style policy"; |
| 1320 | if (raw(st.modules) < CKEY_MODULE_ROUNDED || raw(st.modules) > CKEY_MODULE_DIAMOND) return "unknown module shape"; |
| 1321 | if (raw(st.rings) < CKEY_RING_JOINED || raw(st.rings) > CKEY_RING_DOTS) return "unknown ring style"; |
| 1322 | if (raw(st.rays) < CKEY_RAY_ROUND || raw(st.rays) > CKEY_RAY_FLAT) return "unknown ray cap"; |
| 1323 | if (st.accent_count > 4 || st.accent_percent > 100) return "accents: at most 4 colours and 100%"; |
| 1324 | if (!finite01(st.bevel) || !finite01(st.shadow) || !finite01(st.texture)) return "bevel, shadow, and texture must be 0..1"; |
| 1325 | if (!std::isfinite(st.logo_radius) || st.logo_radius < 0) return "logo radius must be 0 or positive"; |
| 1326 | if (st.logo_href && *st.logo_href && st.logo_radius > 14.5) return "logo radius must be at most 14.5 units"; |
| 1327 | (void)L; |
| 1328 | return {}; |
| 1329 | } |
| 1330 | |
| 1331 | // Scan-safety rules. Only what reliably breaks a code is a hard refusal: |
| 1332 | // near-invisible marks (any ink or accent below kHardContrast against a |
| 1333 | // background), mixed polarity, and logos that hide too much. Lower contrast |
| 1334 | // and strong depth are warnings: the photo sweep in ckey_certify decides |
| 1335 | // whether such a style scans, and it often does. |
| 1336 | constexpr double kHardContrast = 1.5; |
| 1337 | constexpr double kAdviseContrast = 3.0; |
| 1338 | |
| 1339 | std::string safety_problem(const ScalableLayout &L, const ckey_scalable_symbol &sym, const ckey_style &st, |
| 1340 | const std::vector<ckey_rgb> &extra_backgrounds, double &min_contrast, double &load, |
| 1341 | std::string *warnings = nullptr) { |
| 1342 | min_contrast = 1e9; load = 0; |
| 1343 | std::vector<ckey_rgb> inks = {st.ink, st.ink_shade}; |
| 1344 | if (st.accent_percent > 0) for (uint32_t i = 0; i < st.accent_count; ++i) inks.push_back(st.accents[i]); |
| 1345 | std::vector<ckey_rgb> backgrounds = {st.background, st.background_edge}; |
| 1346 | backgrounds.insert(backgrounds.end(), extra_backgrounds.begin(), extra_backgrounds.end()); |
| 1347 | int polarity = 0; std::string problem, advice; |
| 1348 | for (const auto &bg : backgrounds) for (const auto &ink : inks) { |
| 1349 | const double ratio = contrast_ratio(ink, bg); |
| 1350 | min_contrast = std::min(min_contrast, ratio); |
| 1351 | char why[112]; |
| 1352 | if (ratio < kHardContrast && problem.empty()) { std::snprintf(why, sizeof(why), "ink %s on background %s is %.1f:1, too faint to read (minimum %.1f:1)", hex_colour(ink).c_str(), hex_colour(bg).c_str(), ratio, kHardContrast); problem = why; } |
| 1353 | else if (ratio < kAdviseContrast && advice.empty()) { std::snprintf(why, sizeof(why), "ink %s on background %s is only %.1f:1", hex_colour(ink).c_str(), hex_colour(bg).c_str(), ratio); advice = why; } |
| 1354 | const int side = relative_luminance(ink) > relative_luminance(bg) ? 1 : -1; |
| 1355 | if (polarity && side != polarity && problem.empty()) problem = "all inks must be lighter than the background, or all darker"; |
| 1356 | polarity = side; |
| 1357 | } |
| 1358 | if (polarity < 0 && st.shadow > 0.4 && advice.empty()) advice = "strong shadow on dark-on-light marks"; |
| 1359 | if (polarity < 0 && st.bevel > 0.5 && advice.empty()) advice = "strong bevel on dark-on-light marks"; |
| 1360 | if (warnings) *warnings = advice; |
| 1361 | if (!problem.empty()) return problem; |
| 1362 | if (st.logo_href && *st.logo_href) { |
| 1363 | const double radius = st.logo_radius > 0 ? st.logo_radius : logo_quiet_radius(L); |
| 1364 | char why[96]; |
| 1365 | if (radius > logo_max_radius(L)) { std::snprintf(why, sizeof(why), "logo radius %.2f exceeds %.2f (it would hide the Ring Key)", radius, logo_max_radius(L)); return why; } |
| 1366 | if (radius > logo_quiet_radius(L) && !st.logo_plate) return "a logo larger than the empty centre needs a plate"; |
| 1367 | load = logo_load(L, static_cast<int>(sym.ecc_level), radius); |
| 1368 | if (load > 0.5) { std::snprintf(why, sizeof(why), "logo hides %.0f%% of a block's correction (max 50%%); raise the level or shrink it", 100 * load); return why; } |
| 1369 | } |
| 1370 | return {}; |
| 1371 | } |
| 1372 | |
| 1373 | // Policy-aware check. STRICT and ADAPTIVE styles must pass the safety rules |
| 1374 | // (renderers adapt an ADAPTIVE style first, see effective_style); OVERRIDE |
| 1375 | // skips them: the caller has explicitly accepted an uncertified symbol. |
| 1376 | std::string check_style(const ScalableLayout &L, const ckey_scalable_symbol &sym, const ckey_style &st, double &min_contrast, double &load) { |
| 1377 | min_contrast = 0; load = 0; |
| 1378 | std::string problem = structural_problem(L, st); |
| 1379 | if (!problem.empty()) return problem; |
| 1380 | problem = safety_problem(L, sym, st, {}, min_contrast, load); |
| 1381 | return st.policy == CKEY_POLICY_OVERRIDE ? std::string() : problem; |
| 1382 | } |
| 1383 | |
| 1384 | // ---- Adaptive policy: rule-based repairs, each reported in a bitmask ---- |
| 1385 | |
| 1386 | ckey_rgb best_ink_for(const std::vector<ckey_rgb> &backgrounds) { |
| 1387 | double black = 1e9, white = 1e9; |
| 1388 | for (const auto &bg : backgrounds) { black = std::min(black, contrast_ratio({0, 0, 0}, bg)); white = std::min(white, contrast_ratio({255, 255, 255}, bg)); } |
| 1389 | return black >= white ? ckey_rgb{0, 0, 0} : ckey_rgb{255, 255, 255}; |
| 1390 | } |
| 1391 | |
| 1392 | uint32_t adapt_style(const ScalableLayout &L, const ckey_scalable_symbol &sym, ckey_style &st, const std::vector<ckey_rgb> &extra_backgrounds) { |
| 1393 | uint32_t changed = 0; |
| 1394 | std::vector<ckey_rgb> backgrounds = {st.background, st.background_edge}; |
| 1395 | backgrounds.insert(backgrounds.end(), extra_backgrounds.begin(), extra_backgrounds.end()); |
| 1396 | const auto ink_ok = [&](ckey_rgb ink, int &side) { |
| 1397 | for (const auto &bg : backgrounds) { |
| 1398 | if (contrast_ratio(ink, bg) < 3.0) return false; |
| 1399 | const int s = relative_luminance(ink) > relative_luminance(bg) ? 1 : -1; |
| 1400 | if (side && s != side) return false; |
| 1401 | side = s; |
| 1402 | } |
| 1403 | return true; |
| 1404 | }; |
| 1405 | int side = 0; |
| 1406 | if (!ink_ok(st.ink, side) || !ink_ok(st.ink_shade, side)) { |
| 1407 | st.ink = st.ink_shade = best_ink_for(backgrounds); |
| 1408 | changed |= CKEY_ADJUST_INK; side = 0; |
| 1409 | if (!ink_ok(st.ink, side)) { // no ink can work on these backgrounds: give the symbol its own ground |
| 1410 | st.background = st.background_edge = st.ink.r ? ckey_rgb{0, 0, 0} : ckey_rgb{255, 255, 255}; |
| 1411 | backgrounds = {st.background}; |
| 1412 | changed |= CKEY_ADJUST_BACKGROUND; side = 0; ink_ok(st.ink, side); |
| 1413 | } |
| 1414 | } |
| 1415 | if (st.accent_percent > 0 && st.accent_count > 0) { |
| 1416 | int s = side; bool ok = true; |
| 1417 | for (uint32_t i = 0; i < st.accent_count; ++i) { int t = s; ok = ok && ink_ok(st.accents[i], t); } |
| 1418 | if (!ok) { st.accent_percent = 0; st.accent_count = 0; changed |= CKEY_ADJUST_ACCENTS; } |
| 1419 | } |
| 1420 | if (side < 0 && (st.shadow > 0.4 || st.bevel > 0.5)) { st.shadow = std::min(st.shadow, 0.4); st.bevel = std::min(st.bevel, 0.5); changed |= CKEY_ADJUST_EFFECTS; } |
| 1421 | if (st.logo_href && *st.logo_href) { |
| 1422 | double radius = st.logo_radius > 0 ? st.logo_radius : logo_quiet_radius(L); |
| 1423 | if (radius > logo_quiet_radius(L) && !st.logo_plate) { st.logo_plate = 1; changed |= CKEY_ADJUST_LOGO; } |
| 1424 | const double original = radius; |
| 1425 | radius = std::min(radius, logo_max_radius(L)); |
| 1426 | while (radius > logo_quiet_radius(L) && logo_load(L, static_cast<int>(sym.ecc_level), radius) > 0.5) radius -= 0.05; |
| 1427 | radius = std::max(radius, logo_quiet_radius(L)); |
| 1428 | if (radius < original - 1e-9) { st.logo_radius = radius; changed |= CKEY_ADJUST_LOGO; } |
| 1429 | } |
| 1430 | return changed; |
| 1431 | } |
| 1432 | |
| 1433 | // The style a renderer draws: an ADAPTIVE style after its rule-based repairs. |
| 1434 | ckey_style effective_style(const ScalableLayout &L, const ckey_scalable_symbol &sym, const ckey_style &st) { |
| 1435 | ckey_style out = st; |
| 1436 | double contrast = 0, load = 0; |
| 1437 | if (st.policy == CKEY_POLICY_ADAPTIVE && structural_problem(L, st).empty() && !safety_problem(L, sym, st, {}, contrast, load).empty()) |
| 1438 | adapt_style(L, sym, out, {}); |
| 1439 | return out; |
| 1440 | } |
| 1441 | |
| 1442 | void emit_module(std::ostringstream &o, double cx, double cy, double p, ckey_module_shape shape, const std::string &colour) { |
| 1443 | const std::string fill = colour.empty() ? "" : " fill=\"" + colour + "\""; |
| 1444 | const double h = 0.34 * p; |
| 1445 | switch (shape) { |
| 1446 | case CKEY_MODULE_CIRCLE: o << "<circle cx=\"" << cx << "\" cy=\"" << cy << "\" r=\"" << h << "\" stroke=\"none\"" << fill << "/>"; break; |
| 1447 | case CKEY_MODULE_DIAMOND: { |
| 1448 | const double d = 0.40 * p; // inscribed radius 0.28p covers the sample support |
| 1449 | o << "<polygon points=\"" << cx << ',' << cy - d << ' ' << cx + d << ',' << cy << ' ' << cx << ',' << cy + d << ' ' << cx - d << ',' << cy << "\" stroke=\"none\"" << fill << "/>"; break; |
| 1450 | } |
| 1451 | default: |
| 1452 | o << "<rect x=\"" << cx - h << "\" y=\"" << cy - h << "\" width=\"" << 2 * h << "\" height=\"" << 2 * h << "\" rx=\"" |
| 1453 | << (shape == CKEY_MODULE_SQUARE ? 0.0 : 0.16 * p) << "\" stroke=\"none\"" << fill << "/>"; |
| 1454 | } |
| 1455 | } |
| 1456 | |
| 1457 | // Deterministic accent choice per site (decorative; luminance is checked). |
| 1458 | int accent_of(uint32_t id, const ckey_style &st) { |
| 1459 | if (!st.accent_count || !st.accent_percent) return -1; |
| 1460 | const uint32_t h = site_hash(id ^ 0x41434345u); // "ACCE" |
| 1461 | return (h % 100) < st.accent_percent ? static_cast<int>((h / 100) % st.accent_count) : -1; |
| 1462 | } |
| 1463 | |
| 1464 | } // namespace |
| 1465 | |
| 1466 | |
| 1467 | |
| 1468 | |
| 1469 | // --------------------------------------------------------------------------- |
| 1470 | // Full-frame localization (moved from the iPhone reader so it is shared and |
| 1471 | // testable). Border candidates are connected components of the continuous |
| 1472 | // frame stroke; each is fitted with a perspective quadrilateral, then refined |
| 1473 | // against a profile's fixed keys. Neither step can accept a payload. |
| 1474 | // --------------------------------------------------------------------------- |
| 1475 | namespace { |
| 1476 | |
| 1477 | struct Point2 { double x, y; }; |
| 1478 | |
| 1479 | uint8_t frame_threshold(const uint8_t *gray, size_t width, size_t height, size_t stride, bool dark_marks) { |
| 1480 | std::array<size_t, 256> histogram{}; size_t samples = 0; |
| 1481 | for (size_t y = 0; y < height; y += 8) for (size_t x = 0; x < width; x += 8) { ++histogram[gray[y * stride + x]]; ++samples; } |
| 1482 | if (!samples) return dark_marks ? 92 : 164; |
| 1483 | const auto percentile = [&](double fraction) { |
| 1484 | const size_t target = static_cast<size_t>(static_cast<double>(samples - 1) * fraction); size_t total = 0; |
| 1485 | for (int v = 0; v < 256; ++v) { total += histogram[v]; if (total > target) return v; } |
| 1486 | return 255; |
| 1487 | }; |
| 1488 | const int low = percentile(0.10), high = percentile(0.90); |
| 1489 | if (high - low < 32) return dark_marks ? 92 : 164; |
| 1490 | const double contrast = high - low; |
| 1491 | const double value = dark_marks ? low + contrast * 0.32 : high - contrast * 0.32; |
| 1492 | return static_cast<uint8_t>(std::clamp(static_cast<int>(std::lround(value)), 0, 255)); |
| 1493 | } |
| 1494 | |
| 1495 | double cross(const Point2 &o, const Point2 &a, const Point2 &b) { return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); } |
| 1496 | |
| 1497 | std::vector<Point2> convex_hull(std::vector<Point2> p) { |
| 1498 | std::sort(p.begin(), p.end(), [](const Point2 &a, const Point2 &b) { return a.x < b.x || (a.x == b.x && a.y < b.y); }); |
| 1499 | p.erase(std::unique(p.begin(), p.end(), [](const Point2 &a, const Point2 &b) { return a.x == b.x && a.y == b.y; }), p.end()); |
| 1500 | if (p.size() < 3) return p; |
| 1501 | std::vector<Point2> h(2 * p.size()); size_t k = 0; |
| 1502 | for (size_t i = 0; i < p.size(); ++i) { while (k >= 2 && cross(h[k - 2], h[k - 1], p[i]) <= 0) --k; h[k++] = p[i]; } |
| 1503 | for (size_t i = p.size() - 1, t = k + 1; i-- > 0;) { while (k >= t && cross(h[k - 2], h[k - 1], p[i]) <= 0) --k; h[k++] = p[i]; } |
| 1504 | h.resize(k - 1); |
| 1505 | return h; |
| 1506 | } |
| 1507 | |
| 1508 | // Fits the outer edge of a square border component. Second moments cannot |
| 1509 | // orient a square (its inertia is isotropic, so the old principal-axis fit |
| 1510 | // returned an essentially random angle, often 45 degrees off). The minimum- |
| 1511 | // area enclosing rectangle of the convex hull gives the orientation; each |
| 1512 | // side is then re-fitted as a line through the stroke's outer edge, so a |
| 1513 | // tilted (keystoned) display yields a true perspective quadrilateral. |
| 1514 | bool fit_border_quad(const std::vector<Point2> &points, double step, std::array<double, 8> &quad) { |
| 1515 | const auto hull = convex_hull(points); |
| 1516 | if (hull.size() < 4) return false; |
| 1517 | double best_area = std::numeric_limits<double>::infinity(), ux = 1, uy = 0; |
| 1518 | for (size_t i = 0; i < hull.size(); ++i) { |
| 1519 | const Point2 &a = hull[i], &b = hull[(i + 1) % hull.size()]; |
| 1520 | double ex = b.x - a.x, ey = b.y - a.y; const double len = std::hypot(ex, ey); |
| 1521 | if (len < 1e-9) continue; |
| 1522 | ex /= len; ey /= len; |
| 1523 | double min_u = 1e300, max_u = -1e300, min_v = 1e300, max_v = -1e300; |
| 1524 | for (const auto &p : hull) { |
| 1525 | const double u = p.x * ex + p.y * ey, v = -p.x * ey + p.y * ex; |
| 1526 | min_u = std::min(min_u, u); max_u = std::max(max_u, u); min_v = std::min(min_v, v); max_v = std::max(max_v, v); |
| 1527 | } |
| 1528 | const double area = (max_u - min_u) * (max_v - min_v); |
| 1529 | if (area < best_area) { best_area = area; ux = ex; uy = ey; } |
| 1530 | } |
| 1531 | const double vx = -uy, vy = ux; |
| 1532 | double cx = 0, cy = 0; for (const auto &p : points) { cx += p.x; cy += p.y; } cx /= points.size(); cy /= points.size(); |
| 1533 | double min_u = 1e300, max_u = -1e300, min_v = 1e300, max_v = -1e300; |
| 1534 | for (const auto &p : points) { |
| 1535 | const double u = (p.x - cx) * ux + (p.y - cy) * uy, v = (p.x - cx) * vx + (p.y - cy) * vy; |
| 1536 | min_u = std::min(min_u, u); max_u = std::max(max_u, u); min_v = std::min(min_v, v); max_v = std::max(max_v, v); |
| 1537 | } |
| 1538 | const double half_u = (max_u - min_u) / 2, half_v = (max_v - min_v) / 2, mid_u = (max_u + min_u) / 2, mid_v = (max_v + min_v) / 2; |
| 1539 | // Side lines: normal n (outward), offset d, so that n . p = d on the edge. |
| 1540 | struct Line { double nx, ny, d; }; |
| 1541 | std::array<Line, 4> lines{}; |
| 1542 | const std::array<std::array<double, 2>, 4> normals = {{{-vx, -vy}, {ux, uy}, {vx, vy}, {-ux, -uy}}}; // top, right, bottom, left |
| 1543 | for (int s = 0; s < 4; ++s) { |
| 1544 | const double nx = normals[s][0], ny = normals[s][1]; |
| 1545 | const bool along_u = s == 0 || s == 2; |
| 1546 | const double half_along = along_u ? half_u : half_v, half_across = along_u ? half_v : half_u; |
| 1547 | const double band = std::max(3.0 * step, 0.10 * half_across); |
| 1548 | std::vector<Point2> side; |
| 1549 | for (const auto &p : points) { |
| 1550 | const double u = (p.x - cx) * ux + (p.y - cy) * uy - mid_u, v = (p.x - cx) * vx + (p.y - cy) * vy - mid_v; |
| 1551 | const double across = along_u ? (s == 0 ? -v : v) : (s == 1 ? u : -u); |
| 1552 | const double along = along_u ? u : v; |
| 1553 | // Skip the rounded corners: only the straight middle of each side. |
| 1554 | if (across > half_across - band && std::abs(along) < 0.62 * half_along) side.push_back(p); |
| 1555 | } |
| 1556 | Line line{nx, ny, 0}; |
| 1557 | if (side.size() >= 8) { |
| 1558 | double mx = 0, my = 0; for (const auto &p : side) { mx += p.x; my += p.y; } mx /= side.size(); my /= side.size(); |
| 1559 | double sxx = 0, syy = 0, sxy = 0; |
| 1560 | for (const auto &p : side) { sxx += (p.x - mx) * (p.x - mx); syy += (p.y - my) * (p.y - my); sxy += (p.x - mx) * (p.y - my); } |
| 1561 | const double theta = 0.5 * std::atan2(2 * sxy, sxx - syy); // direction of the side |
| 1562 | double lx = -std::sin(theta), ly = std::cos(theta); // its normal |
| 1563 | if (lx * nx + ly * ny < 0) { lx = -lx; ly = -ly; } |
| 1564 | if (lx * nx + ly * ny > 0.94) { line.nx = lx; line.ny = ly; } // accept tilt up to ~20 degrees |
| 1565 | } |
| 1566 | // Outer edge: a high percentile of the outward coordinate of the side's |
| 1567 | // points (the whole stroke lies in the band), plus half a sample. |
| 1568 | std::vector<double> outward; |
| 1569 | for (const auto &p : side.size() >= 8 ? side : points) outward.push_back(p.x * line.nx + p.y * line.ny); |
| 1570 | std::sort(outward.begin(), outward.end()); |
| 1571 | line.d = outward[static_cast<size_t>(0.95 * (outward.size() - 1))] + 0.5 * step; |
| 1572 | lines[s] = line; |
| 1573 | } |
| 1574 | for (int c = 0; c < 4; ++c) { // corners: top-left, top-right, bottom-right, bottom-left |
| 1575 | const Line &a = lines[c == 0 ? 3 : c - 1], &b = lines[c]; |
| 1576 | const double det = a.nx * b.ny - a.ny * b.nx; |
| 1577 | if (std::abs(det) < 1e-9) return false; |
| 1578 | quad[2 * c] = (a.d * b.ny - a.ny * b.d) / det; |
| 1579 | quad[2 * c + 1] = (a.nx * b.d - a.d * b.nx) / det; |
| 1580 | } |
| 1581 | return std::all_of(quad.begin(), quad.end(), [](double v) { return std::isfinite(v); }); |
| 1582 | } |
| 1583 | |
| 1584 | // Candidate ordering only (generated by tools/train_locator_ranker.py). |
| 1585 | double border_rank(double aspect_error, double fill_ratio, double relative_side) { |
| 1586 | const double z = 0.81059192 - 10.65985208 * aspect_error + 0.18755525 * fill_ratio - 0.55068352 * std::abs(relative_side - 0.32); |
| 1587 | return 1.0 / (1.0 + std::exp(-z)); |
| 1588 | } |
| 1589 | |
| 1590 | // The outer ink edge of the frame stroke sits at canonical 0.175..28.825 in |
| 1591 | // Profiles 1/2 and 0.7..115.3 in Profile 3: both 1.0122x inside the field. |
| 1592 | constexpr double kBorderToField = 29.0 / 28.65; |
| 1593 | |
| 1594 | |
| 1595 | } // namespace |