Enable 4:2:0 chroma subsampling for JPEG thumbnails
authorBrion Vibber <brion@pobox.com>
Wed, 27 Apr 2016 16:26:48 +0000 (09:26 -0700)
committerBrion Vibber <brion@pobox.com>
Wed, 27 Apr 2016 22:36:18 +0000 (15:36 -0700)
* Add $wgJpegPixelFormat, default to 'yuv420'
* Implemented for ImageMagick via CLI and extension
* Currently ignored for other scaler backends
* Added test case to run when using ImageMagick

4:2:0 subsampling can save an average of 17% bandwidth
over 4:4:4 subsampling, at the cost of some artifacting
at sharp red or blue edges. This is usually not noticeable
in photographic images.

To restore the previous behavior, set to false:

  $wgJpegPixelFormat = false;

which will maintain the original file's pixel subsampling
settings in the thumbnail.

Can set explicitly to one of:

  'yuv444' - never subsample
  'yuv422' - subsample 2x horizontally, not vert
  'yuv420' - subsample 2x in both dimensions

Bug: T129128
Change-Id: Ib9cb36c3a7e6a69d66c11150ef4a1d02dbac2df5

RELEASE-NOTES-1.27
includes/DefaultSettings.php
includes/media/Bitmap.php
tests/phpunit/data/media/yuv420.jpg [new file with mode: 0644]
tests/phpunit/data/media/yuv444.jpg [new file with mode: 0644]
tests/phpunit/includes/media/JpegPixelFormatTest.php [new file with mode: 0644]

index be15bde..9b77cd1 100644 (file)
@@ -190,6 +190,9 @@ HHVM 3.1. Additionally, the following PHP extensions are required:
   is deprecated.
 * (T33313) Add a preference for watching uploads by default, also applies
   to API-based upload tools.
+* $wgJpegPixelFormat was added to override chroma subsampling for JPEG image
+  thumbnails created via ImageMagick. Defaults to 'yuv420', providing bandwidth
+  savings versus the previous behavior on many files.
 
 === External library changes in 1.27 ===
 
index 13f7c4e..5b3684b 100644 (file)
@@ -980,6 +980,27 @@ $wgCustomConvertCommand = false;
  */
 $wgJpegTran = '/usr/bin/jpegtran';
 
+/**
+ * At default setting of 'yuv420', JPEG thumbnails will use 4:2:0 chroma
+ * subsampling to reduce file size, at the cost of possible color fringing
+ * at sharp edges.
+ *
+ * See https://en.wikipedia.org/wiki/Chroma_subsampling
+ *
+ * Supported values:
+ *   false - use scaling system's default (same as pre-1.27 behavior)
+ *   'yuv444' - luma and chroma at same resolution
+ *   'yuv422' - chroma at 1/2 resolution horizontally, full vertically
+ *   'yuv420' - chroma at 1/2 resolution in both dimensions
+ *
+ * This setting is currently supported only for the ImageMagick backend;
+ * others may default to 4:2:0 or 4:4:4 or maintaining the source file's
+ * sampling in the thumbnail.
+ *
+ * @since 1.27
+ */
+$wgJpegPixelFormat = 'yuv420';
+
 /**
  * Some tests and extensions use exiv2 to manipulate the Exif metadata in some
  * image formats.
index 4da41c8..ccd345c 100644 (file)
@@ -104,6 +104,25 @@ class BitmapHandler extends TransformationalImageHandler {
                return true;
        }
 
+       /**
+        * Get ImageMagick subsampling factors for the target JPEG pixel format.
+        *
+        * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420'
+        * @return array of string keys
+        */
+       protected function imageMagickSubsampling( $pixelFormat ) {
+               switch ( $pixelFormat ) {
+               case 'yuv444':
+                       return [ '1x1', '1x1', '1x1' ];
+               case 'yuv422':
+                       return [ '2x1', '1x1', '1x1' ];
+               case 'yuv420':
+                       return [ '2x2', '1x1', '1x1' ];
+               default:
+                       throw new MWException( 'Invalid pixel format for JPEG output' );
+               }
+       }
+
        /**
         * Transform an image using ImageMagick
         *
@@ -115,7 +134,7 @@ class BitmapHandler extends TransformationalImageHandler {
        protected function transformImageMagick( $image, $params ) {
                # use ImageMagick
                global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea,
-                       $wgImageMagickTempDir, $wgImageMagickConvertCommand;
+                       $wgImageMagickTempDir, $wgImageMagickConvertCommand, $wgJpegPixelFormat;
 
                $quality = [];
                $sharpen = [];
@@ -123,6 +142,7 @@ class BitmapHandler extends TransformationalImageHandler {
                $animation_pre = [];
                $animation_post = [];
                $decoderHint = [];
+               $subsampling = [];
 
                if ( $params['mimeType'] == 'image/jpeg' ) {
                        $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null;
@@ -141,6 +161,10 @@ class BitmapHandler extends TransformationalImageHandler {
                                // JPEG decoder hint to reduce memory, available since IM 6.5.6-2
                                $decoderHint = [ '-define', "jpeg:size={$params['physicalDimensions']}" ];
                        }
+                       if ( $wgJpegPixelFormat ) {
+                               $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat );
+                               $subsampling = [ '-sampling-factor', implode( ',', $factors ) ];
+                       }
                } elseif ( $params['mimeType'] == 'image/png' ) {
                        $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering
                        if ( $params['interlace'] ) {
@@ -225,6 +249,7 @@ class BitmapHandler extends TransformationalImageHandler {
                        [ '-depth', 8 ],
                        $sharpen,
                        [ '-rotate', "-$rotation" ],
+                       $subsampling,
                        $animation_post,
                        [ $this->escapeMagickOutput( $params['dstPath'] ) ] ) );
 
@@ -251,7 +276,7 @@ class BitmapHandler extends TransformationalImageHandler {
         */
        protected function transformImageMagickExt( $image, $params ) {
                global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea,
-                       $wgMaxInterlacingAreas;
+                       $wgMaxInterlacingAreas, $wgJpegPixelFormat;
 
                try {
                        $im = new Imagick();
@@ -272,6 +297,10 @@ class BitmapHandler extends TransformationalImageHandler {
                                if ( $params['interlace'] ) {
                                        $im->setInterlaceScheme( Imagick::INTERLACE_JPEG );
                                }
+                               if ( $wgJpegPixelFormat ) {
+                                       $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat );
+                                       $im->setSamplingFactors( $factors );
+                               }
                        } elseif ( $params['mimeType'] == 'image/png' ) {
                                $im->setCompressionQuality( 95 );
                                if ( $params['interlace'] ) {
diff --git a/tests/phpunit/data/media/yuv420.jpg b/tests/phpunit/data/media/yuv420.jpg
new file mode 100644 (file)
index 0000000..e741ca6
Binary files /dev/null and b/tests/phpunit/data/media/yuv420.jpg differ
diff --git a/tests/phpunit/data/media/yuv444.jpg b/tests/phpunit/data/media/yuv444.jpg
new file mode 100644 (file)
index 0000000..6bccefa
Binary files /dev/null and b/tests/phpunit/data/media/yuv444.jpg differ
diff --git a/tests/phpunit/includes/media/JpegPixelFormatTest.php b/tests/phpunit/includes/media/JpegPixelFormatTest.php
new file mode 100644 (file)
index 0000000..6815a62
--- /dev/null
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Tests related to JPEG chroma subsampling via $wgJpegPixelFormat setting.
+ *
+ * @group Media
+ * @group medium
+ *
+ * @todo covers tags
+ */
+class JpegPixelFormatTest extends MediaWikiMediaTestCase {
+
+       protected function setUp() {
+               parent::setUp();
+       }
+
+       /**
+        * Mark this test as creating thumbnail files.
+        */
+       protected function createsThumbnails() {
+               return true;
+       }
+
+       /**
+        *
+        * @dataProvider providePixelFormats
+        */
+       public function testPixelFormatRendering( $sourceFile, $pixelFormat, $samplingFactor ) {
+               global $wgUseImageMagick, $wgUseImageResize;
+               if ( !$wgUseImageMagick ) {
+                       $this->markTestSkipped( "This test is only applicable when using ImageMagick thumbnailing" );
+               }
+               if ( !$wgUseImageResize ) {
+                       $this->markTestSkipped( "This test is only applicable when using thumbnailing" );
+               }
+
+               $fmtStr = var_export( $pixelFormat, true );
+               $this->setMwGlobals( 'wgJpegPixelFormat', $pixelFormat );
+
+               $file = $this->dataFile( $sourceFile, 'image/jpeg' );
+
+               $params = [
+                       'width' => 320,
+               ];
+               $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE );
+               $this->assertTrue( !$thumb->isError(), "created JPEG thumbnail for pixel format $fmtStr" );
+
+               $path = $thumb->getLocalCopyPath();
+               $this->assertTrue( is_string( $path ), "path returned for JPEG thumbnail for $fmtStr" );
+
+               $cmd = [
+                       'identify',
+                       '-format',
+                       '%[jpeg:sampling-factor]',
+                       $path
+               ];
+               $retval = null;
+               $output = wfShellExec( $cmd, $retval );
+               $this->assertTrue( $retval === 0, "ImageMagick's identify command should return success" );
+
+               $expected = $samplingFactor;
+               $actual = trim( $output );
+               $this->assertEquals(
+                       $expected,
+                       trim( $output ),
+                       "IM identify expects JPEG chroma subsampling \"$expected\" for $fmtStr"
+               );
+       }
+
+       public static function providePixelFormats() {
+               return [
+                       // From 4:4:4 source file
+                       [
+                               'yuv444.jpg',
+                               false,
+                               '1x1,1x1,1x1'
+                       ],
+                       [
+                               'yuv444.jpg',
+                               'yuv444',
+                               '1x1,1x1,1x1'
+                       ],
+                       [
+                               'yuv444.jpg',
+                               'yuv422',
+                               '2x1,1x1,1x1'
+                       ],
+                       [
+                               'yuv444.jpg',
+                               'yuv420',
+                               '2x2,1x1,1x1'
+                       ],
+                       // From 4:2:0 source file
+                       [
+                               'yuv420.jpg',
+                               false,
+                               '2x2,1x1,1x1'
+                       ],
+                       [
+                               'yuv420.jpg',
+                               'yuv444',
+                               '1x1,1x1,1x1'
+                       ],
+                       [
+                               'yuv420.jpg',
+                               'yuv422',
+                               '2x1,1x1,1x1'
+                       ],
+                       [
+                               'yuv420.jpg',
+                               'yuv420',
+                               '2x2,1x1,1x1'
+                       ]
+               ];
+       }
+}