eduardo4547 commited on
Commit
b6ba6de
·
verified ·
1 Parent(s): f5b9c0a

Upload 285 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +6 -0
  2. backend/.env +5 -2
  3. backend/__pycache__/main.cpython-312.pyc +0 -0
  4. backend/__pycache__/main.cpython-313.pyc +0 -0
  5. backend/core/__pycache__/__init__.cpython-313.pyc +0 -0
  6. backend/core/__pycache__/config.cpython-312.pyc +0 -0
  7. backend/core/__pycache__/config.cpython-313.pyc +0 -0
  8. backend/core/config.py +92 -92
  9. backend/data/presets.json +4 -4
  10. backend/logs/app.log +0 -0
  11. backend/main.py +127 -129
  12. backend/models/__pycache__/__init__.cpython-313.pyc +0 -0
  13. backend/models/__pycache__/schemas.cpython-312.pyc +0 -0
  14. backend/models/__pycache__/schemas.cpython-313.pyc +0 -0
  15. backend/models/schemas.py +3 -1
  16. backend/requirements.txt +1 -0
  17. backend/routers/__pycache__/__init__.cpython-313.pyc +0 -0
  18. backend/routers/__pycache__/active_sessions.cpython-312.pyc +0 -0
  19. backend/routers/__pycache__/auth.cpython-313.pyc +0 -0
  20. backend/routers/__pycache__/catalog.cpython-312.pyc +0 -0
  21. backend/routers/__pycache__/catalog.cpython-313.pyc +0 -0
  22. backend/routers/__pycache__/openai_image.cpython-312.pyc +0 -0
  23. backend/routers/__pycache__/segmentation.cpython-312.pyc +0 -0
  24. backend/routers/__pycache__/segmentation.cpython-313.pyc +0 -0
  25. backend/routers/active_sessions.py +11 -0
  26. backend/routers/catalog.py +240 -241
  27. backend/routers/openai_image.py +32 -0
  28. backend/routers/segmentation.py +760 -723
  29. backend/services/__pycache__/__init__.cpython-313.pyc +0 -0
  30. backend/services/__pycache__/gradio_client_service.cpython-312.pyc +0 -0
  31. backend/services/__pycache__/gradio_client_service.cpython-313.pyc +0 -0
  32. backend/services/__pycache__/image_service.cpython-312.pyc +0 -0
  33. backend/services/__pycache__/image_service.cpython-313.pyc +0 -0
  34. backend/services/__pycache__/inpainting_service.cpython-312.pyc +0 -0
  35. backend/services/__pycache__/inpainting_service.cpython-313.pyc +0 -0
  36. backend/services/__pycache__/openai_service.cpython-312.pyc +0 -0
  37. backend/services/__pycache__/sam2_service.cpython-312.pyc +0 -0
  38. backend/services/__pycache__/sam2_service.cpython-313.pyc +0 -0
  39. backend/services/__pycache__/scene_service.cpython-313.pyc +0 -0
  40. backend/services/__pycache__/segmentation_service.cpython-313.pyc +0 -0
  41. backend/services/__pycache__/texture_service.cpython-312.pyc +0 -0
  42. backend/services/__pycache__/texture_service.cpython-313.pyc +0 -0
  43. backend/services/gradio_client_service.py +108 -176
  44. backend/services/image_service.py +12 -40
  45. backend/services/inpainting_service.py +204 -12
  46. backend/services/openai_service.py +251 -0
  47. backend/services/sam2_service.py +3 -0
  48. backend/services/texture_service.py +851 -981
  49. backend/uploads/acm azul.jpg +0 -0
  50. 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=https://eduardo4547-hyper-reality-sam2-gpu.hf.space
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.3,
7
- "separacion_horizontal_px": 0.3,
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.25,
26
- "alto_panel_m": 0.05,
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 GRADIO_SPACE_URL, logger
16
- from routers import auth, catalog, media, pages, segmentation, sessions, share, presets
17
- from routers.catalog import seed_catalog
18
- from services.sam2_service import lifespan
19
-
20
- mimetypes.add_type("application/javascript", ".js", strict=True)
21
- mimetypes.add_type("text/css", ".css", strict=True)
22
- mimetypes.add_type("image/svg+xml", ".svg", strict=True)
23
-
24
- logger.info("[STARTUP] GRADIO_SPACE_URL=%s", GRADIO_SPACE_URL or "(not set — using local SAM2)")
25
-
26
- app = FastAPI(title="Hyper Reality Backend", lifespan=lifespan)
27
-
28
- app.add_middleware(
29
- CORSMiddleware,
30
- allow_origins=["*"],
31
- allow_credentials=True,
32
- allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
33
- allow_headers=["*"],
34
- )
35
-
36
-
37
- @app.middleware("http")
38
- async def remove_x_frame_options(request: Request, call_next):
39
- response = await call_next(request)
40
- if "x-frame-options" in response.headers:
41
- del response.headers["x-frame-options"]
42
- response.headers["Content-Security-Policy"] = "frame-ancestors *"
43
- return response
44
-
45
-
46
- # Routers
47
- app.include_router(pages.router)
48
- app.include_router(auth.router)
49
- app.include_router(share.router)
50
- app.include_router(media.router)
51
- app.include_router(catalog.router)
52
- app.include_router(sessions.router)
53
- app.include_router(segmentation.router)
54
- app.include_router(presets.router)
55
-
56
- # Static files
57
- BASE_DIR = Path(__file__).resolve().parent
58
- UPLOADS_DIR = BASE_DIR / "uploads"
59
- FRONTEND_DIST = BASE_DIR.parent / "frontend" / "dist"
60
-
61
- UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
62
- app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads")
63
-
64
- if (FRONTEND_DIST / "index.html").exists():
65
- # Montado en "/" como catch-all para SPA — los routers de API tienen prioridad
66
- app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
67
-
68
-
69
- # Frontend watcher (development helper)
70
- FRONTEND_DIR = BASE_DIR.parent / "frontend"
71
- FRONTEND_SRC = FRONTEND_DIR / "src"
72
-
73
-
74
- def scan_frontend_sources() -> dict:
75
- if not FRONTEND_SRC.exists():
76
- return {}
77
- files = {}
78
- for path in FRONTEND_SRC.rglob("*"):
79
- if path.is_file() and path.suffix in {".ts", ".tsx", ".js", ".jsx", ".css", ".json", ".html"}:
80
- files[path] = path.stat().st_mtime
81
- for extra in [FRONTEND_DIR / "vite.config.ts", FRONTEND_DIR / "package.json", FRONTEND_DIR / "tsconfig.json"]:
82
- if extra.exists():
83
- files[extra] = extra.stat().st_mtime
84
- return files
85
-
86
-
87
- def run_frontend_build() -> None:
88
- if not FRONTEND_DIR.exists():
89
- return
90
- print("[backend] Ejecutando build del frontend...")
91
- result = subprocess.run(["npm", "run", "build"], cwd=str(FRONTEND_DIR), capture_output=True, text=True)
92
- if result.returncode != 0:
93
- print("[backend] Build falló:")
94
- print(result.stdout)
95
- print(result.stderr)
96
- else:
97
- print("[backend] Build completado correctamente.")
98
-
99
-
100
- def watch_frontend_changes(interval: float = 2.0) -> None:
101
- last_state = scan_frontend_sources()
102
- while True:
103
- time.sleep(interval)
104
- current_state = scan_frontend_sources()
105
- if current_state != last_state:
106
- if last_state:
107
- print("[backend] Cambio detectado en frontend. Reconstruyendo...")
108
- run_frontend_build()
109
- last_state = current_state
110
-
111
-
112
- @app.on_event("startup")
113
- async def startup_seed_catalog():
114
- if MONGODB_URI := os.getenv("MONGODB_URI", ""):
115
- try:
116
- await seed_catalog()
117
- except Exception as exc:
118
- logger.warning("[STARTUP] seed_catalog falló: %s", exc)
119
-
120
-
121
- @app.on_event("startup")
122
- async def startup_watch_frontend():
123
- thread = threading.Thread(target=watch_frontend_changes, daemon=True)
124
- thread.start()
125
-
126
-
127
- if __name__ == "__main__":
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": "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
- # Insertar entradas nuevas del seed y parchar campos faltantes en las existentes
114
- for seed_item in _SEED:
115
- doc = await col.find_one({"_id": seed_item["_id"]})
116
- if not doc:
117
- await col.insert_one(dict(seed_item))
118
- continue
119
- patch = {k: v for k, v in seed_item.items() if k not in doc and k != "_id"}
120
- if patch:
121
- await col.update_one({"_id": seed_item["_id"]}, {"$set": patch})
122
-
123
-
124
- def _serialize(doc: dict) -> dict:
125
- out = dict(doc)
126
- out["id"] = str(out.pop("_id"))
127
- return out
128
-
129
-
130
- def _seed_as_response() -> list[dict]:
131
- return [{**{k: v for k, v in item.items() if k != "_id"}, "id": item["_id"]} for item in _SEED]
132
-
133
-
134
- # ── Endpoints de lectura ──────────────────────────────────────────────────────
135
-
136
- @router.get("/textures")
137
- async def get_texture_catalog() -> JSONResponse:
138
- try:
139
- col = _get_col()
140
- docs = await col.find({}).to_list(length=200)
141
- if docs:
142
- return JSONResponse(content={"categories": [_serialize(d) for d in docs]})
143
- except Exception:
144
- pass
145
- # Fallback a datos estáticos si MongoDB no está disponible o la colección está vacía
146
- return JSONResponse(content={"categories": _seed_as_response()})
147
-
148
-
149
- @router.get("/textures/{category_id}")
150
- async def get_texture_category(category_id: str) -> JSONResponse:
151
- try:
152
- col = _get_col()
153
- doc = await col.find_one({"_id": category_id})
154
- if doc:
155
- return JSONResponse(content=_serialize(doc))
156
- except Exception:
157
- pass
158
- fallback = next((item for item in _SEED if item["_id"] == category_id), None)
159
- if fallback:
160
- return JSONResponse(content=_seed_as_response()[_SEED.index(fallback)])
161
- return JSONResponse(content={"detail": f"Categoria '{category_id}' no encontrada"}, status_code=404)
162
-
163
-
164
- # ── Modelos ───────────────────────────────────────────────────────────────────
165
-
166
- class ProductoItem(BaseModel):
167
- id: str
168
- nombre: str
169
- textura: str
170
- url_preview: str
171
- dimensiones: list[str] = []
172
-
173
-
174
- class CategoriaBody(BaseModel):
175
- id: str
176
- nombre: str
177
- tipo: str = "paredes"
178
- descripcion: str = ""
179
- especificaciones: list[str] = []
180
- url_detalle: str = ""
181
- productos: list[ProductoItem] = []
182
-
183
-
184
- # ── Endpoints de escritura ────────────────────────────────────────────────────
185
-
186
- @router.post("/category")
187
- async def add_category(body: CategoriaBody) -> JSONResponse:
188
- col = _get_col()
189
- existing = await col.find_one({"_id": body.id})
190
- if existing:
191
- return JSONResponse(content={"error": f"Categoria '{body.id}' ya existe"}, status_code=409)
192
- doc = body.model_dump()
193
- doc["_id"] = doc.pop("id")
194
- doc["created_at"] = datetime.utcnow().isoformat() + "Z"
195
- await col.insert_one(doc)
196
- return JSONResponse(content={"ok": True, "id": body.id}, status_code=201)
197
-
198
-
199
- @router.put("/category/{category_id}")
200
- async def update_category(category_id: str, body: CategoriaBody) -> JSONResponse:
201
- col = _get_col()
202
- doc = body.model_dump()
203
- doc.pop("id", None)
204
- doc["updated_at"] = datetime.utcnow().isoformat() + "Z"
205
- result = await col.update_one({"_id": category_id}, {"$set": doc})
206
- if result.matched_count == 0:
207
- return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
208
- return JSONResponse(content={"ok": True})
209
-
210
-
211
- @router.delete("/category/{category_id}")
212
- async def delete_category(category_id: str) -> JSONResponse:
213
- col = _get_col()
214
- result = await col.delete_one({"_id": category_id})
215
- if result.deleted_count == 0:
216
- return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
217
- return JSONResponse(content={"ok": True, "deleted": category_id})
218
-
219
-
220
- @router.post("/category/{category_id}/product")
221
- async def add_product(category_id: str, product: ProductoItem) -> JSONResponse:
222
- col = _get_col()
223
- result = await col.update_one(
224
- {"_id": category_id, "productos.id": {"$ne": product.id}},
225
- {"$push": {"productos": product.model_dump()}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
226
- )
227
- if result.matched_count == 0:
228
- return JSONResponse(content={"error": "Categoria no encontrada o producto duplicado"}, status_code=409)
229
- return JSONResponse(content={"ok": True}, status_code=201)
230
-
231
-
232
- @router.delete("/category/{category_id}/product/{product_id}")
233
- async def delete_product(category_id: str, product_id: str) -> JSONResponse:
234
- col = _get_col()
235
- result = await col.update_one(
236
- {"_id": category_id},
237
- {"$pull": {"productos": {"id": product_id}}, "$set": {"updated_at": datetime.utcnow().isoformat() + "Z"}},
238
- )
239
- if result.matched_count == 0:
240
- return JSONResponse(content={"error": "Categoria no encontrada"}, status_code=404)
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.scene_service import (
53
- build_adaptive_plan,
54
- generate_label_map,
55
- infer_scene_type,
56
- normalize_priority,
57
- normalize_scene_hint,
58
- rank_exterior_candidates,
59
- rank_interior_candidates,
60
- )
61
- from services.segmentation_service import (
62
- generate_guided_label_map,
63
- parse_mask_index,
64
- parse_rgb_color,
65
- segment_exterior_brick_sync,
66
- segment_exterior_depth_sync,
67
- segment_exterior_grabcut_sync,
68
- segment_exterior_hybrid_sync,
69
- segment_video_sync,
70
- )
71
- from services.texture_service import (
72
- apply_local_texture_sync,
73
- build_texture_preview_jpeg,
74
- generate_texture_variations,
75
- list_available_textures,
76
- resolve_texture_path,
77
- )
78
-
79
- import cv2
80
-
81
- router = APIRouter(prefix="/seg")
82
-
83
-
84
- @router.get("/", response_class=HTMLResponse)
85
- async def home() -> HTMLResponse:
86
- dashboard_html = load_classic_dashboard_html().replace(
87
- "__FRONTEND_DEBUG_ENABLED__",
88
- "true" if FRONTEND_DEBUG else "false",
89
- )
90
- return HTMLResponse(content=dashboard_html)
91
-
92
-
93
- @router.post("/upload_video")
94
- async def upload_video(file: UploadFile = File(...)) -> dict[str, Any]:
95
- if not file.content_type or not file.content_type.startswith("video/"):
96
- raise HTTPException(status_code=400, detail="Only video files are allowed")
97
-
98
- safe_name = Path(file.filename or "uploaded_video").name
99
- if not safe_name:
100
- raise HTTPException(status_code=400, detail="Invalid filename")
101
-
102
- destination = VIDEO_UPLOAD_DIR / safe_name
103
- content = await file.read()
104
- if not content:
105
- raise HTTPException(status_code=400, detail="Uploaded video is empty")
106
-
107
- destination.write_bytes(content)
108
- return {
109
- "message": "Video uploaded successfully",
110
- "filename": safe_name,
111
- "url": f"/seg/video/{safe_name}",
112
- }
113
-
114
-
115
- @router.post("/upload_async")
116
- async def upload_image_async(
117
- background_tasks: BackgroundTasks,
118
- file: UploadFile = File(...),
119
- ) -> dict[str, Any]:
120
- if not file.content_type or not file.content_type.startswith("image/"):
121
- raise HTTPException(status_code=400, detail="Only image files are allowed")
122
-
123
- content = await file.read()
124
- job_id = uuid.uuid4().hex
125
- with jobs_lock:
126
- jobs[job_id] = {
127
- "kind": "upload",
128
- "status": "processing",
129
- "stage": "queued",
130
- "progress": 2,
131
- "message": "Queued for segmentation",
132
- "created_at": utc_now_iso(),
133
- "updated_at": utc_now_iso(),
134
- }
135
-
136
- background_tasks.add_task(run_upload_job, job_id, content, file.filename or "uploaded_image")
137
- return {
138
- "processing": True,
139
- "job_id": job_id,
140
- "status": "processing",
141
- "stage": "queued",
142
- "progress": 2,
143
- "message": "Upload accepted. Segmentation started in background.",
144
- "status_url": f"/seg/jobs/{job_id}",
145
- }
146
-
147
-
148
- @router.post("/segment_guided")
149
- async def segment_guided(payload: GuidedSegmentRequest) -> dict[str, Any]:
150
- started = log_timing_start("SEGMENT_GUIDED")
151
- try:
152
- from services.image_service import load_image_rgb_for_edit
153
- safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
154
- label_map, ranked_scores = await asyncio.to_thread(
155
- generate_guided_label_map,
156
- image_rgb,
157
- [list(point) for point in payload.point_coords],
158
- list(payload.point_labels),
159
- list(payload.box_xyxy) if payload.box_xyxy is not None else [],
160
- payload.multimask_output,
161
- )
162
-
163
- guided_owner = f"{Path(safe_name).stem}_guided.jpg"
164
- label_owner = await asyncio.to_thread(save_label_map_for_owner, guided_owner, label_map)
165
- available_indices = list(range(1, len(ranked_scores) + 1))
166
-
167
- return {
168
- "message": "Guided segmentation completed",
169
- "filename": safe_name,
170
- "original_filename_for_apply": label_owner,
171
- "mask_count": len(ranked_scores),
172
- "available_mask_indices": available_indices,
173
- "recommended_mask_index": 1,
174
- "scores": [round(score, 6) for score in ranked_scores],
175
- }
176
- finally:
177
- log_timing_end("SEGMENT_GUIDED", started)
178
- try:
179
- release_resources()
180
- except Exception:
181
- logger.exception("Error releasing resources after SEGMENT_GUIDED")
182
-
183
-
184
- @router.post("/suggest_exterior_masks")
185
- async def suggest_exterior_masks(payload: ExteriorSuggestRequest) -> dict[str, Any]:
186
- started = log_timing_start("EXTERIOR_SUGGEST")
187
- try:
188
- from services.image_service import load_image_rgb_for_edit
189
- safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
190
-
191
- label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
192
-
193
- masks_dir = UPLOAD_DIR / "masks"
194
- label_path = masks_dir / f"{label_owner_name}_labels.png"
195
- if not label_path.exists():
196
- label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
197
- label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
198
- label_path = masks_dir / f"{label_owner_name}_labels.png"
199
-
200
- label_map_arr = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
201
- if label_map_arr is None:
202
- raise HTTPException(status_code=404, detail="Label map not found")
203
-
204
- candidates = rank_exterior_candidates(
205
- label_map_arr,
206
- payload.top_k,
207
- target=payload.target,
208
- min_area_ratio=payload.min_area_ratio,
209
- max_area_ratio=payload.max_area_ratio,
210
- )
211
-
212
- return {
213
- "message": "Exterior mask suggestions generated",
214
- "filename": safe_name,
215
- "original_filename_for_apply": label_owner_name,
216
- "suggestions": candidates,
217
- "target": payload.target,
218
- }
219
- finally:
220
- log_timing_end("EXTERIOR_SUGGEST", started)
221
- try:
222
- release_resources()
223
- except Exception:
224
- logger.exception("Error releasing resources after EXTERIOR_SUGGEST")
225
-
226
-
227
- @router.post("/analyze_scene")
228
- async def analyze_scene(payload: SceneAnalyzeRequest) -> dict[str, Any]:
229
- started = log_timing_start("ANALYZE_SCENE")
230
- try:
231
- from services.image_service import load_image_rgb_for_edit
232
- safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
233
-
234
- label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
235
- masks_dir = UPLOAD_DIR / "masks"
236
- label_path = masks_dir / f"{label_owner_name}_labels.png"
237
-
238
- if not label_path.exists():
239
- label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
240
- label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
241
-
242
- scene_info = await asyncio.to_thread(
243
- infer_scene_type,
244
- image_rgb,
245
- payload.semantic_keywords,
246
- payload.exterior_target,
247
- payload.min_area_ratio,
248
- payload.max_area_ratio,
249
- )
250
- scene_type = scene_info["scene_type"]
251
- scene_hint = normalize_scene_hint(payload.scene_hint)
252
- effective_scene = scene_hint if scene_hint != "auto" else scene_type
253
-
254
- adaptive_plan = build_adaptive_plan(effective_scene, payload.priority, payload.exterior_target)
255
-
256
- label_map_arr = cv2.imread(str(masks_dir / f"{label_owner_name}_labels.png"), cv2.IMREAD_GRAYSCALE)
257
- suggestions: list[dict[str, Any]] = []
258
- if label_map_arr is not None:
259
- if effective_scene == "exterior":
260
- suggestions = rank_exterior_candidates(
261
- label_map_arr, payload.top_k,
262
- target=payload.exterior_target,
263
- min_area_ratio=payload.min_area_ratio,
264
- max_area_ratio=payload.max_area_ratio,
265
- )
266
- else:
267
- suggestions = rank_interior_candidates(label_map_arr, payload.top_k)
268
-
269
- return {
270
- "message": "Scene analysis completed",
271
- "filename": safe_name,
272
- "original_filename_for_apply": label_owner_name,
273
- "scene_type": scene_type,
274
- "effective_scene": effective_scene,
275
- "confidence": scene_info["confidence"],
276
- "signals": scene_info["signals"],
277
- "adaptive_plan": adaptive_plan,
278
- "suggestions": suggestions,
279
- "priority": normalize_priority(payload.priority),
280
- }
281
- finally:
282
- log_timing_end("ANALYZE_SCENE", started)
283
- try:
284
- release_resources()
285
- except Exception:
286
- logger.exception("Error releasing resources after ANALYZE_SCENE")
287
-
288
-
289
- @router.post("/segment_adaptive")
290
- async def segment_adaptive(payload: SegmentAdaptiveRequest) -> dict[str, Any]:
291
- started = log_timing_start("SEGMENT_ADAPTIVE")
292
- try:
293
- from services.image_service import load_image_rgb_for_edit
294
- safe_name, image_rgb = await asyncio.to_thread(load_image_rgb_for_edit, payload.filename)
295
-
296
- scene_info = await asyncio.to_thread(
297
- infer_scene_type,
298
- image_rgb,
299
- payload.semantic_keywords,
300
- payload.exterior_target,
301
- )
302
- scene_hint = normalize_scene_hint(payload.scene_hint)
303
- effective_scene = scene_hint if scene_hint != "auto" else scene_info["scene_type"]
304
- priority = normalize_priority(payload.priority)
305
- adaptive_plan = build_adaptive_plan(effective_scene, priority, payload.exterior_target)
306
-
307
- label_owner_name = Path(payload.original_filename).name if payload.original_filename else safe_name
308
-
309
- if effective_scene == "exterior":
310
- from services.segmentation_service import segment_exterior_depth_sync as seg_depth
311
- from models.schemas import ExteriorDepthRequest as DepthReq
312
-
313
- depth_payload = DepthReq(
314
- filename=payload.filename,
315
- exterior_target=payload.exterior_target,
316
- rect_xywh=payload.rect_xywh,
317
- smooth_strength=1,
318
- sam2_merge_top_k=12,
319
- iterations=6,
320
- use_semantic_hint=True,
321
- use_depth_hint=True,
322
- semantic_keywords=payload.semantic_keywords,
323
- )
324
- result = await asyncio.to_thread(seg_depth, depth_payload)
325
- else:
326
- label_map, _ = await asyncio.to_thread(generate_label_map, image_rgb)
327
- label_owner_name = await asyncio.to_thread(save_label_map_for_owner, label_owner_name, label_map)
328
- top_k = 4 if priority == "speed" else (10 if priority == "quality" else 6)
329
- candidates = rank_interior_candidates(label_map, top_k)
330
- result = {
331
- "message": "Interior adaptive segmentation completed",
332
- "filename": safe_name,
333
- "original_filename_for_apply": label_owner_name,
334
- "scene_type": effective_scene,
335
- "suggestions": candidates,
336
- }
337
-
338
- result["adaptive_plan"] = adaptive_plan
339
- result["detected_scene_type"] = scene_info["scene_type"]
340
- result["effective_scene"] = effective_scene
341
- result["scene_confidence"] = scene_info["confidence"]
342
- return result
343
- finally:
344
- log_timing_end("SEGMENT_ADAPTIVE", started)
345
- try:
346
- release_resources()
347
- except Exception:
348
- logger.exception("Error releasing resources after SEGMENT_ADAPTIVE")
349
-
350
-
351
- @router.post("/segment_video")
352
- async def segment_video(payload: SegmentVideoRequest) -> dict[str, Any]:
353
- try:
354
- return await asyncio.to_thread(segment_video_sync, payload)
355
- except HTTPException:
356
- raise
357
- except Exception as exc:
358
- raise HTTPException(status_code=500, detail=f"Video segmentation failed: {exc}") from exc
359
-
360
-
361
- @router.post("/segment_exterior_grabcut")
362
- async def segment_exterior_grabcut(payload: ExteriorGrabCutRequest) -> dict[str, Any]:
363
- try:
364
- return await asyncio.to_thread(segment_exterior_grabcut_sync, payload)
365
- except HTTPException:
366
- raise
367
- except Exception as exc:
368
- raise HTTPException(status_code=500, detail=f"GrabCut segmentation failed: {exc}") from exc
369
-
370
-
371
- @router.post("/segment_exterior_hybrid")
372
- async def segment_exterior_hybrid(payload: ExteriorHybridRequest) -> dict[str, Any]:
373
- try:
374
- return await asyncio.to_thread(segment_exterior_hybrid_sync, payload)
375
- except HTTPException:
376
- raise
377
- except Exception as exc:
378
- raise HTTPException(status_code=500, detail=f"Hybrid exterior segmentation failed: {exc}") from exc
379
-
380
-
381
- @router.post("/segment_exterior_brick")
382
- async def segment_exterior_brick(payload: ExteriorBrickRequest) -> dict[str, Any]:
383
- try:
384
- return await asyncio.to_thread(segment_exterior_brick_sync, payload)
385
- except HTTPException:
386
- raise
387
- except Exception as exc:
388
- raise HTTPException(status_code=500, detail=f"Brick segmentation failed: {exc}") from exc
389
-
390
-
391
- @router.post("/segment_exterior_depth")
392
- async def segment_exterior_depth(payload: ExteriorDepthRequest) -> dict[str, Any]:
393
- try:
394
- return await asyncio.to_thread(segment_exterior_depth_sync, payload)
395
- except HTTPException:
396
- raise
397
- except Exception as exc:
398
- raise HTTPException(status_code=500, detail=f"Depth exterior segmentation failed: {exc}") from exc
399
-
400
-
401
- @router.post("/apply_texture_ai")
402
- async def apply_texture_ai(
403
- payload: ApplyTextureAIRequest,
404
- background_tasks: BackgroundTasks,
405
- ) -> dict[str, Any]:
406
- started = log_timing_start("APPLY_TEXTURE_AI")
407
- try:
408
- result = await asyncio.wait_for(
409
- asyncio.to_thread(run_inpainting_sync, payload),
410
- timeout=SD_QUICK_TIMEOUT_SECONDS,
411
- )
412
- log_timing_end("APPLY_TEXTURE_AI", started)
413
- try:
414
- release_resources()
415
- except Exception:
416
- logger.exception("Error releasing resources after APPLY_TEXTURE_AI")
417
- result["processing"] = False
418
- return result
419
- except asyncio.TimeoutError:
420
- job_id = uuid.uuid4().hex
421
- with jobs_lock:
422
- jobs[job_id] = {"status": "processing", "created_at": utc_now_iso(), "updated_at": utc_now_iso()}
423
- background_tasks.add_task(run_inpainting_job, job_id, payload)
424
- log_timing_end("APPLY_TEXTURE_AI", started)
425
- try:
426
- release_resources()
427
- except Exception:
428
- pass
429
- return {
430
- "processing": True,
431
- "job_id": job_id,
432
- "message": "Inpainting is taking longer than expected and continues in background.",
433
- "status_url": f"/seg/jobs/{job_id}",
434
- }
435
- except HTTPException:
436
- log_timing_end("APPLY_TEXTURE_AI", started)
437
- try:
438
- release_resources()
439
- except Exception:
440
- pass
441
- raise
442
- except Exception as exc:
443
- log_timing_end("APPLY_TEXTURE_AI", started)
444
- try:
445
- release_resources()
446
- except Exception:
447
- pass
448
- raise HTTPException(status_code=500, detail=f"Inpainting failed: {exc}") from exc
449
-
450
-
451
- @router.get("/jobs/{job_id}")
452
- async def get_job_status(job_id: str) -> dict[str, Any]:
453
- with jobs_lock:
454
- job = jobs.get(job_id)
455
-
456
- if job is None:
457
- raise HTTPException(status_code=404, detail="Job not found")
458
-
459
- if job.get("status") == "processing":
460
- kind = str(job.get("kind", "generic"))
461
- stage = str(job.get("stage", "processing"))
462
- progress = int(job.get("progress", 0) or 0)
463
- eta_seconds: int | None = None
464
-
465
- if kind == "upload" and stage == "segmenting_with_sam2":
466
- stage_started_at_text = job.get("stage_started_at")
467
- estimated_seconds = float(job.get("estimated_seconds", 0.0) or 0.0)
468
- if stage_started_at_text and estimated_seconds > 0:
469
- try:
470
- stage_started_at = datetime.fromisoformat(str(stage_started_at_text))
471
- elapsed = (datetime.now(timezone.utc) - stage_started_at).total_seconds()
472
- eta_seconds = max(0, int(estimated_seconds - elapsed))
473
- estimated_progress = int(min(95, 30 + (max(0.0, elapsed) / estimated_seconds) * 60))
474
- progress = max(progress, estimated_progress)
475
- except ValueError:
476
- pass
477
-
478
- stale_limit_seconds = UPLOAD_JOB_STALE_SECONDS if kind == "upload" else SD_JOB_STALE_SECONDS
479
- created_at_text = job.get("created_at")
480
- if created_at_text:
481
- try:
482
- created_at = datetime.fromisoformat(str(created_at_text))
483
- age_seconds = (datetime.now(timezone.utc) - created_at).total_seconds()
484
- if age_seconds > stale_limit_seconds:
485
- return {
486
- "processing": False,
487
- "status": "timeout",
488
- "message": "The process is taking too long. Please retry.",
489
- "job_id": job_id,
490
- }
491
- except ValueError:
492
- pass
493
-
494
- response: dict[str, Any] = {
495
- "processing": True,
496
- "status": "processing",
497
- "job_id": job_id,
498
- "kind": kind,
499
- "stage": stage,
500
- "progress": progress,
501
- "message": str(job.get("message", "Still processing.")),
502
- }
503
- if eta_seconds is not None:
504
- response["eta_seconds"] = eta_seconds
505
- return response
506
-
507
- if job.get("status") == "done":
508
- result = cast(dict[str, Any], job.get("result", {}))
509
- result["processing"] = False
510
- result["job_id"] = job_id
511
- result["status"] = "done"
512
- return result
513
-
514
- if job.get("status") == "failed":
515
- return {
516
- "processing": False,
517
- "status": "failed",
518
- "job_id": job_id,
519
- "message": job.get("error", "Background task failed"),
520
- }
521
-
522
- return {"processing": True, "status": "processing", "job_id": job_id, "message": "Still processing."}
523
-
524
-
525
- @router.post("/apply_color")
526
- async def apply_color(payload: ApplyColorRequest) -> dict[str, Any]:
527
- started = log_timing_start("APPLY_COLOR")
528
- try:
529
- safe_name = Path(payload.filename).name
530
- if not safe_name:
531
- raise HTTPException(status_code=400, detail="Invalid filename")
532
-
533
- label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
534
-
535
- image_path = UPLOAD_DIR / safe_name
536
- if not image_path.exists():
537
- image_path = OUTPUT_DIR / safe_name
538
- if not image_path.exists() or not image_path.is_file():
539
- raise HTTPException(status_code=404, detail=f"Image not found: {safe_name}")
540
-
541
- image_bgr = cv2.imread(str(image_path))
542
- if image_bgr is None:
543
- raise HTTPException(status_code=400, detail="Image could not be read")
544
-
545
- mask_index = parse_mask_index(payload.mask_filename)
546
- red, green, blue = parse_rgb_color(payload.color)
547
-
548
- label_path = UPLOAD_DIR / "masks" / f"{label_safe_name}_labels.png"
549
- label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
550
- if label_map is None:
551
- raise HTTPException(
552
- status_code=404,
553
- detail="Label map not found. Upload the image first to generate segments.",
554
- )
555
-
556
- segmentation = label_map == mask_index
557
- if not segmentation.any():
558
- raise HTTPException(status_code=400, detail=f"Segment index {mask_index} not found in label map.")
559
-
560
- edited_image = image_bgr.copy()
561
- edited_image[segmentation] = (blue, green, red)
562
-
563
- original_stem = Path(label_safe_name).stem
564
- out_filename = f"{original_stem}_edit.jpg"
565
- out_path = UPLOAD_DIR / out_filename
566
- if not cv2.imwrite(str(out_path), edited_image):
567
- raise HTTPException(status_code=500, detail="Failed to save edited image")
568
-
569
- return {
570
- "message": "Color applied successfully",
571
- "output_filename": out_filename,
572
- "output_url": f"/seg/image/{out_filename}",
573
- }
574
- finally:
575
- log_timing_end("APPLY_COLOR", started)
576
- try:
577
- release_resources()
578
- except Exception:
579
- logger.exception("Error releasing resources after APPLY_COLOR")
580
-
581
-
582
- @router.post("/apply_texture")
583
- async def apply_texture(payload: ApplyTextureRequest) -> dict[str, Any]:
584
- try:
585
- result = await asyncio.to_thread(apply_local_texture_sync, payload)
586
- result["processing"] = False
587
- return result
588
- except HTTPException:
589
- raise
590
- except Exception as exc:
591
- raise HTTPException(status_code=500, detail=f"Texture apply failed: {exc}") from exc
592
-
593
-
594
- @router.get("/textures")
595
- async def get_textures() -> dict[str, Any]:
596
- return {"textures": list_available_textures()}
597
-
598
-
599
- class _GenerateVariationsRequest(BaseModel):
600
- texture_name: str
601
-
602
- class Config:
603
- extra = "ignore"
604
-
605
-
606
- @router.post("/textures/generate")
607
- async def generate_variations(payload: _GenerateVariationsRequest) -> dict[str, Any]:
608
- if not payload.texture_name:
609
- raise HTTPException(status_code=400, detail="texture_name is required")
610
- try:
611
- variations = await asyncio.to_thread(generate_texture_variations, payload.texture_name)
612
- return {"variations": variations}
613
- except HTTPException:
614
- raise
615
- except Exception as exc:
616
- raise HTTPException(status_code=500, detail=f"Variation generation failed: {exc}") from exc
617
-
618
-
619
- @router.get("/texture-preview/{filename:path}")
620
- async def get_texture_preview(filename: str) -> Response:
621
- texture_path = resolve_texture_path(filename)
622
- jpeg = await asyncio.to_thread(build_texture_preview_jpeg, texture_path)
623
- return Response(content=jpeg, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600"})
624
-
625
-
626
- @router.get("/video/{filename}")
627
- async def get_video(filename: str) -> FileResponse:
628
- if Path(filename).name != filename:
629
- raise HTTPException(status_code=400, detail="Invalid file name")
630
- video_path = VIDEO_UPLOAD_DIR / filename
631
- if not video_path.exists() or not video_path.is_file():
632
- raise HTTPException(status_code=404, detail="Video not found")
633
- return FileResponse(video_path)
634
-
635
-
636
- @router.get("/output-video/{filename}")
637
- async def get_output_video(filename: str) -> FileResponse:
638
- if Path(filename).name != filename:
639
- raise HTTPException(status_code=400, detail="Invalid file name")
640
- video_path = VIDEO_OUTPUT_DIR / filename
641
- if not video_path.exists() or not video_path.is_file():
642
- raise HTTPException(status_code=404, detail="Output video not found")
643
- return FileResponse(video_path)
644
-
645
-
646
- @router.get("/image/{filename}")
647
- async def get_image(filename: str) -> FileResponse:
648
- if Path(filename).name != filename:
649
- raise HTTPException(status_code=400, detail="Invalid file name")
650
- image_path = UPLOAD_DIR / filename
651
- if not image_path.exists() or not image_path.is_file():
652
- raise HTTPException(status_code=404, detail="Image not found")
653
- return FileResponse(image_path)
654
-
655
-
656
- @router.post("/masks/reclassify/{filename}")
657
- async def reclassify_mask_metadata(filename: str) -> dict[str, Any]:
658
- """Re-run semantic classification on an already-segmented image and overwrite its metadata JSON."""
659
- import json as _json
660
- safe = Path(filename).name
661
- if not safe:
662
- raise HTTPException(status_code=400, detail="Invalid filename")
663
-
664
- masks_dir = UPLOAD_DIR / "masks"
665
- label_path = masks_dir / f"{safe}_labels.png"
666
- if not label_path.exists():
667
- raise HTTPException(status_code=404, detail="Label map not found — upload the image first")
668
-
669
- label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
670
- if label_map is None:
671
- raise HTTPException(status_code=500, detail="Could not read label map")
672
-
673
- image_path = UPLOAD_DIR / safe
674
- image_rgb: Any = None
675
- if image_path.exists():
676
- img_bgr = cv2.imread(str(image_path))
677
- if img_bgr is not None:
678
- image_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
679
-
680
- from services.scene_service import classify_all_label_map_segments
681
- h, w = label_map.shape[:2]
682
- segments_meta = await asyncio.to_thread(
683
- classify_all_label_map_segments, label_map, w, h, image_rgb
684
- )
685
- meta_path = masks_dir / f"{safe}_labels_meta.json"
686
- meta_path.write_text(_json.dumps({"segments": segments_meta}, ensure_ascii=False), encoding="utf-8")
687
-
688
- return {"segments": segments_meta, "count": len(segments_meta)}
689
-
690
-
691
- @router.get("/masks/meta/{filename}")
692
- async def get_mask_metadata(filename: str) -> dict:
693
- import json as _json
694
- safe = Path(filename).name
695
- if not safe:
696
- raise HTTPException(status_code=400, detail="Invalid filename")
697
- meta_path = UPLOAD_DIR / "masks" / f"{safe}_labels_meta.json"
698
- if not meta_path.exists() or not meta_path.is_file():
699
- raise HTTPException(status_code=404, detail="Segment metadata not found")
700
- try:
701
- return _json.loads(meta_path.read_text(encoding="utf-8"))
702
- except Exception as exc:
703
- raise HTTPException(status_code=500, detail=f"Failed to read metadata: {exc}") from exc
704
-
705
-
706
- @router.get("/masks/{filename}")
707
- async def get_mask_labels(filename: str) -> FileResponse:
708
- if Path(filename).name != filename:
709
- raise HTTPException(status_code=400, detail="Invalid file name")
710
- label_path = UPLOAD_DIR / "masks" / f"{filename}_labels.png"
711
- if not label_path.exists() or not label_path.is_file():
712
- raise HTTPException(status_code=404, detail="Label map not found")
713
- return FileResponse(label_path)
714
-
715
-
716
- @router.get("/ai/{filename}")
717
- async def get_ai_image(filename: str) -> FileResponse:
718
- if Path(filename).name != filename:
719
- raise HTTPException(status_code=400, detail="Invalid file name")
720
- out_path = OUTPUT_DIR / filename
721
- if not out_path.exists() or not out_path.is_file():
722
- raise HTTPException(status_code=404, detail="AI output image not found")
723
- return FileResponse(out_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return bool(GRADIO_SPACE_URL)
22
-
23
-
24
- def _call_gradio_sync(image_path: Path, space_url: str) -> tuple[np.ndarray, int]:
25
- """
26
- Synchronous Gradio call safe to invoke from a background thread.
27
- Returns (label_map, mask_count).
28
- Raises on any error so the caller can handle fallback.
29
- """
30
- from gradio_client import Client, file # type: ignore
31
-
32
- # 300s timeout: ZeroGPU cold start + SAM2+DINO inference can take 60-120s
33
- client = Client(space_url, httpx_kwargs={"timeout": 300.0})
34
-
35
- # segment_for_backend returns (overlay_image, combined_json_str)
36
- _overlay_file, combined_json_str = client.predict(
37
- file(str(image_path)),
38
- api_name="/segment",
39
- )
40
-
41
- if not isinstance(combined_json_str, str):
42
- raise ValueError(f"Unexpected response type from Gradio Space: {type(combined_json_str)}")
43
-
44
- combined: dict = json.loads(combined_json_str)
45
-
46
- if "error" in combined:
47
- raise RuntimeError(f"Gradio Space error: {combined['error'][:500]}")
48
-
49
- label_map_b64: str = combined.get("label_map_b64", "")
50
- if not label_map_b64:
51
- return np.zeros((1, 1), dtype=np.uint8), 0
52
-
53
- # Decode PNG-encoded label map (lossless uint8 grayscale)
54
- label_map_bytes = base64.b64decode(label_map_b64)
55
- pil_label = Image.open(io.BytesIO(label_map_bytes))
56
- label_map = np.array(pil_label, dtype=np.uint8)
57
- mask_count = int(label_map.max())
58
-
59
- entorno = combined.get("entorno", "?")
60
- motor = combined.get("motor", "?")
61
- logger.info(
62
- "Gradio Space segmentation: entorno=%s motor=%s mask_count=%d",
63
- entorno, motor, mask_count,
64
- )
65
-
66
- return label_map, mask_count
67
-
68
-
69
- def segment_via_gradio_sync(image_path: Path) -> tuple[np.ndarray, int]:
70
- """
71
- Blocking call to the Gradio Space from a sync context (background task thread).
72
- Tries the GPU Space first; if it fails, falls back to the CPU Space.
73
- Raises RuntimeError if both fail or neither is configured.
74
- """
75
- if not is_gradio_enabled():
76
- raise RuntimeError("GRADIO_SPACE_URL is not configured")
77
-
78
- gpu_error: Exception | None = None
79
- try:
80
- logger.info("Calling GPU Gradio Space: %s", GRADIO_SPACE_URL)
81
- return _call_gradio_sync(image_path, GRADIO_SPACE_URL)
82
- except Exception as e:
83
- gpu_error = e
84
- logger.warning("GPU Space failed (%s), trying CPU fallback...", gpu_error)
85
-
86
- if not GRADIO_CPU_FALLBACK_URL:
87
- raise RuntimeError(f"GPU Gradio Space failed and no CPU fallback configured. Error: {gpu_error}")
88
-
89
- try:
90
- logger.info("Calling CPU fallback Space: %s", GRADIO_CPU_FALLBACK_URL)
91
- return _call_gradio_sync(image_path, GRADIO_CPU_FALLBACK_URL)
92
- except Exception as exc_cpu:
93
- raise RuntimeError(
94
- f"Both Gradio Spaces failed.\n"
95
- f" GPU ({GRADIO_SPACE_URL}): {gpu_error}\n"
96
- f" CPU ({GRADIO_CPU_FALLBACK_URL}): {exc_cpu}"
97
- ) from exc_cpu
98
-
99
-
100
- async def segment_via_gradio(image_path: Path) -> tuple[np.ndarray, int]:
101
- """
102
- Async wrapper — offloads the blocking call (with GPU→CPU fallback) to a thread.
103
- """
104
- return await asyncio.to_thread(segment_via_gradio_sync, image_path)
105
-
106
-
107
- def _call_gradio_render_sync(
108
- image_path: Path,
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
- if is_gradio_enabled():
138
- label_map, mask_count = segment_via_gradio_sync(image_path)
139
- else:
140
- generate_label_map = _get_generate_label_map()
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": 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": "Segmentation complete",
187
  "result": result,
188
  "updated_at": utc_now_iso(),
189
  }
190
- logger.info(f"[JOB {job_id}] done mask_count={mask_count}")
 
 
 
 
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
- return {
10
- "message": "Inpainting not configured",
11
- "filename": payload.filename,
12
- "prompt": payload.prompt,
13
- "processing": False,
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
- jobs[job_id] = {
22
- "status": "done",
23
- "result": result,
24
- "updated_at": utc_now_iso(),
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 "deck" in texture_key:
306
- return "deck"
307
- if "acm" in texture_key or "wpc" in texture_key:
308
- return "acm"
309
- if any(hint in texture_key for hint in ("wood", "plank", "laminate", "floor")):
310
- return "wood"
311
- if any(hint in texture_key for hint in ("marble", "granite", "tile", "brick", "cobblestone", "stone", "cartago", "riverbed")):
312
- return "stone"
313
- if any(hint in texture_key for hint in ("metal", "rust", "iron", "steel")):
314
- return "metal"
315
- return "generic"
316
-
317
-
318
- def infer_surface_type_and_direction(
319
- binary_mask: np.ndarray,
320
- image_width: int,
321
- image_height: int,
322
- texture_name: str,
323
- ) -> tuple[str, float, float, int]:
324
- mask_u8 = (binary_mask > 0).astype(np.uint8)
325
- ys, xs = np.where(mask_u8 > 0)
326
- if ys.size == 0 or xs.size == 0:
327
- return ("wall", 0.0, 0.78, max(180, image_width // 4))
328
-
329
- min_x, max_x = int(xs.min()), int(xs.max())
330
- min_y, max_y = int(ys.min()), int(ys.max())
331
- bbox_w = max(1, max_x - min_x + 1)
332
- bbox_h = max(1, max_y - min_y + 1)
333
- aspect = bbox_w / max(1.0, float(bbox_h))
334
- center_y = float(ys.mean()) / max(1.0, float(image_height))
335
- dominant_angle = estimate_mask_orientation_degrees(binary_mask)
336
- material = classify_texture_material(texture_name)
337
-
338
- # Trapezoid score: floor in perspective is wider at the bottom than the top
339
- trapezoid_score = _compute_trapezoid_score_from_mask(binary_mask, ys, xs, min_y, max_y, bbox_h)
340
-
341
- is_ceiling = center_y < 0.26 and aspect > 1.35
342
- # Floor: low center + trapezoidal shape, OR clearly low + wide
343
- is_floor = (
344
- (center_y > 0.55 and aspect >= 0.9 and trapezoid_score > 0.30)
345
- or (center_y > 0.68 and aspect > 1.15)
346
- )
347
-
348
- if is_ceiling:
349
- surface_type = "ceiling"
350
- angle = 0.0
351
- blend_alpha = 0.58
352
- tile_width = max(128, image_width // 5)
353
- elif is_floor:
354
- if material in ("deck", "wood"):
355
- surface_type = "deck"
356
- angle = 0.0 # Always straight, no perspective distortion for deck
357
- blend_alpha = 0.82
358
- tile_width = max(240, int(bbox_w * 0.55), image_width // 3)
359
- else:
360
- surface_type = "floor"
361
- angle = 0.0
362
- blend_alpha = 0.80
363
- # ACM floor: ~3 large-format panels visible on the near edge
364
- tile_width = max(200, int(bbox_w * 0.35)) if material == "acm" else max(144, image_width // 3)
365
- else:
366
- surface_type = "wall"
367
- angle = 0.0
368
- if material == "acm":
369
- blend_alpha = 0.78
370
- # ACM wall panels: ~3 panels across the surface width
371
- tile_width = max(180, int(bbox_w * 0.33))
372
- elif material == "wood":
373
- blend_alpha = 0.70
374
- tile_width = max(220, int(bbox_w * 0.55), image_width // 4)
375
- elif material == "stone":
376
- blend_alpha = 0.84
377
- tile_width = max(128, image_width // 4)
378
- else:
379
- blend_alpha = 0.66
380
- tile_width = max(128, image_width // 4)
381
-
382
- return (surface_type, float(angle % 180.0), float(blend_alpha), int(tile_width))
383
-
384
-
385
- def choose_auto_texture_settings(material: str, surface_type: str) -> tuple[float, float, float]:
386
- strength_map = {"acm": 0.98, "stone": 0.96, "wood": 0.88, "deck": 0.88, "metal": 0.91, "generic": 0.9}
387
- intensity_map = {"acm": 0.08, "stone": 0.36, "wood": 0.3, "deck": 0.3, "metal": 0.34, "generic": 0.32}
388
-
389
- strength = float(strength_map.get(material, 0.9))
390
- intensity = float(intensity_map.get(material, 0.32))
391
-
392
- if surface_type in {"wall", "facade"}:
393
- strength += 0.02
394
- angle = 28.0
395
- elif surface_type in {"roof"}:
396
- angle = 42.0
397
- intensity += 0.03
398
- elif surface_type in {"floor", "deck"}:
399
- angle = 24.0
400
- intensity += 0.02
401
- else:
402
- angle = 35.0
403
-
404
- return (
405
- float(np.clip(strength, 0.55, 0.99)),
406
- float(angle % 360.0),
407
- float(np.clip(intensity, 0.0, 1.0)),
408
- )
409
-
410
-
411
- def build_feather_mask(binary_mask: np.ndarray, sigma: float = 2.2) -> np.ndarray:
412
- mask = (binary_mask > 0).astype(np.float32)
413
- if mask.max() <= 0:
414
- return mask
415
- feather = cv2.GaussianBlur(mask, (0, 0), sigmaX=sigma, sigmaY=sigma)
416
- return np.clip(feather, 0.0, 1.0)
417
-
418
-
419
- def build_scene_luminance_map(orig_rgb: np.ndarray) -> np.ndarray:
420
- orig_u8 = orig_rgb.astype(np.uint8)
421
- orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
422
- l_channel = orig_lab[:, :, 0] / 255.0
423
- broad_light = cv2.GaussianBlur(l_channel, (0, 0), sigmaX=18.0, sigmaY=18.0)
424
- local_detail = l_channel - cv2.GaussianBlur(l_channel, (0, 0), sigmaX=4.0, sigmaY=4.0)
425
- light_map = 0.82 + (broad_light * 0.36) + (local_detail * 0.18)
426
- return np.clip(light_map, 0.72, 1.22)
427
-
428
-
429
- def build_texture_relief_map(tex_rgb: np.ndarray, material: str = "generic") -> np.ndarray:
430
- tex_u8 = tex_rgb.astype(np.uint8)
431
- tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
432
- micro_relief = tex_gray - cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=3.0, sigmaY=3.0)
433
-
434
- relief_scale = {"acm": 0.35, "stone": 2.8, "wood": 2.2, "deck": 2.2, "metal": 1.8}.get(material, 2.0)
435
- return np.clip(micro_relief * relief_scale, -1.0, 1.0)
436
-
437
-
438
- def build_directional_light_map(
439
- tex_rgb: np.ndarray,
440
- material: str,
441
- light_angle_degrees: float,
442
- light_intensity: float,
443
- ) -> np.ndarray:
444
- tex_u8 = tex_rgb.astype(np.uint8)
445
- tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
446
- height_map = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=1.4, sigmaY=1.4)
447
- grad_x = cv2.Sobel(height_map, cv2.CV_32F, 1, 0, ksize=3)
448
- grad_y = cv2.Sobel(height_map, cv2.CV_32F, 0, 1, ksize=3)
449
-
450
- relief_scale = {"acm": 0.45, "stone": 3.0, "wood": 2.4, "deck": 2.4, "metal": 1.6}.get(material, 2.0)
451
-
452
- nx = -grad_x * relief_scale
453
- ny = -grad_y * relief_scale
454
- nz = np.ones_like(nx, dtype=np.float32)
455
- norm = np.sqrt((nx * nx) + (ny * ny) + (nz * nz)) + 1e-6
456
- nx = nx / norm
457
- ny = ny / norm
458
- nz = nz / norm
459
-
460
- theta = np.deg2rad(float(light_angle_degrees))
461
- lx = float(np.cos(theta))
462
- ly = float(-np.sin(theta))
463
- lz = 0.82
464
- light_norm = max(1e-6, float(np.sqrt((lx * lx) + (ly * ly) + (lz * lz))))
465
- lx /= light_norm
466
- ly /= light_norm
467
- lz /= light_norm
468
-
469
- diffuse = np.clip((nx * lx) + (ny * ly) + (nz * lz), 0.0, 1.0)
470
- strength = float(np.clip(light_intensity, 0.0, 1.0))
471
- if material == "acm":
472
- return np.clip(0.97 + (diffuse * (0.03 + (0.12 * strength))), 0.95, 1.12)
473
- if material == "deck":
474
- return np.clip(0.88 + (diffuse * (0.12 + (0.50 * strength))), 0.76, 1.28)
475
- return np.clip(0.86 + (diffuse * (0.14 + (0.60 * strength))), 0.72, 1.35)
476
-
477
-
478
- def build_mask_edge_occlusion(binary_mask: np.ndarray, light_intensity: float) -> np.ndarray:
479
- mask_u8 = (binary_mask > 0).astype(np.uint8)
480
- if mask_u8.max() == 0:
481
- return np.ones(mask_u8.shape, dtype=np.float32)
482
-
483
- distance = cv2.distanceTransform(mask_u8, cv2.DIST_L2, 5).astype(np.float32)
484
- inner_values = distance[mask_u8 > 0]
485
- if inner_values.size == 0:
486
- return np.ones(mask_u8.shape, dtype=np.float32)
487
-
488
- max_distance = max(1.0, float(np.percentile(inner_values, 95)))
489
- normalized = np.clip(distance / (max_distance * 0.16), 0.0, 1.0)
490
- edge_strength = 1.0 - normalized
491
- occlusion = 1.0 - (edge_strength * (0.04 + (0.08 * float(np.clip(light_intensity, 0.0, 1.0)))))
492
- occlusion[mask_u8 == 0] = 1.0
493
- return np.clip(occlusion, 0.88, 1.0)
494
-
495
-
496
- def apply_surface_lighting(
497
- tex_rgb: np.ndarray,
498
- orig_rgb: np.ndarray,
499
- binary_mask: np.ndarray,
500
- material: str,
501
- lighting_mode: str,
502
- light_angle_degrees: float,
503
- light_intensity: float,
504
- ) -> np.ndarray:
505
- scene_light = build_scene_luminance_map(orig_rgb)
506
- directional_light = build_directional_light_map(tex_rgb, material, light_angle_degrees, light_intensity)
507
- relief_map = build_texture_relief_map(tex_rgb, material)
508
- edge_occlusion = build_mask_edge_occlusion(binary_mask, light_intensity)
509
-
510
- if material == "acm":
511
- if lighting_mode == "directional":
512
- light_map = directional_light
513
- elif lighting_mode == "flat":
514
- light_map = np.ones(scene_light.shape, dtype=np.float32)
515
- else:
516
- # 45 % scene luminance so ACM panels inherit shadows/gradients from photo
517
- light_map = (scene_light * 0.45) + (directional_light * 0.55)
518
- elif lighting_mode == "directional":
519
- light_map = directional_light
520
- elif lighting_mode == "flat":
521
- light_map = np.ones(scene_light.shape, dtype=np.float32)
522
- else:
523
- light_map = (scene_light * 0.78) + (directional_light * 0.22)
524
-
525
- 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)))
526
- detail_boost = 1.0 + (relief_map * detail_scale)
527
- enhanced = tex_rgb.astype(np.float32)
528
- enhanced *= light_map[:, :, None]
529
- enhanced *= detail_boost[:, :, None]
530
- enhanced *= edge_occlusion[:, :, None]
531
- return np.clip(enhanced, 0, 255).astype(np.uint8)
532
-
533
-
534
- def blend_texture_preserve_shading(
535
- orig_rgb: np.ndarray,
536
- tex_rgb: np.ndarray,
537
- alpha_mask: np.ndarray,
538
- blend_alpha: float,
539
- material: str = "generic",
540
- ) -> np.ndarray:
541
- orig_u8 = orig_rgb.astype(np.uint8)
542
- tex_u8 = tex_rgb.astype(np.uint8)
543
-
544
- orig_lab = cv2.cvtColor(orig_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
545
- tex_lab = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2LAB).astype(np.float32)
546
-
547
- mixed_lab = tex_lab.copy()
548
- if material == "acm":
549
- # 30 % original luminance → panels inherit scene shadows while keeping panel colour
550
- mixed_lab[:, :, 0] = (0.30 * orig_lab[:, :, 0]) + (0.70 * tex_lab[:, :, 0])
551
- mixed_lab[:, :, 1] = (0.97 * tex_lab[:, :, 1]) + (0.03 * orig_lab[:, :, 1])
552
- mixed_lab[:, :, 2] = (0.97 * tex_lab[:, :, 2]) + (0.03 * orig_lab[:, :, 2])
553
- elif material in ("wood", "deck"):
554
- mixed_lab[:, :, 0] = (0.78 * orig_lab[:, :, 0]) + (0.22 * tex_lab[:, :, 0])
555
- mixed_lab[:, :, 1] = (0.9 * tex_lab[:, :, 1]) + (0.1 * orig_lab[:, :, 1])
556
- mixed_lab[:, :, 2] = (0.9 * tex_lab[:, :, 2]) + (0.1 * orig_lab[:, :, 2])
557
- elif material == "stone":
558
- orig_l_base = cv2.GaussianBlur(orig_lab[:, :, 0], (0, 0), sigmaX=11.0, sigmaY=11.0)
559
- mixed_lab[:, :, 0] = (0.18 * orig_l_base) + (0.82 * tex_lab[:, :, 0])
560
- mixed_lab[:, :, 1] = (0.95 * tex_lab[:, :, 1]) + (0.05 * orig_lab[:, :, 1])
561
- mixed_lab[:, :, 2] = (0.95 * tex_lab[:, :, 2]) + (0.05 * orig_lab[:, :, 2])
562
- else:
563
- mixed_lab[:, :, 0] = orig_lab[:, :, 0]
564
- mixed_lab[:, :, 1] = (0.8 * tex_lab[:, :, 1]) + (0.2 * orig_lab[:, :, 1])
565
- mixed_lab[:, :, 2] = (0.8 * tex_lab[:, :, 2]) + (0.2 * orig_lab[:, :, 2])
566
-
567
- shaded_tex = cv2.cvtColor(np.clip(mixed_lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB).astype(np.float32)
568
- if material in ("wood", "deck"):
569
- tex_gray = cv2.cvtColor(tex_u8, cv2.COLOR_RGB2GRAY).astype(np.float32)
570
- tex_base = cv2.GaussianBlur(tex_gray, (0, 0), sigmaX=9.0, sigmaY=9.0)
571
- tex_detail = np.clip((tex_gray - tex_base) / 255.0, -0.35, 0.35)
572
- shaded_tex *= (1.0 + (tex_detail[:, :, None] * 0.28))
573
-
574
- alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
575
- composite = (orig_rgb * (1.0 - alpha)) + (shaded_tex * alpha)
576
- return np.clip(composite, 0, 255).astype(np.uint8)
577
-
578
-
579
- def blend_texture_direct(
580
- orig_rgb: np.ndarray,
581
- tex_rgb: np.ndarray,
582
- alpha_mask: np.ndarray,
583
- blend_alpha: float,
584
- ) -> np.ndarray:
585
- alpha = np.clip(alpha_mask[:, :, None] * float(blend_alpha), 0.0, 1.0)
586
- composite = (orig_rgb * (1.0 - alpha)) + (tex_rgb * alpha)
587
- return np.clip(composite, 0, 255).astype(np.uint8)
588
-
589
-
590
- def apply_local_texture_sync(payload: ApplyTextureRequest) -> dict[str, Any]:
591
- step = "APPLY_TEXTURE"
592
- started = log_timing_start(step)
593
- try:
594
- safe_name = Path(payload.filename).name
595
- if not safe_name:
596
- raise HTTPException(status_code=400, detail="Invalid filename")
597
-
598
- label_safe_name = Path(payload.original_filename).name if payload.original_filename else safe_name
599
-
600
- image_path = UPLOAD_DIR / safe_name
601
- if not image_path.exists() or not image_path.is_file():
602
- image_path = OUTPUT_DIR / safe_name
603
-
604
- if (not image_path.exists() or not image_path.is_file()) and payload.original_filename:
605
- orig_name = Path(payload.original_filename).name
606
- image_path = UPLOAD_DIR / orig_name
607
- if not image_path.exists() or not image_path.is_file():
608
- image_path = OUTPUT_DIR / orig_name
609
-
610
- if not image_path.exists() or not image_path.is_file():
611
- raise HTTPException(
612
- status_code=404,
613
- detail=f"Image not found: {safe_name} (also tried original: {payload.original_filename or 'n/a'})",
614
- )
615
-
616
- masks_dir = UPLOAD_DIR / "masks"
617
- masks_dir.mkdir(exist_ok=True)
618
- label_owner = Path(image_path).stem
619
- label_path = masks_dir / f"{label_owner}_labels.png"
620
- if not label_path.exists() and payload.original_filename:
621
- alt_owner = Path(payload.original_filename).name
622
- alt_label = masks_dir / f"{alt_owner}_labels.png"
623
- if alt_label.exists():
624
- label_path = alt_label
625
-
626
- if not label_path.exists():
627
- raise HTTPException(
628
- status_code=404,
629
- detail=f"Label map not found for {label_owner}. Upload/segment the image first.",
630
- )
631
-
632
- if not payload.mask_indices:
633
- raise HTTPException(status_code=400, detail="No mask indices provided")
634
-
635
- texture_path = resolve_texture_path(payload.texture_name)
636
- orig_pil = Image.open(str(image_path)).convert("RGB")
637
- width, height = orig_pil.size
638
-
639
- label_map = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
640
- if label_map is None:
641
- raise HTTPException(status_code=500, detail="Could not read label map")
642
-
643
- binary_mask = np.zeros((label_map.shape[0], label_map.shape[1]), dtype=np.uint8)
644
- for idx in payload.mask_indices:
645
- binary_mask |= (label_map == idx).astype(np.uint8)
646
-
647
- if binary_mask.max() == 0:
648
- raise HTTPException(status_code=400, detail="None of the selected segments were found in the label map.")
649
-
650
- direction_mode = str(payload.direction_mode or "auto").strip().lower()
651
- if direction_mode not in {"auto", "manual", "none"}:
652
- raise HTTPException(status_code=400, detail="Invalid direction_mode. Use auto, manual, or none.")
653
-
654
- replace_mode = str(getattr(payload, "replace_mode", "realistic") or "realistic").strip().lower()
655
- lighting_mode = str(getattr(payload, "lighting_mode", "scene") or "scene").strip().lower()
656
-
657
- material = classify_texture_material(payload.texture_name)
658
-
659
- # Integración: si la textura es 'acm' y hay un Gradio Space configurado,
660
- # delegar el render al Space usando el preset 'ACM' (si existe).
661
- # Construimos un label_map reducido (1 = unión de máscaras seleccionadas)
662
- # y enviamos la textura como base64 para evitar dependencias de archivos.
663
- try:
664
- import json as _json
665
- import base64 as _base64
666
- from services.gradio_client_service import is_gradio_enabled, render_via_gradio_sync
667
- from services.presets_service import get_preset
668
-
669
- try:
670
- # Activar render remoto para ACM y también para materiales 'deck'
671
- _use_remote = is_gradio_enabled() and material in {"acm", "deck"}
672
- except Exception:
673
- _use_remote = False
674
-
675
- if _use_remote:
676
- # Preparar label_map de unión
677
- lm = cv2.imread(str(label_path), cv2.IMREAD_GRAYSCALE)
678
- if lm is None:
679
- raise HTTPException(status_code=500, detail="Could not read label map for remote render")
680
-
681
- union = np.zeros_like(lm, dtype=np.uint8)
682
- for idx in payload.mask_indices:
683
- union[lm == int(idx)] = 1
684
-
685
- # Encode label_map (PNG) -> base64
686
- buf = io.BytesIO()
687
- Image.fromarray(union, mode="L").save(buf, format="PNG")
688
- label_map_b64 = _base64.b64encode(buf.getvalue()).decode("utf-8")
689
-
690
- # Encodear textura como base64
691
- tex_path = resolve_texture_path(payload.texture_name)
692
- tex_pil = load_texture_pil_rgb(tex_path)
693
- tbuf = io.BytesIO()
694
- tex_pil.save(tbuf, format="PNG")
695
- texture_b64 = _base64.b64encode(tbuf.getvalue()).decode("utf-8")
696
-
697
- # Intentar obtener preset correspondiente:
698
- # - Si el nombre contiene 'wpc' + 'deck' -> 'WPC_DECK'
699
- # - Si contiene solo 'wpc' -> 'WPC'
700
- # - En otro caso -> 'ACM'
701
- preset = {}
702
- try:
703
- _tex_key = (payload.texture_name or "").lower()
704
- if "wpc" in _tex_key:
705
- if "deck" in _tex_key:
706
- preset = get_preset("WPC_DECK")
707
- else:
708
- preset = get_preset("WPC")
709
- else:
710
- preset = get_preset("ACM")
711
- except Exception:
712
- preset = {}
713
-
714
- # Detectar tipo de superficie para que el Space oriente correctamente
715
- # la textura (piso=horizontal, pared=vertical)
716
- try:
717
- _surface_type, _, _, _ = infer_surface_type_and_direction(
718
- binary_mask, width, height, payload.texture_name
719
- )
720
- except Exception:
721
- _surface_type = "wall"
722
-
723
- # Merge: preset base + surface_type (surface_type tiene prioridad
724
- # sobre la orientacion del preset cuando es piso o deck)
725
- params_dict = dict(preset or {})
726
- params_dict["surface_type"] = _surface_type
727
- params_json = _json.dumps(params_dict)
728
-
729
- try:
730
- rendered_img_np, meta = render_via_gradio_sync(
731
- image_path, label_map_b64, 1, None, texture_b64, params_json
732
- )
733
- except Exception as _e:
734
- logger.warning("Remote render via Gradio failed, falling back to local: %s", _e)
735
- rendered_img_np = None
736
-
737
- if rendered_img_np is not None:
738
- # Guardar resultado remoto con mismo formato de salida
739
- input_stem = Path(image_path).stem
740
- edit_suffix = uuid.uuid4().hex[:8]
741
- out_filename = f"{input_stem}_edit_{edit_suffix}.jpg"
742
- out_path = UPLOAD_DIR / out_filename
743
- # Aceptar tanto np.ndarray como PIL.Image devueltos por el Space
744
- try:
745
- if isinstance(rendered_img_np, np.ndarray):
746
- img_to_save = Image.fromarray(rendered_img_np)
747
- elif isinstance(rendered_img_np, Image.Image):
748
- img_to_save = rendered_img_np
749
- else:
750
- # manejar strings: pueden ser rutas locales, URLs o base64
751
- if isinstance(rendered_img_np, str):
752
- s = rendered_img_np.strip()
753
- # 1) ruta local descargada por gradio_client
754
- try:
755
- p = Path(s)
756
- if p.exists():
757
- img_to_save = Image.open(str(p))
758
- else:
759
- # 2) intentar descargar si es URL
760
- if s.startswith("http://") or s.startswith("https://") or "/gradio_api/file=" in s:
761
- try:
762
- from urllib.request import urlopen as _urlopen
763
-
764
- with _urlopen(s) as resp:
765
- data = resp.read()
766
- img_to_save = Image.open(io.BytesIO(data))
767
- except Exception:
768
- # 3) fallback: intentar decodificar base64
769
- try:
770
- b = _base64.b64decode(s)
771
- img_to_save = Image.open(io.BytesIO(b))
772
- except Exception:
773
- raise RuntimeError("Unsupported rendered image string from Gradio Space")
774
- else:
775
- # 3) fallback: intentar decodificar base64
776
- try:
777
- b = _base64.b64decode(s)
778
- img_to_save = Image.open(io.BytesIO(b))
779
- except Exception:
780
- raise RuntimeError("Unsupported rendered image string from Gradio Space")
781
- except Exception:
782
- raise RuntimeError("Unsupported rendered image string from Gradio Space")
783
- else:
784
- # intentar convertir bytes -> Image
785
- try:
786
- img_to_save = Image.open(io.BytesIO(rendered_img_np))
787
- except Exception:
788
- raise RuntimeError("Unsupported rendered image type from Gradio Space")
789
-
790
- img_to_save.convert("RGB").save(str(out_path), format="JPEG", quality=UPLOAD_JPEG_QUALITY, optimize=True)
791
- except Exception:
792
- logger.exception("Failed to save rendered image from Gradio Space")
793
- raise
794
-
795
- try:
796
- out_label_path = masks_dir / f"{Path(out_filename).stem}_labels.png"
797
- if label_path.exists():
798
- shutil.copyfile(str(label_path), str(out_label_path))
799
- except Exception:
800
- logger.exception("Failed to copy label map for remote output image")
801
-
802
- return {
803
- "message": "Texture applied successfully (remote)",
804
- "original": safe_name,
805
- "mask_indices": payload.mask_indices,
806
- "texture_name": payload.texture_name,
807
- "material": material,
808
- "direction_mode": direction_mode,
809
- "surface_type": None,
810
- "replace_mode": replace_mode,
811
- "replace_strength": None,
812
- "lighting_mode": lighting_mode,
813
- "light_angle_degrees": None,
814
- "light_intensity": None,
815
- "blend_alpha": None,
816
- "applied_angle_degrees": None,
817
- "output_filename": out_filename,
818
- "output_url": f"/seg/image/{out_filename}",
819
- }
820
- except HTTPException:
821
- raise
822
- except Exception:
823
- logger.exception("Error during remote render integration (continuing local flow)")
824
-
825
- surface_type, inferred_angle, blend_alpha, target_w = infer_surface_type_and_direction(
826
- binary_mask, width, height, payload.texture_name,
827
- )
828
- replace_strength, light_angle_degrees, light_intensity = choose_auto_texture_settings(material, surface_type)
829
- effective_alpha = float(np.clip(blend_alpha * (0.55 + (0.75 * replace_strength)), 0.0, 0.98))
830
- if material == "acm":
831
- effective_alpha = float(max(effective_alpha, 0.92))
832
-
833
- applied_angle = 0.0
834
- if direction_mode == "auto":
835
- applied_angle = inferred_angle
836
- elif direction_mode == "manual":
837
- applied_angle = float(payload.angle_degrees)
838
-
839
- tex_pil = load_texture_pil_rgb(texture_path)
840
-
841
- # Escalar al tamaño de tile deseado ANTES de rotar
842
- tex_w, tex_h = tex_pil.size
843
- scale = target_w / max(1, tex_w)
844
- if abs(scale - 1.0) > 0.05:
845
- tex_pil = tex_pil.resize(
846
- (max(1, int(tex_w * scale)), max(1, int(tex_h * scale))),
847
- Image.Resampling.LANCZOS,
848
- )
849
- tex_w, tex_h = tex_pil.size
850
-
851
- tiled: Image.Image | None = None
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