Files
avatar/avatar_ultrahdr.py

71 lines
3.1 KiB
Python

"""Package the existing SDR PNG and matching SVG as a backwards-compatible JPEG.
Run: uv run python avatar_ultrahdr.py
"""
import argparse
import io
from pathlib import Path
import numpy as np
from PIL import Image
from pyultrahdr import build_ultra_hdr
from avatar_color import BT2020_Y, SRGB_Y, validate_peak
from avatar_hdr import render_linear_hdr
def export_ultrahdr(png_path, svg_path, output, peak_nits=400):
validate_peak(peak_nits)
with Image.open(png_path) as image:
sdr = np.asarray(image.convert('RGB'))
hdr = render_linear_hdr(Path(svg_path).read_text(encoding='utf-8'), peak_nits)
if hdr.shape != sdr.shape:
raise ValueError('The SDR PNG and SVG must have matching dimensions and content')
encoded = sdr.astype(np.float64) / 255
linear = np.where(encoded <= .04045, encoded / 12.92, ((encoded + .055) / 1.055) ** 2.4)
sdr_white = 203.0
sdr_y = linear @ np.asarray(SRGB_Y) * sdr_white
hdr_y = hdr @ np.asarray(BT2020_Y)
# A single-channel map scales RGB together, preserving the base chromaticity.
# Keep SDR shadows rather than forcing the PQ version's darker black level.
gain = np.log2(np.maximum(hdr_y / np.maximum(sdr_y, 1e-8), 1))
gain_max = max(float(gain.max()), 1e-6)
# The library's capacity argument is a reference-white brightness, not our
# colored pixels' luminance. Full gain must wait for the full gain headroom.
result = build_ultra_hdr(sdr, gain, 0.0, gain_max,
sdr_white * 2 ** gain_max, sdr_white,
base_quality=100, gainmap_quality=100)
Path(output).write_bytes(result)
return verify_ultrahdr(result, sdr)
def verify_ultrahdr(result, sdr):
with Image.open(io.BytesIO(result)) as image:
decoded = np.asarray(image.convert('RGB'))
directory = image._getmp()
frames = directory[45057]
entry = directory[45058][1]
start = image.info['mpoffset'] + entry['DataOffset']
with Image.open(io.BytesIO(result[start:start + entry['Size']])) as gain_image:
gain_image.load()
if gain_image.size != image.size or b'GainMapMax' not in gain_image.info['xmp']:
raise ValueError('Missing full-resolution gain map or HDR metadata')
error = np.abs(decoded.astype(float) - sdr)
if frames != 2:
raise ValueError('Ultra HDR output must contain a base and gain-map image')
return {'size': list(sdr.shape[:2]), 'jpeg_frames': frames,
'sdr_mean_absolute_error': float(error.mean()),
'sdr_max_error': float(error.max())}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--png', type=Path, default=Path('avatar.png'))
parser.add_argument('--svg', type=Path, default=Path('avatar.svg'))
parser.add_argument('--output', type=Path, default=Path('avatar-ultrahdr.jpg'))
parser.add_argument('--peak-nits', type=float, default=400)
args = parser.parse_args()
print(export_ultrahdr(args.png, args.svg, args.output, args.peak_nits))
print(f'Saved {args.output.resolve()}')