Przeglądaj źródła

:sparkles: init from https://github.com/codemasher/php-qrcode-decoder/commit/5798a53268f14eb1d7d70098d23db0a81b25f98b

codemasher 4 lat temu
rodzic
commit
84eb31696c

+ 4 - 1
composer.json

@@ -48,7 +48,10 @@
 	"autoload": {
 		"psr-4": {
 			"chillerlan\\QRCode\\": "src/"
-		}
+		},
+		"files": [
+			"src/includes.php"
+		]
 	},
 	"autoload-dev": {
 		"psr-4": {

+ 82 - 0
src/Common/FormatInformation.php

@@ -0,0 +1,82 @@
+<?php
+/**
+ * Class FormatInformation
+ *
+ * @created      24.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @copyright    2021 Smiley
+ * @license      Apache-2.0
+ */
+
+namespace chillerlan\QRCode\Common;
+
+/**
+ * <p>Encapsulates a QR Code's format information, including the data mask used and
+ * error correction level.</p>
+ *
+ * @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);
+	}
+
+}
+

+ 192 - 0
src/Common/ReedSolomonDecoder.php

@@ -0,0 +1,192 @@
+<?php
+/**
+ * Class ReedSolomonDecoder
+ *
+ * @created      24.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @copyright    2021 Smiley
+ * @license      Apache-2.0
+ */
+
+namespace chillerlan\QRCode\Common;
+
+use RuntimeException;
+use function array_fill, count;
+
+/**
+ * <p>Implements Reed-Solomon decoding, as the name implies.</p>
+ *
+ * <p>The algorithm will not be explained here, but the following references were helpful
+ * in creating this implementation:</p>
+ *
+ * <ul>
+ * <li>Bruce Maggs.
+ * <a href="http://www.cs.cmu.edu/afs/cs.cmu.edu/project/pscico-guyb/realworld/www/rs_decode.ps">
+ * "Decoding Reed-Solomon Codes"</a> (see discussion of Forney's Formula)</li>
+ * <li>J.I. Hall. <a href="www.mth.msu.edu/~jhall/classes/codenotes/GRS.pdf">
+ * "Chapter 5. Generalized Reed-Solomon Codes"</a>
+ * (see discussion of Euclidean algorithm)</li>
+ * </ul>
+ *
+ * <p>Much credit is due to William Rucklidge since portions of this code are an indirect
+ * port of his C++ Reed-Solomon implementation.</p>
+ *
+ * @author Sean Owen
+ * @author William Rucklidge
+ * @author sanfordsquires
+ */
+final class ReedSolomonDecoder{
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * @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;
+	}
+
+}

+ 1 - 1
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;
 	}
 
 	/**

+ 58 - 0
src/Common/functions.php

@@ -0,0 +1,58 @@
+<?php
+/**
+ * @created      24.01.2021
+ * @package      chillerlan\QRCode\Common
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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));
+}
+

+ 361 - 0
src/Decoder/Binarizer.php

@@ -0,0 +1,361 @@
+<?php
+/**
+ * Class Binarizer
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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;
+	}
+
+}

+ 204 - 0
src/Decoder/BitMatrix.php

@@ -0,0 +1,204 @@
+<?php
+/**
+ * Class BitMatrix
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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);
+	}
+
+	/**
+	 * <p>Sets the given bit to true.</p>
+	 *
+	 * @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));
+	}
+
+	/**
+	 * <p>Flips the given bit. 1 << (0xf9 & 0x1f)</p>
+	 *
+	 * @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)));
+	}
+
+	/**
+	 * <p>Sets a square region of the bit matrix to true.</p>
+	 *
+	 * @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;
+	}
+
+	/**
+	 * <p>Gets the requested bit, where true means black.</p>
+	 *
+	 * @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);
+				}
+			}
+		}
+
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * <p>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.</p>
+	 *
+	 * <p>Implementations of this method reverse the data masking process applied to a QR Code and
+	 * make its bits ready to read.</p>
+	 */
+	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);
+				}
+			}
+		}
+
+	}
+
+}

+ 333 - 0
src/Decoder/BitMatrixParser.php

@@ -0,0 +1,333 @@
+<?php
+/**
+ * Class BitMatrixParser
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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;
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * @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;
+	}
+
+	/**
+	 * <p>Reads format information from one of its two locations within the QR Code.</p>
+	 *
+	 * @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;
+	}
+
+	/**
+	 * <p>Reads version information from one of its two locations within the QR Code.</p>
+	 *
+	 * @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;
+	}
+
+}

+ 336 - 0
src/Decoder/Decoder.php

@@ -0,0 +1,336 @@
+<?php
+/**
+ * Class Decoder
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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;
+
+/**
+ * <p>The main class which implements QR Code decoding -- as opposed to locating and extracting
+ * the QR Code from an image.</p>
+ *
+ * @author Sean Owen
+ */
+final class Decoder{
+
+#	private const GB2312_SUBSET = 1;
+
+	/**
+	 * <p>Decodes a QR Code represented as a {@link \chillerlan\QRCode\Decoder\BitMatrix}.
+	 * A 1 or "true" is taken to mean a black module.</p>
+	 *
+	 * @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);
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * @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;
+	}
+
+	/**
+	 * <p>Given data and error-correction codewords received, possibly corrupted by errors, attempts to
+	 * correct the errors in-place using Reed-Solomon error correction.</p>
+	 */
+	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);
+	}
+
+}

+ 86 - 0
src/Decoder/DecoderResult.php

@@ -0,0 +1,86 @@
+<?php
+/**
+ * Class DecoderResult
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @copyright    2021 Smiley
+ * @license      Apache-2.0
+ */
+
+namespace chillerlan\QRCode\Decoder;
+
+use chillerlan\QRCode\Common\{EccLevel, Version};
+
+/**
+ * <p>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.</p>
+ *
+ * @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;
+	}
+
+}

+ 71 - 0
src/Decoder/GDLuminanceSource.php

@@ -0,0 +1,71 @@
+<?php
+/**
+ * Class GDLuminanceSource
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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']);
+			}
+		}
+	}
+
+}

+ 53 - 0
src/Decoder/IMagickLuminanceSource.php

@@ -0,0 +1,53 @@
+<?php
+/**
+ * Class IMagickLuminanceSource
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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);
+		}
+	}
+
+}

+ 103 - 0
src/Decoder/LuminanceSource.php

@@ -0,0 +1,103 @@
+<?php
+/**
+ * Class LuminanceSource
+ *
+ * @created      24.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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;
+	}
+
+}

+ 34 - 0
src/Detector/AlignmentPattern.php

@@ -0,0 +1,34 @@
+<?php
+/**
+ * Class AlignmentPattern
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @copyright    2021 Smiley
+ * @license      Apache-2.0
+ */
+
+namespace chillerlan\QRCode\Detector;
+
+/**
+ * <p>Encapsulates an alignment pattern, which are the smaller square patterns found in
+ * all but the simplest QR Codes.</p>
+ *
+ * @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
+		);
+	}
+
+}

+ 287 - 0
src/Detector/AlignmentPatternFinder.php

@@ -0,0 +1,287 @@
+<?php
+/**
+ * Class AlignmentPatternFinder
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @copyright    2021 Smiley
+ * @license      Apache-2.0
+ */
+
+namespace chillerlan\QRCode\Detector;
+
+use chillerlan\QRCode\Decoder\BitMatrix;
+use function abs, count;
+
+
+/**
+ * <p>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.</p>
+ *
+ * <p>At the moment this only looks for the bottom-right alignment pattern.</p>
+ *
+ * <p>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.</p>
+ *
+ * <p>This class is thread-safe but not reentrant. Each thread must allocate its own object.</p>
+ *
+ * @author Sean Owen
+ */
+final class AlignmentPatternFinder{
+
+	private BitMatrix $bitMatrix;
+	private float     $moduleSize;
+	/** @var \chillerlan\QRCode\Detector\AlignmentPattern[] */
+	private array $possibleCenters;
+	private array $crossCheckStateCount;
+
+	/**
+	 * <p>Creates a finder that will look in a portion of the whole image.</p>
+	 *
+	 * @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 = [];
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * @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;
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * @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);
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * @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);
+	}
+
+}

+ 358 - 0
src/Detector/Detector.php

@@ -0,0 +1,358 @@
+<?php
+/**
+ * Class Detector
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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;
+
+/**
+ * <p>Encapsulates logic that can detect a QR Code in an image, even if the QR Code
+ * is rotated or skewed, or partially obscured.</p>
+ *
+ * @author Sean Owen
+ */
+final class Detector{
+
+	private BitMatrix $bitMatrix;
+
+	/**
+	 * Detector constructor.
+	 */
+	public function __construct(BitMatrix $image){
+		$this->bitMatrix = $image;
+	}
+
+	/**
+	 * <p>Detects a QR Code in an image.</p>
+	 */
+	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);
+	}
+
+	/**
+	 * <p>Computes an average estimated module size based on estimated derived from the positions
+	 * of the three finder patterns.</p>
+	 *
+	 * @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;
+	}
+
+	/**
+	 * <p>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.</p>
+	 */
+	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.</p>
+	 */
+	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;
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * <p>This is used when figuring out how wide a finder pattern is, when the finder pattern
+	 * may be skewed or rotated.</p>
+	 */
+	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;
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * @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;
+	}
+
+	/**
+	 * <p>Attempts to locate an alignment pattern in a limited region of the image, which is
+	 * guessed to contain it.</p>
+	 *
+	 * @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()
+		);
+	}
+
+}

+ 69 - 0
src/Detector/FinderPattern.php

@@ -0,0 +1,69 @@
+<?php
+/**
+ * Class FinderPattern
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @copyright    2021 Smiley
+ * @license      Apache-2.0
+ */
+
+namespace chillerlan\QRCode\Detector;
+
+use function chillerlan\QRCode\Common\{distance, squaredDistance};
+
+/**
+ * <p>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.</p>
+ *
+ * @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
+		);
+	}
+
+}

+ 775 - 0
src/Detector/FinderPatternFinder.php

@@ -0,0 +1,775 @@
+<?php
+/**
+ * Class FinderPatternFinder
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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;
+
+/**
+ * <p>This class attempts to find finder patterns in a QR Code. Finder patterns are the square
+ * markers at three corners of a QR Code.</p>
+ *
+ * <p>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;
+
+	/**
+	 * <p>Creates a finder that will search the image for three finder patterns.</p>
+	 *
+	 * @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);
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * @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);
+	}
+
+	/**
+	 * <p>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.</p>
+	 */
+	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);
+	}
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * <p>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));
+	}
+
+}

+ 171 - 0
src/Detector/GridSampler.php

@@ -0,0 +1,171 @@
+<?php
+/**
+ * Class GridSampler
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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{
+
+	/**
+	 * <p>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.</p>
+	 *
+	 * <p>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.</p>
+	 *
+	 * <p>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.</p>
+	 *
+	 * @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;
+	}
+
+}

+ 152 - 0
src/Detector/PerspectiveTransform.php

@@ -0,0 +1,152 @@
+<?php
+/**
+ * Class PerspectiveTransform
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @copyright    2021 Smiley
+ * @license      Apache-2.0
+ */
+
+namespace chillerlan\QRCode\Detector;
+
+use function count;
+
+/**
+ * <p>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.</p>
+ *
+ * @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;
+		}
+	}
+
+}

+ 61 - 0
src/Detector/ResultPoint.php

@@ -0,0 +1,61 @@
+<?php
+/**
+ * Class ResultPoint
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @copyright    2021 Smiley
+ * @license      Apache-2.0
+ */
+
+namespace chillerlan\QRCode\Detector;
+
+use function abs;
+
+/**
+ * <p>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.</p>
+ *
+ * @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;
+	}
+
+	/**
+	 * <p>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.</p>
+	 */
+	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;
+	}
+
+}

+ 88 - 0
src/QRCodeReader.php

@@ -0,0 +1,88 @@
+<?php
+/**
+ * Class QRCodeReader
+ *
+ * @created      17.01.2021
+ * @author       ZXing Authors
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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);
+	}
+
+}

+ 16 - 0
src/includes.php

@@ -0,0 +1,16 @@
+<?php
+/**
+ * @created      10.01.2021
+ * @author       smiley <smiley@chillerlan.net>
+ * @copyright    2021 smiley
+ * @license      MIT
+ */
+
+namespace chillerlan\QRCode;
+
+// @codeCoverageIgnoreStart
+if(!\defined('QRCODE_DECODER_INCLUDES')){
+	require_once __DIR__.'/Common/functions.php';
+}
+
+// @codeCoverageIgnoreEnd

+ 121 - 0
tests/QRCodeReaderTest.php

@@ -0,0 +1,121 @@
+<?php
+/**
+ * Class QRCodeReaderTest
+ *
+ * @created      17.01.2021
+ * @author       Smiley <smiley@chillerlan.net>
+ * @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());
+	}
+
+}

BIN
tests/qrcodes/alphanum.png


BIN
tests/qrcodes/byte.png


BIN
tests/qrcodes/damaged.png


BIN
tests/qrcodes/hello_world.png


BIN
tests/qrcodes/hello_world_mirrored.png


BIN
tests/qrcodes/kanji.png


BIN
tests/qrcodes/numeric.png


BIN
tests/qrcodes/smol.png