diff --git a/composer.json b/composer.json index 536bdf56f..311442500 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,10 @@ "autoload": { "psr-4": { "chillerlan\\QRCode\\": "src/" - } + }, + "files": [ + "src/includes.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/src/Common/FormatInformation.php b/src/Common/FormatInformation.php new file mode 100644 index 000000000..4f409fc8f --- /dev/null +++ b/src/Common/FormatInformation.php @@ -0,0 +1,82 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Common; + +/** + *
Encapsulates a QR Code's format information, including the data mask used and + * error correction level.
+ * + * @author Sean Owen + * @see \chillerlan\QRCode\Common\ErrorCorrectionLevel + */ +final class FormatInformation{ + + public const MASK_QR = 0x5412; + + /** + * See ISO 18004:2006, Annex C, Table C.1 + * + * [data bits, sequence after masking] + */ + public const DECODE_LOOKUP = [ + [0x00, 0x5412], + [0x01, 0x5125], + [0x02, 0x5E7C], + [0x03, 0x5B4B], + [0x04, 0x45F9], + [0x05, 0x40CE], + [0x06, 0x4F97], + [0x07, 0x4AA0], + [0x08, 0x77C4], + [0x09, 0x72F3], + [0x0A, 0x7DAA], + [0x0B, 0x789D], + [0x0C, 0x662F], + [0x0D, 0x6318], + [0x0E, 0x6C41], + [0x0F, 0x6976], + [0x10, 0x1689], + [0x11, 0x13BE], + [0x12, 0x1CE7], + [0x13, 0x19D0], + [0x14, 0x0762], + [0x15, 0x0255], + [0x16, 0x0D0C], + [0x17, 0x083B], + [0x18, 0x355F], + [0x19, 0x3068], + [0x1A, 0x3F31], + [0x1B, 0x3A06], + [0x1C, 0x24B4], + [0x1D, 0x2183], + [0x1E, 0x2EDA], + [0x1F, 0x2BED], + ]; + + private int $errorCorrectionLevel; + private int $dataMask; + + public function __construct(int $formatInfo){ + $this->errorCorrectionLevel = ($formatInfo >> 3) & 0x03; // Bits 3,4 + $this->dataMask = ($formatInfo & 0x07); // Bottom 3 bits + } + + public function getErrorCorrectionLevel():EccLevel{ + return new EccLevel($this->errorCorrectionLevel); + } + + public function getDataMask():MaskPattern{ + return new MaskPattern($this->dataMask); + } + +} + diff --git a/src/Common/ReedSolomonDecoder.php b/src/Common/ReedSolomonDecoder.php new file mode 100644 index 000000000..943973860 --- /dev/null +++ b/src/Common/ReedSolomonDecoder.php @@ -0,0 +1,192 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Common; + +use RuntimeException; +use function array_fill, count; + +/** + *Implements Reed-Solomon decoding, as the name implies.
+ * + *The algorithm will not be explained here, but the following references were helpful + * in creating this implementation:
+ * + *Much credit is due to William Rucklidge since portions of this code are an indirect + * port of his C++ Reed-Solomon implementation.
+ * + * @author Sean Owen + * @author William Rucklidge + * @author sanfordsquires + */ +final class ReedSolomonDecoder{ + + /** + *Decodes given set of received codewords, which include both data and error-correction + * codewords. Really, this means it uses Reed-Solomon to detect and correct errors, in-place, + * in the input.
+ * + * @param array $received data and error-correction codewords + * @param int $numEccCodewords number of error-correction codewords available + * + * @return int[] + * @throws \RuntimeException if decoding fails for any reason + */ + public function decode(array $received, int $numEccCodewords):array{ + $poly = new GenericGFPoly($received); + $syndromeCoefficients = []; + $noError = true; + + for($i = 0, $j = $numEccCodewords - 1; $i < $numEccCodewords; $i++, $j--){ + $eval = $poly->evaluateAt(GF256::exp($i)); + $syndromeCoefficients[$j] = $eval; + + if($eval !== 0){ + $noError = false; + } + } + + if($noError){ + return $received; + } + + [$sigma, $omega] = $this->runEuclideanAlgorithm( + GF256::buildMonomial($numEccCodewords, 1), + new GenericGFPoly($syndromeCoefficients), + $numEccCodewords + ); + + $errorLocations = $this->findErrorLocations($sigma); + $errorMagnitudes = $this->findErrorMagnitudes($omega, $errorLocations); + $errorLocationsCount = count($errorLocations); + $receivedCount = count($received); + + for($i = 0; $i < $errorLocationsCount; $i++){ + $position = $receivedCount - 1 - GF256::log($errorLocations[$i]); + + if($position < 0){ + throw new RuntimeException('Bad error location'); + } + + $received[$position] ^= $errorMagnitudes[$i]; + } + + return $received; + } + + /** + * @return \chillerlan\QRCode\Common\GenericGFPoly[] [sigma, omega] + * @throws \RuntimeException + */ + private function runEuclideanAlgorithm(GenericGFPoly $a, GenericGFPoly $b, int $R):array{ + // Assume a's degree is >= b's + if($a->getDegree() < $b->getDegree()){ + $temp = $a; + $a = $b; + $b = $temp; + } + + $rLast = $a; + $r = $b; + $tLast = new GenericGFPoly([0]); + $t = new GenericGFPoly([1]); + + // Run Euclidean algorithm until r's degree is less than R/2 + while($r->getDegree() >= $R / 2){ + $rLastLast = $rLast; + $tLastLast = $tLast; + $rLast = $r; + $tLast = $t; + + // Divide rLastLast by rLast, with quotient in q and remainder in r + [$q, $r] = $rLastLast->divide($rLast); + + $t = $q->multiply($tLast)->addOrSubtract($tLastLast); + + if($r->getDegree() >= $rLast->getDegree()){ + throw new RuntimeException('Division algorithm failed to reduce polynomial?'); + } + } + + $sigmaTildeAtZero = $t->getCoefficient(0); + + if($sigmaTildeAtZero === 0){ + throw new RuntimeException('sigmaTilde(0) was zero'); + } + + $inverse = GF256::inverse($sigmaTildeAtZero); + + return [$t->multiplyInt($inverse), $r->multiplyInt($inverse)]; + } + + /** + * @throws \RuntimeException + */ + private function findErrorLocations(GenericGFPoly $errorLocator):array{ + // This is a direct application of Chien's search + $numErrors = $errorLocator->getDegree(); + + if($numErrors === 1){ // shortcut + return [$errorLocator->getCoefficient(1)]; + } + + $result = array_fill(0, $numErrors, 0); + $e = 0; + + for($i = 1; $i < 256 && $e < $numErrors; $i++){ + if($errorLocator->evaluateAt($i) === 0){ + $result[$e] = GF256::inverse($i); + $e++; + } + } + + if($e !== $numErrors){ + throw new RuntimeException('Error locator degree does not match number of roots'); + } + + return $result; + } + + private function findErrorMagnitudes(GenericGFPoly $errorEvaluator, array $errorLocations):array{ + // This is directly applying Forney's Formula + $s = count($errorLocations); + $result = []; + + for($i = 0; $i < $s; $i++){ + $xiInverse = GF256::inverse($errorLocations[$i]); + $denominator = 1; + + for($j = 0; $j < $s; $j++){ + if($i !== $j){ +# $denominator = GF256::multiply($denominator, GF256::addOrSubtract(1, GF256::multiply($errorLocations[$j], $xiInverse))); + // Above should work but fails on some Apple and Linux JDKs due to a Hotspot bug. + // Below is a funny-looking workaround from Steven Parkes + $term = GF256::multiply($errorLocations[$j], $xiInverse); + $denominator = GF256::multiply($denominator, (($term & 0x1) === 0 ? $term | 1 : $term & ~1)); + } + } + + $result[$i] = GF256::multiply($errorEvaluator->evaluateAt($xiInverse), GF256::inverse($denominator)); + } + + return $result; + } + +} diff --git a/src/Common/Version.php b/src/Common/Version.php index 3b613587e..1c0ba45d4 100644 --- a/src/Common/Version.php +++ b/src/Common/Version.php @@ -306,7 +306,7 @@ final class Version{ * the maximum character count for the given $mode and $eccLevel */ public function getMaxLengthForMode(int $mode, EccLevel $eccLevel):?int{ - return self::MAX_LENGTH[$this->version][$mode][$eccLevel->getOrdinal()] ?? null; + return self::MAX_LENGTH[$this->version][Mode::DATA_MODES[$mode]][$eccLevel->getOrdinal()] ?? null; } /** diff --git a/src/Common/functions.php b/src/Common/functions.php new file mode 100644 index 000000000..ebfa03094 --- /dev/null +++ b/src/Common/functions.php @@ -0,0 +1,58 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Common; + +use function array_slice, array_splice, sqrt; +use const PHP_INT_SIZE; + +const QRCODE_DECODER_INCLUDES = true; + +function arraycopy(array $srcArray, int $srcPos, array $destArray, int $destPos, int $length):array{ + array_splice($destArray, $destPos, $length, array_slice($srcArray, $srcPos, $length)); + + return $destArray; +} + +function uRShift(int $a, int $b):int{ + + if($b === 0){ + return $a; + } + + return ($a >> $b) & ~((1 << (8 * PHP_INT_SIZE - 1)) >> ($b - 1)); +} + +function numBitsDiffering(int $a, int $b):int{ + // a now has a 1 bit exactly where its bit differs with b's + $a ^= $b; + // Offset i holds the number of 1 bits in the binary representation of i + $BITS_SET_IN_HALF_BYTE = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4]; + // Count bits set quickly with a series of lookups: + $count = 0; + + for($i = 0; $i < 32; $i += 4){ + $count += $BITS_SET_IN_HALF_BYTE[uRShift($a, $i) & 0x0F]; + } + + return $count; +} + +function squaredDistance(float $aX, float $aY, float $bX, float $bY):float{ + $xDiff = $aX - $bX; + $yDiff = $aY - $bY; + + return $xDiff * $xDiff + $yDiff * $yDiff; +} + +function distance(float $aX, float $aY, float $bX, float $bY):float{ + return sqrt(squaredDistance($aX, $aY, $bX, $bY)); +} + diff --git a/src/Decoder/Binarizer.php b/src/Decoder/Binarizer.php new file mode 100644 index 000000000..d3c7fdaf3 --- /dev/null +++ b/src/Decoder/Binarizer.php @@ -0,0 +1,361 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Decoder; + +use RuntimeException; +use function array_fill, count, max; + +/** + * This class implements a local thresholding algorithm, which while slower than the + * GlobalHistogramBinarizer, is fairly efficient for what it does. It is designed for + * high frequency images of barcodes with black data on white backgrounds. For this application, + * it does a much better job than a global blackpoint with severe shadows and gradients. + * However it tends to produce artifacts on lower frequency images and is therefore not + * a good general purpose binarizer for uses outside ZXing. + * + * This class extends GlobalHistogramBinarizer, using the older histogram approach for 1D readers, + * and the newer local approach for 2D readers. 1D decoding using a per-row histogram is already + * inherently local, and only fails for horizontal gradients. We can revisit that problem later, + * but for now it was not a win to use local blocks for 1D. + * + * This Binarizer is the default for the unit tests and the recommended class for library users. + * + * @author dswitkin@google.com (Daniel Switkin) + */ +final class Binarizer{ + + // This class uses 5x5 blocks to compute local luminance, where each block is 8x8 pixels. + // So this is the smallest dimension in each axis we can accept. + private const BLOCK_SIZE_POWER = 3; + private const BLOCK_SIZE = 8; // ...0100...00 + private const BLOCK_SIZE_MASK = 7; // ...0011...11 + private const MINIMUM_DIMENSION = 40; + private const MIN_DYNAMIC_RANGE = 24; + +# private const LUMINANCE_BITS = 5; + private const LUMINANCE_SHIFT = 3; + private const LUMINANCE_BUCKETS = 32; + + private LuminanceSource $source; + + public function __construct(LuminanceSource $source){ + $this->source = $source; + } + + /** + * @throws \RuntimeException + */ + private function estimateBlackPoint(array $buckets):int{ + // Find the tallest peak in the histogram. + $numBuckets = count($buckets); + $maxBucketCount = 0; + $firstPeak = 0; + $firstPeakSize = 0; + + for($x = 0; $x < $numBuckets; $x++){ + + if($buckets[$x] > $firstPeakSize){ + $firstPeak = $x; + $firstPeakSize = $buckets[$x]; + } + + if($buckets[$x] > $maxBucketCount){ + $maxBucketCount = $buckets[$x]; + } + } + + // Find the second-tallest peak which is somewhat far from the tallest peak. + $secondPeak = 0; + $secondPeakScore = 0; + + for($x = 0; $x < $numBuckets; $x++){ + $distanceToBiggest = $x - $firstPeak; + // Encourage more distant second peaks by multiplying by square of distance. + $score = $buckets[$x] * $distanceToBiggest * $distanceToBiggest; + + if($score > $secondPeakScore){ + $secondPeak = $x; + $secondPeakScore = $score; + } + } + + // Make sure firstPeak corresponds to the black peak. + if($firstPeak > $secondPeak){ + $temp = $firstPeak; + $firstPeak = $secondPeak; + $secondPeak = $temp; + } + + // If there is too little contrast in the image to pick a meaningful black point, throw rather + // than waste time trying to decode the image, and risk false positives. + if($secondPeak - $firstPeak <= $numBuckets / 16){ + throw new RuntimeException('no meaningful dark point found'); + } + + // Find a valley between them that is low and closer to the white peak. + $bestValley = $secondPeak - 1; + $bestValleyScore = -1; + + for($x = $secondPeak - 1; $x > $firstPeak; $x--){ + $fromFirst = $x - $firstPeak; + $score = $fromFirst * $fromFirst * ($secondPeak - $x) * ($maxBucketCount - $buckets[$x]); + + if($score > $bestValleyScore){ + $bestValley = $x; + $bestValleyScore = $score; + } + } + + return $bestValley << self::LUMINANCE_SHIFT; + } + + /** + * Calculates the final BitMatrix once for all requests. This could be called once from the + * constructor instead, but there are some advantages to doing it lazily, such as making + * profiling easier, and not doing heavy lifting when callers don't expect it. + * + * Converts a 2D array of luminance data to 1 bit data. As above, assume this method is expensive + * and do not call it repeatedly. This method is intended for decoding 2D barcodes and may or + * may not apply sharpening. Therefore, a row from this matrix may not be identical to one + * fetched using getBlackRow(), so don't mix and match between them. + * + * @return \chillerlan\QRCode\Decoder\BitMatrix The 2D array of bits for the image (true means black). + */ + public function getBlackMatrix():BitMatrix{ + $width = $this->source->getWidth(); + $height = $this->source->getHeight(); + + if($width >= self::MINIMUM_DIMENSION && $height >= self::MINIMUM_DIMENSION){ + $subWidth = $width >> self::BLOCK_SIZE_POWER; + + if(($width & self::BLOCK_SIZE_MASK) !== 0){ + $subWidth++; + } + + $subHeight = $height >> self::BLOCK_SIZE_POWER; + + if(($height & self::BLOCK_SIZE_MASK) !== 0){ + $subHeight++; + } + + return $this->calculateThresholdForBlock($subWidth, $subHeight, $width, $height); + } + + // If the image is too small, fall back to the global histogram approach. + return $this->getHistogramBlackMatrix($width, $height); + } + + public function getHistogramBlackMatrix(int $width, int $height):BitMatrix{ + $matrix = new BitMatrix(max($width, $height)); + + // Quickly calculates the histogram by sampling four rows from the image. This proved to be + // more robust on the blackbox tests than sampling a diagonal as we used to do. + $buckets = array_fill(0, self::LUMINANCE_BUCKETS, 0); + + for($y = 1; $y < 5; $y++){ + $row = (int)($height * $y / 5); + $localLuminances = $this->source->getRow($row); + $right = (int)(($width * 4) / 5); + + for($x = (int)($width / 5); $x < $right; $x++){ + $pixel = $localLuminances[(int)$x] & 0xff; + $buckets[$pixel >> self::LUMINANCE_SHIFT]++; + } + } + + $blackPoint = $this->estimateBlackPoint($buckets); + + // We delay reading the entire image luminance until the black point estimation succeeds. + // Although we end up reading four rows twice, it is consistent with our motto of + // "fail quickly" which is necessary for continuous scanning. + $localLuminances = $this->source->getMatrix(); + + for($y = 0; $y < $height; $y++){ + $offset = $y * $width; + + for($x = 0; $x < $width; $x++){ + $pixel = (int)($localLuminances[$offset + $x] & 0xff); + + if($pixel < $blackPoint){ + $matrix->set($x, $y); + } + } + } + + return $matrix; + } + + /** + * Calculates a single black point for each block of pixels and saves it away. + * See the following thread for a discussion of this algorithm: + * + * @see http://groups.google.com/group/zxing/browse_thread/thread/d06efa2c35a7ddc0 + */ + private function calculateBlackPoints(array $luminances, int $subWidth, int $subHeight, int $width, int $height):array{ + $blackPoints = array_fill(0, $subHeight, 0); + + foreach($blackPoints as $key => $point){ + $blackPoints[$key] = array_fill(0, $subWidth, 0); + } + + for($y = 0; $y < $subHeight; $y++){ + $yoffset = ($y << self::BLOCK_SIZE_POWER); + $maxYOffset = $height - self::BLOCK_SIZE; + + if($yoffset > $maxYOffset){ + $yoffset = $maxYOffset; + } + + for($x = 0; $x < $subWidth; $x++){ + $xoffset = ($x << self::BLOCK_SIZE_POWER); + $maxXOffset = $width - self::BLOCK_SIZE; + + if($xoffset > $maxXOffset){ + $xoffset = $maxXOffset; + } + + $sum = 0; + $min = 255; + $max = 0; + + for($yy = 0, $offset = $yoffset * $width + $xoffset; $yy < self::BLOCK_SIZE; $yy++, $offset += $width){ + + for($xx = 0; $xx < self::BLOCK_SIZE; $xx++){ + $pixel = (int)($luminances[(int)($offset + $xx)]) & 0xff; + $sum += $pixel; + // still looking for good contrast + if($pixel < $min){ + $min = $pixel; + } + + if($pixel > $max){ + $max = $pixel; + } + } + + // short-circuit min/max tests once dynamic range is met + if($max - $min > self::MIN_DYNAMIC_RANGE){ + // finish the rest of the rows quickly + for($yy++, $offset += $width; $yy < self::BLOCK_SIZE; $yy++, $offset += $width){ + for($xx = 0; $xx < self::BLOCK_SIZE; $xx++){ + $sum += $luminances[$offset + $xx] & 0xff; + } + } + } + } + + // The default estimate is the average of the values in the block. + $average = $sum >> (self::BLOCK_SIZE_POWER * 2); + + if($max - $min <= self::MIN_DYNAMIC_RANGE){ + // If variation within the block is low, assume this is a block with only light or only + // dark pixels. In that case we do not want to use the average, as it would divide this + // low contrast area into black and white pixels, essentially creating data out of noise. + // + // The default assumption is that the block is light/background. Since no estimate for + // the level of dark pixels exists locally, use half the min for the block. + $average = (int)($min / 2); + + if($y > 0 && $x > 0){ + // Correct the "white background" assumption for blocks that have neighbors by comparing + // the pixels in this block to the previously calculated black points. This is based on + // the fact that dark barcode symbology is always surrounded by some amount of light + // background for which reasonable black point estimates were made. The bp estimated at + // the boundaries is used for the interior. + + // The (min < bp) is arbitrary but works better than other heuristics that were tried. + $averageNeighborBlackPoint = (int)(($blackPoints[$y - 1][$x] + (2 * $blackPoints[$y][$x - 1]) + $blackPoints[$y - 1][$x - 1]) / 4); + + if($min < $averageNeighborBlackPoint){ + $average = $averageNeighborBlackPoint; + } + } + } + + $blackPoints[$y][$x] = (int)($average); + } + } + + return $blackPoints; + } + + /** + * For each block in the image, calculate the average black point using a 5x5 grid + * of the blocks around it. Also handles the corner cases (fractional blocks are computed based + * on the last pixels in the row/column which are also used in the previous block). + */ + private function calculateThresholdForBlock( + int $subWidth, + int $subHeight, + int $width, + int $height + ):BitMatrix{ + $matrix = new BitMatrix(max($width, $height)); + $luminances = $this->source->getMatrix(); + $blackPoints = $this->calculateBlackPoints($luminances, $subWidth, $subHeight, $width, $height); + + for($y = 0; $y < $subHeight; $y++){ + $yoffset = ($y << self::BLOCK_SIZE_POWER); + $maxYOffset = $height - self::BLOCK_SIZE; + + if($yoffset > $maxYOffset){ + $yoffset = $maxYOffset; + } + + for($x = 0; $x < $subWidth; $x++){ + $xoffset = ($x << self::BLOCK_SIZE_POWER); + $maxXOffset = $width - self::BLOCK_SIZE; + + if($xoffset > $maxXOffset){ + $xoffset = $maxXOffset; + } + + $left = $this->cap($x, 2, $subWidth - 3); + $top = $this->cap($y, 2, $subHeight - 3); + $sum = 0; + + for($z = -2; $z <= 2; $z++){ + $blackRow = $blackPoints[$top + $z]; + $sum += $blackRow[$left - 2] + $blackRow[$left - 1] + $blackRow[$left] + $blackRow[$left + 1] + $blackRow[$left + 2]; + } + + $average = (int)($sum / 25); + + // Applies a single threshold to a block of pixels. + for($j = 0, $o = $yoffset * $width + $xoffset; $j < self::BLOCK_SIZE; $j++, $o += $width){ + for($i = 0; $i < self::BLOCK_SIZE; $i++){ + // Comparison needs to be <= so that black == 0 pixels are black even if the threshold is 0. + if(($luminances[$o + $i] & 0xff) <= $average){ + $matrix->set($xoffset + $i, $yoffset + $j); + } + } + } + } + } + + return $matrix; + } + + private function cap(int $value, int $min, int $max):int{ + + if($value < $min){ + return $min; + } + + if($value > $max){ + return $max; + } + + return $value; + } + +} diff --git a/src/Decoder/BitMatrix.php b/src/Decoder/BitMatrix.php new file mode 100644 index 000000000..a8d41eefa --- /dev/null +++ b/src/Decoder/BitMatrix.php @@ -0,0 +1,204 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Decoder; + +use chillerlan\QRCode\Common\{MaskPattern, Version}; +use InvalidArgumentException; +use function chillerlan\QRCode\Common\uRShift; +use function array_fill, count; + +final class BitMatrix{ + + private int $dimension; + private int $rowSize; + private array $bits; + + public function __construct(int $dimension){ + $this->dimension = $dimension; + $this->rowSize = ((int)(($this->dimension + 0x1f) / 0x20)); + $this->bits = array_fill(0, $this->rowSize * $this->dimension, 0); + } + + /** + *Sets the given bit to true.
+ * + * @param int $x ; The horizontal component (i.e. which column) + * @param int $y ; The vertical component (i.e. which row) + */ + public function set(int $x, int $y):void{ + $offset = (int)($y * $this->rowSize + ($x / 0x20)); + + $this->bits[$offset] ??= 0; + $this->bits[$offset] |= ($this->bits[$offset] |= 1 << ($x & 0x1f)); + } + + /** + *Flips the given bit. 1 << (0xf9 & 0x1f)
+ * + * @param int $x ; The horizontal component (i.e. which column) + * @param int $y ; The vertical component (i.e. which row) + */ + public function flip(int $x, int $y):void{ + $offset = $y * $this->rowSize + (int)($x / 0x20); + + $this->bits[$offset] = ($this->bits[$offset] ^ (1 << ($x & 0x1f))); + } + + /** + *Sets a square region of the bit matrix to true.
+ * + * @param int $left ; The horizontal position to begin at (inclusive) + * @param int $top ; The vertical position to begin at (inclusive) + * @param int $width ; The width of the region + * @param int $height ; The height of the region + * + * @throws \InvalidArgumentException + */ + public function setRegion(int $left, int $top, int $width, int $height):void{ + + if($top < 0 || $left < 0){ + throw new InvalidArgumentException('Left and top must be nonnegative'); + } + + if($height < 1 || $width < 1){ + throw new InvalidArgumentException('Height and width must be at least 1'); + } + + $right = $left + $width; + $bottom = $top + $height; + + if($bottom > $this->dimension || $right > $this->dimension){ + throw new InvalidArgumentException('The region must fit inside the matrix'); + } + + for($y = $top; $y < $bottom; $y++){ + $yOffset = $y * $this->rowSize; + + for($x = $left; $x < $right; $x++){ + $xOffset = $yOffset + (int)($x / 0x20); + $this->bits[$xOffset] = ($this->bits[$xOffset] |= 1 << ($x & 0x1f)); + } + } + } + + /** + * @return int The dimension (width/height) of the matrix + */ + public function getDimension():int{ + return $this->dimension; + } + + /** + *Gets the requested bit, where true means black.
+ * + * @param int $x The horizontal component (i.e. which column) + * @param int $y The vertical component (i.e. which row) + * + * @return bool value of given bit in matrix + */ + public function get(int $x, int $y):bool{ + $offset = (int)($y * $this->rowSize + ($x / 0x20)); + + $this->bits[$offset] ??= 0; + + return (uRShift($this->bits[$offset], ($x & 0x1f)) & 1) !== 0; + } + + /** + * See ISO 18004:2006 Annex E + */ + public function buildFunctionPattern(Version $version):BitMatrix{ + $dimension = $version->getDimension(); + // @todo + $bitMatrix = new self($dimension); + + // Top left finder pattern + separator + format + $bitMatrix->setRegion(0, 0, 9, 9); + // Top right finder pattern + separator + format + $bitMatrix->setRegion($dimension - 8, 0, 8, 9); + // Bottom left finder pattern + separator + format + $bitMatrix->setRegion(0, $dimension - 8, 9, 8); + + // Alignment patterns + $apc = $version->getAlignmentPattern(); + $max = count($apc); + + for($x = 0; $x < $max; $x++){ + $i = $apc[$x] - 2; + + for($y = 0; $y < $max; $y++){ + if(($x === 0 && ($y === 0 || $y === $max - 1)) || ($x === $max - 1 && $y === 0)){ + // No alignment patterns near the three finder paterns + continue; + } + + $bitMatrix->setRegion($apc[$y] - 2, $i, 5, 5); + } + } + + // Vertical timing pattern + $bitMatrix->setRegion(6, 9, 1, $dimension - 17); + // Horizontal timing pattern + $bitMatrix->setRegion(9, 6, $dimension - 17, 1); + + if($version->getVersionNumber() > 6){ + // Version info, top right + $bitMatrix->setRegion($dimension - 11, 0, 3, 6); + // Version info, bottom left + $bitMatrix->setRegion(0, $dimension - 11, 6, 3); + } + + return $bitMatrix; + } + + /** + * Mirror the bit matrix in order to attempt a second reading. + */ + public function mirror():void{ + + for($x = 0; $x < $this->dimension; $x++){ + for($y = $x + 1; $y < $this->dimension; $y++){ + if($this->get($x, $y) !== $this->get($y, $x)){ + $this->flip($y, $x); + $this->flip($x, $y); + } + } + } + + } + + /** + *Encapsulates data masks for the data bits in a QR code, per ISO 18004:2006 6.8. Implementations + * of this class can un-mask a raw BitMatrix. For simplicity, they will unmask the entire BitMatrix, + * including areas used for finder patterns, timing patterns, etc. These areas should be unused + * after the point they are unmasked anyway.
+ * + *Note that the diagram in section 6.8.1 is misleading since it indicates that i is column position + * and j is row position. In fact, as the text says, i is row position and j is column position.
+ * + *Implementations of this method reverse the data masking process applied to a QR Code and + * make its bits ready to read.
+ */ + public function unmask(int $dimension, MaskPattern $maskPattern):void{ + $mask = $maskPattern->getMask(); + + for($y = 0; $y < $dimension; $y++){ + for($x = 0; $x < $dimension; $x++){ + if($mask($x, $y) === 0){ + $this->flip($x, $y); + } + } + } + + } + +} diff --git a/src/Decoder/BitMatrixParser.php b/src/Decoder/BitMatrixParser.php new file mode 100644 index 000000000..190b7ff82 --- /dev/null +++ b/src/Decoder/BitMatrixParser.php @@ -0,0 +1,333 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Decoder; + +use RuntimeException; +use chillerlan\QRCode\Common\{Version, FormatInformation}; +use function chillerlan\QRCode\Common\numBitsDiffering; +use const PHP_INT_MAX; + +/** + * @author Sean Owen + */ +final class BitMatrixParser{ + + private BitMatrix $bitMatrix; + private ?Version $parsedVersion = null; + private ?FormatInformation $parsedFormatInfo = null; + private bool $mirror = false; + + /** + * @param \chillerlan\QRCode\Decoder\BitMatrix $bitMatrix + * + * @throws \RuntimeException if dimension is not >= 21 and 1 mod 4 + */ + public function __construct(BitMatrix $bitMatrix){ + $dimension = $bitMatrix->getDimension(); + + if($dimension < 21 || ($dimension % 4) !== 1){ + throw new RuntimeException('dimension is not >= 21, dimension mod 4 not 1'); + } + + $this->bitMatrix = $bitMatrix; + } + + /** + * Prepare the parser for a mirrored operation. + * This flag has effect only on the {@link #readFormatInformation()} and the + * {@link #readVersion()}. Before proceeding with {@link #readCodewords()} the + * {@link #mirror()} method should be called. + * + * @param bool mirror Whether to read version and format information mirrored. + */ + public function setMirror(bool $mirror):void{ + $this->parsedVersion = null; + $this->parsedFormatInfo = null; + $this->mirror = $mirror; + } + + /** + * Mirror the bit matrix in order to attempt a second reading. + */ + public function mirror():void{ + $this->bitMatrix->mirror(); + } + + private function copyBit(int $i, int $j, int $versionBits):int{ + + $bit = $this->mirror + ? $this->bitMatrix->get($j, $i) + : $this->bitMatrix->get($i, $j); + + return $bit ? ($versionBits << 1) | 0x1 : $versionBits << 1; + } + + /** + *Reads the bits in the {@link BitMatrix} representing the finder pattern in the + * correct order in order to reconstruct the codewords bytes contained within the + * QR Code.
+ * + * @return array bytes encoded within the QR Code + * @throws \RuntimeException if the exact number of bytes expected is not read + */ + public function readCodewords():array{ + $formatInfo = $this->readFormatInformation(); + $version = $this->readVersion(); + + // Get the data mask for the format used in this QR Code. This will exclude + // some bits from reading as we wind through the bit matrix. + $dimension = $this->bitMatrix->getDimension(); + $this->bitMatrix->unmask($dimension, $formatInfo->getDataMask()); + $functionPattern = $this->bitMatrix->buildFunctionPattern($version); + + $readingUp = true; + $result = []; + $resultOffset = 0; + $currentByte = 0; + $bitsRead = 0; + // Read columns in pairs, from right to left + for($j = $dimension - 1; $j > 0; $j -= 2){ + + if($j === 6){ + // Skip whole column with vertical alignment pattern; + // saves time and makes the other code proceed more cleanly + $j--; + } + // Read alternatingly from bottom to top then top to bottom + for($count = 0; $count < $dimension; $count++){ + $i = $readingUp ? $dimension - 1 - $count : $count; + + for($col = 0; $col < 2; $col++){ + // Ignore bits covered by the function pattern + if(!$functionPattern->get($j - $col, $i)){ + // Read a bit + $bitsRead++; + $currentByte <<= 1; + + if($this->bitMatrix->get($j - $col, $i)){ + $currentByte |= 1; + } + // If we've made a whole byte, save it off + if($bitsRead === 8){ + $result[$resultOffset++] = $currentByte; //(byte) + $bitsRead = 0; + $currentByte = 0; + } + } + } + } + + $readingUp = !$readingUp; // switch directions + } + + if($resultOffset !== $version->getTotalCodewords()){ + throw new RuntimeException('offset differs from total codewords for version'); + } + + return $result; + } + + /** + *Reads format information from one of its two locations within the QR Code.
+ * + * @return \chillerlan\QRCode\Common\FormatInformation encapsulating the QR Code's format info + * @throws \RuntimeException if both format information locations cannot be parsed as + * the valid encoding of format information + */ + public function readFormatInformation():FormatInformation{ + + if($this->parsedFormatInfo !== null){ + return $this->parsedFormatInfo; + } + + // Read top-left format info bits + $formatInfoBits1 = 0; + + for($i = 0; $i < 6; $i++){ + $formatInfoBits1 = $this->copyBit($i, 8, $formatInfoBits1); + } + + // .. and skip a bit in the timing pattern ... + $formatInfoBits1 = $this->copyBit(7, 8, $formatInfoBits1); + $formatInfoBits1 = $this->copyBit(8, 8, $formatInfoBits1); + $formatInfoBits1 = $this->copyBit(8, 7, $formatInfoBits1); + // .. and skip a bit in the timing pattern ... + for($j = 5; $j >= 0; $j--){ + $formatInfoBits1 = $this->copyBit(8, $j, $formatInfoBits1); + } + + // Read the top-right/bottom-left pattern too + $dimension = $this->bitMatrix->getDimension(); + $formatInfoBits2 = 0; + $jMin = $dimension - 7; + + for($j = $dimension - 1; $j >= $jMin; $j--){ + $formatInfoBits2 = $this->copyBit(8, $j, $formatInfoBits2); + } + + for($i = $dimension - 8; $i < $dimension; $i++){ + $formatInfoBits2 = $this->copyBit($i, 8, $formatInfoBits2); + } + + $this->parsedFormatInfo = $this->doDecodeFormatInformation($formatInfoBits1, $formatInfoBits2); + + if($this->parsedFormatInfo !== null){ + return $this->parsedFormatInfo; + } + + // Should return null, but, some QR codes apparently do not mask this info. + // Try again by actually masking the pattern first. + $this->parsedFormatInfo = $this->doDecodeFormatInformation( + $formatInfoBits1 ^ FormatInformation::MASK_QR, + $formatInfoBits2 ^ FormatInformation::MASK_QR + ); + + if($this->parsedFormatInfo !== null){ + return $this->parsedFormatInfo; + } + + throw new RuntimeException('failed to read format info'); + } + + /** + * @param int $maskedFormatInfo1 format info indicator, with mask still applied + * @param int $maskedFormatInfo2 second copy of same info; both are checked at the same time + * to establish best match + * + * @return \chillerlan\QRCode\Common\FormatInformation information about the format it specifies, or {@code null} + * if doesn't seem to match any known pattern + */ + private function doDecodeFormatInformation(int $maskedFormatInfo1, int $maskedFormatInfo2):?FormatInformation{ + // Find the int in FORMAT_INFO_DECODE_LOOKUP with fewest bits differing + $bestDifference = PHP_INT_MAX; + $bestFormatInfo = 0; + + foreach(FormatInformation::DECODE_LOOKUP as $decodeInfo){ + [$maskedBits, $dataBits] = $decodeInfo; + + if($maskedFormatInfo1 === $dataBits || $maskedFormatInfo2 === $dataBits){ + // Found an exact match + return new FormatInformation($maskedBits); + } + + $bitsDifference = numBitsDiffering($maskedFormatInfo1, $dataBits); + + if($bitsDifference < $bestDifference){ + $bestFormatInfo = $maskedBits; + $bestDifference = $bitsDifference; + } + + if($maskedFormatInfo1 !== $maskedFormatInfo2){ + // also try the other option + $bitsDifference = numBitsDiffering($maskedFormatInfo2, $dataBits); + + if($bitsDifference < $bestDifference){ + $bestFormatInfo = $maskedBits; + $bestDifference = $bitsDifference; + } + } + } + // Hamming distance of the 32 masked codes is 7, by construction, so <= 3 bits differing means we found a match + if($bestDifference <= 3){ + return new FormatInformation($bestFormatInfo); + } + + return null; + } + + /** + *Reads version information from one of its two locations within the QR Code.
+ * + * @return \chillerlan\QRCode\Common\Version encapsulating the QR Code's version + * @throws \RuntimeException if both version information locations cannot be parsed as + * the valid encoding of version information + */ + public function readVersion():Version{ + + if($this->parsedVersion !== null){ + return $this->parsedVersion; + } + + $dimension = $this->bitMatrix->getDimension(); + $provisionalVersion = ($dimension - 17) / 4; + + if($provisionalVersion <= 6){ + return new Version($provisionalVersion); + } + + // Read top-right version info: 3 wide by 6 tall + $versionBits = 0; + $ijMin = $dimension - 11; + + for($j = 5; $j >= 0; $j--){ + for($i = $dimension - 9; $i >= $ijMin; $i--){ + $versionBits = $this->copyBit($i, $j, $versionBits); + } + } + + $this->parsedVersion = $this->decodeVersionInformation($versionBits); + + if($this->parsedVersion !== null && $this->parsedVersion->getDimension() === $dimension){ + return $this->parsedVersion; + } + + // Hmm, failed. Try bottom left: 6 wide by 3 tall + $versionBits = 0; + + for($i = 5; $i >= 0; $i--){ + for($j = $dimension - 9; $j >= $ijMin; $j--){ + $versionBits = $this->copyBit($i, $j, $versionBits); + } + } + + $this->parsedVersion = $this->decodeVersionInformation($versionBits); + + if($this->parsedVersion !== null && $this->parsedVersion->getDimension() === $dimension){ + return $this->parsedVersion; + } + + throw new RuntimeException('failed to read version'); + } + + private function decodeVersionInformation(int $versionBits):?Version{ + $bestDifference = PHP_INT_MAX; + $bestVersion = 0; + + for($i = 7; $i <= 40; $i++){ + $targetVersion = new Version($i); + $targetVersionPattern = $targetVersion->getVersionPattern(); + + // Do the version info bits match exactly? done. + if($targetVersionPattern === $versionBits){ + return $targetVersion; + } + + // Otherwise see if this is the closest to a real version info bit string + // we have seen so far + $bitsDifference = numBitsDiffering($versionBits, $targetVersionPattern); + + if($bitsDifference < $bestDifference){ + $bestVersion = $i; + $bestDifference = $bitsDifference; + } + } + // We can tolerate up to 3 bits of error since no two version info codewords will + // differ in less than 8 bits. + if($bestDifference <= 3){ + return new Version($bestVersion); + } + + // If we didn't find a close enough match, fail + return null; + } + +} diff --git a/src/Decoder/Decoder.php b/src/Decoder/Decoder.php new file mode 100644 index 000000000..f50f43ef1 --- /dev/null +++ b/src/Decoder/Decoder.php @@ -0,0 +1,336 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Decoder; + +use Exception, InvalidArgumentException, RuntimeException; +use chillerlan\QRCode\Common\{BitBuffer, EccLevel, ECICharset, Mode, ReedSolomonDecoder, Version}; +use chillerlan\QRCode\Data\{AlphaNum, Byte, ECI, Kanji, Number}; +use chillerlan\QRCode\Detector\Detector; +use function count, array_fill, mb_convert_encoding, mb_detect_encoding; + +/** + *The main class which implements QR Code decoding -- as opposed to locating and extracting + * the QR Code from an image.
+ * + * @author Sean Owen + */ +final class Decoder{ + +# private const GB2312_SUBSET = 1; + + /** + *Decodes a QR Code represented as a {@link \chillerlan\QRCode\Decoder\BitMatrix}. + * A 1 or "true" is taken to mean a black module.
+ * + * @param \chillerlan\QRCode\Decoder\LuminanceSource $source + * + * @return \chillerlan\QRCode\Decoder\DecoderResult text and bytes encoded within the QR Code + * @throws \Exception if the QR Code cannot be decoded + */ + public function decode(LuminanceSource $source):DecoderResult{ + $matrix = (new Binarizer($source))->getBlackMatrix(); + $bitMatrix = (new Detector($matrix))->detect(); + + $fe = null; + + try{ + // Construct a parser and read version, error-correction level + // clone the BitMatrix to avoid errors in case we run into mirroring + return $this->decodeParser(new BitMatrixParser(clone $bitMatrix)); + } + catch(Exception $e){ + $fe = $e; + } + + try{ + $parser = new BitMatrixParser(clone $bitMatrix); + + // Will be attempting a mirrored reading of the version and format info. + $parser->setMirror(true); + + // Preemptively read the version. +# $parser->readVersion(); + + // Preemptively read the format information. +# $parser->readFormatInformation(); + + /* + * Since we're here, this means we have successfully detected some kind + * of version and format information when mirrored. This is a good sign, + * that the QR code may be mirrored, and we should try once more with a + * mirrored content. + */ + // Prepare for a mirrored reading. + $parser->mirror(); + + return $this->decodeParser($parser); + } + catch(Exception $e){ + // Throw the exception from the original reading + if($fe instanceof Exception){ + throw $fe; + } + + throw $e; + } + + } + + /** + * @param \chillerlan\QRCode\Decoder\BitMatrixParser $parser + * + * @return \chillerlan\QRCode\Decoder\DecoderResult + */ + private function decodeParser(BitMatrixParser $parser):DecoderResult{ + $version = $parser->readVersion(); + $eccLevel = $parser->readFormatInformation()->getErrorCorrectionLevel(); + + // Read raw codewords + $rawCodewords = $parser->readCodewords(); + // Separate into data blocks + $dataBlocks = $this->getDataBlocks($rawCodewords, $version, $eccLevel); + + $resultBytes = []; + $resultOffset = 0; + + // Error-correct and copy data blocks together into a stream of bytes + foreach($dataBlocks as $dataBlock){ + [$numDataCodewords, $codewordBytes] = $dataBlock; + + $corrected = $this->correctErrors($codewordBytes, $numDataCodewords); + + for($i = 0; $i < $numDataCodewords; $i++){ + $resultBytes[$resultOffset++] = $corrected[$i]; + } + } + + // Decode the contents of that stream of bytes + return $this->decodeBitStream($resultBytes, $version, $eccLevel); + } + + /** + *When QR Codes use multiple data blocks, they are actually interleaved. + * That is, the first byte of data block 1 to n is written, then the second bytes, and so on. This + * method will separate the data into original blocks.
+ * + * @param array $rawCodewords bytes as read directly from the QR Code + * @param \chillerlan\QRCode\Common\Version $version version of the QR Code + * @param \chillerlan\QRCode\Common\EccLevel $eccLevel error-correction level of the QR Code + * + * @return array DataBlocks containing original bytes, "de-interleaved" from representation in the QR Code + * @throws \InvalidArgumentException + */ + private function getDataBlocks(array $rawCodewords, Version $version, EccLevel $eccLevel):array{ + + if(count($rawCodewords) !== $version->getTotalCodewords()){ + throw new InvalidArgumentException('$rawCodewords differ from total codewords for version'); + } + + // Figure out the number and size of data blocks used by this version and + // error correction level + [$numEccCodewords, $eccBlocks] = $version->getRSBlocks($eccLevel); + + // Now establish DataBlocks of the appropriate size and number of data codewords + $result = [];//new DataBlock[$totalBlocks]; + $numResultBlocks = 0; + + foreach($eccBlocks as $blockData){ + [$numEccBlocks, $eccPerBlock] = $blockData; + + for($i = 0; $i < $numEccBlocks; $i++, $numResultBlocks++){ + $result[$numResultBlocks] = [$eccPerBlock, array_fill(0, $numEccCodewords + $eccPerBlock, 0)]; + } + } + + // All blocks have the same amount of data, except that the last n + // (where n may be 0) have 1 more byte. Figure out where these start. + $shorterBlocksTotalCodewords = count($result[0][1]); + $longerBlocksStartAt = count($result) - 1; + + while($longerBlocksStartAt >= 0){ + $numCodewords = count($result[$longerBlocksStartAt][1]); + + if($numCodewords == $shorterBlocksTotalCodewords){ + break; + } + + $longerBlocksStartAt--; + } + + $longerBlocksStartAt++; + + $shorterBlocksNumDataCodewords = $shorterBlocksTotalCodewords - $numEccCodewords; + // The last elements of result may be 1 element longer; + // first fill out as many elements as all of them have + $rawCodewordsOffset = 0; + + for($i = 0; $i < $shorterBlocksNumDataCodewords; $i++){ + for($j = 0; $j < $numResultBlocks; $j++){ + $result[$j][1][$i] = $rawCodewords[$rawCodewordsOffset++]; + } + } + + // Fill out the last data block in the longer ones + for($j = $longerBlocksStartAt; $j < $numResultBlocks; $j++){ + $result[$j][1][$shorterBlocksNumDataCodewords] = $rawCodewords[$rawCodewordsOffset++]; + } + + // Now add in error correction blocks + $max = count($result[0][1]); + + for($i = $shorterBlocksNumDataCodewords; $i < $max; $i++){ + for($j = 0; $j < $numResultBlocks; $j++){ + $iOffset = $j < $longerBlocksStartAt ? $i : $i + 1; + $result[$j][1][$iOffset] = $rawCodewords[$rawCodewordsOffset++]; + } + } + + return $result; + } + + /** + *Given data and error-correction codewords received, possibly corrupted by errors, attempts to + * correct the errors in-place using Reed-Solomon error correction.
+ */ + private function correctErrors(array $codewordBytes, int $numDataCodewords):array{ + // First read into an array of ints + $codewordsInts = []; + + foreach($codewordBytes as $i => $codewordByte){ + $codewordsInts[$i] = $codewordByte & 0xFF; + } + + $decoded = (new ReedSolomonDecoder)->decode($codewordsInts, (count($codewordBytes) - $numDataCodewords)); + + // Copy back into array of bytes -- only need to worry about the bytes that were data + // We don't care about errors in the error-correction codewords + for($i = 0; $i < $numDataCodewords; $i++){ + $codewordBytes[$i] = $decoded[$i]; + } + + return $codewordBytes; + } + + /** + * @throws \RuntimeException + */ + private function decodeBitStream(array $bytes, Version $version, EccLevel $ecLevel):DecoderResult{ + $bits = new BitBuffer($bytes); + $symbolSequence = -1; + $parityData = -1; + $versionNumber = $version->getVersionNumber(); + + $result = ''; + $eciCharset = null; +# $fc1InEffect = false; + + // While still another segment to read... + while($bits->available() >= 4){ + $datamode = $bits->read(4); // mode is encoded by 4 bits + + // OK, assume we're done. Really, a TERMINATOR mode should have been recorded here + if($datamode === Mode::DATA_TERMINATOR){ + break; + } + + if($datamode === Mode::DATA_ECI){ + // Count doesn't apply to ECI + $value = ECI::parseValue($bits); + $eciCharset = new ECICharset($value); + } + /** @noinspection PhpStatementHasEmptyBodyInspection */ + elseif($datamode === Mode::DATA_FNC1_FIRST || $datamode === Mode::DATA_FNC1_SECOND){ + // We do little with FNC1 except alter the parsed result a bit according to the spec +# $fc1InEffect = true; + } + elseif($datamode === Mode::DATA_STRCTURED_APPEND){ + if($bits->available() < 16){ + throw new RuntimeException('structured append: not enough bits left'); + } + // sequence number and parity is added later to the result metadata + // Read next 8 bits (symbol sequence #) and 8 bits (parity data), then continue + $symbolSequence = $bits->read(8); + $parityData = $bits->read(8); + } + else{ + // First handle Hanzi mode which does not start with character count +/* if($datamode === Mode::DATA_HANZI){ + //chinese mode contains a sub set indicator right after mode indicator + $subset = $bits->read(4); + $length = $bits->read(Mode::getLengthBitsForVersion($datamode, $versionNumber)); + if($subset === self::GB2312_SUBSET){ + $result .= $this->decodeHanziSegment($bits, $length); + } + }*/ +# else{ + // "Normal" QR code modes: + if($datamode === Mode::DATA_NUMBER){ + $result .= Number::decodeSegment($bits, $versionNumber); + } + elseif($datamode === Mode::DATA_ALPHANUM){ + $str = AlphaNum::decodeSegment($bits, $versionNumber); + + // See section 6.4.8.1, 6.4.8.2 +/* if($fc1InEffect){ + $start = \strlen($str); + // We need to massage the result a bit if in an FNC1 mode: + for($i = $start; $i < $start; $i++){ + if($str[$i] === '%'){ + if($i < $start - 1 && $str[$i + 1] === '%'){ + // %% is rendered as % + $str = \substr_replace($str, '', $i + 1, 1);//deleteCharAt(i + 1); + } +# else{ + // In alpha mode, % should be converted to FNC1 separator 0x1D @todo +# $str = setCharAt($i, \chr(0x1D)); // ??? +# } + } + } + } +*/ + $result .= $str; + } + elseif($datamode === Mode::DATA_BYTE){ + $str = Byte::decodeSegment($bits, $versionNumber); + + if($eciCharset !== null){ + $encoding = $eciCharset->getName(); + + if($encoding === null){ + // The spec isn't clear on this mode; see + // section 6.4.5: t does not say which encoding to assuming + // upon decoding. I have seen ISO-8859-1 used as well as + // Shift_JIS -- without anything like an ECI designator to + // give a hint. + $encoding = mb_detect_encoding($str, ['ISO-8859-1', 'SJIS', 'UTF-8']); + } + + $eciCharset = null; + $str = mb_convert_encoding($str, $encoding); + } + + $result .= $str; + } + elseif($datamode === Mode::DATA_KANJI){ + $result .= Kanji::decodeSegment($bits, $versionNumber); + } + else{ + throw new RuntimeException('invalid data mode'); + } +# } + } + } + + return new DecoderResult($bytes, $result, $version, $ecLevel, $symbolSequence, $parityData); + } + +} diff --git a/src/Decoder/DecoderResult.php b/src/Decoder/DecoderResult.php new file mode 100644 index 000000000..fb61802e1 --- /dev/null +++ b/src/Decoder/DecoderResult.php @@ -0,0 +1,86 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Decoder; + +use chillerlan\QRCode\Common\{EccLevel, Version}; + +/** + *Encapsulates the result of decoding a matrix of bits. This typically + * applies to 2D barcode formats. For now it contains the raw bytes obtained, + * as well as a String interpretation of those bytes, if applicable.
+ * + * @author Sean Owen + */ +final class DecoderResult{ + + private array $rawBytes; + private string $text; + private Version $version; + private EccLevel $eccLevel; + private int $structuredAppendParity; + private int $structuredAppendSequenceNumber; + + public function __construct( + array $rawBytes, + string $text, + Version $version, + EccLevel $eccLevel, + int $saSequence = -1, + int $saParity = -1 + ){ + $this->rawBytes = $rawBytes; + $this->text = $text; + $this->version = $version; + $this->eccLevel = $eccLevel; + $this->structuredAppendParity = $saParity; + $this->structuredAppendSequenceNumber = $saSequence; + } + + /** + * @return int[] raw bytes encoded by the barcode, if applicable, otherwise {@code null} + */ + public function getRawBytes():array{ + return $this->rawBytes; + } + + /** + * @return string raw text encoded by the barcode + */ + public function getText():string{ + return $this->text; + } + + public function __toString():string{ + return $this->text; + } + + public function getVersion():Version{ + return $this->version; + } + + public function getEccLevel():EccLevel{ + return $this->eccLevel; + } + + public function hasStructuredAppend():bool{ + return $this->structuredAppendParity >= 0 && $this->structuredAppendSequenceNumber >= 0; + } + + public function getStructuredAppendParity():int{ + return $this->structuredAppendParity; + } + + public function getStructuredAppendSequenceNumber():int{ + return $this->structuredAppendSequenceNumber; + } + +} diff --git a/src/Decoder/GDLuminanceSource.php b/src/Decoder/GDLuminanceSource.php new file mode 100644 index 000000000..710887aae --- /dev/null +++ b/src/Decoder/GDLuminanceSource.php @@ -0,0 +1,71 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + * + * @noinspection PhpComposerExtensionStubsInspection + */ + +namespace chillerlan\QRCode\Decoder; + +use InvalidArgumentException; +use function get_resource_type, imagecolorat, imagecolorsforindex, imagesx, imagesy, is_resource; +use const PHP_MAJOR_VERSION; + +/** + * This class is used to help decode images from files which arrive as GD Resource + * It does not support rotation. + */ +final class GDLuminanceSource extends LuminanceSource{ + + /** + * @var resource|\GdImage + * @phan-suppress PhanUndeclaredTypeProperty + */ + private $gdImage; + + /** + * GDLuminanceSource constructor. + * + * @param resource|\GdImage $gdImage + * @phan-suppress PhanUndeclaredTypeParameter + * + * @throws \InvalidArgumentException + */ + public function __construct($gdImage){ + + /** + * @noinspection PhpElementIsNotAvailableInCurrentPhpVersionInspection + * @phan-suppress PhanUndeclaredClassInstanceof + */ + if( + (PHP_MAJOR_VERSION >= 8 && !$gdImage instanceof \GdImage) + || (PHP_MAJOR_VERSION < 8 && (!is_resource($gdImage) || get_resource_type($gdImage) !== 'gd')) + ){ + throw new InvalidArgumentException('Invalid GD image source.'); + } + + parent::__construct(imagesx($gdImage), imagesy($gdImage)); + + $this->gdImage = $gdImage; + + $this->setLuminancePixels(); + } + + private function setLuminancePixels():void{ + for($j = 0; $j < $this->height; $j++){ + for($i = 0; $i < $this->width; $i++){ + $argb = imagecolorat($this->gdImage, $i, $j); + $pixel = imagecolorsforindex($this->gdImage, $argb); + + $this->setLuminancePixel($pixel['red'], $pixel['green'], $pixel['blue']); + } + } + } + +} diff --git a/src/Decoder/IMagickLuminanceSource.php b/src/Decoder/IMagickLuminanceSource.php new file mode 100644 index 000000000..c2f71231c --- /dev/null +++ b/src/Decoder/IMagickLuminanceSource.php @@ -0,0 +1,53 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + * + * @noinspection PhpComposerExtensionStubsInspection + */ + +namespace chillerlan\QRCode\Decoder; + +use Imagick, InvalidArgumentException; +use function count; + +/** + * This class is used to help decode images from files which arrive as Imagick Resource + * It does not support rotation. + */ +final class IMagickLuminanceSource extends LuminanceSource{ + + private Imagick $imagick; + + /** + * IMagickLuminanceSource constructor. + * + * @param \Imagick $imagick + * + * @throws \InvalidArgumentException + */ + public function __construct(Imagick $imagick){ + parent::__construct($imagick->getImageWidth(), $imagick->getImageHeight()); + + $this->imagick = $imagick; + + $this->setLuminancePixels(); + } + + private function setLuminancePixels():void{ + $this->imagick->setImageColorspace(Imagick::COLORSPACE_GRAY); + $pixels = $this->imagick->exportImagePixels(1, 1, $this->width, $this->height, 'RGB', Imagick::PIXEL_CHAR); + + $countPixels = count($pixels); + + for($i = 0; $i < $countPixels; $i += 3){ + $this->setLuminancePixel($pixels[$i] & 0xff, $pixels[$i + 1] & 0xff, $pixels[$i + 2] & 0xff); + } + } + +} diff --git a/src/Decoder/LuminanceSource.php b/src/Decoder/LuminanceSource.php new file mode 100644 index 000000000..fba88ab9e --- /dev/null +++ b/src/Decoder/LuminanceSource.php @@ -0,0 +1,103 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Decoder; + +use InvalidArgumentException; +use function chillerlan\QRCode\Common\arraycopy; + +/** + * The purpose of this class hierarchy is to abstract different bitmap implementations across + * platforms into a standard interface for requesting greyscale luminance values. The interface + * only provides immutable methods; therefore crop and rotation create copies. This is to ensure + * that one Reader does not modify the original luminance source and leave it in an unknown state + * for other Readers in the chain. + * + * @author dswitkin@google.com (Daniel Switkin) + */ +abstract class LuminanceSource{ + + protected array $luminances; + protected int $width; + protected int $height; + + public function __construct(int $width, int $height){ + $this->width = $width; + $this->height = $height; + // In order to measure pure decoding speed, we convert the entire image to a greyscale array + // up front, which is the same as the Y channel of the YUVLuminanceSource in the real app. + $this->luminances = []; + // @todo: grayscale? + //$this->luminances = $this->grayScaleToBitmap($this->grayscale()); + } + + /** + * Fetches luminance data for the underlying bitmap. Values should be fetched using: + * {@code int luminance = array[y * width + x] & 0xff} + * + * @return array A row-major 2D array of luminance values. Do not use result.length as it may be + * larger than width * height bytes on some platforms. Do not modify the contents + * of the result. + */ + public function getMatrix():array{ + return $this->luminances; + } + + /** + * @return int The width of the bitmap. + */ + public function getWidth():int{ + return $this->width; + } + + /** + * @return int The height of the bitmap. + */ + public function getHeight():int{ + return $this->height; + } + + /** + * Fetches one row of luminance data from the underlying platform's bitmap. Values range from + * 0 (black) to 255 (white). Because Java does not have an unsigned byte type, callers will have + * to bitwise and with 0xff for each value. It is preferable for implementations of this method + * to only fetch this row rather than the whole image, since no 2D Readers may be installed and + * getMatrix() may never be called. + * + * @param int $y The row to fetch, which must be in [0,getHeight()) + * + * @return array An array containing the luminance data. + */ + public function getRow(int $y):array{ + + if($y < 0 || $y >= $this->getHeight()){ + throw new InvalidArgumentException('Requested row is outside the image: '.$y); + } + + return arraycopy($this->luminances, $y * $this->width, [], 0, $this->width); + } + + /** + * @param int $r + * @param int $g + * @param int $b + * + * @return void + */ + protected function setLuminancePixel(int $r, int $g, int $b):void{ + $this->luminances[] = $r === $g && $g === $b + // Image is already greyscale, so pick any channel. + ? $r // (($r + 128) % 256) - 128; + // Calculate luminance cheaply, favoring green. + : ($r + 2 * $g + $b) / 4; // (((($r + 2 * $g + $b) / 4) + 128) % 256) - 128; + } + +} diff --git a/src/Detector/AlignmentPattern.php b/src/Detector/AlignmentPattern.php new file mode 100644 index 000000000..1a8ad016a --- /dev/null +++ b/src/Detector/AlignmentPattern.php @@ -0,0 +1,34 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Detector; + +/** + *Encapsulates an alignment pattern, which are the smaller square patterns found in + * all but the simplest QR Codes.
+ * + * @author Sean Owen + */ +final class AlignmentPattern extends ResultPoint{ + + /** + * Combines this object's current estimate of a finder pattern position and module size + * with a new estimate. It returns a new {@code FinderPattern} containing an average of the two. + */ + public function combineEstimate(float $i, float $j, float $newModuleSize):AlignmentPattern{ + return new self( + ($this->x + $j) / 2.0, + ($this->y + $i) / 2.0, + ($this->estimatedModuleSize + $newModuleSize) / 2.0 + ); + } + +} diff --git a/src/Detector/AlignmentPatternFinder.php b/src/Detector/AlignmentPatternFinder.php new file mode 100644 index 000000000..fcbb26296 --- /dev/null +++ b/src/Detector/AlignmentPatternFinder.php @@ -0,0 +1,287 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Detector; + +use chillerlan\QRCode\Decoder\BitMatrix; +use function abs, count; + + +/** + *This class attempts to find alignment patterns in a QR Code. Alignment patterns look like finder + * patterns but are smaller and appear at regular intervals throughout the image.
+ * + *At the moment this only looks for the bottom-right alignment pattern.
+ * + *This is mostly a simplified copy of {@link FinderPatternFinder}. It is copied, + * pasted and stripped down here for maximum performance but does unfortunately duplicate + * some code.
+ * + *This class is thread-safe but not reentrant. Each thread must allocate its own object.
+ * + * @author Sean Owen + */ +final class AlignmentPatternFinder{ + + private BitMatrix $bitMatrix; + private float $moduleSize; + /** @var \chillerlan\QRCode\Detector\AlignmentPattern[] */ + private array $possibleCenters; + private array $crossCheckStateCount; + + /** + *Creates a finder that will look in a portion of the whole image.
+ * + * @param \chillerlan\QRCode\Decoder\BitMatrix $image image to search + * @param float $moduleSize estimated module size so far + */ + public function __construct(BitMatrix $image, float $moduleSize){ + $this->bitMatrix = $image; + $this->moduleSize = $moduleSize; + $this->possibleCenters = []; + $this->crossCheckStateCount = []; + } + + /** + *This method attempts to find the bottom-right alignment pattern in the image. It is a bit messy since + * it's pretty performance-critical and so is written to be fast foremost.
+ * + * @param int $startX left column from which to start searching + * @param int $startY top row from which to start searching + * @param int $width width of region to search + * @param int $height height of region to search + * + * @return \chillerlan\QRCode\Detector\AlignmentPattern|null + */ + public function find(int $startX, int $startY, int $width, int $height):?AlignmentPattern{ + $maxJ = $startX + $width; + $middleI = $startY + ($height / 2); + $stateCount = []; + + // We are looking for black/white/black modules in 1:1:1 ratio; + // this tracks the number of black/white/black modules seen so far + for($iGen = 0; $iGen < $height; $iGen++){ + // Search from middle outwards + $i = (int)($middleI + (($iGen & 0x01) === 0 ? ($iGen + 1) / 2 : -(($iGen + 1) / 2))); + $stateCount[0] = 0; + $stateCount[1] = 0; + $stateCount[2] = 0; + $j = $startX; + // Burn off leading white pixels before anything else; if we start in the middle of + // a white run, it doesn't make sense to count its length, since we don't know if the + // white run continued to the left of the start point + while($j < $maxJ && !$this->bitMatrix->get($j, $i)){ + $j++; + } + + $currentState = 0; + + while($j < $maxJ){ + + if($this->bitMatrix->get($j, $i)){ + // Black pixel + if($currentState === 1){ // Counting black pixels + $stateCount[$currentState]++; + } + // Counting white pixels + else{ + // A winner? + if($currentState === 2){ + // Yes + if($this->foundPatternCross($stateCount)){ + $confirmed = $this->handlePossibleCenter($stateCount, $i, $j); + + if($confirmed !== null){ + return $confirmed; + } + } + + $stateCount[0] = $stateCount[2]; + $stateCount[1] = 1; + $stateCount[2] = 0; + $currentState = 1; + } + else{ + $stateCount[++$currentState]++; + } + } + } + // White pixel + else{ + // Counting black pixels + if($currentState === 1){ + $currentState++; + } + + $stateCount[$currentState]++; + } + + $j++; + } + + if($this->foundPatternCross($stateCount)){ + $confirmed = $this->handlePossibleCenter($stateCount, $i, $maxJ); + + if($confirmed !== null){ + return $confirmed; + } + } + + } + + // Hmm, nothing we saw was observed and confirmed twice. If we had + // any guess at all, return it. + if(count($this->possibleCenters)){ + return $this->possibleCenters[0]; + } + + return null; + } + + /** + * @param int[] $stateCount count of black/white/black pixels just read + * + * @return bool true if the proportions of the counts is close enough to the 1/1/1 ratios + * used by alignment patterns to be considered a match + */ + private function foundPatternCross(array $stateCount):bool{ + $moduleSize = $this->moduleSize; + $maxVariance = $moduleSize / 2.0; + + for($i = 0; $i < 3; $i++){ + if(abs($moduleSize - $stateCount[$i]) >= $maxVariance){ + return false; + } + } + + return true; + } + + /** + *This is called when a horizontal scan finds a possible alignment pattern. It will + * cross check with a vertical scan, and if successful, will see if this pattern had been + * found on a previous horizontal scan. If so, we consider it confirmed and conclude we have + * found the alignment pattern.
+ * + * @param int[] $stateCount reading state module counts from horizontal scan + * @param int $i row where alignment pattern may be found + * @param int $j end of possible alignment pattern in row + * + * @return \chillerlan\QRCode\Detector\AlignmentPattern|null if we have found the same pattern twice, or null if not + */ + private function handlePossibleCenter(array $stateCount, int $i, int $j):?AlignmentPattern{ + $stateCountTotal = $stateCount[0] + $stateCount[1] + $stateCount[2]; + $centerJ = $this->centerFromEnd($stateCount, $j); + $centerI = $this->crossCheckVertical($i, (int)$centerJ, 2 * $stateCount[1], $stateCountTotal); + + if($centerI !== null){ + $estimatedModuleSize = (float)($stateCount[0] + $stateCount[1] + $stateCount[2]) / 3.0; + + foreach($this->possibleCenters as $center){ + // Look for about the same center and module size: + if($center->aboutEquals($estimatedModuleSize, $centerI, $centerJ)){ + return $center->combineEstimate($centerI, $centerJ, $estimatedModuleSize); + } + } + + // Hadn't found this before; save it + $point = new AlignmentPattern($centerJ, $centerI, $estimatedModuleSize); + $this->possibleCenters[] = $point; + } + + return null; + } + + /** + * Given a count of black/white/black pixels just seen and an end position, + * figures the location of the center of this black/white/black run. + * + * @param int[] $stateCount + * @param int $end + * + * @return float + */ + private function centerFromEnd(array $stateCount, int $end):float{ + return (float)(($end - $stateCount[2]) - $stateCount[1] / 2.0); + } + + /** + *After a horizontal scan finds a potential alignment pattern, this method + * "cross-checks" by scanning down vertically through the center of the possible + * alignment pattern to see if the same proportion is detected.
+ * + * @param int $startI row where an alignment pattern was detected + * @param int $centerJ center of the section that appears to cross an alignment pattern + * @param int $maxCount maximum reasonable number of modules that should be + * observed in any reading state, based on the results of the horizontal scan + * @param int $originalStateCountTotal + * + * @return float|null vertical center of alignment pattern, or null if not found + */ + private function crossCheckVertical(int $startI, int $centerJ, int $maxCount, int $originalStateCountTotal):?float{ + $maxI = $this->bitMatrix->getDimension(); + $stateCount = $this->crossCheckStateCount; + $stateCount[0] = 0; + $stateCount[1] = 0; + $stateCount[2] = 0; + + // Start counting up from center + $i = $startI; + while($i >= 0 && $this->bitMatrix->get($centerJ, $i) && $stateCount[1] <= $maxCount){ + $stateCount[1]++; + $i--; + } + // If already too many modules in this state or ran off the edge: + if($i < 0 || $stateCount[1] > $maxCount){ + return null; + } + + while($i >= 0 && !$this->bitMatrix->get($centerJ, $i) && $stateCount[0] <= $maxCount){ + $stateCount[0]++; + $i--; + } + + if($stateCount[0] > $maxCount){ + return null; + } + + // Now also count down from center + $i = $startI + 1; + while($i < $maxI && $this->bitMatrix->get($centerJ, $i) && $stateCount[1] <= $maxCount){ + $stateCount[1]++; + $i++; + } + + if($i == $maxI || $stateCount[1] > $maxCount){ + return null; + } + + while($i < $maxI && !$this->bitMatrix->get($centerJ, $i) && $stateCount[2] <= $maxCount){ + $stateCount[2]++; + $i++; + } + + if($stateCount[2] > $maxCount){ + return null; + } + + if(5 * abs(($stateCount[0] + $stateCount[1] + $stateCount[2]) - $originalStateCountTotal) >= 2 * $originalStateCountTotal){ + return null; + } + + if(!$this->foundPatternCross($stateCount)){ + return null; + } + + return $this->centerFromEnd($stateCount, $i); + } + +} diff --git a/src/Detector/Detector.php b/src/Detector/Detector.php new file mode 100644 index 000000000..ae28805b4 --- /dev/null +++ b/src/Detector/Detector.php @@ -0,0 +1,358 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Detector; + +use RuntimeException; +use chillerlan\QRCode\Common\Version; +use chillerlan\QRCode\Decoder\BitMatrix; +use function abs, is_nan, max, min, round; +use function chillerlan\QRCode\Common\distance; +use const NAN; + +/** + *Encapsulates logic that can detect a QR Code in an image, even if the QR Code + * is rotated or skewed, or partially obscured.
+ * + * @author Sean Owen + */ +final class Detector{ + + private BitMatrix $bitMatrix; + + /** + * Detector constructor. + */ + public function __construct(BitMatrix $image){ + $this->bitMatrix = $image; + } + + /** + *Detects a QR Code in an image.
+ */ + public function detect():BitMatrix{ + [$bottomLeft, $topLeft, $topRight] = (new FinderPatternFinder($this->bitMatrix))->find(); + + $moduleSize = (float)$this->calculateModuleSize($topLeft, $topRight, $bottomLeft); + $dimension = $this->computeDimension($topLeft, $topRight, $bottomLeft, $moduleSize); + $provisionalVersion = new Version((int)(($dimension - 17) / 4)); + $alignmentPattern = null; + + // Anything above version 1 has an alignment pattern + if(!empty($provisionalVersion->getAlignmentPattern())){ + // Guess where a "bottom right" finder pattern would have been + $bottomRightX = $topRight->getX() - $topLeft->getX() + $bottomLeft->getX(); + $bottomRightY = $topRight->getY() - $topLeft->getY() + $bottomLeft->getY(); + + // Estimate that alignment pattern is closer by 3 modules + // from "bottom right" to known top left location + $correctionToTopLeft = 1.0 - 3.0 / (float)($provisionalVersion->getDimension() - 7); + $estAlignmentX = (int)($topLeft->getX() + $correctionToTopLeft * ($bottomRightX - $topLeft->getX())); + $estAlignmentY = (int)($topLeft->getY() + $correctionToTopLeft * ($bottomRightY - $topLeft->getY())); + + // Kind of arbitrary -- expand search radius before giving up + for($i = 4; $i <= 16; $i <<= 1){//?????????? + $alignmentPattern = $this->findAlignmentInRegion($moduleSize, $estAlignmentX, $estAlignmentY, (float)$i); + + if($alignmentPattern !== null){ + break; + } + } + // If we didn't find alignment pattern... well try anyway without it + } + + $transform = $this->createTransform($topLeft, $topRight, $bottomLeft, $dimension, $alignmentPattern); + + return (new GridSampler)->sampleGrid($this->bitMatrix, $dimension, $transform); + } + + /** + *Computes an average estimated module size based on estimated derived from the positions + * of the three finder patterns.
+ * + * @throws \RuntimeException + */ + private function calculateModuleSize(FinderPattern $topLeft, FinderPattern $topRight, FinderPattern $bottomLeft):float{ + // Take the average + $moduleSize = ( + $this->calculateModuleSizeOneWay($topLeft, $topRight) + + $this->calculateModuleSizeOneWay($topLeft, $bottomLeft) + ) / 2.0; + + if($moduleSize < 1.0){ + throw new RuntimeException('module size < 1.0'); + } + + return $moduleSize; + } + + /** + *Estimates module size based on two finder patterns -- it uses + * {@link #sizeOfBlackWhiteBlackRunBothWays(int, int, int, int)} to figure the + * width of each, measuring along the axis between their centers.
+ */ + private function calculateModuleSizeOneWay(FinderPattern $pattern, FinderPattern $otherPattern):float{ + + $moduleSizeEst1 = $this->sizeOfBlackWhiteBlackRunBothWays( + $pattern->getX(), + $pattern->getY(), + $otherPattern->getX(), + $otherPattern->getY() + ); + + $moduleSizeEst2 = $this->sizeOfBlackWhiteBlackRunBothWays( + $otherPattern->getX(), + $otherPattern->getY(), + $pattern->getX(), + $pattern->getY() + ); + + if(is_nan($moduleSizeEst1)){ + return $moduleSizeEst2 / 7.0; + } + + if(is_nan($moduleSizeEst2)){ + return $moduleSizeEst1 / 7.0; + } + // Average them, and divide by 7 since we've counted the width of 3 black modules, + // and 1 white and 1 black module on either side. Ergo, divide sum by 14. + return ($moduleSizeEst1 + $moduleSizeEst2) / 14.0; + } + + /** + * See {@link #sizeOfBlackWhiteBlackRun(int, int, int, int)}; computes the total width of + * a finder pattern by looking for a black-white-black run from the center in the direction + * of another po$(another finder pattern center), and in the opposite direction too. + */ + private function sizeOfBlackWhiteBlackRunBothWays(float $fromX, float $fromY, float $toX, float $toY):float{ + $result = $this->sizeOfBlackWhiteBlackRun((int)$fromX, (int)$fromY, (int)$toX, (int)$toY); + $dimension = $this->bitMatrix->getDimension(); + // Now count other way -- don't run off image though of course + $scale = 1.0; + $otherToX = $fromX - ($toX - $fromX); + + if($otherToX < 0){ + $scale = $fromX / ($fromX - $otherToX); + $otherToX = 0; + } + elseif($otherToX >= $dimension){ + $scale = ($dimension - 1 - $fromX) / ($otherToX - $fromX); + $otherToX = $dimension - 1; + } + + $otherToY = (int)($fromY - ($toY - $fromY) * $scale); + $scale = 1.0; + + if($otherToY < 0){ + $scale = $fromY / ($fromY - $otherToY); + $otherToY = 0; + } + elseif($otherToY >= $dimension){ + $scale = ($dimension - 1 - $fromY) / ($otherToY - $fromY); + $otherToY = $dimension - 1; + } + + $otherToX = (int)($fromX + ($otherToX - $fromX) * $scale); + $result += $this->sizeOfBlackWhiteBlackRun((int)$fromX, (int)$fromY, (int)$otherToX, (int)$otherToY); + + // Middle pixel is double-counted this way; subtract 1 + return $result - 1.0; + } + + /** + *This method traces a line from a po$in the image, in the direction towards another point. + * It begins in a black region, and keeps going until it finds white, then black, then white again. + * It reports the distance from the start to this point.
+ * + *This is used when figuring out how wide a finder pattern is, when the finder pattern + * may be skewed or rotated.
+ */ + private function sizeOfBlackWhiteBlackRun(int $fromX, int $fromY, int $toX, int $toY):float{ + // Mild variant of Bresenham's algorithm; + // see http://en.wikipedia.org/wiki/Bresenham's_line_algorithm + $steep = abs($toY - $fromY) > abs($toX - $fromX); + + if($steep){ + $temp = $fromX; + $fromX = $fromY; + $fromY = $temp; + $temp = $toX; + $toX = $toY; + $toY = $temp; + } + + $dx = abs($toX - $fromX); + $dy = abs($toY - $fromY); + $error = -$dx / 2; + $xstep = $fromX < $toX ? 1 : -1; + $ystep = $fromY < $toY ? 1 : -1; + + // In black pixels, looking for white, first or second time. + $state = 0; + // Loop up until x == toX, but not beyond + $xLimit = $toX + $xstep; + + for($x = $fromX, $y = $fromY; $x !== $xLimit; $x += $xstep){ + $realX = $steep ? $y : $x; + $realY = $steep ? $x : $y; + + // Does current pixel mean we have moved white to black or vice versa? + // Scanning black in state 0,2 and white in state 1, so if we find the wrong + // color, advance to next state or end if we are in state 2 already + if(($state === 1) === $this->bitMatrix->get($realX, $realY)){ + + if($state === 2){ + return distance($x, $y, $fromX, $fromY); + } + + $state++; + } + + $error += $dy; + + if($error > 0){ + + if($y === $toY){ + break; + } + + $y += $ystep; + $error -= $dx; + } + } + + // Found black-white-black; give the benefit of the doubt that the next pixel outside the image + // is "white" so this last po$at (toX+xStep,toY) is the right ending. This is really a + // small approximation; (toX+xStep,toY+yStep) might be really correct. Ignore this. + if($state === 2){ + return distance($toX + $xstep, $toY, $fromX, $fromY); + } + + // else we didn't find even black-white-black; no estimate is really possible + return NAN; + } + + /** + *Computes the dimension (number of modules on a size) of the QR Code based on the position + * of the finder patterns and estimated module size.
+ * + * @throws \RuntimeException + */ + private function computeDimension( + FinderPattern $topLeft, + FinderPattern $topRight, + FinderPattern $bottomLeft, + float $moduleSize + ):int{ + $tltrCentersDimension = (int)round($topLeft->distance($topRight) / $moduleSize); + $tlblCentersDimension = (int)round($topLeft->distance($bottomLeft) / $moduleSize); + $dimension = (int)((($tltrCentersDimension + $tlblCentersDimension) / 2) + 7); + + switch($dimension % 4){ + case 0: + $dimension++; + break; + // 1? do nothing + case 2: + $dimension--; + break; + case 3: + throw new RuntimeException('estimated dimension: '.$dimension); + } + + if($dimension % 4 !== 1){ + throw new RuntimeException('dimension mod 4 is not 1'); + } + + return $dimension; + } + + /** + *Attempts to locate an alignment pattern in a limited region of the image, which is + * guessed to contain it.
+ * + * @param float $overallEstModuleSize estimated module size so far + * @param int $estAlignmentX x coordinate of center of area probably containing alignment pattern + * @param int $estAlignmentY y coordinate of above + * @param float $allowanceFactor number of pixels in all directions to search from the center + * + * @return \chillerlan\QRCode\Detector\AlignmentPattern|null if found, or null otherwise + */ + private function findAlignmentInRegion( + float $overallEstModuleSize, + int $estAlignmentX, + int $estAlignmentY, + float $allowanceFactor + ):?AlignmentPattern{ + // Look for an alignment pattern (3 modules in size) around where it should be + $dimension = $this->bitMatrix->getDimension(); + $allowance = (int)($allowanceFactor * $overallEstModuleSize); + $alignmentAreaLeftX = max(0, $estAlignmentX - $allowance); + $alignmentAreaRightX = min($dimension - 1, $estAlignmentX + $allowance); + + if($alignmentAreaRightX - $alignmentAreaLeftX < $overallEstModuleSize * 3){ + return null; + } + + $alignmentAreaTopY = max(0, $estAlignmentY - $allowance); + $alignmentAreaBottomY = min($dimension - 1, $estAlignmentY + $allowance); + + if($alignmentAreaBottomY - $alignmentAreaTopY < $overallEstModuleSize * 3){ + return null; + } + + return (new AlignmentPatternFinder($this->bitMatrix, $overallEstModuleSize))->find( + $alignmentAreaLeftX, + $alignmentAreaTopY, + $alignmentAreaRightX - $alignmentAreaLeftX, + $alignmentAreaBottomY - $alignmentAreaTopY, + ); + } + + /** + * + */ + private function createTransform( + FinderPattern $topLeft, + FinderPattern $topRight, + FinderPattern $bottomLeft, + int $dimension, + AlignmentPattern $alignmentPattern = null + ):PerspectiveTransform{ + $dimMinusThree = (float)$dimension - 3.5; + + if($alignmentPattern instanceof AlignmentPattern){ + $bottomRightX = $alignmentPattern->getX(); + $bottomRightY = $alignmentPattern->getY(); + $sourceBottomRightX = $dimMinusThree - 3.0; + $sourceBottomRightY = $sourceBottomRightX; + } + else{ + // Don't have an alignment pattern, just make up the bottom-right point + $bottomRightX = ($topRight->getX() - $topLeft->getX()) + $bottomLeft->getX(); + $bottomRightY = ($topRight->getY() - $topLeft->getY()) + $bottomLeft->getY(); + $sourceBottomRightX = $dimMinusThree; + $sourceBottomRightY = $dimMinusThree; + } + + return PerspectiveTransform::quadrilateralToQuadrilateral( + 3.5, 3.5, + $dimMinusThree, 3.5, + $sourceBottomRightX, $sourceBottomRightY, + 3.5, $dimMinusThree, + $topLeft->getX(), $topLeft->getY(), + $topRight->getX(), $topRight->getY(), + $bottomRightX, $bottomRightY, + $bottomLeft->getX(), $bottomLeft->getY() + ); + } + +} diff --git a/src/Detector/FinderPattern.php b/src/Detector/FinderPattern.php new file mode 100644 index 000000000..e522d6cfd --- /dev/null +++ b/src/Detector/FinderPattern.php @@ -0,0 +1,69 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Detector; + +use function chillerlan\QRCode\Common\{distance, squaredDistance}; + +/** + *Encapsulates a finder pattern, which are the three square patterns found in + * the corners of QR Codes. It also encapsulates a count of similar finder patterns, + * as a convenience to the finder's bookkeeping.
+ * + * @author Sean Owen + */ +final class FinderPattern extends ResultPoint{ + + private int $count; + + public function __construct(float $posX, float $posY, float $estimatedModuleSize, int $count = 1){ + parent::__construct($posX, $posY, $estimatedModuleSize); + + $this->count = $count; + } + + public function getCount():int{ + return $this->count; + } + + /** + * @param \chillerlan\QRCode\Detector\FinderPattern $b second pattern + * + * @return float distance between two points + */ + public function distance(FinderPattern $b):float{ + return distance($this->getX(), $this->getY(), $b->getX(), $b->getY()); + } + + /** + * Get square of distance between a and b. + */ + public function squaredDistance(FinderPattern $b):float{ + return squaredDistance($this->getX(), $this->getY(), $b->getX(), $b->getY()); + } + + /** + * Combines this object's current estimate of a finder pattern position and module size + * with a new estimate. It returns a new {@code FinderPattern} containing a weighted average + * based on count. + */ + public function combineEstimate(float $i, float $j, float $newModuleSize):FinderPattern{ + $combinedCount = $this->count + 1; + + return new self( + ($this->count * $this->x + $j) / $combinedCount, + ($this->count * $this->y + $i) / $combinedCount, + ($this->count * $this->estimatedModuleSize + $newModuleSize) / $combinedCount, + $combinedCount + ); + } + +} diff --git a/src/Detector/FinderPatternFinder.php b/src/Detector/FinderPatternFinder.php new file mode 100644 index 000000000..374688931 --- /dev/null +++ b/src/Detector/FinderPatternFinder.php @@ -0,0 +1,775 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + * + * @phan-file-suppress PhanTypePossiblyInvalidDimOffset + */ + +namespace chillerlan\QRCode\Detector; + +use RuntimeException; +use chillerlan\QRCode\Decoder\BitMatrix; +use function abs, count, usort; +use const PHP_FLOAT_MAX; + +/** + *This class attempts to find finder patterns in a QR Code. Finder patterns are the square + * markers at three corners of a QR Code.
+ * + *This class is thread-safe but not reentrant. Each thread must allocate its own object. + * + * @author Sean Owen + */ +final class FinderPatternFinder{ + + private const MIN_SKIP = 2; + private const MAX_MODULES = 177; // 1 pixel/module times 3 modules/center + private const CENTER_QUORUM = 2; // support up to version 10 for mobile clients + private BitMatrix $bitMatrix; + /** @var \chillerlan\QRCode\Detector\FinderPattern[] */ + private array $possibleCenters; + private bool $hasSkipped = false; + /** @var int[] */ + private array $crossCheckStateCount; + + /** + *
Creates a finder that will search the image for three finder patterns.
+ * + * @param BitMatrix $bitMatrix image to search + */ + public function __construct(BitMatrix $bitMatrix){ + $this->bitMatrix = $bitMatrix; + $this->possibleCenters = []; + $this->crossCheckStateCount = $this->getCrossCheckStateCount(); + } + + /** + * @return \chillerlan\QRCode\Detector\FinderPattern[] + */ + public function find():array{ + $dimension = $this->bitMatrix->getDimension(); + + // We are looking for black/white/black/white/black modules in + // 1:1:3:1:1 ratio; this tracks the number of such modules seen so far + // Let's assume that the maximum version QR Code we support takes up 1/4 the height of the + // image, and then account for the center being 3 modules in size. This gives the smallest + // number of pixels the center could be, so skip this often. + $iSkip = (int)((3 * $dimension) / (4 * self::MAX_MODULES)); + + if($iSkip < self::MIN_SKIP){ + $iSkip = self::MIN_SKIP; + } + + $done = false; + + for($i = $iSkip - 1; $i < $dimension && !$done; $i += $iSkip){ + // Get a row of black/white values + $stateCount = $this->getCrossCheckStateCount(); + $currentState = 0; + + for($j = 0; $j < $dimension; $j++){ + + // Black pixel + if($this->bitMatrix->get($j, $i)){ + // Counting white pixels + if(($currentState & 1) === 1){ + $currentState++; + } + + $stateCount[$currentState]++; + } + // White pixel + else{ + // Counting black pixels + if(($currentState & 1) === 0){ + // A winner? + if($currentState === 4){ + // Yes + if($this->foundPatternCross($stateCount)){ + $confirmed = $this->handlePossibleCenter($stateCount, $i, $j); + + if($confirmed){ + // Start examining every other line. Checking each line turned out to be too + // expensive and didn't improve performance. + $iSkip = 3; + + if($this->hasSkipped){ + $done = $this->haveMultiplyConfirmedCenters(); + } + else{ + $rowSkip = $this->findRowSkip(); + + if($rowSkip > $stateCount[2]){ + // Skip rows between row of lower confirmed center + // and top of presumed third confirmed center + // but back up a bit to get a full chance of detecting + // it, entire width of center of finder pattern + + // Skip by rowSkip, but back off by $stateCount[2] (size of last center + // of pattern we saw) to be conservative, and also back off by iSkip which + // is about to be re-added + $i += $rowSkip - $stateCount[2] - $iSkip; + $j = $dimension - 1; + } + } + } + else{ + $stateCount = $this->doShiftCounts2($stateCount); + $currentState = 3; + + continue; + } + // Clear state to start looking again + $currentState = 0; + $stateCount = $this->getCrossCheckStateCount(); + } + // No, shift counts back by two + else{ + $stateCount = $this->doShiftCounts2($stateCount); + $currentState = 3; + } + } + else{ + $stateCount[++$currentState]++; + } + } + // Counting white pixels + else{ + $stateCount[$currentState]++; + } + } + } + + if($this->foundPatternCross($stateCount)){ + $confirmed = $this->handlePossibleCenter($stateCount, $i, $dimension); + + if($confirmed){ + $iSkip = $stateCount[0]; + + if($this->hasSkipped){ + // Found a third one + $done = $this->haveMultiplyConfirmedCenters(); + } + } + } + } + + return $this->orderBestPatterns($this->selectBestPatterns()); + } + + /** + * @return int[] + */ + private function getCrossCheckStateCount():array{ + return [0, 0, 0, 0, 0]; + } + + /** + * @param int[] $stateCount + * + * @return int[] + */ + private function doShiftCounts2(array $stateCount):array{ + $stateCount[0] = $stateCount[2]; + $stateCount[1] = $stateCount[3]; + $stateCount[2] = $stateCount[4]; + $stateCount[3] = 1; + $stateCount[4] = 0; + + return $stateCount; + } + + /** + * Given a count of black/white/black/white/black pixels just seen and an end position, + * figures the location of the center of this run. + * + * @param int[] $stateCount + * + * @return float + */ + private function centerFromEnd(array $stateCount, int $end):float{ + return (float)(($end - $stateCount[4] - $stateCount[3]) - $stateCount[2] / 2.0); + } + + /** + * @param int[] $stateCount + * + * @return bool + */ + private function foundPatternCross(array $stateCount):bool{ + // Allow less than 50% variance from 1-1-3-1-1 proportions + return $this->foundPatternVariance($stateCount, 2.0); + } + + /** + * @param int[] $stateCount + * + * @return bool + */ + private function foundPatternDiagonal(array $stateCount):bool{ + // Allow less than 75% variance from 1-1-3-1-1 proportions + return $this->foundPatternVariance($stateCount, 1.333); + } + + /** + * @param int[] $stateCount count of black/white/black/white/black pixels just read + * + * @return bool true if the proportions of the counts is close enough to the 1/1/3/1/1 ratios + * used by finder patterns to be considered a match + */ + private function foundPatternVariance(array $stateCount, float $variance):bool{ + $totalModuleSize = 0; + + for($i = 0; $i < 5; $i++){ + $count = $stateCount[$i]; + + if($count === 0){ + return false; + } + + $totalModuleSize += $count; + } + + if($totalModuleSize < 7){ + return false; + } + + $moduleSize = $totalModuleSize / 7.0; + $maxVariance = $moduleSize / $variance; + + return + abs($moduleSize - $stateCount[0]) < $maxVariance + && abs($moduleSize - $stateCount[1]) < $maxVariance + && abs(3.0 * $moduleSize - $stateCount[2]) < 3 * $maxVariance + && abs($moduleSize - $stateCount[3]) < $maxVariance + && abs($moduleSize - $stateCount[4]) < $maxVariance; + } + + /** + * After a vertical and horizontal scan finds a potential finder pattern, this method + * "cross-cross-cross-checks" by scanning down diagonally through the center of the possible + * finder pattern to see if the same proportion is detected. + * + * @param $centerI ; row where a finder pattern was detected + * @param $centerJ ; center of the section that appears to cross a finder pattern + * + * @return bool true if proportions are withing expected limits + */ + private function crossCheckDiagonal(int $centerI, int $centerJ):bool{ + $stateCount = $this->getCrossCheckStateCount(); + + // Start counting up, left from center finding black center mass + $i = 0; + + while($centerI >= $i && $centerJ >= $i && $this->bitMatrix->get($centerJ - $i, $centerI - $i)){ + $stateCount[2]++; + $i++; + } + + if($stateCount[2] === 0){ + return false; + } + + // Continue up, left finding white space + while($centerI >= $i && $centerJ >= $i && !$this->bitMatrix->get($centerJ - $i, $centerI - $i)){ + $stateCount[1]++; + $i++; + } + + if($stateCount[1] === 0){ + return false; + } + + // Continue up, left finding black border + while($centerI >= $i && $centerJ >= $i && $this->bitMatrix->get($centerJ - $i, $centerI - $i)){ + $stateCount[0]++; + $i++; + } + + if($stateCount[0] === 0){ + return false; + } + + $dimension = $this->bitMatrix->getDimension(); + + // Now also count down, right from center + $i = 1; + while($centerI + $i < $dimension && $centerJ + $i < $dimension && $this->bitMatrix->get($centerJ + $i, $centerI + $i)){ + $stateCount[2]++; + $i++; + } + + while($centerI + $i < $dimension && $centerJ + $i < $dimension && !$this->bitMatrix->get($centerJ + $i, $centerI + $i)){ + $stateCount[3]++; + $i++; + } + + if($stateCount[3] === 0){ + return false; + } + + while($centerI + $i < $dimension && $centerJ + $i < $dimension && $this->bitMatrix->get($centerJ + $i, $centerI + $i)){ + $stateCount[4]++; + $i++; + } + + if($stateCount[4] === 0){ + return false; + } + + return $this->foundPatternDiagonal($stateCount); + } + + /** + *After a horizontal scan finds a potential finder pattern, this method + * "cross-checks" by scanning down vertically through the center of the possible + * finder pattern to see if the same proportion is detected.
+ * + * @param int $startI ; row where a finder pattern was detected + * @param int $centerJ ; center of the section that appears to cross a finder pattern + * @param int $maxCount ; maximum reasonable number of modules that should be + * observed in any reading state, based on the results of the horizontal scan + * @param int $originalStateCountTotal + * + * @return float|null vertical center of finder pattern, or null if not found + */ + private function crossCheckVertical(int $startI, int $centerJ, int $maxCount, int $originalStateCountTotal):?float{ + $maxI = $this->bitMatrix->getDimension(); + $stateCount = $this->getCrossCheckStateCount(); + + // Start counting up from center + $i = $startI; + while($i >= 0 && $this->bitMatrix->get($centerJ, $i)){ + $stateCount[2]++; + $i--; + } + + if($i < 0){ + return null; + } + + while($i >= 0 && !$this->bitMatrix->get($centerJ, $i) && $stateCount[1] <= $maxCount){ + $stateCount[1]++; + $i--; + } + + // If already too many modules in this state or ran off the edge: + if($i < 0 || $stateCount[1] > $maxCount){ + return null; + } + + while($i >= 0 && $this->bitMatrix->get($centerJ, $i) && $stateCount[0] <= $maxCount){ + $stateCount[0]++; + $i--; + } + + if($stateCount[0] > $maxCount){ + return null; + } + + // Now also count down from center + $i = $startI + 1; + while($i < $maxI && $this->bitMatrix->get($centerJ, $i)){ + $stateCount[2]++; + $i++; + } + + if($i === $maxI){ + return null; + } + + while($i < $maxI && !$this->bitMatrix->get($centerJ, $i) && $stateCount[3] < $maxCount){ + $stateCount[3]++; + $i++; + } + + if($i === $maxI || $stateCount[3] >= $maxCount){ + return null; + } + + while($i < $maxI && $this->bitMatrix->get($centerJ, $i) && $stateCount[4] < $maxCount){ + $stateCount[4]++; + $i++; + } + + if($stateCount[4] >= $maxCount){ + return null; + } + + // If we found a finder-pattern-like section, but its size is more than 40% different than + // the original, assume it's a false positive + $stateCountTotal = $stateCount[0] + $stateCount[1] + $stateCount[2] + $stateCount[3] + $stateCount[4]; + + if(5 * abs($stateCountTotal - $originalStateCountTotal) >= 2 * $originalStateCountTotal){ + return null; + } + + if(!$this->foundPatternCross($stateCount)){ + return null; + } + + return $this->centerFromEnd($stateCount, $i); + } + + /** + *Like {@link #crossCheckVertical(int, int, int, int)}, and in fact is basically identical, + * except it reads horizontally instead of vertically. This is used to cross-cross + * check a vertical cross check and locate the real center of the alignment pattern.
+ */ + private function crossCheckHorizontal(int $startJ, int $centerI, int $maxCount, int $originalStateCountTotal):?float{ + $maxJ = $this->bitMatrix->getDimension(); + $stateCount = $this->getCrossCheckStateCount(); + + $j = $startJ; + while($j >= 0 && $this->bitMatrix->get($j, $centerI)){ + $stateCount[2]++; + $j--; + } + + if($j < 0){ + return null; + } + + while($j >= 0 && !$this->bitMatrix->get($j, $centerI) && $stateCount[1] <= $maxCount){ + $stateCount[1]++; + $j--; + } + + if($j < 0 || $stateCount[1] > $maxCount){ + return null; + } + + while($j >= 0 && $this->bitMatrix->get($j, $centerI) && $stateCount[0] <= $maxCount){ + $stateCount[0]++; + $j--; + } + + if($stateCount[0] > $maxCount){ + return null; + } + + $j = $startJ + 1; + while($j < $maxJ && $this->bitMatrix->get($j, $centerI)){ + $stateCount[2]++; + $j++; + } + + if($j === $maxJ){ + return null; + } + + while($j < $maxJ && !$this->bitMatrix->get($j, $centerI) && $stateCount[3] < $maxCount){ + $stateCount[3]++; + $j++; + } + + if($j === $maxJ || $stateCount[3] >= $maxCount){ + return null; + } + + while($j < $maxJ && $this->bitMatrix->get($j, $centerI) && $stateCount[4] < $maxCount){ + $stateCount[4]++; + $j++; + } + + if($stateCount[4] >= $maxCount){ + return null; + } + + // If we found a finder-pattern-like section, but its size is significantly different than + // the original, assume it's a false positive + $stateCountTotal = $stateCount[0] + $stateCount[1] + $stateCount[2] + $stateCount[3] + $stateCount[4]; + + if(5 * abs($stateCountTotal - $originalStateCountTotal) >= $originalStateCountTotal){ + return null; + } + + if(!$this->foundPatternCross($stateCount)){ + return null; + } + + return $this->centerFromEnd($stateCount, $j); + } + + /** + *This is called when a horizontal scan finds a possible alignment pattern. It will + * cross check with a vertical scan, and if successful, will, ah, cross-cross-check + * with another horizontal scan. This is needed primarily to locate the real horizontal + * center of the pattern in cases of extreme skew. + * And then we cross-cross-cross check with another diagonal scan.
+ * + *If that succeeds the finder pattern location is added to a list that tracks + * the number of times each location has been nearly-matched as a finder pattern. + * Each additional find is more evidence that the location is in fact a finder + * pattern center + * + * @param int[] $stateCount reading state module counts from horizontal scan + * @param int $i row where finder pattern may be found + * @param int $j end of possible finder pattern in row + * + * @return bool if a finder pattern candidate was found this time + */ + private function handlePossibleCenter(array $stateCount, int $i, int $j):bool{ + $stateCountTotal = $stateCount[0] + $stateCount[1] + $stateCount[2] + $stateCount[3] + $stateCount[4]; + $centerJ = $this->centerFromEnd($stateCount, $j); + $centerI = $this->crossCheckVertical($i, (int)$centerJ, $stateCount[2], $stateCountTotal); + + if($centerI !== null){ + // Re-cross check + $centerJ = $this->crossCheckHorizontal((int)$centerJ, (int)$centerI, $stateCount[2], $stateCountTotal); + if($centerJ !== null && ($this->crossCheckDiagonal((int)$centerI, (int)$centerJ))){ + $estimatedModuleSize = $stateCountTotal / 7.0; + $found = false; + + for($index = 0; $index < count($this->possibleCenters); $index++){ + $center = $this->possibleCenters[$index]; + // Look for about the same center and module size: + if($center->aboutEquals($estimatedModuleSize, $centerI, $centerJ)){ + $this->possibleCenters[$index] = $center->combineEstimate($centerI, $centerJ, $estimatedModuleSize); + $found = true; + break; + } + } + + if(!$found){ + $point = new FinderPattern($centerJ, $centerI, $estimatedModuleSize); + $this->possibleCenters[] = $point; + } + + return true; + } + } + + return false; + } + + /** + * @return int number of rows we could safely skip during scanning, based on the first + * two finder patterns that have been located. In some cases their position will + * allow us to infer that the third pattern must lie below a certain point farther + * down in the image. + */ + private function findRowSkip():int{ + $max = count($this->possibleCenters); + + if($max <= 1){ + return 0; + } + + $firstConfirmedCenter = null; + + foreach($this->possibleCenters as $center){ + + if($center->getCount() >= self::CENTER_QUORUM){ + + if($firstConfirmedCenter === null){ + $firstConfirmedCenter = $center; + } + else{ + // We have two confirmed centers + // How far down can we skip before resuming looking for the next + // pattern? In the worst case, only the difference between the + // difference in the x / y coordinates of the two centers. + // This is the case where you find top left last. + $this->hasSkipped = true; + + return (int)((abs($firstConfirmedCenter->getX() - $center->getX()) - + abs($firstConfirmedCenter->getY() - $center->getY())) / 2); + } + } + } + + return 0; + } + + /** + * @return bool true if we have found at least 3 finder patterns that have been detected + * at least {@link #CENTER_QUORUM} times each, and, the estimated module size of the + * candidates is "pretty similar" + */ + private function haveMultiplyConfirmedCenters():bool{ + $confirmedCount = 0; + $totalModuleSize = 0.0; + $max = count($this->possibleCenters); + + foreach($this->possibleCenters as $pattern){ + if($pattern->getCount() >= self::CENTER_QUORUM){ + $confirmedCount++; + $totalModuleSize += $pattern->getEstimatedModuleSize(); + } + } + + if($confirmedCount < 3){ + return false; + } + // OK, we have at least 3 confirmed centers, but, it's possible that one is a "false positive" + // and that we need to keep looking. We detect this by asking if the estimated module sizes + // vary too much. We arbitrarily say that when the total deviation from average exceeds + // 5% of the total module size estimates, it's too much. + $average = $totalModuleSize / (float)$max; + $totalDeviation = 0.0; + + foreach($this->possibleCenters as $pattern){ + $totalDeviation += abs($pattern->getEstimatedModuleSize() - $average); + } + + return $totalDeviation <= 0.05 * $totalModuleSize; + } + + /** + * @return \chillerlan\QRCode\Detector\FinderPattern[] the 3 best {@link FinderPattern}s from our list of candidates. The "best" are + * those that have been detected at least {@link #CENTER_QUORUM} times, and whose module + * size differs from the average among those patterns the least + * @throws \RuntimeException if 3 such finder patterns do not exist + */ + private function selectBestPatterns():array{ + $startSize = count($this->possibleCenters); + + if($startSize < 3){ + throw new RuntimeException('could not find enough finder patterns'); + } + + usort( + $this->possibleCenters, + fn(FinderPattern $a, FinderPattern $b) => $a->getEstimatedModuleSize() <=> $b->getEstimatedModuleSize() + ); + + $distortion = PHP_FLOAT_MAX; + $bestPatterns = []; + + for($i = 0; $i < $startSize - 2; $i++){ + $fpi = $this->possibleCenters[$i]; + $minModuleSize = $fpi->getEstimatedModuleSize(); + + for($j = $i + 1; $j < $startSize - 1; $j++){ + $fpj = $this->possibleCenters[$j]; + $squares0 = $fpi->squaredDistance($fpj); + + for($k = $j + 1; $k < $startSize; $k++){ + $fpk = $this->possibleCenters[$k]; + $maxModuleSize = $fpk->getEstimatedModuleSize(); + + // module size is not similar + if($maxModuleSize > $minModuleSize * 1.4){ + continue; + } + + $a = $squares0; + $b = $fpj->squaredDistance($fpk); + $c = $fpi->squaredDistance($fpk); + + // sorts ascending - inlined + if($a < $b){ + if($b > $c){ + if($a < $c){ + $temp = $b; + $b = $c; + $c = $temp; + } + else{ + $temp = $a; + $a = $c; + $c = $b; + $b = $temp; + } + } + } + else{ + if($b < $c){ + if($a < $c){ + $temp = $a; + $a = $b; + $b = $temp; + } + else{ + $temp = $a; + $a = $b; + $b = $c; + $c = $temp; + } + } + else{ + $temp = $a; + $a = $c; + $c = $temp; + } + } + + // a^2 + b^2 = c^2 (Pythagorean theorem), and a = b (isosceles triangle). + // Since any right triangle satisfies the formula c^2 - b^2 - a^2 = 0, + // we need to check both two equal sides separately. + // The value of |c^2 - 2 * b^2| + |c^2 - 2 * a^2| increases as dissimilarity + // from isosceles right triangle. + $d = abs($c - 2 * $b) + abs($c - 2 * $a); + + if($d < $distortion){ + $distortion = $d; + $bestPatterns = [$fpi, $fpj, $fpk]; + } + } + } + } + + if($distortion === PHP_FLOAT_MAX){ + throw new RuntimeException('finder patterns may be too distorted'); + } + + return $bestPatterns; + } + + /** + * Orders an array of three ResultPoints in an order [A,B,C] such that AB is less than AC + * and BC is less than AC, and the angle between BC and BA is less than 180 degrees. + * + * @param \chillerlan\QRCode\Detector\FinderPattern[] $patterns array of three FinderPattern to order + * + * @return \chillerlan\QRCode\Detector\FinderPattern[] + */ + private function orderBestPatterns(array $patterns):array{ + + // Find distances between pattern centers + $zeroOneDistance = $patterns[0]->distance($patterns[1]); + $oneTwoDistance = $patterns[1]->distance($patterns[2]); + $zeroTwoDistance = $patterns[0]->distance($patterns[2]); + + // Assume one closest to other two is B; A and C will just be guesses at first + if($oneTwoDistance >= $zeroOneDistance && $oneTwoDistance >= $zeroTwoDistance){ + [$pointB, $pointA, $pointC] = $patterns; + } + elseif($zeroTwoDistance >= $oneTwoDistance && $zeroTwoDistance >= $zeroOneDistance){ + [$pointA, $pointB, $pointC] = $patterns; + } + else{ + [$pointA, $pointC, $pointB] = $patterns; + } + + // Use cross product to figure out whether A and C are correct or flipped. + // This asks whether BC x BA has a positive z component, which is the arrangement + // we want for A, B, C. If it's negative, then we've got it flipped around and + // should swap A and C. + if($this->crossProductZ($pointA, $pointB, $pointC) < 0.0){ + $temp = $pointA; + $pointA = $pointC; + $pointC = $temp; + } + + return [$pointA, $pointB, $pointC]; + } + + /** + * Returns the z component of the cross product between vectors BC and BA. + */ + private function crossProductZ(FinderPattern $pointA, FinderPattern $pointB, FinderPattern $pointC):float{ + $bX = $pointB->getX(); + $bY = $pointB->getY(); + + return (($pointC->getX() - $bX) * ($pointA->getY() - $bY)) - (($pointC->getY() - $bY) * ($pointA->getX() - $bX)); + } + +} diff --git a/src/Detector/GridSampler.php b/src/Detector/GridSampler.php new file mode 100644 index 000000000..7f03c9436 --- /dev/null +++ b/src/Detector/GridSampler.php @@ -0,0 +1,171 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Detector; + +use Exception, RuntimeException; +use chillerlan\QRCode\Decoder\BitMatrix; +use function array_fill, count, sprintf; + +/** + * Implementations of this class can, given locations of finder patterns for a QR code in an + * image, sample the right points in the image to reconstruct the QR code, accounting for + * perspective distortion. It is abstracted since it is relatively expensive and should be allowed + * to take advantage of platform-specific optimized implementations, like Sun's Java Advanced + * Imaging library, but which may not be available in other environments such as J2ME, and vice + * versa. + * + * The implementation used can be controlled by calling {@link #setGridSampler(GridSampler)} + * with an instance of a class which implements this interface. + * + * @author Sean Owen + */ +final class GridSampler{ + + /** + *
Checks a set of points that have been transformed to sample points on an image against + * the image's dimensions to see if the point are even within the image.
+ * + *This method will actually "nudge" the endpoints back onto the image if they are found to be + * barely (less than 1 pixel) off the image. This accounts for imperfect detection of finder + * patterns in an image where the QR Code runs all the way to the image border.
+ * + *For efficiency, the method will check points from either end of the line until one is found + * to be within the image. Because the set of points are assumed to be linear, this is valid.
+ * + * @param \chillerlan\QRCode\Decoder\BitMatrix $bitMatrix image into which the points should map + * @param float[] $points actual points in x1,y1,...,xn,yn form + * + * @throws \RuntimeException if an endpoint is lies outside the image boundaries + */ + private function checkAndNudgePoints(BitMatrix $bitMatrix, array $points):void{ + $dimension = $bitMatrix->getDimension(); + $nudged = true; + $max = count($points); + + // Check and nudge points from start until we see some that are OK: + for($offset = 0; $offset < $max && $nudged; $offset += 2){ + $x = (int)$points[$offset]; + $y = (int)$points[$offset + 1]; + + if($x < -1 || $x > $dimension || $y < -1 || $y > $dimension){ + throw new RuntimeException(sprintf('checkAndNudgePoints 1, x: %s, y: %s, d: %s', $x, $y, $dimension)); + } + + $nudged = false; + + if($x === -1){ + $points[$offset] = 0.0; + $nudged = true; + } + elseif($x === $dimension){ + $points[$offset] = $dimension - 1; + $nudged = true; + } + if($y === -1){ + $points[$offset + 1] = 0.0; + $nudged = true; + } + elseif($y === $dimension){ + $points[$offset + 1] = $dimension - 1; + $nudged = true; + } + } + // Check and nudge points from end: + $nudged = true; + + for($offset = count($points) - 2; $offset >= 0 && $nudged; $offset -= 2){ + $x = (int)$points[$offset]; + $y = (int)$points[$offset + 1]; + + if($x < -1 || $x > $dimension || $y < -1 || $y > $dimension){ + throw new RuntimeException(sprintf('checkAndNudgePoints 2, x: %s, y: %s, d: %s', $x, $y, $dimension)); + } + + $nudged = false; + + if($x === -1){ + $points[$offset] = 0.0; + $nudged = true; + } + elseif($x === $dimension){ + $points[$offset] = $dimension - 1; + $nudged = true; + } + if($y === -1){ + $points[$offset + 1] = 0.0; + $nudged = true; + } + elseif($y === $dimension){ + $points[$offset + 1] = $dimension - 1; + $nudged = true; + } + } + } + + /** + * Samples an image for a rectangular matrix of bits of the given dimension. The sampling + * transformation is determined by the coordinates of 4 points, in the original and transformed + * image space. + * + * @return \chillerlan\QRCode\Decoder\BitMatrix representing a grid of points sampled from the image within a region + * defined by the "from" parameters + * @throws \RuntimeException if image can't be sampled, for example, if the transformation defined + * by the given points is invalid or results in sampling outside the image boundaries + */ + public function sampleGrid(BitMatrix $image, int $dimension, PerspectiveTransform $transform):BitMatrix{ + + if($dimension <= 0){ + throw new RuntimeException('invalid matrix size'); + } + + $bits = new BitMatrix($dimension); + $points = array_fill(0, 2 * $dimension, 0.0); + + for($y = 0; $y < $dimension; $y++){ + $max = count($points); + $iValue = (float)$y + 0.5; + + for($x = 0; $x < $max; $x += 2){ + $points[$x] = (float)($x / 2) + 0.5; + $points[$x + 1] = $iValue; + } + + $transform->transformPoints($points); + // Quick check to see if points transformed to something inside the image; + // sufficient to check the endpoints + $this->checkAndNudgePoints($image, $points); + + try{ + for($x = 0; $x < $max; $x += 2){ + if($image->get((int)$points[$x], (int)$points[$x + 1])){ + // Black(-ish) pixel + $bits->set($x / 2, $y); + } + } + } + catch(Exception $aioobe){//ArrayIndexOutOfBoundsException + // This feels wrong, but, sometimes if the finder patterns are misidentified, the resulting + // transform gets "twisted" such that it maps a straight line of points to a set of points + // whose endpoints are in bounds, but others are not. There is probably some mathematical + // way to detect this about the transformation that I don't know yet. + // This results in an ugly runtime exception despite our clever checks above -- can't have + // that. We could check each point's coordinates but that feels duplicative. We settle for + // catching and wrapping ArrayIndexOutOfBoundsException. + throw new RuntimeException('ArrayIndexOutOfBoundsException'); + } + + } + + return $bits; + } + +} diff --git a/src/Detector/PerspectiveTransform.php b/src/Detector/PerspectiveTransform.php new file mode 100644 index 000000000..cabce5911 --- /dev/null +++ b/src/Detector/PerspectiveTransform.php @@ -0,0 +1,152 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Detector; + +use function count; + +/** + *This class implements a perspective transform in two dimensions. Given four source and four + * destination points, it will compute the transformation implied between them. The code is based + * directly upon section 3.4.2 of George Wolberg's "Digital Image Warping"; see pages 54-56.
+ * + * @author Sean Owen + */ +final class PerspectiveTransform{ + + private float $a11; + private float $a12; + private float $a13; + private float $a21; + private float $a22; + private float $a23; + private float $a31; + private float $a32; + private float $a33; + + private function __construct( + float $a11, float $a21, float $a31, + float $a12, float $a22, float $a32, + float $a13, float $a23, float $a33 + ){ + $this->a11 = $a11; + $this->a12 = $a12; + $this->a13 = $a13; + $this->a21 = $a21; + $this->a22 = $a22; + $this->a23 = $a23; + $this->a31 = $a31; + $this->a32 = $a32; + $this->a33 = $a33; + } + + public static function quadrilateralToQuadrilateral( + float $x0, float $y0, float $x1, float $y1, float $x2, float $y2, float $x3, float $y3, + float $x0p, float $y0p, float $x1p, float $y1p, float $x2p, float $y2p, float $x3p, float $y3p + ):PerspectiveTransform{ + + $qToS = self::quadrilateralToSquare($x0, $y0, $x1, $y1, $x2, $y2, $x3, $y3); + $sToQ = self::squareToQuadrilateral($x0p, $y0p, $x1p, $y1p, $x2p, $y2p, $x3p, $y3p); + + return $sToQ->times($qToS); + } + + public static function quadrilateralToSquare( + float $x0, float $y0, float $x1, float $y1, + float $x2, float $y2, float $x3, float $y3 + ):PerspectiveTransform{ + // Here, the adjoint serves as the inverse: + return self::squareToQuadrilateral($x0, $y0, $x1, $y1, $x2, $y2, $x3, $y3)->buildAdjoint(); + } + + public function buildAdjoint():PerspectiveTransform{ + // Adjoint is the transpose of the cofactor matrix: + return new self( + $this->a22 * $this->a33 - $this->a23 * $this->a32, + $this->a23 * $this->a31 - $this->a21 * $this->a33, + $this->a21 * $this->a32 - $this->a22 * $this->a31, + $this->a13 * $this->a32 - $this->a12 * $this->a33, + $this->a11 * $this->a33 - $this->a13 * $this->a31, + $this->a12 * $this->a31 - $this->a11 * $this->a32, + $this->a12 * $this->a23 - $this->a13 * $this->a22, + $this->a13 * $this->a21 - $this->a11 * $this->a23, + $this->a11 * $this->a22 - $this->a12 * $this->a21 + ); + } + + public static function squareToQuadrilateral( + float $x0, float $y0, float $x1, float $y1, + float $x2, float $y2, float $x3, float $y3 + ):PerspectiveTransform{ + $dx3 = $x0 - $x1 + $x2 - $x3; + $dy3 = $y0 - $y1 + $y2 - $y3; + + if($dx3 === 0.0 && $dy3 === 0.0){ + // Affine + return new self($x1 - $x0, $x2 - $x1, $x0, $y1 - $y0, $y2 - $y1, $y0, 0.0, 0.0, 1.0); + } + else{ + $dx1 = $x1 - $x2; + $dx2 = $x3 - $x2; + $dy1 = $y1 - $y2; + $dy2 = $y3 - $y2; + $denominator = $dx1 * $dy2 - $dx2 * $dy1; + $a13 = ($dx3 * $dy2 - $dx2 * $dy3) / $denominator; + $a23 = ($dx1 * $dy3 - $dx3 * $dy1) / $denominator; + + return new self( + $x1 - $x0 + $a13 * $x1, $x3 - $x0 + $a23 * $x3, $x0, + $y1 - $y0 + $a13 * $y1, $y3 - $y0 + $a23 * $y3, $y0, + $a13, $a23, 1.0 + ); + } + } + + public function times(PerspectiveTransform $other):PerspectiveTransform{ + return new self( + $this->a11 * $other->a11 + $this->a21 * $other->a12 + $this->a31 * $other->a13, + $this->a11 * $other->a21 + $this->a21 * $other->a22 + $this->a31 * $other->a23, + $this->a11 * $other->a31 + $this->a21 * $other->a32 + $this->a31 * $other->a33, + $this->a12 * $other->a11 + $this->a22 * $other->a12 + $this->a32 * $other->a13, + $this->a12 * $other->a21 + $this->a22 * $other->a22 + $this->a32 * $other->a23, + $this->a12 * $other->a31 + $this->a22 * $other->a32 + $this->a32 * $other->a33, + $this->a13 * $other->a11 + $this->a23 * $other->a12 + $this->a33 * $other->a13, + $this->a13 * $other->a21 + $this->a23 * $other->a22 + $this->a33 * $other->a23, + $this->a13 * $other->a31 + $this->a23 * $other->a32 + $this->a33 * $other->a33 + ); + } + + public function transformPoints(array &$xValues, array &$yValues = null):void{ + $max = count($xValues); + + if($yValues !== null){ + + for($i = 0; $i < $max; $i++){ + $x = $xValues[$i]; + $y = $yValues[$i]; + $denominator = $this->a13 * $x + $this->a23 * $y + $this->a33; + $xValues[$i] = ($this->a11 * $x + $this->a21 * $y + $this->a31) / $denominator; + $yValues[$i] = ($this->a12 * $x + $this->a22 * $y + $this->a32) / $denominator; + } + + return; + } + + for($i = 0; $i < $max; $i += 2){ + $x = $xValues[$i]; + $y = $xValues[$i + 1]; + $denominator = $this->a13 * $x + $this->a23 * $y + $this->a33; + $xValues[$i] = ($this->a11 * $x + $this->a21 * $y + $this->a31) / $denominator; + $xValues[$i + 1] = ($this->a12 * $x + $this->a22 * $y + $this->a32) / $denominator; + } + } + +} diff --git a/src/Detector/ResultPoint.php b/src/Detector/ResultPoint.php new file mode 100644 index 000000000..f57dbaf25 --- /dev/null +++ b/src/Detector/ResultPoint.php @@ -0,0 +1,61 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + */ + +namespace chillerlan\QRCode\Detector; + +use function abs; + +/** + *Encapsulates a point of interest in an image containing a barcode. Typically, this + * would be the location of a finder pattern or the corner of the barcode, for example.
+ * + * @author Sean Owen + */ +abstract class ResultPoint{ + + protected float $x; + protected float $y; + protected float $estimatedModuleSize; + + public function __construct(float $x, float $y, float $estimatedModuleSize){ + $this->x = $x; + $this->y = $y; + $this->estimatedModuleSize = $estimatedModuleSize; + } + + public function getX():float{ + return (float)$this->x; + } + + public function getY():float{ + return (float)$this->y; + } + + public function getEstimatedModuleSize():float{ + return $this->estimatedModuleSize; + } + + /** + *Determines if this finder pattern "about equals" a finder pattern at the stated + * position and size -- meaning, it is at nearly the same center with nearly the same size.
+ */ + public function aboutEquals(float $moduleSize, float $i, float $j):bool{ + + if(abs($i - $this->y) <= $moduleSize && abs($j - $this->x) <= $moduleSize){ + $moduleSizeDiff = abs($moduleSize - $this->estimatedModuleSize); + + return $moduleSizeDiff <= 1.0 || $moduleSizeDiff <= $this->estimatedModuleSize; + } + + return false; + } + +} diff --git a/src/QRCodeReader.php b/src/QRCodeReader.php new file mode 100644 index 000000000..92e53185f --- /dev/null +++ b/src/QRCodeReader.php @@ -0,0 +1,88 @@ + + * @copyright 2021 Smiley + * @license Apache-2.0 + * + * @noinspection PhpComposerExtensionStubsInspection + */ + +namespace chillerlan\QRCode; + +use Imagick, InvalidArgumentException; +use chillerlan\QRCode\Decoder\{Decoder, DecoderResult, GDLuminanceSource, IMagickLuminanceSource}; +use function extension_loaded, file_exists, file_get_contents, imagecreatefromstring, is_file, is_readable; + +final class QRCodeReader{ + + private bool $useImagickIfAvailable; + + public function __construct(bool $useImagickIfAvailable = true){ + $this->useImagickIfAvailable = $useImagickIfAvailable && extension_loaded('imagick'); + } + + /** + * @param \Imagick|\GdImage|resource $im + * + * @return \chillerlan\QRCode\Decoder\DecoderResult + * @phan-suppress PhanUndeclaredTypeParameter (GdImage) + */ + protected function decode($im):DecoderResult{ + + $source = $this->useImagickIfAvailable + ? new IMagickLuminanceSource($im) + : new GDLuminanceSource($im); + + return (new Decoder)->decode($source); + } + + /** + * @param string $imgFilePath + * + * @return \chillerlan\QRCode\Decoder\DecoderResult + */ + public function readFile(string $imgFilePath):DecoderResult{ + + if(!file_exists($imgFilePath) || !is_file($imgFilePath) || !is_readable($imgFilePath)){ + throw new InvalidArgumentException('invalid file: '.$imgFilePath); + } + + $im = $this->useImagickIfAvailable + ? new Imagick($imgFilePath) + : imagecreatefromstring(file_get_contents($imgFilePath)); + + return $this->decode($im); + } + + /** + * @param string $imgBlob + * + * @return \chillerlan\QRCode\Decoder\DecoderResult + */ + public function readBlob(string $imgBlob):DecoderResult{ + + if($this->useImagickIfAvailable){ + $im = new Imagick; + $im->readImageBlob($imgBlob); + } + else{ + $im = imagecreatefromstring($imgBlob); + } + + return $this->decode($im); + } + + /** + * @param \Imagick|\GdImage|resource $imgSource + * + * @return \chillerlan\QRCode\Decoder\DecoderResult + */ + public function readResource($imgSource):DecoderResult{ + return $this->decode($imgSource); + } + +} diff --git a/src/includes.php b/src/includes.php new file mode 100644 index 000000000..0e326fcaa --- /dev/null +++ b/src/includes.php @@ -0,0 +1,16 @@ + + * @copyright 2021 smiley + * @license MIT + */ + +namespace chillerlan\QRCode; + +// @codeCoverageIgnoreStart +if(!\defined('QRCODE_DECODER_INCLUDES')){ + require_once __DIR__.'/Common/functions.php'; +} + +// @codeCoverageIgnoreEnd diff --git a/tests/QRCodeReaderTest.php b/tests/QRCodeReaderTest.php new file mode 100644 index 000000000..2fcf06158 --- /dev/null +++ b/tests/QRCodeReaderTest.php @@ -0,0 +1,121 @@ + + * @copyright 2021 Smiley + * @license MIT + * + * @noinspection PhpComposerExtensionStubsInspection + */ + +namespace chillerlan\QRCodeTest; + +use Exception; +use chillerlan\QRCode\Common\{EccLevel, Mode, Version}; +use chillerlan\QRCode\{QRCode, QROptions, QRCodeReader}; +use PHPUnit\Framework\TestCase; +use function extension_loaded, range, str_repeat, substr; + +/** + * Tests the QR Code reader + */ +class QRCodeReaderTest extends TestCase{ + + // https://www.bobrosslipsum.com/ + protected const loremipsum = 'Just let this happen. We just let this flow right out of our minds. ' + .'Anyone can paint. We touch the canvas, the canvas takes what it wants. From all of us here, ' + .'I want to wish you happy painting and God bless, my friends. A tree cannot be straight if it has a crooked trunk. ' + .'You have to make almighty decisions when you\'re the creator. I guess that would be considered a UFO. ' + .'A big cotton ball in the sky. I\'m gonna add just a tiny little amount of Prussian Blue. ' + .'They say everything looks better with odd numbers of things. But sometimes I put even numbers—just ' + .'to upset the critics. We\'ll lay all these little funky little things in there. '; + + public function qrCodeProvider():array{ + return [ + 'helloworld' => ['hello_world.png', 'Hello world!'], + // covers mirroring + 'mirrored' => ['hello_world_mirrored.png', 'Hello world!'], + // data modes + 'byte' => ['byte.png', 'https://smiley.codes/qrcode/'], + 'numeric' => ['numeric.png', '123456789012345678901234567890'], + 'alphanum' => ['alphanum.png', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 $%*+-./:'], + 'kanji' => ['kanji.png', '茗荷茗荷茗荷茗荷'], + // covers most of ReedSolomonDecoder + 'damaged' => ['damaged.png', 'https://smiley.codes/qrcode/'], + // covers Binarizer::getHistogramBlackMatrix() + 'smol' => ['smol.png', 'https://smiley.codes/qrcode/'], + ]; + } + + /** + * @dataProvider qrCodeProvider + */ + public function testReaderGD(string $img, string $expected):void{ + $reader = new QRCodeReader(false); + + self::assertSame($expected, (string)$reader->readFile(__DIR__.'/qrcodes/'.$img)); + } + + /** + * @dataProvider qrCodeProvider + */ + public function testReaderImagick(string $img, string $expected):void{ + + if(!extension_loaded('imagick')){ + self::markTestSkipped('imagick not installed'); + } + + $reader = new QRCodeReader(true); + + self::assertSame($expected, (string)$reader->readFile(__DIR__.'/qrcodes/'.$img)); + } + + public function dataTestProvider():array{ + $data = []; + $str = str_repeat(self::loremipsum, 5); + + foreach(range(1, 40) as $v){ + $version = new Version($v); + + foreach(EccLevel::MODES as $ecc => $_){ + $eccLevel = new EccLevel($ecc); + + $data['version: '.$version->getVersionNumber().$eccLevel->__toString()] = [ + $version, + $eccLevel, + substr($str, 0, $version->getMaxLengthForMode(Mode::DATA_BYTE, $eccLevel)) + ]; + } + } + + return $data; + } + + /** + * @dataProvider dataTestProvider + */ + public function testReadData(Version $version, EccLevel $ecc, string $expected):void{ + $options = new QROptions; +# $options->imageTransparent = false; + $options->eccLevel = $ecc->getLevel(); + $options->version = $version->getVersionNumber(); + $options->imageBase64 = false; + $options->scale = 1; // what's interesting is that a smaller scale seems to produce less errors??? + + $imagedata = (new QRCode($options))->render($expected); + + try{ + $result = (new QRCodeReader(true))->readBlob($imagedata); + } + catch(Exception $e){ + self::markTestSkipped($version->getVersionNumber().$ecc->__toString().': '.$e->getMessage()); + } + + self::assertSame($expected, $result->getText()); + self::assertSame($version->getVersionNumber(), $result->getVersion()->getVersionNumber()); + self::assertSame($ecc->getLevel(), $result->getEccLevel()->getLevel()); + } + +} diff --git a/tests/qrcodes/alphanum.png b/tests/qrcodes/alphanum.png new file mode 100644 index 000000000..fdcf4f289 Binary files /dev/null and b/tests/qrcodes/alphanum.png differ diff --git a/tests/qrcodes/byte.png b/tests/qrcodes/byte.png new file mode 100644 index 000000000..0e16317ed Binary files /dev/null and b/tests/qrcodes/byte.png differ diff --git a/tests/qrcodes/damaged.png b/tests/qrcodes/damaged.png new file mode 100644 index 000000000..feeb7443e Binary files /dev/null and b/tests/qrcodes/damaged.png differ diff --git a/tests/qrcodes/hello_world.png b/tests/qrcodes/hello_world.png new file mode 100644 index 000000000..3578ea0cc Binary files /dev/null and b/tests/qrcodes/hello_world.png differ diff --git a/tests/qrcodes/hello_world_mirrored.png b/tests/qrcodes/hello_world_mirrored.png new file mode 100644 index 000000000..d871f1dbf Binary files /dev/null and b/tests/qrcodes/hello_world_mirrored.png differ diff --git a/tests/qrcodes/kanji.png b/tests/qrcodes/kanji.png new file mode 100644 index 000000000..1ac7990b2 Binary files /dev/null and b/tests/qrcodes/kanji.png differ diff --git a/tests/qrcodes/numeric.png b/tests/qrcodes/numeric.png new file mode 100644 index 000000000..24b2b88ce Binary files /dev/null and b/tests/qrcodes/numeric.png differ diff --git a/tests/qrcodes/smol.png b/tests/qrcodes/smol.png new file mode 100644 index 000000000..540160e80 Binary files /dev/null and b/tests/qrcodes/smol.png differ