Apply clean aperture crop when decoding to PNG or JPEG. (#3041) #2427
diff --git a/CHANGELOG.md b/CHANGELOG.md index b886768..1fce729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ * Support Sample Transform derived image items with grid input image items. * Add --sato flag to avifdec to enable Sample Transforms support at decoding. * Add --grid option to avifgainmaputil. +* Apply clean aperture crop when decoding to PNG or JPEG. ### Changed since 1.3.0
diff --git a/apps/shared/avifjpeg.c b/apps/shared/avifjpeg.c index cde4298..4b6fe9a 100644 --- a/apps/shared/avifjpeg.c +++ b/apps/shared/avifjpeg.c
@@ -1664,20 +1664,30 @@ cinfo.err = jpeg_std_error(&jerr); jpeg_create_compress(&cinfo); - avifRGBImage rgb; - avifRGBImageSetDefaults(&rgb, avif); - rgb.format = avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400 ? AVIF_RGB_FORMAT_GRAY : AVIF_RGB_FORMAT_RGB; - rgb.chromaUpsampling = chromaUpsampling; - rgb.depth = 8; - if (avifRGBImageAllocatePixels(&rgb) != AVIF_RESULT_OK) { + avifRGBImage rgbData; + avifRGBImageSetDefaults(&rgbData, avif); + rgbData.format = avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400 ? AVIF_RGB_FORMAT_GRAY : AVIF_RGB_FORMAT_RGB; + rgbData.chromaUpsampling = chromaUpsampling; + rgbData.depth = 8; + if (avifRGBImageAllocatePixels(&rgbData) != AVIF_RESULT_OK) { fprintf(stderr, "Conversion to RGB failed: %s (out of memory)\n", outputFilename); goto cleanup; } - if (avifImageYUVToRGB(avif, &rgb) != AVIF_RESULT_OK) { + if (avifImageYUVToRGB(avif, &rgbData) != AVIF_RESULT_OK) { fprintf(stderr, "Conversion to RGB failed: %s\n", outputFilename); goto cleanup; } + avifRGBImage rgbView = rgbData; + if (avif->transformFlags & AVIF_TRANSFORM_CLAP) { + avifCropRect cropRect; + avifDiagnostics diag; + if (avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag) && + (cropRect.x != 0 || cropRect.y != 0 || cropRect.width != avif->width || cropRect.height != avif->height)) { + avifRGBImageSetViewRect(&rgbView, &rgbData, &cropRect); + } + } + f = fopen(outputFilename, "wb"); if (!f) { fprintf(stderr, "Can't open JPEG file for write: %s\n", outputFilename); @@ -1685,8 +1695,8 @@ } jpeg_stdio_dest(&cinfo, f); - cinfo.image_width = avif->width; - cinfo.image_height = avif->height; + cinfo.image_width = rgbView.width; + cinfo.image_height = rgbView.height; const avifBool isGray = avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400; cinfo.input_components = isGray ? 1 : 3; cinfo.in_color_space = isGray ? JCS_GRAYSCALE : JCS_RGB; @@ -1699,21 +1709,6 @@ write_icc_profile(&cinfo, avif->icc.data, (unsigned int)avif->icc.size); } - if (avif->transformFlags & AVIF_TRANSFORM_CLAP) { - avifCropRect cropRect; - avifDiagnostics diag; - if (avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag) && - (cropRect.x != 0 || cropRect.y != 0 || cropRect.width != avif->width || cropRect.height != avif->height)) { - // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Implement. - fprintf(stderr, - "Warning: Clean Aperture values were ignored, the output image was NOT cropped to rectangle {%u,%u,%u,%u}\n", - cropRect.x, - cropRect.y, - cropRect.width, - cropRect.height); - } - } - if (avif->exif.data && (avif->exif.size > 0)) { size_t exifTiffHeaderOffset; avifResult result = avifGetExifTiffHeaderOffset(avif->exif.data, avif->exif.size, &exifTiffHeaderOffset); @@ -1787,7 +1782,7 @@ } while (cinfo.next_scanline < cinfo.image_height) { - row_pointer[0] = &rgb.pixels[cinfo.next_scanline * rgb.rowBytes]; + row_pointer[0] = &rgbView.pixels[cinfo.next_scanline * rgbView.rowBytes]; (void)jpeg_write_scanlines(&cinfo, row_pointer, 1); } @@ -1799,6 +1794,6 @@ fclose(f); } jpeg_destroy_compress(&cinfo); - avifRGBImageFreePixels(&rgb); + avifRGBImageFreePixels(&rgbData); return ret; }
diff --git a/apps/shared/avifpng.c b/apps/shared/avifpng.c index a332b18..46d278a 100644 --- a/apps/shared/avifpng.c +++ b/apps/shared/avifpng.c
@@ -627,8 +627,8 @@ png_bytep * volatile rowPointers = NULL; FILE * volatile f = NULL; - avifRGBImage rgb; - memset(&rgb, 0, sizeof(avifRGBImage)); + avifRGBImage rgbData; + memset(&rgbData, 0, sizeof(avifRGBImage)); volatile int rgbDepth = requestedDepth; if (rgbDepth == 0) { @@ -651,39 +651,55 @@ rgbDepth = 8; } - volatile avifBool monochrome8bit = (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) && !avif->alphaPlane && (avif->depth == 8) && - (rgbDepth == 8); + volatile avifBool hasClap = avif->transformFlags & AVIF_TRANSFORM_CLAP; + volatile avifBool copyYPlane = (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400) && !avif->alphaPlane && (avif->depth == 8) && + (rgbDepth == 8) && !hasClap; volatile int colorType; - if (monochrome8bit) { + if (copyYPlane) { colorType = PNG_COLOR_TYPE_GRAY; } else { - avifRGBImageSetDefaults(&rgb, avif); - rgb.depth = rgbDepth; + avifRGBImageSetDefaults(&rgbData, avif); + rgbData.depth = rgbDepth; if (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400 && avif->alphaPlane) { colorType = PNG_COLOR_TYPE_GRAY_ALPHA; - rgb.format = AVIF_RGB_FORMAT_GRAYA; + rgbData.format = AVIF_RGB_FORMAT_GRAYA; } else if (avif->yuvFormat == AVIF_PIXEL_FORMAT_YUV400 && !avif->alphaPlane) { colorType = PNG_COLOR_TYPE_GRAY; - rgb.format = AVIF_RGB_FORMAT_GRAY; + rgbData.format = AVIF_RGB_FORMAT_GRAY; } else { - rgb.chromaUpsampling = chromaUpsampling; + rgbData.chromaUpsampling = chromaUpsampling; colorType = PNG_COLOR_TYPE_RGBA; if (avifImageIsOpaque(avif)) { colorType = PNG_COLOR_TYPE_RGB; - rgb.format = AVIF_RGB_FORMAT_RGB; + rgbData.format = AVIF_RGB_FORMAT_RGB; } } - if (avifRGBImageAllocatePixels(&rgb) != AVIF_RESULT_OK) { + if (avifRGBImageAllocatePixels(&rgbData) != AVIF_RESULT_OK) { fprintf(stderr, "Conversion to RGB failed: %s (out of memory)\n", outputFilename); goto cleanup; } - if (avifImageYUVToRGB(avif, &rgb) != AVIF_RESULT_OK) { + if (avifImageYUVToRGB(avif, &rgbData) != AVIF_RESULT_OK) { fprintf(stderr, "Conversion to RGB failed: %s\n", outputFilename); goto cleanup; } } + volatile uint32_t width = avif->width; + volatile uint32_t height = avif->height; + + avifRGBImage rgbView = rgbData; + if (hasClap) { + avifCropRect cropRect; + avifDiagnostics diag; + if (avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag) && + (cropRect.x != 0 || cropRect.y != 0 || cropRect.width != avif->width || cropRect.height != avif->height)) { + avifRGBImageSetViewRect(&rgbView, &rgbData, &cropRect); + width = cropRect.width; + height = cropRect.height; + } + } + f = fopen(outputFilename, "wb"); if (!f) { fprintf(stderr, "Can't open PNG file for write: %s\n", outputFilename); @@ -721,7 +737,7 @@ png_set_compression_level(png, compressionLevel); } - png_set_IHDR(png, info, avif->width, avif->height, rgbDepth, colorType, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); + png_set_IHDR(png, info, width, height, rgbDepth, colorType, PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); const avifBool hasIcc = avif->icc.data && (avif->icc.size > 0); if (hasIcc) { @@ -811,39 +827,25 @@ png_write_chunk(png, cicp, cicpData, 4); } - rowPointers = (png_bytep *)malloc(sizeof(png_bytep) * avif->height); + rowPointers = (png_bytep *)malloc(sizeof(png_bytep) * height); if (rowPointers == NULL) { fprintf(stderr, "Error writing PNG: memory allocation failure"); goto cleanup; } uint8_t * row; uint32_t rowBytes; - if (monochrome8bit) { + if (copyYPlane) { row = avif->yuvPlanes[AVIF_CHAN_Y]; rowBytes = avif->yuvRowBytes[AVIF_CHAN_Y]; } else { - row = rgb.pixels; - rowBytes = rgb.rowBytes; + row = rgbView.pixels; + rowBytes = rgbView.rowBytes; } - for (uint32_t y = 0; y < avif->height; ++y) { + for (uint32_t y = 0; y < height; ++y) { rowPointers[y] = row; row += rowBytes; } - if (avif->transformFlags & AVIF_TRANSFORM_CLAP) { - avifCropRect cropRect; - avifDiagnostics diag; - if (avifCropRectFromCleanApertureBox(&cropRect, &avif->clap, avif->width, avif->height, &diag) && - (cropRect.x != 0 || cropRect.y != 0 || cropRect.width != avif->width || cropRect.height != avif->height)) { - // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Implement. - fprintf(stderr, - "Warning: Clean Aperture values were ignored, the output image was NOT cropped to rectangle {%u,%u,%u,%u}\n", - cropRect.x, - cropRect.y, - cropRect.width, - cropRect.height); - } - } if (avifImageGetExifOrientationFromIrotImir(avif) != 1) { // TODO: https://github.com/AOMediaCodec/libavif/issues/2427 - Rotate the samples. fprintf(stderr, @@ -871,6 +873,6 @@ if (rowPointers) { free(rowPointers); } - avifRGBImageFreePixels(&rgb); + avifRGBImageFreePixels(&rgbData); return writeResult; }
diff --git a/apps/shared/avifutil.c b/apps/shared/avifutil.c index 0dd9e17..e7261d2 100644 --- a/apps/shared/avifutil.c +++ b/apps/shared/avifutil.c
@@ -649,3 +649,21 @@ return AVIF_TRUE; } + +void avifRGBImageSetViewRect(avifRGBImage * dstImage, const avifRGBImage * srcImage, const avifCropRect * cropRect) +{ + memset(dstImage, 0, sizeof(avifRGBImage)); + dstImage->width = cropRect->width; + dstImage->height = cropRect->height; + dstImage->depth = srcImage->depth; + dstImage->format = srcImage->format; + dstImage->alphaPremultiplied = srcImage->alphaPremultiplied; + dstImage->isFloat = srcImage->isFloat; + const uint32_t channelCount = avifRGBFormatChannelCount(srcImage->format); + const uint32_t bytesPerChannel = srcImage->depth <= 8 ? 1 : 2; + const uint32_t bytesPerSample = srcImage->format == AVIF_RGB_FORMAT_RGB_565 ? 2 : channelCount * bytesPerChannel; + // This should not overflow if cropRect is a valid crop of the image. + const size_t offset = (size_t)cropRect->y * srcImage->rowBytes + (size_t)cropRect->x * bytesPerSample; + dstImage->pixels = srcImage->pixels + offset; + dstImage->rowBytes = srcImage->rowBytes; +}
diff --git a/apps/shared/avifutil.h b/apps/shared/avifutil.h index 5474dfe..3ea289e 100644 --- a/apps/shared/avifutil.h +++ b/apps/shared/avifutil.h
@@ -59,6 +59,11 @@ // The returned cells must be destroyed with avifImageDestroy(). avifBool avifImageSplitGrid(const avifImage * gridSplitImage, uint32_t gridCols, uint32_t gridRows, avifImage ** gridCells); +// Performs a shallow copy of a rectangular area of an RGB image. 'dstImage' does not own the pixel data. +// Assumes that cropRect is a valid cropping rectangle for srcImage. This is true if it was obtained +// using avifCropRectFromCleanApertureBox(). +void avifRGBImageSetViewRect(avifRGBImage * dstImage, const avifRGBImage * srcImage, const avifCropRect * cropRect); + // This structure holds any timing data coming from source (typically non-AVIF) inputs being fed // into avifenc. If either or both values are 0, the timing is "invalid" / sentinel and the values // should be ignored. This structure is used to override the timing defaults in avifenc when the
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bf38de2..dee2119 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt
@@ -266,6 +266,7 @@ add_cmd_test(test_cmd_progressive ${CMAKE_CURRENT_SOURCE_DIR}/data) add_cmd_test(test_cmd_stdin ${CMAKE_CURRENT_SOURCE_DIR}/data) add_cmd_test(test_cmd_targetsize ${CMAKE_CURRENT_SOURCE_DIR}/data) + add_cmd_test(test_cmd_transform ${CMAKE_CURRENT_SOURCE_DIR}/data) if(AVIF_ENABLE_JPEG_GAIN_MAP_CONVERSION) add_cmd_test(test_cmd_avifgainmaputil ${CMAKE_CURRENT_SOURCE_DIR}/data) @@ -375,7 +376,8 @@ if(AVIF_BUILD_APPS) # Disable all tests that use avifenc without explicitly setting --codec=avm. set_tests_properties( - test_cmd test_cmd_animation test_cmd_grid test_cmd_stdin test_cmd_targetsize PROPERTIES DISABLED True + test_cmd test_cmd_animation test_cmd_grid test_cmd_stdin test_cmd_targetsize test_cmd_transform + PROPERTIES DISABLED True ) endif() endif()
diff --git a/tests/gtest/are_images_equal.cc b/tests/gtest/are_images_equal.cc index e5d5e9e..02df18e 100644 --- a/tests/gtest/are_images_equal.cc +++ b/tests/gtest/are_images_equal.cc
@@ -60,8 +60,10 @@ if (argc == 4) { if (!avif::testutil::AreImagesEqual(*decoded[0], *decoded[1], ignore_alpha)) { + auto psnr = + avif::testutil::GetPsnr(*decoded[0], *decoded[1], ignore_alpha); std::cerr << "Images " << argv[1] << " and " << argv[2] - << " are different." << std::endl; + << " are different (PSNR: " << psnr << ")." << std::endl; return 1; } std::cout << "Images " << argv[1] << " and " << argv[2] << " are identical." @@ -70,7 +72,8 @@ auto psnr = avif::testutil::GetPsnr(*decoded[0], *decoded[1], ignore_alpha); if (psnr < std::stod(argv[4])) { std::cerr << "PSNR: " << psnr << ", images " << argv[1] << " and " - << argv[2] << " are not similar." << std::endl; + << argv[2] << " are not similar enough (threshold: " << argv[4] + << ")." << std::endl; return 1; } std::cout << "PSNR: " << psnr << ", images " << argv[1] << " and "
diff --git a/tests/test_cmd_icc_profile.sh b/tests/test_cmd_icc_profile.sh index ad8d1a2..534f0ad 100755 --- a/tests/test_cmd_icc_profile.sh +++ b/tests/test_cmd_icc_profile.sh
@@ -75,6 +75,7 @@ popd exit 0 fi + "${IMAGEMAGICK}" --version "${AVIFENC}" -s 8 -l "${INPUT_COLOR_PNG}" -o "${ENCODED_FILE}" # Old version of ImageMagick may not support reading ICC from AVIF.
diff --git a/tests/test_cmd_transform.sh b/tests/test_cmd_transform.sh new file mode 100644 index 0000000..0f5617b --- /dev/null +++ b/tests/test_cmd_transform.sh
@@ -0,0 +1,65 @@ +#!/bin/bash +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------------------------ +# +# tests for command lines using image transforms (clap/irot/imir...) + +source $(dirname "$0")/cmd_test_common.sh || exit + +# Input file paths. +INPUT_PNG="${TESTDATA_DIR}/paris_exif_xmp_icc.jpg" +# Output file names. +ENCODED_FILE="encoded.avif" +ENCODED_FILE_CLAP="encoded_clap.avif" + +# Cleanup +cleanup() { + rm -f -r "${TMP_DIR}" +} +trap cleanup EXIT + +pushd ${TMP_DIR} + # Encode/decode uncropped image + # Some image magick versions may drop EXIF. Remove it preemptively to avoid + # ARE_IMAGES_EQUAL failing because of EXIF mismatch. + "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" -o "${ENCODED_FILE}" + "${AVIFDEC}" "${ENCODED_FILE}" "${ENCODED_FILE}.png" + + # Encode with crop + "${AVIFENC}" --ignore-exif -s 10 "${INPUT_PNG}" --crop 10,50,100,25 -o "${ENCODED_FILE_CLAP}" + # Decode to PNG + "${AVIFDEC}" "${ENCODED_FILE_CLAP}" "${ENCODED_FILE_CLAP}.png" + # Decode to JPEG + "${AVIFDEC}" "${ENCODED_FILE_CLAP}" -q 100 "${ENCODED_FILE_CLAP}.jpg" + + if command -v magick &> /dev/null + then + IMAGEMAGICK="magick" + elif command -v convert &> /dev/null + then + IMAGEMAGICK="convert" + else + echo Missing ImageMagick, test skipped + popd + exit 0 + fi + "${IMAGEMAGICK}" --version + + "${IMAGEMAGICK}" "${ENCODED_FILE}.png" -crop 100x25+10+50 "${ENCODED_FILE}_cropped.png" + "${ARE_IMAGES_EQUAL}" "${ENCODED_FILE}_cropped.png" "${ENCODED_FILE_CLAP}.png" 0 + "${ARE_IMAGES_EQUAL}" "${ENCODED_FILE}_cropped.png" "${ENCODED_FILE_CLAP}.jpg" 0 49 +popd + +exit 0