diff --git a/CHANGELOG.md b/CHANGELOG.md index 649862b..a9a54c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.4.0] - 2026-08-28 + +### Added + +- `colorKey` options `soft` (distance-based alpha), `feather` (alpha blur, pixels), and `protectDarkerThan` (restore partial-alpha pixels darker than that luminance so labels stay crisp). + ## [2.3.0] - 2026-08-26 ### Added @@ -177,7 +183,8 @@ 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.3.0...HEAD +[Unreleased]: https://github.com/open-mapsight/tile-proxy/compare/v2.4.0...HEAD +[2.4.0]: https://github.com/open-mapsight/tile-proxy/compare/v2.3.0...v2.4.0 [2.3.0]: https://github.com/open-mapsight/tile-proxy/compare/v2.2.0...v2.3.0 [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 diff --git a/README.md b/README.md index 314ff56..69bd3ae 100644 --- a/README.md +++ b/README.md @@ -244,7 +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. +* `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. Optional `soft` fades alpha by distance to the key color instead of a hard cut. Optional `feather` (integer pixel passes) blurs alpha afterward. Optional `protectDarkerThan` (0–255 luminance) restores partial-alpha pixels darker than that value so labels stay opaque after a street-fill punch. * `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. @@ -324,6 +324,21 @@ To punch a solid nodata fill out of an aerial before overlaying it, add `colorKe Set `fromEdges` to `false` only when every matching pixel should become transparent, including interior matches. +To punch cartographic fills (street interiors) and keep labels, turn off the edge flood, fade the cut, then protect dark ink: + +```jsonc +{ + "op": "colorKey", + "colors": ["#ffffff", "#fff8bd", "#d0d0d0"], + "fuzz": 8, + "fromEdges": false, + "soft": true, + "feather": 2, + "protectDarkerThan": 80, + "cacheServerName": "stadtplan-ink" +} +``` + 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 index 7065076..1682b9c 100644 --- a/classes/Ops/ColorKeyOp.php +++ b/classes/Ops/ColorKeyOp.php @@ -12,6 +12,11 @@ * Make nodata fill colors transparent. Default is a flood from the tile edge * and from already-transparent pixels, so isolated interior matches (roofs, * shadows) stay put. + * + * Optional `soft` fades alpha by Chebyshev distance to the key color instead + * of a hard cut. Optional `feather` blurs alpha afterward. Optional + * `protectDarkerThan` restores partial-alpha pixels darker than that + * luminance so labels stay crisp after a soft/feathered street punch. */ class ColorKeyOp implements OpHandler { @@ -25,6 +30,7 @@ public function __invoke(callable $next, array $cfg, Result $res): Result self::parseColors($cfg['colors'] ?? []), (int)($cfg['fuzz'] ?? 0), (bool)($cfg['fromEdges'] ?? true), + self::parseOptions($cfg), ); $res->setData(Utils::imageToBytes('image/png', $img)); } @@ -36,60 +42,128 @@ public function __invoke(callable $next, array $cfg, Result $res): Result /** * @param list $colors + * @param array{soft?:bool,feather?:int,protectDarkerThan?:int|null} $opts */ - public static function cutImage($img, array $colors, int $fuzz, bool $fromEdges): void + public static function cutImage($img, array $colors, int $fuzz, bool $fromEdges, array $opts = []): void { if ($colors === []) { throw new RuntimeException('colorKey requires `colors`'); } + $opts = self::normalizeOptions($opts); $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; - } + + $targets = $fromEdges + ? self::edgeFloodTargets($img, $colors, $fuzz, $width, $height) + : self::globalTargets($img, $colors, $fuzz, $width, $height); + + foreach ($targets as [$x, $y]) { + [$r, $g, $b] = self::pixelRgb(imagecolorat($img, $x, $y)); + $gdAlpha = $opts['soft'] + ? self::softGdAlpha(self::minChebyshev($r, $g, $b, $colors), $fuzz) + : 127; + imagesetpixel($img, $x, $y, imagecolorallocatealpha($img, $r, $g, $b, $gdAlpha)); + } + + if ($opts['feather'] > 0) { + self::featherAlpha($img, $opts['feather']); + } + + if ($opts['protectDarkerThan'] !== null) { + self::protectDark($img, $opts['protectDarkerThan']); + } + } + + /** + * @param array $cfg + * @return array{soft:bool,feather:int,protectDarkerThan:int|null} + */ + public static function parseOptions(array $cfg): array + { + return self::normalizeOptions([ + 'soft' => $cfg['soft'] ?? false, + 'feather' => $cfg['feather'] ?? 0, + 'protectDarkerThan' => $cfg['protectDarkerThan'] ?? null, + ]); + } + + /** + * @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; } - return false; - }; + throw new RuntimeException('colorKey colors must be #RRGGBB'); + } - $rgba = static function (int $pixel): array { - return [ - ($pixel >> 16) & 0xFF, - ($pixel >> 8) & 0xFF, - $pixel & 0xFF, - ($pixel >> 24) & 0x7F, - ]; - }; + return $out; + } - $match = static function (int $x, int $y) use ($img, $rgba, $isKey): bool { - [$r, $g, $b, $a] = $rgba(imagecolorat($img, $x, $y)); + /** + * @param array $opts + * @return array{soft:bool,feather:int,protectDarkerThan:int|null} + */ + private static function normalizeOptions(array $opts): array + { + $feather = (int)($opts['feather'] ?? 0); + if ($feather < 0) { + throw new RuntimeException('colorKey feather must be >= 0'); + } - return $a < 127 && $isKey($r, $g, $b); - }; + $protect = $opts['protectDarkerThan'] ?? null; + if ($protect !== null) { + $protect = (int)$protect; + if ($protect < 0 || $protect > 255) { + throw new RuntimeException('colorKey protectDarkerThan must be 0-255'); + } + } - if (!$fromEdges) { - for ($y = 0; $y < $height; $y++) { - for ($x = 0; $x < $width; $x++) { - if ($match($x, $y)) { - imagesetpixel($img, $x, $y, $clear); - } + return [ + 'soft' => (bool)($opts['soft'] ?? false), + 'feather' => $feather, + 'protectDarkerThan' => $protect, + ]; + } + + /** + * @param list $colors + * @return list + */ + private static function globalTargets($img, array $colors, int $fuzz, int $width, int $height): array + { + $targets = []; + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + if (self::isKeyPixel($img, $x, $y, $colors, $fuzz)) { + $targets[] = [$x, $y]; } } - - return; } + return $targets; + } + + /** + * @param list $colors + * @return list + */ + private static function edgeFloodTargets($img, array $colors, int $fuzz, int $width, int $height): array + { $visited = array_fill(0, $width * $height, false); $queue = []; $enqueue = static function (int $x, int $y) use (&$queue, &$visited, $width, $height): void { @@ -105,26 +179,25 @@ public static function cutImage($img, array $colors, int $fuzz, bool $fromEdges) }; for ($x = 0; $x < $width; $x++) { - if ($match($x, 0)) { + if (self::isKeyPixel($img, $x, 0, $colors, $fuzz)) { $enqueue($x, 0); } - if ($match($x, $height - 1)) { + if (self::isKeyPixel($img, $x, $height - 1, $colors, $fuzz)) { $enqueue($x, $height - 1); } } for ($y = 0; $y < $height; $y++) { - if ($match(0, $y)) { + if (self::isKeyPixel($img, 0, $y, $colors, $fuzz)) { $enqueue(0, $y); } - if ($match($width - 1, $y)) { + if (self::isKeyPixel($img, $width - 1, $y, $colors, $fuzz)) { $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) { + if (self::pixelAlpha(imagecolorat($img, $x, $y)) < 127) { continue; } $enqueue($x + 1, $y); @@ -134,13 +207,14 @@ public static function cutImage($img, array $colors, int $fuzz, bool $fromEdges) } } + $targets = []; $head = 0; while ($head < count($queue)) { [$x, $y] = $queue[$head++]; - if (!$match($x, $y)) { + if (!self::isKeyPixel($img, $x, $y, $colors, $fuzz)) { continue; } - imagesetpixel($img, $x, $y, $clear); + $targets[] = [$x, $y]; foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) { $nx = $x + $dx; $ny = $y + $dy; @@ -151,36 +225,144 @@ public static function cutImage($img, array $colors, int $fuzz, bool $fromEdges) if ($visited[$i]) { continue; } - if (!$match($nx, $ny)) { + if (!self::isKeyPixel($img, $nx, $ny, $colors, $fuzz)) { continue; } $visited[$i] = true; $queue[] = [$nx, $ny]; } } + + return $targets; } /** - * @param list $raw - * @return list + * @param list $colors */ - public static function parseColors(array $raw): array + private static function isKeyPixel($img, int $x, int $y, array $colors, int $fuzz): bool { - $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; + $pixel = imagecolorat($img, $x, $y); + if (self::pixelAlpha($pixel) >= 127) { + return false; + } + + [$r, $g, $b] = self::pixelRgb($pixel); + + return self::minChebyshev($r, $g, $b, $colors) <= $fuzz; + } + + /** + * @param list $colors + */ + private static function minChebyshev(int $r, int $g, int $b, array $colors): int + { + $best = 255; + foreach ($colors as [$cr, $cg, $cb]) { + $d = max(abs($r - $cr), abs($g - $cg), abs($b - $cb)); + if ($d < $best) { + $best = $d; } + } - throw new RuntimeException('colorKey colors must be #RRGGBB'); + return $best; + } + + private static function softGdAlpha(int $distance, int $fuzz): int + { + if ($distance === 0) { + return 127; } - return $out; + if ($fuzz <= 0) { + return 0; + } + + if ($distance >= $fuzz) { + return 0; + } + + return (int)round((1 - $distance / $fuzz) * 127); + } + + private static function featherAlpha($img, int $passes): void + { + $width = imagesx($img); + $height = imagesy($img); + $opacity = []; + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + $opacity[$y * $width + $x] = 127 - self::pixelAlpha(imagecolorat($img, $x, $y)); + } + } + + for ($pass = 0; $pass < $passes; $pass++) { + $next = $opacity; + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + $sum = 0; + for ($dy = -1; $dy <= 1; $dy++) { + for ($dx = -1; $dx <= 1; $dx++) { + $nx = min($width - 1, max(0, $x + $dx)); + $ny = min($height - 1, max(0, $y + $dy)); + $k = abs($dx) + abs($dy); + $weight = $k === 0 ? 4 : ($k === 1 ? 2 : 1); + $sum += $opacity[$ny * $width + $nx] * $weight; + } + } + $next[$y * $width + $x] = (int)round($sum / 16); + } + } + $opacity = $next; + } + + imagealphablending($img, false); + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + [$r, $g, $b] = self::pixelRgb(imagecolorat($img, $x, $y)); + $gdAlpha = 127 - $opacity[$y * $width + $x]; + imagesetpixel($img, $x, $y, imagecolorallocatealpha($img, $r, $g, $b, $gdAlpha)); + } + } + } + + private static function protectDark($img, int $maxLuminance): void + { + $width = imagesx($img); + $height = imagesy($img); + imagealphablending($img, false); + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + $pixel = imagecolorat($img, $x, $y); + $gdAlpha = self::pixelAlpha($pixel); + if ($gdAlpha === 0 || $gdAlpha === 127) { + continue; + } + + [$r, $g, $b] = self::pixelRgb($pixel); + $luminance = (int)(($r * 299 + $g * 587 + $b * 114) / 1000); + if ($luminance > $maxLuminance) { + continue; + } + + imagesetpixel($img, $x, $y, imagecolorallocatealpha($img, $r, $g, $b, 0)); + } + } + } + + /** + * @return array{0:int,1:int,2:int} + */ + private static function pixelRgb(int $pixel): array + { + return [ + ($pixel >> 16) & 0xFF, + ($pixel >> 8) & 0xFF, + $pixel & 0xFF, + ]; + } + + private static function pixelAlpha(int $pixel): int + { + return ($pixel >> 24) & 0x7F; } } diff --git a/tests/ColorKeyOpTest.php b/tests/ColorKeyOpTest.php index d0bced0..dd1b927 100644 --- a/tests/ColorKeyOpTest.php +++ b/tests/ColorKeyOpTest.php @@ -136,6 +136,103 @@ public function testFuzzDoesNotMatchOutsideTolerance(): void $this->assertRgb($img, 0, 0, 9, 0, 0); } + public function testSoftKeyExactMatchIsFullyTransparent(): void + { + $img = $this->solidImage(1, 1, 255, 248, 189); + + ColorKeyOp::cutImage($img, [[255, 248, 189]], 8, false, ['soft' => true]); + + $this->assertTransparent($img, 0, 0); + } + + public function testSoftKeyHalfwayFuzzIsPartialAlpha(): void + { + $img = $this->solidImage(1, 1, 4, 0, 0); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 8, false, ['soft' => true]); + + $this->assertSame(64, $this->pixel($img, 0, 0)[3]); + $this->assertSame([4, 0, 0], array_slice($this->pixel($img, 0, 0), 0, 3)); + } + + public function testSoftKeyOutsideFuzzStaysOpaque(): void + { + $img = $this->solidImage(1, 1, 9, 0, 0); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 8, false, ['soft' => true]); + + $this->assertRgb($img, 0, 0, 9, 0, 0); + } + + public function testFeatherBlursHardKnockoutIntoNeighbors(): void + { + $img = $this->solidImage(3, 3, 255, 255, 255); + imagesetpixel($img, 1, 1, imagecolorallocate($img, 0, 0, 0)); + + ColorKeyOp::cutImage($img, [[0, 0, 0]], 0, false, ['feather' => 1]); + + $center = $this->pixel($img, 1, 1)[3]; + $edge = $this->pixel($img, 1, 0)[3]; + $corner = $this->pixel($img, 0, 0)[3]; + $this->assertGreaterThan(0, $center); + $this->assertGreaterThan(0, $edge); + $this->assertGreaterThan($edge, $center); + $this->assertGreaterThan($corner, $edge); + } + + public function testProtectDarkerThanRestoresPartialDarkPixels(): void + { + $img = $this->solidImage(3, 3, 255, 248, 189); + imagesetpixel($img, 1, 1, imagecolorallocate($img, 20, 20, 20)); + + ColorKeyOp::cutImage( + $img, + [[255, 248, 189]], + 0, + false, + ['feather' => 1, 'protectDarkerThan' => 80] + ); + + $this->assertRgb($img, 1, 1, 20, 20, 20); + $this->assertGreaterThan(0, $this->pixel($img, 0, 0)[3]); + } + + public function testProtectDarkerThanDoesNotRestoreFullyTransparentFills(): void + { + $img = $this->solidImage(1, 1, 20, 20, 20); + + ColorKeyOp::cutImage($img, [[20, 20, 20]], 0, false, ['protectDarkerThan' => 80]); + + $this->assertTransparent($img, 0, 0); + } + + public function testParseOptionsRejectsNegativeFeather(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('colorKey feather must be >= 0'); + ColorKeyOp::parseOptions(['feather' => -1]); + } + + public function testInvokePassesSoftAndFeatherOptions(): void + { + $img = $this->solidImage(1, 1, 4, 0, 0); + $res = $this->resultWithImage($img); + + $out = (new ColorKeyOp())( + static fn (Result $result): Result => $result, + [ + 'colors' => ['#000000'], + 'fuzz' => 8, + 'fromEdges' => false, + 'soft' => true, + ], + $res + ); + + $processed = Utils::bytesToImage($out->getData()); + $this->assertSame(64, $this->pixel($processed, 0, 0)[3]); + } + public function testInvokeEncodesPngAndClearsEdgeKeyColor(): void { $img = $this->solidImage(3, 3, 255, 255, 255);