465 lines
12 KiB
Python
465 lines
12 KiB
Python
import sys
|
|
import cv2
|
|
import numpy as np
|
|
import math
|
|
|
|
def image_resize(image, width = None, height = None, inter = cv2.INTER_AREA):
|
|
dim = None
|
|
(h, w) = image.shape[:2]
|
|
|
|
if width is None and height is None:
|
|
return image
|
|
|
|
if width is None:
|
|
r = height / float(h)
|
|
dim = (int(w * r), height)
|
|
else:
|
|
r = width / float(w)
|
|
dim = (width, int(h * r))
|
|
resized = cv2.resize(image, dim, interpolation = inter)
|
|
|
|
return resized
|
|
|
|
def display (image) :
|
|
resized = image_resize(image, 800, 800)
|
|
cv2.imshow('img', resized)
|
|
|
|
while cv2.getWindowProperty('img', cv2.WND_PROP_VISIBLE) > 0:
|
|
key = cv2.waitKey(0)
|
|
if key == 27:
|
|
cv2.destroyAllWindows()
|
|
break
|
|
exit(0)
|
|
|
|
def get_center (contour) :
|
|
M = cv2.moments(contour)
|
|
cX = int(M["m10"] / M["m00"])
|
|
cY = int(M["m01"] / M["m00"])
|
|
return cX, cY
|
|
|
|
def draw_line (image, hps, a, b) :
|
|
print(f'{a} -> {b}')
|
|
lA = (hps[a-1]['x'], hps[a-1]['y'])
|
|
lB = (hps[b-1]['x'], hps[b-1]['y'])
|
|
cv2.line(image, lA, lB, [0, 255, 0], 10)
|
|
return (lA, lB)
|
|
|
|
def horiz_angle (line, rotate = 0) :
|
|
deltaY = line[1][1] - line[0][1] #P2_y - P1_y
|
|
deltaX = line[1][0] - line[0][0] #P2_x - P1_x
|
|
|
|
angleInDegrees = normalize_angle(math.degrees(math.atan2(deltaY, deltaX) + rotate))
|
|
return angleInDegrees
|
|
|
|
def verts_angle (line) :
|
|
angleInDegrees = normalize_angle(horiz_angle(line, math.pi/2))
|
|
return angleInDegrees
|
|
|
|
def is_close (point, points) :
|
|
for pt in points :
|
|
if math.dist(point, pt) < 100 :
|
|
return True
|
|
return False
|
|
|
|
# taken from
|
|
# https://gist.github.com/phn/1111712/35e8883de01916f64f7f97da9434622000ac0390
|
|
def normalize_angle (num, lower=0.0, upper=360.0, b=False):
|
|
"""Normalize number to range [lower, upper) or [lower, upper].
|
|
Parameters
|
|
----------
|
|
num : float
|
|
The number to be normalized.
|
|
lower : float
|
|
Lower limit of range. Default is 0.0.
|
|
upper : float
|
|
Upper limit of range. Default is 360.0.
|
|
b : bool
|
|
Type of normalization. See notes.
|
|
Returns
|
|
-------
|
|
n : float
|
|
A number in the range [lower, upper) or [lower, upper].
|
|
Raises
|
|
------
|
|
ValueError
|
|
If lower >= upper.
|
|
Notes
|
|
-----
|
|
If the keyword `b == False`, the default, then the normalization
|
|
is done in the following way. Consider the numbers to be arranged
|
|
in a circle, with the lower and upper marks sitting on top of each
|
|
other. Moving past one limit, takes the number into the beginning
|
|
of the other end. For example, if range is [0 - 360), then 361
|
|
becomes 1. Negative numbers move from higher to lower
|
|
numbers. So, -1 normalized to [0 - 360) becomes 359.
|
|
If the keyword `b == True` then the given number is considered to
|
|
"bounce" between the two limits. So, -91 normalized to [-90, 90],
|
|
becomes -89, instead of 89. In this case the range is [lower,
|
|
upper]. This code is based on the function `fmt_delta` of `TPM`.
|
|
Range must be symmetric about 0 or lower == 0.
|
|
Examples
|
|
--------
|
|
>>> normalize(-270,-180,180)
|
|
90
|
|
>>> import math
|
|
>>> math.degrees(normalize(-2*math.pi,-math.pi,math.pi))
|
|
0.0
|
|
>>> normalize(181,-180,180)
|
|
-179
|
|
>>> normalize(-180,0,360)
|
|
180
|
|
>>> normalize(36,0,24)
|
|
12
|
|
>>> normalize(368.5,-180,180)
|
|
8.5
|
|
>>> normalize(-100, -90, 90, b=True)
|
|
-80.0
|
|
>>> normalize(100, -90, 90, b=True)
|
|
80.0
|
|
>>> normalize(181, -90, 90, b=True)
|
|
-1.0
|
|
>>> normalize(270, -90, 90, b=True)
|
|
-90.0
|
|
"""
|
|
# abs(num + upper) and abs(num - lower) are needed, instead of
|
|
# abs(num), since the lower and upper limits need not be 0. We need
|
|
# to add half size of the range, so that the final result is lower +
|
|
# <value> or upper - <value>, respectively.
|
|
res = num
|
|
if not b:
|
|
if lower >= upper:
|
|
raise ValueError("Invalid lower and upper limits: (%s, %s)" %
|
|
(lower, upper))
|
|
|
|
res = num
|
|
if num > upper or num == lower:
|
|
num = lower + abs(num + upper) % (abs(lower) + abs(upper))
|
|
if num < lower or num == upper:
|
|
num = upper - abs(num - lower) % (abs(lower) + abs(upper))
|
|
|
|
res = lower if res == upper else num
|
|
else:
|
|
total_length = abs(lower) + abs(upper)
|
|
if num < -total_length:
|
|
num += math.ceil(num / (-2 * total_length)) * 2 * total_length
|
|
if num > total_length:
|
|
num -= math.floor(num / (2 * total_length)) * 2 * total_length
|
|
if num > upper:
|
|
num = total_length - num
|
|
if num < lower:
|
|
num = -total_length - num
|
|
|
|
res = num * 1.0 # Make all numbers float, to be consistent
|
|
|
|
return res
|
|
|
|
def mean (lst):
|
|
return sum(lst) / len(lst)
|
|
|
|
def find_hole_punches (img) :
|
|
left=-1
|
|
right=-1
|
|
top=-1
|
|
bottom=-1
|
|
|
|
if orientation :
|
|
left = width * 0.2
|
|
right = width * 0.8
|
|
else :
|
|
top = height * 0.2
|
|
bottom = height * 0.8
|
|
|
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
blur = cv2.medianBlur(gray, 31)
|
|
ret, thresh = cv2.threshold(blur, 200, 255, cv2.THRESH_BINARY)
|
|
canny = cv2.Canny(thresh, 75, 200)
|
|
contours, hierarchy = cv2.findContours(canny, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)
|
|
|
|
contourList = []
|
|
areaList = []
|
|
for contour in contours:
|
|
approx = cv2.approxPolyDP(contour, 0.03 * cv2.arcLength(contour, True), True)
|
|
if cv2.isContourConvex(approx) :
|
|
cX, cY = get_center(contour)
|
|
if (orientation and ( cX < left or cX > right) ) or ( not orientation and ( cY < top or cY > bottom)) :
|
|
area = cv2.contourArea(contour)
|
|
areaList.append(area)
|
|
contourList.append(contour)
|
|
|
|
maxArea=0
|
|
maxIndex=0
|
|
|
|
#reduce to lambda
|
|
for i in range(len(areaList)) :
|
|
area = areaList[i]
|
|
if area > maxArea:
|
|
maxArea = area
|
|
maxIndex = i
|
|
|
|
count = 0
|
|
holePunches = []
|
|
centers = []
|
|
areaRange = 0
|
|
topLeft = None
|
|
minDist = 1000000
|
|
|
|
# pretty good
|
|
# add position constraint
|
|
|
|
while count < 6 :
|
|
areaRange+=1
|
|
for i in range(len(areaList)) :
|
|
area = areaList[i]
|
|
if area == maxArea or area * ((100 + areaRange) / 100) > maxArea :
|
|
cX, cY = get_center(contourList[i])
|
|
if is_close((cX, cY), centers) :
|
|
continue
|
|
centers.append((cX, cY))
|
|
print(f'{cX},{cY}')
|
|
hp = {
|
|
'x' : cX,
|
|
'y' : cY,
|
|
'contour' : contourList[i],
|
|
'dist' : math.dist((cX, cY), (0, 0)),
|
|
'order': -1
|
|
}
|
|
if hp['dist'] < minDist :
|
|
minDist = hp['dist']
|
|
topLeft = hp
|
|
holePunches.append(hp)
|
|
count+=1
|
|
|
|
for hp in holePunches :
|
|
hp['dist'] = math.dist( (topLeft['x'], topLeft['y']), (hp['x'], hp['y']) )
|
|
|
|
print(f'Hole punches: {len(holePunches)}')
|
|
print(f'Found hole punches within {areaRange}% of largest')
|
|
|
|
if len(holePunches) != 6:
|
|
print(f'Wrong number of hole punches, exiting...')
|
|
exit(4)
|
|
|
|
holePunches = sorted(holePunches, key = lambda hp: hp['dist'])
|
|
|
|
i = 0
|
|
for hp in holePunches :
|
|
hp['order'] = i
|
|
#cv2.putText(img, str(i + 1), (hp['x'], hp['y']), cv2.FONT_HERSHEY_SIMPLEX, 20, (0, 0, 255), 5, cv2.LINE_AA, False)
|
|
i+=1
|
|
|
|
return holePunches
|
|
|
|
def correct_rotation (img, original, holePunches) :
|
|
horizLines = [
|
|
(3, 1),
|
|
(6, 4),
|
|
(5, 2)
|
|
]
|
|
vertsLines = [
|
|
(1, 2),
|
|
(1, 4), #double long left
|
|
(1, 4), #
|
|
(3, 5),
|
|
(3, 6), #double long right
|
|
(3, 6), #
|
|
(2, 4),
|
|
(5, 6)
|
|
]
|
|
|
|
rotations = []
|
|
|
|
for h in horizLines :
|
|
line = draw_line(img, holePunches, h[0], h[1])
|
|
angle = horiz_angle(line)
|
|
print(angle)
|
|
rotations.append(angle)
|
|
|
|
for v in vertsLines :
|
|
line = draw_line(img, holePunches, v[0], v[1])
|
|
angle = verts_angle(line)
|
|
print(angle)
|
|
rotations.append(angle)
|
|
|
|
correctionRotation = mean(rotations) - 180
|
|
print(f'Mean rotation: {correctionRotation}')
|
|
|
|
(cX, cY) = (width // 2, height // 2)
|
|
M = cv2.getRotationMatrix2D((cX, cY), correctionRotation, 1.0)
|
|
|
|
#create rotation of original
|
|
return cv2.warpAffine(original, M, (width, height))
|
|
|
|
def create_blank (w, h, rgb_color = (255, 255, 255)) :
|
|
blank = np.zeros([h, w, 3], dtype=np.uint8)
|
|
color = tuple(reversed(rgb_color))
|
|
blank[:] = color
|
|
return blank
|
|
|
|
def get_mean_rect (holePunches) :
|
|
left = 0
|
|
right = 0
|
|
top = 0
|
|
bottom = 0
|
|
for hp in holePunches :
|
|
if hp['order'] == 0 :
|
|
left += float(hp['x'])
|
|
top += float(hp['y'])
|
|
elif hp['order'] == 2 :
|
|
right += float(hp['x'])
|
|
top += float(hp['y'])
|
|
elif hp['order'] == 3 :
|
|
left += float(hp['x'])
|
|
bottom += float(hp['y'])
|
|
elif hp['order'] == 5 :
|
|
right += float(hp['x'])
|
|
bottom += float(hp['y'])
|
|
w = round((right / 2.0) - (left / 2.0))
|
|
h = round((bottom / 2.0) - (top / 2.0))
|
|
return (w, h)
|
|
|
|
|
|
def center_within (larger, smaller) :
|
|
w1 = larger[0]
|
|
h1 = larger[1]
|
|
w2 = smaller[0]
|
|
h2 = smaller[1]
|
|
x = ((w1 - w2) / 2)
|
|
y = ((h1 - h2) / 2)
|
|
return (int(x), int(y))
|
|
|
|
# If we consider (0,0) as top left corner of image called
|
|
# im with left-to-right as x direction and top-to-bottom
|
|
# as y direction. and we have (x1,y1) as the top-left vertex
|
|
# and (x2,y2) as the bottom-right vertex of a rectangle
|
|
# region within that image, then:
|
|
#
|
|
# roi = im[y1:y2, x1:x2]
|
|
|
|
def crop (img, xoffset, yoffset, w, h) :
|
|
#crop_img = img[y:y+h, x:x+w].copy()
|
|
return im[yoffset:yoffset+w, xoffset:xoffset+w].copy()
|
|
|
|
def normalize_image(blank, rotated, offset, tl) :
|
|
rotatedHeight, rotatedWidth = rotated.shape[:2]
|
|
normalHeight, width = blank.shape[:2]
|
|
|
|
diffX = offset[0] - tl["x"]
|
|
diffY = offset[1] - tl["y"]
|
|
|
|
print(f'diffX : {diffX}')
|
|
print(f'diffY : {diffY}')
|
|
|
|
crop = rotated.copy()
|
|
if diffX < 0 :
|
|
crop = crop[0:rotatedHeight, abs(diffX):rotatedWidth]
|
|
rotatedHeight, rotatedWidth = crop.shape[:2]
|
|
print('Cropped X')
|
|
print(f'Rotated: {rotatedWidth},{rotatedHeight}')
|
|
diffX = 0
|
|
|
|
if diffY < 0 :
|
|
crop = crop[abs(diffY):rotatedHeight, 0:rotatedWidth]
|
|
rotatedHeight, rotatedWidth = crop.shape[:2]
|
|
print('Cropped Y')
|
|
print(f'Rotated: {rotatedWidth},{rotatedHeight}')
|
|
diffY = 0
|
|
|
|
if rotatedWidth > width :
|
|
crop = crop[0:rotatedHeight, 0:rotatedWidth-(rotatedWidth - width)]
|
|
rotatedHeight, rotatedWidth = crop.shape[:2]
|
|
print('Cropped X')
|
|
print(f'Rotated: {rotatedWidth},{rotatedHeight}')
|
|
|
|
if rotatedHeight > normalHeight :
|
|
crop = crop[0:rotatedHeight-(rotatedHeight - normalHeight), 0:width]
|
|
rotatedHeight, rotatedWidth = crop.shape[:2]
|
|
print('Cropped Y')
|
|
print(f'Rotated: {rotatedWidth},{rotatedHeight}')
|
|
|
|
print(f'diffX : {diffX}')
|
|
print(f'diffY : {diffY}')
|
|
|
|
print(f'Rotated: {rotatedWidth},{rotatedHeight}')
|
|
print(f'Blank : {width},{normalHeight}')
|
|
|
|
cropHeight = normalHeight
|
|
cropWidth = width
|
|
|
|
if normalHeight > rotatedHeight :
|
|
cropHeight = rotatedHeight
|
|
|
|
if width > rotatedWidth :
|
|
cropWidth = rotatedWidth
|
|
|
|
blank[diffY:cropHeight, diffX:cropWidth] = crop[0:cropHeight-diffY, 0:cropWidth - diffX]
|
|
|
|
return blank
|
|
|
|
if len(sys.argv) < 2:
|
|
print('Please provide path of scan to normalize')
|
|
exit(1)
|
|
|
|
if len(sys.argv) < 3:
|
|
print('Please provide path to output file')
|
|
exit(2)
|
|
|
|
scanImage = sys.argv[-2]
|
|
normalImage = sys.argv[-1]
|
|
pageDim = (11, 8.5)
|
|
pageRatio = pageDim[1] / pageDim[0]
|
|
|
|
print(f'Normalizing {scanImage} as {normalImage}')
|
|
|
|
original = cv2.imread(scanImage)
|
|
img = original.copy()
|
|
|
|
height, width = img.shape[:2]
|
|
orientation = height > width
|
|
if not orientation :
|
|
print(f'Scan is not in portrait mode, exiting...')
|
|
exit(3)
|
|
|
|
normalHeight = round(float(width) / pageRatio)
|
|
|
|
holePunches = find_hole_punches(img)
|
|
rotated = correct_rotation(img, original, holePunches)
|
|
rotatedHeight, rotatedWidth = rotated.shape[:2]
|
|
|
|
holePunches = find_hole_punches(rotated)
|
|
blank = create_blank(width, normalHeight)
|
|
|
|
tl = None
|
|
for hp in holePunches :
|
|
if tl is None :
|
|
tl = hp
|
|
print(f'{hp["order"] + 1} {hp["x"]},{hp["y"]}')
|
|
|
|
print(f'Normal dimensions: {width},{normalHeight}')
|
|
|
|
# the mean rectangle is the average width and height
|
|
# determined by the four corner hole punches
|
|
meanRect = get_mean_rect(holePunches)
|
|
print(f'Mean rectangle: {meanRect[0]},{meanRect[1]}')
|
|
|
|
# offset is the position within the new normal image
|
|
# the top left hole punch should be centered to
|
|
offset = center_within((width, normalHeight), meanRect)
|
|
print(f'Offset : {offset[0]},{offset[1]}')
|
|
print(f'Topleft: {tl["x"]},{tl["y"]}')
|
|
print(f'Rotated: {rotatedWidth},{rotatedHeight}')
|
|
print(f'Blank : {width},{normalHeight}')
|
|
|
|
#cv2.rectangle(blank, offset, (offset[0]+meanRect[0], offset[1]+meanRect[1]), (255, 0, 0), thickness=20)
|
|
|
|
normal = normalize_image(blank, rotated, offset, tl)
|
|
|
|
print(f'Writing normalized image to {normalImage}')
|
|
cv2.imwrite(normalImage, normal)
|
|
|
|
evaluation = find_hole_punches(normal)
|
|
|
|
with open(f'{normalImage}.txt', 'w') as evalFile :
|
|
for hp in evaluation:
|
|
evalFile.write(f'{hp["order"] + 1} : {hp["x"]},{hp["y"]}\n')
|
|
|
|
#display(normal) |