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 + # or upper - , 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)