animation/fourcell/normalize.py

370 lines
9.4 KiB
Python

import argparse
import sys
import cv2
import numpy as np
import math
from json import dumps
from os.path import exists
from common import image_resize, display, normalize_angle
#clockwise from top left
order = [ 1, 3, 4, 6, 5, 2 ]
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
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 simplify_hole_punches (holePunches) :
simple = {}
for hp in holePunches :
simple[hp['order']] = {
'x' : hp['x'],
'y' : hp['y']
}
return simple
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
#
# NORMALIZE
#
# The purpose of this script is to normalize scanned hole punch images to a standard
# size, with standard padding around the holes and oriented straight up and down.
# Useful for converting scanned plotter-drawn pages into 4 frame sheets and for
# generating images to print.
#
parser = argparse.ArgumentParser(description='Normalize a scanned hole punch image.')
parser.add_argument('input', type=str, help='Scan to normalize')
parser.add_argument('output', type=str, help='Normalized file to output')
args = parser.parse_args()
scanImage = args.input
normalImage = args.output
if not exists(scanImage) :
print('Scan provided does not exist')
exit(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"]}')
# 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'Normal : {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)
jsonOut = simplify_hole_punches(evaluation)
with open(f'{normalImage}.json', 'w') as output:
output.write(dumps(jsonOut, sort_keys = True, indent = 4))
print(f'Wrote hole punch definition file to {normalImage}.json')