Files
filmsim/border.py
2025-06-19 15:31:45 -04:00

160 lines
6.4 KiB
Python

import numpy as np
from PIL import Image, ImageDraw
from scipy.signal import find_peaks, savgol_filter
from scipy.ndimage import sobel
def analyze_profile(
profile: np.ndarray,
prominence: float,
width: int,
direction: str
) -> int | None:
"""
Analyzes a 1D profile to find the most likely edge coordinate.
"""
if profile.size == 0:
return None
# 1. Smooth the profile to reduce noise while preserving peak shapes.
# The window length must be odd.
window_length = min(21, len(profile) // 2 * 2 + 1)
if window_length < 5: # savgol_filter requires window_length > polyorder
smoothed_profile = profile
else:
smoothed_profile = savgol_filter(profile, window_length=window_length, polyorder=2)
# 2. Find all significant peaks in the profile.
# Prominence is a measure of how much a peak stands out from the baseline.
peaks, properties = find_peaks(smoothed_profile, prominence=prominence, width=width)
if len(peaks) == 0:
return None
# 3. Select the best peak. We choose the one with the highest prominence.
most_prominent_peak_index = np.argmax(properties['prominences'])
best_peak = peaks[most_prominent_peak_index]
return best_peak
def find_film_edges_gradient(
image_path: str,
border_percent: int = 15,
prominence: float = 10.0,
min_width: int = 2
) -> tuple[int, int, int, int] | None:
"""
Detects film edges using a directional gradient method, which is robust
to complex borders and internal image features.
Args:
image_path (str): Path to the image file.
border_percent (int): The percentage of image dimensions to search for a border.
prominence (float): Required prominence of a gradient peak to be considered an edge.
This is the most critical tuning parameter. Higher values mean
the edge must be sharper and more distinct.
min_width (int): The minimum width (in pixels) of a peak to be considered.
Helps ignore single-pixel noise.
Returns:
A tuple (left, top, right, bottom) or None if detection fails.
"""
try:
with Image.open(image_path) as img:
image_gray = np.array(img.convert('L'), dtype=float)
height, width = image_gray.shape
except Exception as e:
print(f"Error opening or processing image: {e}")
return None
# 1. Calculate directional gradients for the entire image once.
grad_y = sobel(image_gray, axis=0) # For horizontal lines (top, bottom)
grad_x = sobel(image_gray, axis=1) # For vertical lines (left, right)
coords = {}
search_w = int(width * border_percent / 100)
search_h = int(height * border_percent / 100)
# 2. Find Left Edge (Dark -> Light transition, so positive grad_x)
left_band_grad = grad_x[:, :search_w]
# We only care about positive gradients (dark to light)
left_profile = np.sum(np.maximum(0, left_band_grad), axis=0)
left_coord = analyze_profile(left_profile, prominence, min_width, "left")
coords['left'] = left_coord if left_coord is not None else 0
# 3. Find Right Edge (Light -> Dark transition, so negative grad_x)
right_band_grad = grad_x[:, -search_w:]
# We want the strongest negative gradient, so we flip the sign.
right_profile = np.sum(np.maximum(0, -right_band_grad), axis=0)
# The profile is from right-to-left, so we analyze its reversed version.
right_coord = analyze_profile(right_profile[::-1], prominence, min_width, "right")
if right_coord is not None:
# Convert coordinate back to the original image space
coords['right'] = width - 1 - right_coord
else:
coords['right'] = width - 1
# 4. Find Top Edge (Dark -> Light transition, so positive grad_y)
top_band_grad = grad_y[:search_h, :]
top_profile = np.sum(np.maximum(0, top_band_grad), axis=1)
top_coord = analyze_profile(top_profile, prominence, min_width, "top")
coords['top'] = top_coord if top_coord is not None else 0
# 5. Find Bottom Edge (Light -> Dark transition, so negative grad_y)
bottom_band_grad = grad_y[-search_h:, :]
bottom_profile = np.sum(np.maximum(0, -bottom_band_grad), axis=1)
bottom_coord = analyze_profile(bottom_profile[::-1], prominence, min_width, "bottom")
if bottom_coord is not None:
coords['bottom'] = height - 1 - bottom_coord
else:
coords['bottom'] = height - 1
# 6. Sanity check and return
left, top, right, bottom = map(int, [coords['left'], coords['top'], coords['right'], coords['bottom']])
if not (left < right and top < bottom):
print("Warning: Detection failed, coordinates are illogical.")
return None
return (left, top, right, bottom)
# --- Example Usage ---
if __name__ == "__main__":
# Use the image you provided.
# NOTE: You must save your image as 'test_negative_v2.png' in the same
# directory as the script, or change the path here.
image_file = "test_negative_v2.png"
print(f"Attempting to detect edges on '{image_file}' with gradient method...")
# Run the detection. The 'prominence' parameter is the one to tune.
# Start with a moderate value and increase if it detects noise, decrease
# if it misses a subtle edge.
crop_box = find_film_edges_gradient(
image_file,
border_percent=15, # Increase search area slightly due to complex borders
prominence=5.0, # A higher value is needed for this high-contrast example
min_width=2
)
if crop_box:
print(f"\nDetected image box: {crop_box}")
with Image.open(image_file) as img:
# Add a white border for better visualization if the background is black
img_with_border = Image.new('RGB', (img.width + 2, img.height + 2), 'white')
img_with_border.paste(img, (1, 1))
draw = ImageDraw.Draw(img_with_border)
# Offset the crop box by 1 pixel due to the added border
offset_box = [c + 1 for c in crop_box]
draw.rectangle(offset_box, outline="red", width=2)
output_path = "test_negative_detected_final.png"
img_with_border.save(output_path)
print(f"Saved visualization to '{output_path}'")
try:
img_with_border.show(title="Detection Result")
except Exception:
pass
else:
print("\nCould not robustly detect the film edges.")