Zum Inhalt

cv

assign_colour(face, bounding_box, mass_centre, colour, segment_width, segment_height)

Assigns the colour value at a mass center in an image to a 3x3 grid according to the sub-sections in the bounding box.

Parameters:

Name Type Description Default
face ndarray

The face to assign the colour to.

required
bounding_box Tuple[int, int, int, int]

The bounding box (outer grid) surrounding the mass centres.

required
mass_centre Tuple[float, float]

The mass centre, which should be alignable in the bounding box in a 3x3 grid.

required
colour TileColor

The colour of the pixel of the mass centre.

required
segment_width float

The width of a segment in the bounding box (its width / 3).

required
segment_height float

The height of a segment in the bounding box (its height / 3).

required

Returns:

Type Description
ndarray

A 3x3 numpy array consisting of the TileColors assigned so far.

Source code in core/cv/cv.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def assign_colour(face: np.ndarray,
                  bounding_box: Tuple[int, int, int, int],
                  mass_centre: Tuple[float, float],
                  colour: cube.TileColor,
                  segment_width: float,
                  segment_height: float) -> np.ndarray:
    """
    Assigns the colour value at a mass center in an image to a 3x3
    grid according to the sub-sections in the bounding box.
    Args:
        face: The face to assign the colour to.
        bounding_box: The bounding box (outer grid) surrounding the mass centres.
        mass_centre: The mass centre, which should be alignable in the bounding box in a 3x3 grid.
        colour: The colour of the pixel of the mass centre.
        segment_width: The width of a segment in the bounding box (its width / 3).
        segment_height: The height of a segment in the bounding box (its height / 3).

    Returns:
        A 3x3 numpy array consisting of the TileColors assigned so far.
    """
    for row in range(3):
        for col in range(3):
            if mass_centre[0] > bounding_box[0] + col * segment_width and \
               mass_centre[0] < bounding_box[0] + (col + 1) * segment_width and \
               mass_centre[1] > bounding_box[1] + (row) * segment_height and \
               mass_centre[1] < bounding_box[1] + (row + 1) * segment_height:
                face[row, col] = colour
                return face
    return face

find_colours(image)

Creates a 3x3 numpy array containing TileColors describing the cube which is pictured in the input image in a minimal way

Parameters:

Name Type Description Default
image ndarray

The input image (BGR) containing a face of a cube.

required

Returns:

Type Description
ndarray

A 3x3 numpy array containing the TileColors of the pictured cube.

Source code in core/cv/cv.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def find_colours(image: np.ndarray) -> np.ndarray:
    """
    Creates a 3x3 numpy array containing TileColors describing the cube which is pictured in the input image in a
    minimal way
    Args:
        image: The input image (BGR) containing a face of a cube.

    Returns:
        A 3x3 numpy array containing the TileColors of the pictured cube.
    """
    image_hsv     = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
    image_black   = np.zeros(image.shape)
    image_height, image_width, _ = image.shape

    global COLOURS, COLOUR_RANGES

    coloured_contours = []
    for colour in COLOURS:
        binarized = mask_colour(image_hsv, colour)
        size = 11
        elem = cv2.getStructuringElement(cv2.MORPH_RECT, (size, size))
        binarized = cv2.erode(binarized, elem)

        contours, hierarchy = cv2.findContours(binarized, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

        squares_idx = []
        for (idx, contour) in enumerate(contours):
            if is_square_contour(contour, image_width, image_height):
                squares_idx.append(idx)

        for idx in range(len(contours)):
            if idx in squares_idx:
                cv2.drawContours(image_black, contours, idx, get_bgr(colour), -1)
                coloured_contours.append((contours[idx], colour))
            else:
                cv2.drawContours(image_black, contours, idx, (0.5, 0.5, 0.5), 1)

    while len(coloured_contours) > SEGMENTS_PER_FACE:
        # remove outliers until we have the right amount of segments
        sorted_areas   = list(sorted(map(lambda x: cv2.contourArea(x[0]), coloured_contours)))
        mean_area = sorted_areas[len(sorted_areas) // 2] if len(sorted_areas) % 2 != 0 else \
            (sorted_areas[len(sorted_areas) // 2] + sorted_areas[len(sorted_areas) // 2 - 1]) / 2

        max_deviation_idx = -1
        max_deviation     = 0
        for idx, (contour, colour) in enumerate(coloured_contours):
            deviation = abs(cv2.contourArea(contour) - mean_area)
            if deviation > max_deviation:
                max_deviation = deviation
                max_deviation_idx = idx

        coloured_contours.remove(coloured_contours[max_deviation_idx])

    found_contours = [point for sublist in coloured_contours for point in sublist[0]]
    # IMPROVEMENT: make sure the rect is really upright, i.e. by rotation

    if logger.isEnabledFor(logging.DEBUG):
        cv2.imshow("face", image_black)
        cv2.waitKey()

    rect = cv2.boundingRect(np.array(found_contours))
    try:
        result = to_array(image_hsv, coloured_contours, rect)
    except ValueError as e:
        logger.error(e)
        image = cv2.rectangle(image, (rect[0], rect[1]), (rect[0] + rect[2], rect[1] + rect[3]), (255, 255, 255), 1)
        cv2.imwrite("error.png", image)
        cv2.imwrite("cv.png", (image_black * 255).astype(np.uint8))
        raise e

    return result

get_bgr(colour)

Returns the BGR value for a colour supplied as a string.

Parameters:

Name Type Description Default
colour str

The colour.

required

Returns:

Type Description
Tuple[float, float, float]

The BGR value of the colour as a tuple of (B, G, R) in range 0..1

Source code in core/cv/cv.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def get_bgr(colour: str) -> Tuple[float, float, float]:
    """
    Returns the BGR value for a colour supplied as a string.
    Args:
        colour: The colour.

    Returns:
        The BGR value of the colour as a tuple of (B, G, R) in range 0..1

    """
    if colour == 'red':
        return (0.0, 0.0, 1.0)
    elif colour == 'blue':
        return (1.0, 0.0, 0.0)
    elif colour == 'green':
        return (0.0, 1.0 ,0.0)
    elif colour == 'yellow':
        return (0.0, 1.0, 1.0)
    elif colour == 'orange':
        return (0.0, 0.5, 1.0)
    elif colour == 'white':
        return (1.0, 1.0, 1.0)

get_colour(image_hsv, face, bounding_box)

Creates an array of TileColors described in the passed image. More specifically, this function fills in values of the face which are not yet assigned and tries to reconstruct them.

Parameters:

Name Type Description Default
image_hsv ndarray

An image in HSV color space containing a face of a Rubik's cube.

required
face ndarray

The assigned face values so far.

required
bounding_box Tuple[int, int, int, int]

The bounding box containing all cube segments.

required

Returns:

Type Description
ndarray

A 3x3 numpy array containing the TileColors of the pictured cube.

Source code in core/cv/cv.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def get_colour(image_hsv: np.ndarray,
               face: np.ndarray,
               bounding_box: Tuple[int, int, int, int]) -> np.ndarray:
    """
    Creates an array of TileColors described in the passed image.
    More specifically, this function fills in values of the face which are not yet assigned and tries to reconstruct them.
    Args:
        image_hsv: An image in HSV color space containing a face of a Rubik's cube.
        face: The assigned face values so far.
        bounding_box: The bounding box containing all cube segments.

    Returns:
        A 3x3 numpy array containing the TileColors of the pictured cube.
    """
    width, height = bounding_box[2], bounding_box[3]
    segment_width, segment_height = width / 3.0, height / 3.0

    missing_segments = np.argwhere(face == -1)

    for location in missing_segments:

        image_loc_y = int(bounding_box[1] + (location[0] + 0.5) * segment_height)
        image_loc_x = int(bounding_box[0] + (location[1] + 0.5) * segment_width)
        picked_colour = image_hsv[image_loc_y, image_loc_x]

        for colour in COLOURS:
            if mask_colour(np.array([[picked_colour]]), colour)[0][0]:
                face[location[0], location[1]] = cube.TileColor.from_string(colour)

        if face[location[0], location[1]] == -1:
            raise ValueError(
                "Tried to recover colour at " + str(image_loc_x) + " " + str(image_loc_y) + " but got: " + str(
                    picked_colour))

    return face

is_square_box(box)

Returns whether or not a box is square-like.

Parameters:

Name Type Description Default
box Tuple[int, int, int, int]

The bounding box in question. The rectangle is defined as the opencv rectangle -- a tuple of (xmin, ymin, width, height).

required

Returns:

Type Description
bool

A bool, indicating whether or not the supplied box is square-like.

Source code in core/cv/cv.py
51
52
53
54
55
56
57
58
59
60
61
def is_square_box(box: Tuple[int, int, int, int]) -> bool:
    """
    Returns whether or not a box is square-like.
    Args:
        box: The bounding box in question.
            The rectangle is defined as the opencv rectangle -- a tuple of `(xmin, ymin, width, height)`.

    Returns:
        A bool, indicating whether or not the supplied box is square-like.
    """
    return abs((box[2] / box[3]) - 1.0) < EPSILON

is_square_contour(contour, width, height)

Returns whether or not a contour is square-like. The checks are based upon:

- the area of the contour is similar to the area of the bounding box
- the aspect ratio of the bounding box is close to 1 (a square)
- the width and height are within an expected range

Parameters:

Name Type Description Default
contour List[Tuple[int, int]]

The contour in question. The contour is defined as the opencv contour -- a list of points (tuples) of (x,y).

required

Returns:

Type Description
bool

A bool, indicating whether or not the supplied contour is square-like.

Source code in core/cv/cv.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def is_square_contour(contour: List[Tuple[int, int]], width: int, height: int) -> bool:
    """
    Returns whether or not a contour is square-like.
    The checks are based upon:

        - the area of the contour is similar to the area of the bounding box
        - the aspect ratio of the bounding box is close to 1 (a square)
        - the width and height are within an expected range

    Args:
        contour: The contour in question.
            The contour is defined as the opencv contour -- a list of points (tuples) of `(x,y)`.

    Returns:
        A bool, indicating whether or not the supplied contour is square-like.
    """
    contour_area = cv2.contourArea(contour)
    if contour_area == 0.0:
        return False

    rect = cv2.minAreaRect(contour)

    area_measure = (rect[1][0] * rect[1][1]) / contour_area
    aspect_ratio_measure = rect[1][0] / rect[1][1]

    estimated_side_length = np.sqrt(contour_area)

    return (min(width, height) * MIN_SIDE_LENGTH_RATIO) < estimated_side_length < (min(width, height) * MAX_SIDE_LENGTH_RATIO) and \
           (abs(area_measure - 1.0) < EPSILON) and \
           (abs(aspect_ratio_measure - 1.0) < EPSILON)

mask_colour(image_hsv, colour)

Masks the supplied image with the supplied colour according to COLOUR_RANGES.

Parameters:

Name Type Description Default
image_hsv ndarray

The image (HSV) to be masked.

required
colour str

The colour to mask with.

required

Returns:

Type Description
ndarray

The masked image.

Source code in core/cv/cv.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def mask_colour(image_hsv: np.ndarray, colour: str) -> np.ndarray:
    """
    Masks the supplied image with the supplied colour according to `COLOUR_RANGES`.
    Args:
        image_hsv: The image (HSV) to be masked.
        colour: The colour to mask with.

    Returns:
        The masked image.

    """
    if colour == 'red':
        return cv2.bitwise_or(
            cv2.inRange(image_hsv, COLOUR_RANGES[colour + '_min_0'], COLOUR_RANGES[colour + '_max_0']),
            cv2.inRange(image_hsv, COLOUR_RANGES[colour + '_min_1'], COLOUR_RANGES[colour + '_max_1']))

    return cv2.inRange(image_hsv, COLOUR_RANGES[colour + '_min'], COLOUR_RANGES[colour + '_max'])

process_images(face_images, use_kmeans=True)

Processes the images of each face of the cube to a 3x3 numpy array containing the respective TileColors.

Parameters:

Name Type Description Default
face_images List[numpy.ndarray]

A list of numpy arrays (BGR images) with one side of the cube shown in each of the image. Six images are expected in total.

required
use_kmeans bool

Flag, indicating whether or not use the kmeans clustering algorithm and corresponding computer to assign the colours. Otherwise, use simpler approach using HSV colour ranges.

True

Returns:

Type Description
List[numpy.ndarray]

A list of 3x3 numpy arrays containing the TileColors of the respective image.

Source code in core/cv/cv.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
def process_images(face_images: List[np.ndarray],
                   use_kmeans: bool = True) -> List[np.ndarray]:
    """
    Processes the images of each face of the cube to a 3x3 numpy array containing the respective TileColors.
    Args:
        face_images: A list of numpy arrays (BGR images) with one side of the cube shown in each of the image.
            Six images are expected in total.
        use_kmeans: Flag, indicating whether or not use the kmeans clustering algorithm and corresponding computer
            to assign the colours. Otherwise, use simpler approach using HSV colour ranges.

    Returns:
        A list of 3x3 numpy arrays containing the TileColors of the respective image.
    """
    assert len(face_images) == len(cube.FacePosition), f"Expected {len(cube.FacePosition)} face images but got: {len(face_images)}"
    for idx, img in enumerate(face_images):
        filename = os.path.join(tempfile.gettempdir(), "img_" + str(idx) + ".png")
        cv2.imwrite(filename, img)

    if not use_kmeans:
        faces = list(map(find_colours, face_images))
    else:
        faces = kmeans_color_detection(face_images)

    logger.debug(faces)

    assert len(faces) == len(cube.FacePosition)
    flattened = np.array(faces).flatten()
    assert (len(flattened) == cube.Cube3D.NR_SEGMENTS_PER_FACE * len(cube.FacePosition))
    for color in cube.TileColor:
        if color is not cube.TileColor.NONE:
            assert (len(np.where(flattened == color)[0]) == cube.Cube3D.NR_SEGMENTS_PER_FACE), \
                f'Have too many / too few tiles for colour {cube.FacePosition.from_tile_color(color).to_string_notation()}: {len(np.where(flattened == color)[0])}'

    return faces

sort_mass_centres(mass_centre)

Function to sort mass centres by.

Parameters:

Name Type Description Default
mass_centre Tuple[float, float]

The supplied mass centre.

required

Returns:

Type Description
int

The value used for sorting.

Source code in core/cv/cv.py
139
140
141
142
143
144
145
146
147
148
149
def sort_mass_centres(mass_centre: Tuple[float, float]) -> int:
    """
    Function to sort mass centres by.
    Args:
        mass_centre: The supplied mass centre.

    Returns:
        The value used for sorting.
    """
    x, y = mass_centre[0]
    return x + 3 * y

to_array(image_hsv, coloured_contours, bounding_box)

Converts a list of coloured contours to a 3x3 numpy array which contains the TileColors depicted in the image.

Parameters:

Name Type Description Default
image_hsv ndarray

The input image containing a face of a cube in HSV color space.

required
coloured_contours List[Tuple[List[Tuple[int, int]], str]]

A list of contours and their respective colour.

required
bounding_box Tuple[int, int, int, int]

The bounding box containing the contours.

required

Returns:

Type Description
ndarray

A 3x3 numpy array containing the TileColors of the pictured cube.

Source code in core/cv/cv.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def to_array(image_hsv: np.ndarray,
             coloured_contours: List[Tuple[List[Tuple[int, int]], str]],
             bounding_box: Tuple[int, int, int, int]) -> np.ndarray:
    """
    Converts a list of coloured contours to a 3x3 numpy array which contains the TileColors depicted in the image.
    Args:
        image_hsv: The input image containing a face of a cube in HSV color space.
        coloured_contours: A list of contours and their respective colour.
        bounding_box: The bounding box containing the contours.

    Returns:
        A 3x3 numpy array containing the TileColors of the pictured cube.
    """
    mass_centres = []
    for (contour, colour) in coloured_contours:
        m = cv2.moments(contour)
        mass_centre = (m['m10'] / m['m00'], m['m01'] / m['m00'])
        mass_centres.append((mass_centre, cube.TileColor.from_string(colour)))

    sorted_mass_centres = sorted(mass_centres, key=sort_mass_centres)

    face = np.full((3, 3), dtype=cube.TileColor, fill_value=-1)
    if len(sorted_mass_centres) <= SEGMENTS_PER_FACE and is_square_box(bounding_box):
        # we can possibly reconstruct the lost faces

        width, height = bounding_box[2], bounding_box[3]
        segment_width, segment_height = width / 3.0, height / 3.0
        for (mass_centre, colour) in sorted_mass_centres:
            face = assign_colour(face, bounding_box, mass_centre, colour, segment_width, segment_height)

        face = get_colour(image_hsv, face, bounding_box)

    elif len(sorted_mass_centres) > SEGMENTS_PER_FACE:
        raise ValueError("Found too many segments")

    elif not is_square_box(bounding_box):
        raise ValueError("Found segments are not bounded by a square box")

    return face