Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
defaultTestSuite="unit"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>test/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>test/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
Expand Down
24 changes: 24 additions & 0 deletions src/Reader/BoundingBox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

/**
* Axis-aligned bounding box for a detected symbol within an image.
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\Barcode\Reader;

final readonly class BoundingBox
{
public function __construct(
public int $x,
public int $y,
public int $width,
public int $height,
) {}
}
258 changes: 258 additions & 0 deletions src/Reader/FinderPatternDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
<?php

declare(strict_types=1);

/**
* Detects QR code finder patterns in a binarized scanline.
*
* Finder patterns have the characteristic 1:1:3:1:1 ratio of
* dark:light:dark:light:dark module widths. This class scans
* pixel rows and columns looking for this signature.
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\Barcode\Reader;

final class FinderPatternDetector
{
/**
* Detected finder pattern center with estimated module size.
*
* @var list<array{x: float, y: float, moduleSize: float}>
*/
private array $candidates = [];

/**
* Scan a binary image (row by row) for finder pattern signatures.
*
* @param array<int, array<int, bool>> $binaryImage Row-major, true = dark
* @param int $width Image width
* @param int $height Image height
* @return list<array{x: float, y: float, moduleSize: float}> Up to 3 finder pattern centers
*/
public function detect(array $binaryImage, int $width, int $height): array
{
$this->candidates = [];

for ($row = 0; $row < $height; $row++) {
$stateCount = [0, 0, 0, 0, 0];
$currentState = 0;
$started = false;

for ($col = 0; $col < $width; $col++) {
$dark = $binaryImage[$row][$col];
if ($dark) {
if (!$started) {
$started = true;
}
if ($currentState === 1 || $currentState === 3) {
$currentState++;
}
$stateCount[$currentState]++;
} else {
if (!$started) {
continue;
}
if ($currentState === 1 || $currentState === 3) {
$stateCount[$currentState]++;
} elseif ($currentState === 0 || $currentState === 2) {
$currentState++;
$stateCount[$currentState]++;
} elseif ($currentState === 4) {
if ($this->isFinderRatio($stateCount)) {
$centerCol = $this->centerFromEnd($stateCount, $col);
$centerRow = $this->crossCheckVertical(
$binaryImage,
$row,
(int) round($centerCol),
$stateCount[2],
$width,
$height,
);
if ($centerRow !== null) {
$moduleSize = array_sum($stateCount) / 7.0;
$this->addCandidate($centerCol, $centerRow, $moduleSize);
}
}
$stateCount[0] = $stateCount[2];
$stateCount[1] = $stateCount[3];
$stateCount[2] = $stateCount[4];
$stateCount[3] = 1;
$stateCount[4] = 0;
$currentState = 3;
}
}
}

if ($currentState === 4 && $this->isFinderRatio($stateCount)) {
$centerCol = $this->centerFromEnd($stateCount, $width);
$centerRow = $this->crossCheckVertical(
$binaryImage,
$row,
(int) round($centerCol),
$stateCount[2],
$width,
$height,
);
if ($centerRow !== null) {
$moduleSize = array_sum($stateCount) / 7.0;
$this->addCandidate($centerCol, $centerRow, $moduleSize);
}
}
}

return $this->selectBestThree();
}

/**
* Check if the state counts match the 1:1:3:1:1 finder pattern ratio.
*
* @param array<int, int> $stateCount
*/
private function isFinderRatio(array $stateCount): bool
{
$total = array_sum($stateCount);
if ($total < 7) {
return false;
}

$moduleSize = $total / 7.0;
$tolerance = $moduleSize * 0.5;

return abs($stateCount[0] - $moduleSize) < $tolerance
&& abs($stateCount[1] - $moduleSize) < $tolerance
&& abs($stateCount[2] - 3.0 * $moduleSize) < 3.0 * $tolerance
&& abs($stateCount[3] - $moduleSize) < $tolerance
&& abs($stateCount[4] - $moduleSize) < $tolerance;
}

/**
* @param array<int, int> $stateCount
*/
private function centerFromEnd(array $stateCount, int $end): float
{
return (float) ($end - $stateCount[4] - $stateCount[3]) - $stateCount[2] / 2.0;
}

/**
* Cross-check a horizontal candidate by scanning vertically.
*
* @param array<int, array<int, bool>> $image
*/
private function crossCheckVertical(
array $image,
int $startRow,
int $centerCol,
int $expectedCenterWidth,
int $width,
int $height,
): ?float {
if ($centerCol < 0 || $centerCol >= $width) {
return null;
}

$stateCount = [0, 0, 0, 0, 0];

$row = $startRow;
while ($row >= 0 && $image[$row][$centerCol]) {
$stateCount[2]++;
$row--;
}
if ($row < 0) {
return null;
}
while ($row >= 0 && !$image[$row][$centerCol]) {
$stateCount[1]++;
$row--;
}
if ($row < 0) {
return null;
}
while ($row >= 0 && $image[$row][$centerCol]) {
$stateCount[0]++;
$row--;
}

$row = $startRow + 1;
while ($row < $height && $image[$row][$centerCol]) {
$stateCount[2]++;
$row++;
}
if ($row >= $height) {
return null;
}
while ($row < $height && !$image[$row][$centerCol]) {
$stateCount[3]++;
$row++;
}
if ($row >= $height) {
return null;
}
while ($row < $height && $image[$row][$centerCol]) {
$stateCount[4]++;
$row++;
}

if (!$this->isFinderRatio($stateCount)) {
return null;
}

$total = array_sum($stateCount);
$expectedTotal = (int) round(7.0 * $expectedCenterWidth / 3.0);
if (5 * abs($total - $expectedTotal) >= 2 * $expectedTotal) {
return null;
}

return (float) ($row - $stateCount[4] - $stateCount[3]) - $stateCount[2] / 2.0;
}

private function addCandidate(float $x, float $y, float $moduleSize): void
{
foreach ($this->candidates as &$c) {
if (abs($c['x'] - $x) < $moduleSize * 3 && abs($c['y'] - $y) < $moduleSize * 3) {
$c['x'] = ($c['x'] + $x) / 2.0;
$c['y'] = ($c['y'] + $y) / 2.0;
$c['moduleSize'] = ($c['moduleSize'] + $moduleSize) / 2.0;
return;
}
}
$this->candidates[] = ['x' => $x, 'y' => $y, 'moduleSize' => $moduleSize];
}

/**
* Select the best three finder patterns (if at least three are found).
*
* @return list<array{x: float, y: float, moduleSize: float}>
*/
private function selectBestThree(): array
{
if (count($this->candidates) < 3) {
return $this->candidates;
}

// Sort by module size consistency — pick three with most similar sizes
usort($this->candidates, static fn (array $a, array $b): int => (int) (($a['moduleSize'] - $b['moduleSize']) * 1000));

$bestDiff = PHP_FLOAT_MAX;
$bestTriple = [$this->candidates[0], $this->candidates[1], $this->candidates[2]];

$count = count($this->candidates);
for ($i = 0; $i < $count - 2; $i++) {
for ($j = $i + 1; $j < $count - 1; $j++) {
for ($k = $j + 1; $k < $count; $k++) {
$diff = $this->candidates[$k]['moduleSize'] - $this->candidates[$i]['moduleSize'];
if ($diff < $bestDiff) {
$bestDiff = $diff;
$bestTriple = [$this->candidates[$i], $this->candidates[$j], $this->candidates[$k]];
}
}
}
}

return $bestTriple;
}
}
15 changes: 7 additions & 8 deletions src/Reader/ImageLocatorInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
declare(strict_types=1);

/**
* Contract for locating barcodes within raster images.
* Contract for locating barcodes and 2D codes within raster images.
*
* Implementations use image processing to find barcode symbols in
* photographs or scanned documents and return their module matrices.
* Combines both QR/2D and linear barcode detection into a single
* interface. Implementations may delegate to separate locators for
* each symbology family.
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
Expand All @@ -16,15 +17,13 @@

namespace Horde\Barcode\Reader;

use Horde\Barcode\Encoder\ModuleMatrix;

interface ImageLocatorInterface
interface ImageLocatorInterface extends QrLocatorInterface, LinearLocatorInterface
{
/**
* Locate and extract barcode module matrices from an image.
* Locate and decode all recognizable symbols in an image.
*
* @param string $imageData Raw image binary data (PNG, JPEG, etc.)
* @return list<ModuleMatrix> Zero or more located symbols
* @return list<LocatedSymbol>
*/
public function locate(string $imageData): array;
}
25 changes: 25 additions & 0 deletions src/Reader/LinearLocatorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

/**
* Contract for locating linear (1D) barcodes within raster images.
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\Barcode\Reader;

interface LinearLocatorInterface
{
/**
* Locate and decode linear barcodes in an image.
*
* @param string $imageData Raw image binary data (PNG, JPEG, etc.)
* @return list<LocatedSymbol>
*/
public function locateLinear(string $imageData): array;
}
30 changes: 30 additions & 0 deletions src/Reader/LocatedSymbol.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

/**
* A barcode or 2D code detected within an image.
*
* Combines the bounding box location, decoded payload, symbology type,
* and optionally the raw symbol structure (ModuleMatrix or BarPattern).
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*/

namespace Horde\Barcode\Reader;

use Horde\Barcode\Encoder\BarPattern;
use Horde\Barcode\Encoder\ModuleMatrix;

final readonly class LocatedSymbol
{
public function __construct(
public BoundingBox $bounds,
public string $payload,
public SymbolType $type,
public ModuleMatrix|BarPattern|null $symbol = null,
) {}
}
Loading