Source code for pyctools.components.colourspace.gammacorrection

#  Pyctools - a picture processing algorithm development kit.
#  http://github.com/jim-easterbrook/pyctools
#  Copyright (C) 2016-20  Pyctools contributors
#
#  This program is free software: you can redistribute it and/or
#  modify it under the terms of the GNU General Public License as
#  published by the Free Software Foundation, either version 3 of the
#  License, or (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#  General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see
#  <http://www.gnu.org/licenses/>.

__all__ = ['GammaCorrect', 'PiecewiseGammaCorrect']
__docformat__ = 'restructuredtext en'

from collections import OrderedDict
import math

import sys
if 'sphinx' in sys.modules:
    __all__ += ['apply_transfer_function']

import numpy
try:
    from scipy import interpolate
except ImportError:
    interpolate = None

from pyctools.core.config import ConfigBool, ConfigEnum, ConfigFloat, ConfigStr
from pyctools.core.base import Transformer
from pyctools.core.types import pt_float
from .gammacorrectioncore import apply_transfer_function


[docs] class GammaCorrect(Transformer): """Gamma correction. Convert linear intensity values to 'gamma corrected' form suitable for display or storage in standard video or image files. In ``inverse`` mode gamma corrected data is converted to linear intensity. The ``'hybrid_log'`` gamma option is an implementation of a proposal from `BBC R&D <http://www.bbc.co.uk/rd>`_ for `HDR imaging <https://en.wikipedia.org/wiki/High-dynamic-range_imaging>`_. See http://www.bbc.co.uk/rd/publications/whitepaper309 for more information. The ``'S-Log'`` option is taken from a Sony document https://pro.sony.com/bbsccms/assets/files/mkt/cinema/solutions/slog_manual.pdf ``'Canon-Log'`` is taken from a white paper on the EOS C300 camera: http://learn.usa.canon.com/app/pdfs/white_papers/White_Paper_Clog_optoelectronic.pdf The gamma options have all been normalised so that linear intensity ``black`` level input produces a gamma corrected output of 0 and linear intensity ``white`` level input produces an output of 255. The linear intensity black and white values are set by the ``black`` and ``white`` config items. You can use an :py:class:`~pyctools.components.arithmetic.Arithmetic` or :py:class:`~pyctools.components.colourspace.levels.ComputerToStudio` component to scale the output if required. The ``scale`` option adjusts the input and output ranges without changing the mapping from input white to output 255. With some functions this acts as a highlight compression adjustment. The ``function`` output emits the transfer function data whenever it changes. It can be connected to a :py:class:`~pyctools.components.io.plotdata.PlotData` component. ============== ===== ==== Config ============== ===== ==== ``black`` float "Linear intensity" black level. ``white`` float "Linear intensity" white level. ``scale`` float Adjust the range of some functions. ``gamma`` str Choose a gamma curve. Possible values: {}. ``knee`` bool Turn on "knee" (highlight compression). ``knee_point`` float Highlight compression threshold (normalised 0..1 range). ``knee_slope`` float Slope of transfer function above knee threshold. ``inverse`` bool ============== ===== ==== """ outputs = ['output', 'function'] #: gamma_toe = OrderedDict([ # name gamma toe threshold "a" ('linear', (1.0, 1.0, 0.0, 0.0)), ('bt709', (0.45, 4.5, 0.018, 0.099)), ('srgb', (1.0 / 2.4, 12.92, 0.0031308, 0.055)), ('adobe_rgb', (256.0 / 563.0, None, 0.0, 0.0)), ('hybrid_log', (None, None, 0.0, 0.0)), ('S-Log', (None, None, -0.037584, 0.0)), ('Canon-Log', (None, None, -0.0452664, 0.0)), ]) __doc__ = __doc__.format(', '.join(["``'" + x + "'``" for x in gamma_toe])) def initialise(self): self.config['gamma'] = ConfigEnum(choices=(self.gamma_toe.keys())) self.config['black'] = ConfigFloat(value=0.0, decimals=2) self.config['white'] = ConfigFloat(value=255.0, decimals=2) self.config['scale'] = ConfigFloat(value=1.0, decimals=2) self.config['inverse'] = ConfigBool() self.config['knee'] = ConfigBool() self.config['knee_point'] = ConfigFloat(value=0.9, decimals=3) self.config['knee_slope'] = ConfigFloat(value=0.1, decimals=3) self.initialised = False def on_set_config(self): self.initialised = False def adjust_params(self): self.initialised = True self.update_config() self.gamma, toe, threshold, self.a = self.gamma_toe[self.config['gamma']] knee = self.config['knee'] knee_point = self.config['knee_point'] knee_slope = self.config['knee_slope'] black = self.config['black'] white = self.config['white'] scale = self.config['scale'] # choose function to evaluate if self.config['gamma'] == 'hybrid_log': func = self.eval_hybrid_log elif self.config['gamma'] == 'S-Log': func = self.eval_s_log elif self.config['gamma'] == 'Canon-Log': func = self.eval_canon_log else: func = self.eval_gamma # set function ranges self.k_out = 1.0 self.k_in = 1.0 self.k_out = 1.0 / func(scale) self.k_in = scale # make list of in and out values in_lo = (-16.0 - black) / (white - black) in_hi = (256.0 + 16.0 - black) / (white - black) in_val = [] out_val = [] # compute first two points (linear slope) if toe is None: v_out = func((threshold / scale) + 0.0000000001) v_in = in_lo in_val.append(v_in) out_val.append(v_out) v_in = threshold / scale else: v_in = in_lo v_out = v_in * toe in_val.append(v_in) out_val.append(v_out) v_in = threshold / scale v_out = v_in * toe in_val.append(v_in) out_val.append(v_out) # complicated section needs many points x_step = 0.0001 while v_in < in_hi: v_in += x_step if knee and v_in >= knee_point: # knee section just needs another two endpoints v_in = knee_point v_out = func(v_in) in_val.append(v_in) out_val.append(v_out) step = max(in_hi - v_in, 0.1) in_val.append(v_in + step) out_val.append(v_out + (knee_slope * step)) break v_out = func(v_in) y_step = abs(v_out - out_val[-1]) if y_step < 0.005 and x_step < 0.5: v_in -= x_step x_step *= 2.0 continue if y_step > 0.5 and x_step > 0.005: v_in -= x_step x_step /= 2.0 continue in_val.append(v_in) out_val.append(v_out) self.in_val = numpy.array(in_val, dtype=pt_float) self.out_val = numpy.array(out_val, dtype=pt_float) # scale "linear" values self.in_val *= pt_float(white - black) self.in_val += pt_float(black) # scale gamma corrected values to normal video range self.out_val *= pt_float(255.0) # send section of curve to function output func_frame = self.outframe_pool['function'].get() lo = 0 while lo < len(self.out_val) and self.out_val[lo] < -64: lo += 1 hi = len(self.out_val) - 1 while hi >= 0 and self.out_val[hi] > 256 + 64: hi -= 1 func_frame.data = numpy.stack((self.in_val[lo:hi+1], self.out_val[lo:hi+1])) func_frame.type = 'func' audit = func_frame.metadata.get('audit') audit += 'data = GammaFunction({})\n'.format(self.config['gamma']) func_frame.metadata.set('audit', audit) func_frame.metadata.set( 'labels', str(['gamma curve', self.config['gamma']])) self.send('function', func_frame) def eval_hybrid_log(self, v_in): v_in *= self.k_in if v_in <= 1.0: v_out = 0.5 * math.sqrt(v_in) else: v_out = (0.17883277 * math.log(v_in - 0.28466892)) + 0.55991073 v_out *= self.k_out return v_out def eval_s_log(self, v_in): v_in *= self.k_in v_out = (0.432699 * math.log10(v_in + 0.037584)) + 0.616596 + 0.03 v_out *= self.k_out return v_out def eval_canon_log(self, v_in): v_in *= self.k_in v_out = (0.529136 * math.log10((10.1596 * v_in) + 1.0)) + 0.0730597 v_out *= self.k_out return v_out def eval_gamma(self, v_in): v_in *= self.k_in v_out = v_in ** self.gamma v_out = ((1.0 + self.a) * v_out) - self.a v_out *= self.k_out return v_out def transform(self, in_frame, out_frame): if not self.initialised: self.adjust_params() inverse = self.config['inverse'] data = in_frame.as_numpy(dtype=pt_float, copy=True) if inverse: apply_transfer_function(data, self.out_val, self.in_val) else: apply_transfer_function(data, self.in_val, self.out_val) out_frame.data = data # add audit audit = out_frame.metadata.get('audit') audit += 'data = {}GammaCorrect(data, {})\n'.format( ('', 'Inverse ')[inverse], self.config['gamma']) audit += ' range: {}-{}, scale: {}\n'.format( self.config['black'], self.config['white'], self.config['scale']) if self.config['knee']: audit += ' knee at {}, slope {}\n'.format( self.config['knee_point'], self.config['knee_slope']) out_frame.metadata.set('audit', audit) return True
[docs] class PiecewiseGammaCorrect(Transformer): """Gamma correction with a piecewise linear transform. The transform is specified as a series of input values and corresponding output values. Linear interpolation is used to convert data that lies between ``in_vals`` values, and extrapolation is used for data outside the range of ``in_vals``. The ``function`` output emits the transfer function data whenever it changes. It can be connected to the :py:class:`~pyctools.components.io.plotdata.PlotData` component. The ``smooth`` option (present if scipy_ is installed) converts the series of data points to a smooth curve using cubic spline interpolation. .. _scipy: http://scipy.org/ ============== ===== ==== Config ============== ===== ==== ``in_vals`` str List of input values, in increasing order. ``out_vals`` str List of corresponding output values. ``inverse`` bool ``smooth`` bool Smooth transform with cubic spline interpolation. Requires scipy_. ============== ===== ==== """ outputs = ['output', 'function'] #: def initialise(self): self.config['in_vals'] = ConfigStr(value='0.0, 255.0') self.config['out_vals'] = ConfigStr(value='0.0, 255.0') self.config['inverse'] = ConfigBool() if interpolate: self.config['smooth'] = ConfigBool() self.initialised = False def on_set_config(self): self.initialised = False def adjust_params(self): self.initialised = True self.update_config() in_vals = eval(self.config['in_vals']) out_vals = eval(self.config['out_vals']) if len(in_vals) > len(out_vals): in_vals = in_vals[:len(out_vals)] elif len(out_vals) > len(in_vals): out_vals = out_vals[:len(in_vals)] self.in_vals = numpy.array(in_vals, dtype=pt_float) self.out_vals = numpy.array(out_vals, dtype=pt_float) # smooth data if interpolate and self.config['smooth'] and len(in_vals) >= 4: # extend input to straighten ends of interpolation dx0 = (self.in_vals[1] - self.in_vals[0]) / 2.0 dy0 = (self.out_vals[1] - self.out_vals[0]) / 2.0 dx1 = (self.in_vals[-1] - self.in_vals[-2]) / 2.0 dy1 = (self.out_vals[-1] - self.out_vals[-2]) / 2.0 x = numpy.concatenate(( [self.in_vals[0]-dx0, self.in_vals[0], self.in_vals[0]+dx0], self.in_vals[1:-2], [self.in_vals[-1]-dx1, self.in_vals[-1], self.in_vals[-1]+dx1])) y = numpy.concatenate(( [self.out_vals[0]-dy0, self.out_vals[0], self.out_vals[0]+dy0], self.out_vals[1:-2], [self.out_vals[-1]-dy1, self.out_vals[-1], self.out_vals[-1]+dy1])) tck = interpolate.splrep(x, y) step = (self.in_vals[-1] - self.in_vals[0]) / 256.0 x = numpy.arange( self.in_vals[0], self.in_vals[-1] + step, step) y = interpolate.splev(x, tck) self.in_vals = x.astype(pt_float) self.out_vals = y.astype(pt_float) # send function output func_frame = self.outframe_pool['function'].get() func_frame.data = numpy.stack((self.in_vals, self.out_vals)) func_frame.type = 'func' audit = func_frame.metadata.get('audit') audit += 'data = PiecewiseGammaFunction()\n' audit += ' in_vals: {}\n'.format(self.config['in_vals']) audit += ' out_vals: {}\n'.format(self.config['out_vals']) if interpolate: audit += ' smoothing: {}\n'.format(self.config['smooth']) func_frame.metadata.set('audit', audit) self.send('function', func_frame) def transform(self, in_frame, out_frame): if not self.initialised: self.adjust_params() inverse = self.config['inverse'] data = in_frame.as_numpy(dtype=pt_float, copy=True) if inverse: apply_transfer_function(data, self.out_vals, self.in_vals) else: apply_transfer_function(data, self.in_vals, self.out_vals) out_frame.data = data # add audit audit = out_frame.metadata.get('audit') audit += 'data = {}PiecewiseGammaCorrect(data)\n'.format(('', 'Inverse ')[inverse]) audit += ' in_vals: {}\n'.format(self.config['in_vals']) audit += ' out_vals: {}\n'.format(self.config['out_vals']) out_frame.metadata.set('audit', audit) return True