Skip to content

getPenaltyN4 is asymmetric around 50 %, so a non-conformant mask pattern is selected #412

Description

@ZampiSoftLLC

Summary

getPenaltyN4 penalises a symbol with 52 % dark modules but not one with 48 %, even though both deviate from 50 % by the same amount. Penalty rule 4 in ISO/IEC 18004 scores the deviation of the dark-module proportion from 50 %, so the score has to be symmetric. Because rule 4 feeds into getBestMask, this changes which mask pattern is selected — for inputs as short as 'aa'.

The symbols produced are still valid and readable; the mask chosen is simply not the one the spec prescribes.

Expected

ISO/IEC 18004:2015, §7.8.3.1 Table 11, feature 4: the points are N4 × k, where k rates the deviation of the proportion of dark modules from 50 % in steps of 5 %, with N4 = 10. NOTE 4 of the same table anchors it with an example: 0 points when the proportion is between 45 % and 55 %, 10 points when it is between 40 % and 60 %.

So k counts complete 5 % steps of deviation, in either direction.

Actual

https://github.com/soldair/node-qrcode/blob/master/lib/core/mask-pattern.js

const k = Math.abs(Math.ceil((darkCount * 100 / modulesCount) / 5) - 10)

For a proportion p, this evaluates to floor((50 - p) / 5) below 50 % but to ceil((p - 50) / 5) above it — it rounds down on one side and up on the other. Every proportion in the 50 %–55 % band is charged a step it should not be charged, and each band above that is charged one step too many.

Reproduction

Self-contained, qrcode@1.5.4 only:

const QRCode = require('qrcode')
const MaskPattern = require('qrcode/lib/core/mask-pattern')
const BitMatrix = require('qrcode/lib/core/bit-matrix')

const spec = (dark, total) => Math.floor(Math.abs(dark * 100 / total - 50) / 5) * 10

function m (size, dark) {
  const bm = new BitMatrix(size)
  bm.data = new Array(dark).fill(1).concat(new Array(size * size - dark).fill(0))
  return bm
}

// 1. not symmetric around 50 %
for (const dark of [176, 184, 192, 200, 208, 216, 224]) {
  console.log(`${dark * 100 / 400}%\t lib ${MaskPattern.getPenaltyN4(m(20, dark))}\t spec ${spec(dark, 400)}`)
}

// 2. it changes the selected mask
const before = QRCode.create('aa', { errorCorrectionLevel: 'M' }).maskPattern
MaskPattern.getPenaltyN4 = (data) => {
  let dark = 0
  for (let i = 0; i < data.data.length; i++) dark += data.data[i]
  return spec(dark, data.data.length)
}
const after = QRCode.create('aa', { errorCorrectionLevel: 'M' }).maskPattern
console.log(`maskPattern: ${before} -> ${after} with the spec formula`)

Output:

44%	 lib 10	 spec 10
46%	 lib 0	 spec 0
48%	 lib 0	 spec 0
50%	 lib 0	 spec 0
52%	 lib 10	 spec 0
54%	 lib 10	 spec 0
56%	 lib 20	 spec 10
maskPattern: 3 -> 0 with the spec formula

48 % and 52 % are the same distance from 50 %, and only one of them is charged.

Why the test suite doesn't catch it

The three fixtures in test/unit/core/mask-pattern.test.js for Penalty N4 are at 50.00 %, 43.08 % and 22.00 % — all at or below 50 %, which is exactly the half where the formula happens to be right. All three keep passing with either of the corrected formulas below, so the fix needs a new fixture above 50 % (e.g. a 20×20 matrix with 208 dark modules, expected 0).

Suggested fix

const k = Math.floor(Math.abs(darkCount * 100 / modulesCount - 50) / 5)

Math.max(0, Math.ceil(Math.abs(darkCount * 100 / modulesCount - 50) / 5) - 1) works equally well; the two differ only when the deviation is exactly a multiple of 5 %, and Table 11 states its ranges with touching endpoints, so it does not settle that case. Either form removes the asymmetry, and both keep the three existing fixtures green.

Impact

Comparing against an encoder that follows Table 11 across 479 (text length × EC level) combinations: 464 symbols come out identical module for module, 15 pick a different mask. 12 of those 15 are caused by this rule alone.

The history is that PR #69 (2017) replaced an earlier continuous-ratio formula, which was also wrong; the replacement fixed the magnitude but introduced the asymmetry.


Secondary, and more of a reading than a bug: getPenaltyN3 counts one pattern twice

getPenaltyN3 matches the 11-module windows 0x05D and 0x5D0 independently, so a 1:1:3:1:1 core that has a 4-module light area on both sides scores 80 rather than 40.

Table 11 gives the feature as the 1:1:3:1:1 pattern preceded or followed by a 4-module light area, with the evaluation condition "existence of the pattern" and the points N3; NOTE 3 states the imposed penalty for such a pattern as 40 points, in the singular. On that reading the light area is a qualifier on the feature, not part of its identity, so a pattern with light on both sides is still one occurrence.

Both Penalty N3 fixtures in the test suite return the same score under either reading (160 and 280), so they don't distinguish the two — neither matrix happens to contain a core with a light area on both sides.

Filing it here rather than separately since it lives in the same function group, but it is clearly the weaker of the two claims and worth splitting off if you disagree with the reading. In the same 479-combination comparison it accounts for the other 3 of the 15 mask differences.

Environment

  • qrcode@1.5.4
  • Node.js v24.14.0, macOS
  • Spec consulted: ISO/IEC 18004:2015 (third edition), §7.8.3.1 and Table 11 with its notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions