Ответ 1
прелиминарии
Некоторые предварительные коды:
%matplotlib inline
%load_ext Cython
import numpy as np
import cv2
from matplotlib import pyplot as plt
import skimage as sk
import skimage.morphology as skm
import itertools
def ShowImage(title,img,ctype):
plt.figure(figsize=(20, 20))
if ctype=='bgr':
b,g,r = cv2.split(img) # get b,g,r
rgb_img = cv2.merge([r,g,b]) # switch it to rgb
plt.imshow(rgb_img)
elif ctype=='hsv':
rgb = cv2.cvtColor(img,cv2.COLOR_HSV2RGB)
plt.imshow(rgb)
elif ctype=='gray':
plt.imshow(img,cmap='gray')
elif ctype=='rgb':
plt.imshow(img)
else:
raise Exception("Unknown colour type")
plt.axis('off')
plt.title(title)
plt.show()
Для справки, здесь ваше оригинальное изображение:
#Read in image
img = cv2.imread('part.jpg')
ShowImage('Original',img,'bgr')
Идентификация номеров
Чтобы упростить ситуацию, мы хотим классифицировать пиксели как входящие или выключенные. Мы можем сделать это с помощью порогового значения. Поскольку наш образ содержит два ясных класса пикселей (черно-белый), мы можем использовать метод Otsu. Мы инвертируем цветовую схему, так как библиотеки, которые мы используем, рассматривают черные пиксели, скучные и интересные белые пиксели.
#Convert image to grayscale
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
#Apply Otsu method to eliminate pixels of intermediate colour
ret, thresh = cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
ShowImage('Applying Otsu',thresh,'gray')
#Verify that pixels are either black or white and nothing in between
np.unique(thresh)
Наша стратегия будет заключаться в том, чтобы находить номера, а затем следовать по ряду (линиям) рядом с ними по частям, а затем маркировать эти части. Поскольку, как удобно, все арабские цифры формируются из смежных пикселей, мы можем начать с поиска связанных компонентов.
ret, components = cv2.connectedComponents(thresh)
#Each component is a different colour
ShowImage('Connected Components', components, 'rgb')
Затем мы можем фильтровать связанные компоненты, чтобы найти числа, фильтруя для измерения. Обратите внимание, что это не очень надежный способ сделать это. Лучшим вариантом будет использование распознавания символов, но это остается как упражнение для читателя :-)
class Box:
def __init__(self,x0,x1,y0,y1):
self.x0, self.x1, self.y0, self.y1 = x0,x1,y0,y1
def overlaps(self,box2,tol):
if self.x0 is None or box2.x0 is None:
return False
return not (self.x1+tol<=box2.x0 or self.x0-tol>=box2.x1 or self.y1+tol<=box2.y0 or self.y0-tol>=box2.y1)
def merge(self,box2):
self.x0 = min(self.x0,box2.x0)
self.x1 = max(self.x1,box2.x1)
self.y0 = min(self.y0,box2.y0)
self.y1 = max(self.y1,box2.y1)
box2.x0 = None #Used to mark 'box2' as being no longer valid. It can be removed later
def dist(self,x,y):
#Get center point
ax = (self.x0+self.x1)/2
ay = (self.y0+self.y1)/2
#Get distance to center point
return np.sqrt((ax-x)**2+(ay-y)**2)
def good(self):
return not (self.x0 is None)
def ExtractComponent(original_image, component_matrix, component_number):
"""Extracts a component from a ConnectedComponents matrix"""
#Create a true-false matrix indicating if a pixel is part of a particular component
is_component = component_matrix==component_number
#Find the coordinates of those pixels
coords = np.argwhere(is_component)
# Bounding box of non-black pixels.
y0, x0 = coords.min(axis=0)
y1, x1 = coords.max(axis=0) + 1 # slices are exclusive at the top
# Get the contents of the bounding box.
return x0,x1,y0,y1,original_image[y0:y1, x0:x1]
numbers_img = thresh.copy() #This is used purely to show that we can identify numbers
numbers = []
for component in range(components.max()):
tx0,tx1,ty0,ty1,this_component = ExtractComponent(thresh, components, component)
#ShowImage('Component #{0}'.format(component), this_component, 'gray')
cheight, cwidth = this_component.shape
#print(cwidth,cheight) #Enable this to see dimensions
#Identify numbers based on aspect ratio
if (abs(cwidth-14)<3 or abs(cwidth-7)<3) and abs(cheight-24)<3:
numbers_img[ty0:ty1,tx0:tx1] = 128
numbers.append(Box(tx0,tx1,ty0,ty1))
ShowImage('Numbers', numbers_img, 'gray')
Теперь мы соединяем числа в смежные блоки, слегка расширяя их ограничивающие прямоугольники и просматривая перекрытия.
#This is kind of a silly way to do this, but it will work find for small quantities (hundreds)
merged=True #If true, then a merge happened this round
while merged: #Continue until there are no more mergers
merged=False #Reset merge indicator
for a,b in itertools.combinations(numbers,2): #Consider all pairs of numbers
if a.overlaps(b,10): #If this pair overlaps
a.merge(b) #Merge it
merged=True #Make a note that we've merged
numbers = [x for x in numbers if x.good()] #Eliminate those boxes that were gobbled by the mergers
#This is used purely to show that we can identify numbers
numbers_img = thresh.copy()
for n in numbers:
numbers_img[n.y0:n.y1,n.x0:n.x1] = 128
thresh[n.y0:n.y1,n.x0:n.x1] = 0 #Drop numbers from thresholded image
ShowImage('Numbers', numbers_img, 'gray')
Хорошо, теперь мы определили цифры! Мы будем использовать их позже для идентификации деталей.
Идентификация стрелок
Затем мы хотим выяснить, на какие части указывают цифры. Для этого мы хотим обнаружить линии. Преобразование Хью хорошо для этого. Чтобы уменьшить количество ложных срабатываний, мы скелетонизируем данные, которые преобразуют его в представление шириной не более одного пикселя.
skel = sk.img_as_ubyte(skm.skeletonize(thresh>0))
ShowImage('Skeleton', skel, 'gray')
Теперь мы выполняем преобразование Хафа. Мы ищем тот, который идентифицирует все строки, идущие от чисел к частям. Получение этого права может занять некоторое время с параметрами.
lines = cv2.HoughLinesP(
skel,
1, #Resolution of r in pixels
np.pi / 180, #Resolution of theta in radians
30, #Minimum number of intersections to detect a line
None,
80, #Min line length
10 #Max line gap
)
lines = [x[0] for x in lines]
line_img = thresh.copy()
line_img = cv2.cvtColor(line_img, cv2.COLOR_GRAY2BGR)
for l in lines:
color = tuple(map(int, np.random.randint(low=0, high=255, size=3)))
cv2.line(line_img, (l[0], l[1]), (l[2], l[3]), color, 3, cv2.LINE_AA)
ShowImage('Lines', line_img, 'bgr')
Теперь мы хотим найти линию или строки, которые ближе всего к каждому числу, и сохранить только эти. Мы по существу отфильтровываем все строки, которые не являются стрелками. Для этого мы сравниваем конечные точки каждой строки с центральной точкой каждого номера.
comp_labels = np.zeros(img.shape[0:2], dtype=np.uint8)
for n_idx,n in enumerate(numbers):
distvals = []
for i,l in enumerate(lines):
#Distances from each point of line to midpoint of rectangle
dists = [n.dist(l[0],l[1]),n.dist(l[2],l[3])]
#Minimum distance and the end point (0 or 1) of the line associated with that point
#Tuples of (Line Number, Line Point, Dist to Line Point) are produced
distvals.append( (i,np.argmin(dists),np.min(dists)) )
#Sort by distance between the number box and the line
distvals = sorted(distvals, key=lambda x: x[2])
#Include nearby lines, not just the closest one. This accounts for forking.
distvals = [x for x in distvals if x[2]<1.5*distvals[0][2]]
#Draw a white rectangle where the number box was
cv2.rectangle(comp_labels, (n.x0,n.y0), (n.x1,n.y1), 1, cv2.FILLED)
#Draw white lines where the arrows are
for dv in distvals:
l = lines[dv[0]]
lp = (l[0],l[1]) if dv[1]==0 else (l[2],l[3])
cv2.line(comp_labels, (l[0], l[1]), (l[2], l[3]), 1, 3, cv2.LINE_AA)
cv2.line(comp_labels, (lp[0], lp[1]), ((n.x0+n.x1)//2, (n.y0+n.y1)//2), 1, 3, cv2.LINE_AA)
ShowImage('Lines', comp_labels, 'gray')
Поиск деталей
Эта часть была тяжелой! Теперь мы хотим сегментировать части изображения. Если бы был какой-то способ отключить линии, соединяющие подчасти вместе, это было бы легко. К сожалению, линии, соединяющие подчасти, имеют ту же ширину, что и многие из линий, составляющих части.
Чтобы обойти это, мы могли бы использовать много логики. Это было бы болезненно и подвержено ошибкам.
В качестве альтернативы можно предположить, что у вас есть эксперт в цикле. Эта экспертная единственная задача - разрезать линии, соединяющие подчасти. Это должно быть как легко, так и быстро для них. Маркировка для людей была бы медленной и печальной, но быстро для компьютеров. Разделить вещи легко для людей, но трудно для компьютеров. Поэтому мы позволяем делать то, что они делают лучше всего.
В этом случае вы могли бы обучить кого-то выполнять эту работу через несколько минут, поэтому настоящий "эксперт" на самом деле не нужен. Просто умеренно компетентный человек.
Если вы преследуете это, вам нужно написать эксперта в инструменте цикла. Для этого сохраните скелетные изображения, попросите вашего эксперта изменить их и снова просмотрите скелетонизированные образы.
#Save the image, or display it on a GUI
#cv2.imwrite("/z/skel.png", skel);
#EXPERT DOES THEIR THING HERE
#Read the expert-mediated image back in
skelhuman = cv2.imread('/z/skel.png')
#Convert back to the form we need
skelhuman = cv2.cvtColor(skelhuman,cv2.COLOR_BGR2GRAY)
ret, skelhuman = cv2.threshold(skelhuman,0,255,cv2.THRESH_OTSU)
ShowImage('SkelHuman', skelhuman, 'gray')
Теперь, когда мы разделяем детали, мы удалим как можно больше стрелок. Мы уже извлекли эти выше, поэтому мы можем добавить их позже, если нам нужно.
Чтобы устранить стрелки, мы найдем все строки, которые заканчиваются в других местах, кроме другой. То есть мы найдем пиксели, которые имеют только один соседний пиксель. Затем мы удалим пиксель и посмотрим на его соседа. Выполнение этого итерационно устраняет стрелки. Поскольку я не знаю другого термина для этого, я назову это "Трансформация предохранителей". Поскольку это потребует манипулирования отдельными пикселями, которые были бы слишком медленными в Python, мы напишем преобразование в Cython.
%%cython -a --cplus
import cython
from libcpp.queue cimport queue
import numpy as np
cimport numpy as np
@cython.boundscheck(False)
@cython.wraparound(False)
@cython.nonecheck(False)
@cython.cdivision(True)
cpdef void FuseTransform(unsigned char [:, :] image):
# set the variable extension types
cdef int c, x, y, nx, ny, width, height, neighbours
cdef queue[int] q
# grab the image dimensions
height = image.shape[0]
width = image.shape[1]
cdef int dx[8]
cdef int dy[8]
#Offsets to neighbouring cells
dx[:] = [-1,-1,0,1,1,1,0,-1]
dy[:] = [0,-1,-1,-1,0,1,1,1]
#Find seed cells: those with only one neighbour
for y in range(1, height-1):
for x in range(1, width-1):
if image[y,x]==0: #Seed cells cannot be blank cells
continue
neighbours = 0
for n in range(0,8): #Looks at all neighbours
nx = x+dx[n]
ny = y+dy[n]
if image[ny,nx]>0: #This neighbour has a value
neighbours += 1
if neighbours==1: #Was there only one neighbour?
q.push(y*width+x) #If so, this is a seed cell
#Starting with the seed cells, gobble up the lines
while not q.empty():
c = q.front()
q.pop()
y = c//width #Convert flat index into 2D x-y index
x = c%width
image[y,x] = 0 #Gobble up this part of the fuse
neighbour = -1 #No neighbours yet
for n in range(0,8): #Look at all neighbours
nx = x+dx[n] #Find coordinates of neighbour cells
ny = y+dy[n]
#If the neighbour would be off the side of the matrix, ignore it
if nx<0 or ny<0 or nx==width or ny==height:
continue
if image[ny,nx]>0: #Is the neighbouring cell active?
if neighbour!=-1: #If we've already found an active neighbour
neighbour=-1 #Then pretend we found no neighbours
break #And stop looking. This is the end of the fuse.
else: #Otherwise, make a note of the neighbour index.
neighbour = ny*width+nx
if neighbour!=-1: #If there was only one neighbour
q.push(neighbour) #Continue burning the fuse
Назад в стандартный Python:
#Apply the Fuse Transform
skh_dilated=skelhuman.copy()
FuseTransform(skh_dilated)
ShowImage('Fuse Transform', skh_dilated, 'gray')
Теперь, когда мы устранили все стрелки и линии, соединяющие детали, мы расширяем оставшиеся пиксели.
kernel = np.ones((3,3),np.uint8)
dilated = cv2.dilate(skh_dilated, kernel, iterations=6)
ShowImage('Dilation', dilated, 'gray')
Все вместе
И наложите метки и стрелки, которые мы сегментировали раньше...
comp_labels_dilated = cv2.dilate(comp_labels, kernel, iterations=5)
labels_combined = np.uint8(np.logical_or(comp_labels_dilated,dilated))
ShowImage('Comp Labels', labels_combined, 'gray')
Наконец, мы берем объединенные квадраты чисел, стрелки компонентов, а также детали и цвет каждого из них, используя красивые цвета от Color Brewer. Затем мы накладываем это на исходное изображение, чтобы получить нужную подсветку.
ret, labels = cv2.connectedComponents(labels_combined)
colormask = np.zeros(img.shape, dtype=np.uint8)
#Colors from Color Brewer
colors = [(228,26,28),(55,126,184),(77,175,74),(152,78,163),(255,127,0),(255,255,51),(166,86,40),(247,129,191),(153,153,153)]
for l in range(labels.max()):
if l==0: #Background component
colormask[labels==0] = (255,255,255)
else:
colormask[labels==l] = colors[l]
ShowImage('Comp Labels', colormask, 'bgr')
blended = cv2.addWeighted(img,0.7,colormask,0.3,0)
ShowImage('Blended', blended, 'bgr')
Окончательное изображение
Итак, чтобы повторить, мы идентифицировали числа, стрелки и части. В некоторых случаях мы могли отделить их автоматически. В других случаях мы использовали эксперта в цикле. Там, где нам приходилось манипулировать пикселями по отдельности, мы использовали Cython для скорости.
Конечно, опасность такого рода заключается в том, что какой-то другой образ нарушит (многие) предположения, которые я сделал здесь. Но это риск, который вы принимаете, когда пытаетесь использовать одно изображение, чтобы представить проблему.