Spaces:
Running
Running
Upload 4 files
Browse filesinitial commit of files
- Stencil.py +326 -0
- StencilCV.py +369 -0
- app.py +389 -0
- requirements.txt +18 -0
Stencil.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stencil Image Generator using Stable Diffusion
|
| 3 |
+
|
| 4 |
+
This module provides a simple interface to generate drawing stencil images
|
| 5 |
+
using pretrained Stable Diffusion models with prompt engineering.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler
|
| 10 |
+
from PIL import Image, ImageOps, ImageEnhance, ImageFilter
|
| 11 |
+
from typing import Optional, List, Union
|
| 12 |
+
import os
|
| 13 |
+
import numpy as np
|
| 14 |
+
from scipy import ndimage
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _patch_clip_init():
|
| 18 |
+
"""
|
| 19 |
+
Monkey-patch CLIPTextModel.__init__ to ignore offload_state_dict parameter.
|
| 20 |
+
This fixes compatibility issues between mismatched transformers versions.
|
| 21 |
+
"""
|
| 22 |
+
try:
|
| 23 |
+
from transformers import CLIPTextModel
|
| 24 |
+
original_init = CLIPTextModel.__init__
|
| 25 |
+
|
| 26 |
+
def patched_init(self, config, *args, **kwargs):
|
| 27 |
+
# Remove the offload_state_dict parameter if it exists
|
| 28 |
+
kwargs.pop('offload_state_dict', None)
|
| 29 |
+
return original_init(self, config, *args, **kwargs)
|
| 30 |
+
|
| 31 |
+
CLIPTextModel.__init__ = patched_init
|
| 32 |
+
except ImportError:
|
| 33 |
+
pass # transformers not installed yet
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class StencilGenerator:
|
| 37 |
+
"""
|
| 38 |
+
A class to generate drawing stencil images using Stable Diffusion.
|
| 39 |
+
|
| 40 |
+
This generator automatically appends stencil-specific prompt decorations
|
| 41 |
+
to guide the model toward producing black and white stencil-style images.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(
|
| 45 |
+
self,
|
| 46 |
+
model_id: str = "stabilityai/stable-diffusion-2-1-base",
|
| 47 |
+
device: Optional[str] = None,
|
| 48 |
+
use_fp16: bool = True
|
| 49 |
+
):
|
| 50 |
+
"""
|
| 51 |
+
Initialize the Stencil Generator.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
model_id: HuggingFace model ID for Stable Diffusion model
|
| 55 |
+
device: Device to run on ('cuda', 'cpu', or None for auto-detect)
|
| 56 |
+
use_fp16: Whether to use half precision (FP16) for faster inference
|
| 57 |
+
"""
|
| 58 |
+
self.model_id = model_id
|
| 59 |
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 60 |
+
self.use_fp16 = use_fp16 and self.device == "cuda"
|
| 61 |
+
|
| 62 |
+
# Apply monkey-patch to fix transformers version compatibility
|
| 63 |
+
_patch_clip_init()
|
| 64 |
+
|
| 65 |
+
print(f"Loading model {model_id} on {self.device}...")
|
| 66 |
+
|
| 67 |
+
# Load the pipeline with version-compatible parameters
|
| 68 |
+
dtype = torch.float16 if self.use_fp16 else torch.float32
|
| 69 |
+
|
| 70 |
+
self.pipe = StableDiffusionPipeline.from_pretrained(
|
| 71 |
+
model_id,
|
| 72 |
+
torch_dtype=dtype,
|
| 73 |
+
safety_checker=None, # Disable for faster loading
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Use DPM-Solver for faster generation
|
| 77 |
+
self.pipe.scheduler = DPMSolverMultistepScheduler.from_config(
|
| 78 |
+
self.pipe.scheduler.config
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
self.pipe = self.pipe.to(self.device)
|
| 82 |
+
|
| 83 |
+
# Enable memory optimizations
|
| 84 |
+
if self.device == "cuda":
|
| 85 |
+
self.pipe.enable_attention_slicing()
|
| 86 |
+
# Uncomment if you have limited VRAM
|
| 87 |
+
# self.pipe.enable_vae_slicing()
|
| 88 |
+
|
| 89 |
+
print("Model loaded successfully!")
|
| 90 |
+
|
| 91 |
+
# Default stencil prompt suffix - simplified since post-processing does the heavy lifting
|
| 92 |
+
self.stencil_suffix = (
|
| 93 |
+
"black silhouette, high contrast, simple stencil design, "
|
| 94 |
+
"centered in frame, complete object visible, isolated subject"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Default negative prompt to avoid unwanted features
|
| 98 |
+
# self.default_negative_prompt = (
|
| 99 |
+
# "color, colorful, photograph, realistic, detailed, complex, "
|
| 100 |
+
# "blurry, low quality, watermark, text, cropped, cut off, "
|
| 101 |
+
# "partial, multiple subjects, duplicate"
|
| 102 |
+
# )
|
| 103 |
+
|
| 104 |
+
# Simpler stencil prompt suffix (seems to work better) - simplified since post-processing does the heavy lifting
|
| 105 |
+
# self.stencil_suffix = (
|
| 106 |
+
# "black silhouette, high contrast, sketch line drawing, simple, simple stencil design, white background, "
|
| 107 |
+
# # "centered in frame, complete object visible, isolated subject"
|
| 108 |
+
# )
|
| 109 |
+
|
| 110 |
+
# Simpler negative prompt (seems to work better) to avoid unwanted features
|
| 111 |
+
self.default_negative_prompt = (
|
| 112 |
+
"color, colorful, photograph, realistic, detailed, complex, "
|
| 113 |
+
# "blurry, low quality, watermark, text, cropped, cut off, "
|
| 114 |
+
# "partial, multiple subjects, duplicate"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def _clean_stencil_image(
|
| 118 |
+
self,
|
| 119 |
+
image: Image.Image,
|
| 120 |
+
binary_threshold: int = 128,
|
| 121 |
+
invert_if_needed: bool = True,
|
| 122 |
+
remove_small_objects: bool = True,
|
| 123 |
+
min_object_size: int = 100
|
| 124 |
+
) -> Image.Image:
|
| 125 |
+
"""
|
| 126 |
+
Aggressively convert any image to a clean binary stencil.
|
| 127 |
+
This uses Otsu's method and morphological operations to force
|
| 128 |
+
a clean black silhouette on pure white background, regardless
|
| 129 |
+
of what the model generated.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
image: Input PIL Image
|
| 133 |
+
binary_threshold: Threshold for binarization (0-255), 128 = middle
|
| 134 |
+
invert_if_needed: Auto-detect if we need to invert (black on white vs white on black)
|
| 135 |
+
remove_small_objects: Remove small noise/artifacts
|
| 136 |
+
min_object_size: Minimum pixel area to keep (removes noise)
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
Pure black and white stencil image
|
| 140 |
+
"""
|
| 141 |
+
# Convert to grayscale first
|
| 142 |
+
if image.mode != 'L':
|
| 143 |
+
image = image.convert('L')
|
| 144 |
+
|
| 145 |
+
# Convert to numpy array
|
| 146 |
+
img_array = np.array(image)
|
| 147 |
+
|
| 148 |
+
# Apply Otsu's method for automatic threshold detection
|
| 149 |
+
# This finds the optimal threshold to separate foreground/background
|
| 150 |
+
try:
|
| 151 |
+
from skimage.filters import threshold_otsu
|
| 152 |
+
binary_threshold = threshold_otsu(img_array)
|
| 153 |
+
except ImportError:
|
| 154 |
+
# Fall back to simple threshold if skimage not available
|
| 155 |
+
binary_threshold = 128
|
| 156 |
+
|
| 157 |
+
# Apply binary threshold - create stark black and white
|
| 158 |
+
binary = img_array > binary_threshold
|
| 159 |
+
|
| 160 |
+
# Decide if we need to invert (we want black subject on white background)
|
| 161 |
+
if invert_if_needed:
|
| 162 |
+
# Count pixels - if more white than black, we likely have black subject on white (correct)
|
| 163 |
+
# If more black than white, we have white subject on black (need to invert)
|
| 164 |
+
white_pixels = np.sum(binary)
|
| 165 |
+
total_pixels = binary.size
|
| 166 |
+
if white_pixels < total_pixels / 2:
|
| 167 |
+
# More black than white - invert
|
| 168 |
+
binary = ~binary
|
| 169 |
+
|
| 170 |
+
# Remove small objects (noise/artifacts)
|
| 171 |
+
if remove_small_objects:
|
| 172 |
+
try:
|
| 173 |
+
from scipy.ndimage import label, sum as ndi_sum
|
| 174 |
+
# Label connected components
|
| 175 |
+
labeled_array, num_features = label(~binary) # Invert for labeling dark regions
|
| 176 |
+
|
| 177 |
+
# Calculate size of each component
|
| 178 |
+
component_sizes = ndi_sum(~binary, labeled_array, range(num_features + 1))
|
| 179 |
+
|
| 180 |
+
# Remove small components
|
| 181 |
+
mask_size = component_sizes < min_object_size
|
| 182 |
+
remove_pixel = mask_size[labeled_array]
|
| 183 |
+
binary[remove_pixel] = True # Set to white (background)
|
| 184 |
+
except ImportError:
|
| 185 |
+
pass # Skip if scipy not available
|
| 186 |
+
|
| 187 |
+
# Apply slight morphological closing to fill small holes in the subject
|
| 188 |
+
try:
|
| 189 |
+
from scipy.ndimage import binary_closing
|
| 190 |
+
binary = binary_closing(binary, structure=np.ones((3, 3)))
|
| 191 |
+
except ImportError:
|
| 192 |
+
pass
|
| 193 |
+
|
| 194 |
+
# Convert boolean array to uint8 (True->255, False->0)
|
| 195 |
+
result = (binary * 255).astype(np.uint8)
|
| 196 |
+
|
| 197 |
+
# Convert back to PIL Image
|
| 198 |
+
cleaned_image = Image.fromarray(result, mode='L').convert('RGB')
|
| 199 |
+
|
| 200 |
+
return cleaned_image
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def generate(
|
| 205 |
+
self,
|
| 206 |
+
prompt: str,
|
| 207 |
+
num_images: int = 1,
|
| 208 |
+
negative_prompt: Optional[str] = None,
|
| 209 |
+
num_inference_steps: int = 25,
|
| 210 |
+
guidance_scale: float = 7.5,
|
| 211 |
+
width: int = 512,
|
| 212 |
+
height: int = 512,
|
| 213 |
+
seed: Optional[int] = None,
|
| 214 |
+
add_stencil_suffix: bool = True,
|
| 215 |
+
clean_background: bool = True,
|
| 216 |
+
) -> Union[Image.Image, List[Image.Image]]:
|
| 217 |
+
"""
|
| 218 |
+
Generate stencil images based on the prompt.
|
| 219 |
+
|
| 220 |
+
Args:
|
| 221 |
+
prompt: Base text prompt describing what to draw
|
| 222 |
+
negative_prompt: Things to avoid in the generation
|
| 223 |
+
num_images: Number of images to generate
|
| 224 |
+
num_inference_steps: Number of denoising steps (higher = better quality, slower)
|
| 225 |
+
guidance_scale: How strongly to follow the prompt (7-8 recommended)
|
| 226 |
+
width: Image width in pixels (must be divisible by 8)
|
| 227 |
+
height: Image height in pixels (must be divisible by 8)
|
| 228 |
+
seed: Random seed for reproducibility (None for random)
|
| 229 |
+
add_stencil_suffix: Whether to automatically add stencil styling to prompt
|
| 230 |
+
clean_background: Whether to post-process into pure binary stencil (highly recommended)
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
Single PIL Image if num_images=1, otherwise list of PIL Images
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# Construct full prompt
|
| 238 |
+
full_prompt = prompt
|
| 239 |
+
if add_stencil_suffix:
|
| 240 |
+
full_prompt = f"{prompt}, {self.stencil_suffix}"
|
| 241 |
+
|
| 242 |
+
# Use default negative prompt if none provided
|
| 243 |
+
full_negative_prompt = negative_prompt or self.default_negative_prompt
|
| 244 |
+
|
| 245 |
+
# Set seed if provided
|
| 246 |
+
generator = None
|
| 247 |
+
if seed is not None:
|
| 248 |
+
generator = torch.Generator(device=self.device).manual_seed(seed)
|
| 249 |
+
|
| 250 |
+
print(f"Generating {num_images} stencil image(s)...")
|
| 251 |
+
print(f"Prompt: {full_prompt}")
|
| 252 |
+
|
| 253 |
+
# Generate images
|
| 254 |
+
with torch.autocast(self.device) if self.use_fp16 else torch.no_grad():
|
| 255 |
+
result = self.pipe(
|
| 256 |
+
prompt=full_prompt,
|
| 257 |
+
num_images_per_prompt=num_images,
|
| 258 |
+
negative_prompt=full_negative_prompt,
|
| 259 |
+
num_inference_steps=num_inference_steps,
|
| 260 |
+
guidance_scale=guidance_scale,
|
| 261 |
+
width=width,
|
| 262 |
+
height=height,
|
| 263 |
+
generator=generator,
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
images = result.images
|
| 267 |
+
|
| 268 |
+
# Apply post-processing to clean background if enabled
|
| 269 |
+
if clean_background:
|
| 270 |
+
print("Cleaning background...")
|
| 271 |
+
images = [self._clean_stencil_image(img) for img in images]
|
| 272 |
+
|
| 273 |
+
print("Generation complete!")
|
| 274 |
+
|
| 275 |
+
# Return single image or list
|
| 276 |
+
return images[0] if num_images == 1 else images
|
| 277 |
+
|
| 278 |
+
def save_image(
|
| 279 |
+
self,
|
| 280 |
+
image: Image.Image,
|
| 281 |
+
output_path: str,
|
| 282 |
+
create_dirs: bool = True
|
| 283 |
+
):
|
| 284 |
+
"""
|
| 285 |
+
Save a generated image to disk.
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
image: PIL Image to save
|
| 289 |
+
output_path: Path where to save the image
|
| 290 |
+
create_dirs: Whether to create parent directories if they don't exist
|
| 291 |
+
"""
|
| 292 |
+
if create_dirs:
|
| 293 |
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
| 294 |
+
|
| 295 |
+
image.save(output_path)
|
| 296 |
+
print(f"Image saved to: {output_path}")
|
| 297 |
+
|
| 298 |
+
def generate_and_save(
|
| 299 |
+
self,
|
| 300 |
+
prompt: str,
|
| 301 |
+
output_path: str,
|
| 302 |
+
num_images: int = 1,
|
| 303 |
+
**kwargs
|
| 304 |
+
) -> Image.Image:
|
| 305 |
+
"""
|
| 306 |
+
Generate a stencil image and save it to disk in one call.
|
| 307 |
+
|
| 308 |
+
Args:
|
| 309 |
+
prompt: Base text prompt describing what to draw
|
| 310 |
+
output_path: Path where to save the image
|
| 311 |
+
**kwargs: Additional arguments passed to generate()
|
| 312 |
+
|
| 313 |
+
Returns:
|
| 314 |
+
The generated PIL Image
|
| 315 |
+
"""
|
| 316 |
+
image = self.generate(prompt, num_images, **kwargs)
|
| 317 |
+
# Save single or multiple images
|
| 318 |
+
# if numb images is 1, save directly, else save with index suffix
|
| 319 |
+
if num_images == 1:
|
| 320 |
+
self.save_image(image, output_path)
|
| 321 |
+
else:
|
| 322 |
+
for idx, img in enumerate(image):
|
| 323 |
+
path = output_path.replace(".png", f"_{idx+1}.png")
|
| 324 |
+
self.save_image(img, path)
|
| 325 |
+
return image
|
| 326 |
+
|
StencilCV.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
StencilCV - Traditional Computer Vision Approach to Stencil Generation
|
| 3 |
+
|
| 4 |
+
This module uses classical computer vision techniques (edge detection, contours,
|
| 5 |
+
thresholding) to convert images into clean stencils. No AI required - fast,
|
| 6 |
+
deterministic, and reliable.
|
| 7 |
+
|
| 8 |
+
Approaches:
|
| 9 |
+
1. Edge detection (Canny) - creates outline stencils
|
| 10 |
+
2. Contour extraction - creates filled silhouette stencils
|
| 11 |
+
3. Threshold-based - converts to binary black/white
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
from PIL import Image
|
| 17 |
+
from typing import Union, Literal
|
| 18 |
+
import os
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class StencilCV:
|
| 22 |
+
"""
|
| 23 |
+
Computer vision-based stencil generator.
|
| 24 |
+
|
| 25 |
+
Converts any input image (photo, drawing, etc.) into a clean stencil
|
| 26 |
+
using traditional image processing techniques.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(self):
|
| 30 |
+
"""Initialize the StencilCV processor."""
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
def load_image(self, image_path: str) -> np.ndarray:
|
| 34 |
+
"""
|
| 35 |
+
Load an image from file.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
image_path: Path to input image
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
Image as numpy array (BGR format)
|
| 42 |
+
"""
|
| 43 |
+
img = cv2.imread(image_path)
|
| 44 |
+
if img is None:
|
| 45 |
+
raise ValueError(f"Could not load image from {image_path}")
|
| 46 |
+
return img
|
| 47 |
+
|
| 48 |
+
def from_pil(self, pil_image: Image.Image) -> np.ndarray:
|
| 49 |
+
"""
|
| 50 |
+
Convert PIL Image to OpenCV format.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
pil_image: PIL Image object
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Image as numpy array (BGR format)
|
| 57 |
+
"""
|
| 58 |
+
# Convert PIL (RGB) to OpenCV (BGR)
|
| 59 |
+
return cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
| 60 |
+
|
| 61 |
+
def to_pil(self, cv_image: np.ndarray) -> Image.Image:
|
| 62 |
+
"""
|
| 63 |
+
Convert OpenCV image to PIL Image.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
cv_image: OpenCV image (BGR or grayscale)
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
PIL Image object
|
| 70 |
+
"""
|
| 71 |
+
if len(cv_image.shape) == 2: # Grayscale
|
| 72 |
+
return Image.fromarray(cv_image)
|
| 73 |
+
else: # BGR to RGB
|
| 74 |
+
return Image.fromarray(cv2.cvtColor(cv_image, cv2.COLOR_BGR2RGB))
|
| 75 |
+
|
| 76 |
+
def edge_stencil(
|
| 77 |
+
self,
|
| 78 |
+
image: Union[str, np.ndarray, Image.Image],
|
| 79 |
+
blur_kernel: int = 5,
|
| 80 |
+
canny_low: int = 50,
|
| 81 |
+
canny_high: int = 150,
|
| 82 |
+
line_thickness: int = 2,
|
| 83 |
+
invert: bool = False
|
| 84 |
+
) -> Image.Image:
|
| 85 |
+
"""
|
| 86 |
+
Create outline stencil using Canny edge detection.
|
| 87 |
+
Perfect for line art, coloring book style stencils.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
image: Input image (path, numpy array, or PIL Image)
|
| 91 |
+
blur_kernel: Gaussian blur kernel size (reduces noise)
|
| 92 |
+
canny_low: Canny edge detection low threshold
|
| 93 |
+
canny_high: Canny edge detection high threshold
|
| 94 |
+
line_thickness: Thickness of edge lines
|
| 95 |
+
invert: If True, white lines on black; if False, black lines on white
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
PIL Image with edge-based stencil
|
| 99 |
+
"""
|
| 100 |
+
# Load image
|
| 101 |
+
if isinstance(image, str):
|
| 102 |
+
img = self.load_image(image)
|
| 103 |
+
elif isinstance(image, Image.Image):
|
| 104 |
+
img = self.from_pil(image)
|
| 105 |
+
else:
|
| 106 |
+
img = image.copy()
|
| 107 |
+
|
| 108 |
+
# Convert to grayscale
|
| 109 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 110 |
+
|
| 111 |
+
# Apply Gaussian blur to reduce noise
|
| 112 |
+
blurred = cv2.GaussianBlur(gray, (blur_kernel, blur_kernel), 0)
|
| 113 |
+
|
| 114 |
+
# Apply Canny edge detection
|
| 115 |
+
edges = cv2.Canny(blurred, canny_low, canny_high)
|
| 116 |
+
|
| 117 |
+
# Dilate edges to make them thicker if requested
|
| 118 |
+
if line_thickness > 1:
|
| 119 |
+
kernel = np.ones((line_thickness, line_thickness), np.uint8)
|
| 120 |
+
edges = cv2.dilate(edges, kernel, iterations=1)
|
| 121 |
+
|
| 122 |
+
# Invert if needed (black on white by default)
|
| 123 |
+
if not invert:
|
| 124 |
+
edges = cv2.bitwise_not(edges)
|
| 125 |
+
|
| 126 |
+
# Convert to RGB
|
| 127 |
+
result = cv2.cvtColor(edges, cv2.COLOR_GRAY2RGB)
|
| 128 |
+
|
| 129 |
+
return self.to_pil(result)
|
| 130 |
+
|
| 131 |
+
def silhouette_stencil(
|
| 132 |
+
self,
|
| 133 |
+
image: Union[str, np.ndarray, Image.Image],
|
| 134 |
+
threshold_method: Literal['otsu', 'adaptive', 'simple'] = 'otsu',
|
| 135 |
+
threshold_value: int = 127,
|
| 136 |
+
blur_kernel: int = 5,
|
| 137 |
+
fill_holes: bool = True,
|
| 138 |
+
remove_small_objects: int = 500,
|
| 139 |
+
smooth_edges: bool = True
|
| 140 |
+
) -> Image.Image:
|
| 141 |
+
"""
|
| 142 |
+
Create filled silhouette stencil using thresholding and contours.
|
| 143 |
+
Perfect for solid stencils, like spray paint templates.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
image: Input image (path, numpy array, or PIL Image)
|
| 147 |
+
threshold_method: Method for thresholding ('otsu', 'adaptive', 'simple')
|
| 148 |
+
threshold_value: Threshold value for 'simple' method (0-255)
|
| 149 |
+
blur_kernel: Gaussian blur kernel size
|
| 150 |
+
fill_holes: Fill holes in the silhouette
|
| 151 |
+
remove_small_objects: Remove objects smaller than this (pixels)
|
| 152 |
+
smooth_edges: Apply morphological smoothing to edges
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
PIL Image with silhouette stencil
|
| 156 |
+
"""
|
| 157 |
+
# Load image
|
| 158 |
+
if isinstance(image, str):
|
| 159 |
+
img = self.load_image(image)
|
| 160 |
+
elif isinstance(image, Image.Image):
|
| 161 |
+
img = self.from_pil(image)
|
| 162 |
+
else:
|
| 163 |
+
img = image.copy()
|
| 164 |
+
|
| 165 |
+
# Convert to grayscale
|
| 166 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 167 |
+
|
| 168 |
+
# Apply Gaussian blur to reduce noise
|
| 169 |
+
blurred = cv2.GaussianBlur(gray, (blur_kernel, blur_kernel), 0)
|
| 170 |
+
|
| 171 |
+
# Apply thresholding based on method
|
| 172 |
+
if threshold_method == 'otsu':
|
| 173 |
+
# Otsu's method automatically finds optimal threshold
|
| 174 |
+
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 175 |
+
elif threshold_method == 'adaptive':
|
| 176 |
+
# Adaptive threshold - good for varying lighting
|
| 177 |
+
binary = cv2.adaptiveThreshold(
|
| 178 |
+
blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 179 |
+
cv2.THRESH_BINARY, 11, 2
|
| 180 |
+
)
|
| 181 |
+
else: # simple
|
| 182 |
+
_, binary = cv2.threshold(blurred, threshold_value, 255, cv2.THRESH_BINARY)
|
| 183 |
+
|
| 184 |
+
# Ensure we have black subject on white background
|
| 185 |
+
# (Check if more white than black, invert if needed)
|
| 186 |
+
if np.mean(binary) < 127:
|
| 187 |
+
binary = cv2.bitwise_not(binary)
|
| 188 |
+
|
| 189 |
+
# Remove small objects (noise)
|
| 190 |
+
if remove_small_objects > 0:
|
| 191 |
+
# Find contours
|
| 192 |
+
contours, _ = cv2.findContours(
|
| 193 |
+
cv2.bitwise_not(binary),
|
| 194 |
+
cv2.RETR_EXTERNAL,
|
| 195 |
+
cv2.CHAIN_APPROX_SIMPLE
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Filter contours by area and redraw
|
| 199 |
+
mask = np.ones_like(binary) * 255
|
| 200 |
+
for contour in contours:
|
| 201 |
+
area = cv2.contourArea(contour)
|
| 202 |
+
if area >= remove_small_objects:
|
| 203 |
+
cv2.drawContours(mask, [contour], -1, 0, -1)
|
| 204 |
+
|
| 205 |
+
binary = mask
|
| 206 |
+
|
| 207 |
+
# Fill holes in the silhouette
|
| 208 |
+
if fill_holes:
|
| 209 |
+
# Find contours
|
| 210 |
+
contours, hierarchy = cv2.findContours(
|
| 211 |
+
cv2.bitwise_not(binary),
|
| 212 |
+
cv2.RETR_CCOMP,
|
| 213 |
+
cv2.CHAIN_APPROX_SIMPLE
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# Fill all contours (including holes)
|
| 217 |
+
mask = np.ones_like(binary) * 255
|
| 218 |
+
for i, contour in enumerate(contours):
|
| 219 |
+
# Only draw external contours (fill holes)
|
| 220 |
+
if hierarchy[0][i][3] == -1: # External contour
|
| 221 |
+
cv2.drawContours(mask, [contour], -1, 0, -1)
|
| 222 |
+
|
| 223 |
+
binary = mask
|
| 224 |
+
|
| 225 |
+
# Smooth edges using morphological operations
|
| 226 |
+
if smooth_edges:
|
| 227 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 228 |
+
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
|
| 229 |
+
binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
|
| 230 |
+
|
| 231 |
+
# Convert to RGB
|
| 232 |
+
result = cv2.cvtColor(binary, cv2.COLOR_GRAY2RGB)
|
| 233 |
+
|
| 234 |
+
return self.to_pil(result)
|
| 235 |
+
|
| 236 |
+
def hybrid_stencil(
|
| 237 |
+
self,
|
| 238 |
+
image: Union[str, np.ndarray, Image.Image],
|
| 239 |
+
show_edges: bool = True,
|
| 240 |
+
show_fill: bool = True,
|
| 241 |
+
edge_thickness: int = 2
|
| 242 |
+
) -> Image.Image:
|
| 243 |
+
"""
|
| 244 |
+
Create hybrid stencil with both edges and filled regions.
|
| 245 |
+
Combines edge detection with silhouette for detailed stencils.
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
image: Input image (path, numpy array, or PIL Image)
|
| 249 |
+
show_edges: Include edge lines
|
| 250 |
+
show_fill: Include filled silhouette
|
| 251 |
+
edge_thickness: Thickness of edge lines
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
PIL Image with hybrid stencil
|
| 255 |
+
"""
|
| 256 |
+
# Load image
|
| 257 |
+
if isinstance(image, str):
|
| 258 |
+
img = self.load_image(image)
|
| 259 |
+
elif isinstance(image, Image.Image):
|
| 260 |
+
img = self.from_pil(image)
|
| 261 |
+
else:
|
| 262 |
+
img = image.copy()
|
| 263 |
+
|
| 264 |
+
# Start with white canvas
|
| 265 |
+
result = np.ones_like(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) * 255
|
| 266 |
+
|
| 267 |
+
# Add filled silhouette if requested
|
| 268 |
+
if show_fill:
|
| 269 |
+
silhouette = self.silhouette_stencil(img)
|
| 270 |
+
silhouette_cv = self.from_pil(silhouette)
|
| 271 |
+
silhouette_gray = cv2.cvtColor(silhouette_cv, cv2.COLOR_BGR2GRAY)
|
| 272 |
+
result = cv2.bitwise_and(result, silhouette_gray)
|
| 273 |
+
|
| 274 |
+
# Add edges if requested
|
| 275 |
+
if show_edges:
|
| 276 |
+
edges = self.edge_stencil(img, line_thickness=edge_thickness, invert=True)
|
| 277 |
+
edges_cv = self.from_pil(edges)
|
| 278 |
+
edges_gray = cv2.cvtColor(edges_cv, cv2.COLOR_BGR2GRAY)
|
| 279 |
+
result = cv2.bitwise_and(result, edges_gray)
|
| 280 |
+
|
| 281 |
+
# Convert to RGB
|
| 282 |
+
result_rgb = cv2.cvtColor(result, cv2.COLOR_GRAY2RGB)
|
| 283 |
+
|
| 284 |
+
return self.to_pil(result_rgb)
|
| 285 |
+
|
| 286 |
+
def auto_stencil(
|
| 287 |
+
self,
|
| 288 |
+
image: Union[str, np.ndarray, Image.Image],
|
| 289 |
+
style: Literal['outline', 'filled', 'hybrid'] = 'filled'
|
| 290 |
+
) -> Image.Image:
|
| 291 |
+
"""
|
| 292 |
+
Automatically create a stencil with sensible defaults.
|
| 293 |
+
|
| 294 |
+
Args:
|
| 295 |
+
image: Input image (path, numpy array, or PIL Image)
|
| 296 |
+
style: Style of stencil ('outline', 'filled', 'hybrid')
|
| 297 |
+
|
| 298 |
+
Returns:
|
| 299 |
+
PIL Image with stencil
|
| 300 |
+
"""
|
| 301 |
+
if style == 'outline':
|
| 302 |
+
return self.edge_stencil(image)
|
| 303 |
+
elif style == 'filled':
|
| 304 |
+
return self.silhouette_stencil(image)
|
| 305 |
+
else: # hybrid
|
| 306 |
+
return self.hybrid_stencil(image)
|
| 307 |
+
|
| 308 |
+
def save(self, image: Image.Image, output_path: str):
|
| 309 |
+
"""
|
| 310 |
+
Save stencil image to file.
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
image: PIL Image to save
|
| 314 |
+
output_path: Path to save image
|
| 315 |
+
"""
|
| 316 |
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
| 317 |
+
image.save(output_path)
|
| 318 |
+
print(f"Saved stencil to: {output_path}")
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def main():
|
| 322 |
+
"""Example usage demonstrating different stencil styles."""
|
| 323 |
+
|
| 324 |
+
print("StencilCV - Computer Vision Stencil Generator")
|
| 325 |
+
print("=" * 60)
|
| 326 |
+
|
| 327 |
+
# Note: You'll need input images to test this
|
| 328 |
+
# For now, let's create a simple example
|
| 329 |
+
|
| 330 |
+
# Create a sample image (circle)
|
| 331 |
+
print("\nCreating sample image...")
|
| 332 |
+
sample = np.ones((512, 512, 3), dtype=np.uint8) * 255
|
| 333 |
+
cv2.circle(sample, (256, 256), 100, (0, 0, 0), -1)
|
| 334 |
+
sample_path = "sample_input.png"
|
| 335 |
+
cv2.imwrite(sample_path, sample)
|
| 336 |
+
print(f"Created sample image: {sample_path}")
|
| 337 |
+
|
| 338 |
+
# Initialize processor
|
| 339 |
+
processor = StencilCV()
|
| 340 |
+
|
| 341 |
+
# Create output directory
|
| 342 |
+
output_dir = "output_cv_stencils"
|
| 343 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 344 |
+
|
| 345 |
+
# Example 1: Edge/Outline stencil
|
| 346 |
+
print("\n1. Creating outline stencil...")
|
| 347 |
+
outline = processor.edge_stencil(sample_path)
|
| 348 |
+
processor.save(outline, f"{output_dir}/outline_stencil.png")
|
| 349 |
+
|
| 350 |
+
# Example 2: Filled silhouette stencil
|
| 351 |
+
print("2. Creating filled silhouette stencil...")
|
| 352 |
+
filled = processor.silhouette_stencil(sample_path)
|
| 353 |
+
processor.save(filled, f"{output_dir}/filled_stencil.png")
|
| 354 |
+
|
| 355 |
+
# Example 3: Hybrid stencil
|
| 356 |
+
print("3. Creating hybrid stencil...")
|
| 357 |
+
hybrid = processor.hybrid_stencil(sample_path)
|
| 358 |
+
processor.save(hybrid, f"{output_dir}/hybrid_stencil.png")
|
| 359 |
+
|
| 360 |
+
print(f"\n{'=' * 60}")
|
| 361 |
+
print(f"All stencils saved to: {output_dir}/")
|
| 362 |
+
print("\nTo use with your own images:")
|
| 363 |
+
print(" processor = StencilCV()")
|
| 364 |
+
print(" stencil = processor.auto_stencil('your_image.jpg', style='filled')")
|
| 365 |
+
print(" processor.save(stencil, 'output.png')")
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
if __name__ == "__main__":
|
| 369 |
+
main()
|
app.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio Web Interface for Stencil Image Generator
|
| 3 |
+
|
| 4 |
+
This module provides a web-based UI for the Stencil Generator using Gradio.
|
| 5 |
+
Run this file to launch the interactive web interface.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import gradio as gr
|
| 9 |
+
from Stencil import StencilGenerator
|
| 10 |
+
from StencilCV import StencilCV
|
| 11 |
+
import torch
|
| 12 |
+
from typing import Optional
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
MAX_IMAGES = 4
|
| 16 |
+
|
| 17 |
+
class StencilApp:
|
| 18 |
+
"""Wrapper class for the Gradio application."""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
"""Initialize the Stencil Generator."""
|
| 22 |
+
self.generator = None
|
| 23 |
+
self.original_images = [] # Store original images for toggling
|
| 24 |
+
self.outlined_status = [] # Track which images have outline applied
|
| 25 |
+
|
| 26 |
+
def load_model(self):
|
| 27 |
+
"""Lazy load the model when first needed."""
|
| 28 |
+
if self.generator is None:
|
| 29 |
+
print("Initializing Stencil Generator...")
|
| 30 |
+
self.generator = StencilGenerator(
|
| 31 |
+
model_id="stabilityai/stable-diffusion-2-1-base",
|
| 32 |
+
use_fp16=torch.cuda.is_available()
|
| 33 |
+
)
|
| 34 |
+
return self.generator
|
| 35 |
+
|
| 36 |
+
def generate_stencil(
|
| 37 |
+
self,
|
| 38 |
+
prompt: str,
|
| 39 |
+
negative_prompt: Optional[str],
|
| 40 |
+
num_images: int,
|
| 41 |
+
num_inference_steps: int,
|
| 42 |
+
guidance_scale: float,
|
| 43 |
+
width: int,
|
| 44 |
+
height: int,
|
| 45 |
+
seed: int,
|
| 46 |
+
use_seed: bool,
|
| 47 |
+
add_stencil_suffix: bool,
|
| 48 |
+
clean_background: bool
|
| 49 |
+
):
|
| 50 |
+
"""
|
| 51 |
+
Generate stencil images based on user inputs.
|
| 52 |
+
|
| 53 |
+
This is the main function called by the Gradio interface.
|
| 54 |
+
"""
|
| 55 |
+
if not prompt or prompt.strip() == "":
|
| 56 |
+
return [], "Please enter a prompt!"
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
# Load model if not already loaded
|
| 60 |
+
generator = self.load_model()
|
| 61 |
+
|
| 62 |
+
# Generate the image(s)
|
| 63 |
+
images = generator.generate(
|
| 64 |
+
prompt=prompt,
|
| 65 |
+
negative_prompt=negative_prompt if negative_prompt else None,
|
| 66 |
+
num_images=num_images,
|
| 67 |
+
num_inference_steps=num_inference_steps,
|
| 68 |
+
guidance_scale=guidance_scale,
|
| 69 |
+
width=width,
|
| 70 |
+
height=height,
|
| 71 |
+
seed=seed if use_seed else None,
|
| 72 |
+
add_stencil_suffix=add_stencil_suffix,
|
| 73 |
+
clean_background=clean_background
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Ensure images is a list
|
| 77 |
+
if not isinstance(images, list):
|
| 78 |
+
images = [images]
|
| 79 |
+
|
| 80 |
+
# Store original images and reset outlined status
|
| 81 |
+
self.original_images = [img.copy() for img in images]
|
| 82 |
+
self.outlined_status = [False] * len(images)
|
| 83 |
+
|
| 84 |
+
return images, f"Generation successful! Created {len(images)} image(s)."
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
return [], f"Error: {str(e)}"
|
| 88 |
+
|
| 89 |
+
def apply_outline(self, gallery_data, selected_index):
|
| 90 |
+
"""
|
| 91 |
+
Toggle outline processing on a selected image using StencilCV.
|
| 92 |
+
If the image has outline applied, revert to original. Otherwise, apply outline.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
gallery_data: Gallery data from Gradio (list of images or tuples)
|
| 96 |
+
selected_index: Index of the selected image (from gr.Gallery select event)
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
Updated gallery and status message
|
| 100 |
+
"""
|
| 101 |
+
# print(f"DEBUG: apply_outline called")
|
| 102 |
+
# print(f"DEBUG: gallery_data type: {type(gallery_data)}")
|
| 103 |
+
# print(f"DEBUG: gallery_data length: {len(gallery_data) if gallery_data else 0}")
|
| 104 |
+
# print(f"DEBUG: selected_index: {selected_index}")
|
| 105 |
+
|
| 106 |
+
if not gallery_data:
|
| 107 |
+
return gallery_data, "No images to process!"
|
| 108 |
+
|
| 109 |
+
if selected_index is None:
|
| 110 |
+
return gallery_data, "Please select an image first by clicking on it!"
|
| 111 |
+
|
| 112 |
+
if selected_index >= len(self.original_images):
|
| 113 |
+
return gallery_data, "Error: Image index out of range!"
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
# Create a copy of the gallery data
|
| 117 |
+
updated_gallery = list(gallery_data)
|
| 118 |
+
|
| 119 |
+
# Check if this image already has outline applied
|
| 120 |
+
if self.outlined_status[selected_index]:
|
| 121 |
+
# Revert to original
|
| 122 |
+
# print(f"DEBUG: Reverting image {selected_index} to original")
|
| 123 |
+
updated_gallery[selected_index] = self.original_images[selected_index].copy()
|
| 124 |
+
self.outlined_status[selected_index] = False
|
| 125 |
+
return updated_gallery, f"Reverted image {selected_index + 1} to original."
|
| 126 |
+
else:
|
| 127 |
+
# Apply outline
|
| 128 |
+
# print(f"DEBUG: Applying outline to image {selected_index}")
|
| 129 |
+
|
| 130 |
+
# Initialize StencilCV processor
|
| 131 |
+
processor = StencilCV()
|
| 132 |
+
|
| 133 |
+
# Get the original image (not the gallery one, to ensure consistency)
|
| 134 |
+
original_img = self.original_images[selected_index]
|
| 135 |
+
|
| 136 |
+
# print(f"DEBUG: Applying edge_stencil...")
|
| 137 |
+
# Apply outline to the original image
|
| 138 |
+
outlined = processor.edge_stencil(original_img)
|
| 139 |
+
# print(f"DEBUG: Outline applied successfully!")
|
| 140 |
+
|
| 141 |
+
# Update gallery with outlined version
|
| 142 |
+
updated_gallery[selected_index] = outlined
|
| 143 |
+
self.outlined_status[selected_index] = True
|
| 144 |
+
|
| 145 |
+
return updated_gallery, f"Applied outline to image {selected_index + 1}. Click again to revert."
|
| 146 |
+
|
| 147 |
+
except Exception as e:
|
| 148 |
+
import traceback
|
| 149 |
+
print("DEBUG: Exception occurred:")
|
| 150 |
+
traceback.print_exc()
|
| 151 |
+
return gallery_data, f"Error applying outline: {str(e)}"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def create_interface():
|
| 155 |
+
"""Create and configure the Gradio interface."""
|
| 156 |
+
|
| 157 |
+
app = StencilApp()
|
| 158 |
+
|
| 159 |
+
# Define the interface
|
| 160 |
+
with gr.Blocks(title="Stencil Image Generator", theme=gr.themes.Soft()) as interface:
|
| 161 |
+
|
| 162 |
+
gr.Markdown(
|
| 163 |
+
"""
|
| 164 |
+
# 🎨 Stencil Image Generator
|
| 165 |
+
|
| 166 |
+
Generate black and white stencil-style images using AI. Perfect for creating
|
| 167 |
+
cutting templates, vector art, and silhouette designs.
|
| 168 |
+
"""
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
with gr.Row():
|
| 172 |
+
with gr.Column(scale=1):
|
| 173 |
+
# Input controls
|
| 174 |
+
prompt = gr.Textbox(
|
| 175 |
+
label="Prompt",
|
| 176 |
+
placeholder="e.g., a cat sitting, a tree with spreading branches...",
|
| 177 |
+
lines=3
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
num_images = gr.Slider(
|
| 181 |
+
minimum=1,
|
| 182 |
+
maximum=MAX_IMAGES,
|
| 183 |
+
value=1,
|
| 184 |
+
step=1,
|
| 185 |
+
label="Number of Images",
|
| 186 |
+
info="Generate multiple variations to choose from"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
with gr.Accordion("Advanced Settings", open=False):
|
| 190 |
+
negative_prompt = gr.Textbox(
|
| 191 |
+
label="Negative Prompt (optional)",
|
| 192 |
+
placeholder="Things to avoid in the generation...",
|
| 193 |
+
lines=2
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
add_stencil_suffix = gr.Checkbox(
|
| 197 |
+
label="Add stencil styling suffix(recommended)",
|
| 198 |
+
value=True,
|
| 199 |
+
info="Automatically adds stencil-specific styling to your prompt (prompt decorations)"
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
clean_background = gr.Checkbox(
|
| 203 |
+
label="Clean white background (recommended)",
|
| 204 |
+
value=True,
|
| 205 |
+
info="Post-process to ensure pure white background and remove artifacts"
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
num_inference_steps = gr.Slider(
|
| 209 |
+
minimum=10,
|
| 210 |
+
maximum=50,
|
| 211 |
+
value=25,
|
| 212 |
+
step=5,
|
| 213 |
+
label="Inference Steps",
|
| 214 |
+
info="Higher = better quality but slower"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
guidance_scale = gr.Slider(
|
| 218 |
+
minimum=1,
|
| 219 |
+
maximum=15,
|
| 220 |
+
value=7.5,
|
| 221 |
+
step=0.5,
|
| 222 |
+
label="Guidance Scale",
|
| 223 |
+
info="How closely to follow the prompt (7-8 recommended)"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
with gr.Row():
|
| 227 |
+
width = gr.Slider(
|
| 228 |
+
minimum=256,
|
| 229 |
+
maximum=1024,
|
| 230 |
+
value=512,
|
| 231 |
+
step=64,
|
| 232 |
+
label="Width"
|
| 233 |
+
)
|
| 234 |
+
height = gr.Slider(
|
| 235 |
+
minimum=256,
|
| 236 |
+
maximum=1024,
|
| 237 |
+
value=512,
|
| 238 |
+
step=64,
|
| 239 |
+
label="Height"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
with gr.Row():
|
| 243 |
+
use_seed = gr.Checkbox(
|
| 244 |
+
label="Use fixed seed",
|
| 245 |
+
value=False,
|
| 246 |
+
info="Enable for reproducible results"
|
| 247 |
+
)
|
| 248 |
+
seed = gr.Number(
|
| 249 |
+
label="Seed",
|
| 250 |
+
value=42,
|
| 251 |
+
precision=0
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
generate_btn = gr.Button("Generate Stencil", variant="primary", size="lg")
|
| 255 |
+
|
| 256 |
+
# Example prompts
|
| 257 |
+
gr.Examples(
|
| 258 |
+
examples=[
|
| 259 |
+
["a cat sitting"],
|
| 260 |
+
["a tree with spreading branches"],
|
| 261 |
+
["a bicycle"],
|
| 262 |
+
["a coffee cup"],
|
| 263 |
+
["a bird in flight"],
|
| 264 |
+
["a deer with antlers"],
|
| 265 |
+
["a mountain landscape"],
|
| 266 |
+
["a lighthouse by the sea"]
|
| 267 |
+
],
|
| 268 |
+
inputs=prompt,
|
| 269 |
+
label="Example Prompts"
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
with gr.Column(scale=1):
|
| 273 |
+
# Output - Gallery for multiple images
|
| 274 |
+
output_gallery = gr.Gallery(
|
| 275 |
+
label="Generated Stencils (click to select)",
|
| 276 |
+
show_label=True,
|
| 277 |
+
columns=2,
|
| 278 |
+
rows=2,
|
| 279 |
+
height="auto",
|
| 280 |
+
object_fit="contain"
|
| 281 |
+
)
|
| 282 |
+
status_text = gr.Textbox(
|
| 283 |
+
label="Status",
|
| 284 |
+
interactive=False,
|
| 285 |
+
lines=1
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# Hidden state to track selected image
|
| 289 |
+
selected_image_index = gr.State(value=None)
|
| 290 |
+
|
| 291 |
+
# Post-processing section
|
| 292 |
+
with gr.Accordion("Post-Processing Options", open=False):
|
| 293 |
+
gr.Markdown(
|
| 294 |
+
"""
|
| 295 |
+
**Outline Generation**: Click an image above to select it, then click the button below
|
| 296 |
+
to toggle outline processing. This creates a line-art effect and works best on images
|
| 297 |
+
with clear subjects. Click the button again to revert to the original.
|
| 298 |
+
|
| 299 |
+
You can toggle outline on/off for each image independently to compare styles.
|
| 300 |
+
"""
|
| 301 |
+
)
|
| 302 |
+
apply_outline_btn = gr.Button("Toggle Outline on Selected Image", variant="secondary")
|
| 303 |
+
|
| 304 |
+
gr.Markdown(
|
| 305 |
+
"""
|
| 306 |
+
### Tips for Best Results:
|
| 307 |
+
- Keep prompts simple and descriptive
|
| 308 |
+
- Generate multiple images to see variations
|
| 309 |
+
- The AI automatically adds stencil styling
|
| 310 |
+
- Use negative prompts to avoid unwanted features
|
| 311 |
+
- Try the outline option after generation for different styles
|
| 312 |
+
- Higher inference steps = better quality (but slower)
|
| 313 |
+
"""
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
# Connect the generate button
|
| 317 |
+
generate_btn.click(
|
| 318 |
+
fn=app.generate_stencil,
|
| 319 |
+
inputs=[
|
| 320 |
+
prompt,
|
| 321 |
+
negative_prompt,
|
| 322 |
+
num_images,
|
| 323 |
+
num_inference_steps,
|
| 324 |
+
guidance_scale,
|
| 325 |
+
width,
|
| 326 |
+
height,
|
| 327 |
+
seed,
|
| 328 |
+
use_seed,
|
| 329 |
+
add_stencil_suffix,
|
| 330 |
+
clean_background
|
| 331 |
+
],
|
| 332 |
+
outputs=[output_gallery, status_text]
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# Track when user selects an image in the gallery
|
| 336 |
+
def update_selection(evt: gr.SelectData):
|
| 337 |
+
print(f"DEBUG: Gallery selection event - index: {evt.index}")
|
| 338 |
+
return evt.index
|
| 339 |
+
|
| 340 |
+
output_gallery.select(
|
| 341 |
+
fn=update_selection,
|
| 342 |
+
outputs=selected_image_index
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
# Connect the outline button
|
| 346 |
+
apply_outline_btn.click(
|
| 347 |
+
fn=app.apply_outline,
|
| 348 |
+
inputs=[output_gallery, selected_image_index],
|
| 349 |
+
outputs=[output_gallery, status_text]
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
gr.Markdown(
|
| 353 |
+
"""
|
| 354 |
+
---
|
| 355 |
+
Built with [Stable Diffusion](https://stability.ai/) and [Gradio](https://gradio.app/)
|
| 356 |
+
"""
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
return interface
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def launch(
|
| 363 |
+
share: bool = False,
|
| 364 |
+
server_name: str = "0.0.0.0",
|
| 365 |
+
server_port: int = 7860,
|
| 366 |
+
**kwargs
|
| 367 |
+
):
|
| 368 |
+
"""
|
| 369 |
+
Launch the Gradio interface.
|
| 370 |
+
|
| 371 |
+
Args:
|
| 372 |
+
share: Whether to create a public shareable link
|
| 373 |
+
server_name: Server host (0.0.0.0 for public access)
|
| 374 |
+
server_port: Port to run the server on
|
| 375 |
+
**kwargs: Additional arguments passed to gradio.launch()
|
| 376 |
+
"""
|
| 377 |
+
interface = create_interface()
|
| 378 |
+
interface.launch(
|
| 379 |
+
share=share,
|
| 380 |
+
server_name=server_name,
|
| 381 |
+
server_port=server_port,
|
| 382 |
+
**kwargs
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
if __name__ == "__main__":
|
| 387 |
+
# Launch with default settings
|
| 388 |
+
# Set share=True to create a public link
|
| 389 |
+
launch(share=True, pwa=True)
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.0.0
|
| 2 |
+
diffusers>=0.21.0
|
| 3 |
+
transformers>=4.30.0
|
| 4 |
+
accelerate>=0.20.0
|
| 5 |
+
safetensors>=0.3.0
|
| 6 |
+
gradio>=4.0.0
|
| 7 |
+
numpy>=1.24.0
|
| 8 |
+
Pillow>=9.0.0
|
| 9 |
+
scipy>=1.10.0
|
| 10 |
+
scikit-image>=0.20.0
|
| 11 |
+
opencv-python>=4.8.0
|
| 12 |
+
|
| 13 |
+
# Note: Pillow, numpy, scipy, scikit-image required for AI-based post-processing
|
| 14 |
+
# opencv-python required for StencilCV (traditional computer vision approach)
|
| 15 |
+
|
| 16 |
+
# Tested with:
|
| 17 |
+
# torch==2.4.1, diffusers==0.30.3, transformers==4.44.2
|
| 18 |
+
# accelerate==1.1.1, safetensors==0.4.5, gradio==4.44.0
|