hyper-reality-visualizer / backend /services /texture_service.py
eduardo4547's picture
Upload 150 files
cb5d9d0 verified
import io
import shutil
import uuid
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from fastapi import HTTPException
from PIL import Image
from core.config import (
OUTPUT_DIR,
TEXTURE_DIR,
UPLOAD_DIR,
UPLOAD_JPEG_QUALITY,
log_timing_end,
log_timing_start,
logger,
)
from models.schemas import ApplyTextureRequest
def generate_texture_variations(texture_name: str) -> list[dict[str, str]]:
"""
Genera variaciones de color/brillo/saturación de una textura de referencia usando HSV.
Los archivos se cachean en TEXTURE_DIR/generated/ — si ya existen no se regeneran.
Devuelve lista de {ref, label, preview_url}.
"""
texture_path = resolve_texture_path(texture_name)
generated_dir = TEXTURE_DIR / "generated"
generated_dir.mkdir(parents=True, exist_ok=True)
tex_pil = load_texture_pil_rgb(texture_path)
tex_bgr = cv2.cvtColor(np.array(tex_pil, dtype=np.uint8), cv2.COLOR_RGB2BGR)
tex_hsv = cv2.cvtColor(tex_bgr, cv2.COLOR_BGR2HSV).astype(np.int32)
base_stem = Path(texture_name).stem
results: list[dict[str, str]] = []
def _save(hsv: np.ndarray, suffix: str, label: str) -> None:
fname = f"{base_stem}__{suffix}.jpg"
out_path = generated_dir / fname
if not out_path.exists():
bgr = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR)
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
Image.fromarray(rgb).save(str(out_path), format="JPEG", quality=92, optimize=True)
ref = f"generated/{fname}"
results.append({"ref": ref, "label": label, "preview_url": f"/seg/texture-preview/{ref}"})
# Rotaciones de tono: 12 pasos de 30° recorriendo el círculo cromático
# En OpenCV HSV, H ∈ [0,179] → shift en grados / 2
for deg, label in [
(30, "Naranja"),
(60, "Amarillo"),
(90, "Verde lima"),
(120, "Verde"),
(150, "Verde agua"),
(165, "Cyan"),
(180, "Azul cielo"),
(210, "Azul"),
(240, "Índigo"),
(270, "Violeta"),
(300, "Magenta"),
(330, "Rosa"),
]:
v = tex_hsv.copy()
v[:, :, 0] = (v[:, :, 0] + deg // 2) % 180
_save(v, f"hue{deg}", label)
# Variaciones de brillo
for factor, label, suffix in [
(0.45, "Oscuro", "dark"),
(1.55, "Claro", "light"),
]:
v = tex_hsv.copy()
v[:, :, 2] = np.clip(v[:, :, 2] * factor, 0, 255)
_save(v, suffix, label)
# Variaciones de saturación
for factor, label, suffix in [
(0.0, "Gris", "gray"),
(0.45, "Apagado", "muted"),
(1.75, "Vívido", "vivid"),
]:
v = tex_hsv.copy()
v[:, :, 1] = np.clip(v[:, :, 1] * factor, 0, 255)
_save(v, suffix, label)
return results
def list_available_textures() -> list[str]:
allowed = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".exr"}
return [
str(path.relative_to(TEXTURE_DIR)).replace("\\", "/")
for path in sorted(TEXTURE_DIR.rglob("*"))
if path.is_file() and path.suffix.lower() in allowed
]
def resolve_texture_path(texture_name: str) -> Path:
if not texture_name:
raise HTTPException(status_code=400, detail="Invalid texture_name")
normalized = texture_name.replace("\\", "/").strip("/")
candidate = (TEXTURE_DIR / normalized).resolve()
base = TEXTURE_DIR.resolve()
try:
candidate.relative_to(base)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid texture_name") from exc
if not candidate.exists() or not candidate.is_file():
raise HTTPException(status_code=404, detail=f"Texture not found: {normalized}")
return candidate
def build_texture_preview_jpeg(texture_path: Path, max_size: int = 320) -> bytes:
pil_img = load_texture_pil_rgb(texture_path)
pil_img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
out = io.BytesIO()
pil_img.save(out, format="JPEG", quality=88, optimize=True)
return out.getvalue()
def load_texture_pil_rgb(texture_path: Path) -> Image.Image:
suffix = texture_path.suffix.lower()
if suffix != ".exr":
try:
return Image.open(str(texture_path)).convert("RGB")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Could not read texture file: {exc}") from exc
exr = cv2.imread(str(texture_path), cv2.IMREAD_UNCHANGED)
if exr is None:
raise HTTPException(status_code=500, detail="Could not decode EXR texture")
if exr.ndim == 2:
exr = np.stack([exr, exr, exr], axis=-1)
if exr.ndim != 3:
raise HTTPException(status_code=500, detail="EXR texture has unsupported shape")
if exr.shape[2] > 3:
exr = exr[:, :, :3]
exr = np.nan_to_num(exr, nan=0.0, posinf=0.0, neginf=0.0)
exr = np.maximum(exr, 0)
if np.issubdtype(exr.dtype, np.floating):
scale = float(np.percentile(exr, 99.0))
if scale <= 1e-8:
scale = float(np.max(exr))
if scale <= 1e-8:
scale = 1.0
img = np.clip(exr / scale, 0.0, 1.0)
img = np.power(img, 1.0 / 2.2)
img_u8 = (img * 255.0).astype(np.uint8)
elif exr.dtype == np.uint16:
img_u8 = (exr / 257.0).astype(np.uint8)
else:
img_u8 = np.clip(exr, 0, 255).astype(np.uint8)
img_rgb = cv2.cvtColor(img_u8, cv2.COLOR_BGR2RGB)
return Image.fromarray(img_rgb).convert("RGB")
def estimate_mask_orientation_degrees(binary_mask: np.ndarray) -> float:
mask_u8 = (binary_mask > 0).astype(np.uint8)
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return 0.0
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) < 25.0:
return 0.0
rect = cv2.minAreaRect(largest)
(_, _), (width, height), angle = rect
dominant_angle = float(angle)
if width < height:
dominant_angle += 90.0
dominant_angle %= 180.0
return dominant_angle
def _compute_trapezoid_score_from_mask(
binary_mask: np.ndarray,
ys: np.ndarray,
xs: np.ndarray,
min_y: int,
max_y: int,
bbox_h: int,
) -> float:
"""Return 0..1 indicating how floor-like (wider at bottom) the mask shape is."""
quarter = max(1, bbox_h // 4)
top_xs = xs[ys <= (min_y + quarter)]
bot_xs = xs[ys >= (max_y - quarter)]
if len(top_xs) < 3 or len(bot_xs) < 3:
return 0.0
top_w = float(top_xs.max() - top_xs.min())
bot_w = float(bot_xs.max() - bot_xs.min())
if top_w < 5.0:
return 1.0 if bot_w > 20.0 else 0.0
ratio = bot_w / top_w
return float(np.clip((ratio - 1.0) / 1.8, 0.0, 1.0))
def _sort_quad_corners(pts: np.ndarray) -> np.ndarray:
"""Sort 4 points into [TL, TR, BR, BL] order."""
result = np.zeros((4, 2), dtype=np.float32)
s = pts[:, 0] + pts[:, 1]
d = pts[:, 0] - pts[:, 1]
result[0] = pts[np.argmin(s)]
result[1] = pts[np.argmax(d)]
result[2] = pts[np.argmax(s)]
result[3] = pts[np.argmin(d)]
return result
def _extract_mask_quad(binary_mask: np.ndarray) -> np.ndarray | None:
"""Approximate mask as 4-corner polygon sorted [TL, TR, BR, BL], or None."""
mask_u8 = (binary_mask > 0).astype(np.uint8)
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
largest = max(contours, key=cv2.contourArea)
if cv2.contourArea(largest) < 400.0:
return None
hull = cv2.convexHull(largest)
peri = cv2.arcLength(hull, True)
for eps_frac in (0.03, 0.05, 0.08, 0.10, 0.13):
approx = cv2.approxPolyDP(hull, eps_frac * peri, True)
if len(approx) == 4:
return _sort_quad_corners(approx.reshape(4, 2).astype(np.float32))
return None
def _tile_texture_perspective(
tex_pil: Image.Image,
quad: np.ndarray,
image_width: int,
image_height: int,
) -> Image.Image | None:
"""
Tile texture with perspective correction for a floor surface.
quad: [TL, TR, BR, BL] in image coordinates.
Returns full-image-size PIL image with warped tiled texture, or None on failure.
Pixels outside the perspective quad are filled with regular tiling to avoid black gaps.
"""
tl, tr, br, bl = quad
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
rect_w = min(int(max(bot_w, top_w)) + 1, image_width * 2)
rect_h = min(int(max(left_h, right_h)) + 1, image_height * 2)
if rect_w < 8 or rect_h < 8:
return None
tex_arr = np.array(tex_pil.convert("RGB"), dtype=np.uint8)
th, tw = tex_arr.shape[:2]
if tw < 1 or th < 1:
return None
rect_tiled = np.zeros((rect_h, rect_w, 3), dtype=np.uint8)
for ry in range(0, rect_h, th):
for rx in range(0, rect_w, tw):
py = min(th, rect_h - ry)
px = min(tw, rect_w - rx)
rect_tiled[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
src_pts = np.array(
[[0.0, 0.0], [float(rect_w - 1), 0.0], [float(rect_w - 1), float(rect_h - 1)], [0.0, float(rect_h - 1)]],
dtype=np.float32,
)
dst_pts = quad.astype(np.float32)
try:
H = cv2.getPerspectiveTransform(src_pts, dst_pts)
warped = cv2.warpPerspective(rect_tiled, H, (image_width, image_height))
# Mapa de cobertura: píxeles realmente cubiertos por el warp
cov_src = np.ones((rect_h, rect_w), dtype=np.uint8) * 255
coverage = cv2.warpPerspective(cov_src, H, (image_width, image_height))
except cv2.error:
return None
# Rellenar píxeles sin cobertura (fuera del quad) con tiling regular
# para evitar espacios negros donde la máscara supera el quad aproximado
regular = np.zeros((image_height, image_width, 3), dtype=np.uint8)
for ry in range(0, image_height, th):
for rx in range(0, image_width, tw):
py = min(th, image_height - ry)
px = min(tw, image_width - rx)
regular[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
uncovered = coverage < 128
warped[uncovered] = regular[uncovered]
return Image.fromarray(warped)
def classify_texture_material(texture_name: str) -> str:
texture_key = texture_name.lower()
if "acm" in texture_key or "wpc" in texture_key:
return "acm"
if any(hint in texture_key for hint in ("deck", "wood", "plank", "laminate", "floor")):
return "wood"
if any(hint in texture_key for hint in ("marble", "granite", "tile", "brick", "cobblestone", "stone", "cartago", "riverbed")):
return "stone"
if any(hint in texture_key for hint in ("metal", "rust", "iron", "steel")):
return "metal"
return "generic"
def infer_surface_type_and_direction(
binary_mask: np.ndarray,
image_width: int,
image_height: int,
texture_name: str,
) -> tuple[str, float, float, int]:
mask_u8 = (binary_mask > 0).astype(np.uint8)
ys, xs = np.where(mask_u8 > 0)
if ys.size == 0 or xs.size == 0:
return ("wall", 0.0, 0.78, max(180, image_width // 4))
min_x, max_x = int(xs.min()), int(xs.max())
min_y, max_y = int(ys.min()), int(ys.max())
bbox_w = max(1, max_x - min_x + 1)
bbox_h = max(1, max_y - min_y + 1)
aspect = bbox_w / max(1.0, float(bbox_h))
center_y = float(ys.mean()) / max(1.0, float(image_height))
dominant_angle = estimate_mask_orientation_degrees(binary_mask)
material = classify_texture_material(texture_name)
# Trapezoid score: floor in perspective is wider at the bottom than the top
trapezoid_score = _compute_trapezoid_score_from_mask(binary_mask, ys, xs, min_y, max_y, bbox_h)
is_ceiling = center_y < 0.26 and aspect > 1.35
# Floor: low center + trapezoidal shape, OR clearly low + wide
is_floor = (
(center_y > 0.55 and aspect >= 0.9 and trapezoid_score > 0.30)
or (center_y > 0.68 and aspect > 1.15)
)
if is_ceiling:
surface_type = "ceiling"
angle = 0.0
blend_alpha = 0.58
tile_width = max(128, image_width // 5)
elif is_floor:
if material == "wood":
surface_type = "deck"
angle = dominant_angle if 8.0 <= dominant_angle <= 172.0 else 0.0
blend_alpha = 0.82
tile_width = max(320, int(bbox_w * 0.95), image_width // 2)
else:
surface_type = "floor"
angle = 0.0
blend_alpha = 0.80
# ACM floor: ~3 large-format panels visible on the near edge
tile_width = max(200, int(bbox_w * 0.35)) if material == "acm" else max(144, image_width // 3)
else:
surface_type = "wall"
angle = 0.0
if material == "acm":
blend_alpha = 0.78
# ACM wall panels: ~3 panels across the surface width
tile_width = max(180, int(bbox_w * 0.33))
elif material == "wood":
blend_alpha = 0.70
tile_width = max(220, int(bbox_w * 0.55), image_width // 4)
elif material == "stone":
blend_alpha = 0.84
tile_width = max(128, image_width // 4)
else:
blend_alpha = 0.66
tile_width = max(128, image_width // 4)
return (surface_type, float(angle % 180.0), float(blend_alpha), int(tile_width))
def choose_auto_texture_settings(material: str, surface_type: str) -> tuple[float, float, float]:
strength_map = {"acm": 0.98, "stone": 0.96, "wood": 0.88, "metal": 0.91, "generic": 0.9}
intensity_map = {"acm": 0.08, "stone": 0.36, "wood": 0.3, "metal": 0.34, "generic": 0.32}
strength = float(strength_map.get(material, 0.9))
intensity = float(intensity_map.get(material, 0.32))
if surface_type in {"wall", "facade"}:
strength += 0.02
angle = 28.0
elif surface_type in {"roof"}:
angle = 42.0
intensity += 0.03
elif surface_type in {"floor", "deck"}:
angle = 24.0
intensity += 0.02
else:
angle = 35.0
return (
float(np.clip(strength, 0.55, 0.99)),
float(angle % 360.0),
float(np.clip(intensity, 0.0, 1.0)),
)
def build_feather_mask(binary_mask: np.ndarray, sigma: float = 2.2) -> np.ndarray:
mask = (binary_mask > 0).astype(np.float32)
if mask.max() <= 0:
return mask
feather = cv2.GaussianBlur(mask, (0, 0), sigmaX=sigma, sigmaY=sigma)
return np.clip(feather, 0.0, 1.0)
def build_scene_luminance_map(orig_rgb: np.ndarray) -> np.ndarray:
orig_u8 = orig_rgb.astype(np.uint8)
orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
l_channel = orig_lab[:, :, 0] / 255.0
broad_light = cv2.GaussianBlur(l_channel, (0, 0), sigmaX=18.0, sigmaY=18.0)
local_detail = l_channel - cv2.GaussianBlur(l_channel, (0, 0), sigmaX=4.0, sigmaY=4.0)
light_map = 0.82 + (broad_light * 0.36) + (local_detail * 0.18)
return np.clip(light_map, 0.72, 1.22)
def build_texture_relief_map(tex_rgb: np.ndarray, material: str = "generic") -> np.ndarray:
tex_u8 = tex_rgb.astype(np.uint8)
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
micro_relief = tex_gray - cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=3.0, sigmaY=3.0)
relief_scale = {"acm": 0.35, "stone": 2.8, "wood": 2.2, "metal": 1.8}.get(material, 2.0)
return np.clip(micro_relief * relief_scale, -1.0, 1.0)
def build_directional_light_map(
tex_rgb: np.ndarray,
material: str,
light_angle_degrees: float,
light_intensity: float,
) -> np.ndarray:
tex_u8 = tex_rgb.astype(np.uint8)
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
height_map = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=1.4, sigmaY=1.4)
grad_x = cv2.Sobel(height_map, cv2.CV_32F, 1, 0, ksize=3)
grad_y = cv2.Sobel(height_map, cv2.CV_32F, 0, 1, ksize=3)
relief_scale = {"acm": 0.45, "stone": 3.0, "wood": 2.4, "metal": 1.6}.get(material, 2.0)
nx = -grad_x * relief_scale
ny = -grad_y * relief_scale
nz = np.ones_like(nx, dtype=np.float32)
norm = np.sqrt((nx * nx) + (ny * ny) + (nz * nz)) + 1e-6
nx = nx / norm
ny = ny / norm
nz = nz / norm
theta = np.deg2rad(float(light_angle_degrees))
lx = float(np.cos(theta))
ly = float(-np.sin(theta))
lz = 0.82
light_norm = max(1e-6, float(np.sqrt((lx * lx) + (ly * ly) + (lz * lz))))
lx /= light_norm
ly /= light_norm
lz /= light_norm
diffuse = np.clip((nx * lx) + (ny * ly) + (nz * lz), 0.0, 1.0)
strength = float(np.clip(light_intensity, 0.0, 1.0))
if material == "acm":
return np.clip(0.97 + (diffuse * (0.03 + (0.12 * strength))), 0.95, 1.12)
return np.clip(0.86 + (diffuse * (0.14 + (0.60 * strength))), 0.72, 1.35)
def build_mask_edge_occlusion(binary_mask: np.ndarray, light_intensity: float) -> np.ndarray:
mask_u8 = (binary_mask > 0).astype(np.uint8)
if mask_u8.max() == 0:
return np.ones(mask_u8.shape, dtype=np.float32)
distance = cv2.distanceTransform(mask_u8, cv2.DIST_L2, 5).astype(np.float32)
inner_values = distance[mask_u8 > 0]
if inner_values.size == 0:
return np.ones(mask_u8.shape, dtype=np.float32)
max_distance = max(1.0, float(np.percentile(inner_values, 95)))
normalized = np.clip(distance / (max_distance * 0.16), 0.0, 1.0)
edge_strength = 1.0 - normalized
occlusion = 1.0 - (edge_strength * (0.04 + (0.08 * float(np.clip(light_intensity, 0.0, 1.0)))))
occlusion[mask_u8 == 0] = 1.0
return np.clip(occlusion, 0.88, 1.0)
def apply_surface_lighting(
tex_rgb: np.ndarray,
orig_rgb: np.ndarray,
binary_mask: np.ndarray,
material: str,
lighting_mode: str,
light_angle_degrees: float,
light_intensity: float,
) -> np.ndarray:
scene_light = build_scene_luminance_map(orig_rgb)
directional_light = build_directional_light_map(tex_rgb, material, light_angle_degrees, light_intensity)
relief_map = build_texture_relief_map(tex_rgb, material)
edge_occlusion = build_mask_edge_occlusion(binary_mask, light_intensity)
if material == "acm":
if lighting_mode == "directional":
light_map = directional_light
elif lighting_mode == "flat":
light_map = np.ones(scene_light.shape, dtype=np.float32)
else:
# 45 % scene luminance so ACM panels inherit shadows/gradients from photo
light_map = (scene_light * 0.45) + (directional_light * 0.55)
elif lighting_mode == "directional":
light_map = directional_light
elif lighting_mode == "flat":
light_map = np.ones(scene_light.shape, dtype=np.float32)
else:
light_map = (scene_light * 0.78) + (directional_light * 0.22)
detail_scale = 0.02 + (0.05 * float(np.clip(light_intensity, 0.0, 1.0))) if material == "acm" else 0.08 + (0.18 * float(np.clip(light_intensity, 0.0, 1.0)))
detail_boost = 1.0 + (relief_map * detail_scale)
enhanced = tex_rgb.astype(np.float32)
enhanced *= light_map[:, :, None]
enhanced *= detail_boost[:, :, None]
enhanced *= edge_occlusion[:, :, None]
return np.clip(enhanced, 0, 255).astype(np.uint8)
def blend_texture_preserve_shading(
orig_rgb: np.ndarray,
tex_rgb: np.ndarray,
alpha_mask: np.ndarray,
blend_alpha: float,
material: str = "generic",
) -> np.ndarray:
orig_u8 = orig_rgb.astype(np.uint8)
tex_u8 = tex_rgb.astype(np.uint8)
orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
tex_lab = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
mixed_lab = tex_lab.copy()
if material == "acm":
# 30 % original luminance → panels inherit scene shadows while keeping panel colour
mixed_lab[:, :, 0] = (0.30 * orig_lab[:, :, 0]) + (0.70 * tex_lab[:, :, 0])
mixed_lab[:, :, 1] = (0.97 * tex_lab[:, :, 1]) + (0.03 * orig_lab[:, :, 1])
mixed_lab[:, :, 2] = (0.97 * tex_lab[:, :, 2]) + (0.03 * orig_lab[:, :, 2])
elif material == "wood":
mixed_lab[:, :, 0] = (0.78 * orig_lab[:, :, 0]) + (0.22 * tex_lab[:, :, 0])
mixed_lab[:, :, 1] = (0.9 * tex_lab[:, :, 1]) + (0.1 * orig_lab[:, :, 1])
mixed_lab[:, :, 2] = (0.9 * tex_lab[:, :, 2]) + (0.1 * orig_lab[:, :, 2])
elif material == "stone":
orig_l_base = cv2.GaussianBlur(orig_lab[:, :, 0], (0, 0), sigmaX=11.0, sigmaY=11.0)
mixed_lab[:, :, 0] = (0.18 * orig_l_base) + (0.82 * tex_lab[:, :, 0])
mixed_lab[:, :, 1] = (0.95 * tex_lab[:, :, 1]) + (0.05 * orig_lab[:, :, 1])
mixed_lab[:, :, 2] = (0.95 * tex_lab[:, :, 2]) + (0.05 * orig_lab[:, :, 2])
else:
mixed_lab[:, :, 0] = orig_lab[:, :, 0]
mixed_lab[:, :, 1] = (0.8 * tex_lab[:, :, 1]) + (0.2 * orig_lab[:, :, 1])
mixed_lab[:, :, 2] = (0.8 * tex_lab[:, :, 2]) + (0.2 * orig_lab[:, :, 2])
shaded_tex = cv2.cvtColor(np.clip(mixed_lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)
if material == "wood":
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32)
tex_base = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=9.0, sigmaY=9.0)
tex_detail = np.clip((tex_gray - tex_base) / 255.0, -0.35, 0.35)
shaded_tex *= (1.0 + (tex_detail[:, :, None] * 0.28))
alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
composite = (orig_rgb * (1.0 - alpha)) + (shaded_tex * alpha)
return np.clip(composite, 0, 255).astype(np.uint8)
def blend_texture_direct(
orig_rgb: np.ndarray,
tex_rgb: np.ndarray,
alpha_mask: np.ndarray,
blend_alpha: float,
) -> np.ndarray:
alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
composite = (orig_rgb * (1.0 - alpha)) + (tex_rgb * alpha)
return np.clip(composite, 0, 255).astype(np.uint8)
def apply_local_texture_sync(payload: ApplyTextureRequest) -> dict[str, Any]:
step = "APPLY_TEXTURE"
started = log_timing_start(step)
try:
safe_name = Path(payload.filename).name
if not safe_name:
raise HTTPException(status_code=400, detail="Invalid filename")
label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
image_path = UPLOAD_DIR / safe_name
if not image_path.exists() or not image_path.is_file():
image_path = OUTPUT_DIR / safe_name
if (not image_path.exists() or not image_path.is_file()) and payload.original_filename:
orig_name = Path(payload.original_filename).name
image_path = UPLOAD_DIR / orig_name
if not image_path.exists() or not image_path.is_file():
image_path = OUTPUT_DIR / orig_name
if not image_path.exists() or not image_path.is_file():
raise HTTPException(
status_code=404,
detail=f"Image not found: {safe_name} (also tried original: {payload.original_filename or 'n/a'})",
)
masks_dir = UPLOAD_DIR / "masks"
masks_dir.mkdir(exist_ok=True)
label_owner = Path(image_path).stem
label_path = masks_dir / f"{label_owner}_labels.png"
if not label_path.exists() and payload.original_filename:
alt_owner = Path(payload.original_filename).name
alt_label = masks_dir / f"{alt_owner}_labels.png"
if alt_label.exists():
label_path = alt_label
if not label_path.exists():
raise HTTPException(
status_code=404,
detail=f"Label map not found for {label_owner}. Upload/segment the image first.",
)
if not payload.mask_indices:
raise HTTPException(status_code=400, detail="No mask indices provided")
texture_path = resolve_texture_path(payload.texture_name)
orig_pil = Image.open(str(image_path)).convert("RGB")
width, height = orig_pil.size
label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
if label_map is None:
raise HTTPException(status_code=500, detail="Could not read label map")
binary_mask = np.zeros((label_map.shape[0], label_map.shape[1]), dtype=np.uint8)
for idx in payload.mask_indices:
binary_mask |= (label_map == idx).astype(np.uint8)
if binary_mask.max() == 0:
raise HTTPException(status_code=400, detail="None of the selected segments were found in the label map.")
direction_mode = str(payload.direction_mode or "auto").strip().lower()
if direction_mode not in {"auto", "manual", "none"}:
raise HTTPException(status_code=400, detail="Invalid direction_mode. Use auto, manual, or none.")
replace_mode = str(getattr(payload, "replace_mode", "realistic") or "realistic").strip().lower()
lighting_mode = str(getattr(payload, "lighting_mode", "scene") or "scene").strip().lower()
material = classify_texture_material(payload.texture_name)
surface_type, inferred_angle, blend_alpha, target_w = infer_surface_type_and_direction(
binary_mask, width, height, payload.texture_name,
)
replace_strength, light_angle_degrees, light_intensity = choose_auto_texture_settings(material, surface_type)
effective_alpha = float(np.clip(blend_alpha * (0.55 + (0.75 * replace_strength)), 0.0, 0.98))
if material == "acm":
effective_alpha = float(max(effective_alpha, 0.92))
applied_angle = 0.0
if direction_mode == "auto":
applied_angle = inferred_angle
elif direction_mode == "manual":
applied_angle = float(payload.angle_degrees)
tex_pil = load_texture_pil_rgb(texture_path)
# Escalar al tamaño de tile deseado ANTES de rotar
tex_w, tex_h = tex_pil.size
scale = target_w / max(1, tex_w)
if abs(scale - 1.0) > 0.05:
tex_pil = tex_pil.resize(
(max(1, int(tex_w * scale)), max(1, int(tex_h * scale))),
Image.Resampling.LANCZOS,
)
tex_w, tex_h = tex_pil.size
tiled: Image.Image | None = None
# Floor surfaces: perspective tiling solo cuando la perspectiva es fuerte.
# Para pisos de dormitorio/habitación (perspectiva suave) el quad aproximado
# no cubre bien el mask irregular → produce negro. Se usa tiling regular en esos casos.
if surface_type == "floor" and direction_mode in {"auto", "none"}:
ys_m, xs_m = np.where(binary_mask > 0)
if ys_m.size > 0:
min_y_m, max_y_m = int(ys_m.min()), int(ys_m.max())
bbox_h_m = max(1, max_y_m - min_y_m + 1)
trap_score = _compute_trapezoid_score_from_mask(
binary_mask, ys_m, xs_m, min_y_m, max_y_m, bbox_h_m
)
if trap_score > 0.35:
quad = _extract_mask_quad(binary_mask)
if quad is not None:
tiled = _tile_texture_perspective(tex_pil, quad, width, height)
if tiled is not None:
logger.info(f"[APPLY_TEXTURE] perspective floor tiling applied (trap={trap_score:.2f})")
else:
logger.info(f"[APPLY_TEXTURE] flat floor tiling (trap={trap_score:.2f} < 0.35, skip perspective)")
# Wall / ceiling surfaces: perspective tiling cuando el quad es significativamente
# no-rectangular (pared fotografiada en ángulo). Un ratio > 1.20 entre lado mayor
# y lado menor indica distorsión perspectiva visible.
if surface_type in {"wall", "ceiling"} and tiled is None and direction_mode in {"auto", "none"}:
quad = _extract_mask_quad(binary_mask)
if quad is not None:
tl, tr, br, bl = quad
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
w_ratio = max(top_w, bot_w) / max(1.0, min(top_w, bot_w))
h_ratio = max(left_h, right_h) / max(1.0, min(left_h, right_h))
if w_ratio > 1.20 or h_ratio > 1.20:
tiled = _tile_texture_perspective(tex_pil, quad, width, height)
if tiled is not None:
logger.info(
f"[APPLY_TEXTURE] perspective wall tiling applied "
f"(w_ratio={w_ratio:.2f}, h_ratio={h_ratio:.2f})"
)
if tiled is None:
if abs(applied_angle) > 0.01:
# Tile on a large canvas first, then rotate the full canvas to avoid black corners
diag = int(np.ceil(np.sqrt(width ** 2 + height ** 2))) + max(tex_w, tex_h)
large_w = width + diag
large_h = height + diag
large = Image.new("RGB", (large_w, large_h))
for y in range(0, large_h, tex_h):
for x in range(0, large_w, tex_w):
large.paste(tex_pil, (x, y))
large = large.rotate(-applied_angle, resample=Image.Resampling.BICUBIC, expand=False)
cx = (large_w - width) // 2
cy = (large_h - height) // 2
tiled = large.crop((cx, cy, cx + width, cy + height))
else:
tiled = Image.new("RGB", (width, height))
for y in range(0, height, tex_h):
for x in range(0, width, tex_w):
tiled.paste(tex_pil, (x, y))
orig_u8 = np.array(orig_pil, dtype=np.uint8)
try:
if bool(getattr(payload, "clear_mask_before_apply", False)):
orig_candidate = None
if payload.original_filename:
cand = UPLOAD_DIR / Path(payload.original_filename).name
if cand.exists() and cand.is_file():
orig_candidate = cand
else:
cand2 = OUTPUT_DIR / Path(payload.original_filename).name
if cand2.exists() and cand2.is_file():
orig_candidate = cand2
if orig_candidate is None:
stem = Path(image_path).stem
if "_edit_" in stem:
base_name = stem.split("_edit_")[0] + ".jpg"
cand = UPLOAD_DIR / base_name
if cand.exists() and cand.is_file():
orig_candidate = cand
else:
cand2 = OUTPUT_DIR / base_name
if cand2.exists() and cand2.is_file():
orig_candidate = cand2
if orig_candidate is not None:
try:
base_pil = Image.open(str(orig_candidate)).convert("RGB")
if base_pil.size != (width, height):
base_pil = base_pil.resize((width, height), Image.Resampling.LANCZOS)
base_arr = np.array(base_pil, dtype=np.uint8)
mask_bool = (binary_mask > 0)
orig_u8[mask_bool] = base_arr[mask_bool]
logger.info(f"[APPLY_TEXTURE] cleared mask from original source: {orig_candidate}")
except Exception:
logger.exception("Failed to restore original pixels for clear_mask_before_apply")
except Exception:
logger.exception("Error handling clear_mask_before_apply")
tiled_arr = np.array(tiled, dtype=np.uint8)
# Parchar píxeles muy oscuros (suma R+G+B < 20) dentro de la máscara.
# Cubren tanto negro exacto (0,0,0) como píxeles casi negros que el warp
# de perspectiva puede generar en bordes del quad o zonas sin cobertura.
mask_bool = binary_mask > 0
dark_in_mask = mask_bool & (tiled_arr.sum(axis=2) < 20)
if dark_in_mask.any():
th_f, tw_f = tex_pil.size[1], tex_pil.size[0]
tex_np = np.array(tex_pil, dtype=np.uint8)
ys_b, xs_b = np.where(dark_in_mask)
tiled_arr[ys_b, xs_b] = tex_np[ys_b % th_f, xs_b % tw_f]
lit_tex = apply_surface_lighting(
tiled_arr,
orig_u8,
binary_mask,
material,
lighting_mode,
light_angle_degrees,
light_intensity,
)
orig_arr = orig_u8.astype(np.float32)
tex_arr = lit_tex.astype(np.float32)
if replace_mode in {"hard", "absolute", "force", "replace"}:
mask_bool = (binary_mask > 0).astype(bool)
composite_arr = orig_arr.copy()
composite_arr[mask_bool] = tex_arr[mask_bool]
composite = np.clip(composite_arr, 0, 255).astype(np.uint8)
else:
feather_mask = build_feather_mask(binary_mask)
# All materials use shading-preservation so scene luminance (shadows/highlights)
# from the original photo is transferred onto the texture.
composite = blend_texture_preserve_shading(orig_arr, tex_arr, feather_mask, effective_alpha, material)
input_stem = Path(image_path).stem
edit_suffix = uuid.uuid4().hex[:8]
out_filename = f"{input_stem}_edit_{edit_suffix}.jpg"
out_path = UPLOAD_DIR / out_filename
Image.fromarray(composite).save(str(out_path), format="JPEG", quality=UPLOAD_JPEG_QUALITY, optimize=True)
try:
out_label_path = masks_dir / f"{Path(out_filename).stem}_labels.png"
if label_path.exists():
shutil.copyfile(str(label_path), str(out_label_path))
except Exception:
logger.exception("Failed to copy label map for output image")
return {
"message": "Texture applied successfully",
"original": safe_name,
"mask_indices": payload.mask_indices,
"texture_name": payload.texture_name,
"material": material,
"direction_mode": direction_mode,
"surface_type": surface_type,
"replace_mode": replace_mode,
"replace_strength": round(replace_strength, 3),
"lighting_mode": lighting_mode,
"light_angle_degrees": round(light_angle_degrees, 2),
"light_intensity": round(light_intensity, 3),
"blend_alpha": round(effective_alpha, 3),
"applied_angle_degrees": round(applied_angle, 2),
"output_filename": out_filename,
"output_url": f"/seg/image/{out_filename}",
}
finally:
log_timing_end(step, started)