[libcamera-devel] [PATCH v3 01/12] utils: tuning: libtuning: Implement the core of libtuning

Laurent Pinchart laurent.pinchart at ideasonboard.com
Wed Nov 23 11:35:47 CET 2022


Hi Paul,

On Wed, Nov 23, 2022 at 07:23:03PM +0900, Paul Elder wrote:
> On Wed, Nov 23, 2022 at 03:30:14AM +0200, Laurent Pinchart wrote:
> > On Fri, Nov 11, 2022 at 02:31:43AM +0900, Paul Elder via libcamera-devel wrote:
> > > Implement the core of libtuning, our new tuning tool infrastructure. It
> > > leverages components from raspberrypi's ctt that could be reused for
> > > tuning tools for other platforms.
> > > 
> > > The core components include:
> > > - The Image class
> > > - libtuning (entry point and other core functions)
> > > - macbeth-related tools, including the macbeth reference image
> > > - utils
> > > 
> > > Signed-off-by: Paul Elder <paul.elder at ideasonboard.com>
> > > 
> > > ---
> > > Changes in v3:
> > > - *Split into separate patches*
> > >   - The following changes apply to the next two patches as well
> > > - fix style
> > > - rename Camera to Tuner
> > > - remove indirection from fake polymorphism
> > > - remove unused options property from Module
> > > - remove unimplemented gradients
> > > - convert readme to rst
> > > - fix readme license
> > > - reorder dependencies list
> > > - add file descriptions
> > > - remove indirection from Image loading
> > > - remove Image member variables that are unused due to dropping BRCM
> > >   support
> > > - remove G from Color enum
> > >   - Color was /not/ renamed to BayerComponent because it was much too
> > >     long for use in code
> > > - add @property getters to Param
> > > - fix undefined functions/variables
> > > 
> > > Changes in v2:
> > > - fix all python errors
> > > - fix style
> > > - add SPDX and copyright
> > > - remove validateConfig() from the base/abstract Module class
> > > - actually append the image after loading, even if it's alsc_only
> > > - s/average_functions/average/
> > > - remove separate params field for Average and Smoothing
> > > - move remainder parameter in Gradient to Linear, as it only applies to
> > >   that
> > > - from gradient.Linear, remove the remainders that I thought don't make
> > >   sense
> > > - add Float to gradient.Linear's remainder types, to divide everything
> > >   in as a float; useful for rkisp1's sector sizes (the x-size and y-size
> > >   tuning options)
> > > - add a map function to Gradient, for mapping values onto a curve
> > > - in Smoothing, move ksize to a constructor parameter
> > > - remove brcm image loading
> > > - move process_args from utils to libtuning
> > > - move Module's type string and human-readble module name to class
> > >   variable
> > > - move locate_macbeth from utils to macbeth
> > > - add out_name to Module, for the output to know what name to write for
> > >   the key in the tuning output (eg. rkisp1 uses "LensShadingCorrection"
> > >   while raspberrypi uses "rpi.alsc")
> > > ---
> > >  utils/tuning/README.rst                |  11 +
> > >  utils/tuning/libtuning/__init__.py     |  13 +
> > >  utils/tuning/libtuning/image.py        | 133 +++++++
> > >  utils/tuning/libtuning/libtuning.py    | 203 ++++++++++
> > >  utils/tuning/libtuning/macbeth.py      | 516 +++++++++++++++++++++++++
> > >  utils/tuning/libtuning/macbeth_ref.pgm |   6 +
> > >  utils/tuning/libtuning/utils.py        | 152 ++++++++
> > >  7 files changed, 1034 insertions(+)
> > >  create mode 100644 utils/tuning/README.rst
> > >  create mode 100644 utils/tuning/libtuning/__init__.py
> > >  create mode 100644 utils/tuning/libtuning/image.py
> > >  create mode 100644 utils/tuning/libtuning/libtuning.py
> > >  create mode 100644 utils/tuning/libtuning/macbeth.py
> > >  create mode 100644 utils/tuning/libtuning/macbeth_ref.pgm
> > >  create mode 100644 utils/tuning/libtuning/utils.py
> > > 
> > > diff --git a/utils/tuning/README.rst b/utils/tuning/README.rst
> > > new file mode 100644
> > > index 00000000..ce533b2c
> > > --- /dev/null
> > > +++ b/utils/tuning/README.rst
> > > @@ -0,0 +1,11 @@
> > > +.. SPDX-License-Identifier: CC-BY-SA-4.0
> > > +
> > > +.. TODO: Write an overview of libtuning
> > > +
> > > +Dependencies
> > > +------------
> > > +
> > > +- cv2
> > > +- numpy
> > > +- pyexiv2
> > > +- rawpy
> > > diff --git a/utils/tuning/libtuning/__init__.py b/utils/tuning/libtuning/__init__.py
> > > new file mode 100644
> > > index 00000000..93049976
> > > --- /dev/null
> > > +++ b/utils/tuning/libtuning/__init__.py
> > > @@ -0,0 +1,13 @@
> > > +# SPDX-License-Identifier: GPL-2.0-or-later
> > > +#
> > > +# Copyright (C) 2022, Paul Elder <paul.elder at ideasonboard.com>
> > > +
> > > +from libtuning.utils import *
> > > +from libtuning.libtuning import *
> > > +
> > > +from libtuning.image import *
> > > +from libtuning.macbeth import *
> > > +
> > > +from libtuning.average import *
> > > +from libtuning.gradient import *
> > > +from libtuning.smoothing import *
> > > diff --git a/utils/tuning/libtuning/image.py b/utils/tuning/libtuning/image.py
> > > new file mode 100644
> > > index 00000000..c734ca69
> > > --- /dev/null
> > > +++ b/utils/tuning/libtuning/image.py
> > > @@ -0,0 +1,133 @@
> > > +# SPDX-License-Identifier: BSD-2-Clause
> > > +#
> > > +# Copyright (C) 2019, Raspberry Pi Ltd
> > > +#
> > > +# image.py - Container for an image and associated metadata
> > > +
> > > +import binascii
> > > +import numpy as np
> > > +from pathlib import Path
> > > +import pyexiv2 as pyexif
> > > +import rawpy as raw
> > > +import re
> > > +
> > > +import libtuning as lt
> > > +import libtuning.utils as utils
> > > +
> > > +
> > > +class Image:
> > > +    def __init__(self, path: Path):
> > > +        self.path = path
> > > +        self.name = path.name
> > 
> > Unless I'm mistaken, self.name is never used.
> 
> It will be :)
> 
> I'm using it in the debug stuff I'm working on rn.

You could add it then. Or maybe use self.path.name ? Or add

	@property
	def name(self):
	    return self.path.name

> > > +        self.lsc_only = False
> > > +        self.color = -1
> > > +        self.lux = -1
> > > +
> > > +        try:
> > > +            self._load_metadata_exif()
> > > +        except Exception as e:
> > > +            utils.eprint(f'Failed to load metadata from {self.path}: {e}')
> > > +            raise e
> > > +
> > > +        try:
> > > +            self._read_image_dng()
> > > +        except Exception as e:
> > > +            utils.eprint(f'Failed to load image data from {self.path}: {e}')
> > > +            raise e
> > > +
> > > +    # May raise KeyError as there are too many to check
> > > +    def _load_metadata_exif(self):
> > > +        # RawPy doesn't load all the image tags that we need, so we use py3exiv2
> > > +        metadata = pyexif.ImageMetadata(str(self.path))
> > > +        metadata.read()
> > > +
> > > +        # The DNG and TIFF/EP specifications use different IFDs to store the
> > > +        # raw image data and the Exif tags. DNG stores them in a SubIFD and in
> > > +        # an Exif IFD respectively (named "SubImage1" and "Photo" by pyexiv2),
> > > +        # while TIFF/EP stores them both in IFD0 (name "Image"). Both are used
> > > +        # in "DNG" files, with libcamera-apps following the DNG recommendation
> > > +        # and applications based on picamera2 following TIFF/EP.
> > > +        #
> > > +        # This code detects which tags are being used, and therefore extracts the
> > > +        # correct values.
> > > +        try:
> > > +            self.w = metadata['Exif.SubImage1.ImageWidth'].value
> > > +            subimage = 'SubImage1'
> > > +            photo = 'Photo'
> > > +        except KeyError:
> > > +            self.w = metadata['Exif.Image.ImageWidth'].value
> > > +            subimage = 'Image'
> > > +            photo = 'Image'
> > > +        self.pad = 0
> > > +        self.h = metadata[f'Exif.{subimage}.ImageLength'].value
> > > +        white = metadata[f'Exif.{subimage}.WhiteLevel'].value
> > > +        self.sigbits = int(white).bit_length()
> > > +        self.fmt = (self.sigbits - 4) // 2
> > > +        self.exposure = int(metadata[f'Exif.{photo}.ExposureTime'].value * 1000000)
> > > +        self.againQ8 = metadata[f'Exif.{photo}.ISOSpeedRatings'].value * 256 / 100
> > > +        self.againQ8_norm = self.againQ8 / 256
> > > +        self.camName = metadata['Exif.Image.Model'].value
> > > +        self.blacklevel = int(metadata[f'Exif.{subimage}.BlackLevel'].value[0])
> > > +        self.blacklevel_16 = self.blacklevel << (16 - self.sigbits)
> > > +
> > > +        # Channel order depending on bayer pattern
> > > +        # The key is the order given by exif, where 0 is R, 1 is G, and 2 is B
> > > +        # The value is the index where the color can be found, where the first
> > > +        # is R, then G, then G, then B.
> > > +        bayer_case = {
> > > +            '0 1 1 2': (lt.Color.R, lt.Color.GR, lt.Color.GB, lt.Color.B),
> > > +            '1 2 0 1': (lt.Color.GB, lt.Color.R, lt.Color.B, lt.Color.GR),
> > > +            '2 1 1 0': (lt.Color.B, lt.Color.GB, lt.Color.GR, lt.Color.R),
> > > +            '1 0 2 1': (lt.Color.GR, lt.Color.R, lt.Color.B, lt.Color.GB)
> > > +        }
> > > +        # Note: This needs to be in IFD0
> > > +        cfa_pattern = metadata[f'Exif.{subimage}.CFAPattern'].value
> > > +        self.order = bayer_case[cfa_pattern]
> > > +
> > > +    def _read_image_dng(self):
> > > +        raw_im = raw.imread(str(self.path))
> > > +        raw_data = raw_im.raw_image
> > > +        shift = 16 - self.sigbits
> > > +        c0 = np.left_shift(raw_data[0::2, 0::2].astype(np.int64), shift)
> > > +        c1 = np.left_shift(raw_data[0::2, 1::2].astype(np.int64), shift)
> > > +        c2 = np.left_shift(raw_data[1::2, 0::2].astype(np.int64), shift)
> > > +        c3 = np.left_shift(raw_data[1::2, 1::2].astype(np.int64), shift)
> > > +        self.channels = [c0, c1, c2, c3]
> > > +        # Reorder the channels into R, GR, GB, B
> > > +        self.channels = [self.channels[i] for i in self.order]
> > > +
> > > +    # \todo Move this to macbeth.py
> > > +    def get_patches(self, cen_coords, size=16):
> > > +        saturated = True
> > 
> >         saturated = False
> > 
> > > +
> > > +        # Obtain channel widths and heights
> > > +        ch_w, ch_h = self.w, self.h
> > > +        cen_coords = list(np.array((cen_coords[0])).astype(np.int32))
> > > +        self.cen_coords = cen_coords
> > > +
> > > +        # Squares are ordered by stacking macbeth chart columns from left to
> > > +        # right. Some useful patch indices:
> > > +        #     white = 3
> > > +        #     black = 23
> > > +        #     'reds' = 9, 10
> > > +        #     'blues' = 2, 5, 8, 20, 22
> > > +        #     'greens' = 6, 12, 17
> > > +        #     greyscale = 3, 7, 11, 15, 19, 23
> > > +        all_patches = []
> > > +        for ch in self.channels:
> > > +            ch_patches = []
> > > +            for cen in cen_coords:
> > > +                # Macbeth centre is placed at top left of central 2x2 patch to
> > > +                # account for rounding. Patch pixels are sorted by pixel
> > > +                # brightness so spatial information is lost.
> > > +                patch = ch[cen[1] - 7:cen[1] + 9, cen[0] - 7:cen[0] + 9].flatten()
> > > +                patch.sort()
> > > +                if patch[-5] == (2**self.sigbits - 1) * 2**(16 - self.sigbits):
> > > +                    saturated = False
> > 
> >                     saturated = True
> > 
> > > +                ch_patches.append(patch)
> > > +
> > > +            all_patches.append(ch_patches)
> > > +
> > > +        self.patches = all_patches
> > > +
> > > +        return saturated
> > 
> >         return not saturated
> > 
> > > diff --git a/utils/tuning/libtuning/libtuning.py b/utils/tuning/libtuning/libtuning.py
> > > new file mode 100644
> > > index 00000000..055c4e4b
> > > --- /dev/null
> > > +++ b/utils/tuning/libtuning/libtuning.py
> > > @@ -0,0 +1,203 @@
> > > +# SPDX-License-Identifier: GPL-2.0-or-later
> > > +#
> > > +# Copyright (C) 2022, Paul Elder <paul.elder at ideasonboard.com>
> > > +#
> > > +# libtuning.py - An infrastructure for camera tuning tools
> > > +
> > > +import argparse
> > > +
> > > +import libtuning.utils as utils
> > > +from libtuning.utils import eprint
> > > +
> > > +from enum import Enum, IntEnum
> > > +
> > > +
> > > +class Color(IntEnum):
> > > +    R = 0
> > > +    GR = 1
> > > +    GB = 2
> > > +    B = 3
> > 
> > I would name the class BayerComponent or something similar.
> 
> I tried that, and the destroyed readability because the name is too long
> and python is bad at wrapping lines.

OK, let's revisit this later then.

> > > +
> > > +
> > > +class Debug(Enum):
> > > +    Plot = 1
> > > +
> > > +
> > > +# @brief What to do with the leftover pixels after dividing them into ALSC
> > > +#        sectors, when the division gradient is uniform
> > > +# @var Float Force floating point division so all sectors divide equally
> > > +# @var DistributeFront Divide the remainder equally (until running out,
> > > +#      obviously) into the existing sectors, starting from the front
> > > +# @var DistributeBack Same as DistributeFront but starting from the back
> > > +class Remainder(Enum):
> > > +    Float = 0
> > > +    DistributeFront = 1
> > > +    DistributeBack = 2
> > > +
> > > +
> > > +# @brief A helper class to contain a default value for a module configuration
> > > +# parameter
> > > +class Param(object):
> > > +    # @var Required The value contained in this instance is irrelevant, and the
> > > +    #      value must be provided by the tuning configuration file.
> > > +    # @var Optional If the value is not provided by the tuning configuration
> > > +    #      file, then the value contained in this instance will be used instead.
> > > +    # @var Hardcode The value contained in this instance will be used
> > > +    class Mode(Enum):
> > > +        Required = 0
> > > +        Optional = 1
> > > +        Hardcode = 2
> > > +
> > > +    # @param name Name of the parameter. Shall match the name used in the
> > > +    #        configuration file for the parameter
> > > +    # @param required Whether or not a value is required in the config
> > > +    #        parameter of getVal()
> > > +    # @param val Default value (only relevant if mode is Optional)
> > > +    def __init__(self, name: str, required: Mode, val=None):
> > > +        self.name = name
> > > +        self.__required = required
> > > +        self.val = val
> > > +
> > > +    def get_value(self, config: dict):
> > > +        if self.required is self.Mode.Hardcode:
> > > +            return self.val
> > > +
> > > +        if self.required is self.Mode.Required and self.name not in config:
> > > +            raise ValueError(f'Parameter {self.name} is required but not provided in the configuration')
> > > +
> > > +        return config[self.name] if self.required is self.Mode.Required else self.val
> > > +
> > > +    @property
> > > +    def required(self):
> > > +        return self.__required is self.Mode.Required
> > > +
> > > +    # @brief Used by libtuning to auto-generate help information for the tuning
> > > +    #        script on the available parameters for the configuration file
> > > +    # \todo Implement this
> > > +    @property
> > > +    def info(self):
> > > +        raise NotImplementedError
> > > +
> > > +
> > > +class Tuner(object):
> > > +
> > > +    # External functions
> > > +
> > > +    def __init__(self, platform_name):
> > > +        self.name = platform_name
> > > +        self.modules = []
> > > +        self.parser = None
> > > +        self.generator = None
> > > +        self.output_order = []
> > > +        self.config = {}
> > > +        self.output = {}
> > > +
> > > +    def add(self, module):
> > > +        self.modules.append(module)
> > > +
> > > +    def set_input_parser(self, parser):
> > > +        self.parser = parser
> > > +
> > > +    def set_output_formatter(self, output):
> > > +        self.generator = output
> > > +
> > > +    def set_output_order(self, modules):
> > > +        self.output_order = modules
> > > +
> > > +    # @brief Convert classes in self.output_order to the instances in self.modules
> > > +    def _prepare_output_order(self):
> > > +        output_order = self.output_order
> > > +        self.output_order = []
> > > +        for module_type in output_order:
> > > +            modules = [module for module in self.modules if module.type == module_type.type]
> > > +            if len(modules) > 1:
> > > +                eprint(f'Multiple modules found for module type "{module_type.type}"')
> > > +                return False
> > > +            if len(modules) < 1:
> > > +                eprint(f'No module found for module type "{module_type.type}"')
> > > +                return False
> > > +            self.output_order.append(modules[0])
> > > +
> > > +        return True
> > > +
> > > +    # \todo Validate parser and generator at Tuner construction time?
> > > +    def _validate_settings(self):
> > > +        if self.parser is None:
> > > +            eprint('Missing parser')
> > > +            return False
> > > +
> > > +        if self.generator is None:
> > > +            eprint('Missing generator')
> > > +            return False
> > > +
> > > +        if len(self.modules) == 0:
> > > +            eprint('No modules added')
> > > +            return False
> > > +
> > > +        if len(self.output_order) != len(self.modules):
> > > +            eprint('Number of outputs does not match number of modules')
> > > +            return False
> > > +
> > > +        return True
> > > +
> > > +    def _process_args(self, argv, platform_name):
> > > +        parser = argparse.ArgumentParser(description=f'Camera Tuning for {platform_name}')
> > > +        parser.add_argument('-i', '--input', type=str, required=True,
> > > +                            help='''Directory containing calibration images (required).
> > > +                                    Images for ALSC must be named "alsc_{Color Temperature}k_1[u].dng",
> > > +                                    and all other images must be named "{Color Temperature}k_{Lux Level}l.dng"''')
> > > +        parser.add_argument('-o', '--output', type=str, required=True,
> > > +                            help='Output file (required)')
> > > +        # It is not our duty to scan all modules to figure out their default
> > > +        # options, so simply return an empty configuration if none is provided.
> > > +        parser.add_argument('-c', '--config', type=str, default='',
> > > +                            help='Config file (optional)')
> > > +        # \todo Check if we really need this or if stderr is good enough, or if
> > > +        # we want a better logging infrastructure with log levels
> > > +        parser.add_argument('-l', '--log', type=str, default=None,
> > > +                            help='Output log file (optional)')
> > > +        return parser.parse_args(argv[1:])
> > > +
> > > +    def run(self, argv):
> > > +        args = self._process_args(argv, self.name)
> > > +        if args is None:
> > > +            return -1
> > > +
> > > +        if not self._validate_settings():
> > > +            return -1
> > > +
> > > +        if not self._prepare_output_order():
> > > +            return -1
> > > +
> > > +        if len(args.config) > 0:
> > > +            self.config, disable = self.parser.parse(args.config, self.modules)
> > > +        else:
> > > +            self.config = {'general': {}}
> > > +            disable = []
> > > +
> > > +        for module in disable:
> > > +            if module in self.modules:
> > > +                self.modules.remove(module)
> > > +
> > > +        for module in self.modules:
> > > +            if not module.validate_config(self.config):
> > > +                eprint(f'Config is invalid for module {module.type}')
> > > +                return -1
> > > +
> > > +        images = utils.load_images(args.input, self.config, self.modules)
> > > +        if images is None or len(images) == 0:
> > > +            eprint(f'No images were found, or able to load')
> > > +            return -1
> > > +
> > > +        # We need args for input image locations and debug options, and config
> > > +        # for stuff like do_color and luminance_strength.
> > > +        for module in self.modules:
> > > +            out = module.process(args, self.config, images, self.output)
> > > +            if out is None:
> > > +                eprint(f'Module {module.name} failed to process, aborting')
> > > +                break
> > > +            self.output[module] = out
> > > +
> > > +        self.generator.write(args.output, self.output, self.output_order)
> > > +
> > > +        return 0
> > > diff --git a/utils/tuning/libtuning/macbeth.py b/utils/tuning/libtuning/macbeth.py
> > > new file mode 100644
> > > index 00000000..5faddf66
> > > --- /dev/null
> > > +++ b/utils/tuning/libtuning/macbeth.py
> > > @@ -0,0 +1,516 @@
> > > +# SPDX-License-Identifier: BSD-2-Clause
> > > +#
> > > +# Copyright (C) 2019, Raspberry Pi Ltd
> > > +#
> > > +# macbeth.py - Locate and extract Macbeth charts from images
> > > +# (Copied from: ctt_macbeth_locator.py)
> > > +
> > > +# \todo Add debugging
> > > +
> > > +import cv2
> > > +import os
> > > +from pathlib import Path
> > > +import numpy as np
> > > +
> > > +from libtuning.image import Image
> > > +
> > > +
> > > +# Reshape image to fixed width without distorting returns image and scale
> > > +# factor
> > > +def reshape(img, width):
> > > +    factor = width / img.shape[0]
> > > +    return cv2.resize(img, None, fx=factor, fy=factor), factor
> > > +
> > > +
> > > +# Correlation function to quantify match
> > > +def correlate(im1, im2):
> > > +    f1 = im1.flatten()
> > > +    f2 = im2.flatten()
> > > +    cor = np.corrcoef(f1, f2)
> > > +    return cor[0][1]
> > > +
> > > +
> > > +# @brief Compute coordinates of macbeth chart vertices and square centres
> > > +# @return (max_cor, best_map_col_norm, fit_coords, success)
> > > +#
> > > +# Also returns an error/success message for debugging purposes. Additionally,
> > > +# it scores the match with a confidence value.
> > > +#
> > > +#    Brief explanation of the macbeth chart locating algorithm:
> > > +#    - Find rectangles within image
> > > +#    - Take rectangles within percentage offset of median perimeter. The
> > > +#        assumption is that these will be the macbeth squares
> > > +#    - For each potential square, find the 24 possible macbeth centre locations
> > > +#        that would produce a square in that location
> > > +#    - Find clusters of potential macbeth chart centres to find the potential
> > > +#        macbeth centres with the most votes, i.e. the most likely ones
> > > +#    - For each potential macbeth centre, use the centres of the squares that
> > > +#        voted for it to find macbeth chart corners
> > > +#    - For each set of corners, transform the possible match into normalised
> > > +#        space and correlate with a reference chart to evaluate the match
> > > +#    - Select the highest correlation as the macbeth chart match, returning the
> > > +#        correlation as the confidence score
> > > +#
> > > +# \todo Clean this up
> > > +def get_macbeth_chart(img, ref_data):
> > > +    ref, ref_w, ref_h, ref_corns = ref_data
> > > +
> > > +    # The code will raise and catch a MacbethError in case of a problem, trying
> > > +    # to give some likely reasons why the problem occured, hence the try/except
> > > +    try:
> > > +        # Obtain image, convert to grayscale and normalise
> > > +        src = img
> > > +        src, factor = reshape(src, 200)
> > > +        original = src.copy()
> > > +        a = 125 / np.average(src)
> > > +        src_norm = cv2.convertScaleAbs(src, alpha=a, beta=0)
> > > +
> > > +        # This code checks if there are seperate colour channels. In the past the
> > > +        # macbeth locator ran on jpgs and this makes it robust to different
> > > +        # filetypes. Note that running it on a jpg has 4x the pixels of the
> > > +        # average bayer channel so coordinates must be doubled.
> > > +
> > > +        # This is best done in img_load.py in the get_patches method. The
> > > +        # coordinates and image width, height must be divided by two if the
> > > +        # macbeth locator has been run on a demosaicked image.
> > > +        if len(src_norm.shape) == 3:
> > > +            src_bw = cv2.cvtColor(src_norm, cv2.COLOR_BGR2GRAY)
> > > +        else:
> > > +            src_bw = src_norm
> > > +        original_bw = src_bw.copy()
> > > +
> > > +        # Obtain image edges
> > > +        sigma = 2
> > > +        src_bw = cv2.GaussianBlur(src_bw, (0, 0), sigma)
> > > +        t1, t2 = 50, 100
> > > +        edges = cv2.Canny(src_bw, t1, t2)
> > > +
> > > +        # Dilate edges to prevent self-intersections in contours
> > > +        k_size = 2
> > > +        kernel = np.ones((k_size, k_size))
> > > +        its = 1
> > > +        edges = cv2.dilate(edges, kernel, iterations=its)
> > > +
> > > +        # Find contours in image
> > > +        conts, _ = cv2.findContours(edges, cv2.RETR_TREE,
> > > +                                    cv2.CHAIN_APPROX_NONE)
> > > +        if len(conts) == 0:
> > > +            raise MacbethError(
> > > +                '\nWARNING: No macbeth chart found!'
> > > +                '\nNo contours found in image\n'
> > > +                'Possible problems:\n'
> > > +                '- Macbeth chart is too dark or bright\n'
> > > +                '- Macbeth chart is occluded\n'
> > > +            )
> > > +
> > > +        # Find quadrilateral contours
> > > +        epsilon = 0.07
> > > +        conts_per = []
> > > +        for i in range(len(conts)):
> > > +            per = cv2.arcLength(conts[i], True)
> > > +            poly = cv2.approxPolyDP(conts[i], epsilon * per, True)
> > > +            if len(poly) == 4 and cv2.isContourConvex(poly):
> > > +                conts_per.append((poly, per))
> > > +
> > > +        if len(conts_per) == 0:
> > > +            raise MacbethError(
> > > +                '\nWARNING: No macbeth chart found!'
> > > +                '\nNo quadrilateral contours found'
> > > +                '\nPossible problems:\n'
> > > +                '- Macbeth chart is too dark or bright\n'
> > > +                '- Macbeth chart is occluded\n'
> > > +                '- Macbeth chart is out of camera plane\n'
> > > +            )
> > > +
> > > +        # Sort contours by perimeter and get perimeters within percent of median
> > > +        conts_per = sorted(conts_per, key=lambda x: x[1])
> > > +        med_per = conts_per[int(len(conts_per) / 2)][1]
> > > +        side = med_per / 4
> > > +        perc = 0.1
> > > +        med_low, med_high = med_per * (1 - perc), med_per * (1 + perc)
> > > +        squares = []
> > > +        for i in conts_per:
> > > +            if med_low <= i[1] and med_high >= i[1]:
> > > +                squares.append(i[0])
> > > +
> > > +        # Obtain coordinates of nomralised macbeth and squares
> > > +        square_verts, mac_norm = get_square_verts(0.06)
> > > +        # For each square guess, find 24 possible macbeth chart centres
> > > +        mac_mids = []
> > > +        squares_raw = []
> > > +        for i in range(len(squares)):
> > > +            square = squares[i]
> > > +            squares_raw.append(square)
> > > +
> > > +            # Convert quads to rotated rectangles. This is required as the
> > > +            # 'squares' are usually quite irregular quadrilaterls, so
> > > +            # performing a transform would result in exaggerated warping and
> > > +            # inaccurate macbeth chart centre placement
> > > +            rect = cv2.minAreaRect(square)
> > > +            square = cv2.boxPoints(rect).astype(np.float32)
> > > +
> > > +            # Reorder vertices to prevent 'hourglass shape'
> > > +            square = sorted(square, key=lambda x: x[0])
> > > +            square_1 = sorted(square[:2], key=lambda x: x[1])
> > > +            square_2 = sorted(square[2:], key=lambda x: -x[1])
> > > +            square = np.array(np.concatenate((square_1, square_2)), np.float32)
> > > +            square = np.reshape(square, (4, 2)).astype(np.float32)
> > > +            squares[i] = square
> > > +
> > > +            # Find 24 possible macbeth chart centres by trasnforming normalised
> > > +            # macbeth square vertices onto candidate square vertices found in image
> > > +            for j in range(len(square_verts)):
> > > +                verts = square_verts[j]
> > > +                p_mat = cv2.getPerspectiveTransform(verts, square)
> > > +                mac_guess = cv2.perspectiveTransform(mac_norm, p_mat)
> > > +                mac_guess = np.round(mac_guess).astype(np.int32)
> > > +
> > > +                mac_mid = np.mean(mac_guess, axis=1)
> > > +                mac_mids.append([mac_mid, (i, j)])
> > > +
> > > +        if len(mac_mids) == 0:
> > > +            raise MacbethError(
> > > +                '\nWARNING: No macbeth chart found!'
> > > +                '\nNo possible macbeth charts found within image'
> > > +                '\nPossible problems:\n'
> > > +                '- Part of the macbeth chart is outside the image\n'
> > > +                '- Quadrilaterals in image background\n'
> > > +            )
> > > +
> > > +        # Reshape data
> > > +        for i in range(len(mac_mids)):
> > > +            mac_mids[i][0] = mac_mids[i][0][0]
> > > +
> > > +        # Find where midpoints cluster to identify most likely macbeth centres
> > > +        clustering = cluster.AgglomerativeClustering(
> > > +            n_clusters=None,
> > > +            compute_full_tree=True,
> > > +            distance_threshold=side * 2
> > > +        )
> > > +        mac_mids_list = [x[0] for x in mac_mids]
> > > +
> > > +        if len(mac_mids_list) == 1:
> > > +            # Special case of only one valid centre found (probably not needed)
> > > +            clus_list = []
> > > +            clus_list.append([mac_mids, len(mac_mids)])
> > > +
> > > +        else:
> > > +            clustering.fit(mac_mids_list)
> > > +
> > > +            # Create list of all clusters
> > > +            clus_list = []
> > > +            if clustering.n_clusters_ > 1:
> > > +                for i in range(clustering.labels_.max() + 1):
> > > +                    indices = [j for j, x in enumerate(clustering.labels_) if x == i]
> > > +                    clus = []
> > > +                    for index in indices:
> > > +                        clus.append(mac_mids[index])
> > > +                    clus_list.append([clus, len(clus)])
> > > +                clus_list.sort(key=lambda x: -x[1])
> > > +
> > > +            elif clustering.n_clusters_ == 1:
> > > +                # Special case of only one cluster found
> > > +                clus_list.append([mac_mids, len(mac_mids)])
> > > +            else:
> > > +                raise MacbethError(
> > > +                    '\nWARNING: No macebth chart found!'
> > > +                    '\nNo clusters found'
> > > +                    '\nPossible problems:\n'
> > > +                    '- NA\n'
> > > +                )
> > > +
> > > +        # Keep only clusters with enough votes
> > > +        clus_len_max = clus_list[0][1]
> > > +        clus_tol = 0.7
> > > +        for i in range(len(clus_list)):
> > > +            if clus_list[i][1] < clus_len_max * clus_tol:
> > > +                clus_list = clus_list[:i]
> > > +                break
> > > +            cent = np.mean(clus_list[i][0], axis=0)[0]
> > > +            clus_list[i].append(cent)
> > > +
> > > +        # Get centres of each normalised square
> > > +        reference = get_square_centres(0.06)
> > > +
> > > +        # For each possible macbeth chart, transform image into
> > > +        # normalised space and find correlation with reference
> > > +        max_cor = 0
> > > +        best_map = None
> > > +        best_fit = None
> > > +        best_cen_fit = None
> > > +        best_ref_mat = None
> > > +
> > > +        for clus in clus_list:
> > > +            clus = clus[0]
> > > +            sq_cents = []
> > > +            ref_cents = []
> > > +            i_list = [p[1][0] for p in clus]
> > > +            for point in clus:
> > > +                i, j = point[1]
> > > +
> > > +                # Remove any square that voted for two different points within
> > > +                # the same cluster. This causes the same point in the image to be
> > > +                # mapped to two different reference square centres, resulting in
> > > +                # a very distorted perspective transform since cv2.findHomography
> > > +                # simply minimises error.
> > > +                # This phenomenon is not particularly likely to occur due to the
> > > +                # enforced distance threshold in the clustering fit but it is
> > > +                # best to keep this in just in case.
> > > +                if i_list.count(i) == 1:
> > > +                    square = squares_raw[i]
> > > +                    sq_cent = np.mean(square, axis=0)
> > > +                    ref_cent = reference[j]
> > > +                    sq_cents.append(sq_cent)
> > > +                    ref_cents.append(ref_cent)
> > > +
> > > +                    # At least four squares need to have voted for a centre in
> > > +                    # order for a transform to be found
> > > +            if len(sq_cents) < 4:
> > > +                raise MacbethError(
> > > +                    '\nWARNING: No macbeth chart found!'
> > > +                    '\nNot enough squares found'
> > > +                    '\nPossible problems:\n'
> > > +                    '- Macbeth chart is occluded\n'
> > > +                    '- Macbeth chart is too dark of bright\n'
> > > +                )
> > > +
> > > +            ref_cents = np.array(ref_cents)
> > > +            sq_cents = np.array(sq_cents)
> > > +
> > > +            # Find best fit transform from normalised centres to image
> > > +            h_mat, mask = cv2.findHomography(ref_cents, sq_cents)
> > > +            if 'None' in str(type(h_mat)):
> > > +                raise MacbethError(
> > > +                    '\nERROR\n'
> > > +                )
> > > +
> > > +            # Transform normalised corners and centres into image space
> > > +            mac_fit = cv2.perspectiveTransform(mac_norm, h_mat)
> > > +            mac_cen_fit = cv2.perspectiveTransform(np.array([reference]), h_mat)
> > > +
> > > +            # Transform located corners into reference space
> > > +            ref_mat = cv2.getPerspectiveTransform(
> > > +                mac_fit,
> > > +                np.array([ref_corns])
> > > +            )
> > > +            map_to_ref = cv2.warpPerspective(
> > > +                original_bw, ref_mat,
> > > +                (ref_w, ref_h)
> > > +            )
> > > +
> > > +            # Normalise brigthness
> > > +            a = 125 / np.average(map_to_ref)
> > > +            map_to_ref = cv2.convertScaleAbs(map_to_ref, alpha=a, beta=0)
> > > +
> > > +            # Find correlation with bw reference macbeth
> > > +            cor = correlate(map_to_ref, ref)
> > > +
> > > +            # Keep only if best correlation
> > > +            if cor > max_cor:
> > > +                max_cor = cor
> > > +                best_map = map_to_ref
> > > +                best_fit = mac_fit
> > > +                best_cen_fit = mac_cen_fit
> > > +                best_ref_mat = ref_mat
> > > +
> > > +            # Rotate macbeth by pi and recorrelate in case macbeth chart is
> > > +            # upside-down
> > > +            mac_fit_inv = np.array(
> > > +                ([[mac_fit[0][2], mac_fit[0][3],
> > > +                  mac_fit[0][0], mac_fit[0][1]]])
> > > +            )
> > > +            mac_cen_fit_inv = np.flip(mac_cen_fit, axis=1)
> > > +            ref_mat = cv2.getPerspectiveTransform(
> > > +                mac_fit_inv,
> > > +                np.array([ref_corns])
> > > +            )
> > > +            map_to_ref = cv2.warpPerspective(
> > > +                original_bw, ref_mat,
> > > +                (ref_w, ref_h)
> > > +            )
> > > +            a = 125 / np.average(map_to_ref)
> > > +            map_to_ref = cv2.convertScaleAbs(map_to_ref, alpha=a, beta=0)
> > > +            cor = correlate(map_to_ref, ref)
> > > +            if cor > max_cor:
> > > +                max_cor = cor
> > > +                best_map = map_to_ref
> > > +                best_fit = mac_fit_inv
> > > +                best_cen_fit = mac_cen_fit_inv
> > > +                best_ref_mat = ref_mat
> > > +
> > > +        # Check best match is above threshold
> > > +        cor_thresh = 0.6
> > > +        if max_cor < cor_thresh:
> > > +            raise MacbethError(
> > > +                '\nWARNING: Correlation too low'
> > > +                '\nPossible problems:\n'
> > > +                '- Bad lighting conditions\n'
> > > +                '- Macbeth chart is occluded\n'
> > > +                '- Background is too noisy\n'
> > > +                '- Macbeth chart is out of camera plane\n'
> > > +            )
> > > +
> > > +        # Represent coloured macbeth in reference space
> > > +        best_map_col = cv2.warpPerspective(
> > > +            original, best_ref_mat, (ref_w, ref_h)
> > > +        )
> > > +        best_map_col = cv2.resize(
> > > +            best_map_col, None, fx=4, fy=4
> > > +        )
> > > +        a = 125 / np.average(best_map_col)
> > > +        best_map_col_norm = cv2.convertScaleAbs(
> > > +            best_map_col, alpha=a, beta=0
> > > +        )
> > > +
> > > +        # Rescale coordinates to original image size
> > > +        fit_coords = (best_fit / factor, best_cen_fit / factor)
> > > +
> > > +        return (max_cor, best_map_col_norm, fit_coords, True)
> > > +
> > > +    # Catch macbeth errors and continue with code
> > > +    except MacbethError as error:
> > > +        eprint(error)
> > > +        return (0, None, None, False)
> > > +
> > > +
> > > +def find_macbeth(img, mac_config):
> > > +    small_chart = mac_config['small']
> > > +    show = mac_config['show']
> > > +
> > > +    # Catch the warnings
> > > +    warnings.simplefilter("ignore")
> > > +    warnings.warn("runtime", RuntimeWarning)
> > > +
> > > +    # Reference macbeth chart is created that will be correlated with the
> > > +    # located macbeth chart guess to produce a confidence value for the match.
> > > +    script_dir = Path(os.path.realpath(os.path.dirname(__file__)))
> > > +    macbeth_ref_path = script_dir.joinpath('macbeth_ref.pgm')
> > > +    ref = cv2.imread(str(macbeth_ref_path), flags=cv2.IMREAD_GRAYSCALE)
> > > +    ref_w = 120
> > > +    ref_h = 80
> > > +    rc1 = (0, 0)
> > > +    rc2 = (0, ref_h)
> > > +    rc3 = (ref_w, ref_h)
> > > +    rc4 = (ref_w, 0)
> > > +    ref_corns = np.array((rc1, rc2, rc3, rc4), np.float32)
> > > +    ref_data = (ref, ref_w, ref_h, ref_corns)
> > > +
> > > +    # Locate macbeth chart
> > > +    cor, mac, coords, ret = get_macbeth_chart(img, ref_data)
> > > +
> > > +    # Following bits of code try to fix common problems with simple techniques.
> > > +    # If now or at any point the best correlation is of above 0.75, then
> > > +    # nothing more is tried as this is a high enough confidence to ensure
> > > +    # reliable macbeth square centre placement.
> > > +
> > > +    for brightness in [2, 4]:
> > > +        if cor >= 0.75:
> > > +            break
> > > +        img_br = cv2.convertScaleAbs(img, alpha=brightness, beta=0)
> > > +        cor_b, mac_b, coords_b, ret_b = get_macbeth_chart(img_br, ref_data)
> > > +        if cor_b > cor:
> > > +            cor, mac, coords, ret = cor_b, mac_b, coords_b, ret_b
> > > +
> > > +    # In case macbeth chart is too small, take a selection of the image and
> > > +    # attempt to locate macbeth chart within that. The scale increment is
> > > +    # root 2
> > > +
> > > +    # These variables will be used to transform the found coordinates at
> > > +    # smaller scales back into the original. If ii is still -1 after this
> > > +    # section that means it was not successful
> > > +    ii = -1
> > > +    w_best = 0
> > > +    h_best = 0
> > > +    d_best = 100
> > > +
> > > +    # d_best records the scale of the best match. Macbeth charts are only looked
> > > +    # for at one scale increment smaller than the current best match in order to avoid
> > > +    # unecessarily searching for macbeth charts at small scales.
> > > +    # If a macbeth chart ha already been found then set d_best to 0
> > > +    if cor != 0:
> > > +        d_best = 0
> > > +
> > > +    for index, pair in enumerate([{'sel': 2 / 3, 'inc': 1 / 6},
> > > +                                  {'sel': 1 / 2, 'inc': 1 / 8},
> > > +                                  {'sel': 1 / 3, 'inc': 1 / 12},
> > > +                                  {'sel': 1 / 4, 'inc': 1 / 16}]):
> > > +        if cor >= 0.75:
> > > +            break
> > > +
> > > +        # Check if we need to check macbeth charts at even smaller scales. This
> > > +        # slows the code down significantly and has therefore been omitted by
> > > +        # default, however it is not unusably slow so might be useful if the
> > > +        # macbeth chart is too small to be picked up to by the current
> > > +        # subselections.  Use this for macbeth charts with side lengths around
> > > +        # 1/5 image dimensions (and smaller...?) it is, however, recommended
> > > +        # that macbeth charts take up as large as possible a proportion of the
> > > +        # image.
> > > +        if index >= 2 and (not small_chart or d_best <= index - 1):
> > > +            break
> > > +
> > > +        w, h = list(img.shape[:2])
> > > +        # Set dimensions of the subselection and the step along each axis
> > > +        # between selections
> > > +        w_sel = int(w * pair['sel'])
> > > +        h_sel = int(h * pair['sel'])
> > > +        w_inc = int(w * pair['inc'])
> > > +        h_inc = int(h * pair['inc'])
> > > +
> > > +        loop = ((1 - pair['sel']) / pair['inc']) + 1
> > > +        # For each subselection, look for a macbeth chart
> > > +        for i in range(loop):
> > > +            for j in range(loop):
> > > +                w_s, h_s = i * w_inc, j * h_inc
> > > +                img_sel = img[w_s:w_s + w_sel, h_s:h_s + h_sel]
> > > +                cor_ij, mac_ij, coords_ij, ret_ij = get_macbeth_chart(img_sel, ref_data)
> > > +
> > > +                # If the correlation is better than the best then record the
> > > +                # scale and current subselection at which macbeth chart was
> > > +                # found. Also record the coordinates, macbeth chart and message.
> > > +                if cor_ij > cor:
> > > +                    cor = cor_ij
> > > +                    mac, coords, ret = mac_ij, coords_ij, ret_ij
> > > +                    ii, jj = i, j
> > > +                    w_best, h_best = w_inc, h_inc
> > > +                    d_best = index + 1
> > > +
> > > +    # Transform coordinates from subselection to original image
> > > +    if ii != -1:
> > > +        for a in range(len(coords)):
> > > +            for b in range(len(coords[a][0])):
> > > +                coords[a][0][b][1] += ii * w_best
> > > +                coords[a][0][b][0] += jj * h_best
> > > +
> > > +    if not ret:
> > > +        return None
> > > +
> > > +    coords_fit = coords
> > > +    if cor < 0.75:
> > > +        eprint(f'Warning: Low confidence {cor:.3f} for macbeth chart in {img.path.name}')
> > > +
> > > +    if show:
> > > +        draw_macbeth_results(img, coords_fit)
> > > +
> > > +    return coords_fit
> > > +
> > > +
> > > +def locate_macbeth(image: Image, config: dict):
> > > +    # Find macbeth centres
> > > +    av_chan = (np.mean(np.array(image.channels), axis=0) / (2**16))
> > > +    av_val = np.mean(av_chan)
> > > +    if av_val < image.blacklevel_16 / (2**16) + 1 / 64:
> > > +        eprint(f'Image {image.path.name} too dark')
> > > +        return None
> > > +
> > > +    macbeth = find_macbeth(av_chan, config['general']['macbeth'])
> > > +
> > > +    if macbeth is None:
> > > +        eprint(f'No macbeth chart found in {image.path.name}')
> > > +        return None
> > > +
> > > +    mac_cen_coords = macbeth[1]
> > > +    if not image.get_patches(mac_cen_coords):
> > > +        eprint(f'Macbeth patches have saturated in {image.path.name}')
> > > +        return None
> > > +
> > > +    return macbeth
> > > diff --git a/utils/tuning/libtuning/macbeth_ref.pgm b/utils/tuning/libtuning/macbeth_ref.pgm
> > > new file mode 100644
> > > index 00000000..37897140
> > > --- /dev/null
> > > +++ b/utils/tuning/libtuning/macbeth_ref.pgm
> > > @@ -0,0 +1,6 @@
> > > +# SPDX-License-Identifier: BSD-2-Clause
> > > +P5
> > > +# Reference macbeth chart
> > > +120 80
> > > +255
> > > +      !#!"
 #!"&&$#$#'"%&#+2///..../.........-()))))))))))))))))))(((-,*)'(&)#($%(%"###""!%""&"&&!$" #!$ !"! $&**"  !#
5.,%
+,-5"0<HBAA54" %##((()*+,---.........+*)))))))))))))))-.,,--+))('((''('%'%##"!""!"!""""#!  
   !  %?/v??z:????L??????c?,!#""%%''')**+)-../..../.-*)))))))))))))**,,)**'(''&'((&&%%##$! !!!! ! !  
   


!



   5*"-)&7(1.75Rnge`\`$ ""!"%%%'')())++--/---,-..,-.,++**))))())*)*)''%'%&%&'&%%"""""         
    
 



 

!

   !!$&$$&##(+*,,/10122126545./66402006486869650*.1.***)*+)()&((('('##)('&%%&%$$$#$%$%$ (((*))('((('('(&%V
0;>>;@@>@AAAACBCB=&<?????????????????<5x???????????????|64RYVTSRRRMMNLKJJLH+&0gijgdeffmmnpnkji`#3????????????????bY! 

3FHHIIIHIJIIJHIII@#??????????????????=7}????????????????:5Wcbcbdcb`^^`^^_^Y,'6????????????????r'<????????????????l%


2FHHIIHJJJJJJIIJI?%;?????????????????>7|????????????????;8Xfeeegeccb`^aba]Z+)<????????????????r)>????????????????q#


3GHIIIIJIIJJIHIJI@&5?????????????????=8~????????????????;8Zgghggedbdcbda^\Z+(;????????????????y)9????????????????z"



3GIIJJJJJKJJJJJJJ@'4?????????????????>9|????????????????=8Zhighgeeeedeca__[/)B????????????????v&:????????????????|#




3GJJIIJKKKJJJKKJK@&6?????????????????>9~????????????????<8Yghegggffihccab^\/*C????????????????z'9?????????????????$
 
 6IKJJMMMKMKKMKKMLC&2?????????????????@9?????????????????<9Yghhhhijiegdcebc^0)G????????????????(7?????????????????%   6JLMMNMMKMMNMMMMMD&2?????????????????@:~????????????????=9Xfghhjiigdgddedc`1)M????????????????}(:??????????????¾?&

  "8LNOONNOMONNMMNOND'3?????????????????@;????????????????=:Ziiigheegegegggdc1,Q????????????????~)8?????????????????%
# "9NNNPPPQOOOOONNOOD'0??????????????????;????????????????=;[iigeeegghgdedgea0-P????????????????(8???????????????Ý'
 "#$:NNOQPPRPQPOOPQPPD*1?????????????????A;?????????????????;:Yfghgghgghghhdggc3.\????????????????~);???????????????¤(&%%;OQQQRSSRPQQQQSQQF)3?????????????????B<?????????????????=:Wfhghhhihggghfhee4/f?????????????????*:???????????????ä&
%%%?RSSSSSTTTTSSSTTRE)5?????????????????B=?????????????????@:Ygiihhiiiihihiiif72p????????????????}(9???????????????Ʃ'
#%&?TUTTTUUQSTTTTTVSF*3?????????????????F>?????????????????A;[ghjiihiiiihihije50r?????????????????)6???????????????ƫ&
 &#%?SVVVUUUUUTUUVVUUG*5?????????????????F=?????????????????A;Yhijiiijjiiiiijje81t????????????????~)5???????????????ư' '$$=OQRRQQPRSRSSSSSSG+6?????????????????D@??????????????????;Wefgggggfffgeeefc41x????????????????{*5?????????????????(
 &&&'++++,,*-,-00-0100*-SUX\]]`_ffgiooopo=;X\bedbadbca`]\]ZZ;;<::8:;9983433110/-,...1//12410/..--+)"",---,-./,,.-/-0-(
 &&%+/0103322011223233)(34534767::;;==:=B9;BFGEEGIKJKIJGIJCD=<:76566554111/0/1.*+00233300/00//..,+*#")(*)++,++))*++**'!!&$*w????¼???????????1-_addc`ceccdccedbb?A|????????????????B>=>?@@?====;<:;:<:11r?????????????????+.?????????????????( !'%*z???????????????ɠ42gjmllklomooonpopmHG?????????????????D>AEDEFEECEECCCDDEC46????????????????׿0:???????????????Ѿ,!!&&,|???????????????ʡ61inknnoopoppoqqrqoEE?????????????????FACGFFFFFFDFDDDDDDC57??????????????????09?????????????????+!"%%-~???????????????ʡ42inopppppoqqqrrsrnAB?????????????????C?DGGGGFFFFDFFDDEDC48??????????????????1;?????????????????+!!"#*|???????????????ʡ62imoppppqqqqrtrqtrGD?????????????????H?CGGGGGGGGFFFFFFDB38??????????????????1<???????????????Խ,  !)}???????????????ˢ63mooppqqqqqqrrtvtoDH?????????????????JACHHGGHGGFFFDDGGFD29??????????????????3>???????????????׽, 
$){???????????????ˢ53jpppqprqrrrttuvuo>H?????????????????JAFHHHHHGGHGGFGGFFE28??????????????????3:???????????????ڽ- "*{???????????????̣53loqpqsqrrrtrutsvrAH?????????????????HCGHIHHHHHHGFGHGGGD5;??????????????????28?????????????????,

 +}???????????????ʡ52mqoqpqrttttttuurpFI?????????????????OCEHHIHHHHGHGGFFIGF8<??????????????????48???????????????ۿ,
 
(|???????????????ʢ41krqpqqqrrtrtuvtuoEH?????????????????PBHHIIIHIIHIHGHGHHE7<??????????????????58?????????????????*  (z???????????????ʡ63kpqprqqstttutrvvoFO?????????????????LEHHIIHIHHHIGHGIHGF4=??????????????????5<?????????????????*  'z???????????????ȡ62lppqrqrrrtttuttvpAG?????????????????MGHIIIIHIIIHHIIJHHG4<??????????????????4<?????????????????+ !){???????????????Ƞ62jopqqqqqrtttutttrEH?????????????????OHFIIIIIJIIIIHIHIHI7>??????????????????5;?????????????????, !)z???????????????Ɵ53lppqqrqrtttuuuutsFI?????????????????RHGJIJHJKJJJIIIIIIH9>??????????????????5;?????????????????+  !({???????????????Ŝ41joppprqrrrutttvvrIH?????????????????THCJJJJJIJIJJIJJJIH7=??????????????????5;?????????????????+  (u?????????????????65gjlmmmnoopnpprpqoIH?????????????????OIBIJJJIJJJJIIIHHHG89??????????????????29???????????????ʾ'  "&,-*)-01/,0/12102-+04448789<>>??AFAD at DBCIJNRWTSUXT[WUQUOKFEBBABA?>>=<<;;67942:<<<>9999864565363&(13335422./1/-+..+  !"&$$""$"&$%'()(''*+-0124688:<>>??A>?EBCHKOLJLNOSQOXQQVMLACGHGHIGFHGDCCBB@??7432233210111.,++,++%(++)*(''%%%$$#%&$#

  ")0/001120024455520+-U]`addcdhefeekecYGFJRXYYVWWZWVXXVZTOBF}????????????????K7Ybccddfeg`^]^]\[Z[*)OTTPPQPOKOLLJJLIK 
  !1;:9:<<===;=???A at 9*/?????????????????FJmxyxwyzzzxyzzz{zxLO?????????????????]=??????????????????.-???????????????y#
 !!2><=;==>=<<>@@@@A9-0?????????????????IKnz||{|{||{}}~}}{zLO?????????????????]>??????????????????..????????????????~%

  $2==;<>>?===>@A at AB;+1?????????????????JJo{|y{||}{||}}}}}yMT?????????????????_>??????????????????-.????????????????}#
  %2<=;=<@?>==>?A at AA9+3?????????????????FMlz{{y|}}}}||}|}}{MT?????????????????d>??????????????????-,????????????????#


 %1<<<;==<<=>?A?@AA:,3?????????????????INo{{y{||||}|}}|~}{RT?????????????????d=??????????????????/-????????????????}#


!$0<<<=<<==>A@@>@AA:-2?????????????????HInzz{{||{{}~~}}|}zMR?????????????????d=??????????????????++????????????????~#
 "$/;<==>;===@@@@>AA:+2?????????????????KHn||y|||||{}~}|}|xMS?????????????????d=??????????????????+,????????????????}#
 ! "/:<=>@<<>=@@@@@AA;-3?????????????????MFs||{{{y}z}}|}|}}yMW?????????????????c>??????????????????,)????????????????|!

 !1;>?>><<>@>>=>ABB;,0?????????????????LHr{|{|}|y|}}}}}zNX?????????????????c???????????????????()????????????????z#

  $/;;<=;<>>=>>>@@BB:,1?????????????????IInyz||||||{||}{~|{NV?????????????????c;?????????????????('????????????????}#

 $0:<==<;>@>>>>@ABB:,/?????????????????HLlx|}y{y{|y{|}}}}yMR?????????????????d>~?????????????????*(???????????????y"

 !&3:;<<;==@@=>AABBA;-3?????????????????KLqz{|||y{}|}{}|~{zRQ?????????????????c9w?????????????????)'????????????????y"
 !%1<<;=>===<=@@ABBC<.5?????????????????IIlz{|}~~~|}{||~}}zMU?????????????????d;p?????????????????)$???????????????x"
  $2===<==@=<>=ABBBC?/0?????????????????IGkz}}{||}{||y||}zyOV?????????????????c7o?????????????????'&~??????????~?z"

#"#/;<:<<?>;===@?AAA>07?????????????????GGgwxz{yyxyzzyz{yuuHO?????????????????\8v?????????????????'$w~~}|||{~|{zxxxxv!

"""'*+(+)*))()+,,.../0398;=<=>DCCDDCBBDHBCJMMLMPNPOJPKPSJDICCNMPONMNNOKHIFDBHE3/46433323.....*+,)( 
!##!!!!!$#$$#$#&"

!!"(+**,,*+.//1478:<:33ACDFGGIIHIJLPKNMQFIPTTRVXVUXUUTXUSTNEGGFDEFAA>==;94877520-,))*(((('&$#!!"  










&%'FQPQR]dq??????????=F?????????????????QN?????????????????LE????znki^[YTPUOS;.%-/12322221/10//,/













%#0??????????????????@Q?????????????????QM?????????????????KE?????????????????H01NNQOQQOOMNNLKLJGB





'&/??????????????????AW?????????????????OL?????????????????KE?????????????????F-,PQQPQPPQPOONMNNKE


''0??????????????????CZ?????????????????RM?????????????????JE?????????????????F,*NSQPPQOOOOMNNMKID


('2??????????????????D[?????????????????QK?????????????????IF?????????????????F,*NPPPPPPNOONMMMJIF!

'(2??????????????????F]?????????????????RL?????????????????HD????????????????F+%MPPPPOOONONNMMKID)*4??????????????????D^?????????????????PL?????????????????IC?????????????????F+&NPOOOPPOONMMKMKHD
**6??????????????????D_?????????????????QJ?????????????????FC~????????????????F,'MPOOOOONONNKKIIIG
,+7??????????????????D^?????????????????QI?????????????????EB|????????????????E+&MONOOONNNNKMJKJHH

,-8??????????????????D]?????????????????PI?????????????????HE????????????????C,#LOOOONONNNKKKMKJF

,*6??????????????????Ca?????????????????MH?????????????????IF?????????????????D*%KONOMNMMKMKJJJIJE


,,6??????????????????B^?????????????????MG?????????????????HB}????????????????D+&LONOOONNMMMMKLKIA




,,6??????????????????A\?????????????????MF?????????????????IE????????????????E+&LNNMONNMMKKKKKIHF 

--6??????????????????A[?????????????????KF?????????????????JC????????????????F*&LMONMNMNKKJMKJJIF 







**5??????????????????>W?????????????????KE?????????????????F?}????????????????C*%KONNNJKKKMKJKJKID









,*4??????????????????<W?????????????????MA?????????????????GCx????????????????B)%HKLKKJJJKIHIHHFGC!







()*q????????????????o39v|}wwwwwwrqtuspn=9^gadcfgce`dbUY[\^>;DIJDB?FEGE=7>8634.(&&(%&*&%%'+*)+*#%(









)''03364443233222243/-+133423333423766645789:><<<;<;<?=?;<<:78673/001113--.-+*)&&#"&$#%&""$!! 














))+rbPpAD9-*******+*++)++--.//./.0/21453469:=;98<;<>=;><7766666741012.-13/-+-/(''&&&%%&$.%0()-%-#-#' 
#&
(
%
 







)))h?n?YQg?7(*))))*)**,--....../0/0001357666::;;>?>AA866666666656565300/20/.-*)(('((&&%)d=yoP?<???F?QFx;?2?1?0








))*RQ.0*,,5*(*))))*,**,+/.../...02/22224456468;:>BB;>;:76666666666755303033/,.-*(())('&')#)"##(+$+*
#)) & 




> > > diff --git a/utils/tuning/libtuning/utils.py b/utils/tuning/libtuning/utils.py
> > > new file mode 100644
> > > index 00000000..8a9f13f7
> > > --- /dev/null
> > > +++ b/utils/tuning/libtuning/utils.py
> > > @@ -0,0 +1,152 @@
> > > +# SPDX-License-Identifier: BSD-2-Clause
> > > +#
> > > +# Copyright (C) 2019, Raspberry Pi Ltd
> > > +# Copyright (C) 2022, Paul Elder <paul.elder at ideasonboard.com>
> > > +#
> > > +# utils.py - Utilities for libtuning
> > > +
> > > +import decimal
> > > +import math
> > > +import numpy as np
> > > +import os
> > > +from pathlib import Path
> > > +import re
> > > +import sys
> > > +
> > > +import libtuning as lt
> > > +from libtuning.image import Image
> > > +from libtuning.macbeth import locate_macbeth
> > > +
> > > +# Utility functions
> > > +
> > > +
> > > +def eprint(*args, **kwargs):
> > > +    print(*args, file=sys.stderr, **kwargs)
> > > +
> > > +
> > > +def get_module_by_type_name(modules, name):
> > > +    for module in modules:
> > > +        if module.type == name:
> > > +            return module
> > > +    return None
> > > +
> > > +
> > > +# @brief Round value while keeping the maximum number of decimal points
> > > +# @param limits Tuple of [min, max] acceptable values
> > > +# @description Prevents rounding such that significant figures are lost
> > > +# \todo Bikeshed this name
> > > +def round_with_sigfigs(val, limits: tuple):
> > > +    decimal_points = abs(decimal.Decimal(str(limits[-1])).as_tuple().exponent)
> > 
> > To be honest, I wonder if deducing the decimal point from the limits is
> > worth it. For all you know, you may have a [0.0, 4.0] range and want 3
> > decimal points. I'd rather pass the precision to the function.
> 
> Given the two sample points that I have I didn't think that you'd have a
> range of [0.0, 4.0].
> 
> This means we'll have to add a new module parameter for precision. Which
> I guess is fine; range + precision.
> 
> > > +
> > > +    # These are decimal left-shift multipliers
> > > +    lshift = 10**(decimal_points - 1)
> > > +    adjust = 10**(-decimal_points)
> > > +
> > > +    # We need the division to get rid of stray floating points
> > > +    # These are bounds for 5% and 95% of one significant figure *lower* than
> > > +    # the maximum number. They allow checking if a normal rounding would cause
> > > +    # an "overflow rounding" (where significant decimal points would be lost).
> > > +    # The "overflow rounding" can then be prevented by adding or subtracting
> > > +    # adjust.
> > > +    lower_bound = adjust * 10 * 5 * lshift / lshift
> > > +    upper_bound = adjust * 10 * 95 * lshift / lshift
> > > +
> > > +    out = val
> > > +    out = np.where((lshift * out) % 1 <= lower_bound, out + adjust, out)
> > > +    out = np.where((lshift * out) % 1 >= upper_bound, out - adjust, out)
> > > +    out = np.round(out, 3)
> > 
> > You write in a reply to v2
> > 
> > > "Round value while keeping the maximum number of decimal points"
> > > So like if limits is [0, 3.999], then 2.5999 would normally get rounded
> > > to 2.6, but this function would make sure it gets rounded to 2.599.
> > 
> > Why is that desired ? The rounding error is larger.
> 
> Good question. I don't know the answer. I just maintaned behavior from ctt.

Maybe it was bad behaviour to start with ? :-)

> > > +
> > > +    return out
> > > +
> > > +
> > > +# Private utility functions
> > > +
> > > +
> > > +def _list_image_files(directory):
> > > +    d = Path(directory)
> > > +    files = [d.joinpath(f) for f in os.listdir(d)
> > > +             if re.search(r'\.(jp[e]g$)|(dng$)', f)]
> > > +    files.sort()
> > > +    return files
> > > +
> > > +
> > > +def _parse_image_filename(fn: Path):
> > > +    result = re.search(r'^(alsc_)?(\d+)[kK]_(\d+)?[lLuU]?.\w{3,4}$', fn.name)
> > > +    if result is None:
> > > +        eprint(f'The file name of {fn.name} is incorrectly formatted')
> > > +        return None, None, None
> > > +
> > > +    color = int(result.group(2))
> > > +    lsc_only = result.group(1) is not None
> > > +    lux = None if lsc_only else int(result.group(3))
> > > +
> > > +    return color, lux, lsc_only
> > > +
> > > +
> > > +# \todo Implement this from check_imgs() in ctt.py
> > > +def _validate_images(images):
> > > +    return True
> > > +
> > > +
> > > +# Public utility functions
> > > +
> > > +
> > > +def load_images(input_dir: str, config: dict, modules: list) -> list:
> > > +    files = _list_image_files(input_dir)
> > > +    if len(files) == 0:
> > > +        eprint(f'No images found in {input_dir}')
> > > +        return None
> > > +
> > > +    has_lsc = any(isinstance(m, lt.modules.lsc.LSC) for m in modules)
> > 
> > Instead of passing the modules to this function, I think the caller
> > should figure out what images it needs, and pass that explicitly as an
> > argument.
> 
> Hm, yeah you're probably right. I'll give it another shot; last time I
> tried it broke.
> 
> > > +    # Only one LSC module allowed
> > > +    has_only_lsc = has_lsc and len(modules) == 1
> > > +
> > > +    # \todo Should this be separated into two lists for lsc_only?
> > > +    images = []
> > > +    for f in files:
> > > +        color, lux, lsc_only = _parse_image_filename(f)
> > > +        if color is None:
> > > +            continue
> > > +
> > > +        # Skip lsc image if we don't have an lsc module
> > > +        if lsc_only and not has_lsc:
> > > +            eprint(f'Skipping {fn.name} as this tuner has no LSC module')
> > 
> > fn is not defined.
> > 
> > > +            continue
> > > +
> > > +        # Skip non-lsc image if we have only an lsc module
> > > +        if not lsc_only and has_only_lsc:
> > > +            eprint(f'Skipping {fn.name} as this tuner only has an LSC module')
> > 
> > Same here.
> > 
> > > +            continue
> > > +
> > > +        # Load image
> > > +        try:
> > > +            image = Image(f)
> > > +        except Exception as e:
> > > +            eprint(f'Failed to load image {f.name}: {e}')
> > > +            continue
> > > +
> > > +        # Populate simple fields
> > > +        image.lsc_only = lsc_only
> > > +        image.color = color
> > > +        image.lux = lux
> > > +
> > > +        # Black level comes from the TIFF tags, but they are overridable by the
> > > +        # config file.
> > > +        if 'blacklevel' in config['general']:
> > > +            image.blacklevel_16 = config['general']['blacklevel']
> > > +
> > > +        if lsc_only:
> > > +            images.append(image)
> > > +            continue
> > > +
> > > +        # Handle macbeth
> > > +        macbeth = locate_macbeth(params)
> > 
> > params is not defined.
> > 
> > > +        if macbeth is None:
> > > +            continue
> > > +
> > > +        images.append(image)
> > > +
> > > +    if not _validate_images(images):
> > > +        return None
> > > +
> > > +    return images

-- 
Regards,

Laurent Pinchart


More information about the libcamera-devel mailing list