carvite / core /processor.py
Khan19970's picture
Upload 3 files
f8fb745 verified
from pathlib import Path
from typing import Union, Optional, Tuple, cast
import numpy as np
from PIL import Image
from rembg import remove, new_session
from functools import lru_cache
# maxsize=1 is intentional: only one model should be active at a time.
# Loading a new model automatically evicts the previous one, freeing VRAM/RAM.
@lru_cache(maxsize=1)
def get_session(model_name: str):
"""
Maintains a single model session in memory.
Uses LRU cache with maxsize=1 to ensure that when a new model is loaded,
the previous one is evicted to free up VRAM/RAM.
"""
return new_session(model_name)
def process_image(
input_data: Union[str, Path, Image.Image],
model_name: str,
max_size: Optional[Tuple[int, int]] = (1024, 1024),
) -> Tuple[Image.Image, np.ndarray]:
"""
The primary entry point for background removal.
This function handles:
1. Input normalization (Path/String to PIL Image).
2. Optional resizing for performance consistency.
3. Background removal via the rembg session.
4. Type-safe conversion of results into a visual Image and a boolean Mask.
Returns:
- A PIL Image (RGBA) with the background removed.
- A boolean numpy array (Mask) where True indicates foreground.
"""
# Load image if a path is provided
if isinstance(input_data, (str, Path)):
img = Image.open(input_data).convert("RGBA")
else:
img = input_data.convert("RGBA")
# Resize for consistency if max_size is provided
if max_size:
if img.width > max_size[0] or img.height > max_size[1]:
# thumbnail() preserves aspect ratio, so the output may be smaller than max_size
# on one dimension. Callers should not assume exact output dimensions.
img.thumbnail(max_size, Image.Resampling.LANCZOS)
session = get_session(model_name)
# rembg.remove returns Union[bytes, Image, ndarray].
# Since we provide an Image, it returns an Image.
result = remove(img, session=session)
# Type Guard for the checker
if not isinstance(result, Image.Image):
if isinstance(result, np.ndarray):
result = Image.fromarray(result)
else: # it's bytes
import io
result = Image.open(io.BytesIO(result)).convert("RGBA")
output_img = cast(Image.Image, result)
# Generate boolean mask from the alpha channel
alpha = np.array(output_img)[:, :, 3]
mask = alpha > 0
return output_img, mask
def detect_ground_plane(background_image: Image.Image) -> Tuple[float, float]:
"""
Detects where the ground/floor is in the background image.
The algorithm analyzes the bottom third of the background image, examining
brightness patterns and surface uniformity. Low variance (<1000) indicates
a uniform surface like a showroom floor or concrete platform.
This process is completely automatic & no manual adjustment.
Algorithm:
1. Analyzes bottom third of image (converts to RGB, extracts lower section)
2. Calculates variance of pixel values across the bottom section
3. Low variance (< 1000) = uniform floor detected (showroom/studio)
4. High variance (>= 1000) = non-uniform surface (outdoor/textured scene)
5. Returns perspective scale factor based on detected surface type
Args:
background_image (Image.Image): The background scene where the car will be placed
Returns:
tuple: (vertical_position, scale_factor)
- vertical_position (float): Always 0.92 (kept for compatibility, not used)
- scale_factor (float): 0.9 for showrooms (90% size for perspective),
1.0 for outdoor (100% normal size)
Example:
>>> from PIL import Image
>>> showroom = Image.open("showroom.jpg")
>>> ground_pos, scale = detect_ground_plane(showroom)
>>> print(f"Scale factor: {scale}")
Scale factor: 0.9
Technical Details:
- Variance threshold: 1000 (empirically determined)
- Analysis region: Bottom 33% of image (height * 2/3 to height)
- Showroom detection: Uniform tiled floors, bright surfaces
- Outdoor detection: Sky, varied textures, non-uniform lighting
"""
# Convert to numpy for analysis
img_array = np.array(background_image.convert("RGB"))
height, width = img_array.shape[:2]
# Analyze bottom section
thirds = height // 3
bottom_section = img_array[2 * thirds :, :]
bottom_variance = np.var(bottom_section)
# Determine if there's a visible floor
has_visible_floor = bottom_variance < 1000
if has_visible_floor:
# Showroom/floor - make car slightly smaller for perspective
vertical_position = 0.92 # Not used, but kept for compatibility
scale_factor = 0.9 # 90% of requested size
else:
# Outdoor/sky - normal size
vertical_position = 0.92
scale_factor = 1.0
return vertical_position, scale_factor
def smart_composite(
car_image: Image.Image,
car_mask: np.ndarray,
background_image: Image.Image,
target_car_ratio: float = 0.6,
) -> Image.Image:
"""
Intelligently composites the car onto background with scene-aware positioning.
This function ensures natural car placement by:
1. Detecting ground plane in background (showroom vs outdoor)
2. Finding actual car dimensions from mask (ignoring empty transparent space)
3. Scaling car to target size with perspective adjustment
4. Locating exact wheel positions in the scaled car
5. Aligning wheels with fixed ground line at 92% down the image
CRITICAL: The car's wheels ALWAYS touch the ground at the same vertical position
(92% down the image), regardless of car_size. Only the car's scale changes, not
its ground position. This ensures natural placement whether car_size is 50% or 80%.
Result: No floating cars. Wheels make proper contact with the floor at any size.
Args:
car_image (Image.Image): RGBA car image with transparent background (from U²-Net)
car_mask (np.ndarray): Boolean mask where True = car pixels, False = transparent
background_image (Image.Image): New background scene (showroom, garage, outdoor)
target_car_ratio (float): Car size as decimal (0.5 = 50%, 0.6 = 60%, 0.8 = 80%)
Default: 0.6 (60% of frame)
Returns:
Image.Image: RGB composite image with car naturally positioned on background
Algorithm Details:
**Ground Detection Phase:**
- Calls detect_ground_plane() to analyze background
- Returns perspective_scale: 0.9 for showrooms, 1.0 for outdoor
**Dimension Detection Phase:**
- Finds car bounding box from mask (actual car pixels, not image size)
- Calculates car_width and car_height from bounding box
**Scaling Phase:**
- Adjusts target ratio: adjusted_ratio = target_car_ratio × perspective_scale
- Calculates scale_factor = min(width_scale, height_scale) to fit frame
- Resizes car image to new_car_width × new_car_height
**Wheel Detection Phase:**
- Analyzes scaled car's alpha channel
- Finds last row containing car pixels (car_bottom_relative)
- This is where the wheels are located
**Positioning Phase:**
- Ground line fixed at 92% down image: ground_line_y = height × 0.92
- Calculates paste position: paste_y = ground_line_y - car_bottom_relative
- Centers horizontally: paste_x = (width - car_width) / 2
- Ensures car doesn't exceed image bounds (safety checks)
**Compositing Phase:**
- Pastes scaled car onto background at calculated position
- Uses car's alpha channel as mask for clean edges
- Returns final RGB composite
Technical Constants:
- Ground line position: 92% (0.92) down from top
- Bottom margin: 8% (leaves space below wheels)
- Showroom perspective: 0.9× scale reduction
- Outdoor perspective: 1.0× normal scale
Edge Cases Handled:
- Empty mask: Returns background unchanged
- Car too large: Safety bounds prevent overflow
- Car too small: Minimum paste position = 0
- No car pixels: Uses full car height as fallback
"""
# Detect where the ground is in the background
ground_position, perspective_scale = detect_ground_plane(background_image)
# Get car bounding box from original mask
rows = np.any(car_mask, axis=1)
cols = np.any(car_mask, axis=0)
if not np.any(rows) or not np.any(cols):
return background_image.convert("RGB")
y_min, y_max = np.where(rows)[0][[0, -1]]
x_min, x_max = np.where(cols)[0][[0, -1]]
car_width = x_max - x_min
car_height = y_max - y_min
target_width, target_height = background_image.size
# Apply perspective scaling (smaller cars in showrooms)
adjusted_car_ratio = target_car_ratio * perspective_scale
# Calculate scaling factor
scale_x = (target_width * adjusted_car_ratio) / car_width
scale_y = (target_height * adjusted_car_ratio) / car_height
scale_factor = min(scale_x, scale_y)
# Scale the car
new_car_width = int(car_image.width * scale_factor)
new_car_height = int(car_image.height * scale_factor)
car_scaled = car_image.resize(
(new_car_width, new_car_height), Image.Resampling.LANCZOS
)
# Resize background
bg_resized = background_image.resize(
(target_width, target_height), Image.Resampling.LANCZOS
).convert("RGBA")
# CRITICAL: Find where the wheels actually are in the SCALED car
car_scaled_array = np.array(car_scaled)
car_scaled_alpha = car_scaled_array[:, :, 3]
car_scaled_mask = car_scaled_alpha > 0
# Find the actual bottom row of car pixels (wheels)
if np.any(car_scaled_mask):
car_rows = np.any(car_scaled_mask, axis=1)
# Get the last row that has car pixels
car_bottom_relative = np.where(car_rows)[0][-1]
else:
car_bottom_relative = new_car_height - 1
# Center horizontally
paste_x = (target_width - new_car_width) // 2
# GROUND POSITIONING LOGIC:
# The ground is at a fixed position in the background (e.g., 92% down)
# We want the car's wheels (bottom pixels) to sit exactly at that position
# This should work for ANY car_size value
# Where the ground line is in pixels
ground_line_y = int(target_height * 0.92) # 92% down the image
# Position the car so its bottom (wheels) align with the ground line
paste_y = ground_line_y - car_bottom_relative
# Safety bounds
paste_x = max(0, min(paste_x, target_width - new_car_width))
paste_y = max(0, min(paste_y, target_height - new_car_height))
# Composite
bg_resized.paste(car_scaled, (paste_x, paste_y), car_scaled)
return bg_resized.convert("RGB")
def normalize_and_composite(
car_image: Image.Image,
car_mask: np.ndarray,
background_image: Image.Image,
target_car_ratio: float = 0.6,
) -> Image.Image:
"""
Intelligently scales and composites the car onto a new background.
Fixes the perspective/scaling issue by:
1. Detecting the actual car bounding box from the mask
2. Scaling the car to occupy a consistent portion of the frame
3. Centering the car with a slight bottom bias for natural appearance
Args:
car_image: RGBA image of car with transparent background
car_mask: Boolean mask of the car (True = car pixels)
background_image: New background image
target_car_ratio: How much of the frame the car should occupy (0.0-1.0)
Default 0.6 means car takes up 60% of the frame
Returns:
RGB image with car properly scaled and composited on new background
"""
# Get car bounding box from mask to find actual car dimensions
rows = np.any(car_mask, axis=1)
cols = np.any(car_mask, axis=0)
if not np.any(rows) or not np.any(cols):
# No car detected in mask, return background as-is
return background_image.convert("RGB")
y_min, y_max = np.where(rows)[0][[0, -1]]
x_min, x_max = np.where(cols)[0][[0, -1]]
car_width = x_max - x_min
car_height = y_max - y_min
# Use the background dimensions as the target canvas size
target_width, target_height = background_image.size
# Calculate scaling factor to make car occupy target_car_ratio of the frame
# Scale based on the dimension that would result in the smaller final size
# This ensures the car fits within the frame
scale_x = (target_width * target_car_ratio) / car_width
scale_y = (target_height * target_car_ratio) / car_height
scale_factor = min(scale_x, scale_y)
# Calculate new car dimensions
new_car_width = int(car_image.width * scale_factor)
new_car_height = int(car_image.height * scale_factor)
# Scale the car image
car_scaled = car_image.resize(
(new_car_width, new_car_height), Image.Resampling.LANCZOS
)
# Resize background to target dimensions if needed
bg_resized = background_image.resize(
(target_width, target_height), Image.Resampling.LANCZOS
).convert("RGBA")
# Calculates position to center the car horizontally
# and position it slightly below center vertically (more natural for car photos)
paste_x = (target_width - new_car_width) // 2
paste_y = int((target_height - new_car_height) * 0.55) # 55% down from top
# Ensure we don't paste outside the canvas
paste_x = max(0, min(paste_x, target_width - new_car_width))
paste_y = max(0, min(paste_y, target_height - new_car_height))
# Paste the scaled car onto the background using its alpha channel as mask
bg_resized.paste(car_scaled, (paste_x, paste_y), car_scaled)
return bg_resized.convert("RGB")
def replace_background(
foreground_input: Union[str, Path, Image.Image],
background_input: Union[str, Path, Image.Image],
model_name: str,
max_size: Optional[Tuple[int, int]] = (1024, 1024),
normalize: bool = True,
target_car_ratio: float = 0.6,
smart_placement: bool = True,
) -> Image.Image:
"""
Removes the background from the foreground image and replaces it with
the provided background image.
Args:
foreground_input: Car image (path or PIL Image)
background_input: New background image (path or PIL Image)
model_name: Model to use for background removal
max_size: Maximum dimensions for processing
normalize: If True, intelligently scales car to fit background naturally
target_car_ratio: How much of frame car should occupy (0.0-1.0)
smart_placement: If True, detects ground plane and positions car accordingly
Returns:
RGB image with new background
"""
# Remove background from car image
foreground_img, mask = process_image(foreground_input, model_name, max_size)
# Load background image
if isinstance(background_input, (str, Path)):
background_img = Image.open(background_input).convert("RGBA")
else:
background_img = background_input.convert("RGBA")
# Use smart placement if enabled
if normalize and smart_placement:
return smart_composite(foreground_img, mask, background_img, target_car_ratio)
elif normalize:
return normalize_and_composite(
foreground_img, mask, background_img, target_car_ratio
)
else:
# Legacy method: just resize background to match foreground
background_img = background_img.resize(
foreground_img.size, Image.Resampling.LANCZOS
)
combined_img = Image.alpha_composite(background_img, foreground_img)
return combined_img.convert("RGB")
def clear_sessions():
"""Explicitly clears the model cache to free resources."""
get_session.cache_clear()