107 lines
4.3 KiB
Python
107 lines
4.3 KiB
Python
"""True 16-bit RGB BT.2020/PQ PNG, using PNG 3 cICP and cLLI metadata.
|
|
|
|
Color math and compositing operate in absolute linear-light nits. resvg is used
|
|
only for vector coverage masks (8-bit alpha), never to supply HDR color values.
|
|
"""
|
|
|
|
from copy import deepcopy
|
|
import io
|
|
from pathlib import Path
|
|
import struct
|
|
import xml.etree.ElementTree as ET
|
|
import zlib
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
import resvg_py
|
|
|
|
from avatar_color import BT2020_Y, equal_luminance, validate_peak
|
|
|
|
NS = '{http://www.w3.org/2000/svg}'
|
|
|
|
|
|
def pq_encode(nits):
|
|
"""ST 2084 inverse EOTF; 10,000 nits maps to 1."""
|
|
m1, m2 = 2610 / 16384, 2523 / 32
|
|
c1, c2, c3 = 3424 / 4096, 2413 / 128, 2392 / 128
|
|
p = np.maximum(np.asarray(nits, dtype=np.float64), 0) / 10000
|
|
p = p ** m1
|
|
return ((c1 + c2 * p) / (1 + c3 * p)) ** m2
|
|
|
|
|
|
def pq_decode(signal):
|
|
m1, m2 = 2610 / 16384, 2523 / 32
|
|
c1, c2, c3 = 3424 / 4096, 2413 / 128, 2392 / 128
|
|
p = np.asarray(signal, dtype=np.float64) ** (1 / m2)
|
|
return 10000 * (np.maximum(p - c1, 0) / (c2 - c3 * p)) ** (1 / m1)
|
|
|
|
|
|
def render_linear_hdr(svg, peak_nits=400):
|
|
validate_peak(peak_nits)
|
|
root = ET.fromstring(svg)
|
|
width, height = int(root.get('width')), int(root.get('height'))
|
|
canvas = np.full((height, width, 3), .02, dtype=np.float32)
|
|
# Match SVG paint order. The current exporter has a translated background
|
|
# group and an independently transformed logo path.
|
|
layers = []
|
|
for child in root:
|
|
if child.tag == NS + 'g':
|
|
for path in child:
|
|
layers.append((path, child.get('transform', '')))
|
|
elif child.tag == NS + 'path':
|
|
layers.append((child, ''))
|
|
for path, parent_transform in layers:
|
|
base = path.get('data-base')
|
|
if base is None:
|
|
raise ValueError('HDR rendering requires palette metadata from make_svg')
|
|
level = float(path.get('data-level', '1'))
|
|
peak = equal_luminance(base, peak_nits, 10000, hdr=True)
|
|
rgb = np.asarray(peak, dtype=np.float32) * level
|
|
mask_svg = ET.Element(NS + 'svg', dict(root.attrib))
|
|
mask_path = deepcopy(path)
|
|
mask_path.set('fill', '#ffffff')
|
|
mask_path.set('transform', parent_transform + ' ' + path.get('transform', ''))
|
|
mask_svg.append(mask_path)
|
|
png = resvg_py.svg_to_bytes(svg_string=ET.tostring(mask_svg, encoding='unicode'))
|
|
with Image.open(io.BytesIO(png)) as image:
|
|
coverage = image.convert('RGBA').getchannel('A')
|
|
box = coverage.getbbox()
|
|
if box is None:
|
|
continue
|
|
alpha = np.asarray(coverage.crop(box), dtype=np.float32)[..., None] / 255
|
|
x0, y0, x1, y1 = box
|
|
region = canvas[y0:y1, x0:x1]
|
|
region *= 1 - alpha
|
|
region += alpha * rgb
|
|
return canvas
|
|
|
|
|
|
def png_chunk(kind, payload):
|
|
return (struct.pack('>I', len(payload)) + kind + payload
|
|
+ struct.pack('>I', zlib.crc32(kind + payload) & 0xffffffff))
|
|
|
|
|
|
def write_hdr_png(path, linear_nits):
|
|
height, width, channels = linear_nits.shape
|
|
if channels != 3 or not np.isfinite(linear_nits).all() or linear_nits.min() < 0 or linear_nits.max() > 10000:
|
|
raise ValueError('HDR pixels must be finite RGB values in 0..10000 nits')
|
|
encoded = np.rint(pq_encode(linear_nits) * 65535).astype('>u2')
|
|
# Calculate static luminance metadata from the actually encoded pixels.
|
|
decoded = pq_decode(encoded.astype(np.float64) / 65535)
|
|
luminance = decoded @ np.asarray(BT2020_Y)
|
|
max_y, average_y = float(luminance.max()), float(luminance.mean())
|
|
scanlines = b''.join(b'\0' + row.tobytes() for row in encoded)
|
|
content = b'\x89PNG\r\n\x1a\n'
|
|
content += png_chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 16, 2, 0, 0, 0))
|
|
content += png_chunk(b'cICP', bytes((9, 16, 0, 1))) # BT.2020, PQ, RGB, full range
|
|
content += png_chunk(b'cLLI', struct.pack('>II', round(max_y * 10000), round(average_y * 10000)))
|
|
content += png_chunk(b'tEXt', b'Software\0cmatrix-avatar; linear-light BT.2020/PQ renderer')
|
|
content += png_chunk(b'IDAT', zlib.compress(scanlines, 6))
|
|
content += png_chunk(b'IEND', b'')
|
|
Path(path).write_bytes(content)
|
|
return max_y, average_y
|
|
|
|
|
|
def export_hdr(svg, output, peak_nits=400):
|
|
return write_hdr_png(output, render_linear_hdr(svg, peak_nits))
|