#!/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://art.pixls.us) using its "external 3dLUT" interface import os 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 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(FilmStocks)) p.add_argument('-f', '--film', type=int, choices=range(len(FilmStocks)), help=film_avail, default=0) paper_avail = f"Print paper to use. Options: " + \ ", ".join(f'{i} : {s.name}' for (i, s) in enumerate(PrintPapers)) p.add_argument('-p', '--paper', type=int, choices=range(len(PrintPapers)), 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 = list(FilmStocks)[opts.film].name paper = list(PrintPapers)[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(list(FilmStocks)[opts.film].value, list(PrintPapers)[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: 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}') params.enlarger.y_filter_shift = y_shift params.enlarger.m_filter_shift = 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() 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 = 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()