webtoon_cropper / app.py
wise-water's picture
init commit
13aa528
raw
history blame contribute delete
24.6 kB
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