"""Automatic search of the spinning disk's circle"""
from . import filters
import cv2
from numpy import ndarray
from pathlib import Path
from math import log2
from math import ceil
from logging import getLogger
log = getLogger(__name__)
[docs]
def find(
image: ndarray,
center_search_ratio: float = 0.05,
radius_ratio_min: float = 0.5,
radius_ratio_max: float = 0.4,
min_scaled_edge: int = 512,
) -> tuple[int, int, int]:
"""
Finds the outside circle in the image.
Returns center coordinates `x` and `y` as well as radius `r` of the
circle, all in pixels.
The radius is searched between `radius_ratio_min` and
`radius_ratio_max` relative to the shorter edge of the input image.
The circle center is searched in a region of relative edge length
`center_search_ratio` relative to the image width or height.
To speed up the search, we use a pyramid of progressively
down-scaled images. We stop down-scaling once the shorter edge
(of width or height) get below `min_scaled_edge`.
"""
log.info('Determining position of circle in image.')
(height, width) = image.shape
short_edge = min(width, height)
max_scale = ceil(log2(short_edge/min_scaled_edge))
x_max = 0
y_max = 0
r_max = 0
d_max = -1
for scale in reversed(range(max_scale + 1)):
if scale > 0:
w = int(width / 2**scale)
h = int(height / 2**scale)
scaled = cv2.resize(image, (w, h), interpolation=cv2.INTER_AREA)
log.debug(f'Searching in image down-scaled to {w}×{h} pixels.')
else:
(w, h) = (width, height)
scaled = image
log.debug(f'Searching in original image {w}×{h} in pixels.')
if scale == max_scale:
x1 = int(w/2 - w * center_search_ratio)
x2 = int(w/2 + w * center_search_ratio)
y1 = int(h/2 - h * center_search_ratio)
y2 = int(h/2 + h * center_search_ratio)
r1 = int(min(w, h) * radius_ratio_max)
r2 = int(min(w, h) * radius_ratio_min)
else:
x1 = 2*x_max - 4
x2 = 2*x_max + 4
y1 = 2*y_max - 4
y2 = 2*y_max + 4
r1 = 2*r_max - 4
r2 = 2*r_max + 4
log.debug(f'Searching in x = {x1} … {x2}.')
log.debug(f'Searching in y = {y1} … {y2}.')
log.debug(f'Searching in r = {r1} … {r2}.')
x_max = 0
y_max = 0
r_max = 0
d_max = -1
for x in range(x1, x2+1):
for y in range(y1, y2+1):
for r in range(r1, r2+1):
if x-r > 1 and x+r < w-1 and y-r > 1 and y+r < h-1:
density = edge_density(x, y, r, scaled)
if density < d_max or d_max < 0:
x_max = x
y_max = y
r_max = r
d_max = density
log.debug(f'Found maximum at (x, y, r) = ({x_max}, {y_max}, {r_max}).')
log.debug(f'The density is {d_max}.')
x = x_max
y = y_max
r = r_max
log.info(f'Found circle of radius {r} centered at ({x}, {y}).')
return (x, y, r)
[docs]
def draw(canvas: ndarray, x: float, y: float, r: float) -> ndarray:
"""
Draws the circle at `(x, y)` with radius `r` onto the image `canvas`.
This is meant for visual inspection.
"""
log.info('Drawing circle onto image.')
image = filters.black_on_white(canvas.copy())
black = 0
cv2.circle(image, (x, y), r, color=black, thickness=4)
return image
[docs]
def write(file: Path, x: float, y: float, r: float):
"""Writes circle position `(x, y)` and radius `r` to given `file`."""
log.info('Writing circle position to file.')
with file.open('w') as stream:
stream.write(f'centre_x\t{x}\n')
stream.write(f'centre_y\t{y}\n')
stream.write(f'radius\t{r}\n')
log.debug(f'Wrote (x, y, r) = ({x}, {y}, {r}) to "{file}".')
[docs]
def read(file: Path) -> tuple[int, int, int]:
"""
Reads circle position from given file.
Returns center coordinates `x` and `y` as well as radius `r` of the
circle, all in pixels.
"""
log.info('Reading circle position from file.')
x = y = r = None
with file.open() as stream:
for line in stream:
(key, value) = line.split('\t')
if key == 'centre_x':
x = int(value)
elif key == 'centre_y':
y = int(value)
elif key == 'radius':
r = int(value)
if x is None:
raise ValueError(f'Did not find value for "centre_x" in "{file}".')
if y is None:
raise ValueError(f'Did not find value for "centre_y" in "{file}".')
if r is None:
raise ValueError(f'Did not find value for "radius" in "{file}".')
log.debug(f'Read (x, y, r) = ({x}, {y}, {r}) from "{file}".')
return (x, y, r)
[docs]
def edge_density(x0: float, y0: float, r: float, image: ndarray) -> float:
"""
Measures the "color density" along the edge of a given circle.
This function helps in finding the circle by estimating how much
contrast is visible along the edge for any given guess as to where
and how large the circle might be. The circle is centered at
`(x0, y0`) and has radius `r`.
"""
# Note: This function might be a performance bottleneck that could
# be accelerated with Numba.
total_color = 0
total_count = 0
f = 1 - r
ddf_x = 1
ddf_y = -2 * r
x = 0
y = r
total_color += image[y0 + r, x0]
total_color += image[y0 - r, x0]
total_color += image[y0, x0 + r]
total_color += image[y0, x0 - r]
total_count += 4
while x < y:
if f >= 0:
y -= 1
ddf_y += 2
f += ddf_y
x += 1
ddf_x += 2
f += ddf_x
total_color += image[y0 + y, x0 + x]
total_color += image[y0 + y, x0 - x]
total_color += image[y0 - y, x0 + x]
total_color += image[y0 - y, x0 - x]
total_color += image[y0 + x, x0 + y]
total_color += image[y0 + x, x0 - y]
total_color += image[y0 - x, x0 + y]
total_color += image[y0 - x, x0 - y]
total_count += 8
return total_color / total_count