From 84eb31696ca6dc7f2be9e492fdf2cf3bfe0126c6 Mon Sep 17 00:00:00 2001 From: codemasher Date: Mon, 25 Jan 2021 00:42:26 +0100 Subject: [PATCH] :sparkles: init from https://github.com/codemasher/php-qrcode-decoder/commit/5798a53268f14eb1d7d70098d23db0a81b25f98b --- composer.json | 5 +- src/Common/FormatInformation.php | 82 +++ src/Common/ReedSolomonDecoder.php | 192 ++++++ src/Common/Version.php | 2 +- src/Common/functions.php | 58 ++ src/Decoder/Binarizer.php | 361 +++++++++++ src/Decoder/BitMatrix.php | 204 +++++++ src/Decoder/BitMatrixParser.php | 333 ++++++++++ src/Decoder/Decoder.php | 336 ++++++++++ src/Decoder/DecoderResult.php | 86 +++ src/Decoder/GDLuminanceSource.php | 71 +++ src/Decoder/IMagickLuminanceSource.php | 53 ++ src/Decoder/LuminanceSource.php | 103 ++++ src/Detector/AlignmentPattern.php | 34 ++ src/Detector/AlignmentPatternFinder.php | 287 +++++++++ src/Detector/Detector.php | 358 +++++++++++ src/Detector/FinderPattern.php | 69 +++ src/Detector/FinderPatternFinder.php | 775 ++++++++++++++++++++++++ src/Detector/GridSampler.php | 171 ++++++ src/Detector/PerspectiveTransform.php | 152 +++++ src/Detector/ResultPoint.php | 61 ++ src/QRCodeReader.php | 88 +++ src/includes.php | 16 + tests/QRCodeReaderTest.php | 121 ++++ tests/qrcodes/alphanum.png | Bin 0 -> 383 bytes tests/qrcodes/byte.png | Bin 0 -> 1759 bytes tests/qrcodes/damaged.png | Bin 0 -> 8646 bytes tests/qrcodes/hello_world.png | Bin 0 -> 3121 bytes tests/qrcodes/hello_world_mirrored.png | Bin 0 -> 12319 bytes tests/qrcodes/kanji.png | Bin 0 -> 383 bytes tests/qrcodes/numeric.png | Bin 0 -> 388 bytes tests/qrcodes/smol.png | Bin 0 -> 389 bytes 32 files changed, 4016 insertions(+), 2 deletions(-) create mode 100644 src/Common/FormatInformation.php create mode 100644 src/Common/ReedSolomonDecoder.php create mode 100644 src/Common/functions.php create mode 100644 src/Decoder/Binarizer.php create mode 100644 src/Decoder/BitMatrix.php create mode 100644 src/Decoder/BitMatrixParser.php create mode 100644 src/Decoder/Decoder.php create mode 100644 src/Decoder/DecoderResult.php create mode 100644 src/Decoder/GDLuminanceSource.php create mode 100644 src/Decoder/IMagickLuminanceSource.php create mode 100644 src/Decoder/LuminanceSource.php create mode 100644 src/Detector/AlignmentPattern.php create mode 100644 src/Detector/AlignmentPatternFinder.php create mode 100644 src/Detector/Detector.php create mode 100644 src/Detector/FinderPattern.php create mode 100644 src/Detector/FinderPatternFinder.php create mode 100644 src/Detector/GridSampler.php create mode 100644 src/Detector/PerspectiveTransform.php create mode 100644 src/Detector/ResultPoint.php create mode 100644 src/QRCodeReader.php create mode 100644 src/includes.php create mode 100644 tests/QRCodeReaderTest.php create mode 100644 tests/qrcodes/alphanum.png create mode 100644 tests/qrcodes/byte.png create mode 100644 tests/qrcodes/damaged.png create mode 100644 tests/qrcodes/hello_world.png create mode 100644 tests/qrcodes/hello_world_mirrored.png create mode 100644 tests/qrcodes/kanji.png create mode 100644 tests/qrcodes/numeric.png create mode 100644 tests/qrcodes/smol.png 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 0000000000000000000000000000000000000000..fdcf4f289da99534c5e8f7b7e6b971921ca01cf6 GIT binary patch literal 383 zcmV-_0f7FAP)4R?8B?FbE7z|NoccAn&3I>Ms}6M~ zP#{DsiY?}Wo;JIrFqYO;=A-|{3t8x59_YUE2z{p zJy>is)F(?L=Ni~hp}kGp(36yntdjB=L5t-HE>d=aL7v0Nm zS)1G4h&2OI56$clUSyBi$!^F$^K#j{bYIhuN+=XUKP7#Vx0!3lP$mn$WG9*APZk*d zE9CL6uHCy!2Uo1AHnW&G=-l(}x5^FivSXe7dNf8wyx+n3s0vJx^VdnU+t^Kng;VLT zKd^SjeoqPYnDh5(DH%)aqcxDjoX{6kuKc2S=yojnik?*}tKjq1pfNY;nfCH)39CeQ z`9g%OIqVjcz1_4o(PT%T76dOQD3@*9La@B}tk%YKYX#ot%>fO4nrHv%vIHZNwb4Z_ zG;(&FiecUK$U4<(ESe-3D1;Ajr6(w#P8nYxc?--d3ICdp~i|md;2>D zNf!UQSl_+z#J~^?xMw;3NA7@<9)5mo1!+LOri|8GSJCCyECCFoDNBtNpAa&)8*s-b z7$zFq(Qq6c#l#&?*Ma%Y2y&ms%%J|yIY?KG4=n{VAdAW<<2aUfE$#P#a>fJ_mYK7% zBsZN1h-FCX5F#o((Io$E%n>FFAta1pL|<*8Xdg=`R19CTDy?iS0bqta;tqLmUwDdC z(M51X_g*}qV+?;Rj+HR^18Q|ja@Nv%Z>Hl*yBKLP^%?eOi%bChA1*M)bW2F^0JECu z=5_8%X;s?*?E&wu7r+!Dc`3ahm|@ zQs~~Z8Gi2+=~ak-_v{(V!~1TCFUVFPt2~0_f=7}|>jrDNBkHhY*Ds@_B@yE)3yX#3r z)e&9Jk+|*@VUCnGeS94IYGx7)^Q{%+9`p6wnf=;|NX+SKS!;&tT5i>sY?&O{g>nCd zJgjnspEQVzX$z7x1y}(5wsem$zo%^XqZ`w@%sw=c(yE20!O>UgfNJs!dQmuj!#N%5 z({es>FiNUl_;Hd$(T4egw!Yq2JY0LvoDZIq&N+x0NrH`9@>~s}6$(5#S*>Txh_*fJ zHx$~Dai&S~nsk`EUSorC*bh)Flch{6sE=?r`QpLP-c?ZpZqKfT)nvOF_ZAqw5&6W< z1k21a1f?CqW6n`TVyExpUP@W-#=!)xUe4s@jG>5cD%Ml$f}B$|{doEcbIIWLAEs2t^$SEg9Et+;$R*bJb5))`&;UT{h| zPCUS+H-R#duW06wm?-tp%o3^3*2^wJl)-~>pmPlxu243d|q?Jg`Uw|T<*EplA zQn_s*J~%BlY|}u3QM;w<&Xe+HOS1)6Y^K5ou^4GD4V4e04m}+4NYiXg6tXR{D|7dp zow|8mi*U3!FTFzTqUjUe;m`~o%Ba7OMTQKd#lCB&+;W8jJR|@B*J?VqXvQR-9lrlM zqVO^o+4r|H_J@Crv>Cy%T|zye-yr>p!7n+Q`eFMb)Z$%l4K6bt*4>j^kHOYl!wuw> zFr+b0S%(Q~*;r)zInVYS^|CeM=#sq+e_9StMJH8!rlvii({o_a+PYl94RL(UqgLjg zo%Oyb9@YiK6zW5<1iOvQ@mF|A)4S)3Vn&7@)ifc-h;aB7swiVc zU8-3|9L37_JwdlVQuZ?{bKt>ktOIWADv)bwFY3Ef&L_V5t@}NFaW!Di?FhDD;vq5C zPyrYLG^wZ)prQNgZ+$D(v~vSriCf5N+NzGvQa78x#;G9arEF9vwlx;3yp!FEj4NIH zT^J02^h;xb$y@xTCCuac0?UJJ)0wIt$scUhk~V>TeL=`ce%C3fdoP2a1jLEOZ{>); z8}B+9i6g%iFlQcgM3RZziH4(DUEM><>w*6}lANc*NMR89b~a)jVd0eWzeqY0=MLEJ mfh0<{3#iM##>PKGWL^KXm$ua&n7w*%fdYJjyvbgtFZ~5)FM?D6 literal 0 HcmV?d00001 diff --git a/tests/qrcodes/damaged.png b/tests/qrcodes/damaged.png new file mode 100644 index 0000000000000000000000000000000000000000..feeb7443efeee44979a090d86a7e49aaa7c4ee39 GIT binary patch literal 8646 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
whdBsYR^A^M-ZUEFd0_R(VW== zG=xbi*x%b5hIr|&*Cm#cmGCFyeQNLR_amT@*I%Eor0N`Z^6Q)ePQtAA;0FV-(edJ*!?NL1fS`f$qtwy$s?qo%u6OU97=Ox)LP5vkYH4M&OTeEQ25sk# ze|?%2>8sPN8B}i2dal?ddOErFITd7ogF#JA?N{4N)O|PU{e*xq8c#l~h?nYw&%e$+ z6A>Rh*aZL@`2V+|;osSRZ)o`6{{H`c{onf?tTX4Y@Kn=L$mr$m5U@W3Pgy}-zWRwp G`2PS;r}*6f literal 0 HcmV?d00001 diff --git a/tests/qrcodes/hello_world.png b/tests/qrcodes/hello_world.png new file mode 100644 index 0000000000000000000000000000000000000000..3578ea0cc1bd88805bad82da46582cb3af13c163 GIT binary patch literal 3121 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 AivR!s literal 0 HcmV?d00001 diff --git a/tests/qrcodes/hello_world_mirrored.png b/tests/qrcodes/hello_world_mirrored.png new file mode 100644 index 0000000000000000000000000000000000000000..d871f1dbf8da31bf339e3dd4fa710270807aa881 GIT binary patch literal 12319 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 C@0E}M literal 0 HcmV?d00001 diff --git a/tests/qrcodes/kanji.png b/tests/qrcodes/kanji.png new file mode 100644 index 0000000000000000000000000000000000000000..1ac7990b2661796ec8a46d5a26eaa7e01edd069d GIT binary patch literal 383 zcmV-_0f7FAP)t%ynzs9>4UA)WV^@_)oDn@tg`eyC< z>riJ<>1zGSTX_g++?$Khb#yh25b_@EcZgSZF}e@VfY%((9#NCM+JiHA{nG2cUT-c& zH#G0sbAqf|$x^$%d+oXd!K5R*j4pJ&=UF%{JF*?27~RchWJsSwPINT&qD%6g%^;4; z*V|5HDQ9H1`$kBgN>}p)dq(qiyN-0%<_i0+wf%50x|?T)h#KxDw#~c0Q8BtV-Avtd dGj-F=)E5Rv_Gf{2&pH4A002ovPDHLkV1lwswUqz> literal 0 HcmV?d00001 diff --git a/tests/qrcodes/numeric.png b/tests/qrcodes/numeric.png new file mode 100644 index 0000000000000000000000000000000000000000..24b2b88ce2870e362548c353c739684323a932a5 GIT binary patch literal 388 zcmV-~0ek+5P)4RzVKJFbr!E|NnA0Op09FNegHk$N_a*6UTGhF(YE! z9FMyJ{s`lV7jsVC5fO7v{KZF2mdBuHnV+SzTvoppHmk(YLiWx{R>s?UB%vg+jZ=(> zP%*3$8s=STcc`ru-)5BG5rb+L%q#>>CL6QJ^lX>V?Z5Ame(Z)C9@HrcLeTGyghi3X!WrN&8oI+Hq|Z;`MH&3fvz=h1vctQ$t{aF5X(rCP`yGit-r i{}$iGzx?0*1;z_Nk77}(p>JXU00004R?7~lwLsD2>-OagWmt)Sg( z?KtklXb$%3ct~2dwwtWlc*~H;1CXGfC0(cjBNOu~$7P{_kSyQ*EXjwR+?lJ z7