Skip to content

Commit

Permalink
API changes: yuv_format=>subsampling, remove qmin/max_alpha, add quality
Browse files Browse the repository at this point in the history
- Rename `yuv_format` encoder option to `subsampling`
- Remove qmin_alpha and qmax_alpha encoder settings
- Add jpeg-like quality encoder setting which maps onto qmin/qmax
  • Loading branch information
fdintino committed Mar 28, 2021
1 parent 9078a34 commit cbefd5c
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 40 deletions.
34 changes: 23 additions & 11 deletions src/pillow_avif/AvifImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,26 @@ def _save(im, fp, filename, save_all=False):

is_single_frame = total == 1

qmin = info.get("qmin")
qmax = info.get("qmax")

if qmin is None and qmax is None:
# The min and max quantizer settings in libavif range from 0 (best quality)
# to 63 (worst quality). If neither are explicitly specified, we use a 0-100
# quality scale (default 90) and calculate the qmin and qmax from that.
#
# - qmin is 0 for quality >= 64. Below that, qmin has an inverse linear
# relation to quality (i.e., quality 63 = qmin 1, quality 0 => qmin 63)
# - qmax is 0 for quality=100, then qmax increases linearly relative to
# quality decreasing, until it flattens out at quality=37.
quality = info.get("quality", 90)
if not isinstance(quality, int):
raise ValueError("Invalid quality setting")
qmin = max(0, min(64 - quality, 63))
qmax = max(0, min(100 - quality, 63))

duration = info.get("duration", 0)
yuv_format = info.get("yuv_format", "4:2:0")
qmin = info.get("qmin", 0)
qmax = info.get("qmax", 0)
qmin_alpha = info.get("qmin_alpha", 0)
qmax_alpha = info.get("qmax_alpha", 0)
subsampling = info.get("subsampling", "4:2:0")
speed = info.get("speed", 8)
codec = info.get("codec", "auto")
range_ = info.get("range", "full")
Expand All @@ -133,17 +147,15 @@ def _save(im, fp, filename, save_all=False):
enc = _avif.AvifEncoder(
im.size[0],
im.size[1],
yuv_format,
subsampling,
qmin,
qmax,
qmin_alpha,
qmax_alpha,
speed,
codec,
range_,
icc_profile or b'',
exif or b'',
xmp or b'',
icc_profile or b"",
exif or b"",
xmp or b"",
)

# Add each frame
Expand Down
38 changes: 14 additions & 24 deletions src/pillow_avif/_avif.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@
#endif

typedef struct {
avifPixelFormat yuv_format;
avifPixelFormat subsampling;
int qmin;
int qmax;
int qmin_alpha;
int qmax_alpha;
int speed;
avifCodecChoice codec;
avifRange range;
Expand Down Expand Up @@ -162,11 +160,9 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {
AvifEncoderObject *self = NULL;
avifEncoder *encoder = NULL;

char *yuv_format = "420";
char *subsampling = "4:2:0";
int qmin = AVIF_QUANTIZER_BEST_QUALITY; // =0
int qmax = 10; // "High Quality", but not lossless
int qmin_alpha = AVIF_QUANTIZER_LOSSLESS; // =0
int qmax_alpha = AVIF_QUANTIZER_LOSSLESS; // =0
int speed = 8;
PyObject *icc_bytes;
PyObject *exif_bytes;
Expand All @@ -177,14 +173,12 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {

if (!PyArg_ParseTuple(
args,
"IIsiiiiissSSS",
"IIsiiissSSS",
&width,
&height,
&yuv_format,
&subsampling,
&qmin,
&qmax,
&qmin_alpha,
&qmax_alpha,
&speed,
&codec,
&range,
Expand All @@ -194,23 +188,21 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {
return NULL;
}

if (strcmp(yuv_format, "4:0:0") == 0) {
enc_options.yuv_format = AVIF_PIXEL_FORMAT_YUV400;
} else if (strcmp(yuv_format, "4:2:0") == 0) {
enc_options.yuv_format = AVIF_PIXEL_FORMAT_YUV420;
} else if (strcmp(yuv_format, "4:2:2") == 0) {
enc_options.yuv_format = AVIF_PIXEL_FORMAT_YUV422;
} else if (strcmp(yuv_format, "4:4:4") == 0) {
enc_options.yuv_format = AVIF_PIXEL_FORMAT_YUV444;
if (strcmp(subsampling, "4:0:0") == 0) {
enc_options.subsampling = AVIF_PIXEL_FORMAT_YUV400;
} else if (strcmp(subsampling, "4:2:0") == 0) {
enc_options.subsampling = AVIF_PIXEL_FORMAT_YUV420;
} else if (strcmp(subsampling, "4:2:2") == 0) {
enc_options.subsampling = AVIF_PIXEL_FORMAT_YUV422;
} else if (strcmp(subsampling, "4:4:4") == 0) {
enc_options.subsampling = AVIF_PIXEL_FORMAT_YUV444;
} else {
PyErr_Format(PyExc_ValueError, "Invalid yuv_format: %s", yuv_format);
PyErr_Format(PyExc_ValueError, "Invalid subsampling: %s", subsampling);
return NULL;
}

enc_options.qmin = normalize_quantize_value(qmin);
enc_options.qmax = normalize_quantize_value(qmax);
enc_options.qmin_alpha = normalize_quantize_value(qmin_alpha);
enc_options.qmax_alpha = normalize_quantize_value(qmax_alpha);

if (speed < AVIF_SPEED_SLOWEST) {
speed = AVIF_SPEED_SLOWEST;
Expand Down Expand Up @@ -267,8 +259,6 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {
encoder->maxThreads = max_threads;
encoder->minQuantizer = enc_options.qmin;
encoder->maxQuantizer = enc_options.qmax;
encoder->minQuantizerAlpha = enc_options.qmin_alpha;
encoder->maxQuantizerAlpha = enc_options.qmax_alpha;
encoder->codecChoice = enc_options.codec;
encoder->speed = enc_options.speed;
encoder->timescale = (uint64_t)1000;
Expand All @@ -277,7 +267,7 @@ AvifEncoderNew(PyObject *self_, PyObject *args) {
avifImage *image = avifImageCreateEmpty();
// Set these in advance so any upcoming RGB -> YUV use the proper coefficients
image->yuvRange = enc_options.range;
image->yuvFormat = enc_options.yuv_format;
image->yuvFormat = enc_options.subsampling;
image->colorPrimaries = AVIF_COLOR_PRIMARIES_UNSPECIFIED;
image->transferCharacteristics = AVIF_TRANSFER_CHARACTERISTICS_UNSPECIFIED;
image->matrixCoefficients = AVIF_MATRIX_COEFFICIENTS_BT601;
Expand Down
42 changes: 37 additions & 5 deletions tests/test_file_avif.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
except ImportError:
from multiprocessing import cpu_count

try:
from unittest import mock
except ImportError:
import mock

import pytest

from PIL import Image
Expand Down Expand Up @@ -349,17 +354,17 @@ def test_seek(self):
with pytest.raises(EOFError):
im.seek(1)

@pytest.mark.parametrize("yuv_format", ["4:4:4", "4:2:2", "4:0:0"])
def test_encoder_yuv_format(self, tmp_path, yuv_format):
@pytest.mark.parametrize("subsampling", ["4:4:4", "4:2:2", "4:0:0"])
def test_encoder_subsampling(self, tmp_path, subsampling):
with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif")
im.save(test_file, yuv_format=yuv_format)
im.save(test_file, subsampling=subsampling)

def test_encoder_yuv_format_invalid(self, tmp_path):
def test_encoder_subsampling_invalid(self, tmp_path):
with Image.open(TEST_AVIF_FILE) as im:
test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError):
im.save(test_file, yuv_format="foo")
im.save(test_file, subsampling="foo")

def test_encoder_range(self, tmp_path):
with Image.open(TEST_AVIF_FILE) as im:
Expand Down Expand Up @@ -434,6 +439,33 @@ def test_encoder_codec_available_cannot_decode(self):
def test_encoder_codec_available_invalid(self):
assert _avif.encoder_codec_available("foo") is False

@pytest.mark.parametrize(
"quality,expected_qminmax",
[
[0, (63, 63)],
[100, (0, 0)],
[90, (0, 10)],
[None, (0, 10)], # default
[50, (14, 50)],
],
)
def test_encoder_quality_qmin_qmax_map(self, tmp_path, quality, expected_qminmax):
MockEncoder = mock.Mock(wraps=_avif.AvifEncoder)
with mock.patch.object(_avif, "AvifEncoder", new=MockEncoder) as mock_encoder:
with Image.open("tests/images/hopper.avif") as im:
test_file = str(tmp_path / "temp.avif")
if quality is None:
im.save(test_file)
else:
im.save(test_file, quality=quality)
assert mock_encoder.call_args[0][3:5] == expected_qminmax

def test_encoder_quality_valueerror(self, tmp_path):
with Image.open("tests/images/hopper.avif") as im:
test_file = str(tmp_path / "temp.avif")
with pytest.raises(ValueError):
im.save(test_file, quality="invalid")

@skip_unless_avif_decoder("aom")
def test_decoder_codec_available(self):
assert _avif.decoder_codec_available("aom") is True
Expand Down
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ deps =
pytest-cov
test-image-results
pillow
py27: mock

[testenv:coverage-report]
skip_install = true
Expand Down

0 comments on commit cbefd5c

Please sign in to comment.