diff --git a/.github/workflows/Dockerfile.alpine b/.github/workflows/Dockerfile.alpine index 3e72588..ae725c7 100644 --- a/.github/workflows/Dockerfile.alpine +++ b/.github/workflows/Dockerfile.alpine @@ -10,8 +10,8 @@ RUN \ apk update RUN : \ - && apk add --no-cache sudo git alpine-sdk sed bash openimageio-dev opencv-dev vips-dev imagemagick ghostscript-fonts rsvg-convert \ - pandoc build-base libffi-dev openblas-dev freetype-dev libpng-dev jpeg-dev tiff-dev lcms2-dev jq \ + && apk add --no-cache sudo git alpine-sdk sed bash openimageio-dev opencv-dev vips-dev vips-tools imagemagick ghostscript-fonts rsvg-convert \ + pandoc build-base libffi-dev openblas-dev freetype-dev libpng-dev jpeg-dev tiff-dev lcms2-dev jq exiftool \ python3-dev py3-opencv py3-numpy py3-scipy py3-matplotlib py3-pip py3-wheel py3-numpy py3-scipy \ && apk cache clean && apk cache purge \ && pip install --no-cache-dir --break-system-packages scikit-image diff --git a/.gitignore b/.gitignore index 8a81382..1049c0b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ x* __pycache__ olddocs/ report.md +# Re-include tests/ (files under an ignored parent directory need explicit negations) +!tests/ +!tests/formats/ diff --git a/Makefile.mk b/Makefile.mk index d10e34a..811dd76 100644 --- a/Makefile.mk +++ b/Makefile.mk @@ -120,6 +120,15 @@ check-syntax: && ( grep $$TCOLOR -nE '\bprint *\(' -r src/*.py || : ) .PHONY: check-syntax +# == check-formats == +# Verify pixel format, alpha, CMYK and metadata handling of "imagewmark add" +tests/formats/check: imagewmark tests/formats/check-formats.sh + $(QCHECK) + $Q tests/formats/check-formats.sh + $(QOK) +.PHONY: tests/formats/check +check: tests/formats/check + # == check == check: check-syntax .PHONY: check diff --git a/NEWS.md b/NEWS.md index 7490f46..5046755 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,8 +2,11 @@ ### Added: * Added `results.txt` generation to test suite, listing best JSD scores per extraction +* Added formats check to `make check`, verifying bit depth, alpha, CMYK and float pixel handling ### Changed: +* Preserve input image bit depth (8/16 bit, float), alpha channel, CMYK format and + colorspace in the C++ embedding pipeline; convert CMYK to RGB for non-JPEG/TIFF images * Replaced OpenCV with libvips for the C++ watermark embedding pipeline * Moved to affine transform with bicubic interpolation for image resizing * Optimized PNG compression options when saving embedded images diff --git a/cxx/embed.cc b/cxx/embed.cc index aa75e40..a39f994 100644 --- a/cxx/embed.cc +++ b/cxx/embed.cc @@ -222,6 +222,14 @@ wm_range (const VImage &W, float strength) return 16; } +/// Clamp all pixel values into [lo, hi] +static VImage +clamp_range (const VImage &img, double lo, double hi) +{ + const VImage low = (img < lo).ifthenelse (lo, img); + return (low > hi).ifthenelse (hi, low); +} + /** Clip the host image to leave head-room for the watermark. * we need some headroom to add the watermark, so in this function we ensure that * ll channels of the image are in range [r, 255 - r], where r is the is the @@ -237,8 +245,7 @@ wm_pre_clip (const VImage &img, const VImage &W, float strength) * individual color channels */ const double r = wm_range (W, strength); - const VImage clipped = (img < r).ifthenelse (r, img); - return (clipped > 255 - r).ifthenelse (255 - r, clipped); + return clamp_range (img, r, 255 - r); } #if 0 @@ -264,15 +271,63 @@ print_first_pixels (const VImage &img, int n = 16) } #endif -/// Convert from float to int; see: dither.py:round_pixels() +/// Normalization factor mapping native pixel values into the canonical [0,255] +/// float range the watermark algorithm works in (mirrors common.py's 0-255 range). +static double +format_scale (VipsBandFormat format) +{ + switch (format) { + case VIPS_FORMAT_UCHAR: return 1.0; // 8-bit [0,255] + case VIPS_FORMAT_USHORT: return 255.0 / 65535.0; // 16-bit [0,65535] + case VIPS_FORMAT_FLOAT: + case VIPS_FORMAT_DOUBLE: return 255.0; // floating point [0,1] + default: + die (1, "unsupported image pixel format: %s", vips_enum_nick (VIPS_TYPE_BAND_FORMAT, format)); + } +} + +/// Normalize an image into the canonical [0,255] float range the watermark +/// algorithm works in. 8-bit input has scale 1.0 and skips the multiply. static VImage -round_pixels (const VImage &img) +image_to_canonical (const VImage &img) { - // Add 0.5 to round to nearest integer when converting to 8-bit unsigned integer - //dprintf (2, "round_pixels: image type (before) = %s %s\n", vips_enum_nick (VIPS_TYPE_INTERPRETATION, img.interpretation()), vips_enum_nick (VIPS_TYPE_BAND_FORMAT, img.format())); - VImage result = (img + 0.5).cast (VIPS_FORMAT_UCHAR); - //dprintf (2, "round_pixels: image type (after) = %s %s\n", vips_enum_nick (VIPS_TYPE_INTERPRETATION, img.interpretation()), vips_enum_nick (VIPS_TYPE_BAND_FORMAT, img.format())); - return result; + const double scale = format_scale (img.format()); + VImage result = img.cast (VIPS_FORMAT_FLOAT); + return scale == 1.0 ? result : result * scale; +} + +/// Convert canonical [0,255] floats back into the host image's native pixel +/// format, preserving its bit depth; see: dither.py:round_pixels() +static VImage +round_pixels (const VImage &img, VipsBandFormat format, VipsInterpretation native_interp) +{ + VImage scaled; + if (format == VIPS_FORMAT_FLOAT || format == VIPS_FORMAT_DOUBLE) + scaled = img / 255.0; // floating point formats are stored in [0,1] + else { + // Scale back into the native value range (skipped for 8-bit input); add + // 0.5 to round to nearest integer, .cast() clips out-of-range values + const double scale = format_scale (format); + scaled = (scale == 1.0 ? img : img / scale) + 0.5; + } + // Determine the interpretation to restore on save: + VipsInterpretation interp; + if (format == VIPS_FORMAT_FLOAT || format == VIPS_FORMAT_DOUBLE) + interp = native_interp; // floating point pixels keep the native interpretation + else if (native_interp == VIPS_INTERPRETATION_CMYK) + interp = VIPS_INTERPRETATION_CMYK; // CMYK colorspace round trip + else if (img.bands() <= 1) + // Use the 16-bit interpretation variant for USHORT, otherwise savers + // (e.g. pngsave) down-convert 16-bit images to 8-bit + interp = format == VIPS_FORMAT_USHORT ? VIPS_INTERPRETATION_GREY16 : VIPS_INTERPRETATION_B_W; + else + interp = format == VIPS_FORMAT_USHORT ? VIPS_INTERPRETATION_RGB16 : VIPS_INTERPRETATION_sRGB; + // Nothing to convert if the pipeline already produced the target format + if (scaled.format() == format && scaled.interpretation() == interp) + return scaled; + // The .copy() also resets the image pipeline to avoid libvips "invalid + // buffer size" errors after complex operations like bandsplit + arithmetic + return scaled.cast (format).copy (VImage::option()->set ("interpretation", interp)); } // "ITU-R BT.1700 Characteristics of composite video signals for conventional analogue television systems" @@ -299,23 +354,37 @@ yiq2rgb_matrix() return VImage::new_matrix (3, 3, const_cast (matrix), 9); } -/// Embed the watermark +/// Naive device color conversion CMYK→RGB, mirrors libvips' fallback CMYK +/// handling: R = (1-C)·(1-K), G = (1-M)·(1-K), B = (1-Y)·(1-K), bands [0,255] +static VImage +cmyk_to_rgb (const VImage &c, const VImage &m, const VImage &y, const VImage &k) +{ + const VImage one_minus_k = (255.0 - k) / 255.0; + return VImage::bandjoin ({ (255.0 - c) * one_minus_k, + (255.0 - m) * one_minus_k, + (255.0 - y) * one_minus_k }); +} + +/// Embed the watermark into the luminance channel of a canonical [0,255] float +/// image. Greyscale and RGB images follow the Python implementation; for CMYK +/// the luminance is taken from the naive RGB equivalent and the C,M,Y channels +/// are adjusted by the luminance delta while the K channel stays untouched. static VImage add_watermark (const VImage &src, const VImage &W, double strength, const AddOptions &options) { - // Convert to float and apply head-room clipping - // TODO: check element type of Mat src, do we need .cast ? - // TODO: check, can we use single pass for cast(float) + clip ? - VImage img = wm_pre_clip (src.cast (VIPS_FORMAT_FLOAT), W, strength); // Extract the Y (luminance) channel - VImage yiq, Y; + VImage Y; std::vector ch; if (src.bands() == 1) - Y = img; + Y = wm_pre_clip (src, W, strength); else if (src.bands() == 3) { - yiq = img.recomb (rgb2yiq_matrix()); + const VImage yiq = wm_pre_clip (src, W, strength).recomb (rgb2yiq_matrix()); ch = yiq.bandsplit(); Y = ch[0]; + } else if (src.bands() == 4) { + ch = src.bandsplit(); + const VImage rgb = wm_pre_clip (cmyk_to_rgb (ch[0], ch[1], ch[2], ch[3]), W, strength); + Y = rgb.recomb (rgb2yiq_matrix()).extract_band (0); } else die (1, "Input image with %d channels not supported", src.bands()); // Compute local variance @@ -324,22 +393,33 @@ add_watermark (const VImage &src, const VImage &W, double strength, const AddOpt VImage I_s = compute_F (I_var, strength); // Add the scaled watermark VImage Y_wm = Y + W * I_s; - // TODO: Check if we need to clip Y_wm back to [0,255] range, or do we always have enough headroom, or do we auto-clip later ? // Reconstruct image from luminance channel if (src.bands() == 3) { ch[0] = Y_wm; return VImage::bandjoin (ch).recomb (yiq2rgb_matrix()); + } else if (src.bands() == 4) { + // C' = C - (Y'-Y)·255/(255-K), keeping K and the black generation intact; + // pixels with K=255 are pure black and cannot change luminance, keep the + // original separation there + const VImage delta = Y_wm - Y; + const VImage denom = 255.0 - ch[3]; + const VImage factor = (denom < 0.5).ifthenelse (0.0, 255.0 / denom); + const VImage delta_factor = delta * factor; + ch[0] = clamp_range (ch[0] - delta_factor, 0, 255); + ch[1] = clamp_range (ch[1] - delta_factor, 0, 255); + ch[2] = clamp_range (ch[2] - delta_factor, 0, 255); + return VImage::bandjoin (ch); } else return Y_wm; } -/// PSNR (peak-signal-to-noise ratio), see: common.py:psnr() +/// PSNR (peak-signal-to-noise ratio) of two canonical [0,255] float images, +/// see: common.py:psnr() static double compute_psnr (const VImage &orig, const VImage &wm) { - // TODO: is .cast(FLOAT) needed ? // Mean Squared Error: Σ |orig - wm|² / (H × W × bands) - const double mse = (orig.cast (VIPS_FORMAT_FLOAT) - wm.cast (VIPS_FORMAT_FLOAT)).pow (2.0).avg(); + const double mse = (orig - wm).pow (2.0).avg(); // No difference at all? if (mse == 0) return 100; @@ -355,8 +435,9 @@ save_host_image (const VImage &img, const std::string &path, const std::string & // Write the result including ALL metadata auto save_opts = VImage::option()->set ("keep", VIPS_FOREIGN_KEEP_ALL); // drop meta-data: VIPS_FOREIGN_KEEP_NONE - // TODO: preserve number of input image channels - // TODO: preserve bit depth (8bpp, 16bpp) when writing + // The input image's channels (incl. alpha) and bit depth are preserved by + // load_host_image() + round_pixels(); savers may still down-convert where + // the target format cannot hold them (e.g. JPEG is always 8-bit) // Check if the output is a PNG (case-insensitive) std::string out_lower = path; @@ -391,28 +472,82 @@ save_host_image (const VImage &img, const std::string &path, const std::string & img.write_to_file (path.c_str(), save_opts); } -static VImage +/// True if the output file extension selects a saver that can store CMYK +static bool +save_supports_cmyk (const std::string &path) +{ + const std::string lower = string_tolower (path); + return string_endswith (lower, ".jpg") || string_endswith (lower, ".jpeg") || + string_endswith (lower, ".tif") || string_endswith (lower, ".tiff"); +} + +/// True if the output file extension selects a saver that can store an alpha channel +static bool +save_supports_alpha (const std::string &path) +{ + const std::string lower = string_tolower (path); + for (const char *ext : { ".jpg", ".jpeg", ".jpe", ".jfif", + ".ppm", ".pgm", ".pbm", ".pnm", ".pfm", ".hdr" }) + if (string_endswith (lower, ext)) + return false; + return true; +} + +/// True if the output file extension selects a saver that can store floating point pixels +static bool +save_supports_float (const std::string &path) +{ + const std::string lower = string_tolower (path); + for (const char *ext : { ".tif", ".tiff", ".pfm", ".hdr", ".exr", ".jxl", ".v" }) + if (string_endswith (lower, ext)) + return true; + return false; +} + +/// Host image loaded for watermarking: the watermarkable channels (greyscale, +/// RGB or CMYK) in their native pixel format, with any alpha channel split off +/// so it can pass through untouched. `format` and `interpretation` record the +/// pixel storage to restore on save (bit depth, colorspace round trip). +struct HostImage { + VImage img; // greyscale, RGB or CMYK channels + VImage alpha; // split-off alpha channel + bool has_alpha = false; // true if alpha was split off + VipsBandFormat format = VIPS_FORMAT_UCHAR; // native pixel format of img + VipsInterpretation interpretation = VIPS_INTERPRETATION_sRGB; // colorspace of img +}; + +/// Load the host image, preserving all channels (including alpha) and the +/// original pixel format (bit depth). Only the watermarkable channels are +/// kept in `img`, the alpha channel is split off and rejoined untouched. +static HostImage load_host_image (const std::string &path) { VImage host = VImage::new_from_file (path.c_str()); - if (host.has_alpha()) - // TODO: we need load -> save handling that preserves alpha channel - host = host.extract_band (0, VImage::option()->set ("n", host.bands() - 1)); - if (host.bands() == 1) { - // TODO: greyscale should be handled everywhere - std::vector rgb = { host, host, host }; - host = VImage::bandjoin (rgb); - } else if (host.bands() > 3) { + if (host.coding() != VIPS_CODING_NONE) // e.g. LabQ + host = host.colourspace (VIPS_INTERPRETATION_sRGB); + HostImage result; + result.format = host.format(); + result.interpretation = host.interpretation(); + // Split off the alpha channel (kept in native format, rejoined untouched) + if (host.bands() == 5 && host.has_alpha()) { + result.has_alpha = true; + result.alpha = host.extract_band (4); + host = host.extract_band (0, VImage::option()->set ("n", 4)); + } else if (host.bands() == 4 && host.has_alpha()) { + result.has_alpha = true; + result.alpha = host.extract_band (3); host = host.extract_band (0, VImage::option()->set ("n", 3)); - // TODO: this must properly handle CMYK -> RGB, we need something like VIPS_INTERPRETATION_sRGB conversion like cv2.IMREAD_COLOR + } else if (host.bands() == 2 && host.has_alpha()) { + result.has_alpha = true; + result.alpha = host.extract_band (1); + host = host.extract_band (0); } - if (host.bands() != 3) - die (1, "failed to load RGB image: %s", path.c_str()); - // TODO: normalize pixel range, so we dont deal with 0-255 for 8bit and 0-65535 for 16bit images - if (host.format() != VIPS_FORMAT_UCHAR) - // TODO: investigate if using FLOAT everywhere is better, esp for 16bit images - host = host.cast (VIPS_FORMAT_UCHAR); - return host.copy (VImage::option()->set ("interpretation", VIPS_INTERPRETATION_sRGB)); + if (host.interpretation() == VIPS_INTERPRETATION_CMYK && host.bands() != 4) + die (1, "unsupported CMYK image (%d channels) for: %s", host.bands(), path.c_str()); + if (host.bands() != 1 && host.bands() != 3 && host.bands() != 4) + die (1, "unsupported image format (%d channels) for: %s", host.bands(), path.c_str()); + result.img = host; + return result; } /// Copy flotas into a VImage with the given dimensions and band count @@ -429,8 +564,9 @@ image_from_floats (const FloatS &floats, int width, int height, int bands = 1) static void command_add (const AddOptions &opt) { - // Load the host image in RGB order - VImage host = load_host_image (opt.input_img); + // Load the host image, preserving all channels and bit depth + HostImage loaded = load_host_image (opt.input_img); + const VImage &host = loaded.img; // Message → payload → ECC → reshape to 16 × 16 matrix const std::vector payload = parse_payload (opt.message_hex.empty() ? "0" : opt.message_hex); @@ -510,20 +646,49 @@ command_add (const AddOptions &opt) W = W.crop (dx, dy, host.width(), host.height()); } - // Embed the watermark - VImage watermarked = add_watermark (host, W, opt.strength, opt); - - // Conversion to 8-bit - watermarked = round_pixels (watermarked); - - // Write the result - save_host_image (watermarked, opt.output_img, opt.input_img); + // Embed the watermark into the luminance channel (canonical [0,255] floats) + const VImage host_canonical = image_to_canonical (host); + VImage watermarked = add_watermark (host_canonical, W, opt.strength, opt); // Optional quality reporting if (opt.trace_psnr || opt.trace_quality) { - double psnr = compute_psnr (host, watermarked); + double psnr = compute_psnr (host_canonical, watermarked); dprintf (2, "PSNR: %f\n", psnr); } + + // Restore the input image's colorspace and pixel format (bit depth): + // - CMYK can only be stored by a few formats, fall back to RGB otherwise + // - floating point pixels can only be stored by a few formats, fall back to 8-bit + const bool native_cmyk = loaded.interpretation == VIPS_INTERPRETATION_CMYK; + const bool out_cmyk = native_cmyk && save_supports_cmyk (opt.output_img); + if (native_cmyk && !out_cmyk) { + const std::vector wmch = watermarked.bandsplit(); + watermarked = cmyk_to_rgb (wmch[0], wmch[1], wmch[2], wmch[3]); + // the embedded ICC profile describes CMYK colors, drop it for RGB output + watermarked.remove ("icc-profile-data"); + } + VipsBandFormat out_format = loaded.format; + if ((out_format == VIPS_FORMAT_FLOAT || out_format == VIPS_FORMAT_DOUBLE) && + !save_supports_float (opt.output_img)) + out_format = VIPS_FORMAT_UCHAR; + const VipsInterpretation out_interp = out_cmyk ? VIPS_INTERPRETATION_CMYK : + native_cmyk ? VIPS_INTERPRETATION_sRGB : + loaded.interpretation; + watermarked = round_pixels (watermarked, out_format, out_interp); + + // Rejoin the alpha channel (passes through untouched); formats without alpha + // support (e.g. JPEG) drop it here + if (loaded.has_alpha && save_supports_alpha (opt.output_img)) { + VImage alpha = loaded.alpha; + // Convert the alpha band along with the image if the output pixel format + // differs from its native format (e.g. float input saved as 8-bit PNG) + if (alpha.format() != out_format) + alpha = round_pixels (image_to_canonical (alpha), out_format, alpha.interpretation()); + watermarked = VImage::bandjoin ({ watermarked, alpha }); + } + + // Write the result + save_host_image (watermarked, opt.output_img, opt.input_img); } // Silence some of VIPS's warnings. diff --git a/tests/formats/check-formats.sh b/tests/formats/check-formats.sh new file mode 100755 index 0000000..a7182a0 --- /dev/null +++ b/tests/formats/check-formats.sh @@ -0,0 +1,264 @@ +#!/bin/bash +# Licensed under the GNU GPL-3.0+: https://www.gnu.org/licenses/gpl-3.0.html + +# Check pixel-format and colorspace handling of "imagewmark add": +# - bit depth (8/16 bit) and float pixels must survive the watermark round trip +# - alpha channels must be preserved bit-exact (except for JPEG output) +# - CMYK images must stay CMYK where the output format supports it and must +# convert to RGB otherwise +# - EXIF and other metadata must be preserved +# - the embedded watermark must still be decodable +# +# Usage: tests/formats/check-formats.sh +# Dependencies: ImageMagick (convert, identify, compare) and imagewmark are +# required, vips and exiftool are optional (their checks are skipped). +set -Eeuo pipefail + +test "${1-}" == -x && { shift ; set -x ; } + +SELFDIR=$(dirname "$(readlink -f "$0")") +SRCDIR=$(dirname "$SELFDIR")/.. +IMAGEWMARK=${IMAGEWMARK:-$SRCDIR/imagewmark} +WATERMARK=${WATERMARK:-fedcba98765432100123456789abcdef} + +# ImageMagick tool selection: IMv7 deprecates convert/compare/identify in +# favor of magick(1) and prints a warning for each legacy invocation +if command -v magick >/dev/null 2>&1; then + IMCONVERT="magick" + IMIDENTIFY="magick identify" + IMCOMPARE="magick compare" +elif command -v convert >/dev/null 2>&1; then + IMCONVERT="convert" + IMIDENTIFY="identify" + IMCOMPARE="compare" +else + echo "check-formats: skipping, missing dependency: ImageMagick" + exit 0 +fi +[ -x "$IMAGEWMARK" ] || { echo "check-formats: skipping, missing executable: $IMAGEWMARK" ; exit 0 ; } + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +cd "$tmpdir" + +failures=0 +checks=0 +check() +{ + local name="$1"; shift + checks=$((checks + 1)) + if "$@"; then + echo " OK $name" + else + echo " FAIL $name" + failures=$((failures + 1)) + fi +} + +# check_opt - like check(), but skipped when is unavailable +check_opt() +{ + local dep="$1" name="$2"; shift 2 + if command -v "$dep" >/dev/null 2>&1; then + check "$name" "$@" + else + checks=$((checks + 1)) + echo " SKIP $name (no $dep)" + fi +} + +# normalized RMSE of two images, see: compare -metric RMSE +rmse() +{ + local r=$($IMCOMPARE -metric RMSE "$1" "$2" null: 2>&1 || true) + r=$(printf '%s\n' "$r" | sed -e 's/.*(//' -e 's/).*//') + printf '%s' "$r" +} + +# colorspace_grep - ImageMagick colorspace must match +colorspace_grep() +{ + $IMIDENTIFY -quiet -format "%[colorspace]" "$1" 2>/dev/null | grep -qi "^$2$" +} + +# channels_grep - ImageMagick channel set must match, e.g. rgba; +# IMv7 appends " ." to %[channels], e.g. "srgba 4.0" +channels_grep() +{ + $IMIDENTIFY -quiet -format "%[channels]" "$1" 2>/dev/null | grep -qiE "^$2( |$)" +} + +# depth_is - ImageMagick pixel depth must match +depth_is() +{ + [ "$($IMIDENTIFY -quiet -format "%z" "$1" 2>/dev/null)" == "$2" ] +} + +# alpha_cmp - alpha channels must be bit-exact; both alpha +# extractions are normalized to 16-bit so cross depth comparisons work +alpha_cmp() +{ + $IMCONVERT "$1" -alpha extract -depth 16 "$tmpdir/alpha_in.png" + $IMCONVERT "$2" -alpha extract -depth 16 "$tmpdir/alpha_out.png" + local ae=$($IMCOMPARE -metric AE "$tmpdir/alpha_in.png" "$tmpdir/alpha_out.png" null: 2>&1 || true) + ae=${ae%% *} # IMv7 appends the normalized value: "0 (0)" + awk "BEGIN { exit !($ae == 0) }" +} + +# cmyk_fidelity - CMYK output must keep the colors of the +# input CMYK image, only the watermark luminance delta may shift pixels +cmyk_fidelity() +{ + awk "BEGIN { exit !($(rmse "$1" "$2") < 0.1) }" +} + +# float_fidelity - float pixels are stored in [0,1], so the +# watermark delta (~4/255) needs a tighter bound than for 8-bit images +float_fidelity() +{ + awk "BEGIN { exit !($(rmse "$1" "$2") < 0.05) }" +} + +# vips_fidelity - watermarked output must keep the +# colors of a libvips reference conversion of the input (converts both via +# vips colourspace so mixed colorspace outputs compare consistently) +vips_fidelity() +{ + vips colourspace "$1" "$tmpdir/v_ref.png" srgb + vips colourspace "$2" "$tmpdir/v_out.png" srgb + awk "BEGIN { exit !($(rmse "$tmpdir/v_ref.png" "$tmpdir/v_out.png") < $3) }" +} + +# decodes - watermark must still be decodable +decodes() +{ + "$IMAGEWMARK" get --json "$tmpdir/decodes.json" "$1" >/dev/null 2>&1 || return 1 + grep -qE "\b$WATERMARK\b" "$tmpdir/decodes.json" +} + +# exiftool_grep - grep an EXIF tag value written by exiftool +exiftool_grep() +{ + exiftool -s "$2" "$1" 2>/dev/null | grep -q "$3" +} + +# == 1. create fixtures == +# base test image: color gradient, includes saturated regions; +# generate 16-bit first and derive 8-bit from it to avoid double quantization +$IMCONVERT -size 512x512 gradient:red-blue -depth 16 -define png:bit-depth=16 base16.png +$IMCONVERT base16.png -depth 8 base8.png +# png:color-type=6 keeps the fully opaque alpha band in the fixture, IMv7's +# PNG writer would otherwise strip it (fully opaque alpha => writes RGB) +$IMCONVERT base8.png -alpha set -define png:color-type=6 rgba8.png +$IMCONVERT base8.png -alpha set -channel A -evaluate set 60% +channel rgba8a.png +$IMCONVERT -size 512x512 gradient: -colorspace gray -depth 16 -define png:bit-depth=16 gray16.png +$IMCONVERT gray16.png -depth 8 gray8.png +$IMCONVERT gray8.png -alpha set -channel A -evaluate set 60% +channel gray8a.png +$IMCONVERT base16.png -alpha set -define png:color-type=6 -define png:bit-depth=16 rgba16.png +# 60% alpha as an absolute value: 39321/65535 == 153/255, exactly on the 8-bit +# grid, so float->8-bit alpha conversions stay bit-exact at 16-bit precision +$IMCONVERT base16.png -alpha set -channel A -evaluate set 39321 +channel -define png:bit-depth=16 rgba16a.png +$IMCONVERT gray16.png -alpha set -channel A -evaluate set 60% +channel -define png:bit-depth=16 gray16a.png +$IMCONVERT base8.png -colorspace CMYK cmyk.jpg # 4-band CMYK JPEG +$IMCONVERT base16.png -colorspace CMYK -depth 16 cmyk16.tif # 4-band 16-bit CMYK TIFF +$IMCONVERT rgba8a.png -colorspace CMYK -alpha on -depth 8 cmyka.tif # 5-band CMYK+alpha TIFF +$IMCONVERT rgba16a.png -colorspace CMYK -alpha on -depth 16 cmyka16.tif +# EXIF metadata fixture +$IMCONVERT base8.png -quality 92 exif.jpg +exiftool -overwrite_original -Artist='imagewmark-artist' -ImageDescription='imagewmark-desc' exif.jpg >/dev/null 2>&1 || : +# float fixtures: floating point TIFF pixels in [0,1]; ImageMagick cannot write +# float TIFFs (-depth 32 yields integer), so generate them via vips +if command -v vips >/dev/null 2>&1; then + $IMCONVERT base16.png -depth 16 base16.tif + $IMCONVERT rgba16a.png -depth 16 rgba16a.tif + vips cast base16.tif f32.v float && vips linear f32.v f32.tif 0.00001525902189669642 0 + vips cast rgba16a.tif f32a.v float && vips linear f32a.v f32a.tif 0.00001525902189669642 0 +fi + +# == 2. watermark every fixture == +for f in rgba8.png rgba8a.png gray8.png gray8a.png \ + rgba16.png rgba16a.png gray16.png gray16a.png \ + cmyk.jpg cmyk16.tif cmyka.tif cmyka16.tif exif.jpg ; do + "$IMAGEWMARK" add "$f" "out_$f" "$WATERMARK" +done +# cross-format outputs +"$IMAGEWMARK" add rgba8a.png out_rgba8a.jpg "$WATERMARK" # JPEG output drops alpha +"$IMAGEWMARK" add gray16.png out_gray16.jpg "$WATERMARK" # 16-bit to 8-bit JPEG +"$IMAGEWMARK" add rgba16a.png out_rgba16.jpg "$WATERMARK" # 16-bit+alpha to JPEG +"$IMAGEWMARK" add cmyk.jpg out_cmyk.png "$WATERMARK" # PNG has no native CMYK, output RGB +"$IMAGEWMARK" add cmyka.tif out_cmyka.png "$WATERMARK" # CMYKA to RGBA PNG +"$IMAGEWMARK" add cmyk16.tif out_cmyk16.jpg "$WATERMARK" # 16-bit CMYK to 8-bit CMYK JPEG +"$IMAGEWMARK" add exif.jpg out_exif.png "$WATERMARK" # JPEG to PNG keeps EXIF +if command -v vips >/dev/null 2>&1; then + "$IMAGEWMARK" add f32.tif out_f32.tif "$WATERMARK" # float round trip + "$IMAGEWMARK" add f32a.tif out_f32a.tif "$WATERMARK" # float+alpha round trip + "$IMAGEWMARK" add f32.tif out_f32.png "$WATERMARK" # float to 8-bit PNG + "$IMAGEWMARK" add f32a.tif out_f32a.png "$WATERMARK" # float+alpha to 8-bit PNG +fi + +# == 3. pixel format, colorspace and alpha checks == +check '8-bit RGB output keeps alpha' channels_grep out_rgba8.png 'srgba' +check '8-bit RGBA output keeps 4 channels' channels_grep out_rgba8a.png 'srgba' +check '8-bit output stays 8-bit' depth_is out_rgba8a.png 8 +check 'alpha preserved (8-bit RGBA)' alpha_cmp rgba8a.png out_rgba8a.png +check 'alpha preserved (8-bit grey)' alpha_cmp gray8a.png out_gray8a.png +check '16-bit output stays 16-bit' depth_is out_rgba16.png 16 +check '16-bit RGBA output keeps 4 channels' channels_grep out_rgba16a.png 'srgba' +check 'alpha preserved (16-bit RGBA)' alpha_cmp rgba16a.png out_rgba16a.png +check '16-bit grey output stays 16-bit' depth_is out_gray16.png 16 +check '16-bit grey keeps 1 channel' channels_grep out_gray16.png 'gray' +check 'alpha preserved (16-bit grey+alpha)' alpha_cmp gray16a.png out_gray16a.png +check 'JPEG output drops alpha' channels_grep out_rgba8a.jpg 'srgb' +check '16-bit input to JPEG is 8-bit' depth_is out_rgba16.jpg 8 +check 'CMYK output stays CMYK' colorspace_grep out_cmyk.jpg 'cmyk' +check 'CMYK colors preserved' cmyk_fidelity cmyk.jpg out_cmyk.jpg +check 'CMYK to PNG output is RGB' colorspace_grep out_cmyk.png 'srgb' +# the CMYK to RGB output conversion is a device level naive conversion, so it +# differs from vips' profile based reference conversion, hence the loose bound +check_opt vips 'CMYK to PNG colors preserved' vips_fidelity cmyk.jpg out_cmyk.png 0.3 +check '16-bit CMYK output stays 16-bit' depth_is out_cmyk16.tif 16 +check 'CMYK stays CMYK (16-bit)' colorspace_grep out_cmyk16.tif 'cmyk' +check 'CMYK colors preserved (16-bit)' cmyk_fidelity cmyk16.tif out_cmyk16.tif +check '16-bit CMYK to JPEG is 8-bit CMYK' colorspace_grep out_cmyk16.jpg 'cmyk' +check '16-bit CMYK JPEG output is 8-bit' depth_is out_cmyk16.jpg 8 +check 'CMYKA TIFF keeps 5 channels' channels_grep out_cmyka.tif 'cmyka' +check 'alpha preserved (CMYKA TIFF)' alpha_cmp cmyka.tif out_cmyka.tif +# input and output are both converted with the same vips reference conversion, +# so only the watermark delta and conversion noise show up here +check_opt vips 'CMYKA colors preserved' vips_fidelity cmyka.tif out_cmyka.tif 0.05 +check 'CMYKA to PNG output is RGBA' channels_grep out_cmyka.png 'srgba' +check_opt vips 'CMYKA to PNG colors preserved' vips_fidelity cmyka.tif out_cmyka.png 0.3 +check 'CMYKA 16-bit TIFF keeps 5 channels' channels_grep out_cmyka16.tif 'cmyka' +check 'alpha preserved (CMYKA 16-bit)' alpha_cmp cmyka16.tif out_cmyka16.tif +check_opt vips 'float output stays float' depth_is out_f32.tif 32 +check_opt vips 'float output keeps 3 channels' channels_grep out_f32.tif 'srgb' +check_opt vips 'float round trip preserves values' float_fidelity f32.tif out_f32.tif +check_opt vips 'float+alpha output keeps 4 channels' channels_grep out_f32a.tif 'srgba' +check_opt vips 'alpha preserved (float+alpha)' alpha_cmp f32a.tif out_f32a.tif +check_opt vips 'float to PNG output is 8-bit' depth_is out_f32.png 8 +check_opt vips 'float+alpha to PNG output is 8-bit' depth_is out_f32a.png 8 +check_opt vips 'alpha preserved (float+alpha to PNG)' alpha_cmp f32a.tif out_f32a.png +check_opt exiftool 'EXIF metadata preserved (JPEG)' exiftool_grep out_exif.jpg -Artist 'imagewmark-artist' +check_opt exiftool 'EXIF metadata preserved (PNG)' exiftool_grep out_exif.png -Artist 'imagewmark-artist' + +# == 4. watermark decodability == +# get/OpenCV cannot read 5-channel CMYK TIFFs nor 32-bit float TIFFs, so CMYKA +# and float decodability is verified via the PNG outputs +check 'watermark decodes (8-bit RGB)' decodes out_rgba8.png +check 'watermark decodes (8-bit RGBA)' decodes out_rgba8a.png +check 'watermark decodes (8-bit grey+alpha)' decodes out_gray8a.png +check 'watermark decodes (16-bit RGBA)' decodes out_rgba16a.png +check 'watermark decodes (16-bit grey)' decodes out_gray16.png +check 'watermark decodes (16-bit grey+alpha)' decodes out_gray16a.png +check 'watermark decodes (CMYK JPEG)' decodes out_cmyk.jpg +check 'watermark decodes (CMYK PNG)' decodes out_cmyk.png +check 'watermark decodes (CMYKA PNG)' decodes out_cmyka.png +check 'watermark decodes (16-bit RGB JPEG)' decodes out_rgba16.jpg +check 'watermark decodes (EXIF JPEG)' decodes out_exif.jpg +check_opt vips 'watermark decodes (float to PNG)' decodes out_f32.png + +if [ "$failures" -ne 0 ]; then + echo "check-formats: $failures of $checks checks FAILED" + exit 1 +fi +echo "check-formats: all $checks checks passed"