#!/usr/bin/env python3
#
# script to generate an ASC-CLF LUT by running the agx-emulsion film
# simulation pipeline (see https://github.com/andreavolpato/agx-emulsion)
#
# the script can be run standalone or integrated with ART
# (https://artraweditor.github.io) using its "external 3dLUT" interface
import os
os.environ['KMP_WARNINGS'] = 'off'
import numpy
import argparse
import gzip
import struct
import math
import sys
import json
import time
import io
import warnings
import copy
from scipy.optimize import least_squares
from contextlib import redirect_stdout, redirect_stderr
from agx_emulsion.model.process import photo_params, AgXPhoto
from agx_emulsion.model.stocks import FilmStocks, PrintPapers
def _enum(cls, *vals):
res = []
for v in vals:
try:
res.append(cls[v])
except KeyError:
pass
return res
film_stocks = _enum(FilmStocks,
'kodak_portra_400',
'kodak_ultramax_400',
'kodak_gold_200',
'kodak_vision3_50d',
'fujifilm_pro_400h',
'fujifilm_xtra_400',
'fujifilm_c200',
'kodak_ektar_100',
'kodak_portra_160',
'kodak_portra_800',
'kodak_portra_800_push1',
'kodak_portra_800_push2',
'kodak_vision3_250d',
'kodak_vision3_200t',
'kodak_vision3_500t')
print_papers = _enum(PrintPapers,
'kodak_endura_premier',
'kodak_ektacolor_edge',
'kodak_supra_endura',
'kodak_portra_endura',
'fujifilm_crystal_archive_typeii',
'kodak_2393',
'kodak_2383')
def getopts():
p = argparse.ArgumentParser()
p.add_argument('-o', '--output')
p.add_argument('-O', '--outdir', default='.')
p.add_argument('-s', '--size', choices=['small', 'medium', 'large', 'huge'],
default='medium')
p.add_argument('-z', '--compressed', action='store_true')
film_avail = f"Film stock to use. Options: " + \
", ".join(f'{i} : {s.name}' for (i, s) in enumerate(film_stocks))
p.add_argument('-f', '--film', type=int, choices=range(len(film_stocks)),
help=film_avail, default=0)
paper_avail = f"Print paper to use. Options: " + \
", ".join(f'{i} : {s.name}' for (i, s) in enumerate(print_papers))
p.add_argument('-p', '--paper', type=int, choices=range(len(print_papers)),
default=0, help=paper_avail)
p.add_argument('-e', '--camera-expcomp', type=float, default=0)
p.add_argument('-E', '--print-exposure', type=float, default=1)
p.add_argument('-g', '--input-gain', type=float, default=0)
p.add_argument('--y-shift', type=float, default=0)
p.add_argument('--m-shift', type=float, default=0)
p.add_argument('--film-gamma', type=float, default=1)
p.add_argument('--print-gamma', type=float, default=1)
p.add_argument('--dir-couplers-amount', type=float, default=1)
p.add_argument('--output-black-offset', type=float, default=0)
p.add_argument('--gamut', choices=['srgb', 'rec2020'], default='rec2020')
p.add_argument('--json', nargs=2)
p.add_argument('--server', action='store_true')
p.add_argument('--auto-ym-shifts', action='store_true')
opts = p.parse_args()
if opts.json:
with open(opts.json[0]) as f:
params = json.load(f)
update_opts(opts, params, opts.json[1])
if not opts.output:
film = film_stocks[opts.film].name
paper = print_papers[opts.paper].name
name = f'{film}@{paper}.clf{"z" if opts.compressed else ""}'
opts.output = os.path.join(opts.outdir, name)
return opts
def update_opts(opts, params, output):
opts.film = params.get("film", opts.film)
opts.paper = params.get("paper", opts.paper)
opts.camera_expcomp = params.get("camera_expcomp", opts.camera_expcomp)
opts.print_exposure = params.get("print_exposure", opts.print_exposure)
opts.input_gain = params.get("input_gain", opts.input_gain)
opts.y_shift = params.get("y_shift", opts.y_shift)
opts.m_shift = params.get("m_shift", opts.m_shift)
opts.auto_ym_shifts = params.get("auto_ym_shifts", opts.auto_ym_shifts)
opts.film_gamma = params.get("film_gamma", opts.film_gamma)
opts.print_gamma = params.get("print_gamma", opts.print_gamma)
opts.dir_couplers_amount = params.get("dir_couplers_amount",
opts.dir_couplers_amount)
opts.output_black_offset = params.get(
"output_black_offset", opts.output_black_offset)
opts.output = output
def srgb(a, inv):
if not inv:
a = numpy.fmax(numpy.fmin(a, 1.0), 0.0)
return numpy.where(a <= 0.0031308,
12.92 * a,
1.055 * numpy.power(a, 1.0/2.4)-0.055)
else:
return numpy.where(a <= 0.04045, a / 12.92,
numpy.power((a + 0.055) / 1.055, 2.4))
def pq(a, inv):
m1 = 2610.0 / 16384.0
m2 = 2523.0 / 32.0
c1 = 107.0 / 128.0
c2 = 2413.0 / 128.0
c3 = 2392.0 / 128.0
scale = 100.0
if not inv:
# assume 1.0 is 100 nits, normalise so that 1.0 is 10000 nits
a /= scale
# apply the PQ curve
aa = numpy.power(a, m1)
res = numpy.power((c1 + c2 * aa)/(1.0 + c3 * aa), m2)
else:
p = numpy.power(a, 1.0/m2)
aa = numpy.fmax(p-c1, 0.0) / (c2 - c3 * p)
res = numpy.power(aa, 1.0/m1)
res *= scale
return res
class LUTCreator:
lutsize = {
'small' : 16,
'medium' : 36,
'large' : 64,
'huge' : 121
}
ap0_to_rec709 = """\
2.55128702 -1.11947013 -0.4318176
-0.27586285 1.36601602 -0.09015301
-0.01729251 -0.14852912 1.16582168
""".encode('utf-8')
rec709_to_ap0 = """\
0.43392843 0.3762503 0.18982151
0.088802 0.81526168 0.09593625
0.01775005 0.10944762 0.87280228
""".encode('utf-8')
ap0_to_rec2020 = """\
1.50910172 -0.2589874 -0.2501146
-0.07757638 1.17706684 -0.09949036
0.0020526 -0.03114411 1.02909153
""".encode('utf-8')
rec2020_to_ap0 = """\
0.67022657 0.15216775 0.17760585
0.0441723 0.86177705 0.09405057
0.0 0.02577705 0.97422293
""".encode('utf-8')
def get_base_image(self, opts):
dim = self.lutsize[opts.size]
sz = complex(0, float(dim))
table = numpy.mgrid[0.0:1.0:sz, 0.0:1.0:sz, 0.0:1.0:sz].reshape(3,-1).T
n = int(math.sqrt(dim**3))
data = table.reshape(-1)
shaper = lambda a: pq(a, True)
data = numpy.fromiter(map(shaper, data), dtype=numpy.float32)
data = data.reshape(n, n, -1)
return data
def get_params(self, opts):
params = photo_params(film_stocks[opts.film].value,
print_papers[opts.paper].value)
params.camera.exposure_compensation_ev = opts.camera_expcomp
params.enlarger.print_exposure = opts.print_exposure
params.enlarger.lens_blur = 0
params.scanner.lens_blur = 0
if opts.gamut == 'srgb':
params.io.input_color_space = 'sRGB'
params.settings.rgb_to_raw_method = 'mallett2019'
else:
params.io.input_color_space = 'ITU-R BT.2020'
params.settings.rgb_to_raw_method = 'hanatos2025'
params.io.input_cctf_decoding = False
params.io.output_color_space = 'ACES2065-1'
params.io.output_cctf_encoding = False
params.io.crop = False
params.io.preview_resize_factor = 1.0
params.io.upscale_factor = 1.0
params.io.full_image = True
params.io.compute_negative = False
params.negative.grain.active = False
params.negative.halation.active = False
params.print_paper.glare.active = False
params.negative.parametric.density_curves.active = False
params.camera.auto_exposure = False
params.camera.auto_exposure_method = 'median'
params.enlarger.print_exposure_compensation = True
params.debug.deactivate_spatial_effects = True
params.negative.data.tune.gamma_factor = opts.film_gamma
params.print_paper.data.tune.gamma_factor = opts.print_gamma
params.enlarger.y_filter_shift = opts.y_shift
params.enlarger.m_filter_shift = opts.m_shift
params.negative.dir_couplers.amount = opts.dir_couplers_amount
params.negative.dir_couplers.active = opts.dir_couplers_amount > 0
params.settings.use_scanner_lut = False
params.settings.use_enlarger_lut = False
params.settings.use_camera_lut = False
if opts.auto_ym_shifts:
key = self._key('autoshifts', opts)
res = self.cache.get(key)
if res is not None:
y_shift, m_shift = res
else:
image = numpy.array([[
[0.184, 0.184, 0.184],
]])
par = copy.copy(params)
par.debug.return_negative_density_cmy = True
photo = AgXPhoto(par)
density_cmy = photo.process(image)
def sqr(x): return x*x
def func(x):
y_shift, m_shift = x
photo.enlarger.y_filter_shift = y_shift
photo.enlarger.m_filter_shift = m_shift
log_raw = photo._expose_print(density_cmy)
print_cmy = photo._develop_print(log_raw)
out = photo._scan(print_cmy)
r, g, b = out.flatten()
return (abs(b-g), abs(r-g), abs(r-b))
start = time.time()
res = least_squares(func, [0.0, 0.0],
method='dogbox',
bounds=[(-10, -10), (10, 10)],
max_nfev=20)
y_shift, m_shift = round(res.x[0], 3), round(res.x[1], 3)
end = time.time()
print(f'least_squares: {round(end - start, 2)}, '
f'y_shift: {y_shift}, m_shift: {m_shift}')
self.cache[key] = (y_shift, m_shift)
params.enlarger.y_filter_shift = y_shift + opts.y_shift
params.enlarger.m_filter_shift = m_shift + opts.m_shift
return params
def __init__(self, opts):
with warnings.catch_warnings():
warnings.simplefilter('ignore')
self.image = self.get_base_image(opts)
self.shaper = self.get_shaper()
self.cache = {}
def _key(self, step, opts):
keys = {
'film' : ['film',
'camera_expcomp',
'film_gamma',
'dir_couplers_amount'],
'full' : ['film',
'camera_expcomp',
'film_gamma',
'dir_couplers_amount',
'paper',
'print_exposure',
'y_shift',
'm_shift',
'print_gamma',
'auto_ym_shifts'],
'autoshifts' : ['film',
'camera_expcomp',
'film_gamma',
'dir_couplers_amount',
'paper',
'print_exposure',
'print_gamma'],
}
d = {'step' : step}
for k in keys[step]:
d[k] = getattr(opts, k)
return json.dumps(d)
def _get(self, step, opts, photo, image):
k = self._key(step, opts)
res = self.cache.get(k)
if res is None and step == 'film':
photo.debug.return_negative_density_cmy = True
res = photo.process(image)
self.cache[k] = res
return res
def __call__(self, opts):
start = time.time()
params = self.get_params(opts)
photo = AgXPhoto(params)
def identity(rgb, *args, **kwds): return rgb
photo.print_paper._apply_cctf_encoding_and_clip = identity
image = self._get('full', opts, photo, self.image)
if image is None:
image = self._get('film', opts, photo, self.image)
log_raw = photo._expose_print(image)
density_cmy = photo._develop_print(log_raw)
image = photo._scan(density_cmy)
self.cache[self._key('full', opts)] = image
#image = photo.process(self.image)
self.make_lut(opts, image)
end = time.time()
sys.stderr.write('total time: %.3f\n' % (end - start))
def get_shaper(self):
f = io.BytesIO()
f.write(b'\n')
f.write(b' \n')
for i in range(65536):
v = struct.unpack('e', struct.pack('H', i))[0]
if math.isfinite(v) and v >= 0:
o = pq(v, False)
else:
o = 0.0
j = struct.unpack('H', struct.pack('e', o))[0]
f.write(f' {j}\n'.encode('utf-8'))
f.write(b' \n')
f.write(b'\n')
return f.getvalue()
def make_lut(self, opts, data):
data = data.reshape(-1, 3)
dim = int(round(math.pow(data.shape[0], 1.0/3.0)))
fopen = open if not opts.compressed else gzip.open
with fopen(opts.output, 'wb') as f:
f.write(b'\n')
f.write(b'\n')
if opts.input_gain:
f.write(b'\n')
f.write(b' \n')
g = math.pow(2, opts.input_gain)
f.write(f' {g} {g} {g}\n'.encode('utf-8'))
f.write(b' 0.0 0.0 0.0\n')
f.write(b' 1.0 1.0 1.0\n')
f.write(b' \n')
f.write(b'\n')
if opts.gamut == 'srgb':
f.write(self.ap0_to_rec709)
else:
f.write(self.ap0_to_rec2020)
f.write(self.shaper)
f.write(b'\n')
f.write(f' \n'.encode('utf-8'))
for rgb in data:
f.write((' %.8f %.8f %.8f\n' %
tuple(rgb)).encode('utf-8'))
f.write(b' \n')
f.write(b'\n')
if opts.output_black_offset:
f.write(b'\n')
f.write(b' \n')
bl = opts.output_black_offset * 2000.0 / 65535.0
f.write(b' 1.0 1.0 1.0\n')
f.write(f' {bl} {bl} {bl}\n'.encode('utf-8'))
f.write(b' 1.0 1.0 1.0\n')
f.write(b' \n')
f.write(b'\n')
f.write(b'\n')
# end of class LUTCreator
def main():
opts = getopts()
process = LUTCreator(opts)
if opts.server:
while True:
p = sys.stdin.readline().strip()
o = sys.stdin.readline().strip()
with open(p) as f:
params = json.load(f)
update_opts(opts, params, o)
buf = io.StringIO()
with redirect_stdout(buf):
with redirect_stderr(buf):
process(opts)
data = buf.getvalue().splitlines()
sys.stdout.write(f'Y {len(data)}\n')
for line in data:
sys.stdout.write(f'{line}\n')
sys.stdout.flush()
else:
process(opts)
if __name__ == '__main__':
main()