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.")