191 lines
6.0 KiB
Python
191 lines
6.0 KiB
Python
"""
|
|
Crop Concept 2 PM5 rowing machine screens from photos using OpenCV.
|
|
|
|
Detection strategy:
|
|
The LCD screen has HIGH internal edge density (text/numbers/lines)
|
|
compared to other bright regions (windows, walls, lockers).
|
|
We threshold at multiple brightness levels, filter by edge density,
|
|
aspect ratio, and size, then pick the best match.
|
|
|
|
Usage:
|
|
python crop_screens.py [input_dir] [output_dir]
|
|
"""
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import os
|
|
import glob
|
|
import sys
|
|
|
|
|
|
def order_corners(pts):
|
|
"""Order 4 points as [top-left, top-right, bottom-right, bottom-left]."""
|
|
rect = np.zeros((4, 2), dtype="float32")
|
|
s = pts.sum(axis=1)
|
|
rect[0] = pts[np.argmin(s)]
|
|
rect[2] = pts[np.argmax(s)]
|
|
d = np.diff(pts, axis=1)
|
|
rect[1] = pts[np.argmin(d)]
|
|
rect[3] = pts[np.argmax(d)]
|
|
return rect
|
|
|
|
|
|
def find_screen(image):
|
|
"""
|
|
Detect the Concept 2 PM5 LCD screen region in the image.
|
|
|
|
Returns (x, y, w, h, contour) or None if not found.
|
|
The contour is the best-matching contour for perspective correction.
|
|
"""
|
|
h_img, w_img = image.shape[:2]
|
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
|
|
# Pre-compute edge map for internal-content scoring
|
|
blurred = cv2.GaussianBlur(gray, (11, 11), 0)
|
|
edges = cv2.Canny(blurred, 80, 100)
|
|
|
|
candidates = []
|
|
|
|
# Sweep brightness thresholds — screen brightness varies by
|
|
# lighting conditions (ranges from ~100 in dim gyms to ~200+)
|
|
for thresh_val in range(70, 210, 10):
|
|
_, thresh = cv2.threshold(gray, thresh_val, 255, cv2.THRESH_BINARY)
|
|
kern = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
|
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kern)
|
|
thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kern)
|
|
|
|
contours, _ = cv2.findContours(
|
|
thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
|
)
|
|
|
|
for cnt in contours:
|
|
x, y, w, h = cv2.boundingRect(cnt)
|
|
area = cv2.contourArea(cnt)
|
|
rect_area = w * h
|
|
if rect_area == 0:
|
|
continue
|
|
|
|
# Size: screen is a small-to-medium portion of the photo
|
|
area_ratio = rect_area / (h_img * w_img)
|
|
if area_ratio < 0.004480508227271387 or area_ratio > 0.13807760800032298:
|
|
continue
|
|
|
|
# Aspect ratio: LCD is roughly square
|
|
aspect = w / h
|
|
if aspect < 0.6831978184146027 or aspect > 1.9505294279578584:
|
|
continue
|
|
|
|
# Rectangularity
|
|
rectangularity = area / rect_area
|
|
if rectangularity < 0.6914579162415992:
|
|
continue
|
|
|
|
# KEY: edge density — LCD with text has high edge density
|
|
roi_edges = edges[y : y + h, x : x + w]
|
|
edge_density = np.sum(roi_edges > 0) / rect_area
|
|
if edge_density < 0.012759310759672408:
|
|
continue
|
|
|
|
# Score: edge density * area * rectangularity
|
|
# This favours text-rich regions that are large and well-shaped
|
|
score = edge_density * area * rectangularity
|
|
candidates.append((score, x, y, w, h, cnt))
|
|
|
|
if not candidates:
|
|
return None
|
|
|
|
candidates.sort(key=lambda c: c[0], reverse=True)
|
|
best = candidates[0]
|
|
return best[1], best[2], best[3], best[4], best[5]
|
|
|
|
|
|
def perspective_correct(image, contour, dst_w, dst_h):
|
|
"""Warp the screen quadrilateral to a flat rectangle."""
|
|
# Approximate contour to a polygon, tightening until we get 4 corners
|
|
peri = cv2.arcLength(contour, True)
|
|
for eps_mult in [0.02, 0.03, 0.05, 0.08, 0.10]:
|
|
approx = cv2.approxPolyDP(contour, eps_mult * peri, True)
|
|
if len(approx) == 4:
|
|
break
|
|
|
|
if len(approx) != 4:
|
|
# Fall back to the minimum area rectangle corners
|
|
rect = cv2.minAreaRect(contour)
|
|
approx = cv2.boxPoints(rect).astype(np.float32)
|
|
else:
|
|
approx = approx.reshape(4, 2).astype(np.float32)
|
|
|
|
src = order_corners(approx)
|
|
dst = np.array(
|
|
[[0, 0], [dst_w - 1, 0], [dst_w - 1, dst_h - 1], [0, dst_h - 1]],
|
|
dtype="float32",
|
|
)
|
|
M = cv2.getPerspectiveTransform(src, dst)
|
|
return cv2.warpPerspective(image, M, (dst_w, dst_h))
|
|
|
|
|
|
def crop_screen(image_path, output_path, padding=15):
|
|
"""Load an image, find the screen, perspective-correct and save it."""
|
|
image = cv2.imread(image_path)
|
|
if image is None:
|
|
print(f" ERROR: Could not read {image_path}")
|
|
return False
|
|
|
|
h_img, w_img = image.shape[:2]
|
|
result = find_screen(image)
|
|
|
|
if result is None:
|
|
print(f" SKIP: No screen detected in {os.path.basename(image_path)}")
|
|
return False
|
|
|
|
x, y, w, h, contour = result
|
|
|
|
# Use perspective correction to flatten the screen
|
|
corrected = perspective_correct(image, contour, w + 2 * padding, h + 2 * padding)
|
|
|
|
cv2.imwrite(output_path, corrected, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
|
print(
|
|
f" OK: {os.path.basename(image_path)} -> {os.path.basename(output_path)} ({w}x{h})"
|
|
)
|
|
return True
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) >= 3:
|
|
input_dir = sys.argv[1]
|
|
output_dir = sys.argv[2]
|
|
elif len(sys.argv) == 2:
|
|
input_dir = sys.argv[1]
|
|
output_dir = os.path.join(input_dir, "cropped")
|
|
else:
|
|
input_dir = "/mnt/user-data/uploads"
|
|
output_dir = "/mnt/user-data/outputs"
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
images = sorted(
|
|
glob.glob(os.path.join(input_dir, "*.JPEG"))
|
|
+ glob.glob(os.path.join(input_dir, "*.jpeg"))
|
|
+ glob.glob(os.path.join(input_dir, "*.jpg"))
|
|
+ glob.glob(os.path.join(input_dir, "*.JPG"))
|
|
)
|
|
|
|
if not images:
|
|
print(f"No images found in {input_dir}")
|
|
return
|
|
|
|
print(f"Found {len(images)} images in {input_dir}\n")
|
|
|
|
success = 0
|
|
for img_path in images:
|
|
name = os.path.splitext(os.path.basename(img_path))[0]
|
|
out_path = os.path.join(output_dir, f"{name}_screen.jpg")
|
|
if crop_screen(img_path, out_path):
|
|
success += 1
|
|
|
|
print(f"\nDone: {success}/{len(images)} screens cropped -> {output_dir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|