| 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}"}) |
|
|
| |
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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)) |
| |
| 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 |
|
|
| |
| |
| 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 = _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 |
| |
| 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 |
| |
| 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 |
| |
| 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: |
| |
| 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": |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| |
| |
| 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)") |
|
|
| |
| |
| |
| 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: |
| |
| 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) |
|
|
| |
| |
| |
| 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) |
| |
| |
| 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) |
|
|