├── .gitignore ├── LICENSE.txt ├── README.md ├── __init__.py ├── color_data ├── BabelColor_Avg_2006.csv ├── BabelColor_Avg_2012.csv ├── COLOR_LABELS.txt ├── ColorChecker_2005.csv ├── SpyderCHECKR-24-SCK200.csv ├── xrite_passport_colors_sRGB-GMB-2005.csv └── xrite_post-2014.csv ├── examples ├── below_fishbox.jpg ├── below_fishbox2.jpg └── test.jpg └── macduff.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | 4 | result.png 5 | result.csv 6 | 7 | .* 8 | !.gitignore 9 | 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | IMPORTANT: READ BEFORE DOWNLOADING, COPYING, INSTALLING OR USING. 2 | 3 | By downloading, copying, installing or using the software you agree to this license. 4 | If you do not agree to this license, do not download, install, 5 | copy or use the software. 6 | 7 | License Agreement 8 | For Python-Macduff 9 | 10 | Copyright (C) 2018, Andrew Port, all rights reserved. 11 | Third party copyrights are property of their respective owners. 12 | 13 | Redistribution and use in source and binary forms, with or without modification, 14 | are permitted provided that the following conditions are met: 15 | 16 | * Redistribution's of source code must retain the above copyright notice, 17 | this list of conditions and the following disclaimer. 18 | 19 | * Redistribution's in binary form must reproduce the above copyright notice, 20 | this list of conditions and the following disclaimer in the documentation 21 | and/or other materials provided with the distribution. 22 | 23 | * The name of the copyright holders may not be used to endorse or promote products 24 | derived from this software without specific prior written permission. 25 | 26 | This software is provided by the copyright holders and contributors "as is" and 27 | any express or implied warranties, including, but not limited to, the implied 28 | warranties of merchantability and fitness for a particular purpose are disclaimed. 29 | In no event shall Andrew Port or contributors be liable for any direct, 30 | indirect, incidental, special, exemplary, or consequential damages 31 | (including, but not limited to, procurement of substitute goods or services; 32 | loss of use, data, or profits; or business interruption) however caused 33 | and on any theory of liability, whether in contract, strict liability, 34 | or tort (including negligence or otherwise) arising in any way out of 35 | the use of this software, even if advised of the possibility of such damage. 36 | 37 | 38 | License for original C++ Macduff code: 39 | IMPORTANT: READ BEFORE DOWNLOADING, COPYING, INSTALLING OR USING. 40 | 41 | By downloading, copying, installing or using the software you agree to this license. 42 | If you do not agree to this license, do not download, install, 43 | copy or use the software. 44 | 45 | License Agreement 46 | For Macduff 47 | 48 | Copyright (C) 2010, Ryan Baumann, all rights reserved. 49 | Third party copyrights are property of their respective owners. 50 | 51 | Redistribution and use in source and binary forms, with or without modification, 52 | are permitted provided that the following conditions are met: 53 | 54 | * Redistribution's of source code must retain the above copyright notice, 55 | this list of conditions and the following disclaimer. 56 | 57 | * Redistribution's in binary form must reproduce the above copyright notice, 58 | this list of conditions and the following disclaimer in the documentation 59 | and/or other materials provided with the distribution. 60 | 61 | * The name of the copyright holders may not be used to endorse or promote products 62 | derived from this software without specific prior written permission. 63 | 64 | This software is provided by the copyright holders and contributors "as is" and 65 | any express or implied warranties, including, but not limited to, the implied 66 | warranties of merchantability and fitness for a particular purpose are disclaimed. 67 | In no event shall Ryan Baumann or contributors be liable for any direct, 68 | indirect, incidental, special, exemplary, or consequential damages 69 | (including, but not limited to, procurement of substitute goods or services; 70 | loss of use, data, or profits; or business interruption) however caused 71 | and on any theory of liability, whether in contract, strict liability, 72 | or tort (including negligence or otherwise) arising in any way out of 73 | the use of this software, even if advised of the possibility of such damage. 74 | 75 | 76 | License for find_quad code included from OpenCV: 77 | IMPORTANT: READ BEFORE DOWNLOADING, COPYING, INSTALLING OR USING. 78 | 79 | By downloading, copying, installing or using the software you agree to this license. 80 | If you do not agree to this license, do not download, install, 81 | copy or use the software. 82 | 83 | 84 | License Agreement 85 | For Open Source Computer Vision Library 86 | 87 | Copyright (C) 2000-2008, Intel Corporation, all rights reserved. 88 | Copyright (C) 2008-2010, Willow Garage Inc., all rights reserved. 89 | Third party copyrights are property of their respective owners. 90 | 91 | Redistribution and use in source and binary forms, with or without modification, 92 | are permitted provided that the following conditions are met: 93 | 94 | * Redistribution's of source code must retain the above copyright notice, 95 | this list of conditions and the following disclaimer. 96 | 97 | * Redistribution's in binary form must reproduce the above copyright notice, 98 | this list of conditions and the following disclaimer in the documentation 99 | and/or other materials provided with the distribution. 100 | 101 | * The name of the copyright holders may not be used to endorse or promote products 102 | derived from this software without specific prior written permission. 103 | 104 | This software is provided by the copyright holders and contributors "as is" and 105 | any express or implied warranties, including, but not limited to, the implied 106 | warranties of merchantability and fitness for a particular purpose are disclaimed. 107 | In no event shall the Intel Corporation or contributors be liable for any direct, 108 | indirect, incidental, special, exemplary, or consequential damages 109 | (including, but not limited to, procurement of substitute goods or services; 110 | loss of use, data, or profits; or business interruption) however caused 111 | and on any theory of liability, whether in contract, strict liability, 112 | or tort (including negligence or otherwise) arising in any way out of 113 | the use of this software, even if advised of the possibility of such damage. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python-Macduff 2 | This is a Python port of [Macduff](https://github.com/ryanfb/macduff), a tool 3 | for finding the Macbeth ColorChecker chart in an image. 4 | 5 | The translation to python was done by Andrew Port and was supported by 6 | [Rare](https://rare.org) as part of work done for the 7 | [Fish Forever](http://www.fishforever.org/) project. 8 | 9 | ## Changes from the original algorithm: 10 | * An (in-code) parameter `MIN_RELATIVE_SQUARE_SIZE` has been added as a 11 | work-around for an issue where the algorithm would choke on images where 12 | the ColorChecker was smaller than a certain hard-coded size relative to 13 | the image dimensions. 14 | 15 | * An optional `patch_size` parameter has been added to give better results 16 | when the approximate (within rtol=25%) pixel-width of the color patches is 17 | known. 18 | 19 | * Several additional colorchecker color value options are now included. The 20 | default has been changed to those values provided by xrite for the "passport" 21 | colorchecker. 22 | 23 | ## Usage 24 | 25 | # if pixel-width of color patches is unknown, 26 | $ python macduff.py examples/test.jpg result.png > result.csv 27 | 28 | # if pixel-width of color patches is known to be, e.g. 65, 29 | $ python macduff.py examples/test.jpg result.png 65 > result.csv 30 | 31 | ## DESCRIPTION 32 | 33 | ![Macduff result](https://ryanfb.s3.amazonaws.com/images/macduff.png) 34 | 35 | Macduff will try its best to find your ColorChecker. If you specify an output 36 | image, it will be written with the "found" ColorChecker overlaid on the input 37 | image with circles on each patch (the outer circle is the "reference" value, 38 | the inner circle is the average value from the actual image). Macduff outputs 39 | various useless debug info on stderr, and useful information in CSV-style 40 | on stdout. The first 24 lines will be the ColorChecker patch locations and 41 | average values: 42 | 43 | x,y,r,g,b 44 | 45 | The last two lines contain the patch square size (i.e. you can feed another 46 | program this and the location and safely use a square of `size` with the top 47 | left corner at `x-size/2,y-size/2` for each patch) and error against the 48 | reference chart. The patches are output in row order from the typical 49 | ColorChecker orientation ("dark skin" top left, "black" bottom right): 50 | 51 | ![ColorChecker layout](https://ryanfb.s3.amazonaws.com/images/CC_Avg20_orig_layout.png) 52 | 53 | See also: [Automatic ColorChecker Detection, a Survey](http://ryanfb.github.io/etc/2015/07/08/automatic_colorchecker_detection.html) 54 | 55 | ## LICENSE 56 | The original Macduff code is protected under the 3-clause BSD and includes some code taken from OpenCV. See LICENSE.TXT. 57 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .macduff import * 2 | -------------------------------------------------------------------------------- /color_data/BabelColor_Avg_2006.csv: -------------------------------------------------------------------------------- 1 | 115,81,67 2 | 196,149,129 3 | 93,123,157 4 | 90,108,65 5 | 130,129,176 6 | 99,191,171 7 | 220,123,45 8 | 72,92,168 9 | 195,84,98 10 | 91,59,105 11 | 160,189,62 12 | 229,161,41 13 | 43,62,147 14 | 71,149,72 15 | 176,48,56 16 | 238,200,22 17 | 188,84,150 18 | 0,136,166 19 | 245,245,240 20 | 200,201,201 21 | 160,161,161 22 | 120,121,121 23 | 83,84,85 24 | 50,50,50 -------------------------------------------------------------------------------- /color_data/BabelColor_Avg_2012.csv: -------------------------------------------------------------------------------- 1 | 115,82,68 2 | 195,149,128 3 | 93,123,157 4 | 91,108,65 5 | 130,129,175 6 | 99,191,171 7 | 220,123,46 8 | 72,92,168 9 | 194,84,97 10 | 91,59,104 11 | 161,189,62 12 | 229,161,40 13 | 42,63,147 14 | 72,149,72 15 | 175,50,57 16 | 238,200,22 17 | 188,84,150 18 | 0,137,166 19 | 245,245,240 20 | 201,202,201 21 | 161,162,162 22 | 120,121,121 23 | 83,85,85 24 | 50,50,51 -------------------------------------------------------------------------------- /color_data/COLOR_LABELS.txt: -------------------------------------------------------------------------------- 1 | dark skin 2 | light skin 3 | blue sky 4 | foliage 5 | blue flower 6 | bluish green 7 | orange 8 | purplish blue 9 | moderate red 10 | purple 11 | yellow green 12 | orange yellow 13 | blue 14 | green 15 | red 16 | yellow 17 | magenta 18 | cyan 19 | white 9.5 (.05 D) 20 | neutral 8 (.23 D) 21 | neutral 6.5 (.44 D) 22 | neutral 5 (.70 D) 23 | neutral 3.5 (1.05 D) 24 | black 2 (1.5 D) -------------------------------------------------------------------------------- /color_data/ColorChecker_2005.csv: -------------------------------------------------------------------------------- 1 | 116,81,67 2 | 199,147,129 3 | 91,122,156 4 | 90,108,64 5 | 130,128,176 6 | 92,190,172 7 | 224,124,47 8 | 68,91,170 9 | 198,82,97 10 | 94,58,106 11 | 159,189,63 12 | 230,162,39 13 | 35,63,147 14 | 67,149,74 15 | 180,49,57 16 | 238,198,20 17 | 193,84,151 18 | 0,136,170 19 | 245,245,243 20 | 200,202,202 21 | 161,163,163 22 | 121,121,122 23 | 82,84,86 24 | 49,49,51 -------------------------------------------------------------------------------- /color_data/SpyderCHECKR-24-SCK200.csv: -------------------------------------------------------------------------------- 1 | 249,242,238 2 | 202,198,195 3 | 161,157,154 4 | 122,118,116 5 | 80,80,78 6 | 43,41,43 7 | 0,127,159 8 | 192,75,145 9 | 245,205,0 10 | 186,26,51 11 | 57,146,64 12 | 25,55,135 13 | 222,118,32 14 | 58,88,159 15 | 195,79,95 16 | 83,58,106 17 | 157,188,54 18 | 238,158,25 19 | 98,187,166 20 | 126,125,174 21 | 82,106,60 22 | 87,120,155 23 | 197,145,125 24 | 112,76,60 -------------------------------------------------------------------------------- /color_data/xrite_passport_colors_sRGB-GMB-2005.csv: -------------------------------------------------------------------------------- 1 | 115,82,68 2 | 194,150,130 3 | 98,122,157 4 | 87,108,67 5 | 133,128,177 6 | 103,189,170 7 | 214,126,44 8 | 80,91,166 9 | 193,90,99 10 | 94,60,108 11 | 157,188,64 12 | 224,163,46 13 | 56,61,150 14 | 70,148,73 15 | 175,54,60 16 | 231,199,31 17 | 187,86,149 18 | 8,133,161 19 | 243,243,242 20 | 200,200,200 21 | 160,160,160 22 | 122,122,121 23 | 85,85,85 24 | 52,52,52 -------------------------------------------------------------------------------- /color_data/xrite_post-2014.csv: -------------------------------------------------------------------------------- 1 | 116,79,65 2 | 197,144,127 3 | 91,120,155 4 | 91,108,64 5 | 131,127,175 6 | 95,189,172 7 | 224,124,48 8 | 69,90,167 9 | 197,80,95 10 | 93,58,104 11 | 156,187,58 12 | 227,161,39 13 | 40,62,145 14 | 61,147,70 15 | 178,54,57 16 | 236,199,15 17 | 191,79,146 18 | 0,133,165 19 | 241,242,235 20 | 201,202,201 21 | 161,163,163 22 | 121,121,121 23 | 83,84,85 24 | 50,50,50 -------------------------------------------------------------------------------- /examples/below_fishbox.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathandy/python-macduff-colorchecker-detector/555e3c7ca6a711b78596839de2e228108965fbd7/examples/below_fishbox.jpg -------------------------------------------------------------------------------- /examples/below_fishbox2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathandy/python-macduff-colorchecker-detector/555e3c7ca6a711b78596839de2e228108965fbd7/examples/below_fishbox2.jpg -------------------------------------------------------------------------------- /examples/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathandy/python-macduff-colorchecker-detector/555e3c7ca6a711b78596839de2e228108965fbd7/examples/test.jpg -------------------------------------------------------------------------------- /macduff.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Python-Macduff: "the Macbeth ColorChecker finder", ported to Python. 3 | 4 | Original C++ code: github.com/ryanfb/macduff/ 5 | 6 | Usage: 7 | # if pixel-width of color patches is unknown, 8 | $ python macduff.py examples/test.jpg result.png > result.csv 9 | 10 | # if pixel-width of color patches is known to be, e.g. 65, 11 | $ python macduff.py examples/test.jpg result.png 65 > result.csv 12 | """ 13 | from __future__ import print_function, division 14 | import cv2 as cv 15 | import numpy as np 16 | from numpy.linalg import norm 17 | from math import sqrt 18 | from sys import stderr, argv 19 | from copy import copy 20 | import os 21 | 22 | 23 | _root = os.path.dirname(os.path.realpath(__file__)) 24 | 25 | 26 | # Each color square must takes up more than this percentage of the image 27 | MIN_RELATIVE_SQUARE_SIZE = 0.0001 28 | 29 | DEBUG = False 30 | 31 | MACBETH_WIDTH = 6 32 | MACBETH_HEIGHT = 4 33 | MACBETH_SQUARES = MACBETH_WIDTH * MACBETH_HEIGHT 34 | 35 | MAX_CONTOUR_APPROX = 50 # default was 7 36 | 37 | 38 | # pick the colorchecker values to use -- several options available in 39 | # the `color_data` subdirectory 40 | # Note: all options are explained in detail at 41 | # http://www.babelcolor.com/colorchecker-2.htm 42 | color_data = os.path.join(_root, 'color_data', 43 | 'xrite_passport_colors_sRGB-GMB-2005.csv') 44 | expected_colors = np.flip(np.loadtxt(color_data, delimiter=','), 1) 45 | expected_colors = expected_colors.reshape(MACBETH_HEIGHT, MACBETH_WIDTH, 3) 46 | 47 | 48 | # a class to simplify the translation from c++ 49 | class Box2D: 50 | """ 51 | Note: The Python equivalent of `RotatedRect` and `Box2D` objects 52 | are tuples, `((center_x, center_y), (w, h), rotation)`. 53 | Example: 54 | >>> cv.boxPoints(((0, 0), (2, 1), 0)) 55 | array([[-1. , 0.5], 56 | [-1. , -0.5], 57 | [ 1. , -0.5], 58 | [ 1. , 0.5]], dtype=float32) 59 | >>> cv.boxPoints(((0, 0), (2, 1), 90)) 60 | array([[-0.5, -1. ], 61 | [ 0.5, -1. ], 62 | [ 0.5, 1. ], 63 | [-0.5, 1. ]], dtype=float32) 64 | """ 65 | def __init__(self, center=None, size=None, angle=0, rrect=None): 66 | if rrect is not None: 67 | center, size, angle = rrect 68 | 69 | # self.center = Point2D(*center) 70 | # self.size = Size(*size) 71 | self.center = center 72 | self.size = size 73 | self.angle = angle # in degrees 74 | 75 | def rrect(self): 76 | return self.center, self.size, self.angle 77 | 78 | 79 | def crop_patch(center, size, image): 80 | """Returns mean color in intersection of `image` and `rectangle`.""" 81 | x, y = center - np.array(size)/2 82 | w, h = size 83 | x0, y0, x1, y1 = map(round, [x, y, x + w, y + h]) 84 | return image[int(max(y0, 0)): int(min(y1, image.shape[0])), 85 | int(max(x0, 0)): int(min(x1, image.shape[1]))] 86 | 87 | 88 | def contour_average(contour, image): 89 | """Assuming `contour` is a polygon, returns the mean color inside it. 90 | 91 | Note: This function is inefficiently implemented!!! 92 | Maybe using drawing/fill functions would improve speed. 93 | """ 94 | 95 | # find up-right bounding box 96 | xbb, ybb, wbb, hbb = cv.boundingRect(contour) 97 | 98 | # now found which points in bounding box are inside contour and sum 99 | def is_inside_contour(pt): 100 | return cv.pointPolygonTest(contour, pt, False) > 0 101 | 102 | from itertools import product as catesian_product 103 | from operator import add 104 | from functools import reduce 105 | bb = catesian_product(range(max(xbb, 0), min(xbb + wbb, image.shape[1])), 106 | range(max(ybb, 0), min(ybb + hbb, image.shape[0]))) 107 | pts_inside_of_contour = [xy for xy in bb if is_inside_contour(xy)] 108 | 109 | # pts_inside_of_contour = list(filter(is_inside_contour, bb)) 110 | color_sum = reduce(add, (image[y, x] for x, y in pts_inside_of_contour)) 111 | return color_sum / len(pts_inside_of_contour) 112 | 113 | 114 | def rotate_box(box_corners): 115 | """NumPy equivalent of `[arr[i-1] for i in range(len(arr)]`""" 116 | return np.roll(box_corners, 1, 0) 117 | 118 | 119 | def check_colorchecker(values, expected_values=expected_colors): 120 | """Find deviation of colorchecker `values` from expected values.""" 121 | diff = (values - expected_values).ravel(order='K') 122 | return sqrt(np.dot(diff, diff)) 123 | 124 | 125 | # def check_colorchecker_lab(values): 126 | # """Converts to Lab color space then takes Euclidean distance.""" 127 | # lab_values = cv.cvtColor(values, cv.COLOR_BGR2Lab) 128 | # lab_expected = cv.cvtColor(expected_colors, cv.COLOR_BGR2Lab) 129 | # return check_colorchecker(lab_values, lab_expected) 130 | 131 | 132 | def draw_colorchecker(colors, centers, image, radius): 133 | for observed_color, expected_color, pt in zip(colors.reshape(-1, 3), 134 | expected_colors.reshape(-1, 3), 135 | centers.reshape(-1, 2)): 136 | x, y = pt 137 | cv.circle(image, (x, y), radius//2, expected_color.tolist(), -1) 138 | cv.circle(image, (x, y), radius//4, observed_color.tolist(), -1) 139 | return image 140 | 141 | 142 | class ColorChecker: 143 | def __init__(self, error, values, points, size): 144 | self.error = error 145 | self.values = values 146 | self.points = points 147 | self.size = size 148 | 149 | 150 | def find_colorchecker(boxes, image, debug_filename=None, use_patch_std=True, 151 | debug=DEBUG): 152 | 153 | points = np.array([[box.center[0], box.center[1]] for box in boxes]) 154 | passport_box = cv.minAreaRect(points.astype('float32')) 155 | (x, y), (w, h), a = passport_box 156 | box_corners = cv.boxPoints(passport_box) 157 | 158 | # sort `box_corners` to be in order tl, tr, br, bl 159 | top_corners = sorted(enumerate(box_corners), key=lambda c: c[1][1])[:2] 160 | top_left_idx = min(top_corners, key=lambda c: c[1][0])[0] 161 | box_corners = np.roll(box_corners, -top_left_idx, 0) 162 | tl, tr, br, bl = box_corners 163 | 164 | if debug: 165 | debug_images = [copy(image), copy(image)] 166 | for box in boxes: 167 | pts_ = [cv.boxPoints(box.rrect()).astype(np.int32)] 168 | cv.polylines(debug_images[0], pts_, True, (255, 0, 0)) 169 | pts_ = [box_corners.astype(np.int32)] 170 | cv.polylines(debug_images[0], pts_, True, (0, 0, 255)) 171 | 172 | bgrp = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 0, 255)] 173 | for pt, c in zip(box_corners, bgrp): 174 | cv.circle(debug_images[0], tuple(np.array(pt, dtype='int')), 10, c) 175 | # cv.imwrite(debug_filename, np.vstack(debug_images)) 176 | 177 | print("Box:\n\tCenter: %f,%f\n\tSize: %f,%f\n\tAngle: %f\n" 178 | "" % (x, y, w, h, a), file=stderr) 179 | 180 | landscape_orientation = True # `passport_box` is wider than tall 181 | if norm(tr - tl) < norm(bl - tl): 182 | landscape_orientation = False 183 | 184 | average_size = int(sum(min(box.size) for box in boxes) / len(boxes)) 185 | if landscape_orientation: 186 | dx = (tr - tl)/(MACBETH_WIDTH - 1) 187 | dy = (bl - tl)/(MACBETH_HEIGHT - 1) 188 | else: 189 | dx = (bl - tl)/(MACBETH_WIDTH - 1) 190 | dy = (tr - tl)/(MACBETH_HEIGHT - 1) 191 | 192 | # calculate the averages for our oriented colorchecker 193 | checker_dims = (MACBETH_HEIGHT, MACBETH_WIDTH) 194 | patch_values = np.empty(checker_dims + (3,), dtype='float32') 195 | patch_points = np.empty(checker_dims + (2,), dtype='float32') 196 | sum_of_patch_stds = np.array((0.0, 0.0, 0.0)) 197 | for x in range(MACBETH_WIDTH): 198 | for y in range(MACBETH_HEIGHT): 199 | center = tl + x*dx + y*dy 200 | 201 | px, py = center 202 | img_patch = crop_patch(center, [average_size]*2, image) 203 | 204 | if not landscape_orientation: 205 | y = MACBETH_HEIGHT - 1 - y 206 | 207 | patch_points[y, x] = center 208 | patch_values[y, x] = img_patch.mean(axis=(0, 1)) 209 | sum_of_patch_stds += img_patch.std(axis=(0, 1)) 210 | 211 | if debug: 212 | rect = (px, py), (average_size, average_size), 0 213 | pts_ = [cv.boxPoints(rect).astype(np.int32)] 214 | cv.polylines(debug_images[1], pts_, True, (0, 255, 0)) 215 | if debug: 216 | cv.imwrite(debug_filename, np.vstack(debug_images)) 217 | 218 | # determine which orientation has lower error 219 | orient_1_error = check_colorchecker(patch_values) 220 | orient_2_error = check_colorchecker(patch_values[::-1, ::-1]) 221 | 222 | if orient_1_error > orient_2_error: # rotate by 180 degrees 223 | patch_values = patch_values[::-1, ::-1] 224 | patch_points = patch_points[::-1, ::-1] 225 | 226 | if use_patch_std: 227 | error = sum_of_patch_stds.mean() / MACBETH_SQUARES 228 | else: 229 | error = min(orient_1_error, orient_2_error) 230 | 231 | if debug: 232 | print("dx =", dx, file=stderr) 233 | print("dy =", dy, file=stderr) 234 | print("Average contained rect size is %d\n" % average_size, file=stderr) 235 | print("Orientation 1: %f\n" % orient_1_error, file=stderr) 236 | print("Orientation 2: %f\n" % orient_2_error, file=stderr) 237 | print("Error: %f\n" % error, file=stderr) 238 | 239 | return ColorChecker(error=error, 240 | values=patch_values, 241 | points=patch_points, 242 | size=average_size) 243 | 244 | 245 | def angle_cos(p0, p1, p2): 246 | d1, d2 = (p0-p1).astype('float'), (p2-p1).astype('float') 247 | return abs(np.dot(d1, d2) / np.sqrt(np.dot(d1, d1)*np.dot(d2, d2))) 248 | 249 | 250 | # https://github.com/opencv/opencv/blob/master/samples/python/squares.py 251 | # Note: This is similar to find_quads, added to hastily add support to 252 | # the `patch_size` parameter 253 | def find_squares(img): 254 | img = cv.GaussianBlur(img, (5, 5), 0) 255 | squares = [] 256 | for gray in cv.split(img): 257 | for thrs in range(0, 255, 26): 258 | if thrs == 0: 259 | bin = cv.Canny(gray, 0, 50, apertureSize=5) 260 | bin = cv.dilate(bin, None) 261 | else: 262 | _retval, bin = cv.threshold(gray, thrs, 255, cv.THRESH_BINARY) 263 | 264 | tmp = cv.findContours(bin, cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE) 265 | try: 266 | contours, _ = tmp 267 | except ValueError: # OpenCV version < 4.0.0 268 | bin, contours, _ = tmp 269 | 270 | for cnt in contours: 271 | cnt_len = cv.arcLength(cnt, True) 272 | cnt = cv.approxPolyDP(cnt, 0.02*cnt_len, True) 273 | if (len(cnt) == 4 and cv.contourArea(cnt) > 1000 274 | and cv.isContourConvex(cnt)): 275 | cnt = cnt.reshape(-1, 2) 276 | max_cos = max([angle_cos(cnt[i], cnt[(i+1) % 4], cnt[(i + 2) % 4]) 277 | for i in range(4)]) 278 | if max_cos < 0.1: 279 | squares.append(cnt) 280 | return squares 281 | 282 | 283 | def is_right_size(quad, patch_size, rtol=.25): 284 | """Determines if a (4-point) contour is approximately the right size.""" 285 | cw = abs(np.linalg.norm(quad[0] - quad[1]) - patch_size) < rtol*patch_size 286 | ch = abs(np.linalg.norm(quad[0] - quad[3]) - patch_size) < rtol*patch_size 287 | return cw and ch 288 | 289 | 290 | # stolen from icvGenerateQuads 291 | def find_quad(src_contour, min_size, debug_image=None): 292 | 293 | for max_error in range(2, MAX_CONTOUR_APPROX + 1): 294 | dst_contour = cv.approxPolyDP(src_contour, max_error, closed=True) 295 | if len(dst_contour) == 4: 296 | break 297 | 298 | # we call this again on its own output, because sometimes 299 | # cvApproxPoly() does not simplify as much as it should. 300 | dst_contour = cv.approxPolyDP(dst_contour, max_error, closed=True) 301 | if len(dst_contour) == 4: 302 | break 303 | 304 | # reject non-quadrangles 305 | is_acceptable_quad = False 306 | is_quad = False 307 | if len(dst_contour) == 4 and cv.isContourConvex(dst_contour): 308 | is_quad = True 309 | perimeter = cv.arcLength(dst_contour, closed=True) 310 | area = cv.contourArea(dst_contour, oriented=False) 311 | 312 | d1 = np.linalg.norm(dst_contour[0] - dst_contour[2]) 313 | d2 = np.linalg.norm(dst_contour[1] - dst_contour[3]) 314 | d3 = np.linalg.norm(dst_contour[0] - dst_contour[1]) 315 | d4 = np.linalg.norm(dst_contour[1] - dst_contour[2]) 316 | 317 | # philipg. Only accept those quadrangles which are more square 318 | # than rectangular and which are big enough 319 | cond = (d3/1.1 < d4 < d3*1.1 and 320 | d3*d4/1.5 < area and 321 | min_size < area and 322 | d1 >= 0.15*perimeter and 323 | d2 >= 0.15*perimeter) 324 | 325 | if not cv.CALIB_CB_FILTER_QUADS or area > min_size and cond: 326 | is_acceptable_quad = True 327 | # return dst_contour 328 | if debug_image is not None: 329 | cv.drawContours(debug_image, [src_contour], -1, (255, 0, 0), 1) 330 | if is_acceptable_quad: 331 | cv.drawContours(debug_image, [dst_contour], -1, (0, 255, 0), 1) 332 | elif is_quad: 333 | cv.drawContours(debug_image, [dst_contour], -1, (0, 0, 255), 1) 334 | return debug_image 335 | 336 | if is_acceptable_quad: 337 | return dst_contour 338 | return None 339 | 340 | 341 | def find_macbeth(img, patch_size=None, is_passport=False, debug=DEBUG, 342 | min_relative_square_size=MIN_RELATIVE_SQUARE_SIZE): 343 | macbeth_img = img 344 | if isinstance(img, str): 345 | macbeth_img = cv.imread(img) 346 | macbeth_original = copy(macbeth_img) 347 | macbeth_split = cv.split(macbeth_img) 348 | 349 | # threshold each channel and OR results together 350 | block_size = int(min(macbeth_img.shape[:2]) * 0.02) | 1 351 | macbeth_split_thresh = [] 352 | for channel in macbeth_split: 353 | res = cv.adaptiveThreshold(channel, 354 | 255, 355 | cv.ADAPTIVE_THRESH_MEAN_C, 356 | cv.THRESH_BINARY_INV, 357 | block_size, 358 | C=6) 359 | macbeth_split_thresh.append(res) 360 | adaptive = np.bitwise_or(*macbeth_split_thresh) 361 | 362 | if debug: 363 | print("Used %d as block size\n" % block_size, file=stderr) 364 | cv.imwrite('debug_threshold.png', 365 | np.vstack(macbeth_split_thresh + [adaptive])) 366 | 367 | # do an opening on the threshold image 368 | element_size = int(2 + block_size / 10) 369 | shape, ksize = cv.MORPH_RECT, (element_size, element_size) 370 | element = cv.getStructuringElement(shape, ksize) 371 | adaptive = cv.morphologyEx(adaptive, cv.MORPH_OPEN, element) 372 | 373 | if debug: 374 | print("Used %d as element size\n" % element_size, file=stderr) 375 | cv.imwrite('debug_adaptive-open.png', adaptive) 376 | 377 | # find contours in the threshold image 378 | tmp = cv.findContours(image=adaptive, 379 | mode=cv.RETR_LIST, 380 | method=cv.CHAIN_APPROX_SIMPLE) 381 | try: 382 | contours, _ = tmp 383 | except ValueError: # OpenCV < 4.0.0 384 | adaptive, contours, _ = tmp 385 | 386 | if debug: 387 | show_contours = cv.cvtColor(copy(adaptive), cv.COLOR_GRAY2BGR) 388 | cv.drawContours(show_contours, contours, -1, (0, 255, 0)) 389 | cv.imwrite('debug_all_contours.png', show_contours) 390 | 391 | min_size = np.product(macbeth_img.shape[:2]) * min_relative_square_size 392 | 393 | def is_seq_hole(c): 394 | return cv.contourArea(c, oriented=True) > 0 395 | 396 | def is_big_enough(contour): 397 | _, (w, h), _ = cv.minAreaRect(contour) 398 | return w * h >= min_size 399 | 400 | # filter out contours that are too small or clockwise 401 | contours = [c for c in contours if is_big_enough(c) and is_seq_hole(c)] 402 | 403 | if debug: 404 | show_contours = cv.cvtColor(copy(adaptive), cv.COLOR_GRAY2BGR) 405 | cv.drawContours(show_contours, contours, -1, (0, 255, 0)) 406 | cv.imwrite('debug_big_contours.png', show_contours) 407 | 408 | debug_img = cv.cvtColor(copy(adaptive), cv.COLOR_GRAY2BGR) 409 | for c in contours: 410 | debug_img = find_quad(c, min_size, debug_image=debug_img) 411 | cv.imwrite("debug_quads.png", debug_img) 412 | 413 | if contours: 414 | if patch_size is None: 415 | initial_quads = [find_quad(c, min_size) for c in contours] 416 | else: 417 | initial_quads = [s for s in find_squares(macbeth_original) 418 | if is_right_size(s, patch_size)] 419 | if is_passport and len(initial_quads) <= MACBETH_SQUARES: 420 | qs = [find_quad(c, min_size) for c in contours] 421 | qs = [x for x in qs if x is not None] 422 | initial_quads = [x for x in qs if is_right_size(x, patch_size)] 423 | initial_quads = [q for q in initial_quads if q is not None] 424 | initial_boxes = [Box2D(rrect=cv.minAreaRect(q)) for q in initial_quads] 425 | 426 | if debug: 427 | show_quads = cv.cvtColor(copy(adaptive), cv.COLOR_GRAY2BGR) 428 | cv.drawContours(show_quads, initial_quads, -1, (0, 255, 0)) 429 | cv.imwrite('debug_quads2.png', show_quads) 430 | print("%d initial quads found", len(initial_quads), file=stderr) 431 | 432 | if is_passport or (len(initial_quads) > MACBETH_SQUARES): 433 | if debug: 434 | print(" (probably a Passport)\n", file=stderr) 435 | 436 | # set up the points sequence for cvKMeans2, using the box centers 437 | points = np.array([box.center for box in initial_boxes], 438 | dtype='float32') 439 | 440 | # partition into two clusters: passport and colorchecker 441 | criteria = \ 442 | (cv.TERM_CRITERIA_EPS + cv.TERM_CRITERIA_MAX_ITER, 10, 1.0) 443 | compactness, clusters, centers = \ 444 | cv.kmeans(data=points, 445 | K=2, 446 | bestLabels=None, 447 | criteria=criteria, 448 | attempts=100, 449 | flags=cv.KMEANS_RANDOM_CENTERS) 450 | 451 | partitioned_quads = [[], []] 452 | partitioned_boxes = [[], []] 453 | for i, cluster in enumerate(clusters.ravel()): 454 | partitioned_quads[cluster].append(initial_quads[i]) 455 | partitioned_boxes[cluster].append(initial_boxes[i]) 456 | 457 | debug_fns = [None, None] 458 | if debug: 459 | debug_fns = ['debug_passport_box_%s.jpg' % i for i in (0, 1)] 460 | 461 | # show clustering 462 | img_clusters = [] 463 | for cl in partitioned_quads: 464 | img_copy = copy(macbeth_original) 465 | cv.drawContours(img_copy, cl, -1, (255, 0, 0)) 466 | img_clusters.append(img_copy) 467 | cv.imwrite('debug_clusters.jpg', np.vstack(img_clusters)) 468 | 469 | # check each of the two partitioned sets for the best colorchecker 470 | partitioned_checkers = [] 471 | for cluster_boxes, fn in zip(partitioned_boxes, debug_fns): 472 | partitioned_checkers.append( 473 | find_colorchecker(cluster_boxes, macbeth_original, fn, 474 | debug=debug)) 475 | 476 | # use the colorchecker with the lowest error 477 | found_colorchecker = min(partitioned_checkers, 478 | key=lambda checker: checker.error) 479 | 480 | else: # just one colorchecker to test 481 | debug_img = None 482 | if debug: 483 | debug_img = "debug_passport_box.jpg" 484 | print("\n", file=stderr) 485 | 486 | found_colorchecker = \ 487 | find_colorchecker(initial_boxes, macbeth_original, debug_img, 488 | debug=debug) 489 | 490 | # render the found colorchecker 491 | draw_colorchecker(found_colorchecker.values, 492 | found_colorchecker.points, 493 | macbeth_img, 494 | found_colorchecker.size) 495 | 496 | # print out the colorchecker info 497 | for color, pt in zip(found_colorchecker.values.reshape(-1, 3), 498 | found_colorchecker.points.reshape(-1, 2)): 499 | b, g, r = color 500 | x, y = pt 501 | if debug: 502 | print("%.0f,%.0f,%.0f,%.0f,%.0f\n" % (x, y, r, g, b)) 503 | if debug: 504 | print("%0.f\n%f\n" 505 | "" % (found_colorchecker.size, found_colorchecker.error)) 506 | else: 507 | raise Exception('Something went wrong -- no contours found') 508 | return macbeth_img, found_colorchecker 509 | 510 | 511 | def write_results(colorchecker, filename=None): 512 | mes = ',r,g,b\n' 513 | for k, (b, g, r) in enumerate(colorchecker.values.reshape(1, 3)): 514 | mes += '{},{},{},{}\n'.format(k, r, g, b) 515 | 516 | if filename is None: 517 | print(mes) 518 | else: 519 | with open(filename, 'w+') as f: 520 | f.write(mes) 521 | 522 | 523 | if __name__ == '__main__': 524 | if len(argv) == 3: 525 | out, colorchecker = find_macbeth(argv[1]) 526 | cv.imwrite(argv[2], out) 527 | elif len(argv) == 4: 528 | out, colorchecker = find_macbeth(argv[1], patch_size=float(argv[3])) 529 | cv.imwrite(argv[2], out) 530 | else: 531 | print('Usage: %s <(optional) patch_size>\n' 532 | '' % argv[0], file=stderr) 533 | # write_results(colorchecker, 'results.csv') 534 | --------------------------------------------------------------------------------