diff --git a/src/Mike42/ImagePhp/BlackAndWhiteRasterImage.php b/src/Mike42/ImagePhp/BlackAndWhiteRasterImage.php index 8f8f3f5..85cda7c 100644 --- a/src/Mike42/ImagePhp/BlackAndWhiteRasterImage.php +++ b/src/Mike42/ImagePhp/BlackAndWhiteRasterImage.php @@ -156,9 +156,23 @@ public function toGrayscale() : GrayscaleRasterImage } return $img; } - + public function toBlackAndWhite() : BlackAndWhiteRasterImage { return clone $this; } + + public function toIndexed(): IndexedRasterImage + { + $width = $this -> width; + $height = $this -> height; + $pixelData = array_fill(0, $width * $height, 0); + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + $pixelData[$y * $width + $x] = $this -> getPixel($x, $y); + } + } + $colorTable = PaletteGenerator::blackAndWhitePalette(); + return IndexedRasterImage::create($width, $height, $pixelData, $colorTable, 255); + } } diff --git a/src/Mike42/ImagePhp/Codec/GifCodec.php b/src/Mike42/ImagePhp/Codec/GifCodec.php index 405c312..0528cf4 100644 --- a/src/Mike42/ImagePhp/Codec/GifCodec.php +++ b/src/Mike42/ImagePhp/Codec/GifCodec.php @@ -1,9 +1,9 @@ toGrayscale(); - } else { - if ($image -> getMaxVal() != 256) { - // Scaling has the side-effect of mapping to 256 colors - $image = $image -> scale($image -> getWidth(), $image -> getHeight()); - } + $image = $image -> toIndexed(); + } + $palette = $image -> getPalette(); + if ($image -> getMaxVal() > 256) { + // Color count is too large, clone the image and drop the color depth + $image = $image -> toIndexed(); + $image -> setMaxVal(255); + $palette = $image -> getPalette(); } // GIF signature $signature = pack("c6", 0x47, 0x49, 0x46, 0x38, 0x39, 0x61); @@ -28,14 +30,31 @@ public function encode(RasterImage $image, string $format): string $header = pack('v2c3', $width, $height, 0xF7, 0, 0); // Color table of grayscale $colorTable = []; - for ($i = 0; $i < 256; $i++) { - $colorTable[] = $i; - $colorTable[] = $i; - $colorTable[] = $i; + $paletteSize = count($palette); + for ($i = 0; $i < $paletteSize; $i++) { + $entry = $palette[$i]; + $colorTable[] = $entry[0]; + $colorTable[] = $entry[1]; + $colorTable[] = $entry[2]; + } + // Padding to 256 color entries + for ($i = $paletteSize; $i < 256; $i++) { + $colorTable[] = 0; + $colorTable[] = 0; + $colorTable[] = 0; } + $gct = pack("C*", ... $colorTable); + // Transparent color for graphic control + $transparentColorFlag = 0x00; + $transparentColor = 0; + if ($image -> getTransparentColor() !== null) { + $transparentColor = $image -> getTransparentColor() & 0xFF; + $transparentColorFlag = 0x01; + } // Graphic control - $gce = pack("C4vC2", 0x21, 0xF9, 0x04, 0x01, 0x00, 0x10, 0x00); + // TODO one of these flags does not do what you think it does. + $gce = pack("C4vC2", 0x21, 0xF9, 0x04, 0x00 | $transparentColorFlag, 0x00, $transparentColor, 0x00); // Image $imageDescriptor = pack('Cv4C', 0x2C, 0, 0, $width, $height, 0); $raster = $image -> getRasterData(); diff --git a/src/Mike42/ImagePhp/Codec/PnmCodec.php b/src/Mike42/ImagePhp/Codec/PnmCodec.php index c362610..d44e1a7 100644 --- a/src/Mike42/ImagePhp/Codec/PnmCodec.php +++ b/src/Mike42/ImagePhp/Codec/PnmCodec.php @@ -72,7 +72,7 @@ public function decode(string $blob): RasterImage $next_line_end = strpos($blob, "\n", $line_end + 1); $maxValLine = substr($blob, $line_end + 1, ($next_line_end - $line_end) - 1); $maxVal = (int)$maxValLine; - $depth = $maxVal >= 255 ? 2 : 1; + $depth = $maxVal > 255 ? 2 : 1; $line_end = $next_line_end; // Extract data $expectedBytes = $width * $height * $depth; @@ -93,7 +93,7 @@ public function decode(string $blob): RasterImage $next_line_end = strpos($blob, "\n", $line_end + 1); $maxValLine = substr($blob, $line_end + 1, ($next_line_end - $line_end) - 1); $maxVal = (int)$maxValLine; - $depth = $maxVal >= 255 ? 2 : 1; + $depth = $maxVal > 255 ? 2 : 1; $line_end = $next_line_end; $expectedBytes = $width * $height * $depth * 3; $data = substr($blob, $line_end + 1); diff --git a/src/Mike42/ImagePhp/GrayscaleRasterImage.php b/src/Mike42/ImagePhp/GrayscaleRasterImage.php index f4c551b..b5f1411 100644 --- a/src/Mike42/ImagePhp/GrayscaleRasterImage.php +++ b/src/Mike42/ImagePhp/GrayscaleRasterImage.php @@ -20,7 +20,7 @@ public function getHeight() : int { return $this -> height; } - + public function setPixel(int $x, int $y, int $value) { if ($x < 0 || $x >= $this -> width) { @@ -110,7 +110,7 @@ public function toGrayscale() : GrayscaleRasterImage { return clone $this; } - + public function toBlackAndWhite() : BlackAndWhiteRasterImage { $img = BlackAndWhiteRasterImage::create($this -> width, $this -> height); @@ -123,4 +123,16 @@ public function toBlackAndWhite() : BlackAndWhiteRasterImage } return $img; } + + public function toIndexed(): IndexedRasterImage + { + if ($this -> maxVal > 255) { + // Making use of how scale() uses default values to make a new canvas, which has the + // side-effect of creating an 8-bit image. + return $this -> scale($this -> width, $this -> height) -> toIndexed(); + } + $data = $this -> data; + $colorTable = PaletteGenerator::monochromePalette(); + return IndexedRasterImage::create($this -> width, $this -> height, $data, $colorTable, 255); + } } diff --git a/src/Mike42/ImagePhp/IndexedRasterImage.php b/src/Mike42/ImagePhp/IndexedRasterImage.php new file mode 100644 index 0000000..4e17e0e --- /dev/null +++ b/src/Mike42/ImagePhp/IndexedRasterImage.php @@ -0,0 +1,282 @@ + width = $width; + $this -> height = $height; + $this -> data = $data; + $this -> palette = $palette; + $this -> maxVal = $maxVal; + } + + public function getPalette() + { + return $this -> palette; + } + + public function getRasterData(): string + { + + if ($this -> maxVal > 255) { + return pack("n*", ... $this -> data); + } + return pack("C*", ... $this -> data); + } + + public function getHeight(): int + { + return $this -> height; + } + + public function getMaxVal() + { + return $this -> maxVal; + } + + public function setPixel(int $x, int $y, int $value) + { + if ($x < 0 || $x >= $this -> width) { + return; + } + if ($y < 0 || $y >= $this -> height) { + return; + } + // Use 0 if $value is out of range + if ($value < 0 || $value > $this -> maxVal) { + $value = 0; + } + $byte = $y * $this -> width + $x; + $this -> data[$byte] = $value; + } + + public function toRgb(): RgbRasterImage + { + $img = RgbRasterImage::create($this -> width, $this -> height); + for ($y = 0; $y < $this -> height; $y++) { + for ($x = 0; $x < $this -> width; $x++) { + $original = $this -> indexToRgb($this -> getPixel($x, $y)); + $val = $img -> rgbToInt($original[0], $original[1], $original[2]); + $img -> setPixel($x, $y, $val); + } + } + return $img; + } + + public function toBlackAndWhite() : BlackAndWhiteRasterImage + { + $img = BlackAndWhiteRasterImage::create($this -> width, $this -> height); + for ($y = 0; $y < $this -> height; $y++) { + for ($x = 0; $x < $this -> width; $x++) { + $original = $this -> indexToRgb($this -> getPixel($x, $y)); + $lightness = intdiv($original[0] + $original[1] + $original[2], 3); + $img -> setPixel($x, $y, $lightness > 128 ? 0 : 1); + } + } + return $img; + } + + public function toGrayscale(): GrayscaleRasterImage + { + $img = GrayscaleRasterImage::create($this -> width, $this -> height); + for ($y = 0; $y < $this -> height; $y++) { + for ($x = 0; $x < $this -> width; $x++) { + $original = $this -> indexToRgb($this -> getPixel($x, $y)); + $lightness = intdiv($original[0] + $original[1] + $original[2], 3); + $img -> setPixel($x, $y, $lightness); + } + } + return $img; + } + + public function getPixel(int $x, int $y) + { + if ($x < 0 || $x >= $this -> width) { + return; + } + if ($y < 0 || $y >= $this -> height) { + return; + } + $byte = $y * $this -> width + $x; + return $this -> data[$byte]; + } + + public function getWidth(): int + { + return $this -> width; + } + + public function toIndexed(): IndexedRasterImage + { + return clone $this; + } + + public function indexToRgb(int $index) + { + if ($index >= 0 && $index < count($this -> palette)) { + // Defined index + return $this -> palette[$index]; + } + // Black + return [0, 0, 0]; + } + + public function rgbToIndex(array $rgb) + { + $ret = array_search($rgb, $this -> palette, true); + if ($ret !== false) { + // Index of defined color + return $ret; + } + // First color. + return 0; + } + + public function getTransparentColor() + { + return $this -> transparentColor; + } + + public function setTransparentColor(int $color = null) + { + $this -> transparentColor = $color; + } + + public function setPalette(array $palette) + { + $palette = self::validatePalette($palette); + // Build map of old palette colors to new ones + $map = []; + foreach ($this -> palette as $id => $color) { + $map[$id] = self::closestColorId($color, $palette); + } + // Replace existing data values using the map + foreach ($this -> data as $k => $v) { + $this -> data[$k] = $map[$v]; + } + // Swap palette in the background + $this -> palette = $palette; + } + + protected static function closestColorId(array $color, array $palette) + { + $closest = 0; + $distance = (256 ** 2) * 3; + foreach ($palette as $id => $compare) { + $compareDist = (($color[0] - $compare[0]) ** 2) + + (($color[1] - $compare[1]) ** 2) + + (($color[2] - $compare[2]) ** 2); + if ($compareDist < $distance) { + $closest = $id; + $distance = $compareDist; + } + } + return $closest; + } + + public function setMaxVal(int $maxVal) + { + if ($maxVal >= count($this -> palette) - 1) { + // No need to adjust palette + $this -> maxVal = $maxVal; + return; + } + // TODO construct suitably-sized palette using quantization + if ($maxVal >= 255) { + $this -> maxVal = $maxVal; + $this -> setPalette(PaletteGenerator::colorPalette()); + return; + } else if ($maxVal >= 2) { + $this -> maxVal = $maxVal; + $this -> setPalette(PaletteGenerator::blackAndWhitePalette()); + return; + } else if ($maxVal >= 1) { + $this -> maxVal = $maxVal; + $this -> setPalette(PaletteGenerator::whitePalette()); + return; + } + throw new \Exception("Image must contain at least one color"); + } + + public function allocateColor(array $color) + { + $idx = count($this -> palette); + if ($idx > $this -> maxVal) { + throw new \Exception("Palette is at its maximum size"); + } + $this -> palette[] = $color; + return $idx; + } + + public function deallocateColor(array $color) + { + // TODO + throw new \Exception("Not implemented"); + } + + public static function create(int $width, int $height, array $data = null, array $palette = null, int $maxVal = 255) + { + $expectedSize = $width * $height; + if ($data == null) { + // Empty image, white background + if (count($palette) == 0) { + $palette = PaletteGenerator::whitePalette(); // White + } + $data = array_fill(0, $expectedSize, 0); + } + // Validation + $actualSize = count($data); + if ($actualSize !== $expectedSize) { + throw new \Exception("Expected $expectedSize pixels for $width x $height image, but got $actualSize."); + } + // Normalise palette data + $newPalette = self::validatePalette($palette); + // Validate that we can render this data with this palette + $highestPaletteValue = count($palette) - 1; + $highestPixel = max($data); + if ($highestPixel > $highestPaletteValue) { + throw new \Exception("Expected all image values to be <= the palette size ($highestPaletteValue), but the highest is $highestPixel."); + } + $highestPixel = max($data); + if ($highestPixel > $highestPaletteValue) { + throw new \Exception("Image data cannot be rendered with this palette. The palette contains values up to $highestPaletteValue, but image values go up to $highestPixel."); + } + return new IndexedRasterImage($width, $height, $data, $maxVal, $palette); + } + + protected static function validatePalette($palette) + { + // So that we know that we aren't missing any keys, palette should be array, not map. + // Palette entries must be array of three values up to 255 for R, G, B. + // It's slightly easier to just convert the structure than to check all of this + $newPalette = []; + foreach ($palette as $color) { + if (!is_array($color) || count($color) !== 3) { + throw new \Exception("Bad palette data: Need three values per entry."); + } + // Eradicate keys and non-numeric values + $color = array_values($color); + $color = [(int)$color[0], (int)$color[1], (int)$color[2]]; + // Gets written to image formats with 8-bits for each value + if (max($color) > 255) { + throw new \Exception("Bad palette data: Entries cannot exceed 255."); + } + $newPalette[] = $color; + } + return $newPalette; + } +} diff --git a/src/Mike42/ImagePhp/PaletteGenerator.php b/src/Mike42/ImagePhp/PaletteGenerator.php new file mode 100644 index 0000000..2594fb7 --- /dev/null +++ b/src/Mike42/ImagePhp/PaletteGenerator.php @@ -0,0 +1,46 @@ +> 5) & 0x07; + $g = ($i >> 2) & 0x07; + $b = $i & 0x03; + + // Scaled 256-level values + $rScaled = (int)round($r * (255 / 7)); + $gScaled = (int)round($g * (255 / 7)); + $bScaled = (int)round($b * (255 / 3)); + + $colorTable[] = [$rScaled, $gScaled, $bScaled]; + } + return $colorTable; + } + + public static function whitePalette() + { + return [[255, 255, 255]]; + } +} diff --git a/src/Mike42/ImagePhp/RasterImage.php b/src/Mike42/ImagePhp/RasterImage.php index d7a6d31..2f6b94d 100644 --- a/src/Mike42/ImagePhp/RasterImage.php +++ b/src/Mike42/ImagePhp/RasterImage.php @@ -20,7 +20,9 @@ public function getPixel(int $x, int $y); public function setPixel(int $x, int $y, int $value); - public function toGrayscale(); + public function toGrayscale() : GrayscaleRasterImage; - public function toBlackAndWhite(); + public function toBlackAndWhite() : BlackAndWhiteRasterImage; + + public function toIndexed() : IndexedRasterImage; } diff --git a/src/Mike42/ImagePhp/RgbRasterImage.php b/src/Mike42/ImagePhp/RgbRasterImage.php index 774b911..ccab971 100644 --- a/src/Mike42/ImagePhp/RgbRasterImage.php +++ b/src/Mike42/ImagePhp/RgbRasterImage.php @@ -4,23 +4,23 @@ class RgbRasterImage extends AbstractRasterImage { protected $width; - + protected $height; - + protected $data; - + protected $maxVal; - + public function getWidth() : int { return $this -> width; } - + public function getHeight() : int { return $this -> height; } - + public function getRasterData(): string { if ($this -> maxVal > 255) { @@ -28,12 +28,12 @@ public function getRasterData(): string } return pack("C*", ... $this -> data); } - + public function getMaxVal() { return $this -> maxVal; } - + public function getPixel(int $x, int $y) { if ($x < 0 || $x >= $this -> width) { @@ -45,6 +45,16 @@ public function getPixel(int $x, int $y) $byte = ($y * $this -> width + $x) * 3; return self::rgbToInt($this -> data[$byte], $this -> data[$byte + 1], $this -> data[$byte + 2], $this -> maxVal); } + + public function indexToRgb(int $val) + { + return self::intToRgb($val); + } + + public function rgbToIndex(array $val) + { + return self::rgbToInt($val[0], $val[1], $val[2]); + } public static function rgbToInt(int $r, int $g, int $b) { @@ -91,7 +101,7 @@ protected function __construct($width, $height, array $data, int $maxVal) $this -> data = $data; $this -> maxVal = $maxVal; } - + public static function create($width, $height, array $data = null, $maxVal = 255) : RgbRasterImage { $expectedBytes = $width * $height * 3; @@ -104,19 +114,19 @@ public static function create($width, $height, array $data = null, $maxVal = 255 } return new RgbRasterImage($width, $height, $data, $maxVal); } - + public static function convertDepth(&$item, $key, array $data) { $maxVal = $data[0]; $newMaxVal = $data[1]; $item = intdiv($item * $newMaxVal, $maxVal); } - + public function toRgb() : RgbRasterImage { return clone $this; } - + public function toGrayscale() : GrayscaleRasterImage { $img = GrayscaleRasterImage::create($this -> width, $this -> height); @@ -129,7 +139,7 @@ public function toGrayscale() : GrayscaleRasterImage } return $img; } - + public function toBlackAndWhite() : BlackAndWhiteRasterImage { $img = BlackAndWhiteRasterImage::create($this -> width, $this -> height); @@ -142,4 +152,28 @@ public function toBlackAndWhite() : BlackAndWhiteRasterImage } return $img; } + + public function toIndexed(): IndexedRasterImage + { + // NB: It might be possible to speed this up with array_fill_keys and array_replace. + // Each pixel as a numeric value + $pixels = array_map([$this, "rgbToIndex"], array_chunk($this -> data, 3, false)); + // List of unique colors + $colorValues = array_values(array_unique($pixels, SORT_NUMERIC)); + $paletteSize = count($colorValues); + // Use 24-bit color number as key, palette index as value + $lookup = array_flip($colorValues); + // Replace palette values w/ expanded [r, g, b] values for use in IndexedRasterImage + for ($i = 0; $i < $paletteSize; $i++) { + $colorValues[$i] = $this -> intToRgb($colorValues[$i]); + } + // Replace pixel values with color ID's + $imageSize = count($pixels); + for ($i = 0; $i < $imageSize; $i++) { + $pixels[$i] = $lookup[$pixels[$i]]; + } + // Max value round-off to 256 colors if possible, otherwise leave unlimited + $maxVal = $paletteSize > 256 ? 16777215 : 255; + return IndexedRasterImage::create($this -> width, $this -> height, $pixels, $colorValues, $maxVal); + } }