From a2d2741ef00c14ab0dd99ace8ab07c62a22a01f5 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Sun, 24 May 2026 21:31:03 +0200 Subject: [PATCH] feat: Add PixelReader interface and Threshold filter Add uniform per-pixel access across drivers (GD, Imagick, PNG) via PixelReader interface with getPixelRgb() and getLuminance() methods. Add Threshold binarization filter for image preprocessing. --- src/Driver/GdResource.php | 39 +++++++++++++++++++++++++++++++++- src/Driver/ImagickResource.php | 18 +++++++++++++++- src/Driver/PixelReader.php | 29 +++++++++++++++++++++++++ src/Driver/PngResource.php | 14 +++++++++++- src/Filter/Threshold.php | 27 +++++++++++++++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/Driver/PixelReader.php create mode 100644 src/Filter/Threshold.php diff --git a/src/Driver/GdResource.php b/src/Driver/GdResource.php index c35d19b..7e72add 100644 --- a/src/Driver/GdResource.php +++ b/src/Driver/GdResource.php @@ -15,11 +15,12 @@ use Horde\Image\Filter\Grayscale; use Horde\Image\Filter\Negate; use Horde\Image\Filter\Sepia; +use Horde\Image\Filter\Threshold; use Horde\Image\Geometry\Rectangle; use Horde\Image\Geometry\Size; use GdImage; -final class GdResource implements ImageResource +final class GdResource implements ImageResource, PixelReader { public function __construct( private GdImage $gd, @@ -46,6 +47,22 @@ public function gd(): GdImage return $this->gd; } + /** @return array{int, int, int} */ + public function getPixelRgb(int $x, int $y): array + { + $rgb = imagecolorat($this->gd, $x, $y); + if ($rgb === false) { + return [0, 0, 0]; + } + return [($rgb >> 16) & 0xFF, ($rgb >> 8) & 0xFF, $rgb & 0xFF]; + } + + public function getLuminance(int $x, int $y): int + { + [$r, $g, $b] = $this->getPixelRgb($x, $y); + return (int) round(0.299 * $r + 0.587 * $g + 0.114 * $b); + } + public function size(): Size { return new Size((float) imagesx($this->gd), (float) imagesy($this->gd)); @@ -157,10 +174,30 @@ private function applyFilter(Filter $filter): void ), $filter instanceof Gamma => imagegammacorrect($this->gd, 1.0, $filter->gamma), $filter instanceof Sepia => $this->applySepiaFilter($filter->threshold), + $filter instanceof Threshold => $this->applyThreshold($filter->level), default => throw new DriverException('Filter not supported by GdDriver: ' . $filter::class), }; } + private function applyThreshold(int $level): void + { + $w = imagesx($this->gd); + $h = imagesy($this->gd); + $black = imagecolorallocate($this->gd, 0, 0, 0); + $white = imagecolorallocate($this->gd, 255, 255, 255); + + for ($y = 0; $y < $h; $y++) { + for ($x = 0; $x < $w; $x++) { + $rgb = imagecolorat($this->gd, $x, $y); + $r = ($rgb >> 16) & 0xFF; + $g = ($rgb >> 8) & 0xFF; + $b = $rgb & 0xFF; + $lum = (int) round(0.299 * $r + 0.587 * $g + 0.114 * $b); + imagesetpixel($this->gd, $x, $y, $lum < $level ? $black : $white); + } + } + } + private function applySepiaFilter(float $threshold): void { imagefilter($this->gd, IMG_FILTER_GRAYSCALE); diff --git a/src/Driver/ImagickResource.php b/src/Driver/ImagickResource.php index f9ddeb2..68711d1 100644 --- a/src/Driver/ImagickResource.php +++ b/src/Driver/ImagickResource.php @@ -20,12 +20,13 @@ use Horde\Image\Filter\Pixelate; use Horde\Image\Filter\Sepia; use Horde\Image\Filter\Sharpen; +use Horde\Image\Filter\Threshold; use Horde\Image\Geometry\Rectangle; use Horde\Image\Geometry\Size; use Imagick; use ImagickPixel; -final class ImagickResource implements ImageResource +final class ImagickResource implements ImageResource, PixelReader { public function __construct( private Imagick $imagick, @@ -117,6 +118,7 @@ public function apply(Filter $filter): static ), $filter instanceof Negate => $clone->imagick->negateImage(false), $filter instanceof Pixelate => $clone->applyPixelate($filter->size), + $filter instanceof Threshold => $clone->imagick->thresholdImage($filter->level * 257), $filter instanceof Modulate => $clone->imagick->modulateImage( $filter->brightness, $filter->saturation, @@ -147,6 +149,20 @@ public function imagick(): Imagick return $this->imagick; } + /** @return array{int, int, int} */ + public function getPixelRgb(int $x, int $y): array + { + $pixel = $this->imagick->getImagePixelColor($x, $y); + $color = $pixel->getColor(); + return [(int) $color['r'], (int) $color['g'], (int) $color['b']]; + } + + public function getLuminance(int $x, int $y): int + { + [$r, $g, $b] = $this->getPixelRgb($x, $y); + return (int) round(0.299 * $r + 0.587 * $g + 0.114 * $b); + } + public function withImagick(Imagick $imagick): static { $clone = clone $this; diff --git a/src/Driver/PixelReader.php b/src/Driver/PixelReader.php new file mode 100644 index 0000000..db914df --- /dev/null +++ b/src/Driver/PixelReader.php @@ -0,0 +1,29 @@ +> row => col => [r, g, b] */ private array $pixels; @@ -74,6 +74,18 @@ public function getPixel(int $x, int $y): array return [0, 0, 0]; } + /** @return array{int, int, int} */ + public function getPixelRgb(int $x, int $y): array + { + return $this->getPixel($x, $y); + } + + public function getLuminance(int $x, int $y): int + { + [$r, $g, $b] = $this->getPixel($x, $y); + return (int) round(0.299 * $r + 0.587 * $g + 0.114 * $b); + } + public function resize(Size $size): static { $newW = max(1, (int) $size->width); diff --git a/src/Filter/Threshold.php b/src/Filter/Threshold.php new file mode 100644 index 0000000..903a4ed --- /dev/null +++ b/src/Filter/Threshold.php @@ -0,0 +1,27 @@ +