From d9f94688246a16a0c8211ce88453f158c8e691b8 Mon Sep 17 00:00:00 2001 From: Troy Sornson Date: Fri, 1 Mar 2024 22:09:18 -0700 Subject: [PATCH] Add crystal native implementation of PNG support --- BENCHMARKS.md | 2 + README.md | 4 +- scripts/benchmark.cr | 7 ++- shard.yml | 4 ++ spec/cr-image/format/png_spec.cr | 4 +- spec/spec_helper.cr | 1 - src/cr-image.cr | 1 + src/cr-image/format/png.cr | 82 ++++++++++++++++++++++++++++++++ src/cr-image/image.cr | 1 + 9 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 src/cr-image/format/png.cr diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 5b9577f..c4325d4 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -81,6 +81,8 @@ For these tests, with `template[n]`, `template = CrImage::OneMap.new(n, n)` | ------------------------------------ | -------- | ------- | | to_gray | 1.95ms | 732kiB | | to_ppm(IO::Memory.new) | 6.9ms | 4.01MiB | +|libspng to_png(IO::Memory.new) | 119.5ms | 1.55MiB | +|native crystal to_png(IO::Memory.new) | 117.86ms | 1.93MiB | | to_jpeg(IO::Memory.new) | 10.24ms | 1.2MiB | | to_webp(IO::Memory.new) | 213.93ms | 1.5MiB | | to_webp(IO::Memory.new, lossy: true) | 38.07ms | 1.45MiB | diff --git a/README.md b/README.md index fd182af..b9e12c6 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ All sample images used are from [Unsplash](https://unsplash.com/). CrImage supports the formats: * PPM * JPEG (requires `libturbojpeg`) -* PNG (requirens `libspng`) +* PNG (natively by default, or optionally through requires `libspng`) * WebP (requires `libwebp`) For the formats that require a linked library, they must be `require`d explicitly: @@ -33,8 +33,8 @@ For the formats that require a linked library, they must be `require`d explicitl ```crystal require "cr-image" require "cr-image/jpeg" -require "cr-image/png" require "cr-image/webp" +require "cr-image/png" # replaces native crystal with libspng # Or, alternatively require "cr-image/all_formats" diff --git a/scripts/benchmark.cr b/scripts/benchmark.cr index d0aa8a4..5d809dd 100755 --- a/scripts/benchmark.cr +++ b/scripts/benchmark.cr @@ -1,7 +1,8 @@ #!/usr/bin/env -S crystal run --no-debug --release require "../src/cr-image" -require "../src/all_formats" +require "../src/webp" +require "../src/jpeg" record Result, name : String, time : Float64, memory : Int64 alias Color = CrImage::Color @@ -156,6 +157,10 @@ results.clear results << benchmark { image.to_gray } results << benchmark { image.to_ppm(IO::Memory.new) } +# NOTE: to get the benchmark for libspng, you need to: +# require "../src/png" +# Since the perf of both is so close, the default is for the native implementation +results << benchmark { image.to_png(IO::Memory.new) } results << benchmark { image.to_jpeg(IO::Memory.new) } results << benchmark { image.to_webp(IO::Memory.new) } results << benchmark { image.to_webp(IO::Memory.new, lossy: true) } diff --git a/shard.yml b/shard.yml index 022aa5a..de532bc 100644 --- a/shard.yml +++ b/shard.yml @@ -4,6 +4,10 @@ version: 0.1.0 authors: - Troy Sornson +dependencies: + png: + github: sleepinginsomniac/png + development_dependencies: spectator: gitlab: arctic-fox/spectator diff --git a/spec/cr-image/format/png_spec.cr b/spec/cr-image/format/png_spec.cr index a7b061e..cd19977 100644 --- a/spec/cr-image/format/png_spec.cr +++ b/spec/cr-image/format/png_spec.cr @@ -10,7 +10,7 @@ Spectator.describe CrImage::Format::PNG do io = IO::Memory.new image.to_png(io) - expect_digest(io.to_s).to eq "c9450089873a4bbe98a2aa54f1bea959915ba088" + expect_digest(io.to_s).to eq "8635306d7a0201533e8812ebf3671b9c2e31c4b0" end end @@ -20,7 +20,7 @@ Spectator.describe CrImage::Format::PNG do io = IO::Memory.new image.to_png(io) - expect_digest(io.to_s).to eq "4cac53568704ac1617cd96313658559f22c1a24b" + expect_digest(io.to_s).to eq "4623c3074b418ca58a9c083297baa3fefaad8f1c" end end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 48e58b7..d27ee0b 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -2,7 +2,6 @@ require "digest" require "spectator" require "../src/cr-image" require "../src/jpeg" -require "../src/png" require "../src/webp" require "./helpers/**" diff --git a/src/cr-image.cr b/src/cr-image.cr index d6ad036..1ab4870 100644 --- a/src/cr-image.cr +++ b/src/cr-image.cr @@ -7,6 +7,7 @@ require "./cr-image/format/open" # Native crystal image format implementations require "./cr-image/format/ppm" +require "./cr-image/format/png" # Require `image` first, and then subclasses of it require "./cr-image/image" diff --git a/src/cr-image/format/png.cr b/src/cr-image/format/png.cr new file mode 100644 index 0000000..f08765a --- /dev/null +++ b/src/cr-image/format/png.cr @@ -0,0 +1,82 @@ +require "png" + +# Provides methods to read from and write to PNG. Requires `libspng` to function. +# +# ``` +# image = File.open("image.png") { |file| CrImage::RGBAImage.from_png(file) } +# File.open("other_image.png") { |file| image.to_png(file) } +# ``` +# Alternatively, you can use the convenience methods in the `Open` and `Save` modules +# to acheive the same thing: +# ``` +# image = CrImage::RGBAImage.open("image.png") +# image.save("other_image.png") +# ``` +module CrImage::Format::PNG + {% CrImage::Format::SUPPORTED_FORMATS << {extension: ".png", method: "png"} %} + + macro included + # Read `image_data` and PNG encoded bytes + def self.from_png(image_data : Bytes) : self + from_png(IO::Memory.new(image_data)) + end + + # Construct an Image by reading bytes from `io` + def self.from_png(io : IO) : self + png = ::PNG.read(io) + + width = png.width.to_i32 + height = png.height.to_i32 + + red = Array.new(width * height) { ChannelType::Red.default } + green = Array.new(width * height) { ChannelType::Green.default } + blue = Array.new(width * height) { ChannelType::Blue.default } + alpha = Array.new(width * height) { ChannelType::Alpha.default } + + red_offset = 0 + green_offset = 1 + blue_offset = 2 + alpha_offset = 3 + jump = png.color_type.channels + + case jump + when 1 + red_offset = green_offset = blue_offset = 0 + alpha_offset = -1 + when 2 + red_offset = green_offset = blue_offset = 0 + alpha_offset = 1 + when 3 + alpha_offset = -1 + end + + (width * height).times do |index| + png.data[index * jump] + red.unsafe_put(index, png.data[index * jump + red_offset]) + green.unsafe_put(index, png.data[index * jump + green_offset]) + blue.unsafe_put(index, png.data[index * jump + blue_offset]) + if alpha_offset > -1 + alpha.unsafe_put(index, png.data[index * jump + alpha_offset]) + end + end + + new(red, green, blue, alpha, width, height) + end + end + + # Output the image as PNG to `io` + def to_png(io : IO) : Nil + bytes = Bytes.new(size * 4) + idx = 0 + (size * 4).times.step(4).each do |index| + bytes.unsafe_put(index, red[idx]) + bytes.unsafe_put(index + 1, green[idx]) + bytes.unsafe_put(index + 2, blue[idx]) + bytes.unsafe_put(index + 3, alpha[idx]) + idx += 1 + end + canvas = ::PNG::Canvas.new(::PNG::Header.new(width.to_u32, height.to_u32, color_type: ::PNG::ColorType::TrueColorAlpha), bytes) + + ::PNG.write(io, canvas) + end +end diff --git a/src/cr-image/image.cr b/src/cr-image/image.cr index bbb1cb1..f849900 100644 --- a/src/cr-image/image.cr +++ b/src/cr-image/image.cr @@ -11,6 +11,7 @@ abstract class CrImage::Image macro inherited include Format::PPM + include Format::PNG include Operation::BilinearResize include Operation::BoxBlur