From d3c83053f235de1bad8e8e66c6e8f3eae25268cd Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 30 May 2026 17:28:48 +0200 Subject: [PATCH] chore: auto-commit (5 archivos) - CMakeLists.txt - app.md - appicon.ico - backend/ - main.cpp Co-Authored-By: Claude Opus 4.7 (1M context) --- CMakeLists.txt | 23 + app.md | 71 +++ appicon.ico | Bin 0 -> 7759 bytes backend/__pycache__/server.cpython-313.pyc | Bin 0 -> 8595 bytes backend/backends/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 191 bytes .../__pycache__/triposr.cpython-313.pyc | Bin 0 -> 5142 bytes backend/backends/_iface.py | 17 + backend/backends/hunyuan3d_2.py | 23 + backend/backends/trellis.py | 23 + backend/backends/triposr.py | 93 +++ backend/run.sh | 20 + backend/server.py | 201 +++++++ main.cpp | 549 ++++++++++++++++++ 14 files changed, 1020 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 app.md create mode 100644 appicon.ico create mode 100644 backend/__pycache__/server.cpython-313.pyc create mode 100644 backend/backends/__init__.py create mode 100644 backend/backends/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/backends/__pycache__/triposr.cpython-313.pyc create mode 100644 backend/backends/_iface.py create mode 100644 backend/backends/hunyuan3d_2.py create mode 100644 backend/backends/trellis.py create mode 100644 backend/backends/triposr.py create mode 100755 backend/run.sh create mode 100644 backend/server.py create mode 100644 main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..47f944d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,23 @@ +add_imgui_app(image_to_3d_studio + main.cpp + # Funciones del registry usadas (issue 0085 — declaracion explicita en CMake): + ${CMAKE_SOURCE_DIR}/functions/core/http_request.cpp + ${CMAKE_SOURCE_DIR}/functions/gfx/gl_texture_load.cpp + # Implementacion stb_image (gl_texture_load la usa): + ${CMAKE_SOURCE_DIR}/vendor/stb/stb_image_impl.cpp + # Visor 3D: GLB loader + mesh GPU + orbit camera + FBO + mesh_viewer. + # (gl_loader viene bundled en fn_framework; el resto se enlaza aqui.) + ${CMAKE_SOURCE_DIR}/functions/gfx/gltf_load_mesh.cpp + ${CMAKE_SOURCE_DIR}/functions/gfx/mesh_gpu.cpp + ${CMAKE_SOURCE_DIR}/functions/gfx/gl_framebuffer.cpp + ${CMAKE_SOURCE_DIR}/functions/core/orbit_camera.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/mesh_viewer.cpp +) +target_include_directories(image_to_3d_studio PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/vendor # nlohmann/json.hpp para gltf_load_mesh +) + +if(WIN32) + set_target_properties(image_to_3d_studio PROPERTIES WIN32_EXECUTABLE TRUE) +endif() diff --git a/app.md b/app.md new file mode 100644 index 0000000..f719d95 --- /dev/null +++ b/app.md @@ -0,0 +1,71 @@ +--- +name: image_to_3d_studio +lang: cpp +domain: gfx +description: "Image to 3D studio: imagen origen + POST a backend Python (TripoSR/Hunyuan3D/Trellis) y guarda mesh GLB." +tags: [imagegen, 3d, mesh, viewer, imgui] +icon: + phosphor: "cube-transparent" + accent: "#0ea5e9" +uses_functions: + - http_request_cpp_core # POST /generate al backend Python + - gl_texture_load_cpp_gfx # preview de la imagen origen en panel Input + - gltf_load_mesh_cpp_gfx # parse GLB -> Mesh CPU para el viewer 3D + - mesh_gpu_cpp_gfx # sube Mesh a GPU (VAO/VBO/EBO) + - mesh_viewer_cpp_viz # render orbit + FBO depth del mesh + - orbit_camera_cpp_core # camara orbital (drag rotar / wheel zoom) + - gl_framebuffer_cpp_gfx # FBO con depth para el render offscreen +uses_types: [] +framework: "imgui" +entry_point: "main.cpp" +dir_path: "projects/imagegen/apps/image_to_3d_studio" +repo_url: "https://gitea.organic-machine.com/dataforge/image_to_3d_studio" +--- + +# image_to_3d_studio + +UI ImGui que envia una imagen al backend Python (FastAPI) y guarda el +mesh GLB resultante en `local_files/cache/`. Despachador soporta varios +modelos image-to-3D detras del mismo endpoint: + +- **TripoSR** (MIT, ~0.5 s, ~6 GB VRAM) — backend listo. +- **Hunyuan3D-2** (Tencent Community, mejor textura) — stub. +- **Trellis** (Microsoft MIT, mesh/3DGS/NeRF) — stub. + +Viewer GLB integrado: panel **Viewer 3D** carga el mesh con `gltf_load_mesh`, +lo sube a GPU y lo renderiza con `mesh_viewer` (orbit camera + FBO con depth). +Drag = rotar, rueda = zoom, "Reset cam" recentra, checkbox wireframe. Auto-carga +el mesh al terminar Generate; tambien boton "View 3D" en el panel Output. + +## Arquitectura + +``` +image_to_3d_studio (C++ ImGui) + ── multipart POST /generate ──▶ backend FastAPI 127.0.0.1:8600 + dispatcher → backends/.py + ◀── bytes GLB ── + guarda en /local_files/cache/_.glb +``` + +## Build + +```bash +cd cpp && cmake --build build --target image_to_3d_studio -j +``` + +## Run + +```bash +# 1) backend Python (otra terminal) +projects/imagegen/apps/image_to_3d_studio/backend/run.sh + +# 2) app C++ +./cpp/build/image_to_3d_studio +``` + +## Estado + +- Notebook `projects/imagegen/analysis/spike_image_to_3d/notebooks/01_panorama_image_to_3d.ipynb` — panorama + tabla comparativa. +- Notebook `02_smoke_triposr.ipynb` — smoke end-to-end. +- Backend `backend/server.py` — dispatcher, TripoSR funcional, Hunyuan3D-2 + Trellis = stubs `501 Not Implemented`. +- App C++ — Input/Models/Output paneles + POST + cache. Sin viewer GLB todavia. diff --git a/appicon.ico b/appicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..75d3224757b39eb2da05ed66f786d385631e93db GIT binary patch literal 7759 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 Handle + donde Handle tiene metodo infer(image: PIL.Image, cfg: dict) -> bytes (GLB). + +`cfg` recibe: seed, mc_resolution, foreground_ratio, texture. +""" +from __future__ import annotations + +from typing import Protocol, Any, Dict +from PIL import Image + + +class BackendHandle(Protocol): + def infer(self, image: Image.Image, cfg: Dict[str, Any]) -> bytes: ... + def close(self) -> None: ... diff --git a/backend/backends/hunyuan3d_2.py b/backend/backends/hunyuan3d_2.py new file mode 100644 index 0000000..fb24a77 --- /dev/null +++ b/backend/backends/hunyuan3d_2.py @@ -0,0 +1,23 @@ +"""Backend Hunyuan3D-2 (Tencent, Community License). + +STUB. Para implementar: clonar github.com/Tencent/Hunyuan3D-2 a sources/, +instalar deps, cargar pipeline shape + texture. +""" +from __future__ import annotations + +from typing import Any, Dict +from PIL import Image + + +class Handle: + def infer(self, image: Image.Image, cfg: Dict[str, Any]) -> bytes: + raise NotImplementedError( + "hunyuan3d-2 backend pendiente. Ver notebook 03_smoke_hunyuan3d.ipynb" + ) + + def close(self) -> None: # pragma: no cover + return + + +def load() -> Handle: + return Handle() diff --git a/backend/backends/trellis.py b/backend/backends/trellis.py new file mode 100644 index 0000000..9160448 --- /dev/null +++ b/backend/backends/trellis.py @@ -0,0 +1,23 @@ +"""Backend Trellis (Microsoft, MIT code). + +STUB. Para implementar: clonar github.com/microsoft/TRELLIS a sources/, +instalar deps (kaolin + custom CUDA), cargar pipeline structured latents. +""" +from __future__ import annotations + +from typing import Any, Dict +from PIL import Image + + +class Handle: + def infer(self, image: Image.Image, cfg: Dict[str, Any]) -> bytes: + raise NotImplementedError( + "trellis backend pendiente. Ver notebook 04_smoke_trellis.ipynb" + ) + + def close(self) -> None: # pragma: no cover + return + + +def load() -> Handle: + return Handle() diff --git a/backend/backends/triposr.py b/backend/backends/triposr.py new file mode 100644 index 0000000..2478c34 --- /dev/null +++ b/backend/backends/triposr.py @@ -0,0 +1,93 @@ +"""Backend TripoSR (Stability + Tripo, MIT). + +Asume que `sources/TripoSR` esta clonado en el registry. Importa `tsr.system.TSR`. +Descarga checkpoint desde HF en la primera carga (~1.2 GB). +""" +from __future__ import annotations + +import io +import os +import pathlib +import sys +from dataclasses import dataclass +from typing import Any, Dict + +import numpy as np +import torch +import trimesh +from PIL import Image + + +def _ensure_sources_on_path() -> pathlib.Path: + root = pathlib.Path(os.environ.get("FN_REGISTRY_ROOT", "/home/lucas/fn_registry")) + src = root / "sources" / "TripoSR" + if not src.exists(): + raise RuntimeError( + f"TripoSR no clonado en {src}. " + "git clone --depth=1 https://github.com/VAST-AI-Research/TripoSR.git " + f"{src}" + ) + if str(src) not in sys.path: + sys.path.insert(0, str(src)) + return src + + +@dataclass +class Handle: + model: Any + rembg_session: Any + device: str + + def infer(self, image: Image.Image, cfg: Dict[str, Any]) -> bytes: + from tsr.utils import remove_background, resize_foreground + + fg_ratio = float(cfg.get("foreground_ratio", 0.85)) + mc_res = int(cfg.get("mc_resolution", 256)) + + fg = remove_background(image, self.rembg_session) + fg = resize_foreground(fg, fg_ratio) + + # Composite RGBA -> RGB sobre gris 0.5 (preprocesado canonico TripoSR + # run.py). Sin esto el tokenizer DINO recibe 4 canales y peta: + # "The size of tensor a (4) must match tensor b (3) at dim 2". + arr = np.asarray(fg).astype(np.float32) / 255.0 + if arr.shape[-1] == 4: + arr = arr[:, :, :3] * arr[:, :, 3:4] + (1.0 - arr[:, :, 3:4]) * 0.5 + fg = Image.fromarray((arr * 255.0).astype(np.uint8)) + + with torch.no_grad(): + scene_codes = self.model([fg], device=self.device) + meshes = self.model.extract_mesh( + scene_codes, has_vertex_color=False, resolution=mc_res + ) + m = meshes[0] + tm = trimesh.Trimesh( + vertices=np.asarray(m.vertices), + faces=np.asarray(m.faces), + process=True, + ) + buf = io.BytesIO() + tm.export(buf, file_type="glb") + return buf.getvalue() + + def close(self) -> None: + del self.model + del self.rembg_session + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + +def load() -> Handle: + _ensure_sources_on_path() + from tsr.system import TSR + from rembg import new_session + + device = "cuda" if torch.cuda.is_available() else "cpu" + model = TSR.from_pretrained( + "stabilityai/TripoSR", + config_name="config.yaml", + weight_name="model.ckpt", + ) + model.renderer.set_chunk_size(8192) + model.to(device) + return Handle(model=model, rembg_session=new_session(), device=device) diff --git a/backend/run.sh b/backend/run.sh new file mode 100755 index 0000000..b40a9dc --- /dev/null +++ b/backend/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Lanza el backend FastAPI reutilizando el venv del analysis (mismas deps: +# torch + diffusers + transformers + trimesh + Pillow). No crea venv propio. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")"/../../../../.. && pwd)" +VENV="$ROOT/projects/imagegen/analysis/spike_image_to_3d/.venv" +HERE="$(cd "$(dirname "$0")" && pwd)" + +if [ ! -x "$VENV/bin/python" ]; then + echo "venv del analysis no existe: $VENV" >&2 + echo "Crea el analysis primero: ./fn run init_jupyter_analysis --project imagegen spike_image_to_3d" >&2 + exit 1 +fi + +export FN_REGISTRY_ROOT="$ROOT" +exec "$VENV/bin/python" -m uvicorn server:app \ + --host 127.0.0.1 --port "${PORT:-8600}" \ + --app-dir "$HERE" \ + "$@" diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..173c81c --- /dev/null +++ b/backend/server.py @@ -0,0 +1,201 @@ +"""FastAPI dispatcher para image-to-3D. + +Endpoints: + GET /health -> {"status":"ok","models":[...loaded...]} + GET /models -> {"models":[{"id","loaded","vram_gb_est","license"}...]} + POST /generate -> bytes GLB (Content-Type: model/gltf-binary) + multipart/form-data: + file= + model= (triposr | hunyuan3d-2 | trellis) + seed= (opcional, default 0) + mc_resolution= (opcional, default 256) + foreground_ratio= (opcional, default 0.85) + texture= (opcional, default true) + +Implementacion: + - Cada backend (triposr/hunyuan3d-2/trellis) vive en backends/.py + con interfaz comun: load() -> handle, infer(handle, image_pil, cfg) -> bytes_glb. + - Lazy load: el primer POST con un model_id carga el modelo en GPU y lo + guarda en un dict global. Liberacion manual via DELETE /models/. + - Single-process, single-GPU: serializamos peticiones por modelo con un + asyncio.Lock para no chocar dos infer simultaneos en la misma GPU. + +Lanzar: + cd projects/imagegen/apps/image_to_3d_studio/backend + ../../../analysis/spike_image_to_3d/.venv/bin/python -m uvicorn server:app \\ + --host 127.0.0.1 --port 8600 --reload +""" +from __future__ import annotations + +import asyncio +import io +import os +import sys +import time +from dataclasses import dataclass +from typing import Any, Dict + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.responses import Response +from PIL import Image + + +# --- Catalogo de modelos ------------------------------------------------------ + +@dataclass(frozen=True) +class ModelSpec: + id: str + label: str + license: str + vram_gb_est: float + backend_module: str # backends. + + +CATALOG: Dict[str, ModelSpec] = { + "triposr": ModelSpec( + id="triposr", + label="TripoSR (Stability + Tripo, MIT)", + license="MIT", + vram_gb_est=6.0, + backend_module="backends.triposr", + ), + "hunyuan3d-2": ModelSpec( + id="hunyuan3d-2", + label="Hunyuan3D-2 (Tencent, Community License)", + license="Tencent Community", + vram_gb_est=12.0, + backend_module="backends.hunyuan3d_2", + ), + "trellis": ModelSpec( + id="trellis", + label="Trellis (Microsoft, MIT code / research weights)", + license="MIT (code)", + vram_gb_est=16.0, + backend_module="backends.trellis", + ), +} + + +# --- Estado en memoria -------------------------------------------------------- + +_loaded: Dict[str, Any] = {} # model_id -> handle del backend +_locks: Dict[str, asyncio.Lock] = {} # model_id -> lock (uno por modelo) + + +def _lock_for(model_id: str) -> asyncio.Lock: + lk = _locks.get(model_id) + if lk is None: + lk = asyncio.Lock() + _locks[model_id] = lk + return lk + + +def _load_backend(spec: ModelSpec) -> Any: + """Importa el modulo del backend on-demand y llama load(). Cada backend + debe exponer load() -> handle e infer(handle, image, cfg) -> bytes.""" + # Garantiza que `backends/` esta en sys.path + here = os.path.dirname(os.path.abspath(__file__)) + if here not in sys.path: + sys.path.insert(0, here) + import importlib + mod = importlib.import_module(spec.backend_module) + return mod.load() + + +# --- FastAPI app -------------------------------------------------------------- + +app = FastAPI(title="image_to_3d_studio backend", version="0.1.0") + + +@app.get("/health") +def health() -> Dict[str, Any]: + return { + "status": "ok", + "loaded": sorted(_loaded.keys()), + "version": "0.1.0", + } + + +@app.get("/models") +def list_models() -> Dict[str, Any]: + return { + "models": [ + { + "id": m.id, + "label": m.label, + "license": m.license, + "vram_gb_est": m.vram_gb_est, + "loaded": m.id in _loaded, + } + for m in CATALOG.values() + ] + } + + +@app.delete("/models/{model_id}") +def unload(model_id: str) -> Dict[str, Any]: + handle = _loaded.pop(model_id, None) + if handle is None: + raise HTTPException(404, f"not loaded: {model_id}") + # Si el backend expone close(), lo llamamos + close = getattr(handle, "close", None) + if callable(close): + close() + # Empuja VRAM libre + try: + import torch + torch.cuda.empty_cache() + except Exception: + pass + return {"unloaded": model_id} + + +@app.post("/generate") +async def generate( + file: UploadFile = File(...), + model: str = Form("triposr"), + seed: int = Form(0), + mc_resolution: int = Form(256), + foreground_ratio: float = Form(0.85), + texture: bool = Form(True), +) -> Response: + spec = CATALOG.get(model) + if spec is None: + raise HTTPException(400, f"unknown model: {model} (catalog: {list(CATALOG)})") + + raw = await file.read() + try: + image = Image.open(io.BytesIO(raw)).convert("RGB") + except Exception as e: + raise HTTPException(400, f"bad image: {e}") + + cfg = dict( + seed=seed, + mc_resolution=mc_resolution, + foreground_ratio=foreground_ratio, + texture=texture, + ) + + lock = _lock_for(model) + async with lock: + if model not in _loaded: + _loaded[model] = await asyncio.to_thread(_load_backend, spec) + handle = _loaded[model] + t0 = time.perf_counter() + try: + glb_bytes = await asyncio.to_thread(handle.infer, image, cfg) + except NotImplementedError as e: + raise HTTPException(501, str(e)) + except Exception as e: + raise HTTPException(500, f"{model} infer failed: {e}") + dt_ms = int((time.perf_counter() - t0) * 1000) + + return Response( + content=glb_bytes, + media_type="model/gltf-binary", + headers={ + "X-Model": model, + "X-Duration-ms": str(dt_ms), + "X-Bytes": str(len(glb_bytes)), + }, + ) diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..25256bc --- /dev/null +++ b/main.cpp @@ -0,0 +1,549 @@ +// image_to_3d_studio — UI ImGui para single-image-to-3D. +// +// Carga una imagen (path en text field), envia POST multipart/form-data al +// backend Python (FastAPI en 127.0.0.1:8600), recibe bytes GLB, los guarda +// en local_files/cache/.glb y reporta path al usuario. +// +// Viewer GLB integrado: panel "Viewer 3D" carga el mesh GLB con +// gltf_load_mesh, lo sube a GPU (mesh_gpu) y lo renderiza con mesh_viewer +// (orbit camera + FBO + depth). Drag = rotar, rueda = zoom. +// +// Backend levantarlo aparte: +// cd projects/imagegen/apps/image_to_3d_studio/backend && ./run.sh +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app_base.h" +#include "core/http_request.h" +#include "core/icons_tabler.h" +#include "core/logger.h" +#include "core/orbit_camera.h" +#include "core/panel_menu.h" +#include "gfx/gl_texture_load.h" +#include "gfx/gltf_load_mesh.h" +#include "gfx/mesh_gpu.h" +#include "viz/mesh_viewer.h" + +namespace { + +// ── Estado UI ────────────────────────────────────────────────────────────── +bool g_show_input = true; +bool g_show_models = true; +bool g_show_output = true; +bool g_show_viewer = true; + +// Viewer 3D +fn::gfx::MeshGpu g_mesh_gpu{}; +fn::core::OrbitCamera g_cam{}; +std::string g_viewer_path; // GLB cargado actualmente en el viewer +std::string g_viewer_err; // error de carga +int g_viewer_tris = 0; +bool g_viewer_wireframe = false; +// Path pendiente de cargar en el viewer (lo setea el worker/boton; lo consume +// render() en el main thread porque las llamadas GL necesitan contexto activo). +std::mutex g_viewer_load_mu; +std::string g_viewer_pending_path; + +// Input +char g_image_path[1024] = ""; +fn::GlTexture g_preview_tex{}; +int g_preview_w = 0, g_preview_h = 0; +std::string g_preview_err; + +// Backend +char g_backend_url[256] = "http://127.0.0.1:8600"; + +// Modelo + params +int g_model_idx = 0; +int g_seed = 0; +int g_mc_resolution = 256; +float g_foreground_ratio = 0.85f; +bool g_texture = true; + +const char* const MODEL_IDS[] = { + "triposr", + "hunyuan3d-2", + "trellis", +}; +const char* const MODEL_LABELS[] = { + "TripoSR (MIT, ~6 GB)", + "Hunyuan3D-2 (Tencent, ~12 GB)", + "Trellis (MIT code, ~16 GB)", +}; +constexpr int MODEL_COUNT = (int)(sizeof(MODEL_IDS) / sizeof(MODEL_IDS[0])); + +// Output / job state +enum class JobState { Idle, Running, Done, Failed }; +std::atomic g_state{JobState::Idle}; +std::mutex g_result_mu; +std::string g_result_path; // GLB en disco (en Done) +std::string g_result_err; // mensaje (en Failed) +int g_result_status_http = 0; +int64_t g_result_duration_ms = 0; +size_t g_result_bytes = 0; + +// Backend health +std::atomic g_health_pinged{false}; +std::string g_health_text; +std::mutex g_health_mu; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +std::vector read_file_bytes(const std::string& path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + auto sz = f.tellg(); + f.seekg(0); + std::vector out((size_t)sz); + f.read((char*)out.data(), sz); + return out; +} + +std::string basename_of(const std::string& path) { + auto p = path.find_last_of("/\\"); + return p == std::string::npos ? path : path.substr(p + 1); +} + +// Heuristica simple por extension. +const char* mime_for(const std::string& path) { + auto dot = path.find_last_of('.'); + if (dot == std::string::npos) return "application/octet-stream"; + std::string ext = path.substr(dot + 1); + for (auto& c : ext) c = (char)tolower((unsigned char)c); + if (ext == "png") return "image/png"; + if (ext == "jpg" || ext == "jpeg") return "image/jpeg"; + if (ext == "webp") return "image/webp"; + if (ext == "bmp") return "image/bmp"; + return "application/octet-stream"; +} + +std::string random_boundary() { + static std::mt19937_64 rng{std::random_device{}()}; + std::ostringstream o; + o << "----fn_boundary_" << std::hex << rng() << rng(); + return o.str(); +} + +// Construye body multipart/form-data a mano. fn_http::Request acepta string, +// no vector — pero string puede contener bytes binarios (data()+size()). +std::string build_multipart( + const std::string& boundary, + const std::vector>& fields, // name -> value (texto) + const std::string& file_field, + const std::string& file_name, + const std::string& file_mime, + const std::vector& file_bytes) +{ + std::string body; + body.reserve(file_bytes.size() + 1024); + + auto add = [&](const std::string& s) { body.append(s); }; + + for (const auto& [name, value] : fields) { + add("--"); add(boundary); add("\r\n"); + add("Content-Disposition: form-data; name=\""); add(name); add("\"\r\n\r\n"); + add(value); add("\r\n"); + } + + add("--"); add(boundary); add("\r\n"); + add("Content-Disposition: form-data; name=\""); add(file_field); + add("\"; filename=\""); add(file_name); add("\"\r\n"); + add("Content-Type: "); add(file_mime); add("\r\n\r\n"); + body.append((const char*)file_bytes.data(), file_bytes.size()); + add("\r\n"); + + add("--"); add(boundary); add("--\r\n"); + return body; +} + +// Hash rapido FNV-1a de bytes — para nombrar el GLB en cache sin colision practica. +std::string fnv1a_hex(const uint8_t* data, size_t n) { + uint64_t h = 1469598103934665603ull; + for (size_t i = 0; i < n; ++i) { + h ^= data[i]; + h *= 1099511628211ull; + } + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", (unsigned long long)h); + return buf; +} + +// Normaliza un Mesh in-place: recentra el centroide del bounding box al origen +// y escala para encajar en una esfera de radio ~1. Asi la orbit camera (target +// fijo en 0,0,0, distance ~3) enmarca cualquier mesh sin importar su escala. +void normalize_mesh(fn::gfx::Mesh& m) { + if (m.positions.size() < 3) return; + float mn[3] = { m.positions[0], m.positions[1], m.positions[2] }; + float mx[3] = { m.positions[0], m.positions[1], m.positions[2] }; + for (size_t i = 0; i < m.positions.size(); i += 3) { + for (int k = 0; k < 3; ++k) { + float v = m.positions[i + k]; + if (v < mn[k]) mn[k] = v; + if (v > mx[k]) mx[k] = v; + } + } + float cx = 0.5f * (mn[0] + mx[0]); + float cy = 0.5f * (mn[1] + mx[1]); + float cz = 0.5f * (mn[2] + mx[2]); + float ext = 0.0f; + for (int k = 0; k < 3; ++k) ext = std::max(ext, mx[k] - mn[k]); + float scale = (ext > 1e-6f) ? (2.0f / ext) : 1.0f; // diametro -> ~2 + for (size_t i = 0; i < m.positions.size(); i += 3) { + m.positions[i + 0] = (m.positions[i + 0] - cx) * scale; + m.positions[i + 1] = (m.positions[i + 1] - cy) * scale; + m.positions[i + 2] = (m.positions[i + 2] - cz) * scale; + } +} + +// Carga un GLB del disco en el viewer 3D. DEBE llamarse desde el main thread +// (las llamadas GL de mesh_gpu_upload requieren contexto activo). +void load_mesh_into_viewer(const std::string& path) { + g_viewer_err.clear(); + fn::gfx::Mesh m = fn::gfx::gltf_load_mesh_from_file(path.c_str()); + if (m.positions.empty()) { + g_viewer_err = std::string("GLB load failed (") + + fn::gfx::gltf_load_last_error() + "): " + path; + fn_log::log_error(g_viewer_err.c_str()); + return; + } + normalize_mesh(m); + if (g_mesh_gpu.ok()) fn::gfx::mesh_gpu_destroy(g_mesh_gpu); + g_mesh_gpu = fn::gfx::mesh_gpu_upload(m); + if (!g_mesh_gpu.ok()) { + g_viewer_err = "mesh_gpu_upload failed (contexto GL?): " + path; + fn_log::log_error(g_viewer_err.c_str()); + return; + } + g_viewer_tris = g_mesh_gpu.index_count / 3; + g_viewer_path = path; + g_cam = fn::core::OrbitCamera{}; // reset camara al cargar mesh nuevo + g_show_viewer = true; + fn_log::log_info(("viewer loaded " + path + " tris=" + + std::to_string(g_viewer_tris)).c_str()); +} + +// Encola un path para cargar en el viewer en el proximo frame del main thread. +void request_view(const std::string& path) { + std::lock_guard lk(g_viewer_load_mu); + g_viewer_pending_path = path; +} + +// ── Acciones ─────────────────────────────────────────────────────────────── + +void reload_preview() { + g_preview_err.clear(); + if (g_preview_tex.id) { fn::gl_texture_destroy(g_preview_tex); g_preview_tex = {}; } + g_preview_w = g_preview_h = 0; + + std::string path = g_image_path; + if (path.empty()) return; + + fn::GlTexture t = fn::gl_texture_load(path.c_str(), /*flip_y=*/false, /*srgb=*/true); + if (!t.id) { + g_preview_err = std::string("no se pudo cargar imagen (") + + fn::gl_texture_last_error() + "): " + path; + fn_log::log_error(g_preview_err.c_str()); + return; + } + g_preview_tex = t; + g_preview_w = t.w; + g_preview_h = t.h; + fn_log::log_info(("preview ok " + path + " " + + std::to_string(t.w) + "x" + std::to_string(t.h)).c_str()); +} + +void ping_backend() { + g_health_pinged = false; + std::thread([url = std::string(g_backend_url)]() { + fn_http::Request req; + req.method = "GET"; + req.url = url + "/health"; + req.timeout_ms = 2000; + auto res = fn_http::request(req); + std::lock_guard lk(g_health_mu); + if (!res.error.empty()) { + g_health_text = "ERR: " + res.error; + } else if (res.status / 100 != 2) { + g_health_text = "HTTP " + std::to_string(res.status) + ": " + res.body; + } else { + g_health_text = std::to_string((long long)res.duration_ms) + + " ms — " + res.body; + } + g_health_pinged = true; + }).detach(); +} + +void start_generate() { + if (g_state.load() == JobState::Running) return; + std::string path = g_image_path; + if (path.empty()) { + std::lock_guard lk(g_result_mu); + g_result_err = "image_path vacio"; + g_state = JobState::Failed; + return; + } + g_state = JobState::Running; + + std::thread([ + path, url = std::string(g_backend_url), + model = std::string(MODEL_IDS[g_model_idx]), + seed = g_seed, mc = g_mc_resolution, + fg = g_foreground_ratio, tex = g_texture + ]() { + auto image_bytes = read_file_bytes(path); + if (image_bytes.empty()) { + std::lock_guard lk(g_result_mu); + g_result_err = "no se pudo leer imagen: " + path; + g_state = JobState::Failed; + return; + } + + std::string boundary = random_boundary(); + std::string body = build_multipart( + boundary, + { + {"model", model}, + {"seed", std::to_string(seed)}, + {"mc_resolution", std::to_string(mc)}, + {"foreground_ratio", std::to_string(fg)}, + {"texture", tex ? "true" : "false"}, + }, + "file", basename_of(path), mime_for(path), image_bytes + ); + + fn_http::Request req; + req.method = "POST"; + req.url = url + "/generate"; + req.headers = {{"Content-Type", "multipart/form-data; boundary=" + boundary}}; + req.body = std::move(body); + req.timeout_ms = 5 * 60 * 1000; // 5 min — modelos grandes son lentos + + auto res = fn_http::request(req); + + std::lock_guard lk(g_result_mu); + g_result_status_http = res.status; + g_result_duration_ms = res.duration_ms; + + if (!res.error.empty()) { + g_result_err = "transport: " + res.error; + g_state = JobState::Failed; + return; + } + if (res.status / 100 != 2) { + g_result_err = "HTTP " + std::to_string(res.status) + ": " + res.body; + g_state = JobState::Failed; + return; + } + + // Guardar GLB en local_files/cache/.glb + std::string cache_dir = fn::local_path("cache"); + std::error_code ec; + std::filesystem::create_directories(cache_dir, ec); + std::string hash = fnv1a_hex( + (const uint8_t*)res.body.data(), + res.body.size() < 4096 ? res.body.size() : 4096); + std::string out_path = cache_dir + "/" + model + "_" + hash + ".glb"; + + std::ofstream f(out_path, std::ios::binary); + f.write(res.body.data(), (std::streamsize)res.body.size()); + f.close(); + + g_result_path = out_path; + g_result_bytes = res.body.size(); + g_result_err.clear(); + g_state = JobState::Done; + // Encola la carga en el viewer; la hace el main thread (GL context). + request_view(out_path); + fn_log::log_info(("generated " + out_path + " (" + + std::to_string(res.body.size()) + " bytes, " + + std::to_string((long long)res.duration_ms) + " ms)").c_str()); + }).detach(); +} + +// ── Paneles ──────────────────────────────────────────────────────────────── + +void draw_input() { + if (!ImGui::Begin(TI_PHOTO " Input", &g_show_input)) { ImGui::End(); return; } + + ImGui::TextUnformatted("Imagen origen"); + ImGui::PushItemWidth(-100); + bool changed = ImGui::InputText("##path", g_image_path, sizeof(g_image_path), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::PopItemWidth(); + ImGui::SameLine(); + if (ImGui::Button(TI_REFRESH " Load") || changed) { + reload_preview(); + } + + ImGui::TextDisabled("(arrastra path o pega aqui — PNG/JPG/WEBP)"); + + if (!g_preview_err.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.4f, 0.4f, 1)); + ImGui::TextWrapped("%s", g_preview_err.c_str()); + ImGui::PopStyleColor(); + } + + if (g_preview_tex.id) { + ImGui::Separator(); + ImGui::Text("%dx%d", g_preview_w, g_preview_h); + float avail = ImGui::GetContentRegionAvail().x; + float scale = avail / (float)g_preview_w; + if (scale > 1.0f) scale = 1.0f; + ImGui::Image((ImTextureID)(intptr_t)g_preview_tex.id, + ImVec2(g_preview_w * scale, g_preview_h * scale)); + } + ImGui::End(); +} + +void draw_models() { + if (!ImGui::Begin(TI_CPU " Models", &g_show_models)) { ImGui::End(); return; } + + ImGui::Combo("modelo", &g_model_idx, MODEL_LABELS, MODEL_COUNT); + ImGui::Separator(); + ImGui::InputInt("seed", &g_seed); + ImGui::SliderInt("mc_resolution", &g_mc_resolution, 64, 512); + ImGui::SliderFloat("foreground_ratio", &g_foreground_ratio, 0.5f, 1.0f); + ImGui::Checkbox("texture (Hunyuan3D)", &g_texture); + + ImGui::Separator(); + ImGui::PushItemWidth(-100); + ImGui::InputText("backend_url", g_backend_url, sizeof(g_backend_url)); + ImGui::PopItemWidth(); + if (ImGui::Button(TI_HEART_RATE_MONITOR " Ping /health")) ping_backend(); + if (g_health_pinged.load()) { + std::lock_guard lk(g_health_mu); + ImGui::SameLine(); + ImGui::TextDisabled("%s", g_health_text.c_str()); + } + + ImGui::Separator(); + bool busy = g_state.load() == JobState::Running; + if (busy) ImGui::BeginDisabled(); + if (ImGui::Button(TI_ROCKET " Generate", ImVec2(-1, 36))) start_generate(); + if (busy) ImGui::EndDisabled(); + + ImGui::End(); +} + +void draw_output() { + if (!ImGui::Begin(TI_BOX " Output", &g_show_output)) { ImGui::End(); return; } + + JobState st = g_state.load(); + switch (st) { + case JobState::Idle: ImGui::TextDisabled("idle — pulsa Generate"); break; + case JobState::Running: ImGui::Text("%s generando...", TI_LOADER); break; + case JobState::Done: { + std::lock_guard lk(g_result_mu); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 1, 0.5f, 1)); + ImGui::Text(TI_CIRCLE_CHECK " done"); + ImGui::PopStyleColor(); + ImGui::Text("status: %d", g_result_status_http); + ImGui::Text("duration: %lld ms",(long long)g_result_duration_ms); + ImGui::Text("bytes: %zu", g_result_bytes); + ImGui::Separator(); + ImGui::TextWrapped("path: %s", g_result_path.c_str()); + if (ImGui::Button(TI_COPY " Copy path")) { + ImGui::SetClipboardText(g_result_path.c_str()); + } + ImGui::SameLine(); + if (ImGui::Button(TI_CUBE " View 3D")) { + request_view(g_result_path); + } + break; + } + case JobState::Failed: { + std::lock_guard lk(g_result_mu); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.4f, 0.4f, 1)); + ImGui::Text(TI_CIRCLE_X " failed"); + ImGui::PopStyleColor(); + ImGui::TextWrapped("%s", g_result_err.c_str()); + break; + } + } + ImGui::End(); +} + +void draw_viewer() { + if (!ImGui::Begin(TI_CUBE " Viewer 3D", &g_show_viewer)) { ImGui::End(); return; } + + ImGui::Checkbox("wireframe", &g_viewer_wireframe); + ImGui::SameLine(); + if (ImGui::Button(TI_REFRESH " Reset cam")) g_cam = fn::core::OrbitCamera{}; + ImGui::SameLine(); + ImGui::TextDisabled("drag=rotar rueda=zoom"); + + if (!g_viewer_err.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.4f, 0.4f, 1)); + ImGui::TextWrapped("%s", g_viewer_err.c_str()); + ImGui::PopStyleColor(); + } + + if (g_mesh_gpu.ok()) { + ImGui::Text("tris: %d", g_viewer_tris); + fn::viz::MeshViewerConfig vc{}; + vc.mesh = &g_mesh_gpu; + vc.cam = &g_cam; + vc.size = {-1.0f, -1.0f}; // stretch a todo el panel + vc.wireframe = g_viewer_wireframe; + vc.color = IM_COL32(170, 200, 235, 255); + fn::viz::mesh_viewer("##i23d_mesh", vc); + } else { + ImGui::TextDisabled("sin mesh — genera (Generate) o pulsa View 3D en Output"); + } + ImGui::End(); +} + +void render() { + // Consumir path pendiente en el main thread (GL context activo aqui). + { + std::string pending; + { + std::lock_guard lk(g_viewer_load_mu); + if (!g_viewer_pending_path.empty()) { + pending = g_viewer_pending_path; + g_viewer_pending_path.clear(); + } + } + if (!pending.empty()) load_mesh_into_viewer(pending); + } + + if (g_show_input) draw_input(); + if (g_show_models) draw_models(); + if (g_show_output) draw_output(); + if (g_show_viewer) draw_viewer(); +} + +} // namespace + +int main(int /*argc*/, char** /*argv*/) { + static fn_ui::PanelToggle panels[] = { + { "Input", nullptr, &g_show_input }, + { "Models", nullptr, &g_show_models }, + { "Output", nullptr, &g_show_output }, + }; + + fn::AppConfig cfg; + cfg.title = "image_to_3d_studio — single image to 3D"; + cfg.about = { "image_to_3d_studio", "0.1.0", + "UI ImGui + backend Python (TripoSR / Hunyuan3D-2 / Trellis)." }; + cfg.log = { "image_to_3d_studio.log", 1 }; + cfg.panels = panels; + cfg.panel_count = sizeof(panels) / sizeof(panels[0]); + cfg.init_gl_loader = true; // gl_texture_load llama glGenTextures + glGenerateMipmap + + return fn::run_app(cfg, render); +}