|
from __future__ import print_function |
|
|
|
import cv2 |
|
import copy |
|
import math |
|
import numpy as np |
|
import os |
|
import sys |
|
|
|
# Detect OpenCV 2.x vs 3.x |
|
from pkg_resources import parse_version |
|
IS_OPENCV_2 = parse_version(cv2.__version__) < parse_version('3.0.0') |
|
|
|
# Alias BoxPoints as this lives in a different place in OpenCV 2 and 3 |
|
if IS_OPENCV_2: |
|
BoxPoints = cv2.cv.BoxPoints |
|
else: |
|
BoxPoints = cv2.boxPoints |
|
|
|
|
|
# Detection settings |
|
MAX_COVERAGE = 0.98 |
|
INSET_PERCENT = 0.005 |
|
|
|
def thresholdImage(img, lowerThresh, ignoreMask): |
|
_, binary = cv2.threshold(img, lowerThresh, 255, cv2.THRESH_BINARY_INV) # THRESH_TOZERO_INV |
|
# binary = cv2.bitwise_not(binary) |
|
|
|
binary = cv2.bitwise_and(ignoreMask, binary) |
|
|
|
# Prevent tiny outlier collections of pixels spoiling the rect fitting |
|
kernel = np.ones((5,5),np.uint8) |
|
binary = cv2.dilate(binary, kernel, iterations = 3) |
|
binary = cv2.erode(binary, kernel, iterations = 3) |
|
|
|
return binary |
|
|
|
def findLargestContourRect(binary): |
|
largestRect = None |
|
largestArea = 0 |
|
|
|
# Find external contours of all shapes |
|
if IS_OPENCV_2: |
|
contours,_ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
|
else: |
|
_, contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
|
|
|
for cnt in contours: |
|
area = cv2.contourArea(cnt) |
|
|
|
# Keep track of the largest area seen |
|
if area > largestArea: |
|
largestArea = area |
|
largestRect = cv2.minAreaRect(cnt) |
|
|
|
return largestRect, largestArea |
|
|
|
def findNonZeroPixelsRect(binary): |
|
edges = copy.copy(binary) |
|
|
|
if IS_OPENCV_2: |
|
contours,_ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
|
else: |
|
_, contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
|
|
|
nonZero = cv2.findNonZero(edges) |
|
|
|
if nonZero is None: |
|
return None, 0, binary |
|
|
|
rect = cv2.minAreaRect(nonZero) |
|
|
|
area = rect[1][0] * rect[1][1] |
|
|
|
return rect, area |
|
|
|
def normaliseRectRotation(rawRects): |
|
""" |
|
Normalize rect orientation to have an angle between -45 and 45 degrees |
|
|
|
Rects generated by OpenCV are can be "portrait" with a near -90 angle and flipped height/width |
|
To combine and compare rects meaningfully, they need to all have the same orientation. |
|
""" |
|
rects = [] |
|
|
|
for rect in rawRects: |
|
center = rect[0] |
|
size = rect[1] |
|
angle = rect[2] |
|
|
|
if angle < -45: |
|
rect = ( |
|
center, |
|
(size[1], size[0]), |
|
angle + 90 |
|
) |
|
|
|
rects.append(rect) |
|
|
|
return rects |
|
|
|
def medianRect(rects): |
|
if len(rects) == 0: |
|
return None |
|
|
|
rects = normaliseRectRotation(rects) |
|
|
|
# Sort rects by area |
|
rects.sort(key=lambda rect: rect[1][0] * rect[1][1]) |
|
|
|
median = ( |
|
(np.median([r[0][0] for r in rects]), np.median([r[0][1] for r in rects])), |
|
(np.median([r[1][0] for r in rects]), np.median([r[1][1] for r in rects])), |
|
np.median([r[2] for r in rects]) |
|
) |
|
|
|
return median |
|
|
|
def correctAspectRatio(rect, targetRatio = 1.5, maxDifference = 0.3): |
|
""" |
|
Return an aspect-ratio corrected rect (and success flag) |
|
|
|
Args: |
|
rect (OpenCV RotatedRect struct) |
|
targetRatio (float): Ratio represented as the larger image dimension divided by the smaller one |
|
|
|
""" |
|
# Indexes into the rect nested tuple |
|
CENTER = 0; SIZE = 1; ANGLE = 2 |
|
X = 0; Y = 1; |
|
|
|
size = rect[SIZE] |
|
|
|
aspectRatio = max(size[X], size[Y]) / float(min(size[X], size[Y])) |
|
aspectError = targetRatio - aspectRatio |
|
|
|
# Factor out orientation to simplify logic below |
|
# This assumes the larger dimension as X |
|
if size[X] == max(size[X], size[Y]): |
|
rectWidth = size[X] |
|
rectHeight = size[Y] |
|
widthDim = X |
|
heightDim = Y |
|
else: |
|
rectHeight = size[X] |
|
rectWidth = size[Y] |
|
widthDim = Y |
|
heightDim = X |
|
|
|
# Only attempt to correct aspect ratio where the ROI is roughly right already |
|
# This prevents odd results for poor outline detection |
|
if abs(aspectError) > maxDifference: |
|
return rect, False |
|
|
|
# Shrink width if the ratio was too wide |
|
if aspectRatio > targetRatio: |
|
print("ratio too large", aspectError) |
|
rectWidth = size[heightDim] * targetRatio |
|
|
|
# Shrink height if the ratio was too tall |
|
elif aspectRatio < targetRatio: |
|
print("ratio too small", aspectError) |
|
# rectWidth = size[heightDim] * targetRatio |
|
rectHeight = size[widthDim] / targetRatio |
|
|
|
# Apply new width/height in the original orientation |
|
if widthDim == X: |
|
newSize = (rectWidth, rectHeight) |
|
else: |
|
newSize = (rectHeight, rectWidth) |
|
|
|
newRect = (rect[CENTER], newSize, rect[ANGLE]) |
|
|
|
return newRect, True |
|
|
|
def findExposureBounds(img, showOutputWindow=False): |
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
|
|
|
# Smooth out noise |
|
# gray = cv2.GaussianBlur(gray,(5,5),0) |
|
gray = cv2.bilateralFilter(gray, 11, 17, 17) |
|
|
|
# Maximise brightness range |
|
gray = cv2.equalizeHist(gray) |
|
|
|
|
|
# Create a mask to ignore the brightest spots |
|
# These are usually where there is no film covering the light source |
|
_, ignoreMask = cv2.threshold(gray, 240, 255, cv2.THRESH_BINARY) |
|
|
|
# Expand masked out area slightly to include adjacent edges |
|
kernel = np.ones((3,3),np.uint8) |
|
ignoreMask = cv2.dilate(ignoreMask, kernel, iterations = 3) |
|
|
|
|
|
# Create a mask to ignore areas of low saturation |
|
# When white balanced against the film stock, this is usually low saturation |
|
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) |
|
hsv = cv2.GaussianBlur(hsv, (5,5), 0) |
|
|
|
satMask = cv2.inRange(hsv, (0, 0, 0), (255, 7, 255)) |
|
|
|
# Combine saturation and brightness masks, then flip |
|
ignoreMask = cv2.bitwise_or(ignoreMask, satMask) |
|
ignoreMask = cv2.bitwise_not(ignoreMask) |
|
|
|
|
|
# Get min/max region of interest areas |
|
height, width, _ = img.shape |
|
maxArea = (height * MAX_COVERAGE) * (width * MAX_COVERAGE) |
|
|
|
minCaptureArea = maxArea * 0.65 |
|
|
|
# algos = [findNonZeroPixelsRect] |
|
algos = [findLargestContourRect] |
|
|
|
results = [] |
|
|
|
for func in algos: |
|
lowerThreshold = 0 |
|
while lowerThreshold < 240: |
|
binary = thresholdImage(gray, lowerThreshold, ignoreMask) |
|
|
|
debugImg = cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR) |
|
rect, area = func(binary) |
|
|
|
# Stop once a valid result is returned |
|
if area >= maxArea: |
|
break |
|
|
|
if area >= minCaptureArea: |
|
results.append(rect) |
|
lowerThreshold += 5 |
|
|
|
# Draw in green for results that are collected |
|
debugLineColour = (0, 255, 0) |
|
|
|
else: |
|
lowerThreshold += 5 |
|
|
|
# Draw in red for areas that were too small |
|
debugLineColour = (0, 0, 255) |
|
|
|
|
|
if showOutputWindow: |
|
if rect is not None: |
|
# Get a rectangle around the contour |
|
|
|
rectPoints = BoxPoints(rect) |
|
rectPoints = np.int0(rectPoints) |
|
|
|
cv2.drawContours(debugImg, [rectPoints], -1, debugLineColour, 3) |
|
|
|
# Draw threshold on debug output |
|
cv2.putText( |
|
img=debugImg, |
|
text='Threshold: ' + str(lowerThreshold), |
|
org=(20, 30), |
|
fontFace=cv2.FONT_HERSHEY_PLAIN, |
|
fontScale=2, |
|
color=(0, 150, 255), |
|
lineType=4 |
|
) |
|
|
|
cv2.imshow('image', cv2.resize(debugImg, (0,0), fx=0.75, fy=0.75) ) |
|
cv2.waitKey(1) |
|
|
|
return medianRect(results) |
|
|
|
|
|
if __name__ == '__main__': |
|
|
|
import argparse |
|
|
|
parser = argparse.ArgumentParser(description='Find crop for film negative scan') |
|
|
|
parser.add_argument('files', nargs='+', help='Image files to perform detection on (JPG, PNG, etc)') |
|
|
|
args = parser.parse_args() |
|
|
|
hasDisplay = os.getenv('DISPLAY') != None |
|
|
|
for filename in args.files: |
|
if not os.path.exists(filename): |
|
print("ERROR:") |
|
print("Could not find file '%s'" % filename) |
|
sys.exit(5) |
|
|
|
# read image and convert to gray |
|
img = cv2.imread(filename, cv2.IMREAD_UNCHANGED) |
|
|
|
# cv2.imshow('image', cv2.resize(img, (0,0), fx=0.75, fy=0.75) ) |
|
# cv2.waitKey(0) |
|
|
|
rawRect = findExposureBounds(img, showOutputWindow=hasDisplay) |
|
|
|
# Outputs for Lightroom |
|
cropLeft = 0 |
|
cropRight = 1.0 |
|
cropTop = 0 |
|
cropBottom = 1.0 |
|
rotation = 0 |
|
|
|
if rawRect is not None: |
|
# Average height and width of the detected area to get a constant inset |
|
insetPixels = ((rawRect[1][0] + rawRect[1][1]) / 2.0) * INSET_PERCENT |
|
|
|
insetRect = ( |
|
rawRect[0], # Center |
|
(rawRect[1][0] - insetPixels, rawRect[1][1] - insetPixels), # Size |
|
rawRect[2] # Rotation |
|
) |
|
|
|
rect, aspectChanged = correctAspectRatio(insetRect) |
|
|
|
boxWidth = rect[1][0] |
|
boxHeight = rect[1][1] |
|
|
|
box = np.int0(BoxPoints(rect)) |
|
|
|
|
|
# # Create a mask that excludes areas that are probably the directly visible light source |
|
# _, wbMask = cv2.threshold(gray, 253, 0, cv2.THRESH_TOZERO) |
|
# wbMask = cv2.bitwise_not(wbMask) |
|
|
|
# # Mask out the detected frame - we only want to look at the base film layer |
|
# cv2.fillConvexPoly(wbMask, box, 0) |
|
|
|
# # cv2.imshow('image', wbMask ) |
|
# # cv2.waitKey(0) |
|
|
|
# # bgr = cv2.mean(img, wbMask) |
|
# lab = cv2.mean(cv2.cvtColor(img, cv2.COLOR_BGR2LAB), wbMask) |
|
|
|
# # print [i for i in reversed(bgr)] |
|
# tint = lab[1] - 127 |
|
# temperature = lab[2] - 127 |
|
# print (lab[0]/255.0)*100, temperature, tint |
|
|
|
|
|
# Lightroom doesn't support rotation more than 45 degrees |
|
# The detected rect usually includes a 90 degree rotation for landscape images |
|
rotation = -rect[2] |
|
|
|
if rotation > 45: |
|
rotation -= 90 |
|
elif rotation < -90: |
|
rotation += 45 |
|
|
|
# Calculate crops in a format for Lightroom (0.0 to 1.0 for each edge) |
|
centerX = rect[0][0] |
|
centerY = rect[0][1] |
|
|
|
# Use the average distance from each side as the crop in Lightroom |
|
imgHeight, imgWidth, _ = img.shape |
|
|
|
top = []; left = []; right = []; bottom =[] |
|
|
|
for point in box: |
|
# point = rotateAroundPoint(point, math.radians(rotation)) |
|
|
|
if point[0] > centerX: |
|
right.append( point[0] ) |
|
else: |
|
left.append( point[0] ) |
|
|
|
if point[1] > centerY: |
|
bottom.append( point[1] ) |
|
else: |
|
top.append( point[1] ) |
|
|
|
cropRight = (min(right)) / float(imgWidth) |
|
cropLeft = (max(left)) / float(imgWidth) |
|
cropBottom = (min(bottom)) / float(imgHeight) |
|
cropTop = (max(top)) / float(imgHeight) |
|
|
|
# Draw original detected area |
|
rawBox = np.int0(BoxPoints(rawRect)) |
|
cv2.drawContours(img, [rawBox], -1, (255, 0, 0), 1) |
|
|
|
# Draw inset area |
|
insetBox = np.int0(BoxPoints(insetRect)) |
|
cv2.drawContours(img, [insetBox], -1, (0, 255, 255), 1) |
|
|
|
# Draw adjusted aspect ratio area |
|
cv2.drawContours(img, [box], -1, (0, 255, 0), 2) |
|
|
|
cv2.circle(img, (int(rect[0][0]), int(rect[0][1])), 3, (0, 255, 0), 3) |
|
|
|
# Write result to disk for Lightroom plugin to pick up |
|
# (The Lightroom API doesn't appear to allow streaming in output from a program) |
|
cropData = [ |
|
cropLeft, |
|
cropRight, |
|
cropTop, |
|
cropBottom, |
|
rotation |
|
] |
|
|
|
for v in cropData: |
|
print(v) |
|
|
|
with open(filename + ".txt", 'w') as out: |
|
out.write("\r\n".join(str(x) for x in cropData)) |
|
|
|
cv2.imwrite(filename + "-analysis.jpg", img) |
|
|
|
# if hasDisplay: |
|
# cv2.imshow('image', cv2.resize(img, (0,0), fx=0.75, fy=0.75) ) |
|
# cv2.waitKey(0) |
I was getting this same error on MacOS, but I figured out that it couldn't find where "python" was:
https://stackoverflow.com/questions/48484152/os-system-returns-error-code-32512-python
So in the AutoCrop.lua script I changed line 23 from:
local pythonCommand = "python ARGS"
to:
local pythonCommand = "/opt/homebrew/bin/python ARGS"