[{"id":29902,"web_url":"https://patchwork.libcamera.org/comment/29902/","msgid":"<171827247488.1550852.16788987201008697340@ping.linuxembedded.co.uk>","date":"2024-06-13T09:54:34","subject":"Re: [PATCH 2/6] utils: raspberrypi: ctt: Added CAC support to the\n\tCTT","submitter":{"id":4,"url":"https://patchwork.libcamera.org/api/people/4/","name":"Kieran Bingham","email":"kieran.bingham@ideasonboard.com"},"content":"Quoting David Plowman (2024-06-06 11:15:08)\n> From: Ben Benson <benbenson2004@gmail.com>\n> \n> Added the ability to tune the chromatic aberration correction\n> within the ctt. There are options for cac_only or to tune as part\n> of a larger tuning process. CTT will now recognise any files that\n> begin with \"cac\" as being chromatic aberration tuning files.\n> \n> Signed-off-by: Ben Benson <ben.benson@raspberrypi.com>\n\nThis and the successive patch was rejected by our push commit hooks as\nthe author (From: Ben Benson <benbenson2004@gmail.com>) has not signed\noff on the commit. However, rather conveniently ... \"Ben Benson\n<ben.benson@raspberrypi.com>\" has.\n\nI'll take the liberty to re-author the commits under the identity \"Ben\nBenson <ben.benson@raspberrypi.com>\" as I believe that's the original\nintent here.\n\n--\nKieran\n\n\n> Reviewed-by: Naushir Patuck <naush@raspberrypi.com>\n> ---\n>  utils/raspberrypi/ctt/alsc_pisp.py            |   2 +-\n>  utils/raspberrypi/ctt/cac_only.py             | 143 +++++++++++\n>  utils/raspberrypi/ctt/ctt_cac.py              | 228 ++++++++++++++++++\n>  utils/raspberrypi/ctt/ctt_dots_locator.py     | 118 +++++++++\n>  utils/raspberrypi/ctt/ctt_image_load.py       |   2 +\n>  utils/raspberrypi/ctt/ctt_log.txt             |  31 +++\n>  utils/raspberrypi/ctt/ctt_pisp.py             |   2 +\n>  .../raspberrypi/ctt/ctt_pretty_print_json.py  |   4 +\n>  utils/raspberrypi/ctt/ctt_run.py              |  85 ++++++-\n>  9 files changed, 606 insertions(+), 9 deletions(-)\n>  create mode 100644 utils/raspberrypi/ctt/cac_only.py\n>  create mode 100644 utils/raspberrypi/ctt/ctt_cac.py\n>  create mode 100644 utils/raspberrypi/ctt/ctt_dots_locator.py\n>  create mode 100644 utils/raspberrypi/ctt/ctt_log.txt\n> \n> diff --git a/utils/raspberrypi/ctt/alsc_pisp.py b/utils/raspberrypi/ctt/alsc_pisp.py\n> index 499aecd1..d0034ae1 100755\n> --- a/utils/raspberrypi/ctt/alsc_pisp.py\n> +++ b/utils/raspberrypi/ctt/alsc_pisp.py\n> @@ -2,7 +2,7 @@\n>  #\n>  # SPDX-License-Identifier: BSD-2-Clause\n>  #\n> -# Copyright (C) 2022, Raspberry Pi (Trading) Limited\n> +# Copyright (C) 2022, Raspberry Pi Ltd\n>  #\n>  # alsc_only.py - alsc tuning tool\n>  \n> diff --git a/utils/raspberrypi/ctt/cac_only.py b/utils/raspberrypi/ctt/cac_only.py\n> new file mode 100644\n> index 00000000..2bb11ccc\n> --- /dev/null\n> +++ b/utils/raspberrypi/ctt/cac_only.py\n> @@ -0,0 +1,143 @@\n> +#!/usr/bin/env python3\n> +#\n> +# SPDX-License-Identifier: BSD-2-Clause\n> +#\n> +# Copyright (C) 2023, Raspberry Pi (Trading) Limited\n> +#\n> +# cac_only.py - cac tuning tool\n> +\n> +\n> +# This file allows you to tune only the chromatic aberration correction\n> +# Specify any number of files in the command line args, and it shall iterate through\n> +# and generate an averaged cac table from all the input images, which you can then\n> +# input into your tuning file.\n> +\n> +# Takes .dng files produced by the camera modules of the dots grid and calculates the chromatic abberation of each dot.\n> +# Then takes each dot, and works out where it was in the image, and uses that to output a tables of the shifts\n> +# across the whole image.\n> +\n> +from PIL import Image\n> +import numpy as np\n> +import rawpy\n> +import sys\n> +import getopt\n> +\n> +from ctt_cac import *\n> +\n> +\n> +def cac(filelist, output_filepath, plot_results=False):\n> +    np.set_printoptions(precision=3)\n> +    np.set_printoptions(suppress=True)\n> +\n> +    # Create arrays to hold all the dots data and their colour offsets\n> +    red_shift = []  # Format is: [[Dot Center X, Dot Center Y, x shift, y shift]]\n> +    blue_shift = []\n> +    # Iterate through the files\n> +    # Multiple files is reccomended to average out the lens aberration through rotations\n> +    for file in filelist:\n> +        print(\"\\n Processing file \" + str(file))\n> +        # Read the raw RGB values from the .dng file\n> +        with rawpy.imread(file) as raw:\n> +            rgb = raw.postprocess()\n> +            sizes = (raw.sizes)\n> +\n> +        image_size = [sizes[2], sizes[3]]  # Image size, X, Y\n> +        # Create a colour copy of the RGB values to use later in the calibration\n> +        imout = Image.new(mode=\"RGB\", size=image_size)\n> +        rgb_image = np.array(imout)\n> +        # The rgb values need reshaping from a 1d array to a 3d array to be worked with easily\n> +        rgb.reshape((image_size[0], image_size[1], 3))\n> +        rgb_image = rgb\n> +\n> +        # Pass the RGB image through to the dots locating program\n> +        # Returns an array of the dots (colour rectangles around the dots), and an array of their locations\n> +        print(\"Finding dots\")\n> +        dots, dots_locations = find_dots_locations(rgb_image)\n> +\n> +        # Now, analyse each dot. Work out the centroid of each colour channel, and use that to work out\n> +        # by how far the chromatic aberration has shifted each channel\n> +        print('Dots found: ' + str(len(dots)))\n> +\n> +        for dot, dot_location in zip(dots, dots_locations):\n> +            if len(dot) > 0:\n> +                if (dot_location[0] > 0) and (dot_location[1] > 0):\n> +                    ret = analyse_dot(dot, dot_location)\n> +                    red_shift.append(ret[0])\n> +                    blue_shift.append(ret[1])\n> +\n> +    # Take our arrays of red shifts and locations, push them through to be interpolated into a 9x9 matrix\n> +    # for the CAC block to handle and then store these as a .json file to be added to the camera\n> +    # tuning file\n> +    print(\"\\nCreating output grid\")\n> +    rx, ry, bx, by = shifts_to_yaml(red_shift, blue_shift, image_size)\n> +\n> +    print(\"CAC correction complete!\")\n> +\n> +    # The json format that we then paste into the tuning file (manually)\n> +    sample = '''\n> +    {\n> +        \"rpi.cac\" :\n> +        {\n> +            \"strength\": 1.0,\n> +            \"lut_rx\" : [\n> +                   rx_vals\n> +            ],\n> +            \"lut_ry\" : [\n> +                   ry_vals\n> +            ],\n> +            \"lut_bx\" : [\n> +                   bx_vals\n> +            ],\n> +            \"lut_by\" : [\n> +                   by_vals\n> +            ]\n> +        }\n> +    }\n> +    '''\n> +\n> +    # Below, may look incorrect, however, the PiSP (standard) dimensions are flipped in comparison to\n> +    # PIL image coordinate directions, hence why xr -> yr. Also, the shifts calculated are colour shifts,\n> +    # and the PiSP block asks for the values it should shift (hence the * -1, to convert from colour shift to a pixel shift)\n> +    sample = sample.replace(\"rx_vals\", pprint_array(ry * -1))\n> +    sample = sample.replace(\"ry_vals\", pprint_array(rx * -1))\n> +    sample = sample.replace(\"bx_vals\", pprint_array(by * -1))\n> +    sample = sample.replace(\"by_vals\", pprint_array(bx * -1))\n> +    print(\"Successfully converted to YAML\")\n> +    f = open(str(output_filepath), \"w+\")\n> +    f.write(sample)\n> +    f.close()\n> +    print(\"Successfully written to yaml file\")\n> +    '''\n> +    If you wish to see a plot of the colour channel shifts, add the -p or --plots option\n> +    Can be a quick way of validating if the data/dots you've got are good, or if you need to\n> +    change some parameters/take some better images\n> +    '''\n> +    if plot_results:\n> +        plot_shifts(red_shift, blue_shift)\n> +\n> +\n> +if __name__ == \"__main__\":\n> +    argv = sys.argv\n> +    # Detect the input and output file paths\n> +    arg_output = \"output.json\"\n> +    arg_help = \"{0} -i <input> -o <output> -p <plot results>\".format(argv[0])\n> +    opts, args = getopt.getopt(argv[1:], \"hi:o:p\", [\"help\", \"input=\", \"output=\", \"plot\"])\n> +\n> +    output_location = 0\n> +    input_location = 0\n> +    filelist = []\n> +    plot_results = False\n> +    for i in range(len(argv)):\n> +        if (\"-h\") in argv[i]:\n> +            print(arg_help)  # print the help message\n> +            sys.exit(2)\n> +        if \"-o\" in argv[i]:\n> +            output_location = i\n> +        if \".dng\" in argv[i]:\n> +            filelist.append(argv[i])\n> +        if \"-p\" in argv[i]:\n> +            plot_results = True\n> +\n> +    arg_output = argv[output_location + 1]\n> +    logfile = open(\"log.txt\", \"a+\")\n> +    cac(filelist, arg_output, plot_results, logfile)\n> diff --git a/utils/raspberrypi/ctt/ctt_cac.py b/utils/raspberrypi/ctt/ctt_cac.py\n> new file mode 100644\n> index 00000000..5a4c5101\n> --- /dev/null\n> +++ b/utils/raspberrypi/ctt/ctt_cac.py\n> @@ -0,0 +1,228 @@\n> +# SPDX-License-Identifier: BSD-2-Clause\n> +#\n> +# Copyright (C) 2023, Raspberry Pi Ltd\n> +#\n> +# ctt_cac.py - CAC (Chromatic Aberration Correction) tuning tool\n> +\n> +from PIL import Image\n> +import numpy as np\n> +import matplotlib.pyplot as plt\n> +from matplotlib import cm\n> +\n> +from ctt_dots_locator import find_dots_locations\n> +\n> +\n> +# This is the wrapper file that creates a JSON entry for you to append\n> +# to your camera tuning file.\n> +# It calculates the chromatic aberration at different points throughout\n> +# the image and uses that to produce a martix that can then be used\n> +# in the camera tuning files to correct this aberration.\n> +\n> +\n> +def pprint_array(array):\n> +    # Function to print the array in a tidier format\n> +    array = array\n> +    output = \"\"\n> +    for i in range(len(array)):\n> +        for j in range(len(array[0])):\n> +            output += str(round(array[i, j], 2)) + \", \"\n> +        # Add the necessary indentation to the array\n> +        output += \"\\n                   \"\n> +    # Cut off the end of the array (nicely formats it)\n> +    return output[:-22]\n> +\n> +\n> +def plot_shifts(red_shifts, blue_shifts):\n> +    # If users want, they can pass a command line option to show the shifts on a graph\n> +    # Can be useful to check that the functions are all working, and that the sample\n> +    # images are doing the right thing\n> +    Xs = np.array(red_shifts)[:, 0]\n> +    Ys = np.array(red_shifts)[:, 1]\n> +    Zs = np.array(red_shifts)[:, 2]\n> +    Zs2 = np.array(red_shifts)[:, 3]\n> +    Zs3 = np.array(blue_shifts)[:, 2]\n> +    Zs4 = np.array(blue_shifts)[:, 3]\n> +\n> +    fig, axs = plt.subplots(2, 2)\n> +    ax = fig.add_subplot(2, 2, 1, projection='3d')\n> +    ax.scatter(Xs, Ys, Zs, cmap=cm.jet, linewidth=0)\n> +    ax.set_title('Red X Shift')\n> +    ax = fig.add_subplot(2, 2, 2, projection='3d')\n> +    ax.scatter(Xs, Ys, Zs2, cmap=cm.jet, linewidth=0)\n> +    ax.set_title('Red Y Shift')\n> +    ax = fig.add_subplot(2, 2, 3, projection='3d')\n> +    ax.scatter(Xs, Ys, Zs3, cmap=cm.jet, linewidth=0)\n> +    ax.set_title('Blue X Shift')\n> +    ax = fig.add_subplot(2, 2, 4, projection='3d')\n> +    ax.scatter(Xs, Ys, Zs4, cmap=cm.jet, linewidth=0)\n> +    ax.set_title('Blue Y Shift')\n> +    fig.tight_layout()\n> +    plt.show()\n> +\n> +\n> +def shifts_to_yaml(red_shift, blue_shift, image_dimensions, output_grid_size=9):\n> +    # Convert the shifts to a numpy array for easier handling and initialise other variables\n> +    red_shifts = np.array(red_shift)\n> +    blue_shifts = np.array(blue_shift)\n> +    # create a grid that's smaller than the output grid, which we then interpolate from to get the output values\n> +    xrgrid = np.zeros((output_grid_size - 1, output_grid_size - 1))\n> +    xbgrid = np.zeros((output_grid_size - 1, output_grid_size - 1))\n> +    yrgrid = np.zeros((output_grid_size - 1, output_grid_size - 1))\n> +    ybgrid = np.zeros((output_grid_size - 1, output_grid_size - 1))\n> +\n> +    xrsgrid = []\n> +    xbsgrid = []\n> +    yrsgrid = []\n> +    ybsgrid = []\n> +    xg = np.zeros((output_grid_size - 1, output_grid_size - 1))\n> +    yg = np.zeros((output_grid_size - 1, output_grid_size - 1))\n> +\n> +    # Format the grids - numpy doesn't work for this, it wants a\n> +    # nice uniformly spaced grid, which we don't know if we have yet, hence the rather mundane setup\n> +    for x in range(output_grid_size - 1):\n> +        xrsgrid.append([])\n> +        yrsgrid.append([])\n> +        xbsgrid.append([])\n> +        ybsgrid.append([])\n> +        for y in range(output_grid_size - 1):\n> +            xrsgrid[x].append([])\n> +            yrsgrid[x].append([])\n> +            xbsgrid[x].append([])\n> +            ybsgrid[x].append([])\n> +\n> +    image_size = (image_dimensions[0], image_dimensions[1])\n> +    gridxsize = image_size[0] / (output_grid_size - 1)\n> +    gridysize = image_size[1] / (output_grid_size - 1)\n> +\n> +    # Iterate through each dot, and it's shift values and put these into the correct grid location\n> +    for red_shift in red_shifts:\n> +        xgridloc = int(red_shift[0] / gridxsize)\n> +        ygridloc = int(red_shift[1] / gridysize)\n> +        xrsgrid[xgridloc][ygridloc].append(red_shift[2])\n> +        yrsgrid[xgridloc][ygridloc].append(red_shift[3])\n> +\n> +    for blue_shift in blue_shifts:\n> +        xgridloc = int(blue_shift[0] / gridxsize)\n> +        ygridloc = int(blue_shift[1] / gridysize)\n> +        xbsgrid[xgridloc][ygridloc].append(blue_shift[2])\n> +        ybsgrid[xgridloc][ygridloc].append(blue_shift[3])\n> +\n> +    # Now calculate the average pixel shift for each square in the grid\n> +    for x in range(output_grid_size - 1):\n> +        for y in range(output_grid_size - 1):\n> +            xrgrid[x, y] = np.mean(xrsgrid[x][y])\n> +            yrgrid[x, y] = np.mean(yrsgrid[x][y])\n> +            xbgrid[x, y] = np.mean(xbsgrid[x][y])\n> +            ybgrid[x, y] = np.mean(ybsgrid[x][y])\n> +\n> +    # Next, we start to interpolate the central points of the grid that gets passed to the tuning file\n> +    input_grids = np.array([xrgrid, yrgrid, xbgrid, ybgrid])\n> +    output_grids = np.zeros((4, output_grid_size, output_grid_size))\n> +\n> +    # Interpolate the centre of the grid\n> +    output_grids[:, 1:-1, 1:-1] = (input_grids[:, 1:, :-1] + input_grids[:, 1:, 1:] + input_grids[:, :-1, 1:] + input_grids[:, :-1, :-1]) / 4\n> +\n> +    # Edge cases:\n> +    output_grids[:, 1:-1, 0] = ((input_grids[:, :-1, 0] + input_grids[:, 1:, 0]) / 2 - output_grids[:, 1:-1, 1]) * 2 + output_grids[:, 1:-1, 1]\n> +    output_grids[:, 1:-1, -1] = ((input_grids[:, :-1, 7] + input_grids[:, 1:, 7]) / 2 - output_grids[:, 1:-1, -2]) * 2 + output_grids[:, 1:-1, -2]\n> +    output_grids[:, 0, 1:-1] = ((input_grids[:, 0, :-1] + input_grids[:, 0, 1:]) / 2 - output_grids[:, 1, 1:-1]) * 2 + output_grids[:, 1, 1:-1]\n> +    output_grids[:, -1, 1:-1] = ((input_grids[:, 7, :-1] + input_grids[:, 7, 1:]) / 2 - output_grids[:, -2, 1:-1]) * 2 + output_grids[:, -2, 1:-1]\n> +\n> +    # Corner Cases:\n> +    output_grids[:, 0, 0] = (output_grids[:, 0, 1] - output_grids[:, 1, 1]) + (output_grids[:, 1, 0] - output_grids[:, 1, 1]) + output_grids[:, 1, 1]\n> +    output_grids[:, 0, -1] = (output_grids[:, 0, -2] - output_grids[:, 1, -2]) + (output_grids[:, 1, -1] - output_grids[:, 1, -2]) + output_grids[:, 1, -2]\n> +    output_grids[:, -1, 0] = (output_grids[:, -1, 1] - output_grids[:, -2, 1]) + (output_grids[:, -2, 0] - output_grids[:, -2, 1]) + output_grids[:, -2, 1]\n> +    output_grids[:, -1, -1] = (output_grids[:, -2, -1] - output_grids[:, -2, -2]) + (output_grids[:, -1, -2] - output_grids[:, -2, -2]) + output_grids[:, -2, -2]\n> +\n> +    # Below, we swap the x and the y coordinates, and also multiply by a factor of -1\n> +    # This is due to the PiSP (standard) dimensions being flipped in comparison to\n> +    # PIL image coordinate directions, hence why xr -> yr. Also, the shifts calculated are colour shifts,\n> +    # and the PiSP block asks for the values it should shift by (hence the * -1, to convert from colour shift to a pixel shift)\n> +\n> +    output_grid_yr, output_grid_xr, output_grid_yb, output_grid_xb = output_grids * -1\n> +    return output_grid_xr, output_grid_yr, output_grid_xb, output_grid_yb\n> +\n> +\n> +def analyse_dot(dot, dot_location=[0, 0]):\n> +    # Scan through the dot, calculate the centroid of each colour channel by doing:\n> +    # pixel channel brightness * distance from top left corner\n> +    # Sum these, and divide by the sum of each channel's brightnesses to get a centroid for each channel\n> +    red_channel = np.array(dot)[:, :, 0]\n> +    y_num_pixels = len(red_channel[0])\n> +    x_num_pixels = len(red_channel)\n> +    yred_weight = np.sum(np.dot(red_channel, np.arange(y_num_pixels)))\n> +    xred_weight = np.sum(np.dot(np.arange(x_num_pixels), red_channel))\n> +    red_sum = np.sum(red_channel)\n> +\n> +    green_channel = np.array(dot)[:, :, 1]\n> +    ygreen_weight = np.sum(np.dot(green_channel, np.arange(y_num_pixels)))\n> +    xgreen_weight = np.sum(np.dot(np.arange(x_num_pixels), green_channel))\n> +    green_sum = np.sum(green_channel)\n> +\n> +    blue_channel = np.array(dot)[:, :, 2]\n> +    yblue_weight = np.sum(np.dot(blue_channel, np.arange(y_num_pixels)))\n> +    xblue_weight = np.sum(np.dot(np.arange(x_num_pixels), blue_channel))\n> +    blue_sum = np.sum(blue_channel)\n> +\n> +    # We return this structure. It contains 2 arrays that contain:\n> +    # the locations of the dot center, along with the channel shifts in the x and y direction:\n> +    # [ [red_center_x, red_center_y, red_x_shift, red_y_shift], [blue_center_x, blue_center_y, blue_x_shift, blue_y_shift] ]\n> +\n> +    return [[int(dot_location[0]) + int(len(dot) / 2), int(dot_location[1]) + int(len(dot[0]) / 2), xred_weight / red_sum - xgreen_weight / green_sum, yred_weight / red_sum - ygreen_weight / green_sum], [dot_location[0] + int(len(dot) / 2), dot_location[1] + int(len(dot[0]) / 2), xblue_weight / blue_sum - xgreen_weight / green_sum, yblue_weight / blue_sum - ygreen_weight / green_sum]]\n> +\n> +\n> +def cac(Cam):\n> +    filelist = Cam.imgs_cac\n> +\n> +    Cam.log += '\\nCAC analysing files: {}'.format(str(filelist))\n> +    np.set_printoptions(precision=3)\n> +    np.set_printoptions(suppress=True)\n> +\n> +    # Create arrays to hold all the dots data and their colour offsets\n> +    red_shift = []  # Format is: [[Dot Center X, Dot Center Y, x shift, y shift]]\n> +    blue_shift = []\n> +    # Iterate through the files\n> +    # Multiple files is reccomended to average out the lens aberration through rotations\n> +    for file in filelist:\n> +        Cam.log += '\\nCAC processing file'\n> +        print(\"\\n Processing file\")\n> +        # Read the raw RGB values\n> +        rgb = file.rgb\n> +        image_size = [file.h, file.w]  # Image size, X, Y\n> +        # Create a colour copy of the RGB values to use later in the calibration\n> +        imout = Image.new(mode=\"RGB\", size=image_size)\n> +        rgb_image = np.array(imout)\n> +        # The rgb values need reshaping from a 1d array to a 3d array to be worked with easily\n> +        rgb.reshape((image_size[0], image_size[1], 3))\n> +        rgb_image = rgb\n> +\n> +        # Pass the RGB image through to the dots locating program\n> +        # Returns an array of the dots (colour rectangles around the dots), and an array of their locations\n> +        print(\"Finding dots\")\n> +        Cam.log += '\\nFinding dots'\n> +        dots, dots_locations = find_dots_locations(rgb_image)\n> +\n> +        # Now, analyse each dot. Work out the centroid of each colour channel, and use that to work out\n> +        # by how far the chromatic aberration has shifted each channel\n> +        Cam.log += '\\nDots found: {}'.format(str(len(dots)))\n> +        print('Dots found: ' + str(len(dots)))\n> +\n> +        for dot, dot_location in zip(dots, dots_locations):\n> +            if len(dot) > 0:\n> +                if (dot_location[0] > 0) and (dot_location[1] > 0):\n> +                    ret = analyse_dot(dot, dot_location)\n> +                    red_shift.append(ret[0])\n> +                    blue_shift.append(ret[1])\n> +\n> +    # Take our arrays of red shifts and locations, push them through to be interpolated into a 9x9 matrix\n> +    # for the CAC block to handle and then store these as a .json file to be added to the camera\n> +    # tuning file\n> +    print(\"\\nCreating output grid\")\n> +    Cam.log += '\\nCreating output grid'\n> +    rx, ry, bx, by = shifts_to_yaml(red_shift, blue_shift, image_size)\n> +\n> +    print(\"CAC correction complete!\")\n> +    Cam.log += '\\nCAC correction complete!'\n> +\n> +    # Give the JSON dict back to the main ctt program\n> +    return {\"strength\": 1.0, \"lut_rx\": list(rx.round(2).reshape(81)), \"lut_ry\": list(ry.round(2).reshape(81)), \"lut_bx\": list(bx.round(2).reshape(81)), \"lut_by\": list(by.round(2).reshape(81))}\n> diff --git a/utils/raspberrypi/ctt/ctt_dots_locator.py b/utils/raspberrypi/ctt/ctt_dots_locator.py\n> new file mode 100644\n> index 00000000..4945c04b\n> --- /dev/null\n> +++ b/utils/raspberrypi/ctt/ctt_dots_locator.py\n> @@ -0,0 +1,118 @@\n> +# SPDX-License-Identifier: BSD-2-Clause\n> +#\n> +# Copyright (C) 2023, Raspberry Pi Ltd\n> +#\n> +# find_dots.py - Used by CAC algorithm to convert image to set of dots\n> +\n> +'''\n> +This file takes the black and white version of the image, along with\n> +the color version. It then located the black dots on the image by\n> +thresholding dark pixels.\n> +In a rather fun way, the algorithm bounces around the thresholded area in a random path\n> +We then use the maximum and minimum of these paths to determine the dot shape and size\n> +This info is then used to return colored dots and locations back to the main file\n> +'''\n> +\n> +import numpy as np\n> +import random\n> +from PIL import Image, ImageEnhance, ImageFilter\n> +\n> +\n> +def find_dots_locations(rgb_image, color_threshold=100, dots_edge_avoid=75, image_edge_avoid=10, search_path_length=500, grid_scan_step_size=10, logfile=open(\"log.txt\", \"a+\")):\n> +    # Initialise some starting variables\n> +    pixels = Image.fromarray(rgb_image)\n> +    pixels = pixels.convert(\"L\")\n> +    enhancer = ImageEnhance.Contrast(pixels)\n> +    im_output = enhancer.enhance(1.4)\n> +    # We smooth it slightly to make it easier for the dot recognition program to locate the dots\n> +    im_output = im_output.filter(ImageFilter.GaussianBlur(radius=2))\n> +    bw_image = np.array(im_output)\n> +\n> +    location = [0, 0]\n> +    dots = []\n> +    dots_location = []\n> +    # the program takes away the edges - we don't want a dot that is half a circle, the\n> +    # centroids would all be wrong\n> +    for x in range(dots_edge_avoid, len(bw_image) - dots_edge_avoid, grid_scan_step_size):\n> +        for y in range(dots_edge_avoid, len(bw_image[0]) - dots_edge_avoid, grid_scan_step_size):\n> +            location = [x, y]\n> +            scrap_dot = False  # A variable used to make sure that this is a valid dot\n> +            if (bw_image[location[0], location[1]] < color_threshold) and not (scrap_dot):\n> +                heading = \"south\"  # Define a starting direction to move in\n> +                coords = []\n> +                for i in range(search_path_length):  # Creates a path of length `search_path_length`. This turns out to always be enough to work out the rough shape of the dot.\n> +                    # Now make sure that the thresholded area doesn't come within 10 pixels of the edge of the image, ensures we capture all the CA\n> +                    if ((image_edge_avoid < location[0] < len(bw_image) - image_edge_avoid) and (image_edge_avoid < location[1] < len(bw_image[0]) - image_edge_avoid)) and not (scrap_dot):\n> +                        if heading == \"south\":\n> +                            if bw_image[location[0] + 1, location[1]] < color_threshold:\n> +                                # Here, notice it does not go south, but actually goes southeast\n> +                                # This is crucial in ensuring that we make our way around the majority of the dot\n> +                                location[0] = location[0] + 1\n> +                                location[1] = location[1] + 1\n> +                                heading = \"south\"\n> +                            else:\n> +                                # This happens when we reach a thresholded edge. We now randomly change direction and keep searching\n> +                                dir = random.randint(1, 2)\n> +                                if dir == 1:\n> +                                    heading = \"west\"\n> +                                if dir == 2:\n> +                                    heading = \"east\"\n> +\n> +                        if heading == \"east\":\n> +                            if bw_image[location[0], location[1] + 1] < color_threshold:\n> +                                location[1] = location[1] + 1\n> +                                heading = \"east\"\n> +                            else:\n> +                                dir = random.randint(1, 2)\n> +                                if dir == 1:\n> +                                    heading = \"north\"\n> +                                if dir == 2:\n> +                                    heading = \"south\"\n> +\n> +                        if heading == \"west\":\n> +                            if bw_image[location[0], location[1] - 1] < color_threshold:\n> +                                location[1] = location[1] - 1\n> +                                heading = \"west\"\n> +                            else:\n> +                                dir = random.randint(1, 2)\n> +                                if dir == 1:\n> +                                    heading = \"north\"\n> +                                if dir == 2:\n> +                                    heading = \"south\"\n> +\n> +                        if heading == \"north\":\n> +                            if bw_image[location[0] - 1, location[1]] < color_threshold:\n> +                                location[0] = location[0] - 1\n> +                                heading = \"north\"\n> +                            else:\n> +                                dir = random.randint(1, 2)\n> +                                if dir == 1:\n> +                                    heading = \"west\"\n> +                                if dir == 2:\n> +                                    heading = \"east\"\n> +                        # Log where our particle travels across the dot\n> +                        coords.append([location[0], location[1]])\n> +                    else:\n> +                        scrap_dot = True  # We just don't have enough space around the dot, discard this one, and move on\n> +                if not scrap_dot:\n> +                    # get the size of the dot surrounding the dot\n> +                    x_coords = np.array(coords)[:, 0]\n> +                    y_coords = np.array(coords)[:, 1]\n> +                    hsquaresize = max(list(x_coords)) - min(list(x_coords))\n> +                    vsquaresize = max(list(y_coords)) - min(list(y_coords))\n> +                    # Create the bounding coordinates of the rectangle surrounding the dot\n> +                    # Program uses the dotsize + half of the dotsize to ensure we get all that color fringing\n> +                    extra_space_factor = 0.45\n> +                    top_left_x = (min(list(x_coords)) - int(hsquaresize * extra_space_factor))\n> +                    btm_right_x = max(list(x_coords)) + int(hsquaresize * extra_space_factor)\n> +                    top_left_y = (min(list(y_coords)) - int(vsquaresize * extra_space_factor))\n> +                    btm_right_y = max(list(y_coords)) + int(vsquaresize * extra_space_factor)\n> +                    # Overwrite the area of the dot to ensure we don't use it again\n> +                    bw_image[top_left_x:btm_right_x, top_left_y:btm_right_y] = 255\n> +                    # Add the color version of the dot to the list to send off, along with some coordinates.\n> +                    dots.append(rgb_image[top_left_x:btm_right_x, top_left_y:btm_right_y])\n> +                    dots_location.append([top_left_x, top_left_y])\n> +                else:\n> +                    # Dot was too close to the image border to be useable\n> +                    pass\n> +    return dots, dots_location\n> diff --git a/utils/raspberrypi/ctt/ctt_image_load.py b/utils/raspberrypi/ctt/ctt_image_load.py\n> index d76ece73..ea5fa360 100644\n> --- a/utils/raspberrypi/ctt/ctt_image_load.py\n> +++ b/utils/raspberrypi/ctt/ctt_image_load.py\n> @@ -350,6 +350,8 @@ def dng_load_image(Cam, im_str):\n>          c2 = np.left_shift(raw_data[1::2, 0::2].astype(np.int64), shift)\n>          c3 = np.left_shift(raw_data[1::2, 1::2].astype(np.int64), shift)\n>          Img.channels = [c0, c1, c2, c3]\n> +        Img.rgb = raw_im.postprocess()\n> +        Img.sizes = raw_im.sizes\n>  \n>      except Exception:\n>          print(\"\\nERROR: failed to load DNG file\", im_str)\n> diff --git a/utils/raspberrypi/ctt/ctt_log.txt b/utils/raspberrypi/ctt/ctt_log.txt\n> new file mode 100644\n> index 00000000..682e24e4\n> --- /dev/null\n> +++ b/utils/raspberrypi/ctt/ctt_log.txt\n> @@ -0,0 +1,31 @@\n> +Log created : Fri Aug 25 17:02:58 2023\n> +\n> +----------------------------------------------------------------------\n> +User Arguments\n> +----------------------------------------------------------------------\n> +\n> +Json file output: output.json\n> +Calibration images directory: ../ctt/\n> +No configuration file input... using default options\n> +No log file path input... using default: ctt_log.txt\n> +\n> +----------------------------------------------------------------------\n> +Image Loading\n> +----------------------------------------------------------------------\n> +\n> +Directory: ../ctt/\n> +Files found: 1\n> +\n> +Image: alsc_3000k_0.dng\n> +Identified as an ALSC image\n> +Colour temperature: 3000 K\n> +\n> +Images found:\n> +Macbeth : 0\n> +ALSC : 1 \n> +CAC: 0 \n> +\n> +Camera metadata\n> +ERROR: No usable macbeth chart images found\n> +\n> +----------------------------------------------------------------------\n> diff --git a/utils/raspberrypi/ctt/ctt_pisp.py b/utils/raspberrypi/ctt/ctt_pisp.py\n> index f837e062..862587a6 100755\n> --- a/utils/raspberrypi/ctt/ctt_pisp.py\n> +++ b/utils/raspberrypi/ctt/ctt_pisp.py\n> @@ -197,6 +197,8 @@ json_template = {\n>      },\n>      \"rpi.ccm\": {\n>      },\n> +    \"rpi.cac\": {\n> +    },\n>      \"rpi.sharpen\": {\n>         \"threshold\": 0.25,\n>         \"limit\": 1.0,\n> diff --git a/utils/raspberrypi/ctt/ctt_pretty_print_json.py b/utils/raspberrypi/ctt/ctt_pretty_print_json.py\n> index 5d16b2a6..d3bd7d97 100755\n> --- a/utils/raspberrypi/ctt/ctt_pretty_print_json.py\n> +++ b/utils/raspberrypi/ctt/ctt_pretty_print_json.py\n> @@ -24,6 +24,10 @@ class Encoder(json.JSONEncoder):\n>              'luminance_lut': 16,\n>              'ct_curve': 3,\n>              'ccm': 3,\n> +            'lut_rx': 9,\n> +            'lut_bx': 9,\n> +            'lut_by': 9,\n> +            'lut_ry': 9,\n>              'gamma_curve': 2,\n>              'y_target': 2,\n>              'prior': 2\n> diff --git a/utils/raspberrypi/ctt/ctt_run.py b/utils/raspberrypi/ctt/ctt_run.py\n> index 0c85d7db..074136a1 100755\n> --- a/utils/raspberrypi/ctt/ctt_run.py\n> +++ b/utils/raspberrypi/ctt/ctt_run.py\n> @@ -9,6 +9,7 @@\n>  import os\n>  import sys\n>  from ctt_image_load import *\n> +from ctt_cac import *\n>  from ctt_ccm import *\n>  from ctt_awb import *\n>  from ctt_alsc import *\n> @@ -22,9 +23,10 @@ import re\n>  \n>  \"\"\"\n>  This file houses the camera object, which is used to perform the calibrations.\n> -The camera object houses all the calibration images as attributes in two lists:\n> +The camera object houses all the calibration images as attributes in three lists:\n>      - imgs (macbeth charts)\n>      - imgs_alsc (alsc correction images)\n> +    - imgs_cac (cac correction images)\n>  Various calibrations are methods of the camera object, and the output is stored\n>  in a dictionary called self.json.\n>  Once all the caibration has been completed, the Camera.json is written into a\n> @@ -73,16 +75,15 @@ class Camera:\n>              self.path = ''\n>          self.imgs = []\n>          self.imgs_alsc = []\n> +        self.imgs_cac = []\n>          self.log = 'Log created : ' + time.asctime(time.localtime(time.time()))\n>          self.log_separator = '\\n'+'-'*70+'\\n'\n>          self.jf = jfile\n>          \"\"\"\n>          initial json dict populated by uncalibrated values\n>          \"\"\"\n> -\n>          self.json = json\n>  \n> -\n>      \"\"\"\n>      Perform colour correction calibrations by comparing macbeth patch colours\n>      to standard macbeth chart colours.\n> @@ -146,6 +147,62 @@ class Camera:\n>          self.log += '\\nCCM calibration written to json file'\n>          print('Finished CCM calibration')\n>  \n> +    \"\"\"\n> +    Perform chromatic abberation correction using multiple dots images.\n> +    \"\"\"\n> +    def cac_cal(self, do_alsc_colour):\n> +        if 'rpi.cac' in self.disable:\n> +            return 1\n> +        print('\\nStarting CAC calibration')\n> +        self.log_new_sec('CAC')\n> +        \"\"\"\n> +        check if cac images have been taken\n> +        \"\"\"\n> +        if len(self.imgs_cac) == 0:\n> +            print('\\nError:\\nNo cac calibration images found')\n> +            self.log += '\\nERROR: No CAC calibration images found!'\n> +            self.log += '\\nCAC calibration aborted!'\n> +            return 1\n> +        \"\"\"\n> +        if image is greyscale then CAC makes no sense\n> +        \"\"\"\n> +        if self.grey:\n> +            print('\\nERROR: Can\\'t do CAC on greyscale image!')\n> +            self.log += '\\nERROR: Cannot perform CAC calibration '\n> +            self.log += 'on greyscale image!\\nCAC aborted!'\n> +            del self.json['rpi.cac']\n> +            return 0\n> +        a = time.time()\n> +        \"\"\"\n> +        Check if camera is greyscale or color. If not greyscale, then perform cac\n> +        \"\"\"\n> +        if do_alsc_colour:\n> +            \"\"\"\n> +            Here we have a color sensor. Perform cac\n> +            \"\"\"\n> +            try:\n> +                cacs = cac(self)\n> +            except ArithmeticError:\n> +                print('ERROR: Matrix is singular!\\nTake new pictures and try again...')\n> +                self.log += '\\nERROR: Singular matrix encountered during fit!'\n> +                self.log += '\\nCCM aborted!'\n> +                return 1\n> +        else:\n> +            \"\"\"\n> +            case where config options suggest greyscale camera. No point in doing CAC\n> +            \"\"\"\n> +            cal_cr_list, cal_cb_list = None, None\n> +            self.log += '\\nWARNING: No ALSC tables found.\\nCCM calibration '\n> +            self.log += 'performed without ALSC correction...'\n> +\n> +        \"\"\"\n> +        Write output to json\n> +        \"\"\"\n> +        self.json['rpi.cac']['cac'] = cacs\n> +        self.log += '\\nCCM calibration written to json file'\n> +        print('Finished CCM calibration')\n> +\n> +\n>      \"\"\"\n>      Auto white balance calibration produces a colour curve for\n>      various colour temperatures, as well as providing a maximum 'wiggle room'\n> @@ -516,6 +573,16 @@ class Camera:\n>                      self.log += '\\nWARNING: Error reading colour temperature'\n>                      self.log += '\\nImage discarded!'\n>                      print('DISCARDED')\n> +            elif 'cac' in filename:\n> +                Img = load_image(self, address, mac=False)\n> +                self.log += '\\nIdentified as an CAC image'\n> +                Img.name = filename\n> +                self.log += '\\nColour temperature: {} K'.format(col)\n> +                self.imgs_cac.append(Img)\n> +                if blacklevel != -1:\n> +                    Img.blacklevel_16 = blacklevel\n> +                print(img_suc_msg)\n> +                continue\n>              else:\n>                  self.log += '\\nIdentified as macbeth chart image'\n>                  \"\"\"\n> @@ -561,6 +628,7 @@ class Camera:\n>          self.log += '\\n\\nImages found:'\n>          self.log += '\\nMacbeth : {}'.format(len(self.imgs))\n>          self.log += '\\nALSC : {} '.format(len(self.imgs_alsc))\n> +        self.log += '\\nCAC: {} '.format(len(self.imgs_cac))\n>          self.log += '\\n\\nCamera metadata'\n>          \"\"\"\n>          check usable images found\n> @@ -569,22 +637,21 @@ class Camera:\n>              print('\\nERROR: No usable macbeth chart images found')\n>              self.log += '\\nERROR: No usable macbeth chart images found'\n>              return 0\n> -        elif len(self.imgs) == 0 and len(self.imgs_alsc) == 0:\n> +        elif len(self.imgs) == 0 and len(self.imgs_alsc) == 0 and len(self.imgs_cac) == 0:\n>              print('\\nERROR: No usable images found')\n>              self.log += '\\nERROR: No usable images found'\n>              return 0\n>          \"\"\"\n>          Double check that every image has come from the same camera...\n>          \"\"\"\n> -        all_imgs = self.imgs + self.imgs_alsc\n> +        all_imgs = self.imgs + self.imgs_alsc + self.imgs_cac\n>          camNames = list(set([Img.camName for Img in all_imgs]))\n>          patterns = list(set([Img.pattern for Img in all_imgs]))\n>          sigbitss = list(set([Img.sigbits for Img in all_imgs]))\n>          blacklevels = list(set([Img.blacklevel_16 for Img in all_imgs]))\n>          sizes = list(set([(Img.w, Img.h) for Img in all_imgs]))\n>  \n> -        if len(camNames) == 1 and len(patterns) == 1 and len(sigbitss) == 1 and \\\n> -           len(blacklevels) == 1 and len(sizes) == 1:\n> +        if 1:\n>              self.grey = (patterns[0] == 128)\n>              self.blacklevel_16 = blacklevels[0]\n>              self.log += '\\nName: {}'.format(camNames[0])\n> @@ -643,6 +710,7 @@ def run_ctt(json_output, directory, config, log_output, json_template, grid_size\n>      mac_small = get_config(macbeth_d, \"small\", 0, 'bool')\n>      mac_show = get_config(macbeth_d, \"show\", 0, 'bool')\n>      mac_config = (mac_small, mac_show)\n> +    cac_d = get_config(configs, \"cac\", {}, 'dict')\n>  \n>      if blacklevel < -1 or blacklevel >= 2**16:\n>          print('\\nInvalid blacklevel, defaulted to 64')\n> @@ -687,7 +755,8 @@ def run_ctt(json_output, directory, config, log_output, json_template, grid_size\n>          Cam.geq_cal()\n>          Cam.lux_cal()\n>          Cam.noise_cal()\n> -        Cam.cac_cal(do_alsc_colour)\n> +        if \"rpi.cac\" in json_template:\n> +            Cam.cac_cal(do_alsc_colour)\n>          Cam.awb_cal(greyworld, do_alsc_colour, grid_size)\n>          Cam.ccm_cal(do_alsc_colour, grid_size)\n>  \n> -- \n> 2.39.2\n>","headers":{"Return-Path":"<libcamera-devel-bounces@lists.libcamera.org>","X-Original-To":"parsemail@patchwork.libcamera.org","Delivered-To":"parsemail@patchwork.libcamera.org","Received":["from lancelot.ideasonboard.com (lancelot.ideasonboard.com\n\t[92.243.16.209])\n\tby patchwork.libcamera.org (Postfix) with ESMTPS id EDF4BBD87C\n\tfor <parsemail@patchwork.libcamera.org>;\n\tThu, 13 Jun 2024 09:54:40 +0000 (UTC)","from lancelot.ideasonboard.com (localhost [IPv6:::1])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTP id 192ED6548D;\n\tThu, 13 Jun 2024 11:54:40 +0200 (CEST)","from perceval.ideasonboard.com (perceval.ideasonboard.com\n\t[IPv6:2001:4b98:dc2:55:216:3eff:fef7:d647])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTPS id 036A565458\n\tfor <libcamera-devel@lists.libcamera.org>;\n\tThu, 13 Jun 2024 11:54:37 +0200 (CEST)","from pendragon.ideasonboard.com\n\t(cpc89244-aztw30-2-0-cust6594.18-1.cable.virginm.net [86.31.185.195])\n\tby perceval.ideasonboard.com (Postfix) with ESMTPSA id BF1A7BEB;\n\tThu, 13 Jun 2024 11:54:23 +0200 (CEST)"],"Authentication-Results":"lancelot.ideasonboard.com; dkim=pass (1024-bit key;\n\tunprotected) header.d=ideasonboard.com header.i=@ideasonboard.com\n\theader.b=\"eTDBzZi3\"; dkim-atps=neutral","DKIM-Signature":"v=1; a=rsa-sha256; c=relaxed/simple; d=ideasonboard.com;\n\ts=mail; t=1718272463;\n\tbh=QvlmTLxEmkYhCHr6Wmr6ixua93aJGxlVuG77Xk3GFho=;\n\th=In-Reply-To:References:Subject:From:Cc:To:Date:From;\n\tb=eTDBzZi3YxvsUmAxVbkMHtk/+QkjwzItlJIBDthWPWbq+WFBOPqH5sgST/Uo7fTFe\n\tMXUEMeycIa9UwS9Ij80L5PXVZ0TAS5rKgYn5GyFPtaqovZhaJGIqAZyWiVNjEMH+nX\n\tYZ4NHzIwCdNEyB40DAq5hdgAkcCyEAMyAp3icBlQ=","Content-Type":"text/plain; charset=\"utf-8\"","MIME-Version":"1.0","Content-Transfer-Encoding":"quoted-printable","In-Reply-To":"<20240606101512.375178-3-david.plowman@raspberrypi.com>","References":"<20240606101512.375178-1-david.plowman@raspberrypi.com>\n\t<20240606101512.375178-3-david.plowman@raspberrypi.com>","Subject":"Re: [PATCH 2/6] utils: raspberrypi: ctt: Added CAC support to the\n\tCTT","From":"Kieran Bingham <kieran.bingham@ideasonboard.com>","Cc":"Ben Benson <benbenson2004@gmail.com>,\n\tBen Benson <ben.benson@raspberrypi.com>,\n\tNaushir Patuck <naush@raspberrypi.com>","To":"David Plowman <david.plowman@raspberrypi.com>,\n\tlibcamera-devel@lists.libcamera.org","Date":"Thu, 13 Jun 2024 10:54:34 +0100","Message-ID":"<171827247488.1550852.16788987201008697340@ping.linuxembedded.co.uk>","User-Agent":"alot/0.10","X-BeenThere":"libcamera-devel@lists.libcamera.org","X-Mailman-Version":"2.1.29","Precedence":"list","List-Id":"<libcamera-devel.lists.libcamera.org>","List-Unsubscribe":"<https://lists.libcamera.org/options/libcamera-devel>,\n\t<mailto:libcamera-devel-request@lists.libcamera.org?subject=unsubscribe>","List-Archive":"<https://lists.libcamera.org/pipermail/libcamera-devel/>","List-Post":"<mailto:libcamera-devel@lists.libcamera.org>","List-Help":"<mailto:libcamera-devel-request@lists.libcamera.org?subject=help>","List-Subscribe":"<https://lists.libcamera.org/listinfo/libcamera-devel>,\n\t<mailto:libcamera-devel-request@lists.libcamera.org?subject=subscribe>","Errors-To":"libcamera-devel-bounces@lists.libcamera.org","Sender":"\"libcamera-devel\" <libcamera-devel-bounces@lists.libcamera.org>"}}]