Compare commits

...

17 Commits

Author SHA1 Message Date
d5d207d19c Add metadata collection for replicate library 2026-05-14 10:57:20 +02:00
5a79e84fbb Update PyInstaller spec: Adjust data paths, explicitly list hidden imports for diffusers/transformers, and expand package exclusions 2026-05-14 10:48:26 +02:00
cfb0041286 Update output path variable from args.output to output_abs in generate_equirect.py 2026-05-14 10:47:38 +02:00
7f720033e2 Initialize environment variables and application support paths 2026-05-14 10:46:26 +02:00
4cf5a322ae Exclude GUI libraries (PyQt, PySide, tkinter) from packaging 2026-05-14 10:43:04 +02:00
eafe02200c Initial setup of PyInstaller spec file for 3D Model Generator 2026-05-14 10:43:04 +02:00
abcc993c03 Update gitignore and add icon assets 2026-05-14 10:41:29 +02:00
ba4d6685ec Add application icon file 2026-05-14 10:41:29 +02:00
26a50b1453 Use resource_path for Blender script execution 2026-05-14 10:41:29 +02:00
91fce08509 Update webview paths and startup parameters 2026-05-14 10:41:29 +02:00
7468fce40f Refactor 3D and HDRI generation to use direct function calls instead of subprocess execution 2026-05-14 10:40:29 +02:00
562a9ad2e6 Fix asset path usage and refactor 3D model generation logic 2026-05-14 10:40:08 +02:00
801007fe7f Refactor: Separate core generation logic into a dedicated function 2026-05-14 10:39:43 +02:00
483c0fe9a2 auto-git:
[change] main.py
2026-05-14 10:38:18 +02:00
aaca619640 Update .gitignore to exclude debug directories 2026-05-14 10:25:29 +02:00
877a5fa8d5 Add README documentation for the 3D Model Generator tool 2026-05-14 10:25:29 +02:00
1905638407 Add comprehensive .gitignore file 2026-05-14 10:25:29 +02:00
7 changed files with 505 additions and 96 deletions

78
.gitignore vendored Normal file
View File

@@ -0,0 +1,78 @@
# macOS
.DS_Store
# Python
__pycache__/
*.py[cod]
*.egg
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Virtual environments
.venv/
venv/
env/
# Local configuration and secrets
.env
.env.*
!.env.example
settings.json
secrets.*
*.pem
*.key
*.p12
*.pfx
id_rsa*
id_ed25519*
# App output and caches
web/output/
PyWebview/web/output/
.hf_cache/
*.log
build/
dist/
# Generated media and 3D assets
*.png
*.jpg
*.jpeg
*.webp
*.glb
*.gltf
*.blend
*.hdr
*.exr
*.mp4
*.mov
*.zip
*.tar.gz
!icon.png
!icon.icns
# Local model weights
*.safetensors
*.ckpt
*.pt
*.pth
*.onnx
*.bin
# Node and lockfile leftovers
node_modules/
package-lock.json
npmCargo.lock
*.lock
# Native build artifacts
*.o
*.dylib
*.a
*.so
*.exe
debug

121
3D Model Generator.spec Normal file
View File

@@ -0,0 +1,121 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files
from PyInstaller.utils.hooks import collect_submodules
from PyInstaller.utils.hooks import copy_metadata
datas = [('web/index.html', 'web'), ('icon.png', '.'), ('scene_setup.py', '.')]
hiddenimports = [
'image_to_3d',
'generate_equirect',
'diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion',
'diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion_inpaint',
'diffusers.pipelines.stable_diffusion.pipeline_output',
'diffusers.models.autoencoders.autoencoder_kl',
'diffusers.models.autoencoders.vae',
'diffusers.models.unets.unet_2d_condition',
'diffusers.models.unets.unet_2d_blocks',
'diffusers.schedulers.scheduling_ddim',
'diffusers.schedulers.scheduling_ddpm',
'diffusers.schedulers.scheduling_dpmsolver_multistep',
'diffusers.schedulers.scheduling_euler_discrete',
'diffusers.schedulers.scheduling_lms_discrete',
'diffusers.schedulers.scheduling_pndm',
'transformers.models.auto.configuration_auto',
'transformers.models.auto.feature_extraction_auto',
'transformers.models.auto.image_processing_auto',
'transformers.models.auto.modeling_auto',
'transformers.models.auto.tokenization_auto',
'transformers.models.clip.configuration_clip',
'transformers.models.clip.image_processing_clip',
'transformers.models.clip.modeling_clip',
'transformers.models.clip.processing_clip',
'transformers.models.clip.tokenization_clip',
'transformers.models.clip.tokenization_clip_fast',
'safetensors.torch',
]
datas += collect_data_files('diffusers')
datas += collect_data_files('transformers')
datas += collect_data_files('huggingface_hub')
datas += copy_metadata('replicate')
hiddenimports += collect_submodules(
'diffusers.schedulers',
filter=lambda name: '.scheduling_' in name and '_flax' not in name,
)
a = Analysis(
['main.py'],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
'gi',
'PyQt5',
'PyQt6',
'PySide2',
'PySide6',
'tkinter',
'altair',
'cv2',
'datasets',
'flax',
'jax',
'jaxlib',
'librosa',
'llvmlite',
'matplotlib',
'nltk',
'numba',
'onnx',
'onnxruntime',
'pandas',
'pygame',
'sklearn',
'soundfile',
'spacy',
'tensorflow',
'torchaudio',
'torchvision',
],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='3D Model Generator',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['icon.icns'],
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='3D Model Generator',
)
app = BUNDLE(
coll,
name='3D Model Generator.app',
icon='icon.icns',
bundle_identifier=None,
)

209
README.md Normal file
View File

@@ -0,0 +1,209 @@
# 3D Model Generator
A local desktop tool for turning short object ideas into image prompts, generated concept images, Replicate Hunyuan 3D models, and optional HDRI scene lighting.
The app runs as a `pywebview` desktop window. It uses Ollama for prompt generation, local Diffusers or Replicate for image generation, Replicate for image-to-3D conversion, and Blender for viewing generated `.glb` files.
## Features
- Generate Stable Diffusion-style prompts from a short object name.
- Convert prompts into editable JSON generation settings.
- Generate image batches with either:
- local Diffusers using a local DreamShaper model
- Replicate API using one of three configured image models
- Generate 3D `.glb` models from selected images with `tencent/hunyuan-3d-3.1`.
- Generate an equirectangular HDRI panorama and open the model in Blender.
- Store image metadata in sidecar JSON files so prompts can be reused later.
- Browse generated 2D images and generated 3D models in the built-in gallery.
## Requirements
- macOS
- Python 3.11
- Ollama running locally
- An Ollama model named `mistral:latest`
- Optional but recommended: a Replicate API token
- Optional for local image generation: local Diffusers model files
- Optional for HDRI: Topaz Photo AI CLI and local HDRI Diffusers model files
- Optional for 3D viewing: Blender installed at `/Applications/Blender.app`
The launcher creates a local virtual environment in `.venv/` and installs Python dependencies from `requirements.txt`.
## Quick Start
```bash
./run.sh
```
The script will:
1. Create `.venv/` if it does not exist.
2. Activate the virtual environment.
3. Install dependencies.
4. Run `python main.py`.
If you want to force a specific Python executable:
```bash
PYTHON=/path/to/python3.11 ./run.sh
```
## Ollama Setup
The app expects Ollama at:
```text
http://localhost:11434/api/generate
```
and uses:
```text
mistral:latest
```
Install and run the model with:
```bash
ollama pull mistral
ollama serve
```
## Replicate Setup
Open Settings in the app and paste your Replicate API token, or set it in your shell:
```bash
export REPLICATE_API_TOKEN="r8_..."
./run.sh
```
The token is saved outside the repository at:
```text
~/Library/Application Support/3d-model-generator/settings.json
```
Do not commit this file.
## Image Generation Backends
Settings lets you choose between:
- `Local (Diffusers)`
- `Replicate API`
The generated prompt JSON stays the same for both modes. Replicate mode maps the same positive prompt, negative prompt, dimensions/aspect ratio, steps where supported, and batch count to each model's API.
Configured Replicate image models:
| Model | Use | Listed price |
| --- | --- | --- |
| `black-forest-labs/flux-schnell` | Cheap drafts | $3 / 1000 output images |
| `google/imagen-4-fast` | Balanced default | $0.02 / output image |
| `bytedance/seedream-4` | Higher quality | $0.03 / output image |
Prices are listed by Replicate and may change.
## 3D Generation
Right-click a generated image and choose:
- `Generate 3D Model`
- `Generate 3D Model + HDRI`
3D conversion uses:
```text
tencent/hunyuan-3d-3.1
```
Current listed Replicate price in the app:
```text
$0.16 / unit
```
The script waits up to 10 minutes for Replicate predictions before timing out.
## Local Model Paths
Some local paths are currently hard-coded and should be edited for your machine.
In `main.py`:
```python
MODEL_PATH = "/Volumes/SD/ML-Models/diffusers/dreamshaper_8_diffusers"
```
In `generate_equirect.py`:
```python
model_path = "/Volumes/SD/ML-Models/diffusers/hdri-panorama-v1-diffusers"
topaz_cli = "/Applications/Topaz Photo AI.app/Contents/MacOS/Topaz Photo AI"
```
In `main.py`, external app paths are also hard-coded:
```python
"/Applications/Blender.app/Contents/MacOS/Blender"
"Adobe Photoshop 2024"
```
If you only use Replicate image generation and 3D generation, the local DreamShaper path is not needed for image generation.
## Output Files
Generated files are written under:
```text
web/output/
```
Typical outputs:
- `.png` generated image
- `.png.json` prompt metadata sidecar
- `.glb` generated 3D model
- `_hdri_seamless.png` generated HDRI image
`web/output/` is ignored by Git.
## Project Structure
```text
main.py Desktop app, UI API, prompt flow, image generation
image_to_3d.py Replicate Hunyuan 3D conversion
generate_equirect.py HDRI generation and seam fixing
scene_setup.py Blender scene setup for generated models
web/index.html App UI
requirements.txt Python dependencies
run.sh Virtualenv setup and app launcher
```
## Git And Secrets
The repository ignores local output, model weights, virtual environments, Hugging Face cache files, and local secret/config files.
Before publishing, it is still worth running:
```bash
git status --short --ignored
git ls-files -co --exclude-standard
```
Do not commit:
- `.env`
- `settings.json`
- Replicate API tokens
- model weights
- generated media
- `web/output/`
## Notes
- `numpy<2` is pinned because some installed ML/runtime dependencies may still be incompatible with NumPy 2.x.
- The app currently targets macOS paths and behavior.
- HDRI generation depends on local models and Topaz Photo AI CLI.
- Replicate image generation and Hunyuan 3D generation spend Replicate credits.

View File

@@ -34,23 +34,13 @@ def unshift_image(img: Image.Image, shift: int) -> Image.Image:
out.paste(img.crop((0, 0, w - shift, h)), (shift, 0))
return out
def main():
parser = argparse.ArgumentParser(
description="Generate an equirectangular HDRI, make it seamless, and upscale it with Topaz Photo AI CLI."
)
parser.add_argument("--prompt", required=True,
help="Text prompt for generation and inpainting")
parser.add_argument("--output", required=True,
help="Filename for the final upscaled image (e.g. seamless.png)")
parser.add_argument("--work-dir", default=os.path.dirname(os.path.abspath(__file__)),
help="Working directory for intermediates and final outputs")
args = parser.parse_args()
def generate_equirect(prompt: str, output: str, work_dir: str | None = None) -> str | None:
# Output-Ordner (bleibt wie gehabt)
output_abs = os.path.abspath(args.output)
output_abs = os.path.abspath(output)
work_dir = work_dir or os.path.dirname(os.path.abspath(__file__))
# Zwischenschritte landen im eigenem temp-Ordner:
with tempfile.TemporaryDirectory(dir=args.work_dir) as tempdir:
with tempfile.TemporaryDirectory(dir=work_dir) as tempdir:
print(f"→ Using tempdir: {tempdir}")
model_path = "/Volumes/SD/ML-Models/diffusers/hdri-panorama-v1-diffusers"
@@ -76,7 +66,7 @@ def main():
print("→ Generating equirectangular HDRI…")
image = gen_pipe(
prompt=args.prompt,
prompt=prompt,
num_inference_steps=steps,
guidance_scale=scale-1.5,
width=width,
@@ -102,7 +92,7 @@ def main():
print("→ Inpainting seam for seamless tiling…")
inpainted = inpaint_pipe(
prompt=args.prompt,
prompt=prompt,
image=shifted,
mask_image=mask,
num_inference_steps=steps,
@@ -111,7 +101,7 @@ def main():
height=height
).images[0]
seamless_path = os.path.join(tempdir, os.path.basename(args.output))
seamless_path = os.path.join(tempdir, os.path.basename(output_abs))
inpainted = unshift_image(inpainted, shift_amt)
inpainted.save(seamless_path)
print(f"→ Crafted seamless image: {seamless_path}")
@@ -132,11 +122,26 @@ def main():
)
if not upscaled_files:
print("→ No PNG output found in tempdir after Topaz run!")
return
return None
upscaled = upscaled_files[0]
shutil.move(upscaled, output_abs)
print(f"→ Upscaled image moved to {output_abs}")
return output_abs
def main():
parser = argparse.ArgumentParser(
description="Generate an equirectangular HDRI, make it seamless, and upscale it with Topaz Photo AI CLI."
)
parser.add_argument("--prompt", required=True,
help="Text prompt for generation and inpainting")
parser.add_argument("--output", required=True,
help="Filename for the final upscaled image (e.g. seamless.png)")
parser.add_argument("--work-dir", default=os.path.dirname(os.path.abspath(__file__)),
help="Working directory for intermediates and final outputs")
args = parser.parse_args()
generate_equirect(args.prompt, args.output, args.work_dir)
if __name__ == "__main__":
main()
main()

BIN
icon.icns Normal file

Binary file not shown.

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB

150
main.py
View File

@@ -1,5 +1,17 @@
import os
import sys
EARLY_IS_FROZEN = getattr(sys, "frozen", False)
EARLY_BASE_DIR = os.path.dirname(sys.executable) if EARLY_IS_FROZEN else os.path.dirname(os.path.abspath(__file__))
EARLY_APP_SUPPORT_DIR = os.path.join(
os.path.expanduser("~"),
"Library",
"Application Support",
"3d-model-generator",
)
EARLY_HF_HOME = os.path.join(EARLY_APP_SUPPORT_DIR if EARLY_IS_FROZEN else EARLY_BASE_DIR, ".hf_cache")
os.environ.setdefault("HF_HOME", EARLY_HF_HOME)
os.environ.setdefault("HUGGINGFACE_HUB_CACHE", os.path.join(EARLY_HF_HOME, "hub"))
import json
import re
import requests
@@ -11,6 +23,7 @@ import webview
import random
import subprocess
import concurrent.futures
import shutil
from PIL import Image, PngImagePlugin
from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler
import torch
@@ -24,22 +37,43 @@ MODEL = "mistral:latest" # or "mistral-small3.1:24b"
# Path to your diffusers-converted model
MODEL_PATH = "/Volumes/SD/ML-Models/diffusers/dreamshaper_8_diffusers"
# Where to save generated images
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ────────────────────────────────────────────────────────────────────────────
# Store everything under web/output instead of project_root/output
WEB_DIR = os.path.join(BASE_DIR, "web")
OUTPUT_DIR = os.path.join(WEB_DIR, "output")
os.makedirs(OUTPUT_DIR, exist_ok=True)
APP_SUPPORT_DIR = os.path.join(
os.path.expanduser("~"),
"Library",
"Application Support",
"3d-model-generator",
)
IS_FROZEN = getattr(sys, "frozen", False)
BASE_DIR = os.path.dirname(sys.executable) if IS_FROZEN else os.path.dirname(os.path.abspath(__file__))
BUNDLE_DIR = getattr(sys, "_MEIPASS", BASE_DIR)
APP_SUPPORT_DIR = EARLY_APP_SUPPORT_DIR
SETTINGS_PATH = os.path.join(APP_SUPPORT_DIR, "settings.json")
# ────────────────────────────────────────────────────────────────────────────
def resource_path(*parts: str) -> str:
return os.path.join(BUNDLE_DIR, *parts)
def prepare_web_dir() -> str:
if not IS_FROZEN:
return os.path.join(BASE_DIR, "web")
src = resource_path("web")
dst = os.path.join(APP_SUPPORT_DIR, "web")
os.makedirs(dst, exist_ok=True)
for root, dirs, files in os.walk(src):
dirs[:] = [d for d in dirs if d != "output"]
rel = os.path.relpath(root, src)
out_root = dst if rel == "." else os.path.join(dst, rel)
os.makedirs(out_root, exist_ok=True)
for fname in files:
shutil.copy2(os.path.join(root, fname), os.path.join(out_root, fname))
return dst
WEB_DIR = prepare_web_dir()
WEB_INDEX = os.path.join(WEB_DIR, "index.html")
OUTPUT_DIR = os.path.join(WEB_DIR, "output")
APP_ICON_PATH = resource_path("icon.png")
os.makedirs(OUTPUT_DIR, exist_ok=True)
IMAGE_BACKEND_LOCAL = "local"
IMAGE_BACKEND_REPLICATE = "replicate"
DEFAULT_IMAGE_BACKEND = IMAGE_BACKEND_LOCAL
@@ -810,7 +844,7 @@ class Api:
# oder als Startscript mitgibst!
cmd = [
"/Applications/Blender.app/Contents/MacOS/Blender",
"--python", "scene_setup.py",
"--python", resource_path("scene_setup.py"),
"--", glb_path, hdri_path or ""
]
try:
@@ -824,7 +858,7 @@ class Api:
def edit_external(self, filepath: str):
# filepath kommt von 2D als "output/foo.png" oder von 3D als "output/foo.png"
ext = os.path.splitext(filepath)[1].lower()
full_path = os.path.join(BASE_DIR, "web", filepath)
full_path = os.path.join(WEB_DIR, filepath)
# Ist es ein Bild oder das PNG eines 3D-Modells?
if ext == ".png":
# Prüfe, ob ein GLB daneben liegt
@@ -852,7 +886,7 @@ class Api:
# ------------- Generate 3D Model -------------
def generate_3d_model(self, filepath: str):
# filepath kommt aus JS als "output/flower-1.png"
full_path = os.path.join(BASE_DIR, "web", filepath)
full_path = os.path.join(WEB_DIR, filepath)
if not os.path.isfile(full_path):
self._js("window.on_error", f"Image not found: {full_path}")
return {"status": "missing_file"}
@@ -874,33 +908,8 @@ class Api:
)
return
# Construct the command: sys.executable ensures we run with same Python interpreter
script_path = os.path.join(BASE_DIR, "image_to_3d.py")
cmd = [sys.executable, script_path, img_path]
base, _ = os.path.splitext(img_path)
glb_path = base + ".glb"
previous_mtime = os.path.getmtime(glb_path) if os.path.isfile(glb_path) else None
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=self._subprocess_env(),
)
# Check for success: sidecar or actual .glb in same folder
current_mtime = os.path.getmtime(glb_path) if os.path.isfile(glb_path) else None
if current_mtime is not None and current_mtime != previous_mtime:
# Notify JavaScript
self._js("window.on_3d_generated", glb_path)
else:
# If the script failed or no .glb was produced, send an error
err_msg = (
result.stderr.strip()
or result.stdout.strip()
or "3D conversion did not produce .glb"
)
self._js("window.on_error", f"3D Model failed: {err_msg}")
glb_path = self._run_generate_3d_and_return(img_path)
self._js("window.on_3d_generated", glb_path)
except Exception as e:
self._js("window.on_error", f"3D conversion exception: {e}")
@@ -915,7 +924,7 @@ class Api:
Called when user wants both 3D model and equirectangular HDRI for an image.
Kicks off both processes, waits for both, and (later) calls the next step.
"""
full_path = os.path.join(BASE_DIR, "web", filepath)
full_path = os.path.join(WEB_DIR, filepath)
if not os.path.isfile(full_path):
self._js("window.on_error", f"Image not found: {full_path}")
return {"status": "missing_file"}
@@ -1041,45 +1050,32 @@ Do NOT mention the object itself. Describe the environment in a concise way, e.g
if not self._get_replicate_api_token():
raise RuntimeError("Replicate API token missing. Open settings and add it.")
script_path = os.path.join(BASE_DIR, "image_to_3d.py")
cmd = [sys.executable, script_path, img_path]
base, _ = os.path.splitext(img_path)
glb_path = base + ".glb"
previous_mtime = os.path.getmtime(glb_path) if os.path.isfile(glb_path) else None
result = subprocess.run(
cmd,
capture_output=True,
text=True,
env=self._subprocess_env(),
)
current_mtime = os.path.getmtime(glb_path) if os.path.isfile(glb_path) else None
if current_mtime is not None and current_mtime != previous_mtime:
self._js("window.on_3d_generated", glb_path)
return glb_path
else:
err_msg = (
result.stderr.strip()
or result.stdout.strip()
or "3D conversion did not produce .glb"
)
raise RuntimeError(err_msg)
import image_to_3d
result = image_to_3d.process_image(img_path, self._get_replicate_api_token())
if result and os.path.isfile(result):
return result
raise RuntimeError("3D conversion did not produce .glb")
def _run_equirect_map(self, prompt: str, output_path: str):
"""
Calls your equi map generator script.
Logs command and full output for debugging.
"""
script_path = os.path.join(BASE_DIR, "generate_equirect.py")
cmd = [sys.executable, script_path, "--prompt", prompt, "--output", output_path]
print(f"[HDRI CMD] {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
print(f"[HDRI STDOUT]\n{result.stdout}")
print(f"[HDRI STDERR]\n{result.stderr}")
if result.returncode == 0 and os.path.isfile(output_path):
import generate_equirect
print(f"[HDRI] Generating to: {output_path}")
result = generate_equirect.generate_equirect(
prompt,
output_path,
work_dir=APP_SUPPORT_DIR if IS_FROZEN else BASE_DIR,
)
if result and os.path.isfile(output_path):
print(f"[HDRI OK] Created: {output_path}")
return output_path
else:
raise RuntimeError(result.stderr.strip() or f"HDRI generation failed for {output_path}")
raise RuntimeError(f"HDRI generation failed for {output_path}")
def _on_3d_and_hdri_ready(self, glb_path: str, hdri_path: str):
"""
@@ -1168,7 +1164,7 @@ Do NOT mention the object itself. Describe the environment in a concise way, e.g
def get_image_json(self, filepath: str):
# filepath kommt aus JS als "output/flower-1.png"
full_path = os.path.join(BASE_DIR, "web", filepath)
full_path = os.path.join(WEB_DIR, filepath)
try:
# Always prefer the sidecar if available (contains most up-to-date data)
sidecar = full_path + ".json"
@@ -1230,7 +1226,7 @@ if __name__ == "__main__":
api = Api()
window = webview.create_window(
"SD 3D Model Image Gen",
"web/index.html",
WEB_INDEX,
js_api=api,
width=900,
height=1000,
@@ -1238,4 +1234,4 @@ if __name__ == "__main__":
resizable=True
)
api.set_window(window)
webview.start(debug=True)
webview.start(debug=not IS_FROZEN, icon=APP_ICON_PATH if os.path.isfile(APP_ICON_PATH) else None)