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)