Upload 285 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +6 -0
- backend/.env +5 -2
- backend/__pycache__/main.cpython-312.pyc +0 -0
- backend/__pycache__/main.cpython-313.pyc +0 -0
- backend/core/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/core/__pycache__/config.cpython-312.pyc +0 -0
- backend/core/__pycache__/config.cpython-313.pyc +0 -0
- backend/core/config.py +92 -92
- backend/data/presets.json +4 -4
- backend/logs/app.log +0 -0
- backend/main.py +127 -129
- backend/models/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/models/__pycache__/schemas.cpython-312.pyc +0 -0
- backend/models/__pycache__/schemas.cpython-313.pyc +0 -0
- backend/models/schemas.py +3 -1
- backend/requirements.txt +1 -0
- backend/routers/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/routers/__pycache__/active_sessions.cpython-312.pyc +0 -0
- backend/routers/__pycache__/auth.cpython-313.pyc +0 -0
- backend/routers/__pycache__/catalog.cpython-312.pyc +0 -0
- backend/routers/__pycache__/catalog.cpython-313.pyc +0 -0
- backend/routers/__pycache__/openai_image.cpython-312.pyc +0 -0
- backend/routers/__pycache__/segmentation.cpython-312.pyc +0 -0
- backend/routers/__pycache__/segmentation.cpython-313.pyc +0 -0
- backend/routers/active_sessions.py +11 -0
- backend/routers/catalog.py +240 -241
- backend/routers/openai_image.py +32 -0
- backend/routers/segmentation.py +760 -723
- backend/services/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/services/__pycache__/gradio_client_service.cpython-312.pyc +0 -0
- backend/services/__pycache__/gradio_client_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/image_service.cpython-312.pyc +0 -0
- backend/services/__pycache__/image_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/inpainting_service.cpython-312.pyc +0 -0
- backend/services/__pycache__/inpainting_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/openai_service.cpython-312.pyc +0 -0
- backend/services/__pycache__/sam2_service.cpython-312.pyc +0 -0
- backend/services/__pycache__/sam2_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/scene_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/segmentation_service.cpython-313.pyc +0 -0
- backend/services/__pycache__/texture_service.cpython-312.pyc +0 -0
- backend/services/__pycache__/texture_service.cpython-313.pyc +0 -0
- backend/services/gradio_client_service.py +108 -176
- backend/services/image_service.py +12 -40
- backend/services/inpainting_service.py +204 -12
- backend/services/openai_service.py +251 -0
- backend/services/sam2_service.py +3 -0
- backend/services/texture_service.py +851 -981
- backend/uploads/acm azul.jpg +0 -0
- backend/uploads/acm azul_edit_2e8f9183.jpg +0 -0
.gitattributes
CHANGED
|
@@ -156,3 +156,9 @@ backend/texturas/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png filter=lfs diff=lfs
|
|
| 156 |
backend/texturas/Texture_WPC_DECK/DECK_gris.png filter=lfs diff=lfs merge=lfs -text
|
| 157 |
backend/texturas/Texture_WPC_DECK/DECK_madera_oscuro.png filter=lfs diff=lfs merge=lfs -text
|
| 158 |
backend/texturas/Texture_WPC_DECK/DECK_madera.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
backend/texturas/Texture_WPC_DECK/DECK_gris.png filter=lfs diff=lfs merge=lfs -text
|
| 157 |
backend/texturas/Texture_WPC_DECK/DECK_madera_oscuro.png filter=lfs diff=lfs merge=lfs -text
|
| 158 |
backend/texturas/Texture_WPC_DECK/DECK_madera.png filter=lfs diff=lfs merge=lfs -text
|
| 159 |
+
backend/uploads/acm[[:space:]]azul_edit_edb46e2f.jpg filter=lfs diff=lfs merge=lfs -text
|
| 160 |
+
backend/uploads/acm[[:space:]]gris_edit_09f2e747.jpg filter=lfs diff=lfs merge=lfs -text
|
| 161 |
+
backend/uploads/acm[[:space:]]gris_edit_19d266a8.jpg filter=lfs diff=lfs merge=lfs -text
|
| 162 |
+
backend/uploads/acm[[:space:]]gris.jpg filter=lfs diff=lfs merge=lfs -text
|
| 163 |
+
backend/uploads/hyper-reality-1778254222964_edit_9c5de50a_edit_7e5043d5.jpg filter=lfs diff=lfs merge=lfs -text
|
| 164 |
+
backend/uploads/hyper-reality-1778254222964_edit_a92f4266_edit_161ee5c4.jpg filter=lfs diff=lfs merge=lfs -text
|
backend/.env
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
MONGODB_URI=mongodb+srv://alaneduardodelacruz407_db_user:YGUWdpXqUiGH104Q@naufar-cluster.n9htwoa.mongodb.net/
|
| 2 |
# Space GPU (principal) — ZeroGPU, más rápido
|
| 3 |
-
GRADIO_SPACE_URL=
|
| 4 |
# Space CPU (respaldo automático si el GPU falla o agota quota)
|
| 5 |
GRADIO_CPU_FALLBACK_URL=https://eduardo4547-hyper-reality-sam2-cpu.hf.space
|
| 6 |
# Para desarrollo local:
|
| 7 |
# GRADIO_SPACE_URL=http://localhost:7860
|
| 8 |
-
# https://eduardo4547-hyper-reality-sam2-gpu.hf.space
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
MONGODB_URI=mongodb+srv://alaneduardodelacruz407_db_user:YGUWdpXqUiGH104Q@naufar-cluster.n9htwoa.mongodb.net/
|
| 2 |
# Space GPU (principal) — ZeroGPU, más rápido
|
| 3 |
+
GRADIO_SPACE_URL=http://localhost:7860
|
| 4 |
# Space CPU (respaldo automático si el GPU falla o agota quota)
|
| 5 |
GRADIO_CPU_FALLBACK_URL=https://eduardo4547-hyper-reality-sam2-cpu.hf.space
|
| 6 |
# Para desarrollo local:
|
| 7 |
# GRADIO_SPACE_URL=http://localhost:7860
|
| 8 |
+
# https://eduardo4547-hyper-reality-sam2-gpu.hf.space
|
| 9 |
+
# URL del servicio local que procesa imágenes con OpenAI (ejemplo Flask/Gradio proxy)
|
| 10 |
+
OPENAI_PROCESS_URL=http://localhost:7861/api/process
|
| 11 |
+
OPENAI_API_KEY=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
|
backend/__pycache__/main.cpython-312.pyc
CHANGED
|
Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ
|
|
|
backend/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (7.6 kB). View file
|
|
|
backend/core/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (175 Bytes). View file
|
|
|
backend/core/__pycache__/config.cpython-312.pyc
CHANGED
|
Binary files a/backend/core/__pycache__/config.cpython-312.pyc and b/backend/core/__pycache__/config.cpython-312.pyc differ
|
|
|
backend/core/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (4.83 kB). View file
|
|
|
backend/core/config.py
CHANGED
|
@@ -1,92 +1,92 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
import os
|
| 3 |
-
import time
|
| 4 |
-
from datetime import datetime, timezone
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
|
| 7 |
-
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 8 |
-
|
| 9 |
-
UPLOAD_DIR = BASE_DIR / "uploads"
|
| 10 |
-
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 11 |
-
|
| 12 |
-
VIDEO_UPLOAD_DIR = UPLOAD_DIR / "videos"
|
| 13 |
-
VIDEO_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 14 |
-
|
| 15 |
-
OUTPUT_DIR = BASE_DIR / "outputs"
|
| 16 |
-
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
-
|
| 18 |
-
VIDEO_OUTPUT_DIR = OUTPUT_DIR / "videos"
|
| 19 |
-
VIDEO_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 20 |
-
|
| 21 |
-
TEXTURE_DIR = BASE_DIR / "texturas"
|
| 22 |
-
TEXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
| 23 |
-
|
| 24 |
-
LOG_DIR = BASE_DIR / "logs"
|
| 25 |
-
LOG_DIR.mkdir(exist_ok=True)
|
| 26 |
-
|
| 27 |
-
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 28 |
-
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
| 29 |
-
|
| 30 |
-
CLASSIC_DASHBOARD_HTML_PATH = TEMPLATES_DIR / "classic_dashboard.html"
|
| 31 |
-
|
| 32 |
-
logging.basicConfig(
|
| 33 |
-
level=logging.INFO,
|
| 34 |
-
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 35 |
-
handlers=[
|
| 36 |
-
logging.FileHandler(LOG_DIR / "app.log", encoding="utf-8"),
|
| 37 |
-
logging.StreamHandler(),
|
| 38 |
-
],
|
| 39 |
-
)
|
| 40 |
-
logger = logging.getLogger("backend.segmentation")
|
| 41 |
-
|
| 42 |
-
SAM2_CONFIG_PATH = os.getenv("SAM2_CONFIG_PATH", "configs/sam2.1/sam2.1_hiera_l.yaml")
|
| 43 |
-
SAM2_MODEL_PATH = os.getenv("SAM2_MODEL_PATH")
|
| 44 |
-
SAM2_DEFAULT_MODEL_NAMES = (
|
| 45 |
-
"sam2.1_hiera_large_fresh.pt",
|
| 46 |
-
"sam2.1_hiera_large.pt",
|
| 47 |
-
"sam2.1_hiera_large.pth",
|
| 48 |
-
"sam2_hiera_large.pt",
|
| 49 |
-
)
|
| 50 |
-
SAM2_MODEL_DIR_CANDIDATES = ("models", "modelo")
|
| 51 |
-
SAM2_UNLOAD_AFTER_USE = str(os.getenv("SAM2_UNLOAD_AFTER_USE", "0")).strip().lower() in {"1", "true", "yes"}
|
| 52 |
-
FRONTEND_DEBUG = str(os.getenv("FRONTEND_DEBUG", "0")).strip().lower() in {"1", "true", "yes", "on"}
|
| 53 |
-
|
| 54 |
-
# URL del Space de Gradio GPU (principal). Si falla, se usa el CPU fallback.
|
| 55 |
-
# Local: http://localhost:7860
|
| 56 |
-
# Producción: https://<tu-space>.hf.space
|
| 57 |
-
GRADIO_SPACE_URL: str = os.getenv("GRADIO_SPACE_URL", "").rstrip("/")
|
| 58 |
-
|
| 59 |
-
# URL del Space de Gradio CPU (respaldo automático si el GPU falla o agota quota).
|
| 60 |
-
GRADIO_CPU_FALLBACK_URL: str = os.getenv("GRADIO_CPU_FALLBACK_URL", "").rstrip("/")
|
| 61 |
-
|
| 62 |
-
MAX_UPLOAD_WIDTH = 1024
|
| 63 |
-
UPLOAD_JPEG_QUALITY = 82
|
| 64 |
-
SD_JOB_STALE_SECONDS = 120
|
| 65 |
-
UPLOAD_JOB_STALE_SECONDS = 900
|
| 66 |
-
UPLOAD_BASE_SECONDS = 8.0
|
| 67 |
-
UPLOAD_SECONDS_PER_MEGAPIXEL = 70.0
|
| 68 |
-
SD_QUICK_TIMEOUT_SECONDS = 15.0
|
| 69 |
-
|
| 70 |
-
SEMANTIC_MODEL_ID = "nvidia/segformer-b5-finetuned-ade-640-640"
|
| 71 |
-
DEPTH_MODEL_ID = "Intel/dpt-hybrid-midas"
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def utc_now_iso() -> str:
|
| 75 |
-
return datetime.now(timezone.utc).isoformat()
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def log_timing_start(step_name: str) -> float:
|
| 79 |
-
started = time.perf_counter()
|
| 80 |
-
logger.info(f"[{step_name}] START at {utc_now_iso()}")
|
| 81 |
-
return started
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def log_timing_end(step_name: str, started: float) -> None:
|
| 85 |
-
elapsed = time.perf_counter() - started
|
| 86 |
-
logger.info(f"[{step_name}] DONE {elapsed:.3f}s at {utc_now_iso()}")
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def load_classic_dashboard_html() -> str:
|
| 90 |
-
if not CLASSIC_DASHBOARD_HTML_PATH.exists() or not CLASSIC_DASHBOARD_HTML_PATH.is_file():
|
| 91 |
-
raise RuntimeError(f"Dashboard HTML template not found: {CLASSIC_DASHBOARD_HTML_PATH}")
|
| 92 |
-
return CLASSIC_DASHBOARD_HTML_PATH.read_text(encoding="utf-8")
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 8 |
+
|
| 9 |
+
UPLOAD_DIR = BASE_DIR / "uploads"
|
| 10 |
+
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 11 |
+
|
| 12 |
+
VIDEO_UPLOAD_DIR = UPLOAD_DIR / "videos"
|
| 13 |
+
VIDEO_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 14 |
+
|
| 15 |
+
OUTPUT_DIR = BASE_DIR / "outputs"
|
| 16 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
VIDEO_OUTPUT_DIR = OUTPUT_DIR / "videos"
|
| 19 |
+
VIDEO_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
|
| 21 |
+
TEXTURE_DIR = BASE_DIR / "texturas"
|
| 22 |
+
TEXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
|
| 24 |
+
LOG_DIR = BASE_DIR / "logs"
|
| 25 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 26 |
+
|
| 27 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 28 |
+
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
CLASSIC_DASHBOARD_HTML_PATH = TEMPLATES_DIR / "classic_dashboard.html"
|
| 31 |
+
|
| 32 |
+
logging.basicConfig(
|
| 33 |
+
level=logging.INFO,
|
| 34 |
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 35 |
+
handlers=[
|
| 36 |
+
logging.FileHandler(LOG_DIR / "app.log", encoding="utf-8"),
|
| 37 |
+
logging.StreamHandler(),
|
| 38 |
+
],
|
| 39 |
+
)
|
| 40 |
+
logger = logging.getLogger("backend.segmentation")
|
| 41 |
+
|
| 42 |
+
SAM2_CONFIG_PATH = os.getenv("SAM2_CONFIG_PATH", "configs/sam2.1/sam2.1_hiera_l.yaml")
|
| 43 |
+
SAM2_MODEL_PATH = os.getenv("SAM2_MODEL_PATH")
|
| 44 |
+
SAM2_DEFAULT_MODEL_NAMES = (
|
| 45 |
+
"sam2.1_hiera_large_fresh.pt",
|
| 46 |
+
"sam2.1_hiera_large.pt",
|
| 47 |
+
"sam2.1_hiera_large.pth",
|
| 48 |
+
"sam2_hiera_large.pt",
|
| 49 |
+
)
|
| 50 |
+
SAM2_MODEL_DIR_CANDIDATES = ("models", "modelo")
|
| 51 |
+
SAM2_UNLOAD_AFTER_USE = str(os.getenv("SAM2_UNLOAD_AFTER_USE", "0")).strip().lower() in {"1", "true", "yes"}
|
| 52 |
+
FRONTEND_DEBUG = str(os.getenv("FRONTEND_DEBUG", "0")).strip().lower() in {"1", "true", "yes", "on"}
|
| 53 |
+
|
| 54 |
+
# URL del Space de Gradio GPU (principal). Si falla, se usa el CPU fallback.
|
| 55 |
+
# Local: http://localhost:7860
|
| 56 |
+
# Producción: https://<tu-space>.hf.space
|
| 57 |
+
GRADIO_SPACE_URL: str = os.getenv("GRADIO_SPACE_URL", "").rstrip("/")
|
| 58 |
+
|
| 59 |
+
# URL del Space de Gradio CPU (respaldo automático si el GPU falla o agota quota).
|
| 60 |
+
GRADIO_CPU_FALLBACK_URL: str = os.getenv("GRADIO_CPU_FALLBACK_URL", "").rstrip("/")
|
| 61 |
+
|
| 62 |
+
MAX_UPLOAD_WIDTH = 1024
|
| 63 |
+
UPLOAD_JPEG_QUALITY = 82
|
| 64 |
+
SD_JOB_STALE_SECONDS = 120
|
| 65 |
+
UPLOAD_JOB_STALE_SECONDS = 900
|
| 66 |
+
UPLOAD_BASE_SECONDS = 8.0
|
| 67 |
+
UPLOAD_SECONDS_PER_MEGAPIXEL = 70.0
|
| 68 |
+
SD_QUICK_TIMEOUT_SECONDS = 15.0
|
| 69 |
+
|
| 70 |
+
SEMANTIC_MODEL_ID = "nvidia/segformer-b5-finetuned-ade-640-640"
|
| 71 |
+
DEPTH_MODEL_ID = "Intel/dpt-hybrid-midas"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def utc_now_iso() -> str:
|
| 75 |
+
return datetime.now(timezone.utc).isoformat()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def log_timing_start(step_name: str) -> float:
|
| 79 |
+
started = time.perf_counter()
|
| 80 |
+
logger.info(f"[{step_name}] START at {utc_now_iso()}")
|
| 81 |
+
return started
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def log_timing_end(step_name: str, started: float) -> None:
|
| 85 |
+
elapsed = time.perf_counter() - started
|
| 86 |
+
logger.info(f"[{step_name}] DONE {elapsed:.3f}s at {utc_now_iso()}")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def load_classic_dashboard_html() -> str:
|
| 90 |
+
if not CLASSIC_DASHBOARD_HTML_PATH.exists() or not CLASSIC_DASHBOARD_HTML_PATH.is_file():
|
| 91 |
+
raise RuntimeError(f"Dashboard HTML template not found: {CLASSIC_DASHBOARD_HTML_PATH}")
|
| 92 |
+
return CLASSIC_DASHBOARD_HTML_PATH.read_text(encoding="utf-8")
|
backend/data/presets.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
| 3 |
"ancho_panel_m": 0.3,
|
| 4 |
"alto_panel_m": 0.3,
|
| 5 |
"intensidad_textura": 0.85,
|
| 6 |
-
"separacion_vertical_px": 0.
|
| 7 |
-
"separacion_horizontal_px": 0.
|
| 8 |
"orientacion": "vertical",
|
| 9 |
"perspectiva_horizontal": 0.5,
|
| 10 |
"perspectiva_vertical": 0.7,
|
|
@@ -22,8 +22,8 @@
|
|
| 22 |
"modo_fusion": "luz suave"
|
| 23 |
},
|
| 24 |
"WPC_DECK": {
|
| 25 |
-
"ancho_panel_m": 0.
|
| 26 |
-
"alto_panel_m": 0.
|
| 27 |
"intensidad_textura": 0.85,
|
| 28 |
"separacion_vertical_px": 0,
|
| 29 |
"separacion_horizontal_px": 5,
|
|
|
|
| 3 |
"ancho_panel_m": 0.3,
|
| 4 |
"alto_panel_m": 0.3,
|
| 5 |
"intensidad_textura": 0.85,
|
| 6 |
+
"separacion_vertical_px": 0.4,
|
| 7 |
+
"separacion_horizontal_px": 0.4,
|
| 8 |
"orientacion": "vertical",
|
| 9 |
"perspectiva_horizontal": 0.5,
|
| 10 |
"perspectiva_vertical": 0.7,
|
|
|
|
| 22 |
"modo_fusion": "luz suave"
|
| 23 |
},
|
| 24 |
"WPC_DECK": {
|
| 25 |
+
"ancho_panel_m": 0.14,
|
| 26 |
+
"alto_panel_m": 0.025,
|
| 27 |
"intensidad_textura": 0.85,
|
| 28 |
"separacion_vertical_px": 0,
|
| 29 |
"separacion_horizontal_px": 5,
|
backend/logs/app.log
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/main.py
CHANGED
|
@@ -1,129 +1,127 @@
|
|
| 1 |
-
import mimetypes
|
| 2 |
-
import os
|
| 3 |
-
import subprocess
|
| 4 |
-
import threading
|
| 5 |
-
import time
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
|
| 8 |
-
from dotenv import load_dotenv
|
| 9 |
-
load_dotenv(Path(__file__).resolve().parent / ".env")
|
| 10 |
-
|
| 11 |
-
from fastapi import FastAPI, Request
|
| 12 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
-
from fastapi.staticfiles import StaticFiles
|
| 14 |
-
|
| 15 |
-
from core.config import
|
| 16 |
-
from routers import auth, catalog, media, pages, segmentation, sessions, share,
|
| 17 |
-
from routers.catalog import seed_catalog
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
mimetypes.add_type("
|
| 21 |
-
mimetypes.add_type("
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
response =
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
#
|
| 47 |
-
|
| 48 |
-
app.include_router(
|
| 49 |
-
app.include_router(
|
| 50 |
-
app.include_router(
|
| 51 |
-
app.include_router(
|
| 52 |
-
app.include_router(
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
print(
|
| 94 |
-
|
| 95 |
-
print(
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
import uvicorn
|
| 129 |
-
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
|
|
|
|
| 1 |
+
import mimetypes
|
| 2 |
+
import os
|
| 3 |
+
import subprocess
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
load_dotenv(Path(__file__).resolve().parent / ".env")
|
| 10 |
+
|
| 11 |
+
from fastapi import FastAPI, Request
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
from fastapi.staticfiles import StaticFiles
|
| 14 |
+
|
| 15 |
+
from core.config import logger
|
| 16 |
+
from routers import auth, catalog, media, pages, segmentation, sessions, share, openai_image, active_sessions
|
| 17 |
+
from routers.catalog import seed_catalog
|
| 18 |
+
|
| 19 |
+
mimetypes.add_type("application/javascript", ".js", strict=True)
|
| 20 |
+
mimetypes.add_type("text/css", ".css", strict=True)
|
| 21 |
+
mimetypes.add_type("image/svg+xml", ".svg", strict=True)
|
| 22 |
+
|
| 23 |
+
app = FastAPI(title="Hyper Reality Backend")
|
| 24 |
+
|
| 25 |
+
app.add_middleware(
|
| 26 |
+
CORSMiddleware,
|
| 27 |
+
allow_origins=["*"],
|
| 28 |
+
allow_credentials=True,
|
| 29 |
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
| 30 |
+
allow_headers=["*"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@app.middleware("http")
|
| 35 |
+
async def remove_x_frame_options(request: Request, call_next):
|
| 36 |
+
response = await call_next(request)
|
| 37 |
+
if "x-frame-options" in response.headers:
|
| 38 |
+
del response.headers["x-frame-options"]
|
| 39 |
+
response.headers["Content-Security-Policy"] = "frame-ancestors *"
|
| 40 |
+
return response
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Routers
|
| 44 |
+
# Mantener sólo la ruta de subida de imágenes (/api/upload-image).
|
| 45 |
+
# Comentamos las demás inclusiones para deshabilitar funcionalidades
|
| 46 |
+
# posteriores (segmentación, inpainting, sesiones, catálogo, etc.).
|
| 47 |
+
# Re-activar routers necesarios para el frontend
|
| 48 |
+
app.include_router(sessions.router)
|
| 49 |
+
app.include_router(segmentation.router)
|
| 50 |
+
app.include_router(openai_image.router)
|
| 51 |
+
app.include_router(active_sessions.router)
|
| 52 |
+
app.include_router(catalog.router)
|
| 53 |
+
|
| 54 |
+
# Static files
|
| 55 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 56 |
+
UPLOADS_DIR = BASE_DIR / "uploads"
|
| 57 |
+
FRONTEND_DIST = BASE_DIR.parent / "frontend" / "dist"
|
| 58 |
+
|
| 59 |
+
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads")
|
| 61 |
+
|
| 62 |
+
if (FRONTEND_DIST / "index.html").exists():
|
| 63 |
+
# Montado en "/" como catch-all para SPA — los routers de API tienen prioridad
|
| 64 |
+
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# Frontend watcher (development helper)
|
| 68 |
+
FRONTEND_DIR = BASE_DIR.parent / "frontend"
|
| 69 |
+
FRONTEND_SRC = FRONTEND_DIR / "src"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def scan_frontend_sources() -> dict:
|
| 73 |
+
if not FRONTEND_SRC.exists():
|
| 74 |
+
return {}
|
| 75 |
+
files = {}
|
| 76 |
+
for path in FRONTEND_SRC.rglob("*"):
|
| 77 |
+
if path.is_file() and path.suffix in {".ts", ".tsx", ".js", ".jsx", ".css", ".json", ".html"}:
|
| 78 |
+
files[path] = path.stat().st_mtime
|
| 79 |
+
for extra in [FRONTEND_DIR / "vite.config.ts", FRONTEND_DIR / "package.json", FRONTEND_DIR / "tsconfig.json"]:
|
| 80 |
+
if extra.exists():
|
| 81 |
+
files[extra] = extra.stat().st_mtime
|
| 82 |
+
return files
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def run_frontend_build() -> None:
|
| 86 |
+
if not FRONTEND_DIR.exists():
|
| 87 |
+
return
|
| 88 |
+
print("[backend] Ejecutando build del frontend...")
|
| 89 |
+
result = subprocess.run(["npm", "run", "build"], cwd=str(FRONTEND_DIR), capture_output=True, text=True)
|
| 90 |
+
if result.returncode != 0:
|
| 91 |
+
print("[backend] Build falló:")
|
| 92 |
+
print(result.stdout)
|
| 93 |
+
print(result.stderr)
|
| 94 |
+
else:
|
| 95 |
+
print("[backend] Build completado correctamente.")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def watch_frontend_changes(interval: float = 2.0) -> None:
|
| 99 |
+
last_state = scan_frontend_sources()
|
| 100 |
+
while True:
|
| 101 |
+
time.sleep(interval)
|
| 102 |
+
current_state = scan_frontend_sources()
|
| 103 |
+
if current_state != last_state:
|
| 104 |
+
if last_state:
|
| 105 |
+
print("[backend] Cambio detectado en frontend. Reconstruyendo...")
|
| 106 |
+
run_frontend_build()
|
| 107 |
+
last_state = current_state
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.on_event("startup")
|
| 111 |
+
async def startup_seed_catalog():
|
| 112 |
+
if MONGODB_URI := os.getenv("MONGODB_URI", ""):
|
| 113 |
+
try:
|
| 114 |
+
await seed_catalog()
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
logger.warning("[STARTUP] seed_catalog falló: %s", exc)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@app.on_event("startup")
|
| 120 |
+
async def startup_watch_frontend():
|
| 121 |
+
thread = threading.Thread(target=watch_frontend_changes, daemon=True)
|
| 122 |
+
thread.start()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
if __name__ == "__main__":
|
| 126 |
+
import uvicorn
|
| 127 |
+
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
|
|
|
|
|
|
backend/models/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (177 Bytes). View file
|
|
|
backend/models/__pycache__/schemas.cpython-312.pyc
CHANGED
|
Binary files a/backend/models/__pycache__/schemas.cpython-312.pyc and b/backend/models/__pycache__/schemas.cpython-312.pyc differ
|
|
|
backend/models/__pycache__/schemas.cpython-313.pyc
ADDED
|
Binary file (6.5 kB). View file
|
|
|
backend/models/schemas.py
CHANGED
|
@@ -112,5 +112,7 @@ class ApplyColorRequest(BaseModel):
|
|
| 112 |
class ApplyTextureAIRequest(BaseModel):
|
| 113 |
filename: str
|
| 114 |
original_filename: str = ""
|
| 115 |
-
mask_filename: str
|
| 116 |
prompt: str = ""
|
|
|
|
|
|
|
|
|
| 112 |
class ApplyTextureAIRequest(BaseModel):
|
| 113 |
filename: str
|
| 114 |
original_filename: str = ""
|
| 115 |
+
mask_filename: str = ""
|
| 116 |
prompt: str = ""
|
| 117 |
+
texture_name: str = ""
|
| 118 |
+
|
backend/requirements.txt
CHANGED
|
@@ -9,3 +9,4 @@ pydantic
|
|
| 9 |
python-multipart
|
| 10 |
gradio_client
|
| 11 |
opencv-python-headless
|
|
|
|
|
|
| 9 |
python-multipart
|
| 10 |
gradio_client
|
| 11 |
opencv-python-headless
|
| 12 |
+
openai
|
backend/routers/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (178 Bytes). View file
|
|
|
backend/routers/__pycache__/active_sessions.cpython-312.pyc
ADDED
|
Binary file (622 Bytes). View file
|
|
|
backend/routers/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (8.86 kB). View file
|
|
|
backend/routers/__pycache__/catalog.cpython-312.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/catalog.cpython-312.pyc and b/backend/routers/__pycache__/catalog.cpython-312.pyc differ
|
|
|
backend/routers/__pycache__/catalog.cpython-313.pyc
ADDED
|
Binary file (15.7 kB). View file
|
|
|
backend/routers/__pycache__/openai_image.cpython-312.pyc
ADDED
|
Binary file (2.05 kB). View file
|
|
|
backend/routers/__pycache__/segmentation.cpython-312.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/segmentation.cpython-312.pyc and b/backend/routers/__pycache__/segmentation.cpython-312.pyc differ
|
|
|
backend/routers/__pycache__/segmentation.cpython-313.pyc
ADDED
|
Binary file (49.1 kB). View file
|
|
|
backend/routers/active_sessions.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
|
| 4 |
+
router = APIRouter()
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@router.get("/api/active-sessions")
|
| 8 |
+
async def get_active_sessions():
|
| 9 |
+
# Endpoint ligero que devuelve sesiones activas.
|
| 10 |
+
# Actualmente no consultamos BD aquí; devolvemos un objeto vacío para evitar errores en frontend.
|
| 11 |
+
return JSONResponse(content={"count": 0, "active_sessions": []})
|
backend/routers/catalog.py
CHANGED
|
@@ -1,241 +1,240 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from datetime import datetime
|
| 3 |
-
|
| 4 |
-
from fastapi import APIRouter
|
| 5 |
-
from fastapi.responses import JSONResponse
|
| 6 |
-
from motor.motor_asyncio import AsyncIOMotorClient
|
| 7 |
-
from pydantic import BaseModel
|
| 8 |
-
|
| 9 |
-
router = APIRouter(prefix="/api/catalog")
|
| 10 |
-
|
| 11 |
-
MONGODB_URI = os.getenv("MONGODB_URI", "")
|
| 12 |
-
_client: AsyncIOMotorClient | None = None
|
| 13 |
-
_db = None
|
| 14 |
-
_col = None
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def _get_col():
|
| 18 |
-
global _client, _db, _col
|
| 19 |
-
if _col is None:
|
| 20 |
-
if not MONGODB_URI:
|
| 21 |
-
raise RuntimeError("MONGODB_URI no configurado")
|
| 22 |
-
_client = AsyncIOMotorClient(MONGODB_URI)
|
| 23 |
-
_db = _client["hyper_reality"]
|
| 24 |
-
_col = _db["catalog"]
|
| 25 |
-
return _col
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
# ── Datos iniciales (se insertan solo si la colección está vacía) ─────────────
|
| 29 |
-
_SEED = [
|
| 30 |
-
{
|
| 31 |
-
"_id": "acm",
|
| 32 |
-
"nombre": "ACM (Aluminio Compuesto)",
|
| 33 |
-
"tipo": "paredes",
|
| 34 |
-
"descripcion": "Paneles de aluminio compuesto para fachadas y exteriores",
|
| 35 |
-
"especificaciones": [
|
| 36 |
-
"Espesor de ACM 4mm.",
|
| 37 |
-
"Medida 1.22m x 2.44m",
|
| 38 |
-
"Facil Mantenimiento.",
|
| 39 |
-
"Espesor de Aluminio 0.40mm.",
|
| 40 |
-
"Se puede doblar o biselar",
|
| 41 |
-
],
|
| 42 |
-
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/16",
|
| 43 |
-
"productos": [
|
| 44 |
-
{"id": "acm_white", "nombre": "Glossy White", "textura": "Texture_ACM/ACM_White.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_White.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 45 |
-
{"id": "acm_amarillo", "nombre": "Amarillo", "textura": "Texture_ACM/ACM_Amarillo.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Amarillo.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 46 |
-
{"id": "acm_orange", "nombre": "Glossy Orange", "textura": "Texture_ACM/ACM_Orange.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Orange.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 47 |
-
{"id": "acm_red", "nombre": "Glossy Red", "textura": "Texture_ACM/ACM_Glossy_Red.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Red.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 48 |
-
{"id": "acm_light_blue", "nombre": "Light Blue", "textura": "Texture_ACM/ACM_Light_Blue.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Light_Blue.png", "dimensiones": ["1.22x2.44"]},
|
| 49 |
-
{"id": "acm_azul", "nombre": "Azul", "textura": "Texture_ACM/ACM_Azul.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Azul.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 50 |
-
{"id": "acm_verde_hn", "nombre": "Verde HN", "textura": "Texture_ACM/ACM_Verde_HN.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_HN.png", "dimensiones": ["1.22x2.44"]},
|
| 51 |
-
{"id": "acm_verde_lima", "nombre": "Verde Lima", "textura": "Texture_ACM/ACM_Verde_Lima.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_Lima.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 52 |
-
{"id": "acm_verde", "nombre": "Verde", "textura": "Texture_ACM/ACM_Verde.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 53 |
-
{"id": "acm_madera_clara","nombre": "Madera Clara", "textura": "Texture_ACM/ACM_Madera_Clara.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Madera_Clara.png", "dimensiones": ["1.22x2.44"]},
|
| 54 |
-
{"id": "acm_roble", "nombre": "Roble (Oak)", "textura": "Texture_ACM/ACM_ROBLE(OAK).png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_ROBLE(OAK).png", "dimensiones": ["1.22x2.44"]},
|
| 55 |
-
{"id": "acm_grafito", "nombre": "Grafito", "textura": "Texture_ACM/ACM_Grafito.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Grafito.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 56 |
-
{"id": "acm_metalic", "nombre": "Silver Metallic", "textura": "Texture_ACM/ACM_Metalic.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Metalic.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 57 |
-
{"id": "acm_mouse_grey", "nombre": "Mouse Grey", "textura": "Texture_ACM/ACM_MouseGrey.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_MouseGrey.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 58 |
-
{"id": "acm_matte_black", "nombre": "Matte Black", "textura": "Texture_ACM/ACM_Matteblack.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Matteblack.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 59 |
-
{"id": "acm_glossy_black","nombre": "Glossy Black", "textura": "Texture_ACM/ACM_Glossy_Black.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Black.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 60 |
-
],
|
| 61 |
-
"created_at": "2026-04-20T00:00:00Z",
|
| 62 |
-
},
|
| 63 |
-
{
|
| 64 |
-
"_id": "wpc",
|
| 65 |
-
"nombre": "WPC (Exterior e Interior)",
|
| 66 |
-
"tipo": "paredes",
|
| 67 |
-
"descripcion": "Los paneles de WPC se utilizan como revestimiento decorativo para paredes. No se deforma, no requiere mantenimiento constante y tiene mayor durabilidad. Crea mayor estetica e instalacion rapida.",
|
| 68 |
-
"especificaciones": [
|
| 69 |
-
"Revestimiento decorativo para paredes.",
|
| 70 |
-
"No se deforma ni requiere mantenimiento constante.",
|
| 71 |
-
"Mayor durabilidad y estetica.",
|
| 72 |
-
"Instalacion rapida.",
|
| 73 |
-
"Ideal para: sala principal (pared protagonista), comedor, interior de oficina, pasillo o entradas.",
|
| 74 |
-
],
|
| 75 |
-
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/39",
|
| 76 |
-
"productos": [
|
| 77 |
-
{"id": "WPC_madera_oscuro", "nombre": "WPC Madera Oscuro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "dimensiones": ["2.90x0.25"]},
|
| 78 |
-
{"id": "WPC_madera_claro", "nombre": "WPC Madera Claro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "dimensiones": ["2.90x0.25"]},
|
| 79 |
-
{"id": "WPC_madera_gris", "nombre": "WPC Madera Gris", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "dimensiones": ["2.90x0.25"]},
|
| 80 |
-
{"id": "WPC_negro", "nombre": "WPC Negro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "dimensiones": ["2.90x0.25"]},
|
| 81 |
-
],
|
| 82 |
-
"created_at": "2026-05-07T00:00:00Z",
|
| 83 |
-
},
|
| 84 |
-
{
|
| 85 |
-
"_id": "wpc_deck",
|
| 86 |
-
"nombre": "WPC Deck",
|
| 87 |
-
"tipo": "suelos",
|
| 88 |
-
"descripcion": "Deck de WPC para exteriores e interiores. Ideal para terrazas, jardines, bordes de piscina y espacios al aire libre.",
|
| 89 |
-
"especificaciones": [
|
| 90 |
-
"Resistencia a la intemperie.",
|
| 91 |
-
"Bajo mantenimiento.",
|
| 92 |
-
"Respetuosa con el medio ambiente.",
|
| 93 |
-
"Esteticamente agradable.",
|
| 94 |
-
],
|
| 95 |
-
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/38",
|
| 96 |
-
"productos": [
|
| 97 |
-
{"id": "DECK_madera", "nombre": "Deck Madera", "textura": "
|
| 98 |
-
{"id": "DECK_madera_oscuro", "nombre": "Deck Madera Oscuro", "textura": "
|
| 99 |
-
{"id": "DECK_gris", "nombre": "Deck Gris", "textura": "
|
| 100 |
-
],
|
| 101 |
-
"created_at": "2026-05-08T00:00:00Z",
|
| 102 |
-
},
|
| 103 |
-
]
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
async def seed_catalog() -> None:
|
| 107 |
-
col = _get_col()
|
| 108 |
-
count = await col.count_documents({})
|
| 109 |
-
if count == 0:
|
| 110 |
-
await col.insert_many(_SEED)
|
| 111 |
-
return
|
| 112 |
-
|
| 113 |
-
#
|
| 114 |
-
for seed_item in _SEED:
|
| 115 |
-
doc = await col.find_one({"_id": seed_item["_id"]})
|
| 116 |
-
if not doc:
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
out =
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
doc =
|
| 193 |
-
doc["
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
doc
|
| 203 |
-
doc
|
| 204 |
-
|
| 205 |
-
result =
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
result =
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
{"
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
{"
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
return JSONResponse(content={"ok": True})
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/catalog")
|
| 10 |
+
|
| 11 |
+
MONGODB_URI = os.getenv("MONGODB_URI", "")
|
| 12 |
+
_client: AsyncIOMotorClient | None = None
|
| 13 |
+
_db = None
|
| 14 |
+
_col = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _get_col():
|
| 18 |
+
global _client, _db, _col
|
| 19 |
+
if _col is None:
|
| 20 |
+
if not MONGODB_URI:
|
| 21 |
+
raise RuntimeError("MONGODB_URI no configurado")
|
| 22 |
+
_client = AsyncIOMotorClient(MONGODB_URI)
|
| 23 |
+
_db = _client["hyper_reality"]
|
| 24 |
+
_col = _db["catalog"]
|
| 25 |
+
return _col
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── Datos iniciales (se insertan solo si la colección está vacía) ─────────────
|
| 29 |
+
_SEED = [
|
| 30 |
+
{
|
| 31 |
+
"_id": "acm",
|
| 32 |
+
"nombre": "ACM (Aluminio Compuesto)",
|
| 33 |
+
"tipo": "paredes",
|
| 34 |
+
"descripcion": "Paneles de aluminio compuesto para fachadas y exteriores",
|
| 35 |
+
"especificaciones": [
|
| 36 |
+
"Espesor de ACM 4mm.",
|
| 37 |
+
"Medida 1.22m x 2.44m",
|
| 38 |
+
"Facil Mantenimiento.",
|
| 39 |
+
"Espesor de Aluminio 0.40mm.",
|
| 40 |
+
"Se puede doblar o biselar",
|
| 41 |
+
],
|
| 42 |
+
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/16",
|
| 43 |
+
"productos": [
|
| 44 |
+
{"id": "acm_white", "nombre": "Glossy White", "textura": "Texture_ACM/ACM_White.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_White.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 45 |
+
{"id": "acm_amarillo", "nombre": "Amarillo", "textura": "Texture_ACM/ACM_Amarillo.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Amarillo.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 46 |
+
{"id": "acm_orange", "nombre": "Glossy Orange", "textura": "Texture_ACM/ACM_Orange.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Orange.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 47 |
+
{"id": "acm_red", "nombre": "Glossy Red", "textura": "Texture_ACM/ACM_Glossy_Red.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Red.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 48 |
+
{"id": "acm_light_blue", "nombre": "Light Blue", "textura": "Texture_ACM/ACM_Light_Blue.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Light_Blue.png", "dimensiones": ["1.22x2.44"]},
|
| 49 |
+
{"id": "acm_azul", "nombre": "Azul", "textura": "Texture_ACM/ACM_Azul.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Azul.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 50 |
+
{"id": "acm_verde_hn", "nombre": "Verde HN", "textura": "Texture_ACM/ACM_Verde_HN.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_HN.png", "dimensiones": ["1.22x2.44"]},
|
| 51 |
+
{"id": "acm_verde_lima", "nombre": "Verde Lima", "textura": "Texture_ACM/ACM_Verde_Lima.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde_Lima.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 52 |
+
{"id": "acm_verde", "nombre": "Verde", "textura": "Texture_ACM/ACM_Verde.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Verde.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 53 |
+
{"id": "acm_madera_clara","nombre": "Madera Clara", "textura": "Texture_ACM/ACM_Madera_Clara.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Madera_Clara.png", "dimensiones": ["1.22x2.44"]},
|
| 54 |
+
{"id": "acm_roble", "nombre": "Roble (Oak)", "textura": "Texture_ACM/ACM_ROBLE(OAK).png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_ROBLE(OAK).png", "dimensiones": ["1.22x2.44"]},
|
| 55 |
+
{"id": "acm_grafito", "nombre": "Grafito", "textura": "Texture_ACM/ACM_Grafito.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Grafito.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 56 |
+
{"id": "acm_metalic", "nombre": "Silver Metallic", "textura": "Texture_ACM/ACM_Metalic.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Metalic.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 57 |
+
{"id": "acm_mouse_grey", "nombre": "Mouse Grey", "textura": "Texture_ACM/ACM_MouseGrey.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_MouseGrey.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 58 |
+
{"id": "acm_matte_black", "nombre": "Matte Black", "textura": "Texture_ACM/ACM_Matteblack.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Matteblack.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 59 |
+
{"id": "acm_glossy_black","nombre": "Glossy Black", "textura": "Texture_ACM/ACM_Glossy_Black.png", "url_preview": "/seg/texture-preview/Texture_ACM/ACM_Glossy_Black.png", "dimensiones": ["1.22x2.44", "1.50x4.98"]},
|
| 60 |
+
],
|
| 61 |
+
"created_at": "2026-04-20T00:00:00Z",
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"_id": "wpc",
|
| 65 |
+
"nombre": "WPC (Exterior e Interior)",
|
| 66 |
+
"tipo": "paredes",
|
| 67 |
+
"descripcion": "Los paneles de WPC se utilizan como revestimiento decorativo para paredes. No se deforma, no requiere mantenimiento constante y tiene mayor durabilidad. Crea mayor estetica e instalacion rapida.",
|
| 68 |
+
"especificaciones": [
|
| 69 |
+
"Revestimiento decorativo para paredes.",
|
| 70 |
+
"No se deforma ni requiere mantenimiento constante.",
|
| 71 |
+
"Mayor durabilidad y estetica.",
|
| 72 |
+
"Instalacion rapida.",
|
| 73 |
+
"Ideal para: sala principal (pared protagonista), comedor, interior de oficina, pasillo o entradas.",
|
| 74 |
+
],
|
| 75 |
+
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/39",
|
| 76 |
+
"productos": [
|
| 77 |
+
{"id": "WPC_madera_oscuro", "nombre": "WPC Madera Oscuro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_oscuro.png", "dimensiones": ["2.90x0.25"]},
|
| 78 |
+
{"id": "WPC_madera_claro", "nombre": "WPC Madera Claro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_claro.png", "dimensiones": ["2.90x0.25"]},
|
| 79 |
+
{"id": "WPC_madera_gris", "nombre": "WPC Madera Gris", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_madera_gris.png", "dimensiones": ["2.90x0.25"]},
|
| 80 |
+
{"id": "WPC_negro", "nombre": "WPC Negro", "textura": "Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "url_preview": "/seg/texture-preview/Texture_WPC_EXTERIOR_INTERIOR/WPC_negro.png", "dimensiones": ["2.90x0.25"]},
|
| 81 |
+
],
|
| 82 |
+
"created_at": "2026-05-07T00:00:00Z",
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"_id": "wpc_deck",
|
| 86 |
+
"nombre": "WPC Deck",
|
| 87 |
+
"tipo": "suelos",
|
| 88 |
+
"descripcion": "Deck de WPC para exteriores e interiores. Ideal para terrazas, jardines, bordes de piscina y espacios al aire libre.",
|
| 89 |
+
"especificaciones": [
|
| 90 |
+
"Resistencia a la intemperie.",
|
| 91 |
+
"Bajo mantenimiento.",
|
| 92 |
+
"Respetuosa con el medio ambiente.",
|
| 93 |
+
"Esteticamente agradable.",
|
| 94 |
+
],
|
| 95 |
+
"url_detalle": "https://heyzine.com/flip-book/447fe3eb8e.html#page/38",
|
| 96 |
+
"productos": [
|
| 97 |
+
{"id": "DECK_madera", "nombre": "Deck Madera", "textura": "Texture_wpc_deck/DECK_madera.png", "url_preview": "/seg/texture-preview/Texture_wpc_deck/DECK_madera.png", "dimensiones": ["2.90x0.14"]},
|
| 98 |
+
{"id": "DECK_madera_oscuro", "nombre": "Deck Madera Oscuro", "textura": "Texture_wpc_deck/DECK_madera_oscuro.png", "url_preview": "/seg/texture-preview/Texture_wpc_deck/DECK_madera_oscuro.png", "dimensiones": ["2.90x0.14"]},
|
| 99 |
+
{"id": "DECK_gris", "nombre": "Deck Gris", "textura": "Texture_wpc_deck/DECK_gris.png", "url_preview": "/seg/texture-preview/Texture_wpc_deck/DECK_gris.png", "dimensiones": ["2.90x0.14"]},
|
| 100 |
+
],
|
| 101 |
+
"created_at": "2026-05-08T00:00:00Z",
|
| 102 |
+
},
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
async def seed_catalog() -> None:
|
| 107 |
+
col = _get_col()
|
| 108 |
+
count = await col.count_documents({})
|
| 109 |
+
if count == 0:
|
| 110 |
+
await col.insert_many(_SEED)
|
| 111 |
+
return
|
| 112 |
+
|
| 113 |
+
# Migrar documentos existentes: añadir campos que falten según _SEED
|
| 114 |
+
for seed_item in _SEED:
|
| 115 |
+
doc = await col.find_one({"_id": seed_item["_id"]})
|
| 116 |
+
if not doc:
|
| 117 |
+
continue
|
| 118 |
+
patch = {k: v for k, v in seed_item.items() if k not in doc and k != "_id"}
|
| 119 |
+
if patch:
|
| 120 |
+
await col.update_one({"_id": seed_item["_id"]}, {"$set": patch})
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _serialize(doc: dict) -> dict:
|
| 124 |
+
out = dict(doc)
|
| 125 |
+
out["id"] = str(out.pop("_id"))
|
| 126 |
+
return out
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _seed_as_response() -> list[dict]:
|
| 130 |
+
return [{**{k: v for k, v in item.items() if k != "_id"}, "id": item["_id"]} for item in _SEED]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ── Endpoints de lectura ──────────────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
+
@router.get("/textures")
|
| 136 |
+
async def get_texture_catalog() -> JSONResponse:
|
| 137 |
+
try:
|
| 138 |
+
col = _get_col()
|
| 139 |
+
docs = await col.find({}).to_list(length=200)
|
| 140 |
+
if docs:
|
| 141 |
+
return JSONResponse(content={"categories": [_serialize(d) for d in docs]})
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
# Fallback a datos estáticos si MongoDB no está disponible o la colección está vacía
|
| 145 |
+
return JSONResponse(content={"categories": _seed_as_response()})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.get("/textures/{category_id}")
|
| 149 |
+
async def get_texture_category(category_id: str) -> JSONResponse:
|
| 150 |
+
try:
|
| 151 |
+
col = _get_col()
|
| 152 |
+
doc = await col.find_one({"_id": category_id})
|
| 153 |
+
if doc:
|
| 154 |
+
return JSONResponse(content=_serialize(doc))
|
| 155 |
+
except Exception:
|
| 156 |
+
pass
|
| 157 |
+
fallback = next((item for item in _SEED if item["_id"] == category_id), None)
|
| 158 |
+
if fallback:
|
| 159 |
+
return JSONResponse(content=_seed_as_response()[_SEED.index(fallback)])
|
| 160 |
+
return JSONResponse(content={"detail": f"Categoria '{category_id}' no encontrada"}, status_code=404)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ── Modelos ───────────────────────────────────────────────────────────────────
|
| 164 |
+
|
| 165 |
+
class ProductoItem(BaseModel):
|
| 166 |
+
id: str
|
| 167 |
+
nombre: str
|
| 168 |
+
textura: str
|
| 169 |
+
url_preview: str
|
| 170 |
+
dimensiones: list[str] = []
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class CategoriaBody(BaseModel):
|
| 174 |
+
id: str
|
| 175 |
+
nombre: str
|
| 176 |
+
tipo: str = "paredes"
|
| 177 |
+
descripcion: str = ""
|
| 178 |
+
especificaciones: list[str] = []
|
| 179 |
+
url_detalle: str = ""
|
| 180 |
+
productos: list[ProductoItem] = []
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ── Endpoints de escritura ────────────────────────────────────────────────────
|
| 184 |
+
|
| 185 |
+
@router.post("/category")
|
| 186 |
+
async def add_category(body: CategoriaBody) -> JSONResponse:
|
| 187 |
+
col = _get_col()
|
| 188 |
+
existing = await col.find_one({"_id": body.id})
|
| 189 |
+
if existing:
|
| 190 |
+
return JSONResponse(content={"error": f"Categoria '{body.id}' ya existe"}, status_code=409)
|
| 191 |
+
doc = body.model_dump()
|
| 192 |
+
doc["_id"] = doc.pop("id")
|
| 193 |
+
doc["created_at"] = datetime.utcnow().isoformat() + "Z"
|
| 194 |
+
await col.insert_one(doc)
|
| 195 |
+
return JSONResponse(content={"ok": True, "id": body.id}, status_code=201)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@router.put("/category/{category_id}")
|
| 199 |
+
async def update_category(category_id: str, body: CategoriaBody) -> JSONResponse:
|
| 200 |
+
col = _get_col()
|
| 201 |
+
doc = body.model_dump()
|
| 202 |
+
doc.pop("id", None)
|
| 203 |
+
doc["updated_at"] = datetime.utcnow().isoformat() + "Z"
|
| 204 |
+
result = await col.update_one({"_id": category_id}, {"$set": doc})
|
| 205 |
+
if result.matched_count == 0:
|
| 206 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 207 |
+
return JSONResponse(content={"ok": True})
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@router.delete("/category/{category_id}")
|
| 211 |
+
async def delete_category(category_id: str) -> JSONResponse:
|
| 212 |
+
col = _get_col()
|
| 213 |
+
result = await col.delete_one({"_id": category_id})
|
| 214 |
+
if result.deleted_count == 0:
|
| 215 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 216 |
+
return JSONResponse(content={"ok": True, "deleted": category_id})
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@router.post("/category/{category_id}/product")
|
| 220 |
+
async def add_product(category_id: str, product: ProductoItem) -> JSONResponse:
|
| 221 |
+
col = _get_col()
|
| 222 |
+
result = await col.update_one(
|
| 223 |
+
{"_id": category_id, "productos.id": {"$ne": product.id}},
|
| 224 |
+
{"$push": {"productos": product.model_dump()}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
|
| 225 |
+
)
|
| 226 |
+
if result.matched_count == 0:
|
| 227 |
+
return JSONResponse(content={"error": "Categoria no encontrada o producto duplicado"}, status_code=409)
|
| 228 |
+
return JSONResponse(content={"ok": True}, status_code=201)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@router.delete("/category/{category_id}/product/{product_id}")
|
| 232 |
+
async def delete_product(category_id: str, product_id: str) -> JSONResponse:
|
| 233 |
+
col = _get_col()
|
| 234 |
+
result = await col.update_one(
|
| 235 |
+
{"_id": category_id},
|
| 236 |
+
{"$pull": {"productos": {"id": product_id}}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
|
| 237 |
+
)
|
| 238 |
+
if result.matched_count == 0:
|
| 239 |
+
return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
|
| 240 |
+
return JSONResponse(content={"ok": True})
|
|
|
backend/routers/openai_image.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
from PIL import Image
|
| 4 |
+
import io
|
| 5 |
+
|
| 6 |
+
from services.openai_service import generate_image_with_openai
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.post("/api/generate-image")
|
| 12 |
+
async def generate_image_endpoint(
|
| 13 |
+
file: UploadFile = File(...),
|
| 14 |
+
texture: str = Form(...),
|
| 15 |
+
api_key: str | None = Form(None),
|
| 16 |
+
preserve: int | None = Form(0),
|
| 17 |
+
):
|
| 18 |
+
if not file.content_type or not file.content_type.startswith("image/"):
|
| 19 |
+
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
contents = await file.read()
|
| 23 |
+
pil = Image.open(io.BytesIO(contents)).convert("RGB")
|
| 24 |
+
except Exception:
|
| 25 |
+
raise HTTPException(status_code=400, detail="No se pudo leer la imagen subida")
|
| 26 |
+
|
| 27 |
+
png_bytes, msg = generate_image_with_openai(api_key, pil, texture, preserve)
|
| 28 |
+
if png_bytes is None:
|
| 29 |
+
raise HTTPException(status_code=500, detail=msg)
|
| 30 |
+
|
| 31 |
+
b64 = __import__("base64").b64encode(png_bytes).decode("ascii")
|
| 32 |
+
return JSONResponse(content={"result_b64": b64, "message": msg})
|
backend/routers/segmentation.py
CHANGED
|
@@ -1,723 +1,760 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Segmentation router - todos los endpoints del editor de texturas con SAM2.
|
| 3 |
-
Prefijo: /seg
|
| 4 |
-
"""
|
| 5 |
-
import asyncio
|
| 6 |
-
import uuid
|
| 7 |
-
from datetime import datetime, timezone
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
from typing import Any, cast
|
| 10 |
-
|
| 11 |
-
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile
|
| 12 |
-
from fastapi.responses import FileResponse, HTMLResponse, Response
|
| 13 |
-
|
| 14 |
-
from core.config import (
|
| 15 |
-
FRONTEND_DEBUG,
|
| 16 |
-
OUTPUT_DIR,
|
| 17 |
-
SD_JOB_STALE_SECONDS,
|
| 18 |
-
SD_QUICK_TIMEOUT_SECONDS,
|
| 19 |
-
UPLOAD_DIR,
|
| 20 |
-
UPLOAD_JOB_STALE_SECONDS,
|
| 21 |
-
VIDEO_OUTPUT_DIR,
|
| 22 |
-
VIDEO_UPLOAD_DIR,
|
| 23 |
-
load_classic_dashboard_html,
|
| 24 |
-
log_timing_end,
|
| 25 |
-
log_timing_start,
|
| 26 |
-
logger,
|
| 27 |
-
utc_now_iso,
|
| 28 |
-
)
|
| 29 |
-
from pydantic import BaseModel
|
| 30 |
-
|
| 31 |
-
from models.schemas import (
|
| 32 |
-
ApplyColorRequest,
|
| 33 |
-
ApplyTextureAIRequest,
|
| 34 |
-
ApplyTextureRequest,
|
| 35 |
-
ExteriorBrickRequest,
|
| 36 |
-
ExteriorDepthRequest,
|
| 37 |
-
ExteriorGrabCutRequest,
|
| 38 |
-
ExteriorHybridRequest,
|
| 39 |
-
ExteriorSuggestRequest,
|
| 40 |
-
GuidedSegmentRequest,
|
| 41 |
-
SceneAnalyzeRequest,
|
| 42 |
-
SegmentAdaptiveRequest,
|
| 43 |
-
SegmentVideoRequest,
|
| 44 |
-
)
|
| 45 |
-
from services.image_service import (
|
| 46 |
-
prepare_and_store_upload,
|
| 47 |
-
run_upload_job,
|
| 48 |
-
save_label_map_for_owner,
|
| 49 |
-
)
|
| 50 |
-
from services.inpainting_service import run_inpainting_job, run_inpainting_sync
|
| 51 |
-
from services.sam2_service import jobs, jobs_lock, release_resources
|
| 52 |
-
from services.
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
"
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
"
|
| 130 |
-
"
|
| 131 |
-
"
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"
|
| 144 |
-
"
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
list(
|
| 160 |
-
payload.
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
"
|
| 171 |
-
"
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
"
|
| 216 |
-
"
|
| 217 |
-
"
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
payload.
|
| 248 |
-
payload.
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
"
|
| 273 |
-
"
|
| 274 |
-
"
|
| 275 |
-
"
|
| 276 |
-
"
|
| 277 |
-
"
|
| 278 |
-
"
|
| 279 |
-
"
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
if
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
"
|
| 334 |
-
"
|
| 335 |
-
"
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
result["
|
| 341 |
-
result["
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
except
|
| 358 |
-
raise
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
except
|
| 368 |
-
raise
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
except
|
| 378 |
-
raise
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
except
|
| 388 |
-
raise
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
except
|
| 398 |
-
raise
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
)
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
"
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
"
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 437 |
-
try:
|
| 438 |
-
release_resources()
|
| 439 |
-
except Exception:
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
@router.
|
| 620 |
-
async def
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
return
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
@router.
|
| 657 |
-
async def
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
if
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
if not
|
| 699 |
-
raise HTTPException(status_code=
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Segmentation router - todos los endpoints del editor de texturas con SAM2.
|
| 3 |
+
Prefijo: /seg
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, cast
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile
|
| 12 |
+
from fastapi.responses import FileResponse, HTMLResponse, Response
|
| 13 |
+
|
| 14 |
+
from core.config import (
|
| 15 |
+
FRONTEND_DEBUG,
|
| 16 |
+
OUTPUT_DIR,
|
| 17 |
+
SD_JOB_STALE_SECONDS,
|
| 18 |
+
SD_QUICK_TIMEOUT_SECONDS,
|
| 19 |
+
UPLOAD_DIR,
|
| 20 |
+
UPLOAD_JOB_STALE_SECONDS,
|
| 21 |
+
VIDEO_OUTPUT_DIR,
|
| 22 |
+
VIDEO_UPLOAD_DIR,
|
| 23 |
+
load_classic_dashboard_html,
|
| 24 |
+
log_timing_end,
|
| 25 |
+
log_timing_start,
|
| 26 |
+
logger,
|
| 27 |
+
utc_now_iso,
|
| 28 |
+
)
|
| 29 |
+
from pydantic import BaseModel
|
| 30 |
+
|
| 31 |
+
from models.schemas import (
|
| 32 |
+
ApplyColorRequest,
|
| 33 |
+
ApplyTextureAIRequest,
|
| 34 |
+
ApplyTextureRequest,
|
| 35 |
+
ExteriorBrickRequest,
|
| 36 |
+
ExteriorDepthRequest,
|
| 37 |
+
ExteriorGrabCutRequest,
|
| 38 |
+
ExteriorHybridRequest,
|
| 39 |
+
ExteriorSuggestRequest,
|
| 40 |
+
GuidedSegmentRequest,
|
| 41 |
+
SceneAnalyzeRequest,
|
| 42 |
+
SegmentAdaptiveRequest,
|
| 43 |
+
SegmentVideoRequest,
|
| 44 |
+
)
|
| 45 |
+
from services.image_service import (
|
| 46 |
+
prepare_and_store_upload,
|
| 47 |
+
run_upload_job,
|
| 48 |
+
save_label_map_for_owner,
|
| 49 |
+
)
|
| 50 |
+
from services.inpainting_service import run_inpainting_job, run_inpainting_sync
|
| 51 |
+
from services.sam2_service import jobs, jobs_lock, release_resources
|
| 52 |
+
from services.openai_service import generate_image_with_openai
|
| 53 |
+
from PIL import Image
|
| 54 |
+
from services.scene_service import (
|
| 55 |
+
build_adaptive_plan,
|
| 56 |
+
generate_label_map,
|
| 57 |
+
infer_scene_type,
|
| 58 |
+
normalize_priority,
|
| 59 |
+
normalize_scene_hint,
|
| 60 |
+
rank_exterior_candidates,
|
| 61 |
+
rank_interior_candidates,
|
| 62 |
+
)
|
| 63 |
+
from services.segmentation_service import (
|
| 64 |
+
generate_guided_label_map,
|
| 65 |
+
parse_mask_index,
|
| 66 |
+
parse_rgb_color,
|
| 67 |
+
segment_exterior_brick_sync,
|
| 68 |
+
segment_exterior_depth_sync,
|
| 69 |
+
segment_exterior_grabcut_sync,
|
| 70 |
+
segment_exterior_hybrid_sync,
|
| 71 |
+
segment_video_sync,
|
| 72 |
+
)
|
| 73 |
+
from services.texture_service import (
|
| 74 |
+
apply_local_texture_sync,
|
| 75 |
+
build_texture_preview_jpeg,
|
| 76 |
+
generate_texture_variations,
|
| 77 |
+
list_available_textures,
|
| 78 |
+
resolve_texture_path,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
import cv2
|
| 82 |
+
|
| 83 |
+
router = APIRouter(prefix="/seg")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("/", response_class=HTMLResponse)
|
| 87 |
+
async def home() -> HTMLResponse:
|
| 88 |
+
dashboard_html = load_classic_dashboard_html().replace(
|
| 89 |
+
"__FRONTEND_DEBUG_ENABLED__",
|
| 90 |
+
"true" if FRONTEND_DEBUG else "false",
|
| 91 |
+
)
|
| 92 |
+
return HTMLResponse(content=dashboard_html)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.post("/upload_video")
|
| 96 |
+
async def upload_video(file: UploadFile = File(...)) -> dict[str, Any]:
|
| 97 |
+
if not file.content_type or not file.content_type.startswith("video/"):
|
| 98 |
+
raise HTTPException(status_code=400, detail="Only video files are allowed")
|
| 99 |
+
|
| 100 |
+
safe_name = Path(file.filename or "uploaded_video").name
|
| 101 |
+
if not safe_name:
|
| 102 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 103 |
+
|
| 104 |
+
destination = VIDEO_UPLOAD_DIR / safe_name
|
| 105 |
+
content = await file.read()
|
| 106 |
+
if not content:
|
| 107 |
+
raise HTTPException(status_code=400, detail="Uploaded video is empty")
|
| 108 |
+
|
| 109 |
+
destination.write_bytes(content)
|
| 110 |
+
return {
|
| 111 |
+
"message": "Video uploaded successfully",
|
| 112 |
+
"filename": safe_name,
|
| 113 |
+
"url": f"/seg/video/{safe_name}",
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@router.post("/upload_async")
|
| 118 |
+
async def upload_image_async(
|
| 119 |
+
background_tasks: BackgroundTasks,
|
| 120 |
+
file: UploadFile = File(...),
|
| 121 |
+
) -> dict[str, Any]:
|
| 122 |
+
if not file.content_type or not file.content_type.startswith("image/"):
|
| 123 |
+
raise HTTPException(status_code=400, detail="Only image files are allowed")
|
| 124 |
+
|
| 125 |
+
content = await file.read()
|
| 126 |
+
job_id = uuid.uuid4().hex
|
| 127 |
+
with jobs_lock:
|
| 128 |
+
jobs[job_id] = {
|
| 129 |
+
"kind": "upload",
|
| 130 |
+
"status": "processing",
|
| 131 |
+
"stage": "queued",
|
| 132 |
+
"progress": 2,
|
| 133 |
+
"message": "Queued for segmentation",
|
| 134 |
+
"created_at": utc_now_iso(),
|
| 135 |
+
"updated_at": utc_now_iso(),
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
background_tasks.add_task(run_upload_job, job_id, content, file.filename or "uploaded_image")
|
| 139 |
+
return {
|
| 140 |
+
"processing": True,
|
| 141 |
+
"job_id": job_id,
|
| 142 |
+
"status": "processing",
|
| 143 |
+
"stage": "queued",
|
| 144 |
+
"progress": 2,
|
| 145 |
+
"message": "Upload accepted. Segmentation started in background.",
|
| 146 |
+
"status_url": f"/seg/jobs/{job_id}",
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.post("/segment_guided")
|
| 151 |
+
async def segment_guided(payload: GuidedSegmentRequest) -> dict[str, Any]:
|
| 152 |
+
started = log_timing_start("SEGMENT_GUIDED")
|
| 153 |
+
try:
|
| 154 |
+
from services.image_service import load_image_rgb_for_edit
|
| 155 |
+
safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
|
| 156 |
+
label_map, ranked_scores = await asyncio.to_thread(
|
| 157 |
+
generate_guided_label_map,
|
| 158 |
+
image_rgb,
|
| 159 |
+
[list(point) for point in payload.point_coords],
|
| 160 |
+
list(payload.point_labels),
|
| 161 |
+
list(payload.box_xyxy) if payload.box_xyxy is not None else [],
|
| 162 |
+
payload.multimask_output,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
guided_owner = f"{Path(safe_name).stem}_guided.jpg"
|
| 166 |
+
label_owner = await asyncio.to_thread(save_label_map_for_owner, guided_owner, label_map)
|
| 167 |
+
available_indices = list(range(1, len(ranked_scores) + 1))
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
"message": "Guided segmentation completed",
|
| 171 |
+
"filename": safe_name,
|
| 172 |
+
"original_filename_for_apply": label_owner,
|
| 173 |
+
"mask_count": len(ranked_scores),
|
| 174 |
+
"available_mask_indices": available_indices,
|
| 175 |
+
"recommended_mask_index": 1,
|
| 176 |
+
"scores": [round(score, 6) for score in ranked_scores],
|
| 177 |
+
}
|
| 178 |
+
finally:
|
| 179 |
+
log_timing_end("SEGMENT_GUIDED", started)
|
| 180 |
+
try:
|
| 181 |
+
release_resources()
|
| 182 |
+
except Exception:
|
| 183 |
+
logger.exception("Error releasing resources after SEGMENT_GUIDED")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@router.post("/suggest_exterior_masks")
|
| 187 |
+
async def suggest_exterior_masks(payload: ExteriorSuggestRequest) -> dict[str, Any]:
|
| 188 |
+
started = log_timing_start("EXTERIOR_SUGGEST")
|
| 189 |
+
try:
|
| 190 |
+
from services.image_service import load_image_rgb_for_edit
|
| 191 |
+
safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
|
| 192 |
+
|
| 193 |
+
label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 194 |
+
|
| 195 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 196 |
+
label_path = masks_dir / f"{label_owner_name}_labels.png"
|
| 197 |
+
if not label_path.exists():
|
| 198 |
+
label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
|
| 199 |
+
label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
|
| 200 |
+
label_path = masks_dir / f"{label_owner_name}_labels.png"
|
| 201 |
+
|
| 202 |
+
label_map_arr = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 203 |
+
if label_map_arr is None:
|
| 204 |
+
raise HTTPException(status_code=404, detail="Label map not found")
|
| 205 |
+
|
| 206 |
+
candidates = rank_exterior_candidates(
|
| 207 |
+
label_map_arr,
|
| 208 |
+
payload.top_k,
|
| 209 |
+
target=payload.target,
|
| 210 |
+
min_area_ratio=payload.min_area_ratio,
|
| 211 |
+
max_area_ratio=payload.max_area_ratio,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
return {
|
| 215 |
+
"message": "Exterior mask suggestions generated",
|
| 216 |
+
"filename": safe_name,
|
| 217 |
+
"original_filename_for_apply": label_owner_name,
|
| 218 |
+
"suggestions": candidates,
|
| 219 |
+
"target": payload.target,
|
| 220 |
+
}
|
| 221 |
+
finally:
|
| 222 |
+
log_timing_end("EXTERIOR_SUGGEST", started)
|
| 223 |
+
try:
|
| 224 |
+
release_resources()
|
| 225 |
+
except Exception:
|
| 226 |
+
logger.exception("Error releasing resources after EXTERIOR_SUGGEST")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@router.post("/analyze_scene")
|
| 230 |
+
async def analyze_scene(payload: SceneAnalyzeRequest) -> dict[str, Any]:
|
| 231 |
+
started = log_timing_start("ANALYZE_SCENE")
|
| 232 |
+
try:
|
| 233 |
+
from services.image_service import load_image_rgb_for_edit
|
| 234 |
+
safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
|
| 235 |
+
|
| 236 |
+
label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 237 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 238 |
+
label_path = masks_dir / f"{label_owner_name}_labels.png"
|
| 239 |
+
|
| 240 |
+
if not label_path.exists():
|
| 241 |
+
label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
|
| 242 |
+
label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
|
| 243 |
+
|
| 244 |
+
scene_info = await asyncio.to_thread(
|
| 245 |
+
infer_scene_type,
|
| 246 |
+
image_rgb,
|
| 247 |
+
payload.semantic_keywords,
|
| 248 |
+
payload.exterior_target,
|
| 249 |
+
payload.min_area_ratio,
|
| 250 |
+
payload.max_area_ratio,
|
| 251 |
+
)
|
| 252 |
+
scene_type = scene_info["scene_type"]
|
| 253 |
+
scene_hint = normalize_scene_hint(payload.scene_hint)
|
| 254 |
+
effective_scene = scene_hint if scene_hint != "auto" else scene_type
|
| 255 |
+
|
| 256 |
+
adaptive_plan = build_adaptive_plan(effective_scene, payload.priority, payload.exterior_target)
|
| 257 |
+
|
| 258 |
+
label_map_arr = cv2.imread(str(masks_dir / f"{label_owner_name}_labels.png"), cv2.IMREAD_GRAYSCALE)
|
| 259 |
+
suggestions: list[dict[str, Any]] = []
|
| 260 |
+
if label_map_arr is not None:
|
| 261 |
+
if effective_scene == "exterior":
|
| 262 |
+
suggestions = rank_exterior_candidates(
|
| 263 |
+
label_map_arr, payload.top_k,
|
| 264 |
+
target=payload.exterior_target,
|
| 265 |
+
min_area_ratio=payload.min_area_ratio,
|
| 266 |
+
max_area_ratio=payload.max_area_ratio,
|
| 267 |
+
)
|
| 268 |
+
else:
|
| 269 |
+
suggestions = rank_interior_candidates(label_map_arr, payload.top_k)
|
| 270 |
+
|
| 271 |
+
return {
|
| 272 |
+
"message": "Scene analysis completed",
|
| 273 |
+
"filename": safe_name,
|
| 274 |
+
"original_filename_for_apply": label_owner_name,
|
| 275 |
+
"scene_type": scene_type,
|
| 276 |
+
"effective_scene": effective_scene,
|
| 277 |
+
"confidence": scene_info["confidence"],
|
| 278 |
+
"signals": scene_info["signals"],
|
| 279 |
+
"adaptive_plan": adaptive_plan,
|
| 280 |
+
"suggestions": suggestions,
|
| 281 |
+
"priority": normalize_priority(payload.priority),
|
| 282 |
+
}
|
| 283 |
+
finally:
|
| 284 |
+
log_timing_end("ANALYZE_SCENE", started)
|
| 285 |
+
try:
|
| 286 |
+
release_resources()
|
| 287 |
+
except Exception:
|
| 288 |
+
logger.exception("Error releasing resources after ANALYZE_SCENE")
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
@router.post("/segment_adaptive")
|
| 292 |
+
async def segment_adaptive(payload: SegmentAdaptiveRequest) -> dict[str, Any]:
|
| 293 |
+
started = log_timing_start("SEGMENT_ADAPTIVE")
|
| 294 |
+
try:
|
| 295 |
+
from services.image_service import load_image_rgb_for_edit
|
| 296 |
+
safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
|
| 297 |
+
|
| 298 |
+
scene_info = await asyncio.to_thread(
|
| 299 |
+
infer_scene_type,
|
| 300 |
+
image_rgb,
|
| 301 |
+
payload.semantic_keywords,
|
| 302 |
+
payload.exterior_target,
|
| 303 |
+
)
|
| 304 |
+
scene_hint = normalize_scene_hint(payload.scene_hint)
|
| 305 |
+
effective_scene = scene_hint if scene_hint != "auto" else scene_info["scene_type"]
|
| 306 |
+
priority = normalize_priority(payload.priority)
|
| 307 |
+
adaptive_plan = build_adaptive_plan(effective_scene, priority, payload.exterior_target)
|
| 308 |
+
|
| 309 |
+
label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 310 |
+
|
| 311 |
+
if effective_scene == "exterior":
|
| 312 |
+
from services.segmentation_service import segment_exterior_depth_sync as seg_depth
|
| 313 |
+
from models.schemas import ExteriorDepthRequest as DepthReq
|
| 314 |
+
|
| 315 |
+
depth_payload = DepthReq(
|
| 316 |
+
filename=payload.filename,
|
| 317 |
+
exterior_target=payload.exterior_target,
|
| 318 |
+
rect_xywh=payload.rect_xywh,
|
| 319 |
+
smooth_strength=1,
|
| 320 |
+
sam2_merge_top_k=12,
|
| 321 |
+
iterations=6,
|
| 322 |
+
use_semantic_hint=True,
|
| 323 |
+
use_depth_hint=True,
|
| 324 |
+
semantic_keywords=payload.semantic_keywords,
|
| 325 |
+
)
|
| 326 |
+
result = await asyncio.to_thread(seg_depth, depth_payload)
|
| 327 |
+
else:
|
| 328 |
+
label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
|
| 329 |
+
label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
|
| 330 |
+
top_k = 4 if priority == "speed" else (10 if priority == "quality" else 6)
|
| 331 |
+
candidates = rank_interior_candidates(label_map, top_k)
|
| 332 |
+
result = {
|
| 333 |
+
"message": "Interior adaptive segmentation completed",
|
| 334 |
+
"filename": safe_name,
|
| 335 |
+
"original_filename_for_apply": label_owner_name,
|
| 336 |
+
"scene_type": effective_scene,
|
| 337 |
+
"suggestions": candidates,
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
result["adaptive_plan"] = adaptive_plan
|
| 341 |
+
result["detected_scene_type"] = scene_info["scene_type"]
|
| 342 |
+
result["effective_scene"] = effective_scene
|
| 343 |
+
result["scene_confidence"] = scene_info["confidence"]
|
| 344 |
+
return result
|
| 345 |
+
finally:
|
| 346 |
+
log_timing_end("SEGMENT_ADAPTIVE", started)
|
| 347 |
+
try:
|
| 348 |
+
release_resources()
|
| 349 |
+
except Exception:
|
| 350 |
+
logger.exception("Error releasing resources after SEGMENT_ADAPTIVE")
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@router.post("/segment_video")
|
| 354 |
+
async def segment_video(payload: SegmentVideoRequest) -> dict[str, Any]:
|
| 355 |
+
try:
|
| 356 |
+
return await asyncio.to_thread(segment_video_sync, payload)
|
| 357 |
+
except HTTPException:
|
| 358 |
+
raise
|
| 359 |
+
except Exception as exc:
|
| 360 |
+
raise HTTPException(status_code=500, detail=f"Video segmentation failed: {exc}") from exc
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
@router.post("/segment_exterior_grabcut")
|
| 364 |
+
async def segment_exterior_grabcut(payload: ExteriorGrabCutRequest) -> dict[str, Any]:
|
| 365 |
+
try:
|
| 366 |
+
return await asyncio.to_thread(segment_exterior_grabcut_sync, payload)
|
| 367 |
+
except HTTPException:
|
| 368 |
+
raise
|
| 369 |
+
except Exception as exc:
|
| 370 |
+
raise HTTPException(status_code=500, detail=f"GrabCut segmentation failed: {exc}") from exc
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
@router.post("/segment_exterior_hybrid")
|
| 374 |
+
async def segment_exterior_hybrid(payload: ExteriorHybridRequest) -> dict[str, Any]:
|
| 375 |
+
try:
|
| 376 |
+
return await asyncio.to_thread(segment_exterior_hybrid_sync, payload)
|
| 377 |
+
except HTTPException:
|
| 378 |
+
raise
|
| 379 |
+
except Exception as exc:
|
| 380 |
+
raise HTTPException(status_code=500, detail=f"Hybrid exterior segmentation failed: {exc}") from exc
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
@router.post("/segment_exterior_brick")
|
| 384 |
+
async def segment_exterior_brick(payload: ExteriorBrickRequest) -> dict[str, Any]:
|
| 385 |
+
try:
|
| 386 |
+
return await asyncio.to_thread(segment_exterior_brick_sync, payload)
|
| 387 |
+
except HTTPException:
|
| 388 |
+
raise
|
| 389 |
+
except Exception as exc:
|
| 390 |
+
raise HTTPException(status_code=500, detail=f"Brick segmentation failed: {exc}") from exc
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
@router.post("/segment_exterior_depth")
|
| 394 |
+
async def segment_exterior_depth(payload: ExteriorDepthRequest) -> dict[str, Any]:
|
| 395 |
+
try:
|
| 396 |
+
return await asyncio.to_thread(segment_exterior_depth_sync, payload)
|
| 397 |
+
except HTTPException:
|
| 398 |
+
raise
|
| 399 |
+
except Exception as exc:
|
| 400 |
+
raise HTTPException(status_code=500, detail=f"Depth exterior segmentation failed: {exc}") from exc
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@router.post("/apply_texture_ai")
|
| 404 |
+
async def apply_texture_ai(
|
| 405 |
+
payload: ApplyTextureAIRequest,
|
| 406 |
+
background_tasks: BackgroundTasks,
|
| 407 |
+
) -> dict[str, Any]:
|
| 408 |
+
started = log_timing_start("APPLY_TEXTURE_AI")
|
| 409 |
+
try:
|
| 410 |
+
# Try to run OpenAI generation synchronously within the quick timeout
|
| 411 |
+
def _run_openai():
|
| 412 |
+
safe_name = Path(payload.filename).name
|
| 413 |
+
image_path = UPLOAD_DIR / safe_name
|
| 414 |
+
if not image_path.exists():
|
| 415 |
+
image_path = OUTPUT_DIR / safe_name
|
| 416 |
+
|
| 417 |
+
if not image_path.exists():
|
| 418 |
+
return {"error": f"Image not found: {payload.filename}"}
|
| 419 |
+
|
| 420 |
+
try:
|
| 421 |
+
pil = Image.open(str(image_path)).convert("RGBA")
|
| 422 |
+
except Exception as e:
|
| 423 |
+
return {"error": f"Cannot open image: {e}"}
|
| 424 |
+
|
| 425 |
+
texture = payload.texture_name or payload.prompt or ""
|
| 426 |
+
png_bytes, msg = generate_image_with_openai(None, pil, texture)
|
| 427 |
+
if png_bytes is None:
|
| 428 |
+
return {"error": msg}
|
| 429 |
+
|
| 430 |
+
out_name = f"{Path(safe_name).stem}_ai_{uuid.uuid4().hex}.png"
|
| 431 |
+
out_path = OUTPUT_DIR / out_name
|
| 432 |
+
out_path.write_bytes(png_bytes)
|
| 433 |
+
return {"message": msg, "filename": out_name, "url": f"/seg/ai/{out_name}", "processing": False}
|
| 434 |
+
|
| 435 |
+
result = await asyncio.wait_for(asyncio.to_thread(_run_openai), timeout=SD_QUICK_TIMEOUT_SECONDS)
|
| 436 |
+
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 437 |
+
try:
|
| 438 |
+
release_resources()
|
| 439 |
+
except Exception:
|
| 440 |
+
logger.exception("Error releasing resources after APPLY_TEXTURE_AI")
|
| 441 |
+
result["processing"] = False
|
| 442 |
+
return result
|
| 443 |
+
except asyncio.TimeoutError:
|
| 444 |
+
job_id = uuid.uuid4().hex
|
| 445 |
+
with jobs_lock:
|
| 446 |
+
jobs[job_id] = {"status": "processing", "created_at": utc_now_iso(), "updated_at": utc_now_iso()}
|
| 447 |
+
# enqueue background job that runs the OpenAI generation
|
| 448 |
+
def _run_openai_job(job_id_inner: str, payload_inner: ApplyTextureAIRequest) -> None:
|
| 449 |
+
try:
|
| 450 |
+
res = _run_openai()
|
| 451 |
+
with jobs_lock:
|
| 452 |
+
if "error" in res:
|
| 453 |
+
jobs[job_id_inner] = {"status": "failed", "error": res.get("error"), "updated_at": utc_now_iso()}
|
| 454 |
+
else:
|
| 455 |
+
jobs[job_id_inner] = {"status": "done", "result": res, "updated_at": utc_now_iso()}
|
| 456 |
+
except Exception as exc:
|
| 457 |
+
with jobs_lock:
|
| 458 |
+
jobs[job_id_inner] = {"status": "failed", "error": str(exc), "updated_at": utc_now_iso()}
|
| 459 |
+
|
| 460 |
+
background_tasks.add_task(_run_openai_job, job_id, payload)
|
| 461 |
+
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 462 |
+
try:
|
| 463 |
+
release_resources()
|
| 464 |
+
except Exception:
|
| 465 |
+
pass
|
| 466 |
+
return {
|
| 467 |
+
"processing": True,
|
| 468 |
+
"job_id": job_id,
|
| 469 |
+
"message": "Inpainting is taking longer than expected and continues in background.",
|
| 470 |
+
"status_url": f"/seg/jobs/{job_id}",
|
| 471 |
+
}
|
| 472 |
+
except HTTPException:
|
| 473 |
+
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 474 |
+
try:
|
| 475 |
+
release_resources()
|
| 476 |
+
except Exception:
|
| 477 |
+
pass
|
| 478 |
+
raise
|
| 479 |
+
except Exception as exc:
|
| 480 |
+
log_timing_end("APPLY_TEXTURE_AI", started)
|
| 481 |
+
try:
|
| 482 |
+
release_resources()
|
| 483 |
+
except Exception:
|
| 484 |
+
pass
|
| 485 |
+
raise HTTPException(status_code=500, detail=f"Inpainting failed: {exc}") from exc
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
@router.get("/jobs/{job_id}")
|
| 489 |
+
async def get_job_status(job_id: str) -> dict[str, Any]:
|
| 490 |
+
with jobs_lock:
|
| 491 |
+
job = jobs.get(job_id)
|
| 492 |
+
|
| 493 |
+
if job is None:
|
| 494 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 495 |
+
|
| 496 |
+
if job.get("status") == "processing":
|
| 497 |
+
kind = str(job.get("kind", "generic"))
|
| 498 |
+
stage = str(job.get("stage", "processing"))
|
| 499 |
+
progress = int(job.get("progress", 0) or 0)
|
| 500 |
+
eta_seconds: int | None = None
|
| 501 |
+
|
| 502 |
+
if kind == "upload" and stage == "segmenting_with_sam2":
|
| 503 |
+
stage_started_at_text = job.get("stage_started_at")
|
| 504 |
+
estimated_seconds = float(job.get("estimated_seconds", 0.0) or 0.0)
|
| 505 |
+
if stage_started_at_text and estimated_seconds > 0:
|
| 506 |
+
try:
|
| 507 |
+
stage_started_at = datetime.fromisoformat(str(stage_started_at_text))
|
| 508 |
+
elapsed = (datetime.now(timezone.utc) - stage_started_at).total_seconds()
|
| 509 |
+
eta_seconds = max(0, int(estimated_seconds - elapsed))
|
| 510 |
+
estimated_progress = int(min(95, 30 + (max(0.0, elapsed) / estimated_seconds) * 60))
|
| 511 |
+
progress = max(progress, estimated_progress)
|
| 512 |
+
except ValueError:
|
| 513 |
+
pass
|
| 514 |
+
|
| 515 |
+
stale_limit_seconds = UPLOAD_JOB_STALE_SECONDS if kind == "upload" else SD_JOB_STALE_SECONDS
|
| 516 |
+
created_at_text = job.get("created_at")
|
| 517 |
+
if created_at_text:
|
| 518 |
+
try:
|
| 519 |
+
created_at = datetime.fromisoformat(str(created_at_text))
|
| 520 |
+
age_seconds = (datetime.now(timezone.utc) - created_at).total_seconds()
|
| 521 |
+
if age_seconds > stale_limit_seconds:
|
| 522 |
+
return {
|
| 523 |
+
"processing": False,
|
| 524 |
+
"status": "timeout",
|
| 525 |
+
"message": "The process is taking too long. Please retry.",
|
| 526 |
+
"job_id": job_id,
|
| 527 |
+
}
|
| 528 |
+
except ValueError:
|
| 529 |
+
pass
|
| 530 |
+
|
| 531 |
+
response: dict[str, Any] = {
|
| 532 |
+
"processing": True,
|
| 533 |
+
"status": "processing",
|
| 534 |
+
"job_id": job_id,
|
| 535 |
+
"kind": kind,
|
| 536 |
+
"stage": stage,
|
| 537 |
+
"progress": progress,
|
| 538 |
+
"message": str(job.get("message", "Still processing.")),
|
| 539 |
+
}
|
| 540 |
+
if eta_seconds is not None:
|
| 541 |
+
response["eta_seconds"] = eta_seconds
|
| 542 |
+
return response
|
| 543 |
+
|
| 544 |
+
if job.get("status") == "done":
|
| 545 |
+
result = cast(dict[str, Any], job.get("result", {}))
|
| 546 |
+
result["processing"] = False
|
| 547 |
+
result["job_id"] = job_id
|
| 548 |
+
result["status"] = "done"
|
| 549 |
+
return result
|
| 550 |
+
|
| 551 |
+
if job.get("status") == "failed":
|
| 552 |
+
return {
|
| 553 |
+
"processing": False,
|
| 554 |
+
"status": "failed",
|
| 555 |
+
"job_id": job_id,
|
| 556 |
+
"message": job.get("error", "Background task failed"),
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
return {"processing": True, "status": "processing", "job_id": job_id, "message": "Still processing."}
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
@router.post("/apply_color")
|
| 563 |
+
async def apply_color(payload: ApplyColorRequest) -> dict[str, Any]:
|
| 564 |
+
started = log_timing_start("APPLY_COLOR")
|
| 565 |
+
try:
|
| 566 |
+
safe_name = Path(payload.filename).name
|
| 567 |
+
if not safe_name:
|
| 568 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 569 |
+
|
| 570 |
+
label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 571 |
+
|
| 572 |
+
image_path = UPLOAD_DIR / safe_name
|
| 573 |
+
if not image_path.exists():
|
| 574 |
+
image_path = OUTPUT_DIR / safe_name
|
| 575 |
+
if not image_path.exists() or not image_path.is_file():
|
| 576 |
+
raise HTTPException(status_code=404, detail=f"Image not found: {safe_name}")
|
| 577 |
+
|
| 578 |
+
image_bgr = cv2.imread(str(image_path))
|
| 579 |
+
if image_bgr is None:
|
| 580 |
+
raise HTTPException(status_code=400, detail="Image could not be read")
|
| 581 |
+
|
| 582 |
+
mask_index = parse_mask_index(payload.mask_filename)
|
| 583 |
+
red, green, blue = parse_rgb_color(payload.color)
|
| 584 |
+
|
| 585 |
+
label_path = UPLOAD_DIR / "masks" / f"{label_safe_name}_labels.png"
|
| 586 |
+
label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 587 |
+
if label_map is None:
|
| 588 |
+
raise HTTPException(
|
| 589 |
+
status_code=404,
|
| 590 |
+
detail="Label map not found. Upload the image first to generate segments.",
|
| 591 |
+
)
|
| 592 |
+
|
| 593 |
+
segmentation = label_map == mask_index
|
| 594 |
+
if not segmentation.any():
|
| 595 |
+
raise HTTPException(status_code=400, detail=f"Segment index {mask_index} not found in label map.")
|
| 596 |
+
|
| 597 |
+
edited_image = image_bgr.copy()
|
| 598 |
+
edited_image[segmentation] = (blue, green, red)
|
| 599 |
+
|
| 600 |
+
original_stem = Path(label_safe_name).stem
|
| 601 |
+
out_filename = f"{original_stem}_edit.jpg"
|
| 602 |
+
out_path = UPLOAD_DIR / out_filename
|
| 603 |
+
if not cv2.imwrite(str(out_path), edited_image):
|
| 604 |
+
raise HTTPException(status_code=500, detail="Failed to save edited image")
|
| 605 |
+
|
| 606 |
+
return {
|
| 607 |
+
"message": "Color applied successfully",
|
| 608 |
+
"output_filename": out_filename,
|
| 609 |
+
"output_url": f"/seg/image/{out_filename}",
|
| 610 |
+
}
|
| 611 |
+
finally:
|
| 612 |
+
log_timing_end("APPLY_COLOR", started)
|
| 613 |
+
try:
|
| 614 |
+
release_resources()
|
| 615 |
+
except Exception:
|
| 616 |
+
logger.exception("Error releasing resources after APPLY_COLOR")
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
@router.post("/apply_texture")
|
| 620 |
+
async def apply_texture(payload: ApplyTextureRequest) -> dict[str, Any]:
|
| 621 |
+
try:
|
| 622 |
+
result = await asyncio.to_thread(apply_local_texture_sync, payload)
|
| 623 |
+
result["processing"] = False
|
| 624 |
+
return result
|
| 625 |
+
except HTTPException:
|
| 626 |
+
raise
|
| 627 |
+
except Exception as exc:
|
| 628 |
+
raise HTTPException(status_code=500, detail=f"Texture apply failed: {exc}") from exc
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
@router.get("/textures")
|
| 632 |
+
async def get_textures() -> dict[str, Any]:
|
| 633 |
+
return {"textures": list_available_textures()}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
class _GenerateVariationsRequest(BaseModel):
|
| 637 |
+
texture_name: str
|
| 638 |
+
|
| 639 |
+
class Config:
|
| 640 |
+
extra = "ignore"
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
@router.post("/textures/generate")
|
| 644 |
+
async def generate_variations(payload: _GenerateVariationsRequest) -> dict[str, Any]:
|
| 645 |
+
if not payload.texture_name:
|
| 646 |
+
raise HTTPException(status_code=400, detail="texture_name is required")
|
| 647 |
+
try:
|
| 648 |
+
variations = await asyncio.to_thread(generate_texture_variations, payload.texture_name)
|
| 649 |
+
return {"variations": variations}
|
| 650 |
+
except HTTPException:
|
| 651 |
+
raise
|
| 652 |
+
except Exception as exc:
|
| 653 |
+
raise HTTPException(status_code=500, detail=f"Variation generation failed: {exc}") from exc
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
@router.get("/texture-preview/{filename:path}")
|
| 657 |
+
async def get_texture_preview(filename: str) -> Response:
|
| 658 |
+
texture_path = resolve_texture_path(filename)
|
| 659 |
+
jpeg = await asyncio.to_thread(build_texture_preview_jpeg, texture_path)
|
| 660 |
+
return Response(content=jpeg, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600"})
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
@router.get("/video/{filename}")
|
| 664 |
+
async def get_video(filename: str) -> FileResponse:
|
| 665 |
+
if Path(filename).name != filename:
|
| 666 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 667 |
+
video_path = VIDEO_UPLOAD_DIR / filename
|
| 668 |
+
if not video_path.exists() or not video_path.is_file():
|
| 669 |
+
raise HTTPException(status_code=404, detail="Video not found")
|
| 670 |
+
return FileResponse(video_path)
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
@router.get("/output-video/{filename}")
|
| 674 |
+
async def get_output_video(filename: str) -> FileResponse:
|
| 675 |
+
if Path(filename).name != filename:
|
| 676 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 677 |
+
video_path = VIDEO_OUTPUT_DIR / filename
|
| 678 |
+
if not video_path.exists() or not video_path.is_file():
|
| 679 |
+
raise HTTPException(status_code=404, detail="Output video not found")
|
| 680 |
+
return FileResponse(video_path)
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
@router.get("/image/{filename}")
|
| 684 |
+
async def get_image(filename: str) -> FileResponse:
|
| 685 |
+
if Path(filename).name != filename:
|
| 686 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 687 |
+
image_path = UPLOAD_DIR / filename
|
| 688 |
+
if not image_path.exists() or not image_path.is_file():
|
| 689 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 690 |
+
return FileResponse(image_path)
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
@router.post("/masks/reclassify/{filename}")
|
| 694 |
+
async def reclassify_mask_metadata(filename: str) -> dict[str, Any]:
|
| 695 |
+
"""Re-run semantic classification on an already-segmented image and overwrite its metadata JSON."""
|
| 696 |
+
import json as _json
|
| 697 |
+
safe = Path(filename).name
|
| 698 |
+
if not safe:
|
| 699 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 700 |
+
|
| 701 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 702 |
+
label_path = masks_dir / f"{safe}_labels.png"
|
| 703 |
+
if not label_path.exists():
|
| 704 |
+
raise HTTPException(status_code=404, detail="Label map not found — upload the image first")
|
| 705 |
+
|
| 706 |
+
label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 707 |
+
if label_map is None:
|
| 708 |
+
raise HTTPException(status_code=500, detail="Could not read label map")
|
| 709 |
+
|
| 710 |
+
image_path = UPLOAD_DIR / safe
|
| 711 |
+
image_rgb: Any = None
|
| 712 |
+
if image_path.exists():
|
| 713 |
+
img_bgr = cv2.imread(str(image_path))
|
| 714 |
+
if img_bgr is not None:
|
| 715 |
+
image_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
| 716 |
+
|
| 717 |
+
from services.scene_service import classify_all_label_map_segments
|
| 718 |
+
h, w = label_map.shape[:2]
|
| 719 |
+
segments_meta = await asyncio.to_thread(
|
| 720 |
+
classify_all_label_map_segments, label_map, w, h, image_rgb
|
| 721 |
+
)
|
| 722 |
+
meta_path = masks_dir / f"{safe}_labels_meta.json"
|
| 723 |
+
meta_path.write_text(_json.dumps({"segments": segments_meta}, ensure_ascii=False), encoding="utf-8")
|
| 724 |
+
|
| 725 |
+
return {"segments": segments_meta, "count": len(segments_meta)}
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
@router.get("/masks/meta/{filename}")
|
| 729 |
+
async def get_mask_metadata(filename: str) -> dict:
|
| 730 |
+
import json as _json
|
| 731 |
+
safe = Path(filename).name
|
| 732 |
+
if not safe:
|
| 733 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 734 |
+
meta_path = UPLOAD_DIR / "masks" / f"{safe}_labels_meta.json"
|
| 735 |
+
if not meta_path.exists() or not meta_path.is_file():
|
| 736 |
+
raise HTTPException(status_code=404, detail="Segment metadata not found")
|
| 737 |
+
try:
|
| 738 |
+
return _json.loads(meta_path.read_text(encoding="utf-8"))
|
| 739 |
+
except Exception as exc:
|
| 740 |
+
raise HTTPException(status_code=500, detail=f"Failed to read metadata: {exc}") from exc
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
@router.get("/masks/{filename}")
|
| 744 |
+
async def get_mask_labels(filename: str) -> FileResponse:
|
| 745 |
+
if Path(filename).name != filename:
|
| 746 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 747 |
+
label_path = UPLOAD_DIR / "masks" / f"{filename}_labels.png"
|
| 748 |
+
if not label_path.exists() or not label_path.is_file():
|
| 749 |
+
raise HTTPException(status_code=404, detail="Label map not found")
|
| 750 |
+
return FileResponse(label_path)
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
@router.get("/ai/{filename}")
|
| 754 |
+
async def get_ai_image(filename: str) -> FileResponse:
|
| 755 |
+
if Path(filename).name != filename:
|
| 756 |
+
raise HTTPException(status_code=400, detail="Invalid file name")
|
| 757 |
+
out_path = OUTPUT_DIR / filename
|
| 758 |
+
if not out_path.exists() or not out_path.is_file():
|
| 759 |
+
raise HTTPException(status_code=404, detail="AI output image not found")
|
| 760 |
+
return FileResponse(out_path)
|
backend/services/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (179 Bytes). View file
|
|
|
backend/services/__pycache__/gradio_client_service.cpython-312.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/gradio_client_service.cpython-312.pyc and b/backend/services/__pycache__/gradio_client_service.cpython-312.pyc differ
|
|
|
backend/services/__pycache__/gradio_client_service.cpython-313.pyc
ADDED
|
Binary file (8.09 kB). View file
|
|
|
backend/services/__pycache__/image_service.cpython-312.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/image_service.cpython-312.pyc and b/backend/services/__pycache__/image_service.cpython-312.pyc differ
|
|
|
backend/services/__pycache__/image_service.cpython-313.pyc
ADDED
|
Binary file (9.84 kB). View file
|
|
|
backend/services/__pycache__/inpainting_service.cpython-312.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/inpainting_service.cpython-312.pyc and b/backend/services/__pycache__/inpainting_service.cpython-312.pyc differ
|
|
|
backend/services/__pycache__/inpainting_service.cpython-313.pyc
ADDED
|
Binary file (1.52 kB). View file
|
|
|
backend/services/__pycache__/openai_service.cpython-312.pyc
ADDED
|
Binary file (12.4 kB). View file
|
|
|
backend/services/__pycache__/sam2_service.cpython-312.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/sam2_service.cpython-312.pyc and b/backend/services/__pycache__/sam2_service.cpython-312.pyc differ
|
|
|
backend/services/__pycache__/sam2_service.cpython-313.pyc
ADDED
|
Binary file (14 kB). View file
|
|
|
backend/services/__pycache__/scene_service.cpython-313.pyc
ADDED
|
Binary file (31.4 kB). View file
|
|
|
backend/services/__pycache__/segmentation_service.cpython-313.pyc
ADDED
|
Binary file (58.4 kB). View file
|
|
|
backend/services/__pycache__/texture_service.cpython-312.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/texture_service.cpython-312.pyc and b/backend/services/__pycache__/texture_service.cpython-312.pyc differ
|
|
|
backend/services/__pycache__/texture_service.cpython-313.pyc
ADDED
|
Binary file (48.2 kB). View file
|
|
|
backend/services/gradio_client_service.py
CHANGED
|
@@ -1,176 +1,108 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Cliente para llamar al Gradio Space de ZeroGPU (SAM2 + SegFormer + DINO).
|
| 3 |
-
Se activa solo si GRADIO_SPACE_URL está definido en el entorno.
|
| 4 |
-
"""
|
| 5 |
-
import asyncio
|
| 6 |
-
import base64
|
| 7 |
-
import io
|
| 8 |
-
import json
|
| 9 |
-
import logging
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
import numpy as np
|
| 13 |
-
from PIL import Image
|
| 14 |
-
|
| 15 |
-
from core.config import GRADIO_CPU_FALLBACK_URL, GRADIO_SPACE_URL
|
| 16 |
-
|
| 17 |
-
logger = logging.getLogger(__name__)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def is_gradio_enabled() -> bool:
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
#
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
)
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
if
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
"""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
logger.
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
label_map_b64: str,
|
| 110 |
-
mask_index: int,
|
| 111 |
-
texture_name: str | None,
|
| 112 |
-
texture_b64: str | None,
|
| 113 |
-
params_json: str,
|
| 114 |
-
space_url: str,
|
| 115 |
-
) -> tuple[np.ndarray, dict]:
|
| 116 |
-
"""
|
| 117 |
-
Synchronous call to the Gradio Space render endpoint.
|
| 118 |
-
Returns (rendered_image_np, combined_dict)
|
| 119 |
-
"""
|
| 120 |
-
from gradio_client import Client, file # type: ignore
|
| 121 |
-
|
| 122 |
-
client = Client(space_url, httpx_kwargs={"timeout": 300.0})
|
| 123 |
-
|
| 124 |
-
# Prepare args: image file + other strings/values
|
| 125 |
-
inputs = [file(str(image_path)), label_map_b64 or "", int(mask_index or 1), texture_name or "", texture_b64 or "", params_json or "{}"]
|
| 126 |
-
|
| 127 |
-
rendered_img, combined_json_str = client.predict(*inputs, api_name="/render")
|
| 128 |
-
|
| 129 |
-
if not isinstance(combined_json_str, str):
|
| 130 |
-
raise ValueError(f"Unexpected response type from Gradio Space render: {type(combined_json_str)}")
|
| 131 |
-
|
| 132 |
-
combined = json.loads(combined_json_str)
|
| 133 |
-
if "error" in combined:
|
| 134 |
-
raise RuntimeError(f"Gradio Space render error: {combined['error'][:500]}")
|
| 135 |
-
|
| 136 |
-
return rendered_img, combined
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def render_via_gradio_sync(
|
| 140 |
-
image_path: Path,
|
| 141 |
-
label_map_b64: str,
|
| 142 |
-
mask_index: int = 1,
|
| 143 |
-
texture_name: str | None = None,
|
| 144 |
-
texture_b64: str | None = None,
|
| 145 |
-
params_json: str = "{}",
|
| 146 |
-
) -> tuple[np.ndarray, dict]:
|
| 147 |
-
if not is_gradio_enabled():
|
| 148 |
-
raise RuntimeError("GRADIO_SPACE_URL is not configured")
|
| 149 |
-
|
| 150 |
-
gpu_error: Exception | None = None
|
| 151 |
-
try:
|
| 152 |
-
return _call_gradio_render_sync(image_path, label_map_b64, mask_index, texture_name, texture_b64, params_json, GRADIO_SPACE_URL)
|
| 153 |
-
except Exception as e:
|
| 154 |
-
gpu_error = e
|
| 155 |
-
logger.warning("GPU Space render failed (%s), trying CPU fallback...", gpu_error)
|
| 156 |
-
|
| 157 |
-
if not GRADIO_CPU_FALLBACK_URL:
|
| 158 |
-
raise RuntimeError(f"GPU Gradio Space render failed and no CPU fallback configured. Error: {gpu_error}")
|
| 159 |
-
|
| 160 |
-
try:
|
| 161 |
-
return _call_gradio_render_sync(image_path, label_map_b64, mask_index, texture_name, texture_b64, params_json, GRADIO_CPU_FALLBACK_URL)
|
| 162 |
-
except Exception as exc_cpu:
|
| 163 |
-
raise RuntimeError(
|
| 164 |
-
f"Both Gradio Spaces render failed.\n GPU ({GRADIO_SPACE_URL}): {gpu_error}\n CPU ({GRADIO_CPU_FALLBACK_URL}): {exc_cpu}"
|
| 165 |
-
) from exc_cpu
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
async def render_via_gradio(
|
| 169 |
-
image_path: Path,
|
| 170 |
-
label_map_b64: str,
|
| 171 |
-
mask_index: int = 1,
|
| 172 |
-
texture_name: str | None = None,
|
| 173 |
-
texture_b64: str | None = None,
|
| 174 |
-
params_json: str = "{}",
|
| 175 |
-
) -> tuple[np.ndarray, dict]:
|
| 176 |
-
return await asyncio.to_thread(render_via_gradio_sync, image_path, label_map_b64, mask_index, texture_name, texture_b64, params_json)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cliente para llamar al Gradio Space de ZeroGPU (SAM2 + SegFormer + DINO).
|
| 3 |
+
Se activa solo si GRADIO_SPACE_URL está definido en el entorno.
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import base64
|
| 7 |
+
import io
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
from core.config import GRADIO_CPU_FALLBACK_URL, GRADIO_SPACE_URL
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def is_gradio_enabled() -> bool:
|
| 21 |
+
# Desactivado por petición del usuario para usar el nuevo flujo simplificado con OpenAI
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _call_gradio_sync(image_path: Path, space_url: str) -> tuple[np.ndarray, int]:
|
| 27 |
+
"""
|
| 28 |
+
Synchronous Gradio call — safe to invoke from a background thread.
|
| 29 |
+
Returns (label_map, mask_count).
|
| 30 |
+
Raises on any error so the caller can handle fallback.
|
| 31 |
+
"""
|
| 32 |
+
from gradio_client import Client, file # type: ignore
|
| 33 |
+
|
| 34 |
+
# 300s timeout: ZeroGPU cold start + SAM2+DINO inference can take 60-120s
|
| 35 |
+
# client = Client(space_url, httpx_kwargs={"timeout": 300.0})
|
| 36 |
+
client = Client(space_url) # httpx_kwargs no es compatible con todas las versiones
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# segment_for_backend returns (overlay_image, combined_json_str)
|
| 40 |
+
_overlay_file, combined_json_str = client.predict(
|
| 41 |
+
file(str(image_path)),
|
| 42 |
+
api_name="/segment",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if not isinstance(combined_json_str, str):
|
| 46 |
+
raise ValueError(f"Unexpected response type from Gradio Space: {type(combined_json_str)}")
|
| 47 |
+
|
| 48 |
+
combined: dict = json.loads(combined_json_str)
|
| 49 |
+
|
| 50 |
+
if "error" in combined:
|
| 51 |
+
raise RuntimeError(f"Gradio Space error: {combined['error'][:500]}")
|
| 52 |
+
|
| 53 |
+
label_map_b64: str = combined.get("label_map_b64", "")
|
| 54 |
+
if not label_map_b64:
|
| 55 |
+
return np.zeros((1, 1), dtype=np.uint8), 0
|
| 56 |
+
|
| 57 |
+
# Decode PNG-encoded label map (lossless uint8 grayscale)
|
| 58 |
+
label_map_bytes = base64.b64decode(label_map_b64)
|
| 59 |
+
pil_label = Image.open(io.BytesIO(label_map_bytes))
|
| 60 |
+
label_map = np.array(pil_label, dtype=np.uint8)
|
| 61 |
+
mask_count = int(label_map.max())
|
| 62 |
+
|
| 63 |
+
entorno = combined.get("entorno", "?")
|
| 64 |
+
motor = combined.get("motor", "?")
|
| 65 |
+
logger.info(
|
| 66 |
+
"Gradio Space segmentation: entorno=%s motor=%s mask_count=%d",
|
| 67 |
+
entorno, motor, mask_count,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
return label_map, mask_count
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def segment_via_gradio_sync(image_path: Path) -> tuple[np.ndarray, int]:
|
| 74 |
+
"""
|
| 75 |
+
Blocking call to the Gradio Space from a sync context (background task thread).
|
| 76 |
+
Tries the GPU Space first; if it fails, falls back to the CPU Space.
|
| 77 |
+
Raises RuntimeError if both fail or neither is configured.
|
| 78 |
+
"""
|
| 79 |
+
if not is_gradio_enabled():
|
| 80 |
+
raise RuntimeError("GRADIO_SPACE_URL is not configured")
|
| 81 |
+
|
| 82 |
+
gpu_error: Exception | None = None
|
| 83 |
+
try:
|
| 84 |
+
logger.info("Calling GPU Gradio Space: %s", GRADIO_SPACE_URL)
|
| 85 |
+
return _call_gradio_sync(image_path, GRADIO_SPACE_URL)
|
| 86 |
+
except Exception as e:
|
| 87 |
+
gpu_error = e
|
| 88 |
+
logger.warning("GPU Space failed (%s), trying CPU fallback...", gpu_error)
|
| 89 |
+
|
| 90 |
+
if not GRADIO_CPU_FALLBACK_URL:
|
| 91 |
+
raise RuntimeError(f"GPU Gradio Space failed and no CPU fallback configured. Error: {gpu_error}")
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
logger.info("Calling CPU fallback Space: %s", GRADIO_CPU_FALLBACK_URL)
|
| 95 |
+
return _call_gradio_sync(image_path, GRADIO_CPU_FALLBACK_URL)
|
| 96 |
+
except Exception as exc_cpu:
|
| 97 |
+
raise RuntimeError(
|
| 98 |
+
f"Both Gradio Spaces failed.\n"
|
| 99 |
+
f" GPU ({GRADIO_SPACE_URL}): {gpu_error}\n"
|
| 100 |
+
f" CPU ({GRADIO_CPU_FALLBACK_URL}): {exc_cpu}"
|
| 101 |
+
) from exc_cpu
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def segment_via_gradio(image_path: Path) -> tuple[np.ndarray, int]:
|
| 105 |
+
"""
|
| 106 |
+
Async wrapper — offloads the blocking call (with GPU→CPU fallback) to a thread.
|
| 107 |
+
"""
|
| 108 |
+
return await asyncio.to_thread(segment_via_gradio_sync, image_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/services/image_service.py
CHANGED
|
@@ -18,13 +18,13 @@ from core.config import (
|
|
| 18 |
logger,
|
| 19 |
utc_now_iso,
|
| 20 |
)
|
| 21 |
-
from services.gradio_client_service import is_gradio_enabled, segment_via_gradio_sync
|
| 22 |
from services.sam2_service import (
|
| 23 |
jobs,
|
| 24 |
jobs_lock,
|
| 25 |
release_resources,
|
| 26 |
)
|
| 27 |
|
|
|
|
| 28 |
# Imported lazily to avoid circular imports
|
| 29 |
def _get_generate_label_map():
|
| 30 |
from services.scene_service import generate_label_map
|
|
@@ -134,47 +134,15 @@ def run_upload_job(job_id: str, content: bytes, original_name: str) -> None:
|
|
| 134 |
logger.info(f"[JOB {job_id}] segmenting_with_sam2 progress=30 estimated_seconds={estimated_seconds}")
|
| 135 |
|
| 136 |
image_path = UPLOAD_DIR / safe_name
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
label_map, mask_count = generate_label_map(image_rgb)
|
| 142 |
-
|
| 143 |
-
with jobs_lock:
|
| 144 |
-
job = jobs.setdefault(job_id, {})
|
| 145 |
-
job.update({
|
| 146 |
-
"stage": "saving_masks",
|
| 147 |
-
"progress": 92,
|
| 148 |
-
"message": "Saving mask map",
|
| 149 |
-
"updated_at": utc_now_iso(),
|
| 150 |
-
})
|
| 151 |
-
logger.info(f"[JOB {job_id}] saving_masks progress=92")
|
| 152 |
-
|
| 153 |
-
masks_dir = UPLOAD_DIR / "masks"
|
| 154 |
-
masks_dir.mkdir(exist_ok=True)
|
| 155 |
-
label_path = masks_dir / f"{safe_name}_labels.png"
|
| 156 |
-
if not cv2.imwrite(str(label_path), label_map):
|
| 157 |
-
raise HTTPException(status_code=500, detail="Failed to save label map")
|
| 158 |
-
|
| 159 |
-
# Classify each segment and save metadata
|
| 160 |
-
try:
|
| 161 |
-
from services.scene_service import classify_all_label_map_segments
|
| 162 |
-
h, w = image_rgb.shape[:2]
|
| 163 |
-
segments_meta = classify_all_label_map_segments(label_map, w, h, image_rgb)
|
| 164 |
-
meta_path = masks_dir / f"{safe_name}_labels_meta.json"
|
| 165 |
-
meta_path.write_text(
|
| 166 |
-
json.dumps({"segments": segments_meta}, ensure_ascii=False),
|
| 167 |
-
encoding="utf-8",
|
| 168 |
-
)
|
| 169 |
-
logger.info(f"[JOB {job_id}] segments_meta saved ({len(segments_meta)} segments)")
|
| 170 |
-
except Exception:
|
| 171 |
-
logger.exception(f"[JOB {job_id}] Failed to save segment metadata")
|
| 172 |
-
|
| 173 |
result: dict[str, Any] = {
|
| 174 |
"message": "Image uploaded successfully",
|
| 175 |
"filename": safe_name,
|
| 176 |
"url": f"/seg/image/{safe_name}",
|
| 177 |
-
"mask_count":
|
| 178 |
}
|
| 179 |
|
| 180 |
with jobs_lock:
|
|
@@ -183,11 +151,15 @@ def run_upload_job(job_id: str, content: bytes, original_name: str) -> None:
|
|
| 183 |
"status": "done",
|
| 184 |
"stage": "done",
|
| 185 |
"progress": 100,
|
| 186 |
-
"message": "
|
| 187 |
"result": result,
|
| 188 |
"updated_at": utc_now_iso(),
|
| 189 |
}
|
| 190 |
-
logger.info(f"[JOB {job_id}] done
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
except Exception as exc:
|
| 193 |
logger.exception(f"[JOB {job_id}] failed: {exc}")
|
|
|
|
| 18 |
logger,
|
| 19 |
utc_now_iso,
|
| 20 |
)
|
|
|
|
| 21 |
from services.sam2_service import (
|
| 22 |
jobs,
|
| 23 |
jobs_lock,
|
| 24 |
release_resources,
|
| 25 |
)
|
| 26 |
|
| 27 |
+
|
| 28 |
# Imported lazily to avoid circular imports
|
| 29 |
def _get_generate_label_map():
|
| 30 |
from services.scene_service import generate_label_map
|
|
|
|
| 134 |
logger.info(f"[JOB {job_id}] segmenting_with_sam2 progress=30 estimated_seconds={estimated_seconds}")
|
| 135 |
|
| 136 |
image_path = UPLOAD_DIR / safe_name
|
| 137 |
+
|
| 138 |
+
# We are simplifying the flow: skipping SAM 2 segmentation.
|
| 139 |
+
# The result will have 0 masks.
|
| 140 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
result: dict[str, Any] = {
|
| 142 |
"message": "Image uploaded successfully",
|
| 143 |
"filename": safe_name,
|
| 144 |
"url": f"/seg/image/{safe_name}",
|
| 145 |
+
"mask_count": 0,
|
| 146 |
}
|
| 147 |
|
| 148 |
with jobs_lock:
|
|
|
|
| 151 |
"status": "done",
|
| 152 |
"stage": "done",
|
| 153 |
"progress": 100,
|
| 154 |
+
"message": "Upload complete (Simplified flow)",
|
| 155 |
"result": result,
|
| 156 |
"updated_at": utc_now_iso(),
|
| 157 |
}
|
| 158 |
+
logger.info(f"[JOB {job_id}] done (simplified, 0 masks)")
|
| 159 |
+
|
| 160 |
+
# Segmentation-related files (masks, metadata) are skipped in the simplified flow.
|
| 161 |
+
pass
|
| 162 |
+
|
| 163 |
|
| 164 |
except Exception as exc:
|
| 165 |
logger.exception(f"[JOB {job_id}] failed: {exc}")
|
backend/services/inpainting_service.py
CHANGED
|
@@ -1,28 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from typing import Any
|
|
|
|
| 2 |
|
| 3 |
-
from core.config import utc_now_iso
|
|
|
|
| 4 |
from models.schemas import ApplyTextureAIRequest
|
| 5 |
from services.sam2_service import jobs, jobs_lock
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
def run_inpainting_sync(payload: ApplyTextureAIRequest) -> dict[str, Any]:
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
def run_inpainting_job(job_id: str, payload: ApplyTextureAIRequest) -> None:
|
| 18 |
try:
|
| 19 |
result = run_inpainting_sync(payload)
|
| 20 |
with jobs_lock:
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
except Exception as exc:
|
| 27 |
with jobs_lock:
|
| 28 |
jobs[job_id] = {
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
import base64
|
| 4 |
+
import openai
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from PIL import Image, ImageOps
|
| 7 |
from typing import Any
|
| 8 |
+
import numpy as np
|
| 9 |
|
| 10 |
+
from core.config import UPLOAD_DIR, OUTPUT_DIR, logger, utc_now_iso
|
| 11 |
+
from services.openai_service import _get_texture_hex
|
| 12 |
from models.schemas import ApplyTextureAIRequest
|
| 13 |
from services.sam2_service import jobs, jobs_lock
|
| 14 |
|
| 15 |
+
# ─── Descripciones en inglés para el prompt de DALL-E ─────────────────────────
|
| 16 |
+
TEXTURE_DESCRIPTIONS = {
|
| 17 |
+
"ACM_Amarillo": "bright yellow smooth aluminum composite panel exterior cladding",
|
| 18 |
+
"ACM_Azul": "blue aluminum composite panel exterior cladding",
|
| 19 |
+
"ACM_Glossy_Black": "glossy black aluminum composite panel exterior cladding",
|
| 20 |
+
"ACM_Glossy_Red": "glossy red aluminum composite panel exterior cladding",
|
| 21 |
+
"ACM_Grafito": "graphite dark grey aluminum composite panel exterior cladding",
|
| 22 |
+
"ACM_Light_Blue": "light sky blue aluminum composite panel exterior cladding",
|
| 23 |
+
"ACM_Madera_Clara": "light wood grain aluminum composite panel exterior cladding",
|
| 24 |
+
"ACM_Matteblack": "matte black aluminum composite panel exterior cladding",
|
| 25 |
+
"ACM_Metalic": "metallic silver brushed aluminum composite panel exterior cladding",
|
| 26 |
+
"ACM_MouseGrey": "mouse grey aluminum composite panel exterior cladding",
|
| 27 |
+
"ACM_Orange": "orange aluminum composite panel exterior cladding",
|
| 28 |
+
"ACM_ROBLE(OAK)": "oak wood grain aluminum composite panel exterior cladding",
|
| 29 |
+
"ACM_Verde": "green aluminum composite panel exterior cladding",
|
| 30 |
+
"ACM_Verde_HN": "dark forest green aluminum composite panel exterior cladding",
|
| 31 |
+
"ACM_Verde_Lima": "lime yellow-green aluminum composite panel exterior cladding",
|
| 32 |
+
"ACM_White": "white aluminum composite panel exterior cladding",
|
| 33 |
+
"DECK_gris": "grey WPC wood-plastic composite deck boards",
|
| 34 |
+
"DECK_madera": "natural wood-look WPC composite deck boards",
|
| 35 |
+
"DECK_madera_oscuro": "dark brown WPC composite deck boards",
|
| 36 |
+
"WPC_madera_claro": "light beige wood-grain WPC exterior wall cladding",
|
| 37 |
+
"WPC_madera_gris": "grey weathered wood-look WPC exterior wall cladding",
|
| 38 |
+
"WPC_madera_oscuro": "dark espresso wood-grain WPC exterior wall cladding",
|
| 39 |
+
"WPC_negro": "black WPC exterior wall cladding",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
def prepare_image_square(pil_img, size=1024):
|
| 43 |
+
"""
|
| 44 |
+
Ajusta la imagen a un cuadrado de 1024x1024 añadiendo padding
|
| 45 |
+
para no deformar la imagen original.
|
| 46 |
+
"""
|
| 47 |
+
orig_w, orig_h = pil_img.size
|
| 48 |
+
ratio = orig_w / orig_h
|
| 49 |
+
|
| 50 |
+
if ratio > 1: # Es más ancha que alta (Horizontal)
|
| 51 |
+
new_w = size
|
| 52 |
+
new_h = int(size / ratio)
|
| 53 |
+
padding = (0, (size - new_h) // 2)
|
| 54 |
+
else: # Es más alta que ancha (Vertical)
|
| 55 |
+
new_h = size
|
| 56 |
+
new_w = int(size * ratio)
|
| 57 |
+
padding = ((size - new_w) // 2, 0)
|
| 58 |
+
|
| 59 |
+
# Redimensionar manteniendo proporción
|
| 60 |
+
resized_img = pil_img.resize((new_w, new_h), Image.LANCZOS)
|
| 61 |
+
|
| 62 |
+
# Crear fondo cuadrado con transparencia
|
| 63 |
+
new_img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
| 64 |
+
new_img.paste(resized_img, padding)
|
| 65 |
+
|
| 66 |
+
return new_img, padding, (new_w, new_h)
|
| 67 |
|
| 68 |
def run_inpainting_sync(payload: ApplyTextureAIRequest) -> dict[str, Any]:
|
| 69 |
+
"""
|
| 70 |
+
Usa DALL-E 2 para editar la imagen de la casa reemplazando las texturas.
|
| 71 |
+
Inspirado en la lógica de 'imagneConaI/app.py'.
|
| 72 |
+
"""
|
| 73 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 74 |
+
if not api_key:
|
| 75 |
+
return {"error": "OpenAI API Key not found in environment"}
|
| 76 |
+
|
| 77 |
+
safe_name = Path(payload.filename).name
|
| 78 |
+
image_path = UPLOAD_DIR / safe_name
|
| 79 |
+
if not image_path.exists():
|
| 80 |
+
image_path = OUTPUT_DIR / safe_name
|
| 81 |
+
|
| 82 |
+
if not image_path.exists():
|
| 83 |
+
return {"error": f"Image not found: {payload.filename}"}
|
| 84 |
+
|
| 85 |
+
# Determinar descripción de textura
|
| 86 |
+
texture_name = payload.texture_name or "ACM_White"
|
| 87 |
+
texture_stem = Path(texture_name).stem
|
| 88 |
+
texture_desc = TEXTURE_DESCRIPTIONS.get(texture_stem, texture_name)
|
| 89 |
+
|
| 90 |
+
# Specs técnicas para el prompt
|
| 91 |
+
acm_specs = ""
|
| 92 |
+
if texture_stem.startswith("ACM"):
|
| 93 |
+
acm_specs = (
|
| 94 |
+
"ACM aluminum composite panel 4mm thick, 0.40mm aluminum layers, "
|
| 95 |
+
"panels sized 1.22m x 2.44m, clean precision-cut joints between panels, "
|
| 96 |
+
)
|
| 97 |
+
elif "WPC" in texture_stem or "DECK" in texture_stem:
|
| 98 |
+
acm_specs = "WPC wood-plastic composite profile boards, "
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
client = openai.OpenAI(api_key=api_key)
|
| 102 |
+
|
| 103 |
+
pil_img_orig = Image.open(str(image_path)).convert("RGBA")
|
| 104 |
+
orig_size = pil_img_orig.size
|
| 105 |
+
|
| 106 |
+
square_img, padding, resized_dims = prepare_image_square(pil_img_orig)
|
| 107 |
+
|
| 108 |
+
buf = io.BytesIO()
|
| 109 |
+
square_img.save(buf, format="PNG")
|
| 110 |
+
buf.seek(0)
|
| 111 |
+
|
| 112 |
+
# Usar el prompt enviado o generar uno por defecto
|
| 113 |
+
prompt = payload.prompt or (
|
| 114 |
+
f"Edit ONLY the exterior wall cladding material of this house. "
|
| 115 |
+
f"Replace all facade wall surfaces with {acm_specs}{texture_desc}. "
|
| 116 |
+
f"Keep EVERYTHING ELSE exactly the same: the house structure, shape, proportions, "
|
| 117 |
+
f"roof, windows, doors, garage, balcony railings, garden, plants, sky and lighting. "
|
| 118 |
+
f"Only change the wall surface material. "
|
| 119 |
+
f"Result must look like a photorealistic architectural photo. "
|
| 120 |
+
f"CRITICAL: Do NOT stylize, do NOT apply artistic filters, do NOT add painterly or CGI effects. "
|
| 121 |
+
f"Preserve photographic grain, camera exposure, highlights and shadows, and match original lighting and perspective. "
|
| 122 |
+
f"Avoid oversmoothing — keep realistic texture detail and crisp panel edges."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# If texture file exists, compute representative hex and request exact color match
|
| 126 |
+
tex_hex = _get_texture_hex(texture_name or "") if texture_name else None
|
| 127 |
+
if tex_hex:
|
| 128 |
+
prompt += f" Use the exact color sample {tex_hex} from the reference texture and match its hue, saturation and brightness precisely. Do not alter the color tone."
|
| 129 |
|
| 130 |
+
# gpt-image-1 solo acepta 1024x1024
|
| 131 |
+
# If a texture file exists, attach it as a reference image when possible
|
| 132 |
+
tex_path = None
|
| 133 |
+
if texture_name:
|
| 134 |
+
try:
|
| 135 |
+
from services.texture_service import resolve_texture_path as _resolve
|
| 136 |
+
tex_path = _resolve(texture_name)
|
| 137 |
+
except Exception:
|
| 138 |
+
tex_path = None
|
| 139 |
+
|
| 140 |
+
if tex_path:
|
| 141 |
+
try:
|
| 142 |
+
with open(tex_path, "rb") as tf:
|
| 143 |
+
tex_buf = io.BytesIO(tf.read())
|
| 144 |
+
tex_buf.seek(0)
|
| 145 |
+
response = client.images.edit(
|
| 146 |
+
model="gpt-image-1",
|
| 147 |
+
image=("house.png", buf, "image/png"),
|
| 148 |
+
reference_image=(Path(tex_path).name, tex_buf, "image/png"),
|
| 149 |
+
prompt=prompt,
|
| 150 |
+
n=1,
|
| 151 |
+
size="1024x1024",
|
| 152 |
+
response_format="b64_json",
|
| 153 |
+
)
|
| 154 |
+
except Exception:
|
| 155 |
+
response = client.images.edit(
|
| 156 |
+
model="gpt-image-1",
|
| 157 |
+
image=("house.png", buf, "image/png"),
|
| 158 |
+
prompt=prompt,
|
| 159 |
+
n=1,
|
| 160 |
+
size="1024x1024",
|
| 161 |
+
response_format="b64_json",
|
| 162 |
+
)
|
| 163 |
+
else:
|
| 164 |
+
response = client.images.edit(
|
| 165 |
+
model="gpt-image-1",
|
| 166 |
+
image=("house.png", buf, "image/png"),
|
| 167 |
+
prompt=prompt,
|
| 168 |
+
n=1,
|
| 169 |
+
size="1024x1024",
|
| 170 |
+
response_format="b64_json",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
img_data = base64.b64decode(response.data[0].b64_json)
|
| 174 |
+
result_square = Image.open(io.BytesIO(img_data)).convert("RGBA")
|
| 175 |
+
|
| 176 |
+
# 2. Recortar el resultado para volver al aspecto original (quitar padding)
|
| 177 |
+
left, top = padding
|
| 178 |
+
right, bottom = left + resized_dims[0], top + resized_dims[1]
|
| 179 |
+
result_cropped = result_square.crop((left, top, right, bottom))
|
| 180 |
+
|
| 181 |
+
# 3. Redimensionar al tamaño original exacto
|
| 182 |
+
result = result_cropped.resize(orig_size, Image.LANCZOS).convert("RGB")
|
| 183 |
+
|
| 184 |
+
# Return generated result without blending with original image
|
| 185 |
+
final = result
|
| 186 |
+
|
| 187 |
+
out_filename = f"{Path(safe_name).stem}_ai_{texture_stem}.jpg"
|
| 188 |
+
out_path = OUTPUT_DIR / out_filename
|
| 189 |
+
final.save(str(out_path), "JPEG", quality=90)
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
"message": f"AI Texture applied: {texture_name}",
|
| 193 |
+
"filename": out_filename,
|
| 194 |
+
"url": f"/seg/ai/{out_filename}",
|
| 195 |
+
"processing": False
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(f"DALL-E Error: {e}")
|
| 200 |
+
return {"error": str(e)}
|
| 201 |
|
| 202 |
def run_inpainting_job(job_id: str, payload: ApplyTextureAIRequest) -> None:
|
| 203 |
try:
|
| 204 |
result = run_inpainting_sync(payload)
|
| 205 |
with jobs_lock:
|
| 206 |
+
if "error" in result:
|
| 207 |
+
jobs[job_id] = {
|
| 208 |
+
"status": "failed",
|
| 209 |
+
"error": result["error"],
|
| 210 |
+
"updated_at": utc_now_iso(),
|
| 211 |
+
}
|
| 212 |
+
else:
|
| 213 |
+
jobs[job_id] = {
|
| 214 |
+
"status": "done",
|
| 215 |
+
"result": result,
|
| 216 |
+
"updated_at": utc_now_iso(),
|
| 217 |
+
}
|
| 218 |
except Exception as exc:
|
| 219 |
with jobs_lock:
|
| 220 |
jobs[job_id] = {
|
backend/services/openai_service.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import os
|
| 3 |
+
import base64
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Tuple
|
| 6 |
+
|
| 7 |
+
import openai
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
from core.config import TEXTURE_DIR
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
TEXTURE_DESCRIPTIONS = {
|
| 14 |
+
# Copiado desde imagneConaI/app.py (reducido a los usados más adelante)
|
| 15 |
+
"ACM_Amarillo": "bright yellow smooth aluminum composite panel exterior cladding",
|
| 16 |
+
"ACM_Azul": "blue aluminum composite panel exterior cladding",
|
| 17 |
+
"ACM_White": "white aluminum composite panel exterior cladding",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _resolve_texture_path(name_or_path: str) -> Tuple[str | None, str]:
|
| 22 |
+
if not name_or_path:
|
| 23 |
+
return None, ""
|
| 24 |
+
p = Path(name_or_path)
|
| 25 |
+
if p.exists():
|
| 26 |
+
return str(p), p.stem
|
| 27 |
+
# buscar en TEXTURE_DIR
|
| 28 |
+
for folder in TEXTURE_DIR.iterdir():
|
| 29 |
+
if not folder.is_dir():
|
| 30 |
+
continue
|
| 31 |
+
for it in folder.glob("*.png"):
|
| 32 |
+
if it.stem.lower() == name_or_path.lower() or it.name.lower() == name_or_path.lower():
|
| 33 |
+
return str(it), it.stem.replace("_", " ")
|
| 34 |
+
return None, name_or_path
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _get_texture_hex(name_or_path: str) -> str | None:
|
| 38 |
+
"""Return average color hex for a texture file if available."""
|
| 39 |
+
path, _ = _resolve_texture_path(name_or_path)
|
| 40 |
+
if not path:
|
| 41 |
+
return None
|
| 42 |
+
try:
|
| 43 |
+
img = Image.open(path).convert("RGB")
|
| 44 |
+
# downsize for speed
|
| 45 |
+
img = img.resize((64, 64))
|
| 46 |
+
pixels = list(img.getdata())
|
| 47 |
+
r = sum(p[0] for p in pixels) // len(pixels)
|
| 48 |
+
g = sum(p[1] for p in pixels) // len(pixels)
|
| 49 |
+
b = sum(p[2] for p in pixels) // len(pixels)
|
| 50 |
+
return f"#{r:02X}{g:02X}{b:02X}"
|
| 51 |
+
except Exception:
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def prepare_image_square(pil_img: Image.Image, size: int = 1024):
|
| 56 |
+
orig_w, orig_h = pil_img.size
|
| 57 |
+
ratio = orig_w / orig_h
|
| 58 |
+
if ratio > 1:
|
| 59 |
+
new_w = size
|
| 60 |
+
new_h = int(size / ratio)
|
| 61 |
+
padding = (0, (size - new_h) // 2)
|
| 62 |
+
else:
|
| 63 |
+
new_h = size
|
| 64 |
+
new_w = int(size * ratio)
|
| 65 |
+
padding = ((size - new_w) // 2, 0)
|
| 66 |
+
resized_img = pil_img.resize((new_w, new_h), Image.LANCZOS)
|
| 67 |
+
new_img = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
| 68 |
+
new_img.paste(resized_img, padding)
|
| 69 |
+
return new_img, padding, (new_w, new_h)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def generate_image_with_openai(
|
| 73 |
+
api_key: str | None,
|
| 74 |
+
pil_img: Image.Image,
|
| 75 |
+
texture: str,
|
| 76 |
+
preserve_percent: int = 0,
|
| 77 |
+
use_vision: bool = False,
|
| 78 |
+
) -> Tuple[bytes | None, str]:
|
| 79 |
+
"""
|
| 80 |
+
Genera/edita una imagen usando la API de OpenAI (gpt-image-1 edit).
|
| 81 |
+
Retorna (png_bytes, message) o (None, error_message).
|
| 82 |
+
"""
|
| 83 |
+
key = api_key.strip() if api_key and isinstance(api_key, str) and api_key.strip() else os.getenv("OPENAI_API_KEY")
|
| 84 |
+
if not key:
|
| 85 |
+
return None, "OpenAI API key not provided"
|
| 86 |
+
|
| 87 |
+
texture_path, texture_display = _resolve_texture_path(texture)
|
| 88 |
+
if not texture_path:
|
| 89 |
+
# intentar usar descripción simple
|
| 90 |
+
texture_desc = TEXTURE_DESCRIPTIONS.get(texture, texture)
|
| 91 |
+
else:
|
| 92 |
+
texture_desc = Path(texture_path).stem
|
| 93 |
+
|
| 94 |
+
# Build specialized specs for ACM / WPC / DECK
|
| 95 |
+
acm_specs = ""
|
| 96 |
+
if texture_display and texture_display.upper().startswith("ACM"):
|
| 97 |
+
acm_specs = (
|
| 98 |
+
"ACM aluminum composite panel 4mm thick, 0.40mm aluminum layers, "
|
| 99 |
+
"panels sized 1.22m x 2.44m, clean precision-cut joints between panels, "
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
deck_specs = ""
|
| 103 |
+
if Path(texture).stem.startswith("DECK") or ("DECK" in Path(texture).stem):
|
| 104 |
+
deck_specs = (
|
| 105 |
+
"WPC deck boards (ash / \"ceniza\" color), plank dimensions 2.5 cm thick x 14 cm wide x 220 cm long, "
|
| 106 |
+
"installed as horizontal planks with 3-5 mm spacing and hidden fasteners, realistic wood grain, slight bevel on plank edges."
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
wpc_panel_specs = ""
|
| 110 |
+
if ("WPC" in Path(texture).stem) and ("DECK" not in Path(texture).stem):
|
| 111 |
+
wpc_panel_specs = (
|
| 112 |
+
"WPC wall panels, each panel sized 0.16 m x 2.90 m (16 cm x 290 cm), realistic ash tone when appropriate, "
|
| 113 |
+
"installed with narrow vertical joints matching panel width and clean aligned seams."
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# If we have a texture file, attempt to compute its representative color and ask model to match it
|
| 117 |
+
tex_hex = _get_texture_hex(texture)
|
| 118 |
+
|
| 119 |
+
# Build prompt depending on type
|
| 120 |
+
if deck_specs:
|
| 121 |
+
prompt = (
|
| 122 |
+
f"Edit ONLY the HORIZONTAL floor/deck surfaces (decks, terraces, balcony floors, wooden walkways). "
|
| 123 |
+
f"Replace all decking/floor surfaces with {deck_specs} {texture_desc}. "
|
| 124 |
+
f"Do NOT apply this material to vertical wall cladding or facades. "
|
| 125 |
+
f"Keep EVERYTHING ELSE exactly the same: the house structure, shape, proportions, roof, windows, doors, garage, balcony railings, garden, plants, sky and lighting. "
|
| 126 |
+
f"Result must look like a photorealistic architectural photo."
|
| 127 |
+
)
|
| 128 |
+
elif wpc_panel_specs:
|
| 129 |
+
prompt = (
|
| 130 |
+
f"Edit ONLY the VERTICAL wall cladding surfaces (exterior and interior wall panels where visible). "
|
| 131 |
+
f"Replace all vertical wall panels with {wpc_panel_specs} {texture_desc}. "
|
| 132 |
+
f"Do NOT apply this material to horizontal floor or deck surfaces. "
|
| 133 |
+
f"Keep EVERYTHING ELSE exactly the same: the house structure, shape, proportions, roof, windows, doors, garage, balcony railings, garden, plants, sky and lighting. "
|
| 134 |
+
f"Result must look like a photorealistic architectural photo."
|
| 135 |
+
)
|
| 136 |
+
else:
|
| 137 |
+
prompt = (
|
| 138 |
+
f"Edit ONLY the exterior wall cladding material of this house. "
|
| 139 |
+
f"Replace all facade wall surfaces with {acm_specs}{texture_desc}. "
|
| 140 |
+
f"Keep EVERYTHING ELSE exactly the same: the house structure, shape, proportions, "
|
| 141 |
+
f"roof, windows, doors, garage, balcony railings, garden, plants, sky and lighting. "
|
| 142 |
+
f"Only change the wall surface material. Result must look like a photorealistic architectural photo."
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
if tex_hex:
|
| 146 |
+
prompt += f" Use the exact color sample {tex_hex} from the reference texture and match its hue, saturation and brightness precisely. Do not alter the color tone."
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
client = openai.OpenAI(api_key=key)
|
| 150 |
+
|
| 151 |
+
pil_img_orig = pil_img.convert("RGBA")
|
| 152 |
+
orig_size = pil_img_orig.size
|
| 153 |
+
square_img, padding, resized_dims = prepare_image_square(pil_img_orig)
|
| 154 |
+
|
| 155 |
+
buf = io.BytesIO()
|
| 156 |
+
square_img.save(buf, format="PNG")
|
| 157 |
+
buf.seek(0)
|
| 158 |
+
|
| 159 |
+
# Optionally run vision analysis to enrich prompt
|
| 160 |
+
house_desc = ""
|
| 161 |
+
if use_vision:
|
| 162 |
+
try:
|
| 163 |
+
bb = io.BytesIO()
|
| 164 |
+
pil_img_orig.convert("RGB").save(bb, format="JPEG", quality=85)
|
| 165 |
+
bb.seek(0)
|
| 166 |
+
b64 = base64.b64encode(bb.getvalue()).decode("ascii")
|
| 167 |
+
|
| 168 |
+
vision_prompt = (
|
| 169 |
+
"You are an architectural photographer. Describe exactly what is visible in this house photo: "
|
| 170 |
+
"number of floors, roof shape, main materials, prominent features (garage, balconies, large windows), "
|
| 171 |
+
"lighting conditions, and anything that affects how to apply a facade material. "
|
| 172 |
+
"Return a concise English description (max 200 words)."
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
vision_resp = client.chat.completions.create(
|
| 176 |
+
model="gpt-4o-mini",
|
| 177 |
+
messages=[{"role": "user", "content": f"data:image/jpeg;base64,{b64}\n\n{vision_prompt}"}],
|
| 178 |
+
max_tokens=300,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
try:
|
| 182 |
+
house_desc = vision_resp.choices[0].message.content.strip()
|
| 183 |
+
except Exception:
|
| 184 |
+
house_desc = str(vision_resp)
|
| 185 |
+
except Exception:
|
| 186 |
+
house_desc = ""
|
| 187 |
+
|
| 188 |
+
if house_desc:
|
| 189 |
+
prompt = f"House description: {house_desc}. " + prompt
|
| 190 |
+
|
| 191 |
+
# If a texture file exists, try to attach it as a reference image to the API call.
|
| 192 |
+
tex_path, _ = _resolve_texture_path(texture)
|
| 193 |
+
try:
|
| 194 |
+
if tex_path:
|
| 195 |
+
with open(tex_path, "rb") as tf:
|
| 196 |
+
tex_buf = io.BytesIO(tf.read())
|
| 197 |
+
tex_buf.seek(0)
|
| 198 |
+
response = client.images.edit(
|
| 199 |
+
model="gpt-image-1",
|
| 200 |
+
image=("image.png", buf, "image/png"),
|
| 201 |
+
reference_image=(Path(tex_path).name, tex_buf, "image/png"),
|
| 202 |
+
prompt=prompt,
|
| 203 |
+
n=1,
|
| 204 |
+
size="1024x1024",
|
| 205 |
+
quality="medium",
|
| 206 |
+
)
|
| 207 |
+
else:
|
| 208 |
+
response = client.images.edit(
|
| 209 |
+
model="gpt-image-1",
|
| 210 |
+
image=("image.png", buf, "image/png"),
|
| 211 |
+
prompt=prompt,
|
| 212 |
+
n=1,
|
| 213 |
+
size="1024x1024",
|
| 214 |
+
quality="medium",
|
| 215 |
+
)
|
| 216 |
+
except Exception:
|
| 217 |
+
# Fallback: try without reference_image if SDK rejected the param
|
| 218 |
+
response = client.images.edit(
|
| 219 |
+
model="gpt-image-1",
|
| 220 |
+
image=("image.png", buf, "image/png"),
|
| 221 |
+
prompt=prompt,
|
| 222 |
+
n=1,
|
| 223 |
+
size="1024x1024",
|
| 224 |
+
quality="medium",
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
img_data = base64.b64decode(response.data[0].b64_json)
|
| 228 |
+
result_square = Image.open(io.BytesIO(img_data)).convert("RGBA")
|
| 229 |
+
left, top = padding
|
| 230 |
+
right, bottom = left + resized_dims[0], top + resized_dims[1]
|
| 231 |
+
result_cropped = result_square.crop((left, top, right, bottom))
|
| 232 |
+
result = result_cropped.resize(orig_size, Image.LANCZOS).convert("RGB")
|
| 233 |
+
|
| 234 |
+
# Apply optional blending/preserve percent (0-100)
|
| 235 |
+
try:
|
| 236 |
+
preserve = max(0.0, min(1.0, float(preserve_percent) / 100.0))
|
| 237 |
+
except Exception:
|
| 238 |
+
preserve = 0.0
|
| 239 |
+
|
| 240 |
+
if preserve > 0:
|
| 241 |
+
pil_img_rgb = pil_img_orig.convert("RGB")
|
| 242 |
+
final = Image.blend(result, pil_img_rgb, preserve)
|
| 243 |
+
else:
|
| 244 |
+
final = result
|
| 245 |
+
|
| 246 |
+
out_buf = io.BytesIO()
|
| 247 |
+
final.save(out_buf, format="PNG")
|
| 248 |
+
return out_buf.getvalue(), f"Generated with texture {texture_display if texture_display else texture_desc}"
|
| 249 |
+
|
| 250 |
+
except Exception as exc:
|
| 251 |
+
return None, f"Error generating image: {exc}"
|
backend/services/sam2_service.py
CHANGED
|
@@ -112,6 +112,9 @@ def find_sam2_model_path() -> Path:
|
|
| 112 |
|
| 113 |
def load_sam2_model() -> None:
|
| 114 |
global sam2_mask_generator, sam2_image_predictor, sam2_load_error
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
if not _TORCH_AVAILABLE:
|
| 117 |
sam2_load_error = "torch not installed — SAM2 unavailable (using Gradio Space)"
|
|
|
|
| 112 |
|
| 113 |
def load_sam2_model() -> None:
|
| 114 |
global sam2_mask_generator, sam2_image_predictor, sam2_load_error
|
| 115 |
+
sam2_load_error = "SAM2 disabled (using Simplified OpenAI flow)"
|
| 116 |
+
logger.info(f"[SAM2] {sam2_load_error}")
|
| 117 |
+
return
|
| 118 |
|
| 119 |
if not _TORCH_AVAILABLE:
|
| 120 |
sam2_load_error = "torch not installed — SAM2 unavailable (using Gradio Space)"
|
backend/services/texture_service.py
CHANGED
|
@@ -1,981 +1,851 @@
|
|
| 1 |
-
import io
|
| 2 |
-
import shutil
|
| 3 |
-
import uuid
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
from typing import Any
|
| 6 |
-
|
| 7 |
-
import cv2
|
| 8 |
-
import numpy as np
|
| 9 |
-
from fastapi import HTTPException
|
| 10 |
-
from PIL import Image
|
| 11 |
-
|
| 12 |
-
from core.config import (
|
| 13 |
-
OUTPUT_DIR,
|
| 14 |
-
TEXTURE_DIR,
|
| 15 |
-
UPLOAD_DIR,
|
| 16 |
-
UPLOAD_JPEG_QUALITY,
|
| 17 |
-
log_timing_end,
|
| 18 |
-
log_timing_start,
|
| 19 |
-
logger,
|
| 20 |
-
)
|
| 21 |
-
from models.schemas import ApplyTextureRequest
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def generate_texture_variations(texture_name: str) -> list[dict[str, str]]:
|
| 25 |
-
"""
|
| 26 |
-
Genera variaciones de color/brillo/saturación de una textura de referencia usando HSV.
|
| 27 |
-
Los archivos se cachean en TEXTURE_DIR/generated/ — si ya existen no se regeneran.
|
| 28 |
-
Devuelve lista de {ref, label, preview_url}.
|
| 29 |
-
"""
|
| 30 |
-
texture_path = resolve_texture_path(texture_name)
|
| 31 |
-
generated_dir = TEXTURE_DIR / "generated"
|
| 32 |
-
generated_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
-
|
| 34 |
-
tex_pil = load_texture_pil_rgb(texture_path)
|
| 35 |
-
tex_bgr = cv2.cvtColor(np.array(tex_pil, dtype=np.uint8), cv2.COLOR_RGB2BGR)
|
| 36 |
-
tex_hsv = cv2.cvtColor(tex_bgr, cv2.COLOR_BGR2HSV).astype(np.int32)
|
| 37 |
-
|
| 38 |
-
base_stem = Path(texture_name).stem
|
| 39 |
-
results: list[dict[str, str]] = []
|
| 40 |
-
|
| 41 |
-
def _save(hsv: np.ndarray, suffix: str, label: str) -> None:
|
| 42 |
-
fname = f"{base_stem}__{suffix}.jpg"
|
| 43 |
-
out_path = generated_dir / fname
|
| 44 |
-
if not out_path.exists():
|
| 45 |
-
bgr = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR)
|
| 46 |
-
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 47 |
-
Image.fromarray(rgb).save(str(out_path), format="JPEG", quality=92, optimize=True)
|
| 48 |
-
ref = f"generated/{fname}"
|
| 49 |
-
results.append({"ref": ref, "label": label, "preview_url": f"/seg/texture-preview/{ref}"})
|
| 50 |
-
|
| 51 |
-
# Rotaciones de tono: 12 pasos de 30° recorriendo el círculo cromático
|
| 52 |
-
# En OpenCV HSV, H ∈ [0,179] → shift en grados / 2
|
| 53 |
-
for deg, label in [
|
| 54 |
-
(30, "Naranja"),
|
| 55 |
-
(60, "Amarillo"),
|
| 56 |
-
(90, "Verde lima"),
|
| 57 |
-
(120, "Verde"),
|
| 58 |
-
(150, "Verde agua"),
|
| 59 |
-
(165, "Cyan"),
|
| 60 |
-
(180, "Azul cielo"),
|
| 61 |
-
(210, "Azul"),
|
| 62 |
-
(240, "Índigo"),
|
| 63 |
-
(270, "Violeta"),
|
| 64 |
-
(300, "Magenta"),
|
| 65 |
-
(330, "Rosa"),
|
| 66 |
-
]:
|
| 67 |
-
v = tex_hsv.copy()
|
| 68 |
-
v[:, :, 0] = (v[:, :, 0] + deg // 2) % 180
|
| 69 |
-
_save(v, f"hue{deg}", label)
|
| 70 |
-
|
| 71 |
-
# Variaciones de brillo
|
| 72 |
-
for factor, label, suffix in [
|
| 73 |
-
(0.45, "Oscuro", "dark"),
|
| 74 |
-
(1.55, "Claro", "light"),
|
| 75 |
-
]:
|
| 76 |
-
v = tex_hsv.copy()
|
| 77 |
-
v[:, :, 2] = np.clip(v[:, :, 2] * factor, 0, 255)
|
| 78 |
-
_save(v, suffix, label)
|
| 79 |
-
|
| 80 |
-
# Variaciones de saturación
|
| 81 |
-
for factor, label, suffix in [
|
| 82 |
-
(0.0, "Gris", "gray"),
|
| 83 |
-
(0.45, "Apagado", "muted"),
|
| 84 |
-
(1.75, "Vívido", "vivid"),
|
| 85 |
-
]:
|
| 86 |
-
v = tex_hsv.copy()
|
| 87 |
-
v[:, :, 1] = np.clip(v[:, :, 1] * factor, 0, 255)
|
| 88 |
-
_save(v, suffix, label)
|
| 89 |
-
|
| 90 |
-
return results
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def list_available_textures() -> list[str]:
|
| 94 |
-
allowed = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".exr"}
|
| 95 |
-
return [
|
| 96 |
-
str(path.relative_to(TEXTURE_DIR)).replace("\\", "/")
|
| 97 |
-
for path in sorted(TEXTURE_DIR.rglob("*"))
|
| 98 |
-
if path.is_file() and path.suffix.lower() in allowed
|
| 99 |
-
]
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def resolve_texture_path(texture_name: str) -> Path:
|
| 103 |
-
if not texture_name:
|
| 104 |
-
raise HTTPException(status_code=400, detail="Invalid texture_name")
|
| 105 |
-
|
| 106 |
-
normalized = texture_name.replace("\\", "/").strip("/")
|
| 107 |
-
candidate = (TEXTURE_DIR / normalized).resolve()
|
| 108 |
-
base = TEXTURE_DIR.resolve()
|
| 109 |
-
|
| 110 |
-
try:
|
| 111 |
-
candidate.relative_to(base)
|
| 112 |
-
except ValueError as exc:
|
| 113 |
-
raise HTTPException(status_code=400, detail="Invalid texture_name") from exc
|
| 114 |
-
|
| 115 |
-
if not candidate.exists() or not candidate.is_file():
|
| 116 |
-
raise HTTPException(status_code=404, detail=f"Texture not found: {normalized}")
|
| 117 |
-
|
| 118 |
-
return candidate
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
def build_texture_preview_jpeg(texture_path: Path, max_size: int = 320) -> bytes:
|
| 122 |
-
pil_img = load_texture_pil_rgb(texture_path)
|
| 123 |
-
pil_img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
| 124 |
-
out = io.BytesIO()
|
| 125 |
-
pil_img.save(out, format="JPEG", quality=88, optimize=True)
|
| 126 |
-
return out.getvalue()
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def load_texture_pil_rgb(texture_path: Path) -> Image.Image:
|
| 130 |
-
suffix = texture_path.suffix.lower()
|
| 131 |
-
|
| 132 |
-
if suffix != ".exr":
|
| 133 |
-
try:
|
| 134 |
-
return Image.open(str(texture_path)).convert("RGB")
|
| 135 |
-
except Exception as exc:
|
| 136 |
-
raise HTTPException(status_code=500, detail=f"Could not read texture file: {exc}") from exc
|
| 137 |
-
|
| 138 |
-
exr = cv2.imread(str(texture_path), cv2.IMREAD_UNCHANGED)
|
| 139 |
-
if exr is None:
|
| 140 |
-
raise HTTPException(status_code=500, detail="Could not decode EXR texture")
|
| 141 |
-
|
| 142 |
-
if exr.ndim == 2:
|
| 143 |
-
exr = np.stack([exr, exr, exr], axis=-1)
|
| 144 |
-
if exr.ndim != 3:
|
| 145 |
-
raise HTTPException(status_code=500, detail="EXR texture has unsupported shape")
|
| 146 |
-
if exr.shape[2] > 3:
|
| 147 |
-
exr = exr[:, :, :3]
|
| 148 |
-
|
| 149 |
-
exr = np.nan_to_num(exr, nan=0.0, posinf=0.0, neginf=0.0)
|
| 150 |
-
exr = np.maximum(exr, 0)
|
| 151 |
-
|
| 152 |
-
if np.issubdtype(exr.dtype, np.floating):
|
| 153 |
-
scale = float(np.percentile(exr, 99.0))
|
| 154 |
-
if scale <= 1e-8:
|
| 155 |
-
scale = float(np.max(exr))
|
| 156 |
-
if scale <= 1e-8:
|
| 157 |
-
scale = 1.0
|
| 158 |
-
img = np.clip(exr / scale, 0.0, 1.0)
|
| 159 |
-
img = np.power(img, 1.0 / 2.2)
|
| 160 |
-
img_u8 = (img * 255.0).astype(np.uint8)
|
| 161 |
-
elif exr.dtype == np.uint16:
|
| 162 |
-
img_u8 = (exr / 257.0).astype(np.uint8)
|
| 163 |
-
else:
|
| 164 |
-
img_u8 = np.clip(exr, 0, 255).astype(np.uint8)
|
| 165 |
-
|
| 166 |
-
img_rgb = cv2.cvtColor(img_u8, cv2.COLOR_BGR2RGB)
|
| 167 |
-
return Image.fromarray(img_rgb).convert("RGB")
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def estimate_mask_orientation_degrees(binary_mask: np.ndarray) -> float:
|
| 171 |
-
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 172 |
-
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 173 |
-
if not contours:
|
| 174 |
-
return 0.0
|
| 175 |
-
|
| 176 |
-
largest = max(contours, key=cv2.contourArea)
|
| 177 |
-
if cv2.contourArea(largest) < 25.0:
|
| 178 |
-
return 0.0
|
| 179 |
-
|
| 180 |
-
rect = cv2.minAreaRect(largest)
|
| 181 |
-
(_, _), (width, height), angle = rect
|
| 182 |
-
|
| 183 |
-
dominant_angle = float(angle)
|
| 184 |
-
if width < height:
|
| 185 |
-
dominant_angle += 90.0
|
| 186 |
-
|
| 187 |
-
dominant_angle %= 180.0
|
| 188 |
-
return dominant_angle
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def _compute_trapezoid_score_from_mask(
|
| 192 |
-
binary_mask: np.ndarray,
|
| 193 |
-
ys: np.ndarray,
|
| 194 |
-
xs: np.ndarray,
|
| 195 |
-
min_y: int,
|
| 196 |
-
max_y: int,
|
| 197 |
-
bbox_h: int,
|
| 198 |
-
) -> float:
|
| 199 |
-
"""Return 0..1 indicating how floor-like (wider at bottom) the mask shape is."""
|
| 200 |
-
quarter = max(1, bbox_h // 4)
|
| 201 |
-
top_xs = xs[ys <= (min_y + quarter)]
|
| 202 |
-
bot_xs = xs[ys >= (max_y - quarter)]
|
| 203 |
-
if len(top_xs) < 3 or len(bot_xs) < 3:
|
| 204 |
-
return 0.0
|
| 205 |
-
top_w = float(top_xs.max() - top_xs.min())
|
| 206 |
-
bot_w = float(bot_xs.max() - bot_xs.min())
|
| 207 |
-
if top_w < 5.0:
|
| 208 |
-
return 1.0 if bot_w > 20.0 else 0.0
|
| 209 |
-
ratio = bot_w / top_w
|
| 210 |
-
return float(np.clip((ratio - 1.0) / 1.8, 0.0, 1.0))
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
def _sort_quad_corners(pts: np.ndarray) -> np.ndarray:
|
| 214 |
-
"""Sort 4 points into [TL, TR, BR, BL] order."""
|
| 215 |
-
result = np.zeros((4, 2), dtype=np.float32)
|
| 216 |
-
s = pts[:, 0] + pts[:, 1]
|
| 217 |
-
d = pts[:, 0] - pts[:, 1]
|
| 218 |
-
result[0] = pts[np.argmin(s)]
|
| 219 |
-
result[1] = pts[np.argmax(d)]
|
| 220 |
-
result[2] = pts[np.argmax(s)]
|
| 221 |
-
result[3] = pts[np.argmin(d)]
|
| 222 |
-
return result
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
def _extract_mask_quad(binary_mask: np.ndarray) -> np.ndarray | None:
|
| 226 |
-
"""Approximate mask as 4-corner polygon sorted [TL, TR, BR, BL], or None."""
|
| 227 |
-
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 228 |
-
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 229 |
-
if not contours:
|
| 230 |
-
return None
|
| 231 |
-
largest = max(contours, key=cv2.contourArea)
|
| 232 |
-
if cv2.contourArea(largest) < 400.0:
|
| 233 |
-
return None
|
| 234 |
-
hull = cv2.convexHull(largest)
|
| 235 |
-
peri = cv2.arcLength(hull, True)
|
| 236 |
-
for eps_frac in (0.03, 0.05, 0.08, 0.10, 0.13):
|
| 237 |
-
approx = cv2.approxPolyDP(hull, eps_frac * peri, True)
|
| 238 |
-
if len(approx) == 4:
|
| 239 |
-
return _sort_quad_corners(approx.reshape(4, 2).astype(np.float32))
|
| 240 |
-
return None
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
def _tile_texture_perspective(
|
| 244 |
-
tex_pil: Image.Image,
|
| 245 |
-
quad: np.ndarray,
|
| 246 |
-
image_width: int,
|
| 247 |
-
image_height: int,
|
| 248 |
-
) -> Image.Image | None:
|
| 249 |
-
"""
|
| 250 |
-
Tile texture with perspective correction for a floor surface.
|
| 251 |
-
quad: [TL, TR, BR, BL] in image coordinates.
|
| 252 |
-
Returns full-image-size PIL image with warped tiled texture, or None on failure.
|
| 253 |
-
Pixels outside the perspective quad are filled with regular tiling to avoid black gaps.
|
| 254 |
-
"""
|
| 255 |
-
tl, tr, br, bl = quad
|
| 256 |
-
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
|
| 257 |
-
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
|
| 258 |
-
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
|
| 259 |
-
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
|
| 260 |
-
rect_w = min(int(max(bot_w, top_w)) + 1, image_width * 2)
|
| 261 |
-
rect_h = min(int(max(left_h, right_h)) + 1, image_height * 2)
|
| 262 |
-
if rect_w < 8 or rect_h < 8:
|
| 263 |
-
return None
|
| 264 |
-
tex_arr = np.array(tex_pil.convert("RGB"), dtype=np.uint8)
|
| 265 |
-
th, tw = tex_arr.shape[:2]
|
| 266 |
-
if tw < 1 or th < 1:
|
| 267 |
-
return None
|
| 268 |
-
rect_tiled = np.zeros((rect_h, rect_w, 3), dtype=np.uint8)
|
| 269 |
-
for ry in range(0, rect_h, th):
|
| 270 |
-
for rx in range(0, rect_w, tw):
|
| 271 |
-
py = min(th, rect_h - ry)
|
| 272 |
-
px = min(tw, rect_w - rx)
|
| 273 |
-
rect_tiled[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
|
| 274 |
-
src_pts = np.array(
|
| 275 |
-
[[0.0, 0.0], [float(rect_w - 1), 0.0], [float(rect_w - 1), float(rect_h - 1)], [0.0, float(rect_h - 1)]],
|
| 276 |
-
dtype=np.float32,
|
| 277 |
-
)
|
| 278 |
-
dst_pts = quad.astype(np.float32)
|
| 279 |
-
try:
|
| 280 |
-
H = cv2.getPerspectiveTransform(src_pts, dst_pts)
|
| 281 |
-
warped = cv2.warpPerspective(rect_tiled, H, (image_width, image_height))
|
| 282 |
-
# Mapa de cobertura: píxeles realmente cubiertos por el warp
|
| 283 |
-
cov_src = np.ones((rect_h, rect_w), dtype=np.uint8) * 255
|
| 284 |
-
coverage = cv2.warpPerspective(cov_src, H, (image_width, image_height))
|
| 285 |
-
except cv2.error:
|
| 286 |
-
return None
|
| 287 |
-
|
| 288 |
-
# Rellenar píxeles sin cobertura (fuera del quad) con tiling regular
|
| 289 |
-
# para evitar espacios negros donde la máscara supera el quad aproximado
|
| 290 |
-
regular = np.zeros((image_height, image_width, 3), dtype=np.uint8)
|
| 291 |
-
for ry in range(0, image_height, th):
|
| 292 |
-
for rx in range(0, image_width, tw):
|
| 293 |
-
py = min(th, image_height - ry)
|
| 294 |
-
px = min(tw, image_width - rx)
|
| 295 |
-
regular[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
|
| 296 |
-
|
| 297 |
-
uncovered = coverage < 128
|
| 298 |
-
warped[uncovered] = regular[uncovered]
|
| 299 |
-
|
| 300 |
-
return Image.fromarray(warped)
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
def classify_texture_material(texture_name: str) -> str:
|
| 304 |
-
texture_key = texture_name.lower()
|
| 305 |
-
if "
|
| 306 |
-
return "
|
| 307 |
-
if
|
| 308 |
-
return "
|
| 309 |
-
if any(hint in texture_key for hint in ("
|
| 310 |
-
return "
|
| 311 |
-
if any(hint in texture_key for hint in ("
|
| 312 |
-
return "
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
angle =
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
float(np.clip(
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
light_map = directional_light
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
enhanced
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
mixed_lab[:, :, 0] = (0.
|
| 551 |
-
mixed_lab[:, :, 1] = (0.
|
| 552 |
-
mixed_lab[:, :, 2] = (0.
|
| 553 |
-
elif material
|
| 554 |
-
|
| 555 |
-
mixed_lab[:, :,
|
| 556 |
-
mixed_lab[:, :,
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
mixed_lab[:, :, 0] =
|
| 560 |
-
mixed_lab[:, :, 1] = (0.
|
| 561 |
-
mixed_lab[:, :, 2] = (0.
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
image_path
|
| 601 |
-
|
| 602 |
-
image_path =
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
if
|
| 648 |
-
raise HTTPException(status_code=400, detail="
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
if tiled is None:
|
| 854 |
-
if abs(applied_angle) > 0.01:
|
| 855 |
-
# Tile on a large canvas first, then rotate the full canvas to avoid black corners
|
| 856 |
-
diag = int(np.ceil(np.sqrt(width ** 2 + height ** 2))) + max(tex_w, tex_h)
|
| 857 |
-
large_w = width + diag
|
| 858 |
-
large_h = height + diag
|
| 859 |
-
large = Image.new("RGB", (large_w, large_h))
|
| 860 |
-
for y in range(0, large_h, tex_h):
|
| 861 |
-
for x in range(0, large_w, tex_w):
|
| 862 |
-
large.paste(tex_pil, (x, y))
|
| 863 |
-
large = large.rotate(-applied_angle, resample=Image.Resampling.BICUBIC, expand=False)
|
| 864 |
-
cx = (large_w - width) // 2
|
| 865 |
-
cy = (large_h - height) // 2
|
| 866 |
-
tiled = large.crop((cx, cy, cx + width, cy + height))
|
| 867 |
-
else:
|
| 868 |
-
tiled = Image.new("RGB", (width, height))
|
| 869 |
-
for y in range(0, height, tex_h):
|
| 870 |
-
for x in range(0, width, tex_w):
|
| 871 |
-
tiled.paste(tex_pil, (x, y))
|
| 872 |
-
|
| 873 |
-
orig_u8 = np.array(orig_pil, dtype=np.uint8)
|
| 874 |
-
|
| 875 |
-
try:
|
| 876 |
-
if bool(getattr(payload, "clear_mask_before_apply", False)):
|
| 877 |
-
orig_candidate = None
|
| 878 |
-
if payload.original_filename:
|
| 879 |
-
cand = UPLOAD_DIR / Path(payload.original_filename).name
|
| 880 |
-
if cand.exists() and cand.is_file():
|
| 881 |
-
orig_candidate = cand
|
| 882 |
-
else:
|
| 883 |
-
cand2 = OUTPUT_DIR / Path(payload.original_filename).name
|
| 884 |
-
if cand2.exists() and cand2.is_file():
|
| 885 |
-
orig_candidate = cand2
|
| 886 |
-
if orig_candidate is None:
|
| 887 |
-
stem = Path(image_path).stem
|
| 888 |
-
if "_edit_" in stem:
|
| 889 |
-
base_name = stem.split("_edit_")[0] + ".jpg"
|
| 890 |
-
cand = UPLOAD_DIR / base_name
|
| 891 |
-
if cand.exists() and cand.is_file():
|
| 892 |
-
orig_candidate = cand
|
| 893 |
-
else:
|
| 894 |
-
cand2 = OUTPUT_DIR / base_name
|
| 895 |
-
if cand2.exists() and cand2.is_file():
|
| 896 |
-
orig_candidate = cand2
|
| 897 |
-
|
| 898 |
-
if orig_candidate is not None:
|
| 899 |
-
try:
|
| 900 |
-
base_pil = Image.open(str(orig_candidate)).convert("RGB")
|
| 901 |
-
if base_pil.size != (width, height):
|
| 902 |
-
base_pil = base_pil.resize((width, height), Image.Resampling.LANCZOS)
|
| 903 |
-
base_arr = np.array(base_pil, dtype=np.uint8)
|
| 904 |
-
mask_bool = (binary_mask > 0)
|
| 905 |
-
orig_u8[mask_bool] = base_arr[mask_bool]
|
| 906 |
-
logger.info(f"[APPLY_TEXTURE] cleared mask from original source: {orig_candidate}")
|
| 907 |
-
except Exception:
|
| 908 |
-
logger.exception("Failed to restore original pixels for clear_mask_before_apply")
|
| 909 |
-
except Exception:
|
| 910 |
-
logger.exception("Error handling clear_mask_before_apply")
|
| 911 |
-
|
| 912 |
-
tiled_arr = np.array(tiled, dtype=np.uint8)
|
| 913 |
-
|
| 914 |
-
# Parchar píxeles muy oscuros (suma R+G+B < 20) dentro de la máscara.
|
| 915 |
-
# Cubren tanto negro exacto (0,0,0) como píxeles casi negros que el warp
|
| 916 |
-
# de perspectiva puede generar en bordes del quad o zonas sin cobertura.
|
| 917 |
-
mask_bool = binary_mask > 0
|
| 918 |
-
dark_in_mask = mask_bool & (tiled_arr.sum(axis=2) < 20)
|
| 919 |
-
if dark_in_mask.any():
|
| 920 |
-
th_f, tw_f = tex_pil.size[1], tex_pil.size[0]
|
| 921 |
-
tex_np = np.array(tex_pil, dtype=np.uint8)
|
| 922 |
-
ys_b, xs_b = np.where(dark_in_mask)
|
| 923 |
-
tiled_arr[ys_b, xs_b] = tex_np[ys_b % th_f, xs_b % tw_f]
|
| 924 |
-
|
| 925 |
-
lit_tex = apply_surface_lighting(
|
| 926 |
-
tiled_arr,
|
| 927 |
-
orig_u8,
|
| 928 |
-
binary_mask,
|
| 929 |
-
material,
|
| 930 |
-
lighting_mode,
|
| 931 |
-
light_angle_degrees,
|
| 932 |
-
light_intensity,
|
| 933 |
-
)
|
| 934 |
-
|
| 935 |
-
orig_arr = orig_u8.astype(np.float32)
|
| 936 |
-
tex_arr = lit_tex.astype(np.float32)
|
| 937 |
-
|
| 938 |
-
if replace_mode in {"hard", "absolute", "force", "replace"}:
|
| 939 |
-
mask_bool = (binary_mask > 0).astype(bool)
|
| 940 |
-
composite_arr = orig_arr.copy()
|
| 941 |
-
composite_arr[mask_bool] = tex_arr[mask_bool]
|
| 942 |
-
composite = np.clip(composite_arr, 0, 255).astype(np.uint8)
|
| 943 |
-
else:
|
| 944 |
-
feather_mask = build_feather_mask(binary_mask)
|
| 945 |
-
# All materials use shading-preservation so scene luminance (shadows/highlights)
|
| 946 |
-
# from the original photo is transferred onto the texture.
|
| 947 |
-
composite = blend_texture_preserve_shading(orig_arr, tex_arr, feather_mask, effective_alpha, material)
|
| 948 |
-
|
| 949 |
-
input_stem = Path(image_path).stem
|
| 950 |
-
edit_suffix = uuid.uuid4().hex[:8]
|
| 951 |
-
out_filename = f"{input_stem}_edit_{edit_suffix}.jpg"
|
| 952 |
-
out_path = UPLOAD_DIR / out_filename
|
| 953 |
-
Image.fromarray(composite).save(str(out_path), format="JPEG", quality=UPLOAD_JPEG_QUALITY, optimize=True)
|
| 954 |
-
|
| 955 |
-
try:
|
| 956 |
-
out_label_path = masks_dir / f"{Path(out_filename).stem}_labels.png"
|
| 957 |
-
if label_path.exists():
|
| 958 |
-
shutil.copyfile(str(label_path), str(out_label_path))
|
| 959 |
-
except Exception:
|
| 960 |
-
logger.exception("Failed to copy label map for output image")
|
| 961 |
-
|
| 962 |
-
return {
|
| 963 |
-
"message": "Texture applied successfully",
|
| 964 |
-
"original": safe_name,
|
| 965 |
-
"mask_indices": payload.mask_indices,
|
| 966 |
-
"texture_name": payload.texture_name,
|
| 967 |
-
"material": material,
|
| 968 |
-
"direction_mode": direction_mode,
|
| 969 |
-
"surface_type": surface_type,
|
| 970 |
-
"replace_mode": replace_mode,
|
| 971 |
-
"replace_strength": round(replace_strength, 3),
|
| 972 |
-
"lighting_mode": lighting_mode,
|
| 973 |
-
"light_angle_degrees": round(light_angle_degrees, 2),
|
| 974 |
-
"light_intensity": round(light_intensity, 3),
|
| 975 |
-
"blend_alpha": round(effective_alpha, 3),
|
| 976 |
-
"applied_angle_degrees": round(applied_angle, 2),
|
| 977 |
-
"output_filename": out_filename,
|
| 978 |
-
"output_url": f"/seg/image/{out_filename}",
|
| 979 |
-
}
|
| 980 |
-
finally:
|
| 981 |
-
log_timing_end(step, started)
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import shutil
|
| 3 |
+
import uuid
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
from fastapi import HTTPException
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
from core.config import (
|
| 13 |
+
OUTPUT_DIR,
|
| 14 |
+
TEXTURE_DIR,
|
| 15 |
+
UPLOAD_DIR,
|
| 16 |
+
UPLOAD_JPEG_QUALITY,
|
| 17 |
+
log_timing_end,
|
| 18 |
+
log_timing_start,
|
| 19 |
+
logger,
|
| 20 |
+
)
|
| 21 |
+
from models.schemas import ApplyTextureRequest
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def generate_texture_variations(texture_name: str) -> list[dict[str, str]]:
|
| 25 |
+
"""
|
| 26 |
+
Genera variaciones de color/brillo/saturación de una textura de referencia usando HSV.
|
| 27 |
+
Los archivos se cachean en TEXTURE_DIR/generated/ — si ya existen no se regeneran.
|
| 28 |
+
Devuelve lista de {ref, label, preview_url}.
|
| 29 |
+
"""
|
| 30 |
+
texture_path = resolve_texture_path(texture_name)
|
| 31 |
+
generated_dir = TEXTURE_DIR / "generated"
|
| 32 |
+
generated_dir.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
tex_pil = load_texture_pil_rgb(texture_path)
|
| 35 |
+
tex_bgr = cv2.cvtColor(np.array(tex_pil, dtype=np.uint8), cv2.COLOR_RGB2BGR)
|
| 36 |
+
tex_hsv = cv2.cvtColor(tex_bgr, cv2.COLOR_BGR2HSV).astype(np.int32)
|
| 37 |
+
|
| 38 |
+
base_stem = Path(texture_name).stem
|
| 39 |
+
results: list[dict[str, str]] = []
|
| 40 |
+
|
| 41 |
+
def _save(hsv: np.ndarray, suffix: str, label: str) -> None:
|
| 42 |
+
fname = f"{base_stem}__{suffix}.jpg"
|
| 43 |
+
out_path = generated_dir / fname
|
| 44 |
+
if not out_path.exists():
|
| 45 |
+
bgr = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR)
|
| 46 |
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 47 |
+
Image.fromarray(rgb).save(str(out_path), format="JPEG", quality=92, optimize=True)
|
| 48 |
+
ref = f"generated/{fname}"
|
| 49 |
+
results.append({"ref": ref, "label": label, "preview_url": f"/seg/texture-preview/{ref}"})
|
| 50 |
+
|
| 51 |
+
# Rotaciones de tono: 12 pasos de 30° recorriendo el círculo cromático
|
| 52 |
+
# En OpenCV HSV, H ∈ [0,179] → shift en grados / 2
|
| 53 |
+
for deg, label in [
|
| 54 |
+
(30, "Naranja"),
|
| 55 |
+
(60, "Amarillo"),
|
| 56 |
+
(90, "Verde lima"),
|
| 57 |
+
(120, "Verde"),
|
| 58 |
+
(150, "Verde agua"),
|
| 59 |
+
(165, "Cyan"),
|
| 60 |
+
(180, "Azul cielo"),
|
| 61 |
+
(210, "Azul"),
|
| 62 |
+
(240, "Índigo"),
|
| 63 |
+
(270, "Violeta"),
|
| 64 |
+
(300, "Magenta"),
|
| 65 |
+
(330, "Rosa"),
|
| 66 |
+
]:
|
| 67 |
+
v = tex_hsv.copy()
|
| 68 |
+
v[:, :, 0] = (v[:, :, 0] + deg // 2) % 180
|
| 69 |
+
_save(v, f"hue{deg}", label)
|
| 70 |
+
|
| 71 |
+
# Variaciones de brillo
|
| 72 |
+
for factor, label, suffix in [
|
| 73 |
+
(0.45, "Oscuro", "dark"),
|
| 74 |
+
(1.55, "Claro", "light"),
|
| 75 |
+
]:
|
| 76 |
+
v = tex_hsv.copy()
|
| 77 |
+
v[:, :, 2] = np.clip(v[:, :, 2] * factor, 0, 255)
|
| 78 |
+
_save(v, suffix, label)
|
| 79 |
+
|
| 80 |
+
# Variaciones de saturación
|
| 81 |
+
for factor, label, suffix in [
|
| 82 |
+
(0.0, "Gris", "gray"),
|
| 83 |
+
(0.45, "Apagado", "muted"),
|
| 84 |
+
(1.75, "Vívido", "vivid"),
|
| 85 |
+
]:
|
| 86 |
+
v = tex_hsv.copy()
|
| 87 |
+
v[:, :, 1] = np.clip(v[:, :, 1] * factor, 0, 255)
|
| 88 |
+
_save(v, suffix, label)
|
| 89 |
+
|
| 90 |
+
return results
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def list_available_textures() -> list[str]:
|
| 94 |
+
allowed = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".exr"}
|
| 95 |
+
return [
|
| 96 |
+
str(path.relative_to(TEXTURE_DIR)).replace("\\", "/")
|
| 97 |
+
for path in sorted(TEXTURE_DIR.rglob("*"))
|
| 98 |
+
if path.is_file() and path.suffix.lower() in allowed
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def resolve_texture_path(texture_name: str) -> Path:
|
| 103 |
+
if not texture_name:
|
| 104 |
+
raise HTTPException(status_code=400, detail="Invalid texture_name")
|
| 105 |
+
|
| 106 |
+
normalized = texture_name.replace("\\", "/").strip("/")
|
| 107 |
+
candidate = (TEXTURE_DIR / normalized).resolve()
|
| 108 |
+
base = TEXTURE_DIR.resolve()
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
candidate.relative_to(base)
|
| 112 |
+
except ValueError as exc:
|
| 113 |
+
raise HTTPException(status_code=400, detail="Invalid texture_name") from exc
|
| 114 |
+
|
| 115 |
+
if not candidate.exists() or not candidate.is_file():
|
| 116 |
+
raise HTTPException(status_code=404, detail=f"Texture not found: {normalized}")
|
| 117 |
+
|
| 118 |
+
return candidate
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def build_texture_preview_jpeg(texture_path: Path, max_size: int = 320) -> bytes:
|
| 122 |
+
pil_img = load_texture_pil_rgb(texture_path)
|
| 123 |
+
pil_img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
| 124 |
+
out = io.BytesIO()
|
| 125 |
+
pil_img.save(out, format="JPEG", quality=88, optimize=True)
|
| 126 |
+
return out.getvalue()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def load_texture_pil_rgb(texture_path: Path) -> Image.Image:
|
| 130 |
+
suffix = texture_path.suffix.lower()
|
| 131 |
+
|
| 132 |
+
if suffix != ".exr":
|
| 133 |
+
try:
|
| 134 |
+
return Image.open(str(texture_path)).convert("RGB")
|
| 135 |
+
except Exception as exc:
|
| 136 |
+
raise HTTPException(status_code=500, detail=f"Could not read texture file: {exc}") from exc
|
| 137 |
+
|
| 138 |
+
exr = cv2.imread(str(texture_path), cv2.IMREAD_UNCHANGED)
|
| 139 |
+
if exr is None:
|
| 140 |
+
raise HTTPException(status_code=500, detail="Could not decode EXR texture")
|
| 141 |
+
|
| 142 |
+
if exr.ndim == 2:
|
| 143 |
+
exr = np.stack([exr, exr, exr], axis=-1)
|
| 144 |
+
if exr.ndim != 3:
|
| 145 |
+
raise HTTPException(status_code=500, detail="EXR texture has unsupported shape")
|
| 146 |
+
if exr.shape[2] > 3:
|
| 147 |
+
exr = exr[:, :, :3]
|
| 148 |
+
|
| 149 |
+
exr = np.nan_to_num(exr, nan=0.0, posinf=0.0, neginf=0.0)
|
| 150 |
+
exr = np.maximum(exr, 0)
|
| 151 |
+
|
| 152 |
+
if np.issubdtype(exr.dtype, np.floating):
|
| 153 |
+
scale = float(np.percentile(exr, 99.0))
|
| 154 |
+
if scale <= 1e-8:
|
| 155 |
+
scale = float(np.max(exr))
|
| 156 |
+
if scale <= 1e-8:
|
| 157 |
+
scale = 1.0
|
| 158 |
+
img = np.clip(exr / scale, 0.0, 1.0)
|
| 159 |
+
img = np.power(img, 1.0 / 2.2)
|
| 160 |
+
img_u8 = (img * 255.0).astype(np.uint8)
|
| 161 |
+
elif exr.dtype == np.uint16:
|
| 162 |
+
img_u8 = (exr / 257.0).astype(np.uint8)
|
| 163 |
+
else:
|
| 164 |
+
img_u8 = np.clip(exr, 0, 255).astype(np.uint8)
|
| 165 |
+
|
| 166 |
+
img_rgb = cv2.cvtColor(img_u8, cv2.COLOR_BGR2RGB)
|
| 167 |
+
return Image.fromarray(img_rgb).convert("RGB")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def estimate_mask_orientation_degrees(binary_mask: np.ndarray) -> float:
|
| 171 |
+
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 172 |
+
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 173 |
+
if not contours:
|
| 174 |
+
return 0.0
|
| 175 |
+
|
| 176 |
+
largest = max(contours, key=cv2.contourArea)
|
| 177 |
+
if cv2.contourArea(largest) < 25.0:
|
| 178 |
+
return 0.0
|
| 179 |
+
|
| 180 |
+
rect = cv2.minAreaRect(largest)
|
| 181 |
+
(_, _), (width, height), angle = rect
|
| 182 |
+
|
| 183 |
+
dominant_angle = float(angle)
|
| 184 |
+
if width < height:
|
| 185 |
+
dominant_angle += 90.0
|
| 186 |
+
|
| 187 |
+
dominant_angle %= 180.0
|
| 188 |
+
return dominant_angle
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _compute_trapezoid_score_from_mask(
|
| 192 |
+
binary_mask: np.ndarray,
|
| 193 |
+
ys: np.ndarray,
|
| 194 |
+
xs: np.ndarray,
|
| 195 |
+
min_y: int,
|
| 196 |
+
max_y: int,
|
| 197 |
+
bbox_h: int,
|
| 198 |
+
) -> float:
|
| 199 |
+
"""Return 0..1 indicating how floor-like (wider at bottom) the mask shape is."""
|
| 200 |
+
quarter = max(1, bbox_h // 4)
|
| 201 |
+
top_xs = xs[ys <= (min_y + quarter)]
|
| 202 |
+
bot_xs = xs[ys >= (max_y - quarter)]
|
| 203 |
+
if len(top_xs) < 3 or len(bot_xs) < 3:
|
| 204 |
+
return 0.0
|
| 205 |
+
top_w = float(top_xs.max() - top_xs.min())
|
| 206 |
+
bot_w = float(bot_xs.max() - bot_xs.min())
|
| 207 |
+
if top_w < 5.0:
|
| 208 |
+
return 1.0 if bot_w > 20.0 else 0.0
|
| 209 |
+
ratio = bot_w / top_w
|
| 210 |
+
return float(np.clip((ratio - 1.0) / 1.8, 0.0, 1.0))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _sort_quad_corners(pts: np.ndarray) -> np.ndarray:
|
| 214 |
+
"""Sort 4 points into [TL, TR, BR, BL] order."""
|
| 215 |
+
result = np.zeros((4, 2), dtype=np.float32)
|
| 216 |
+
s = pts[:, 0] + pts[:, 1]
|
| 217 |
+
d = pts[:, 0] - pts[:, 1]
|
| 218 |
+
result[0] = pts[np.argmin(s)]
|
| 219 |
+
result[1] = pts[np.argmax(d)]
|
| 220 |
+
result[2] = pts[np.argmax(s)]
|
| 221 |
+
result[3] = pts[np.argmin(d)]
|
| 222 |
+
return result
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _extract_mask_quad(binary_mask: np.ndarray) -> np.ndarray | None:
|
| 226 |
+
"""Approximate mask as 4-corner polygon sorted [TL, TR, BR, BL], or None."""
|
| 227 |
+
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 228 |
+
contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 229 |
+
if not contours:
|
| 230 |
+
return None
|
| 231 |
+
largest = max(contours, key=cv2.contourArea)
|
| 232 |
+
if cv2.contourArea(largest) < 400.0:
|
| 233 |
+
return None
|
| 234 |
+
hull = cv2.convexHull(largest)
|
| 235 |
+
peri = cv2.arcLength(hull, True)
|
| 236 |
+
for eps_frac in (0.03, 0.05, 0.08, 0.10, 0.13):
|
| 237 |
+
approx = cv2.approxPolyDP(hull, eps_frac * peri, True)
|
| 238 |
+
if len(approx) == 4:
|
| 239 |
+
return _sort_quad_corners(approx.reshape(4, 2).astype(np.float32))
|
| 240 |
+
return None
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _tile_texture_perspective(
|
| 244 |
+
tex_pil: Image.Image,
|
| 245 |
+
quad: np.ndarray,
|
| 246 |
+
image_width: int,
|
| 247 |
+
image_height: int,
|
| 248 |
+
) -> Image.Image | None:
|
| 249 |
+
"""
|
| 250 |
+
Tile texture with perspective correction for a floor surface.
|
| 251 |
+
quad: [TL, TR, BR, BL] in image coordinates.
|
| 252 |
+
Returns full-image-size PIL image with warped tiled texture, or None on failure.
|
| 253 |
+
Pixels outside the perspective quad are filled with regular tiling to avoid black gaps.
|
| 254 |
+
"""
|
| 255 |
+
tl, tr, br, bl = quad
|
| 256 |
+
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
|
| 257 |
+
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
|
| 258 |
+
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
|
| 259 |
+
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
|
| 260 |
+
rect_w = min(int(max(bot_w, top_w)) + 1, image_width * 2)
|
| 261 |
+
rect_h = min(int(max(left_h, right_h)) + 1, image_height * 2)
|
| 262 |
+
if rect_w < 8 or rect_h < 8:
|
| 263 |
+
return None
|
| 264 |
+
tex_arr = np.array(tex_pil.convert("RGB"), dtype=np.uint8)
|
| 265 |
+
th, tw = tex_arr.shape[:2]
|
| 266 |
+
if tw < 1 or th < 1:
|
| 267 |
+
return None
|
| 268 |
+
rect_tiled = np.zeros((rect_h, rect_w, 3), dtype=np.uint8)
|
| 269 |
+
for ry in range(0, rect_h, th):
|
| 270 |
+
for rx in range(0, rect_w, tw):
|
| 271 |
+
py = min(th, rect_h - ry)
|
| 272 |
+
px = min(tw, rect_w - rx)
|
| 273 |
+
rect_tiled[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
|
| 274 |
+
src_pts = np.array(
|
| 275 |
+
[[0.0, 0.0], [float(rect_w - 1), 0.0], [float(rect_w - 1), float(rect_h - 1)], [0.0, float(rect_h - 1)]],
|
| 276 |
+
dtype=np.float32,
|
| 277 |
+
)
|
| 278 |
+
dst_pts = quad.astype(np.float32)
|
| 279 |
+
try:
|
| 280 |
+
H = cv2.getPerspectiveTransform(src_pts, dst_pts)
|
| 281 |
+
warped = cv2.warpPerspective(rect_tiled, H, (image_width, image_height))
|
| 282 |
+
# Mapa de cobertura: píxeles realmente cubiertos por el warp
|
| 283 |
+
cov_src = np.ones((rect_h, rect_w), dtype=np.uint8) * 255
|
| 284 |
+
coverage = cv2.warpPerspective(cov_src, H, (image_width, image_height))
|
| 285 |
+
except cv2.error:
|
| 286 |
+
return None
|
| 287 |
+
|
| 288 |
+
# Rellenar píxeles sin cobertura (fuera del quad) con tiling regular
|
| 289 |
+
# para evitar espacios negros donde la máscara supera el quad aproximado
|
| 290 |
+
regular = np.zeros((image_height, image_width, 3), dtype=np.uint8)
|
| 291 |
+
for ry in range(0, image_height, th):
|
| 292 |
+
for rx in range(0, image_width, tw):
|
| 293 |
+
py = min(th, image_height - ry)
|
| 294 |
+
px = min(tw, image_width - rx)
|
| 295 |
+
regular[ry : ry + py, rx : rx + px] = tex_arr[:py, :px]
|
| 296 |
+
|
| 297 |
+
uncovered = coverage < 128
|
| 298 |
+
warped[uncovered] = regular[uncovered]
|
| 299 |
+
|
| 300 |
+
return Image.fromarray(warped)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def classify_texture_material(texture_name: str) -> str:
|
| 304 |
+
texture_key = texture_name.lower()
|
| 305 |
+
if "acm" in texture_key or "wpc" in texture_key:
|
| 306 |
+
return "acm"
|
| 307 |
+
if any(hint in texture_key for hint in ("deck", "wood", "plank", "laminate", "floor")):
|
| 308 |
+
return "wood"
|
| 309 |
+
if any(hint in texture_key for hint in ("marble", "granite", "tile", "brick", "cobblestone", "stone", "cartago", "riverbed")):
|
| 310 |
+
return "stone"
|
| 311 |
+
if any(hint in texture_key for hint in ("metal", "rust", "iron", "steel")):
|
| 312 |
+
return "metal"
|
| 313 |
+
return "generic"
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def infer_surface_type_and_direction(
|
| 317 |
+
binary_mask: np.ndarray,
|
| 318 |
+
image_width: int,
|
| 319 |
+
image_height: int,
|
| 320 |
+
texture_name: str,
|
| 321 |
+
) -> tuple[str, float, float, int]:
|
| 322 |
+
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 323 |
+
ys, xs = np.where(mask_u8 > 0)
|
| 324 |
+
if ys.size == 0 or xs.size == 0:
|
| 325 |
+
return ("wall", 0.0, 0.78, max(180, image_width // 4))
|
| 326 |
+
|
| 327 |
+
min_x, max_x = int(xs.min()), int(xs.max())
|
| 328 |
+
min_y, max_y = int(ys.min()), int(ys.max())
|
| 329 |
+
bbox_w = max(1, max_x - min_x + 1)
|
| 330 |
+
bbox_h = max(1, max_y - min_y + 1)
|
| 331 |
+
aspect = bbox_w / max(1.0, float(bbox_h))
|
| 332 |
+
center_y = float(ys.mean()) / max(1.0, float(image_height))
|
| 333 |
+
dominant_angle = estimate_mask_orientation_degrees(binary_mask)
|
| 334 |
+
material = classify_texture_material(texture_name)
|
| 335 |
+
|
| 336 |
+
# Trapezoid score: floor in perspective is wider at the bottom than the top
|
| 337 |
+
trapezoid_score = _compute_trapezoid_score_from_mask(binary_mask, ys, xs, min_y, max_y, bbox_h)
|
| 338 |
+
|
| 339 |
+
is_ceiling = center_y < 0.26 and aspect > 1.35
|
| 340 |
+
# Floor: low center + trapezoidal shape, OR clearly low + wide
|
| 341 |
+
is_floor = (
|
| 342 |
+
(center_y > 0.55 and aspect >= 0.9 and trapezoid_score > 0.30)
|
| 343 |
+
or (center_y > 0.68 and aspect > 1.15)
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
if is_ceiling:
|
| 347 |
+
surface_type = "ceiling"
|
| 348 |
+
angle = 0.0
|
| 349 |
+
blend_alpha = 0.58
|
| 350 |
+
tile_width = max(128, image_width // 5)
|
| 351 |
+
elif is_floor:
|
| 352 |
+
if material == "wood":
|
| 353 |
+
surface_type = "deck"
|
| 354 |
+
angle = dominant_angle if 8.0 <= dominant_angle <= 172.0 else 0.0
|
| 355 |
+
blend_alpha = 0.82
|
| 356 |
+
tile_width = max(320, int(bbox_w * 0.95), image_width // 2)
|
| 357 |
+
else:
|
| 358 |
+
surface_type = "floor"
|
| 359 |
+
angle = 0.0
|
| 360 |
+
blend_alpha = 0.80
|
| 361 |
+
# ACM floor: ~3 large-format panels visible on the near edge
|
| 362 |
+
tile_width = max(200, int(bbox_w * 0.35)) if material == "acm" else max(144, image_width // 3)
|
| 363 |
+
else:
|
| 364 |
+
surface_type = "wall"
|
| 365 |
+
angle = 0.0
|
| 366 |
+
if material == "acm":
|
| 367 |
+
blend_alpha = 0.78
|
| 368 |
+
# ACM wall panels: ~3 panels across the surface width
|
| 369 |
+
tile_width = max(180, int(bbox_w * 0.33))
|
| 370 |
+
elif material == "wood":
|
| 371 |
+
blend_alpha = 0.70
|
| 372 |
+
tile_width = max(220, int(bbox_w * 0.55), image_width // 4)
|
| 373 |
+
elif material == "stone":
|
| 374 |
+
blend_alpha = 0.84
|
| 375 |
+
tile_width = max(128, image_width // 4)
|
| 376 |
+
else:
|
| 377 |
+
blend_alpha = 0.66
|
| 378 |
+
tile_width = max(128, image_width // 4)
|
| 379 |
+
|
| 380 |
+
return (surface_type, float(angle % 180.0), float(blend_alpha), int(tile_width))
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def choose_auto_texture_settings(material: str, surface_type: str) -> tuple[float, float, float]:
|
| 384 |
+
strength_map = {"acm": 0.98, "stone": 0.96, "wood": 0.88, "metal": 0.91, "generic": 0.9}
|
| 385 |
+
intensity_map = {"acm": 0.08, "stone": 0.36, "wood": 0.3, "metal": 0.34, "generic": 0.32}
|
| 386 |
+
|
| 387 |
+
strength = float(strength_map.get(material, 0.9))
|
| 388 |
+
intensity = float(intensity_map.get(material, 0.32))
|
| 389 |
+
|
| 390 |
+
if surface_type in {"wall", "facade"}:
|
| 391 |
+
strength += 0.02
|
| 392 |
+
angle = 28.0
|
| 393 |
+
elif surface_type in {"roof"}:
|
| 394 |
+
angle = 42.0
|
| 395 |
+
intensity += 0.03
|
| 396 |
+
elif surface_type in {"floor", "deck"}:
|
| 397 |
+
angle = 24.0
|
| 398 |
+
intensity += 0.02
|
| 399 |
+
else:
|
| 400 |
+
angle = 35.0
|
| 401 |
+
|
| 402 |
+
return (
|
| 403 |
+
float(np.clip(strength, 0.55, 0.99)),
|
| 404 |
+
float(angle % 360.0),
|
| 405 |
+
float(np.clip(intensity, 0.0, 1.0)),
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def build_feather_mask(binary_mask: np.ndarray, sigma: float = 2.2) -> np.ndarray:
|
| 410 |
+
mask = (binary_mask > 0).astype(np.float32)
|
| 411 |
+
if mask.max() <= 0:
|
| 412 |
+
return mask
|
| 413 |
+
feather = cv2.GaussianBlur(mask, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
| 414 |
+
return np.clip(feather, 0.0, 1.0)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def build_scene_luminance_map(orig_rgb: np.ndarray) -> np.ndarray:
|
| 418 |
+
orig_u8 = orig_rgb.astype(np.uint8)
|
| 419 |
+
orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 420 |
+
l_channel = orig_lab[:, :, 0] / 255.0
|
| 421 |
+
broad_light = cv2.GaussianBlur(l_channel, (0, 0), sigmaX=18.0, sigmaY=18.0)
|
| 422 |
+
local_detail = l_channel - cv2.GaussianBlur(l_channel, (0, 0), sigmaX=4.0, sigmaY=4.0)
|
| 423 |
+
light_map = 0.82 + (broad_light * 0.36) + (local_detail * 0.18)
|
| 424 |
+
return np.clip(light_map, 0.72, 1.22)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def build_texture_relief_map(tex_rgb: np.ndarray, material: str = "generic") -> np.ndarray:
|
| 428 |
+
tex_u8 = tex_rgb.astype(np.uint8)
|
| 429 |
+
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 430 |
+
micro_relief = tex_gray - cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=3.0, sigmaY=3.0)
|
| 431 |
+
|
| 432 |
+
relief_scale = {"acm": 0.35, "stone": 2.8, "wood": 2.2, "metal": 1.8}.get(material, 2.0)
|
| 433 |
+
return np.clip(micro_relief * relief_scale, -1.0, 1.0)
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def build_directional_light_map(
|
| 437 |
+
tex_rgb: np.ndarray,
|
| 438 |
+
material: str,
|
| 439 |
+
light_angle_degrees: float,
|
| 440 |
+
light_intensity: float,
|
| 441 |
+
) -> np.ndarray:
|
| 442 |
+
tex_u8 = tex_rgb.astype(np.uint8)
|
| 443 |
+
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 444 |
+
height_map = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=1.4, sigmaY=1.4)
|
| 445 |
+
grad_x = cv2.Sobel(height_map, cv2.CV_32F, 1, 0, ksize=3)
|
| 446 |
+
grad_y = cv2.Sobel(height_map, cv2.CV_32F, 0, 1, ksize=3)
|
| 447 |
+
|
| 448 |
+
relief_scale = {"acm": 0.45, "stone": 3.0, "wood": 2.4, "metal": 1.6}.get(material, 2.0)
|
| 449 |
+
|
| 450 |
+
nx = -grad_x * relief_scale
|
| 451 |
+
ny = -grad_y * relief_scale
|
| 452 |
+
nz = np.ones_like(nx, dtype=np.float32)
|
| 453 |
+
norm = np.sqrt((nx * nx) + (ny * ny) + (nz * nz)) + 1e-6
|
| 454 |
+
nx = nx / norm
|
| 455 |
+
ny = ny / norm
|
| 456 |
+
nz = nz / norm
|
| 457 |
+
|
| 458 |
+
theta = np.deg2rad(float(light_angle_degrees))
|
| 459 |
+
lx = float(np.cos(theta))
|
| 460 |
+
ly = float(-np.sin(theta))
|
| 461 |
+
lz = 0.82
|
| 462 |
+
light_norm = max(1e-6, float(np.sqrt((lx * lx) + (ly * ly) + (lz * lz))))
|
| 463 |
+
lx /= light_norm
|
| 464 |
+
ly /= light_norm
|
| 465 |
+
lz /= light_norm
|
| 466 |
+
|
| 467 |
+
diffuse = np.clip((nx * lx) + (ny * ly) + (nz * lz), 0.0, 1.0)
|
| 468 |
+
strength = float(np.clip(light_intensity, 0.0, 1.0))
|
| 469 |
+
if material == "acm":
|
| 470 |
+
return np.clip(0.97 + (diffuse * (0.03 + (0.12 * strength))), 0.95, 1.12)
|
| 471 |
+
return np.clip(0.86 + (diffuse * (0.14 + (0.60 * strength))), 0.72, 1.35)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def build_mask_edge_occlusion(binary_mask: np.ndarray, light_intensity: float) -> np.ndarray:
|
| 475 |
+
mask_u8 = (binary_mask > 0).astype(np.uint8)
|
| 476 |
+
if mask_u8.max() == 0:
|
| 477 |
+
return np.ones(mask_u8.shape, dtype=np.float32)
|
| 478 |
+
|
| 479 |
+
distance = cv2.distanceTransform(mask_u8, cv2.DIST_L2, 5).astype(np.float32)
|
| 480 |
+
inner_values = distance[mask_u8 > 0]
|
| 481 |
+
if inner_values.size == 0:
|
| 482 |
+
return np.ones(mask_u8.shape, dtype=np.float32)
|
| 483 |
+
|
| 484 |
+
max_distance = max(1.0, float(np.percentile(inner_values, 95)))
|
| 485 |
+
normalized = np.clip(distance / (max_distance * 0.16), 0.0, 1.0)
|
| 486 |
+
edge_strength = 1.0 - normalized
|
| 487 |
+
occlusion = 1.0 - (edge_strength * (0.04 + (0.08 * float(np.clip(light_intensity, 0.0, 1.0)))))
|
| 488 |
+
occlusion[mask_u8 == 0] = 1.0
|
| 489 |
+
return np.clip(occlusion, 0.88, 1.0)
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def apply_surface_lighting(
|
| 493 |
+
tex_rgb: np.ndarray,
|
| 494 |
+
orig_rgb: np.ndarray,
|
| 495 |
+
binary_mask: np.ndarray,
|
| 496 |
+
material: str,
|
| 497 |
+
lighting_mode: str,
|
| 498 |
+
light_angle_degrees: float,
|
| 499 |
+
light_intensity: float,
|
| 500 |
+
) -> np.ndarray:
|
| 501 |
+
scene_light = build_scene_luminance_map(orig_rgb)
|
| 502 |
+
directional_light = build_directional_light_map(tex_rgb, material, light_angle_degrees, light_intensity)
|
| 503 |
+
relief_map = build_texture_relief_map(tex_rgb, material)
|
| 504 |
+
edge_occlusion = build_mask_edge_occlusion(binary_mask, light_intensity)
|
| 505 |
+
|
| 506 |
+
if material == "acm":
|
| 507 |
+
if lighting_mode == "directional":
|
| 508 |
+
light_map = directional_light
|
| 509 |
+
elif lighting_mode == "flat":
|
| 510 |
+
light_map = np.ones(scene_light.shape, dtype=np.float32)
|
| 511 |
+
else:
|
| 512 |
+
# 45 % scene luminance so ACM panels inherit shadows/gradients from photo
|
| 513 |
+
light_map = (scene_light * 0.45) + (directional_light * 0.55)
|
| 514 |
+
elif lighting_mode == "directional":
|
| 515 |
+
light_map = directional_light
|
| 516 |
+
elif lighting_mode == "flat":
|
| 517 |
+
light_map = np.ones(scene_light.shape, dtype=np.float32)
|
| 518 |
+
else:
|
| 519 |
+
light_map = (scene_light * 0.78) + (directional_light * 0.22)
|
| 520 |
+
|
| 521 |
+
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)))
|
| 522 |
+
detail_boost = 1.0 + (relief_map * detail_scale)
|
| 523 |
+
enhanced = tex_rgb.astype(np.float32)
|
| 524 |
+
enhanced *= light_map[:, :, None]
|
| 525 |
+
enhanced *= detail_boost[:, :, None]
|
| 526 |
+
enhanced *= edge_occlusion[:, :, None]
|
| 527 |
+
return np.clip(enhanced, 0, 255).astype(np.uint8)
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def blend_texture_preserve_shading(
|
| 531 |
+
orig_rgb: np.ndarray,
|
| 532 |
+
tex_rgb: np.ndarray,
|
| 533 |
+
alpha_mask: np.ndarray,
|
| 534 |
+
blend_alpha: float,
|
| 535 |
+
material: str = "generic",
|
| 536 |
+
) -> np.ndarray:
|
| 537 |
+
orig_u8 = orig_rgb.astype(np.uint8)
|
| 538 |
+
tex_u8 = tex_rgb.astype(np.uint8)
|
| 539 |
+
|
| 540 |
+
orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 541 |
+
tex_lab = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 542 |
+
|
| 543 |
+
mixed_lab = tex_lab.copy()
|
| 544 |
+
if material == "acm":
|
| 545 |
+
# 30 % original luminance → panels inherit scene shadows while keeping panel colour
|
| 546 |
+
mixed_lab[:, :, 0] = (0.30 * orig_lab[:, :, 0]) + (0.70 * tex_lab[:, :, 0])
|
| 547 |
+
mixed_lab[:, :, 1] = (0.97 * tex_lab[:, :, 1]) + (0.03 * orig_lab[:, :, 1])
|
| 548 |
+
mixed_lab[:, :, 2] = (0.97 * tex_lab[:, :, 2]) + (0.03 * orig_lab[:, :, 2])
|
| 549 |
+
elif material == "wood":
|
| 550 |
+
mixed_lab[:, :, 0] = (0.78 * orig_lab[:, :, 0]) + (0.22 * tex_lab[:, :, 0])
|
| 551 |
+
mixed_lab[:, :, 1] = (0.9 * tex_lab[:, :, 1]) + (0.1 * orig_lab[:, :, 1])
|
| 552 |
+
mixed_lab[:, :, 2] = (0.9 * tex_lab[:, :, 2]) + (0.1 * orig_lab[:, :, 2])
|
| 553 |
+
elif material == "stone":
|
| 554 |
+
orig_l_base = cv2.GaussianBlur(orig_lab[:, :, 0], (0, 0), sigmaX=11.0, sigmaY=11.0)
|
| 555 |
+
mixed_lab[:, :, 0] = (0.18 * orig_l_base) + (0.82 * tex_lab[:, :, 0])
|
| 556 |
+
mixed_lab[:, :, 1] = (0.95 * tex_lab[:, :, 1]) + (0.05 * orig_lab[:, :, 1])
|
| 557 |
+
mixed_lab[:, :, 2] = (0.95 * tex_lab[:, :, 2]) + (0.05 * orig_lab[:, :, 2])
|
| 558 |
+
else:
|
| 559 |
+
mixed_lab[:, :, 0] = orig_lab[:, :, 0]
|
| 560 |
+
mixed_lab[:, :, 1] = (0.8 * tex_lab[:, :, 1]) + (0.2 * orig_lab[:, :, 1])
|
| 561 |
+
mixed_lab[:, :, 2] = (0.8 * tex_lab[:, :, 2]) + (0.2 * orig_lab[:, :, 2])
|
| 562 |
+
|
| 563 |
+
shaded_tex = cv2.cvtColor(np.clip(mixed_lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)
|
| 564 |
+
if material == "wood":
|
| 565 |
+
tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
| 566 |
+
tex_base = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=9.0, sigmaY=9.0)
|
| 567 |
+
tex_detail = np.clip((tex_gray - tex_base) / 255.0, -0.35, 0.35)
|
| 568 |
+
shaded_tex *= (1.0 + (tex_detail[:, :, None] * 0.28))
|
| 569 |
+
|
| 570 |
+
alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
|
| 571 |
+
composite = (orig_rgb * (1.0 - alpha)) + (shaded_tex * alpha)
|
| 572 |
+
return np.clip(composite, 0, 255).astype(np.uint8)
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def blend_texture_direct(
|
| 576 |
+
orig_rgb: np.ndarray,
|
| 577 |
+
tex_rgb: np.ndarray,
|
| 578 |
+
alpha_mask: np.ndarray,
|
| 579 |
+
blend_alpha: float,
|
| 580 |
+
) -> np.ndarray:
|
| 581 |
+
alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
|
| 582 |
+
composite = (orig_rgb * (1.0 - alpha)) + (tex_rgb * alpha)
|
| 583 |
+
return np.clip(composite, 0, 255).astype(np.uint8)
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def apply_local_texture_sync(payload: ApplyTextureRequest) -> dict[str, Any]:
|
| 587 |
+
step = "APPLY_TEXTURE"
|
| 588 |
+
started = log_timing_start(step)
|
| 589 |
+
try:
|
| 590 |
+
safe_name = Path(payload.filename).name
|
| 591 |
+
if not safe_name:
|
| 592 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 593 |
+
|
| 594 |
+
label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
|
| 595 |
+
|
| 596 |
+
image_path = UPLOAD_DIR / safe_name
|
| 597 |
+
if not image_path.exists() or not image_path.is_file():
|
| 598 |
+
image_path = OUTPUT_DIR / safe_name
|
| 599 |
+
|
| 600 |
+
if (not image_path.exists() or not image_path.is_file()) and payload.original_filename:
|
| 601 |
+
orig_name = Path(payload.original_filename).name
|
| 602 |
+
image_path = UPLOAD_DIR / orig_name
|
| 603 |
+
if not image_path.exists() or not image_path.is_file():
|
| 604 |
+
image_path = OUTPUT_DIR / orig_name
|
| 605 |
+
|
| 606 |
+
if not image_path.exists() or not image_path.is_file():
|
| 607 |
+
raise HTTPException(
|
| 608 |
+
status_code=404,
|
| 609 |
+
detail=f"Image not found: {safe_name} (also tried original: {payload.original_filename or 'n/a'})",
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
masks_dir = UPLOAD_DIR / "masks"
|
| 613 |
+
masks_dir.mkdir(exist_ok=True)
|
| 614 |
+
label_owner = Path(image_path).stem
|
| 615 |
+
label_path = masks_dir / f"{label_owner}_labels.png"
|
| 616 |
+
if not label_path.exists() and payload.original_filename:
|
| 617 |
+
alt_owner = Path(payload.original_filename).name
|
| 618 |
+
alt_label = masks_dir / f"{alt_owner}_labels.png"
|
| 619 |
+
if alt_label.exists():
|
| 620 |
+
label_path = alt_label
|
| 621 |
+
|
| 622 |
+
if not label_path.exists():
|
| 623 |
+
raise HTTPException(
|
| 624 |
+
status_code=404,
|
| 625 |
+
detail=f"Label map not found for {label_owner}. Upload/segment the image first.",
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
if not payload.mask_indices:
|
| 629 |
+
raise HTTPException(status_code=400, detail="No mask indices provided")
|
| 630 |
+
|
| 631 |
+
texture_path = resolve_texture_path(payload.texture_name)
|
| 632 |
+
orig_pil = Image.open(str(image_path)).convert("RGB")
|
| 633 |
+
width, height = orig_pil.size
|
| 634 |
+
|
| 635 |
+
label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
|
| 636 |
+
if label_map is None:
|
| 637 |
+
raise HTTPException(status_code=500, detail="Could not read label map")
|
| 638 |
+
|
| 639 |
+
binary_mask = np.zeros((label_map.shape[0], label_map.shape[1]), dtype=np.uint8)
|
| 640 |
+
for idx in payload.mask_indices:
|
| 641 |
+
binary_mask |= (label_map == idx).astype(np.uint8)
|
| 642 |
+
|
| 643 |
+
if binary_mask.max() == 0:
|
| 644 |
+
raise HTTPException(status_code=400, detail="None of the selected segments were found in the label map.")
|
| 645 |
+
|
| 646 |
+
direction_mode = str(payload.direction_mode or "auto").strip().lower()
|
| 647 |
+
if direction_mode not in {"auto", "manual", "none"}:
|
| 648 |
+
raise HTTPException(status_code=400, detail="Invalid direction_mode. Use auto, manual, or none.")
|
| 649 |
+
|
| 650 |
+
replace_mode = str(getattr(payload, "replace_mode", "realistic") or "realistic").strip().lower()
|
| 651 |
+
lighting_mode = str(getattr(payload, "lighting_mode", "scene") or "scene").strip().lower()
|
| 652 |
+
|
| 653 |
+
material = classify_texture_material(payload.texture_name)
|
| 654 |
+
surface_type, inferred_angle, blend_alpha, target_w = infer_surface_type_and_direction(
|
| 655 |
+
binary_mask, width, height, payload.texture_name,
|
| 656 |
+
)
|
| 657 |
+
replace_strength, light_angle_degrees, light_intensity = choose_auto_texture_settings(material, surface_type)
|
| 658 |
+
effective_alpha = float(np.clip(blend_alpha * (0.55 + (0.75 * replace_strength)), 0.0, 0.98))
|
| 659 |
+
if material == "acm":
|
| 660 |
+
effective_alpha = float(max(effective_alpha, 0.92))
|
| 661 |
+
|
| 662 |
+
applied_angle = 0.0
|
| 663 |
+
if direction_mode == "auto":
|
| 664 |
+
applied_angle = inferred_angle
|
| 665 |
+
elif direction_mode == "manual":
|
| 666 |
+
applied_angle = float(payload.angle_degrees)
|
| 667 |
+
|
| 668 |
+
tex_pil = load_texture_pil_rgb(texture_path)
|
| 669 |
+
|
| 670 |
+
# Escalar al tamaño de tile deseado ANTES de rotar
|
| 671 |
+
tex_w, tex_h = tex_pil.size
|
| 672 |
+
scale = target_w / max(1, tex_w)
|
| 673 |
+
if abs(scale - 1.0) > 0.05:
|
| 674 |
+
tex_pil = tex_pil.resize(
|
| 675 |
+
(max(1, int(tex_w * scale)), max(1, int(tex_h * scale))),
|
| 676 |
+
Image.Resampling.LANCZOS,
|
| 677 |
+
)
|
| 678 |
+
tex_w, tex_h = tex_pil.size
|
| 679 |
+
|
| 680 |
+
tiled: Image.Image | None = None
|
| 681 |
+
|
| 682 |
+
# Floor surfaces: perspective tiling solo cuando la perspectiva es fuerte.
|
| 683 |
+
# Para pisos de dormitorio/habitación (perspectiva suave) el quad aproximado
|
| 684 |
+
# no cubre bien el mask irregular → produce negro. Se usa tiling regular en esos casos.
|
| 685 |
+
if surface_type == "floor" and direction_mode in {"auto", "none"}:
|
| 686 |
+
ys_m, xs_m = np.where(binary_mask > 0)
|
| 687 |
+
if ys_m.size > 0:
|
| 688 |
+
min_y_m, max_y_m = int(ys_m.min()), int(ys_m.max())
|
| 689 |
+
bbox_h_m = max(1, max_y_m - min_y_m + 1)
|
| 690 |
+
trap_score = _compute_trapezoid_score_from_mask(
|
| 691 |
+
binary_mask, ys_m, xs_m, min_y_m, max_y_m, bbox_h_m
|
| 692 |
+
)
|
| 693 |
+
if trap_score > 0.35:
|
| 694 |
+
quad = _extract_mask_quad(binary_mask)
|
| 695 |
+
if quad is not None:
|
| 696 |
+
tiled = _tile_texture_perspective(tex_pil, quad, width, height)
|
| 697 |
+
if tiled is not None:
|
| 698 |
+
logger.info(f"[APPLY_TEXTURE] perspective floor tiling applied (trap={trap_score:.2f})")
|
| 699 |
+
else:
|
| 700 |
+
logger.info(f"[APPLY_TEXTURE] flat floor tiling (trap={trap_score:.2f} < 0.35, skip perspective)")
|
| 701 |
+
|
| 702 |
+
# Wall / ceiling surfaces: perspective tiling cuando el quad es significativamente
|
| 703 |
+
# no-rectangular (pared fotografiada en ángulo). Un ratio > 1.20 entre lado mayor
|
| 704 |
+
# y lado menor indica distorsión perspectiva visible.
|
| 705 |
+
if surface_type in {"wall", "ceiling"} and tiled is None and direction_mode in {"auto", "none"}:
|
| 706 |
+
quad = _extract_mask_quad(binary_mask)
|
| 707 |
+
if quad is not None:
|
| 708 |
+
tl, tr, br, bl = quad
|
| 709 |
+
top_w = float(np.linalg.norm(tr.astype(float) - tl.astype(float)))
|
| 710 |
+
bot_w = float(np.linalg.norm(br.astype(float) - bl.astype(float)))
|
| 711 |
+
left_h = float(np.linalg.norm(bl.astype(float) - tl.astype(float)))
|
| 712 |
+
right_h = float(np.linalg.norm(br.astype(float) - tr.astype(float)))
|
| 713 |
+
w_ratio = max(top_w, bot_w) / max(1.0, min(top_w, bot_w))
|
| 714 |
+
h_ratio = max(left_h, right_h) / max(1.0, min(left_h, right_h))
|
| 715 |
+
if w_ratio > 1.20 or h_ratio > 1.20:
|
| 716 |
+
tiled = _tile_texture_perspective(tex_pil, quad, width, height)
|
| 717 |
+
if tiled is not None:
|
| 718 |
+
logger.info(
|
| 719 |
+
f"[APPLY_TEXTURE] perspective wall tiling applied "
|
| 720 |
+
f"(w_ratio={w_ratio:.2f}, h_ratio={h_ratio:.2f})"
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
+
if tiled is None:
|
| 724 |
+
if abs(applied_angle) > 0.01:
|
| 725 |
+
# Tile on a large canvas first, then rotate the full canvas to avoid black corners
|
| 726 |
+
diag = int(np.ceil(np.sqrt(width ** 2 + height ** 2))) + max(tex_w, tex_h)
|
| 727 |
+
large_w = width + diag
|
| 728 |
+
large_h = height + diag
|
| 729 |
+
large = Image.new("RGB", (large_w, large_h))
|
| 730 |
+
for y in range(0, large_h, tex_h):
|
| 731 |
+
for x in range(0, large_w, tex_w):
|
| 732 |
+
large.paste(tex_pil, (x, y))
|
| 733 |
+
large = large.rotate(-applied_angle, resample=Image.Resampling.BICUBIC, expand=False)
|
| 734 |
+
cx = (large_w - width) // 2
|
| 735 |
+
cy = (large_h - height) // 2
|
| 736 |
+
tiled = large.crop((cx, cy, cx + width, cy + height))
|
| 737 |
+
else:
|
| 738 |
+
tiled = Image.new("RGB", (width, height))
|
| 739 |
+
for y in range(0, height, tex_h):
|
| 740 |
+
for x in range(0, width, tex_w):
|
| 741 |
+
tiled.paste(tex_pil, (x, y))
|
| 742 |
+
|
| 743 |
+
orig_u8 = np.array(orig_pil, dtype=np.uint8)
|
| 744 |
+
|
| 745 |
+
try:
|
| 746 |
+
if bool(getattr(payload, "clear_mask_before_apply", False)):
|
| 747 |
+
orig_candidate = None
|
| 748 |
+
if payload.original_filename:
|
| 749 |
+
cand = UPLOAD_DIR / Path(payload.original_filename).name
|
| 750 |
+
if cand.exists() and cand.is_file():
|
| 751 |
+
orig_candidate = cand
|
| 752 |
+
else:
|
| 753 |
+
cand2 = OUTPUT_DIR / Path(payload.original_filename).name
|
| 754 |
+
if cand2.exists() and cand2.is_file():
|
| 755 |
+
orig_candidate = cand2
|
| 756 |
+
if orig_candidate is None:
|
| 757 |
+
stem = Path(image_path).stem
|
| 758 |
+
if "_edit_" in stem:
|
| 759 |
+
base_name = stem.split("_edit_")[0] + ".jpg"
|
| 760 |
+
cand = UPLOAD_DIR / base_name
|
| 761 |
+
if cand.exists() and cand.is_file():
|
| 762 |
+
orig_candidate = cand
|
| 763 |
+
else:
|
| 764 |
+
cand2 = OUTPUT_DIR / base_name
|
| 765 |
+
if cand2.exists() and cand2.is_file():
|
| 766 |
+
orig_candidate = cand2
|
| 767 |
+
|
| 768 |
+
if orig_candidate is not None:
|
| 769 |
+
try:
|
| 770 |
+
base_pil = Image.open(str(orig_candidate)).convert("RGB")
|
| 771 |
+
if base_pil.size != (width, height):
|
| 772 |
+
base_pil = base_pil.resize((width, height), Image.Resampling.LANCZOS)
|
| 773 |
+
base_arr = np.array(base_pil, dtype=np.uint8)
|
| 774 |
+
mask_bool = (binary_mask > 0)
|
| 775 |
+
orig_u8[mask_bool] = base_arr[mask_bool]
|
| 776 |
+
logger.info(f"[APPLY_TEXTURE] cleared mask from original source: {orig_candidate}")
|
| 777 |
+
except Exception:
|
| 778 |
+
logger.exception("Failed to restore original pixels for clear_mask_before_apply")
|
| 779 |
+
except Exception:
|
| 780 |
+
logger.exception("Error handling clear_mask_before_apply")
|
| 781 |
+
|
| 782 |
+
tiled_arr = np.array(tiled, dtype=np.uint8)
|
| 783 |
+
|
| 784 |
+
# Parchar píxeles muy oscuros (suma R+G+B < 20) dentro de la máscara.
|
| 785 |
+
# Cubren tanto negro exacto (0,0,0) como píxeles casi negros que el warp
|
| 786 |
+
# de perspectiva puede generar en bordes del quad o zonas sin cobertura.
|
| 787 |
+
mask_bool = binary_mask > 0
|
| 788 |
+
dark_in_mask = mask_bool & (tiled_arr.sum(axis=2) < 20)
|
| 789 |
+
if dark_in_mask.any():
|
| 790 |
+
th_f, tw_f = tex_pil.size[1], tex_pil.size[0]
|
| 791 |
+
tex_np = np.array(tex_pil, dtype=np.uint8)
|
| 792 |
+
ys_b, xs_b = np.where(dark_in_mask)
|
| 793 |
+
tiled_arr[ys_b, xs_b] = tex_np[ys_b % th_f, xs_b % tw_f]
|
| 794 |
+
|
| 795 |
+
lit_tex = apply_surface_lighting(
|
| 796 |
+
tiled_arr,
|
| 797 |
+
orig_u8,
|
| 798 |
+
binary_mask,
|
| 799 |
+
material,
|
| 800 |
+
lighting_mode,
|
| 801 |
+
light_angle_degrees,
|
| 802 |
+
light_intensity,
|
| 803 |
+
)
|
| 804 |
+
|
| 805 |
+
orig_arr = orig_u8.astype(np.float32)
|
| 806 |
+
tex_arr = lit_tex.astype(np.float32)
|
| 807 |
+
|
| 808 |
+
if replace_mode in {"hard", "absolute", "force", "replace"}:
|
| 809 |
+
mask_bool = (binary_mask > 0).astype(bool)
|
| 810 |
+
composite_arr = orig_arr.copy()
|
| 811 |
+
composite_arr[mask_bool] = tex_arr[mask_bool]
|
| 812 |
+
composite = np.clip(composite_arr, 0, 255).astype(np.uint8)
|
| 813 |
+
else:
|
| 814 |
+
feather_mask = build_feather_mask(binary_mask)
|
| 815 |
+
# All materials use shading-preservation so scene luminance (shadows/highlights)
|
| 816 |
+
# from the original photo is transferred onto the texture.
|
| 817 |
+
composite = blend_texture_preserve_shading(orig_arr, tex_arr, feather_mask, effective_alpha, material)
|
| 818 |
+
|
| 819 |
+
input_stem = Path(image_path).stem
|
| 820 |
+
edit_suffix = uuid.uuid4().hex[:8]
|
| 821 |
+
out_filename = f"{input_stem}_edit_{edit_suffix}.jpg"
|
| 822 |
+
out_path = UPLOAD_DIR / out_filename
|
| 823 |
+
Image.fromarray(composite).save(str(out_path), format="JPEG", quality=UPLOAD_JPEG_QUALITY, optimize=True)
|
| 824 |
+
|
| 825 |
+
try:
|
| 826 |
+
out_label_path = masks_dir / f"{Path(out_filename).stem}_labels.png"
|
| 827 |
+
if label_path.exists():
|
| 828 |
+
shutil.copyfile(str(label_path), str(out_label_path))
|
| 829 |
+
except Exception:
|
| 830 |
+
logger.exception("Failed to copy label map for output image")
|
| 831 |
+
|
| 832 |
+
return {
|
| 833 |
+
"message": "Texture applied successfully",
|
| 834 |
+
"original": safe_name,
|
| 835 |
+
"mask_indices": payload.mask_indices,
|
| 836 |
+
"texture_name": payload.texture_name,
|
| 837 |
+
"material": material,
|
| 838 |
+
"direction_mode": direction_mode,
|
| 839 |
+
"surface_type": surface_type,
|
| 840 |
+
"replace_mode": replace_mode,
|
| 841 |
+
"replace_strength": round(replace_strength, 3),
|
| 842 |
+
"lighting_mode": lighting_mode,
|
| 843 |
+
"light_angle_degrees": round(light_angle_degrees, 2),
|
| 844 |
+
"light_intensity": round(light_intensity, 3),
|
| 845 |
+
"blend_alpha": round(effective_alpha, 3),
|
| 846 |
+
"applied_angle_degrees": round(applied_angle, 2),
|
| 847 |
+
"output_filename": out_filename,
|
| 848 |
+
"output_url": f"/seg/image/{out_filename}",
|
| 849 |
+
}
|
| 850 |
+
finally:
|
| 851 |
+
log_timing_end(step, started)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/uploads/acm azul.jpg
ADDED
|
backend/uploads/acm azul_edit_2e8f9183.jpg
ADDED
|