diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd7cc0..e1c3cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `colorKey` pipeline operation to make nodata fill colors transparent. Defaults to a flood from the tile edge and existing alpha so isolated interior matches stay put. + ## [2.2.0] - 2026-08-26 ### Added @@ -169,6 +175,7 @@ Rename `publicBasePath` to `mapAssetBasePath` in Mapbox style configs. The old k - Initial release of the PHP tile proxy with operation pipeline, caching, and tests. +[Unreleased]: https://github.com/open-mapsight/tile-proxy/compare/v2.2.0...HEAD [2.2.0]: https://github.com/open-mapsight/tile-proxy/compare/v2.1.2...v2.2.0 [2.1.2]: https://github.com/open-mapsight/tile-proxy/compare/v2.1.1...v2.1.2 [2.1.1]: https://github.com/open-mapsight/tile-proxy/compare/v2.1.0...v2.1.1 diff --git a/README.md b/README.md index b841569..314ff56 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ Operations are chained sequentially as defined in the `ops` array. The first ope * `src`: Fetches the tile from the defined `urls`, or from a `wms` GetMap source. URL templates support `{z}`, `{x}`, `{y}`, `{prefix}`, `{bbox}` / `{bbox3857}` (Web Mercator meters), and `{bbox4326}` (lon,lat,lon,lat). * `colorFilter`: Applies color filters. Supported filters: `reducedSaturation`, `muted`, `culture`. +* `colorKey`: Makes nodata fill colors transparent (`colors` as `#RRGGBB`, optional `fuzz`, optional `fromEdges`). Defaults to a flood from the tile edge and already-transparent pixels so isolated interior matches stay put. Always encodes PNG. * `encode`: Re-encodes the current tile to another image type (`mimeType`, optional `quality` for JPEG/WebP). * `imgOpt`: Optimizes the image using image optimizers. * `merge`: Merges the current tile with another set of operations. @@ -310,6 +311,19 @@ JPEG is the right WMS `FORMAT` for aerial photos (smaller than PNG, widely suppo ] ``` +To punch a solid nodata fill out of an aerial before overlaying it, add `colorKey`. `fuzz` is a per-channel tolerance. Keep `fromEdges` at its default (`true`) so interior roofs or shadows that happen to match the fill stay put: + +```jsonc +{ + "op": "colorKey", + "colors": ["#000000"], + "fuzz": 8, + "cacheServerName": "luftbild-keyed" +} +``` + +Set `fromEdges` to `false` only when every matching pixel should become transparent, including interior matches. + Leaflet, OpenLayers, and MapLibre can consume the proxied XYZ URL like any other raster tileset. If the WMS requires Basic auth, set `upstreamHttp.headers.Authorization` in the PHP bootstrap (not in committed JSONC). Some municipal WMS licenses also forbid local storage — disable or shorten `cacheServerTtl` when that applies. diff --git a/classes/Ops/ColorKeyOp.php b/classes/Ops/ColorKeyOp.php new file mode 100644 index 0000000..7065076 --- /dev/null +++ b/classes/Ops/ColorKeyOp.php @@ -0,0 +1,186 @@ +isFromCache()) { + Utils::assertImageMimeType($res->mimeType); + $img = Utils::bytesToImage($res->getData()); + self::cutImage( + $img, + self::parseColors($cfg['colors'] ?? []), + (int)($cfg['fuzz'] ?? 0), + (bool)($cfg['fromEdges'] ?? true), + ); + $res->setData(Utils::imageToBytes('image/png', $img)); + } + + $res->mimeType = 'image/png'; + + return $next($res); + } + + /** + * @param list $colors + */ + public static function cutImage($img, array $colors, int $fuzz, bool $fromEdges): void + { + if ($colors === []) { + throw new RuntimeException('colorKey requires `colors`'); + } + + $width = imagesx($img); + $height = imagesy($img); + imagealphablending($img, false); + imagesavealpha($img, true); + $clear = imagecolorallocatealpha($img, 0, 0, 0, 127); + + $isKey = static function (int $r, int $g, int $b) use ($colors, $fuzz): bool { + foreach ($colors as [$cr, $cg, $cb]) { + if ( + abs($r - $cr) <= $fuzz + && abs($g - $cg) <= $fuzz + && abs($b - $cb) <= $fuzz + ) { + return true; + } + } + + return false; + }; + + $rgba = static function (int $pixel): array { + return [ + ($pixel >> 16) & 0xFF, + ($pixel >> 8) & 0xFF, + $pixel & 0xFF, + ($pixel >> 24) & 0x7F, + ]; + }; + + $match = static function (int $x, int $y) use ($img, $rgba, $isKey): bool { + [$r, $g, $b, $a] = $rgba(imagecolorat($img, $x, $y)); + + return $a < 127 && $isKey($r, $g, $b); + }; + + if (!$fromEdges) { + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + if ($match($x, $y)) { + imagesetpixel($img, $x, $y, $clear); + } + } + } + + return; + } + + $visited = array_fill(0, $width * $height, false); + $queue = []; + $enqueue = static function (int $x, int $y) use (&$queue, &$visited, $width, $height): void { + if ($x < 0 || $y < 0 || $x >= $width || $y >= $height) { + return; + } + $i = $y * $width + $x; + if ($visited[$i]) { + return; + } + $visited[$i] = true; + $queue[] = [$x, $y]; + }; + + for ($x = 0; $x < $width; $x++) { + if ($match($x, 0)) { + $enqueue($x, 0); + } + if ($match($x, $height - 1)) { + $enqueue($x, $height - 1); + } + } + for ($y = 0; $y < $height; $y++) { + if ($match(0, $y)) { + $enqueue(0, $y); + } + if ($match($width - 1, $y)) { + $enqueue($width - 1, $y); + } + } + + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + $a = $rgba(imagecolorat($img, $x, $y))[3]; + if ($a < 127) { + continue; + } + $enqueue($x + 1, $y); + $enqueue($x - 1, $y); + $enqueue($x, $y + 1); + $enqueue($x, $y - 1); + } + } + + $head = 0; + while ($head < count($queue)) { + [$x, $y] = $queue[$head++]; + if (!$match($x, $y)) { + continue; + } + imagesetpixel($img, $x, $y, $clear); + foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { + $nx = $x + $dx; + $ny = $y + $dy; + if ($nx < 0 || $ny < 0 || $nx >= $width || $ny >= $height) { + continue; + } + $i = $ny * $width + $nx; + if ($visited[$i]) { + continue; + } + if (!$match($nx, $ny)) { + continue; + } + $visited[$i] = true; + $queue[] = [$nx, $ny]; + } + } + } + + /** + * @param list $raw + * @return list + */ + public static function parseColors(array $raw): array + { + $out = []; + foreach ($raw as $color) { + if (is_string($color) && preg_match('/^#([0-9a-f]{6})$/i', $color, $m) === 1) { + $hex = $m[1]; + $out[] = [ + hexdec(substr($hex, 0, 2)), + hexdec(substr($hex, 2, 2)), + hexdec(substr($hex, 4, 2)), + ]; + continue; + } + + throw new RuntimeException('colorKey colors must be #RRGGBB'); + } + + return $out; + } +} diff --git a/classes/Processor.php b/classes/Processor.php index a53e775..006c6a6 100644 --- a/classes/Processor.php +++ b/classes/Processor.php @@ -5,6 +5,7 @@ use Exception; use OpenMapsight\TileProxy\Ops\ColorFilterOp; +use OpenMapsight\TileProxy\Ops\ColorKeyOp; use OpenMapsight\TileProxy\Ops\EncodeOp; use OpenMapsight\TileProxy\Ops\ImgOptOp; use OpenMapsight\TileProxy\Ops\MergeOp; @@ -21,6 +22,7 @@ protected static function getOpHandlers(): array { return [ 'colorFilter' => ColorFilterOp::class, + 'colorKey' => ColorKeyOp::class, 'encode' => EncodeOp::class, 'imgOpt' => ImgOptOp::class, 'merge' => MergeOp::class, diff --git a/tests/ColorKeyOpTest.php b/tests/ColorKeyOpTest.php new file mode 100644 index 0000000..d0bced0 --- /dev/null +++ b/tests/ColorKeyOpTest.php @@ -0,0 +1,293 @@ +assertSame( + [[0, 0, 0], [255, 255, 255], [10, 20, 30]], + ColorKeyOp::parseColors(['#000000', '#FFFFFF', '#0a141e']) + ); + } + + #[DataProvider('invalidColorsProvider')] + public function testParseColorsRejectsInvalidValues(mixed $color): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('colorKey colors must be #RRGGBB'); + ColorKeyOp::parseColors([$color]); + } + + /** + * @return array + */ + public static function invalidColorsProvider(): array + { + return [ + 'short hex' => ['#000'], + 'alpha hex' => ['#00000000'], + 'missing hash' => ['000000'], + 'rgb function' => ['rgb(0,0,0)'], + 'integer' => [0], + 'list' => [[0, 0, 0]], + ]; + } + + public function testCutImageRequiresColors(): void + { + $img = $this->solidImage(2, 2, 0, 0, 0); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('colorKey requires `colors`'); + + ColorKeyOp::cutImage($img, [], 0, false); + } + + public function testGlobalKeyRemovesEveryMatchIncludingInterior(): void + { + $img = $this->solidImage(5, 5, 255, 255, 255); + $black = imagecolorallocate($img, 0, 0, 0); + imagesetpixel($img, 2, 2, $black); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 0, false); + + $this->assertTransparent($img, 2, 2); + $this->assertRgb($img, 0, 0, 255, 255, 255); + } + + public function testEdgeFloodLeavesIsolatedInteriorKeyColor(): void + { + $img = $this->solidImage(5, 5, 255, 255, 255); + imagesetpixel($img, 2, 2, imagecolorallocate($img, 0, 0, 0)); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 0, true); + + $this->assertRgb($img, 2, 2, 0, 0, 0); + $this->assertRgb($img, 0, 0, 255, 255, 255); + } + + public function testEdgeFloodRemovesKeyColorConnectedToBorder(): void + { + $img = $this->solidImage(5, 5, 255, 255, 255); + $black = imagecolorallocate($img, 0, 0, 0); + for ($y = 0; $y < 5; $y++) { + imagesetpixel($img, 0, $y, $black); + } + imagesetpixel($img, 1, 2, $black); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 0, true); + + $this->assertTransparent($img, 0, 0); + $this->assertTransparent($img, 0, 2); + $this->assertTransparent($img, 1, 2); + $this->assertRgb($img, 2, 2, 255, 255, 255); + } + + public function testEdgeFloodContinuesFromExistingTransparency(): void + { + $img = $this->solidImage(5, 5, 255, 255, 255); + $black = imagecolorallocate($img, 0, 0, 0); + $clear = imagecolorallocatealpha($img, 0, 0, 0, 127); + for ($y = 1; $y <= 3; $y++) { + for ($x = 1; $x <= 3; $x++) { + imagesetpixel($img, $x, $y, $black); + } + } + imagesetpixel($img, 2, 2, $clear); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 0, true); + + $this->assertTransparent($img, 1, 1); + $this->assertTransparent($img, 2, 2); + $this->assertTransparent($img, 3, 3); + $this->assertRgb($img, 0, 0, 255, 255, 255); + } + + public function testFuzzMatchesNearbyColors(): void + { + $img = $this->solidImage(1, 1, 8, 0, 0); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 8, false); + + $this->assertTransparent($img, 0, 0); + } + + public function testFuzzDoesNotMatchOutsideTolerance(): void + { + $img = $this->solidImage(1, 1, 9, 0, 0); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 8, false); + + $this->assertRgb($img, 0, 0, 9, 0, 0); + } + + public function testInvokeEncodesPngAndClearsEdgeKeyColor(): void + { + $img = $this->solidImage(3, 3, 255, 255, 255); + imagesetpixel($img, 0, 0, imagecolorallocate($img, 0, 0, 0)); + imagesetpixel($img, 1, 1, imagecolorallocate($img, 0, 0, 0)); + $res = $this->resultWithImage($img); + + $out = (new ColorKeyOp())( + static fn (Result $result): Result => $result, + ['colors' => ['#000000']], + $res + ); + + $this->assertSame('image/png', $out->mimeType); + $processed = Utils::bytesToImage($out->getData()); + $this->assertTransparent($processed, 0, 0); + $this->assertRgb($processed, 1, 1, 0, 0, 0); + } + + public function testInvokeSkipsWorkOnCacheHit(): void + { + $res = $this->newResult(); + $res->mimeType = 'image/jpeg'; + + $called = false; + $out = (new ColorKeyOp())( + static function (Result $result) use (&$called): Result { + $called = true; + return $result; + }, + ['colors' => []], + $res + ); + + $this->assertTrue($called); + $this->assertSame('image/png', $out->mimeType); + $this->assertTrue($out->isFromCache()); + } + + public function testProcessorRunsColorKeyOp(): void + { + $img = $this->solidImage(2, 2, 0, 0, 0); + imagepng($img, $this->tempDir . '/tile.png'); + + $result = Processor::run( + [ + [ + 'cacheServerName' => 'src', + 'urls' => ['file://' . $this->tempDir . '/tile.png'], + 'mimeType' => 'image/png', + 'cacheBrowserTtl' => 3600, + 'cacheServerTtl' => 86400, + ], + [ + 'op' => 'colorKey', + 'colors' => ['#000000'], + 'fromEdges' => false, + 'cacheServerName' => 'keyed', + ], + ], + ['z' => '1', 'x' => '0', 'y' => '0', 'prefix' => null], + $this->tempDir . '/cache', + new MetadataScope(new Metadata($this->tempDir . '/meta.json'), 'test') + ); + + $this->assertNull($result->failure); + $this->assertSame('image/png', $result->mimeType); + $processed = Utils::bytesToImage($result->getData()); + $this->assertTransparent($processed, 0, 0); + $this->assertTransparent($processed, 1, 1); + } + + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/tile_proxy_colorkey_test_' . uniqid(); + mkdir($this->tempDir); + } + + protected function tearDown(): void + { + $files = glob($this->tempDir . '/*') ?: []; + foreach ($files as $file) { + unlink($file); + } + rmdir($this->tempDir); + } + + /** + * @return \GdImage + */ + private function solidImage(int $width, int $height, int $r, int $g, int $b) + { + $img = imagecreatetruecolor($width, $height); + imagealphablending($img, false); + imagesavealpha($img, true); + imagefill($img, 0, 0, imagecolorallocatealpha($img, $r, $g, $b, 0)); + + return $img; + } + + /** + * @param \GdImage $img + */ + private function resultWithImage($img): Result + { + $res = $this->newResult(); + $res->mimeType = 'image/png'; + $res->setData(Utils::imageToBytes('image/png', $img)); + + return $res; + } + + private function newResult(): Result + { + return new Result( + ['z' => '1', 'x' => '0', 'y' => '0', 'prefix' => null], + $this->tempDir . '/cache', + 'test', + new MetadataScope(new Metadata($this->tempDir . '/meta.json'), 'test') + ); + } + + /** + * @param \GdImage $img + */ + private function assertTransparent($img, int $x, int $y): void + { + $this->assertSame(127, $this->pixel($img, $x, $y)[3], "expected transparent pixel at ($x,$y)"); + } + + /** + * @param \GdImage $img + */ + private function assertRgb($img, int $x, int $y, int $r, int $g, int $b): void + { + [$pr, $pg, $pb, $pa] = $this->pixel($img, $x, $y); + $this->assertSame([$r, $g, $b, 0], [$pr, $pg, $pb, $pa], "pixel ($x,$y)"); + } + + /** + * @param \GdImage $img + * @return array{0:int,1:int,2:int,3:int} + */ + private function pixel($img, int $x, int $y): array + { + $p = imagecolorat($img, $x, $y); + + return [ + ($p >> 16) & 0xFF, + ($p >> 8) & 0xFF, + $p & 0xFF, + ($p >> 24) & 0x7F, + ]; + } +}