Show a patch.

GET /api/1.1/patches/20216/?format=api
HTTP 200 OK
Allow: GET, PUT, PATCH, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "id": 20216,
    "url": "https://patchwork.libcamera.org/api/1.1/patches/20216/?format=api",
    "web_url": "https://patchwork.libcamera.org/patch/20216/",
    "project": {
        "id": 1,
        "url": "https://patchwork.libcamera.org/api/1.1/projects/1/?format=api",
        "name": "libcamera",
        "link_name": "libcamera",
        "list_id": "libcamera_core",
        "list_email": "libcamera-devel@lists.libcamera.org",
        "web_url": "",
        "scm_url": "",
        "webscm_url": ""
    },
    "msgid": "<20240606101512.375178-3-david.plowman@raspberrypi.com>",
    "date": "2024-06-06T10:15:08",
    "name": "[2/6] utils: raspberrypi: ctt: Added CAC support to the CTT",
    "commit_ref": null,
    "pull_url": null,
    "state": "accepted",
    "archived": false,
    "hash": "c325fcfe9187c2368fd891f47f93f520ecfd0029",
    "submitter": {
        "id": 42,
        "url": "https://patchwork.libcamera.org/api/1.1/people/42/?format=api",
        "name": "David Plowman",
        "email": "david.plowman@raspberrypi.com"
    },
    "delegate": null,
    "mbox": "https://patchwork.libcamera.org/patch/20216/mbox/",
    "series": [
        {
            "id": 4367,
            "url": "https://patchwork.libcamera.org/api/1.1/series/4367/?format=api",
            "web_url": "https://patchwork.libcamera.org/project/libcamera/list/?series=4367",
            "date": "2024-06-06T10:15:06",
            "name": "Raspberry Pi Camera Tuning Tool updates",
            "version": 1,
            "mbox": "https://patchwork.libcamera.org/series/4367/mbox/"
        }
    ],
    "comments": "https://patchwork.libcamera.org/api/patches/20216/comments/",
    "check": "pending",
    "checks": "https://patchwork.libcamera.org/api/patches/20216/checks/",
    "tags": {},
    "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 6758BBD87C\n\tfor <parsemail@patchwork.libcamera.org>;\n\tThu,  6 Jun 2024 10:15:38 +0000 (UTC)",
            "from lancelot.ideasonboard.com (localhost [IPv6:::1])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTP id C77E265466;\n\tThu,  6 Jun 2024 12:15:37 +0200 (CEST)",
            "from mail-ej1-x633.google.com (mail-ej1-x633.google.com\n\t[IPv6:2a00:1450:4864:20::633])\n\tby lancelot.ideasonboard.com (Postfix) with ESMTPS id AD9B265448\n\tfor <libcamera-devel@lists.libcamera.org>;\n\tThu,  6 Jun 2024 12:15:31 +0200 (CEST)",
            "by mail-ej1-x633.google.com with SMTP id\n\ta640c23a62f3a-a6c7ae658d0so96298266b.0\n\tfor <libcamera-devel@lists.libcamera.org>;\n\tThu, 06 Jun 2024 03:15:31 -0700 (PDT)",
            "from pi5-davidp.pitowers.org\n\t([2001:4d4e:300:1f:c732:5d0a:406b:ae46])\n\tby smtp.gmail.com with ESMTPSA id\n\ta640c23a62f3a-a6c805c59a2sm75809866b.50.2024.06.06.03.15.29\n\t(version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);\n\tThu, 06 Jun 2024 03:15:29 -0700 (PDT)"
        ],
        "Authentication-Results": "lancelot.ideasonboard.com; dkim=pass (2048-bit key;\n\tunprotected) header.d=raspberrypi.com header.i=@raspberrypi.com\n\theader.b=\"goNdh4Tm\"; dkim-atps=neutral",
        "DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;\n\td=raspberrypi.com; s=google; t=1717668931; x=1718273731;\n\tdarn=lists.libcamera.org; \n\th=content-transfer-encoding:mime-version:references:in-reply-to\n\t:message-id:date:subject:cc:to:from:from:to:cc:subject:date\n\t:message-id:reply-to;\n\tbh=wdHkH3C7MJAL7S/KkV0mAs9iOvg4M7ZTCbcpXPlDQSI=;\n\tb=goNdh4Tmr7Lfyck6i4W2R1kGknyAiQbKMfGpN4j3kKafD5BElOAhGrtQXbdnUpBgQr\n\tEDb9FfI/WZYjuuMf0aAiZfxAPsjg07Ym2PpXCPW36ZFyMJyOkqQSYUYqpzB6RkaHZaTH\n\t+w2NaAELegyD8clWjataePiVbmA7ywB4sDD3At3JJmmrPQehiZqmRA1+GmsNDnbBOP4L\n\tHC652H146lDjJrLzDu1XMm2gU81hGPkbQo8Hh2OipB06EnvCLnJA1ksAnYFBZghJ7x/J\n\tQo72DHnrYRLz3wD9hkcX4k9WKWaw1IdVIgcb07NZxVXp2fT5xs1d5xhKqJXVm/gAB2RH\n\tV5fQ==",
        "X-Google-DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;\n\td=1e100.net; s=20230601; t=1717668931; x=1718273731;\n\th=content-transfer-encoding:mime-version:references:in-reply-to\n\t:message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc\n\t:subject:date:message-id:reply-to;\n\tbh=wdHkH3C7MJAL7S/KkV0mAs9iOvg4M7ZTCbcpXPlDQSI=;\n\tb=Tzh30l66hbRe19e692YMAwvOvRmSV7SxhaGyghas8ExGpTE7WB5TnnfBpNSAMXcBTG\n\tElykjzkld3Z7ypposdWAL4XsGt6x16vQ80zJLeSv3k22P3Mqr296TmHWxSbIywc2yqOP\n\ta08HvLk9C2VVp2E+zDIlBVdnWjBZjkZjKUPh7PxCr0vnakK7xBgGIowE6j7G5unGym77\n\tII7CQo2T9E6bWwJVCT9w/SZ2JzcB/KabgqeB0HFPLzat3wwhUKVHugKINURb4W8hdgU7\n\tztrPsbpzp5IOZPm5QF7jLXIJGASkw3BX5hibx2jP8hyuSk0blL6YJTKAAQZIZnS9Z3ob\n\t6Kcg==",
        "X-Gm-Message-State": "AOJu0YyFlRl/Uag0ZUwGlamoBt+e8GIvr62UuCjn5nQeklHaH/WOgyC9\n\tEkpv+9Rao+tCzfze5NjQkALN/zk3JaQ2Ff3VFjf9NoCByXjdv/+wlRPWDk3Tw5FCFlvafbwk0Aa\n\t1",
        "X-Google-Smtp-Source": "AGHT+IHLv2Xy3/iVIue1uDrWWRfxR5JPE86PG0xb712ib9OGCnjPnv7YmreitMaxarfMwpbXyF0SbA==",
        "X-Received": "by 2002:a17:906:3595:b0:a66:713a:5017 with SMTP id\n\ta640c23a62f3a-a699f670be7mr409036366b.29.1717668930201; \n\tThu, 06 Jun 2024 03:15:30 -0700 (PDT)",
        "From": "David Plowman <david.plowman@raspberrypi.com>",
        "To": "libcamera-devel@lists.libcamera.org",
        "Cc": "Ben Benson <benbenson2004@gmail.com>,\n\tBen Benson <ben.benson@raspberrypi.com>,\n\tNaushir Patuck <naush@raspberrypi.com>",
        "Subject": "[PATCH 2/6] utils: raspberrypi: ctt: Added CAC support to the CTT",
        "Date": "Thu,  6 Jun 2024 11:15:08 +0100",
        "Message-Id": "<20240606101512.375178-3-david.plowman@raspberrypi.com>",
        "X-Mailer": "git-send-email 2.39.2",
        "In-Reply-To": "<20240606101512.375178-1-david.plowman@raspberrypi.com>",
        "References": "<20240606101512.375178-1-david.plowman@raspberrypi.com>",
        "MIME-Version": "1.0",
        "Content-Transfer-Encoding": "8bit",
        "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>"
    },
    "content": "From: Ben Benson <benbenson2004@gmail.com>\n\nAdded the ability to tune the chromatic aberration correction\nwithin the ctt. There are options for cac_only or to tune as part\nof a larger tuning process. CTT will now recognise any files that\nbegin with \"cac\" as being chromatic aberration tuning files.\n\nSigned-off-by: Ben Benson <ben.benson@raspberrypi.com>\nReviewed-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",
    "diff": "diff --git a/utils/raspberrypi/ctt/alsc_pisp.py b/utils/raspberrypi/ctt/alsc_pisp.py\nindex 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 \ndiff --git a/utils/raspberrypi/ctt/cac_only.py b/utils/raspberrypi/ctt/cac_only.py\nnew file mode 100644\nindex 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)\ndiff --git a/utils/raspberrypi/ctt/ctt_cac.py b/utils/raspberrypi/ctt/ctt_cac.py\nnew file mode 100644\nindex 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))}\ndiff --git a/utils/raspberrypi/ctt/ctt_dots_locator.py b/utils/raspberrypi/ctt/ctt_dots_locator.py\nnew file mode 100644\nindex 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\ndiff --git a/utils/raspberrypi/ctt/ctt_image_load.py b/utils/raspberrypi/ctt/ctt_image_load.py\nindex 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)\ndiff --git a/utils/raspberrypi/ctt/ctt_log.txt b/utils/raspberrypi/ctt/ctt_log.txt\nnew file mode 100644\nindex 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+----------------------------------------------------------------------\ndiff --git a/utils/raspberrypi/ctt/ctt_pisp.py b/utils/raspberrypi/ctt/ctt_pisp.py\nindex 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 \t\"threshold\": 0.25,\n \t\"limit\": 1.0,\ndiff --git a/utils/raspberrypi/ctt/ctt_pretty_print_json.py b/utils/raspberrypi/ctt/ctt_pretty_print_json.py\nindex 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\ndiff --git a/utils/raspberrypi/ctt/ctt_run.py b/utils/raspberrypi/ctt/ctt_run.py\nindex 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",
    "prefixes": [
        "2/6"
    ]
}