import os import tempfile import zipfile import shutil # For make_archive import uuid from PIL import Image, ImageDraw from psd_tools import PSDImage from psd_tools.api.layers import PixelLayer import gradio as gr import traceback # For printing stack traces import subprocess def install(package): subprocess.check_call([os.sys.executable, "-m", "pip", "install", package]) install("timm") install("pydantic==2.10.6") install("dghs-imgutils==0.15.0") install("onnxruntime >= 1.17.0") install("psd_tools==1.10.7") # os.environ["no_proxy"] = "localhost,127.0.0.1,::1" # --- Attempt to import the actual function (Detector 1) --- # Let's keep the original import name as requested in the previous version try: # Assuming this is the intended "Detector 1" from src.wise_crop.detect_and_crop import crop_and_mask_characters_gradio detector_1_available = True print("Successfully imported 'crop_and_mask_characters_gradio' as Detector 1.") except ImportError: detector_1_available = False print("Warning: Could not import 'crop_and_mask_characters_gradio'. Using dummy function for Detector 1.") # Define a dummy version for Detector 1 if import fails def crop_and_mask_characters_gradio(image_pil: Image.Image): """Dummy function 1 if import fails.""" print("Using DUMMY Detector 1.") if image_pil is None: return [] width, height = image_pil.size boxes = [ (0, (int(width * 0.1), int(height * 0.1), int(width * 0.3), int(height * 0.4))), (1, (int(width * 0.6), int(height * 0.5), int(width * 0.25), int(height * 0.35))), ] valid_boxes = [] for i, (x, y, w, h) in boxes: x1, y1, x2, y2 = max(0, x), max(0, y), min(width, x + w), min(height, y + h) if x2 - x1 > 0 and y2 - y1 > 0: valid_boxes.append((i, (x1, y1, x2 - x1, y2 - y1))) return valid_boxes # from src.oskar_crop.detect_and_crop import process_single_image as detector_2_function try: # Assuming this is the intended "Detector 2" # Note: Renamed the import alias to avoid conflict if both imports succeed. # The function call inside process_lineart still uses crop_and_mask_characters_gradio_2 from src.oskar_crop.detect_and_crop import process_single_image as detector_2_function detector_2_available = True print("Successfully imported 'process_single_image' as Detector 2.") # Define the function name used in process_lineart def crop_and_mask_characters_gradio_2(image_pil: Image.Image): return detector_2_function(image_pil) except ImportError: detector_2_available = False print("Warning: Could not import 'process_single_image'. Using dummy function for Detector 2.") # Define a dummy version for Detector 2 if import fails # --- Define the SECOND Dummy Detection Function --- def crop_and_mask_characters_gradio_2(image_pil: Image.Image): """ SECOND Dummy function to simulate detecting objects and returning bounding boxes. Returns different results than the first function. """ print("Using DUMMY Detector 2.") if image_pil is None: return [] width, height = image_pil.size print(f"Dummy detection 2 running on image size: {width}x{height}") # Define DIFFERENT fixed bounding boxes for demonstration boxes = [ (0, (int(width * 0.05), int(height * 0.6), int(width * 0.4), int(height * 0.3))), # Bottom-leftish, wider (1, (int(width * 0.7), int(height * 0.1), int(width * 0.20), int(height * 0.25))), # Top-rightish, smaller (2, (int(width * 0.4), int(height * 0.4), int(width * 0.15), int(height * 0.15))), # Center-ish, very small ] # Basic validation valid_boxes = [] for i, (x, y, w, h) in boxes: x1 = max(0, x) y1 = max(0, y) x2 = min(width, x + w) y2 = min(height, y + h) new_w = x2 - x1 new_h = y2 - y1 if new_w > 0 and new_h > 0: valid_boxes.append((i, (x1, y1, new_w, new_h))) print(f"Dummy detection 2 found {len(valid_boxes)} boxes.") return valid_boxes # --- Helper Function (make_lineart_transparent - unchanged) --- def make_lineart_transparent(lineart_path, threshold=200): """Converts a lineart image file to a transparent RGBA PIL Image.""" try: # Ensure we handle potential pathlib objects if Gradio passes them lineart_gray = Image.open(str(lineart_path)).convert('L') w, h = lineart_gray.size lineart_rgba = Image.new('RGBA', (w, h), (0, 0, 0, 0)) gray_pixels = lineart_gray.load() rgba_pixels = lineart_rgba.load() for y in range(h): for x in range(w): gray_val = gray_pixels[x, y] alpha = 255 - gray_val if gray_val < threshold : rgba_pixels[x, y] = (0, 0, 0, alpha) else: rgba_pixels[x, y] = (0, 0, 0, 0) return lineart_rgba except FileNotFoundError: print(f"Helper Error: Image file not found at {lineart_path}") # Return a blank transparent image or None? Returning None is clearer. return None except Exception as e: print(f"Helper Error processing image {lineart_path}: {e}") return None # --- Main Processing Function (modified for better error handling with PIL) --- def process_lineart(input_pil_or_path, detector_choice): # Input can be PIL or path from examples """ Processes the input lineart image using the selected detector. Detects objects (e.g., characters based on head/face), crops them, provides a gallery of crops, a ZIP file of crops, and a PSD file with the original lineart (made transparent) and bounding boxes. """ # --- Initialize variables --- input_pil_image = None temp_input_path = None using_temp_input_path = False status_updates = ["Status: Initializing..."] psd_output_path = None # Initialize to None zip_output_path = None # Initialize to None cropped_images_for_gallery = [] # Initialize to empty list try: # --- Handle Input --- if input_pil_or_path is None: gr.Warning("Please upload a PNG image or select an example.") return [], None, None, "Status: No image provided." print(f"Input type: {type(input_pil_or_path)}") print(f"Input value: {input_pil_or_path}") # Check if input is already a PIL image (from upload) or a path (from examples) if isinstance(input_pil_or_path, Image.Image): input_pil_image = input_pil_or_path print("Processing PIL image from upload.") # Create a temporary path for make_lineart_transparent if needed later temp_input_fd, temp_input_path = tempfile.mkstemp(suffix=".png") os.close(temp_input_fd) input_pil_image.save(temp_input_path, "PNG") using_temp_input_path = True elif isinstance(input_pil_or_path, str) and os.path.exists(input_pil_or_path): print(f"Processing image from file path: {input_pil_or_path}") try: input_pil_image = Image.open(input_pil_or_path) # Use the example path directly for make_lineart_transparent temp_input_path = input_pil_or_path using_temp_input_path = False # Don't delete the example file later except Exception as e: status_updates.append(f"ERROR: Could not open image file from path '{input_pil_or_path}': {e}") print(status_updates[-1]) return [], None, None, "\n".join(status_updates) # Return error status else: status_updates.append(f"ERROR: Invalid input type received: {type(input_pil_or_path)}. Expected PIL image or file path.") print(status_updates[-1]) return [], None, None, "\n".join(status_updates) # Return error status # --- Ensure RGBA and get dimensions --- try: input_pil_image = input_pil_image.convert("RGBA") width, height = input_pil_image.size except Exception as e: status_updates.append(f"ERROR: Could not process input image (convert/get size): {e}") print(status_updates[-1]) # Clean up temp file if created before error if using_temp_input_path and temp_input_path and os.path.exists(temp_input_path): try: os.remove(temp_input_path) except Exception as e_rem: print(f"Warning: Could not remove temp input file {temp_input_path}: {e_rem}") return [], None, None, "\n".join(status_updates) # Return error status status_updates = [f"Status: Processing started using {detector_choice}."] # Reset status print("Starting processing...") # --- 1. Detect Objects (Conditional) --- print(f"Selected detector: {detector_choice}") if detector_choice == "Detector 1": if not detector_1_available: status_updates.append("Warning: Using DUMMY Detector 1.") boxes_info = crop_and_mask_characters_gradio(input_pil_image) elif detector_choice == "Detector 2": if not detector_2_available: status_updates.append("Warning: Using DUMMY Detector 2.") boxes_info = crop_and_mask_characters_gradio_2(input_pil_image) else: # This case should ideally not happen with Radio buttons, but good for safety status_updates.append(f"ERROR: Invalid detector choice received: {detector_choice}") print(status_updates[-1]) # Clean up temp file if created before error if using_temp_input_path and temp_input_path and os.path.exists(temp_input_path): try: os.remove(temp_input_path) except Exception as e_rem: print(f"Warning: Could not remove temp input file {temp_input_path}: {e_rem}") return [], None, None, "\n".join(status_updates) # Return error status if not boxes_info: gr.Warning("No objects detected.") status_updates.append("No objects detected.") # Clean up temp file if created if using_temp_input_path and temp_input_path and os.path.exists(temp_input_path): try: os.remove(temp_input_path) except Exception as e_rem: print(f"Warning: Could not remove temp input file {temp_input_path}: {e_rem}") return [], None, None, "\n".join(status_updates) status_updates.append(f"Detected {len(boxes_info)} objects.") print(f"Detected boxes: {boxes_info}") # --- Temporary file paths (partially adjusted) --- temp_dir_for_outputs = tempfile.gettempdir() unique_id = uuid.uuid4().hex[:8] zip_base_name = os.path.join(temp_dir_for_outputs, f"cropped_images_{unique_id}") zip_output_path = f"{zip_base_name}.zip" # Path for the final zip file psd_output_path = os.path.join(temp_dir_for_outputs, f"lineart_boxes_{unique_id}.psd") # temp_input_path is already handled above based on input source # --- 2. Crop Images and Prepare for ZIP --- with tempfile.TemporaryDirectory() as temp_crop_dir: print(f"Saving cropped images to temporary directory: {temp_crop_dir}") for i, (x, y, w, h) in boxes_info: # Ensure box coordinates are within image bounds x1, y1 = max(0, x), max(0, y) x2, y2 = min(width, x + w), min(height, y + h) box = (x1, y1, x2, y2) if box[2] > box[0] and box[3] > box[1]: # Check if width and height are positive try: cropped_img = input_pil_image.crop(box) cropped_images_for_gallery.append(cropped_img) crop_filename = os.path.join(temp_crop_dir, f"cropped_{i}.png") cropped_img.save(crop_filename, "PNG") except Exception as e: print(f"Error cropping or saving box {i} with coords {box}: {e}") status_updates.append(f"Warning: Error processing crop {i}.") else: print(f"Skipping invalid box {i} with coords {box}") status_updates.append(f"Warning: Skipped invalid crop dimensions for box {i}.") # --- 3. Create ZIP File --- # Check if any PNG files were actually created in the temp dir if any(f.endswith(".png") for f in os.listdir(temp_crop_dir)): print(f"Creating ZIP file: {zip_output_path} from {temp_crop_dir}") try: shutil.make_archive(zip_base_name, 'zip', temp_crop_dir) status_updates.append("Cropped images ZIP created.") # zip_output_path is already correctly set except Exception as e: print(f"Error creating ZIP file: {e}") status_updates.append("Error: Failed to create ZIP file.") zip_output_path = None # Indicate failure else: print("No valid cropped images were saved, skipping ZIP creation.") status_updates.append("Skipping ZIP creation (no valid crops).") zip_output_path = None # No zip file to provide # --- 4. Prepare PSD Layers --- # a) Line Layer (Use the temp_input_path which is either the original example path or a temp copy) print(f"Using image path for transparent layer: {temp_input_path}") line_layer_pil = make_lineart_transparent(temp_input_path) if line_layer_pil is None: status_updates.append("Error: Failed to create transparent lineart layer.") print(status_updates[-1]) # Don't create PSD if lineart failed, return current results # Clean up temp file if created if using_temp_input_path and temp_input_path and os.path.exists(temp_input_path): try: os.remove(temp_input_path) except Exception as e_rem: print(f"Warning: Could not remove temp input file {temp_input_path}: {e_rem}") return cropped_images_for_gallery, zip_output_path, None, "\n".join(status_updates) # Return None for PSD status_updates.append("Transparent lineart layer created.") # b) Box Layer box_layer_pil = Image.new('RGBA', (width, height), (255, 255, 255, 255)) # White background draw = ImageDraw.Draw(box_layer_pil) for i, (x, y, w, h) in boxes_info: # Use validated coords again, ensure they are within bounds x1, y1 = max(0, x), max(0, y) x2, y2 = min(width, x + w), min(height, y + h) if x2 > x1 and y2 > y1: # Check validity again just in case rect = [(x1, y1), (x2, y2)] # Changed to fill for solid boxes, yellow fill, semi-transparent draw.rectangle(rect, fill=(255, 255, 0, 128)) status_updates.append("Bounding box layer created.") # --- 5. Create PSD File --- print(f"Creating PSD file: {psd_output_path}") # Double check layer sizes before creating PSD object if line_layer_pil.size != (width, height) or box_layer_pil.size != (width, height): size_error_msg = (f"Error: Layer size mismatch during PSD creation. " f"Line: {line_layer_pil.size}, Box: {box_layer_pil.size}, " f"Expected: {(width, height)}") status_updates.append(size_error_msg) print(size_error_msg) # Clean up temp file if created if using_temp_input_path and temp_input_path and os.path.exists(temp_input_path): try: os.remove(temp_input_path) except Exception as e_rem: print(f"Warning: Could not remove temp input file {temp_input_path}: {e_rem}") return cropped_images_for_gallery, zip_output_path, None, "\n".join(status_updates) # No PSD try: psd = PSDImage.new(mode='RGBA', size=(width, height)) # Add layers (order matters for visibility in PSD viewers) # Base layer is transparent by default with RGBA psd.append(PixelLayer.frompil(line_layer_pil, layer_name='line', top=0, left=0)) psd.append(PixelLayer.frompil(box_layer_pil, layer_name='box', top=0, left=0)) psd.save(psd_output_path) status_updates.append("PSD file created.") except Exception as e: print(f"Error saving PSD file: {e}") traceback.print_exc() status_updates.append("Error: Failed to save PSD file.") psd_output_path = None # Indicate failure print("Processing finished.") status_updates.append("Success!") final_status = "\n".join(status_updates) # Return all paths, even if None (Gradio handles None for File output) return cropped_images_for_gallery, zip_output_path, psd_output_path, final_status except Exception as e: print(f"An unexpected error occurred in process_lineart: {e}") traceback.print_exc() status_updates.append(f"FATAL ERROR: {e}") final_status = "\n".join(status_updates) # Return empty/None outputs and the error status # Ensure cleanup happens even on fatal error if using_temp_input_path and temp_input_path and os.path.exists(temp_input_path): try: os.remove(temp_input_path) print(f"Cleaned up temporary input file due to error: {temp_input_path}") except Exception as e_rem: print(f"Warning: Could not remove temp input file {temp_input_path} during error handling: {e_rem}") return [], None, None, final_status # Return safe defaults finally: # --- Final Cleanup (Only removes temp input if created from upload) --- if using_temp_input_path and temp_input_path and os.path.exists(temp_input_path): try: os.remove(temp_input_path) print(f"Cleaned up temporary input file: {temp_input_path}") except Exception as e_rem: # This might happen if the file was already removed in an error block print(f"Notice: Could not remove temp input file {temp_input_path} in finally block (may already be removed): {e_rem}") # --- Gradio Interface Definition (modified) --- css = ''' .custom-gallery { height: 500px !important; width: 100%; margin: 10px auto; padding: 0px; overflow-y: auto !important; } ''' with gr.Blocks(theme=gr.themes.Soft(), css=css) as demo: gr.Markdown("# Webtoon Lineart Cropper with Filtering by Head-or-Face Detection") gr.Markdown("Upload a PNG lineart image of your webtoon and automatically crop the character's face or head included region. " "This demo leverages some detectors to precisely detect and isolate characters. " "The app will display cropped objects, provide a ZIP of cropped PNGs, " "and a PSD file with transparent lineart and half-transparent yellow-filled box layers. " "We provide two detectors to choose from, each with different filtering methods. ") gr.Markdown("- **Detector 1**: Uses [`imgutils.detect`](https://github.com/deepghs/imgutils/tree/main/imgutils/detect) and VLM-based filtering with [`google/gemma-3-12b-it`](https://huggingface.co/google/gemma-3-12b-it)") gr.Markdown("- **Detector 2**: Uses [`imgutils.detect`](https://github.com/deepghs/imgutils/tree/main/imgutils/detect) and tag-based filtering with [`SmilingWolf/wd-eva02-large-tagger-v3`](https://huggingface.co/SmilingWolf/wd-eva02-large-tagger-v3)") gr.Markdown("**Note 1:** The app may take a few seconds to process the image, depending on the size and number of characters detected. The example image below is a lineart PNG file created synthetically from images on [Danbooru](https://danbooru.donmai.us/posts?page=1&tags=dragon_ball_z) after [lineart extraction](https://huggingface.co/spaces/carolineec/informativedrawings).") gr.Markdown("**Note 2:** This demo is developed by [Kakao Entertainment](https://kakaoent.com/)'s AI Lab for research purposes, specifically designed to preprocess webtoon image data and is also not intended for production use. It is a research prototype and may not be suitable for all use cases. Please use it at your own risk.") with gr.Row(): with gr.Column(scale=1): # Input type remains 'filepath' to handle examples cleanly. image_input = gr.Image(type="filepath", label="Upload Lineart PNG or Select Example", image_mode='RGBA', height=400) detector_choice_radio = gr.Radio( choices=["Detector 1", "Detector 2"], label="Choose Detection Function", value="Detector 1" # Default value ) process_button = gr.Button("Process Uploaded/Modified Image", variant="primary") status_output = gr.Textbox(label="Status", interactive=False, lines=8) # Increased lines slightly more with gr.Column(scale=3): gr.Markdown("### Cropped Objects") # Setting height explicitly can sometimes help layout. gallery_output = gr.Gallery(label="Detected Objects (Cropped)", elem_id="gallery_crops", columns=4, height=500, interactive=False, elem_classes="custom-gallery") # object_fit="contain") with gr.Row(): zip_output = gr.File(label="Download Cropped Images (ZIP)") psd_output = gr.File(label="Download PSD (Lineart + Boxes)") # --- Add Examples --- # IMPORTANT: Make sure 'sample_img.png' exists in the same directory # as this script, or provide the correct relative/absolute path. # Also ensure the image is a valid PNG. example_image_path = "./sample_img/sample_danbooru_dragonball.png" if os.path.exists(example_image_path): gr.Examples( examples=[ [example_image_path, "Detector 1"], [example_image_path, "Detector 2"] # Add example for detector 2 as well ], # Inputs that the examples populate inputs=[image_input, detector_choice_radio], # Outputs that are updated when an example is clicked AND run_on_click=True outputs=[gallery_output, zip_output, psd_output, status_output], # The function to call when an example is clicked fn=process_lineart, # Make clicking an example automatically run the function run_on_click=True, label="Click Example to Run Automatically", # Updated label cache_examples=True, # Disable caching to ensure fresh processing cache_mode="lazy", ) else: gr.Markdown(f"**(Note:** Could not find `{example_image_path}` for examples. Please create it or ensure it's in the correct directory.)") # --- Button Click Handler (for manual uploads/changes) --- process_button.click( fn=process_lineart, inputs=[image_input, detector_choice_radio], outputs=[gallery_output, zip_output, psd_output, status_output] ) # --- Launch the Gradio App --- if __name__ == "__main__": # Create a dummy sample image if it doesn't exist for testing if not os.path.exists("./sample_img/sample_danbooru_dragonball.png"): print("Creating a dummy 'sample_danbooru_dragonball.png' for demonstration.") try: img = Image.new('L', (300, 200), color=255) # White background (grayscale) draw = ImageDraw.Draw(img) # Draw some black lines/shapes draw.line((30, 30, 270, 30), fill=0, width=2) draw.rectangle((50, 50, 150, 150), outline=0, width=3) draw.ellipse((180, 70, 250, 130), outline=0, width=3) img.save("./sample_img/sample_danbooru_dragonball.png", "PNG") print("Dummy 'sample_danbooru_dragonball.png' created.") except Exception as e: print(f"Warning: Failed to create dummy sample image: {e}") demo.launch() # ssr_mode=False