Advanced background removal tool with auto-detection and edge preservation
- Auto-detects white/black/colored backgrounds - Preserves cartoon outlines using gradient edge detection - Aggressive dark gray removal (tolerance=60 for black bg) - Smooth feathering to avoid jagged edges - Test files show clean removal without outline damage
This commit is contained in:
281
scripts/remove_bg_advanced.py
Normal file
281
scripts/remove_bg_advanced.py
Normal file
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Advanced background removal for game assets:
|
||||
- Detects background color (white, black, or other)
|
||||
- Preserves bold cartoon outlines
|
||||
- Creates clean transparent PNGs
|
||||
- Works with dual-style system
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageChops
|
||||
import numpy as np
|
||||
|
||||
def detect_background_color(img):
|
||||
"""
|
||||
Detect dominant background color by sampling corners
|
||||
Returns: 'white', 'black', 'color', or None
|
||||
"""
|
||||
width, height = img.size
|
||||
|
||||
# Sample corner pixels (10x10 from each corner)
|
||||
corners = [
|
||||
img.crop((0, 0, 10, 10)), # Top-left
|
||||
img.crop((width-10, 0, width, 10)), # Top-right
|
||||
img.crop((0, height-10, 10, height)), # Bottom-left
|
||||
img.crop((width-10, height-10, width, height)) # Bottom-right
|
||||
]
|
||||
|
||||
# Get average color from corners
|
||||
corner_colors = []
|
||||
for corner in corners:
|
||||
corner_array = np.array(corner)
|
||||
avg_color = corner_array.mean(axis=(0, 1))
|
||||
corner_colors.append(avg_color)
|
||||
|
||||
avg_bg = np.mean(corner_colors, axis=0)
|
||||
|
||||
# Determine background type
|
||||
if avg_bg[:3].mean() > 240: # Very light
|
||||
return 'white'
|
||||
elif avg_bg[:3].mean() < 15: # Very dark
|
||||
return 'black'
|
||||
else:
|
||||
return 'color'
|
||||
|
||||
def remove_white_background(img, tolerance=30):
|
||||
"""
|
||||
Remove white/light background while preserving cartoon outlines
|
||||
"""
|
||||
# Convert to RGBA
|
||||
img = img.convert('RGBA')
|
||||
data = np.array(img)
|
||||
|
||||
r, g, b, a = data[:,:,0], data[:,:,1], data[:,:,2], data[:,:,3]
|
||||
|
||||
# Create mask: white areas (all channels high)
|
||||
white_mask = (r > 255 - tolerance) & (g > 255 - tolerance) & (b > 255 - tolerance)
|
||||
|
||||
# Create mask: light gray areas
|
||||
light_gray_mask = (
|
||||
(r > 220) & (g > 220) & (b > 220) &
|
||||
(np.abs(r.astype(int) - g.astype(int)) < tolerance) &
|
||||
(np.abs(r.astype(int) - b.astype(int)) < tolerance) &
|
||||
(np.abs(g.astype(int) - b.astype(int)) < tolerance)
|
||||
)
|
||||
|
||||
# Combine masks
|
||||
bg_mask = white_mask | light_gray_mask
|
||||
|
||||
# Set alpha to 0 where background detected
|
||||
data[bg_mask, 3] = 0
|
||||
|
||||
return Image.fromarray(data, mode='RGBA')
|
||||
|
||||
def remove_black_background(img, tolerance=60):
|
||||
"""
|
||||
Remove black/dark background while preserving black outlines
|
||||
More aggressive version to catch dark gray artifacts
|
||||
"""
|
||||
# Convert to RGBA
|
||||
img = img.convert('RGBA')
|
||||
data = np.array(img)
|
||||
|
||||
r, g, b, a = data[:,:,0], data[:,:,1], data[:,:,2], data[:,:,3]
|
||||
|
||||
# Detect edges (where color changes rapidly = outlines!)
|
||||
from scipy import ndimage
|
||||
|
||||
# Calculate gradients (more sensitive)
|
||||
grad_r = ndimage.sobel(r)
|
||||
grad_g = ndimage.sobel(g)
|
||||
grad_b = ndimage.sobel(b)
|
||||
|
||||
# Magnitude of gradient
|
||||
magnitude = np.sqrt(grad_r**2 + grad_g**2 + grad_b**2)
|
||||
|
||||
# Areas with high gradient = edges (keep these!)
|
||||
# Lowered threshold to preserve more edges
|
||||
is_edge = magnitude > 30
|
||||
|
||||
# Create mask: dark areas (all channels low) AND not an edge
|
||||
# More aggressive: also catch dark gray (60 instead of 30)
|
||||
black_mask = (
|
||||
(r < tolerance) &
|
||||
(g < tolerance) &
|
||||
(b < tolerance) &
|
||||
~is_edge # NOT an edge!
|
||||
)
|
||||
|
||||
# Set alpha to 0 where background detected
|
||||
data[black_mask, 3] = 0
|
||||
|
||||
return Image.fromarray(data, mode='RGBA')
|
||||
|
||||
def remove_color_background(img, sample_size=10, tolerance=30):
|
||||
"""
|
||||
Remove colored background by sampling corners
|
||||
"""
|
||||
# Convert to RGBA
|
||||
img = img.convert('RGBA')
|
||||
data = np.array(img)
|
||||
width, height = img.size
|
||||
|
||||
# Sample corner to get BG color
|
||||
corner = img.crop((0, 0, sample_size, sample_size))
|
||||
bg_color = np.array(corner).mean(axis=(0, 1))[:3]
|
||||
|
||||
r, g, b, a = data[:,:,0], data[:,:,1], data[:,:,2], data[:,:,3]
|
||||
|
||||
# Create mask: colors similar to background
|
||||
color_diff = np.sqrt(
|
||||
(r - bg_color[0])**2 +
|
||||
(g - bg_color[1])**2 +
|
||||
(b - bg_color[2])**2
|
||||
)
|
||||
|
||||
bg_mask = color_diff < tolerance
|
||||
|
||||
# Set alpha to 0 where background detected
|
||||
data[bg_mask, 3] = 0
|
||||
|
||||
return Image.fromarray(data, mode='RGBA')
|
||||
|
||||
def cleanup_edges(img):
|
||||
"""
|
||||
Clean up semi-transparent edge pixels (anti-aliasing artifacts)
|
||||
+ Add subtle feathering for smoother edges
|
||||
"""
|
||||
from scipy import ndimage
|
||||
|
||||
data = np.array(img)
|
||||
alpha = data[:,:,3].astype(float)
|
||||
|
||||
# Slightly blur the alpha channel for smooth edges
|
||||
alpha_blurred = ndimage.gaussian_filter(alpha, sigma=0.5)
|
||||
|
||||
# Remove very transparent pixels (< 10% opacity)
|
||||
alpha_blurred[alpha_blurred < 25] = 0
|
||||
|
||||
# Make mostly opaque pixels fully opaque (> 90%)
|
||||
alpha_blurred[alpha_blurred > 230] = 255
|
||||
|
||||
data[:,:,3] = alpha_blurred.astype(np.uint8)
|
||||
|
||||
return Image.fromarray(data, mode='RGBA')
|
||||
|
||||
def remove_background_auto(img_path: Path, output_path: Path = None, dry_run: bool = False):
|
||||
"""
|
||||
Automatically detect and remove background
|
||||
"""
|
||||
try:
|
||||
# Load image
|
||||
img = Image.open(img_path)
|
||||
|
||||
# Skip if already has transparency
|
||||
if img.mode == 'RGBA':
|
||||
data = np.array(img)
|
||||
alpha = data[:,:,3]
|
||||
# If already has transparent pixels, skip
|
||||
if (alpha < 255).sum() > (alpha.size * 0.1): # >10% transparent
|
||||
print(f" ⏭️ Already has transparency: {img_path.name}")
|
||||
return False
|
||||
|
||||
# Detect background type
|
||||
bg_type = detect_background_color(img)
|
||||
|
||||
if bg_type is None:
|
||||
print(f" ⚠️ Could not detect background: {img_path.name}")
|
||||
return False
|
||||
|
||||
print(f" 🎨 Detected {bg_type} background: {img_path.name}")
|
||||
|
||||
# Remove background based on type
|
||||
if bg_type == 'white':
|
||||
result = remove_white_background(img, tolerance=30)
|
||||
elif bg_type == 'black':
|
||||
result = remove_black_background(img, tolerance=60) # More aggressive!
|
||||
else:
|
||||
result = remove_color_background(img, tolerance=30)
|
||||
|
||||
# Cleanup edges
|
||||
result = cleanup_edges(result)
|
||||
|
||||
# Save
|
||||
if output_path is None:
|
||||
output_path = img_path
|
||||
|
||||
if not dry_run:
|
||||
result.save(output_path, 'PNG', optimize=True)
|
||||
print(f" ✅ Removed {bg_type} background: {img_path.name}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error processing {img_path.name}: {e}")
|
||||
return False
|
||||
|
||||
def process_directory(directory: Path, dry_run: bool = False, recursive: bool = True):
|
||||
"""
|
||||
Process all PNG files in directory
|
||||
"""
|
||||
print(f"\n{'='*70}")
|
||||
print(f"📂 Processing: {directory}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
if recursive:
|
||||
png_files = list(directory.rglob('*.png'))
|
||||
else:
|
||||
png_files = list(directory.glob('*.png'))
|
||||
|
||||
if not png_files:
|
||||
print(" No PNG files found")
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for png_file in png_files:
|
||||
if remove_background_auto(png_file, dry_run=dry_run):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='Advanced background removal for game assets')
|
||||
parser.add_argument('path', type=str, help='File or directory to process')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Show what would be done')
|
||||
parser.add_argument('--no-recursive', action='store_true', help='Do not process subdirectories')
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 70)
|
||||
print("🧹 ADVANCED BACKGROUND REMOVAL")
|
||||
print("=" * 70)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n⚠️ DRY RUN MODE - No changes will be made\n")
|
||||
|
||||
print("\nFeatures:")
|
||||
print(" • Auto-detects white, black, or colored backgrounds")
|
||||
print(" • Preserves cartoon outlines and edges")
|
||||
print(" • Creates clean transparent PNGs")
|
||||
print("=" * 70)
|
||||
|
||||
path = Path(args.path)
|
||||
|
||||
if path.is_file():
|
||||
remove_background_auto(path, dry_run=args.dry_run)
|
||||
elif path.is_dir():
|
||||
total = process_directory(path, dry_run=args.dry_run, recursive=not args.no_recursive)
|
||||
print(f"\n{'='*70}")
|
||||
if not args.dry_run:
|
||||
print(f"✅ COMPLETE! Processed {total} images")
|
||||
else:
|
||||
print(f"✅ DRY RUN COMPLETE! Would process {total} images")
|
||||
print("=" * 70)
|
||||
else:
|
||||
print(f"❌ Error: {path} is not a valid file or directory")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user