Initial Commit
This commit is contained in:
262
CLI/3d-model-image-prompt-generator.py
Normal file
262
CLI/3d-model-image-prompt-generator.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from langchain_community.llms import Ollama
|
||||||
|
|
||||||
|
SD_WEBUI_PATH = "/Users/giers/Tools/stable-diffusion-webui"
|
||||||
|
|
||||||
|
def is_sd_webui_running():
|
||||||
|
try:
|
||||||
|
r = requests.get("http://127.0.0.1:7860/sdapi/v1/txt2img", timeout=3)
|
||||||
|
# Should error because POST is required, but if it responds, it's running
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start_sd_webui_headless(webui_dir):
|
||||||
|
# Use --headless, disable extensions/UIs for fastest startup
|
||||||
|
args = [
|
||||||
|
"python3", "launch.py",
|
||||||
|
"--nowebui", # Don't launch browser UI
|
||||||
|
"--headless", # No local UI window
|
||||||
|
"--api", # Enable API
|
||||||
|
"--skip-torch-cuda-test",
|
||||||
|
"--no-hashing", # Faster startup
|
||||||
|
"--disable-nan-check", # Optional: faster
|
||||||
|
"--xformers"
|
||||||
|
]
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
args,
|
||||||
|
cwd=webui_dir,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
return proc
|
||||||
|
|
||||||
|
def get_output_dir():
|
||||||
|
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
return out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_available_filename(base, ext=".png"):
|
||||||
|
out_dir = get_output_dir()
|
||||||
|
i = 1
|
||||||
|
while True:
|
||||||
|
fname = f"{base}-{i}{ext}"
|
||||||
|
fpath = os.path.join(out_dir, fname)
|
||||||
|
if not os.path.exists(fpath):
|
||||||
|
return fpath
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
def flush_print(*args, **kwargs):
|
||||||
|
print(*args, **kwargs)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||||
|
#MODEL = "mistral-small3.1:24b"
|
||||||
|
MODEL = "mistral:latest"
|
||||||
|
SD_URL = "http://127.0.0.1:7860/sdapi/v1/txt2img"
|
||||||
|
|
||||||
|
META_PROMPT = """
|
||||||
|
You are an expert at writing concise, detailed Stable Diffusion prompts for 3D rendered objects as seen in professional game development. When I give you an object name, carefully follow these instructions step by step:
|
||||||
|
|
||||||
|
1. Subject ({{OBJECT}}):
|
||||||
|
- Start with the object name ({{OBJECT}}).
|
||||||
|
- Add 2–3 specific visual or material details (for example: shape, surface texture, design features like “chrome plating,” “organic armor,” “glowing eyes”).
|
||||||
|
|
||||||
|
2. Medium & Style:
|
||||||
|
- Add keywords for a 3D render and concept art look:
|
||||||
|
concept art, 3D render, game asset, professional game designer, digital sculpture, hyperrealistic, octane render, Unreal Engine 5, high poly.
|
||||||
|
|
||||||
|
3. Presentation:
|
||||||
|
- Explicitly state the object is shown fully in frame, not cropped, and completely visible from a neutral angle.
|
||||||
|
- No background: isolated on pure white background, or transparent background.
|
||||||
|
|
||||||
|
4. Lighting & Quality:
|
||||||
|
- Use studio lighting, no dramatic shadows, no depth of field, no blur.
|
||||||
|
- Emphasize sharp focus, ultra detailed, 8K, clean silhouette.
|
||||||
|
|
||||||
|
5. Artist Influence (optional):
|
||||||
|
- If fitting, add a well-known concept artist (example: by Beeple).
|
||||||
|
|
||||||
|
6. Negative Prompt:
|
||||||
|
- Add: Negative prompt: blurry, lowres, bad anatomy, distorted proportions, background, scenery, environment, artifacting, watermark, text, cropped, partial view, depth of field, out of frame, blur, vignette
|
||||||
|
|
||||||
|
Output Format:
|
||||||
|
- Line 1: All positive prompt keywords (subject, details, medium, style, lighting, quality, artist, presentation, background).
|
||||||
|
- Line 2: Start with Negative prompt: and then the negative keywords above.
|
||||||
|
|
||||||
|
Instructions to the LLM:
|
||||||
|
- The first line must always start with the object and its details.
|
||||||
|
- The object must be fully visible, not cropped, and entirely in the image frame.
|
||||||
|
- There must be no background, and the background must be pure white or transparent.
|
||||||
|
- Do NOT use blur, depth of field, vignette, or any visual effects that obscure details.
|
||||||
|
- Do NOT add any scenery or environment.
|
||||||
|
- Be concise and avoid repetition.
|
||||||
|
|
||||||
|
Now, generate a Stable Diffusion prompt for the object: {{OBJECT}}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SECOND_PROMPT_TEMPLATE = """
|
||||||
|
You are a prompt-to-JSON converter for image generation tasks.
|
||||||
|
|
||||||
|
Given a Stable Diffusion prompt, extract the positive and negative prompts.
|
||||||
|
Also, analyze the subject:
|
||||||
|
- If it is a single character, creature, full object, or person standing/upright, set "dimensions" to [512,768] (vertical).
|
||||||
|
- If it is a landscape, wide object, multi-character group, or scene, set "dimensions" to [768,512] (horizontal).
|
||||||
|
- If it is something square or best seen as a square (e.g., shield, logo, face, emblem, single centered item), set "dimensions" to [768,768] (square).
|
||||||
|
|
||||||
|
Return a JSON object with these fields:
|
||||||
|
{
|
||||||
|
"positive prompt": "...",
|
||||||
|
"negative prompt": "...",
|
||||||
|
"dimensions": [W,H]
|
||||||
|
}
|
||||||
|
|
||||||
|
Respond only with the JSON.
|
||||||
|
Here is the Stable Diffusion prompt:
|
||||||
|
|
||||||
|
{PROMPT}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# --- streaming LLM utility ---
|
||||||
|
def stream_ollama(prompt):
|
||||||
|
payload = {"model": MODEL, "prompt": prompt, "stream": True}
|
||||||
|
with requests.post(OLLAMA_URL, json=payload, stream=True, timeout=300) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
full_response = ""
|
||||||
|
for line in r.iter_lines():
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
chunk = json.loads(line.decode("utf-8"))["response"]
|
||||||
|
except Exception:
|
||||||
|
chunk = line.decode("utf-8")
|
||||||
|
print(chunk, end="", flush=True)
|
||||||
|
full_response += chunk
|
||||||
|
print()
|
||||||
|
return full_response.strip()
|
||||||
|
|
||||||
|
def get_json_from_llm(prompt):
|
||||||
|
ollama_chain = Ollama(model=MODEL, base_url="http://localhost:11434")
|
||||||
|
response_gen = ollama_chain.stream(prompt)
|
||||||
|
json_output = ""
|
||||||
|
for chunk in response_gen:
|
||||||
|
print(chunk, end="", flush=True)
|
||||||
|
json_output += chunk
|
||||||
|
print()
|
||||||
|
# Try to extract JSON from output, even if LLM includes code block markers
|
||||||
|
json_start = json_output.find("{")
|
||||||
|
json_end = json_output.rfind("}")
|
||||||
|
if json_start != -1 and json_end != -1:
|
||||||
|
json_str = json_output[json_start:json_end+1]
|
||||||
|
try:
|
||||||
|
data = json.loads(json_str)
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
print("Error parsing JSON:", e)
|
||||||
|
return None
|
||||||
|
print("Failed to extract JSON!")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sanitize_filename(s):
|
||||||
|
# Replace spaces with underscores, remove non-alphanum chars
|
||||||
|
s = s.strip().lower().replace(" ", "_")
|
||||||
|
return re.sub(r'[^a-z0-9_]+', '', s)
|
||||||
|
|
||||||
|
def call_stable_diffusion(prompt_json, user_input):
|
||||||
|
from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler
|
||||||
|
import torch
|
||||||
|
|
||||||
|
# *** Use your diffusers-converted path! ***
|
||||||
|
model_path = "/Volumes/SD/ML-Models/diffusers/dreamshaper_8_diffusers"
|
||||||
|
|
||||||
|
if torch.backends.mps.is_available():
|
||||||
|
device = "mps"
|
||||||
|
print("Using Apple Silicon MPS backend")
|
||||||
|
else:
|
||||||
|
device = "cpu"
|
||||||
|
print("Warning: Running on CPU (slow)")
|
||||||
|
|
||||||
|
print("Loading model, this may take a while the first time...", flush=True)
|
||||||
|
pipe = StableDiffusionPipeline.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
torch_dtype=torch.float32,
|
||||||
|
safety_checker=None,
|
||||||
|
local_files_only=True,
|
||||||
|
)
|
||||||
|
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
|
||||||
|
pipe = pipe.to(device)
|
||||||
|
pipe.set_progress_bar_config(disable=True)
|
||||||
|
pipe.enable_attention_slicing()
|
||||||
|
|
||||||
|
prompt = prompt_json["positive prompt"][:75]
|
||||||
|
negative_prompt = prompt_json["negative prompt"][:75]
|
||||||
|
width, height = prompt_json["dimensions"]
|
||||||
|
num_images = 4
|
||||||
|
steps = prompt_json.get("steps", 30)
|
||||||
|
guidance_scale = prompt_json.get("cfg_scale", 6.5)
|
||||||
|
|
||||||
|
print(f"Generating {num_images} image(s)...", flush=True)
|
||||||
|
images = pipe(
|
||||||
|
prompt=[prompt]*num_images,
|
||||||
|
negative_prompt=[negative_prompt]*num_images,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=guidance_scale,
|
||||||
|
).images
|
||||||
|
|
||||||
|
img_base = sanitize_filename(user_input)
|
||||||
|
for idx, img in enumerate(images, start=1):
|
||||||
|
filename = get_next_available_filename(f"{img_base}-{idx}")
|
||||||
|
img.save(filename)
|
||||||
|
print(f"Saved: {filename}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python 3d-model-image-prompt-generator.py \"object name\"", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
object_name = sys.argv[1]
|
||||||
|
print(f"\n--- Generating Stable Diffusion prompt for: {object_name} ---\n", flush=True)
|
||||||
|
prompt = META_PROMPT.replace("{{OBJECT}}", object_name)
|
||||||
|
|
||||||
|
# 1. SD Prompt Generation (streamed)
|
||||||
|
sd_prompt = stream_ollama(prompt)
|
||||||
|
print("\n--- End of Stable Diffusion prompt ---\n", flush=True)
|
||||||
|
|
||||||
|
# 2. JSON Conversion (streamed)
|
||||||
|
print("--- Generating JSON for image generation ---\n", flush=True)
|
||||||
|
second_prompt = SECOND_PROMPT_TEMPLATE.replace("{PROMPT}", sd_prompt)
|
||||||
|
prompt_json = get_json_from_llm(second_prompt)
|
||||||
|
if not prompt_json:
|
||||||
|
print("Failed to get valid JSON. Exiting.", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 3. Augment JSON with hard-coded SD settings
|
||||||
|
prompt_json["checkpoint"] = "RealismPlus/dreamshaper_8.safetensors"
|
||||||
|
prompt_json["vae"] = "Automatic"
|
||||||
|
prompt_json["sampler"] = "DPM++ 2M Karras"
|
||||||
|
prompt_json["steps"] = 30
|
||||||
|
prompt_json["cfg_scale"] = 6.5
|
||||||
|
|
||||||
|
print("\n--- Final prompt JSON ---\n", flush=True)
|
||||||
|
print(json.dumps(prompt_json, indent=2, ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
# 4. Call local Stable Diffusion via diffusers
|
||||||
|
call_stable_diffusion(prompt_json, object_name)
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print("Exception in main():", e, flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
142
generate_equirect.py
Normal file
142
generate_equirect.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import torch
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from diffusers import (
|
||||||
|
StableDiffusionPipeline,
|
||||||
|
DPMSolverMultistepScheduler,
|
||||||
|
StableDiffusionInpaintPipeline
|
||||||
|
)
|
||||||
|
|
||||||
|
def shift_image(img: Image.Image, shift: int) -> Image.Image:
|
||||||
|
w, h = img.size
|
||||||
|
out = Image.new("RGB", (w, h))
|
||||||
|
out.paste(img.crop((shift, 0, w, h)), (0, 0))
|
||||||
|
out.paste(img.crop((0, 0, shift, h)), (w - shift, 0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def create_mask(width: int, height: int, mask_w: int) -> Image.Image:
|
||||||
|
mask = Image.new("L", (width, height), 0)
|
||||||
|
draw = ImageDraw.Draw(mask)
|
||||||
|
left = (width - mask_w) // 2
|
||||||
|
draw.rectangle([left, 0, left + mask_w, height], fill=255)
|
||||||
|
return mask
|
||||||
|
|
||||||
|
def unshift_image(img: Image.Image, shift: int) -> Image.Image:
|
||||||
|
w, h = img.size
|
||||||
|
out = Image.new("RGB", (w, h))
|
||||||
|
out.paste(img.crop((w - shift, 0, w, h)), (0, 0))
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Output-Ordner (bleibt wie gehabt)
|
||||||
|
output_abs = os.path.abspath(args.output)
|
||||||
|
|
||||||
|
# Zwischenschritte landen im eigenem temp-Ordner:
|
||||||
|
with tempfile.TemporaryDirectory(dir=args.work_dir) as tempdir:
|
||||||
|
print(f"→ Using tempdir: {tempdir}")
|
||||||
|
|
||||||
|
model_path = "/Volumes/SD/ML-Models/diffusers/hdri-panorama-v1-diffusers"
|
||||||
|
topaz_cli = "/Applications/Topaz Photo AI.app/Contents/MacOS/Topaz Photo AI"
|
||||||
|
steps = 20
|
||||||
|
scale = 7.0
|
||||||
|
width, height = 1024, 512
|
||||||
|
|
||||||
|
if torch.backends.mps.is_available():
|
||||||
|
device = "mps"
|
||||||
|
elif torch.cuda.is_available():
|
||||||
|
device = "cuda"
|
||||||
|
else:
|
||||||
|
device = "cpu"
|
||||||
|
|
||||||
|
# 1) Generate base HDRI
|
||||||
|
gen_pipe = StableDiffusionPipeline.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
torch_dtype=torch.float32
|
||||||
|
).to(device)
|
||||||
|
gen_pipe.scheduler = DPMSolverMultistepScheduler.from_config(gen_pipe.scheduler.config)
|
||||||
|
gen_pipe.enable_attention_slicing()
|
||||||
|
|
||||||
|
print("→ Generating equirectangular HDRI…")
|
||||||
|
image = gen_pipe(
|
||||||
|
prompt=args.prompt,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=scale-1.5,
|
||||||
|
width=width,
|
||||||
|
height=height
|
||||||
|
).images[0]
|
||||||
|
|
||||||
|
gen_path = os.path.join(tempdir, f"base_{width}x{height}.png")
|
||||||
|
image.save(gen_path)
|
||||||
|
print(f"→ Saved initial image to {gen_path}")
|
||||||
|
|
||||||
|
# 2) Make it seamless
|
||||||
|
shift_amt = width // 2
|
||||||
|
mask_w = width // 8
|
||||||
|
|
||||||
|
shifted = shift_image(image, shift_amt)
|
||||||
|
mask = create_mask(width, height, mask_w)
|
||||||
|
|
||||||
|
inpaint_pipe = StableDiffusionInpaintPipeline.from_pretrained(
|
||||||
|
"Lykon/dreamshaper-8-inpainting",
|
||||||
|
torch_dtype=torch.float32
|
||||||
|
).to(device)
|
||||||
|
inpaint_pipe.enable_attention_slicing()
|
||||||
|
|
||||||
|
print("→ Inpainting seam for seamless tiling…")
|
||||||
|
inpainted = inpaint_pipe(
|
||||||
|
prompt=args.prompt,
|
||||||
|
image=shifted,
|
||||||
|
mask_image=mask,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
guidance_scale=scale,
|
||||||
|
width=width,
|
||||||
|
height=height
|
||||||
|
).images[0]
|
||||||
|
|
||||||
|
seamless_path = os.path.join(tempdir, os.path.basename(args.output))
|
||||||
|
inpainted = unshift_image(inpainted, shift_amt)
|
||||||
|
inpainted.save(seamless_path)
|
||||||
|
print(f"→ Crafted seamless image: {seamless_path}")
|
||||||
|
|
||||||
|
# 3) Upscale with Topaz Photo AI CLI
|
||||||
|
print("→ Upscaling with Topaz Photo AI CLI…")
|
||||||
|
result = subprocess.run(
|
||||||
|
[topaz_cli, "--cli", seamless_path, "-o", tempdir],
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finde das letzte erstellte PNG im tempdir (das ist das hochskalierte!)
|
||||||
|
# Topaz kann einen Suffix anhängen, falls der Name schon existiert.
|
||||||
|
upscaled_files = sorted(
|
||||||
|
[os.path.join(tempdir, f) for f in os.listdir(tempdir) if f.lower().endswith(".png")],
|
||||||
|
key=os.path.getmtime,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
if not upscaled_files:
|
||||||
|
print("→ No PNG output found in tempdir after Topaz run!")
|
||||||
|
return
|
||||||
|
|
||||||
|
upscaled = upscaled_files[0]
|
||||||
|
shutil.move(upscaled, output_abs)
|
||||||
|
print(f"→ Upscaled image moved to {output_abs}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
218
image_to_3d.py
Normal file
218
image_to_3d.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import replicate
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
MODEL_NAME = "tencent/hunyuan-3d-3.1"
|
||||||
|
TIMEOUT = 900
|
||||||
|
PREDICTION_TIMEOUT = 10 * 60
|
||||||
|
POLL_INTERVAL = 2.0
|
||||||
|
MAX_INPUT_BYTES = 6 * 1024 * 1024
|
||||||
|
MAX_DATA_URI_BYTES = 1024 * 1024
|
||||||
|
MAX_INPUT_SIDE = 2048
|
||||||
|
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
|
||||||
|
|
||||||
|
def notify(title: str, message: str) -> None:
|
||||||
|
script = f"display notification {json.dumps(message)} with title {json.dumps(title)}"
|
||||||
|
try:
|
||||||
|
subprocess.run(["osascript", "-e", script], check=False)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _has_alpha(img: Image.Image) -> bool:
|
||||||
|
return img.mode in {"RGBA", "LA"} or (
|
||||||
|
img.mode == "P" and "transparency" in img.info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_compact_image(img: Image.Image, output_path: str) -> None:
|
||||||
|
if max(img.size) > MAX_INPUT_SIDE:
|
||||||
|
img = img.copy()
|
||||||
|
img.thumbnail((MAX_INPUT_SIDE, MAX_INPUT_SIDE), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
if _has_alpha(img):
|
||||||
|
img.save(output_path, "WEBP", quality=95, method=6)
|
||||||
|
return
|
||||||
|
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(output_path, "JPEG", quality=92, optimize=True)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_input_image(src_path: str, temp_paths: list[str]) -> str:
|
||||||
|
ext = Path(src_path).suffix.lower()
|
||||||
|
if ext in SUPPORTED_EXTENSIONS and os.path.getsize(src_path) <= MAX_DATA_URI_BYTES:
|
||||||
|
return src_path
|
||||||
|
|
||||||
|
img = Image.open(src_path)
|
||||||
|
suffix = ".webp" if _has_alpha(img) else ".jpg"
|
||||||
|
fd, temp_path = tempfile.mkstemp(suffix=suffix)
|
||||||
|
os.close(fd)
|
||||||
|
_save_compact_image(img, temp_path)
|
||||||
|
temp_paths.append(temp_path)
|
||||||
|
|
||||||
|
if os.path.getsize(temp_path) > MAX_INPUT_BYTES:
|
||||||
|
raise RuntimeError("Prepared image is still larger than Replicate's 6MB limit.")
|
||||||
|
|
||||||
|
return temp_path
|
||||||
|
|
||||||
|
|
||||||
|
def run_replicate(image_path: str, api_token: str) -> Any:
|
||||||
|
client = replicate.Client(api_token=api_token)
|
||||||
|
client.poll_interval = POLL_INTERVAL
|
||||||
|
|
||||||
|
with open(image_path, "rb") as image_file:
|
||||||
|
prediction = client.models.predictions.create(
|
||||||
|
model=MODEL_NAME,
|
||||||
|
input={
|
||||||
|
"image": image_file,
|
||||||
|
"generate_type": "Normal",
|
||||||
|
"face_count": 500000,
|
||||||
|
"enable_pbr": False,
|
||||||
|
},
|
||||||
|
wait=False,
|
||||||
|
file_encoding_strategy="base64",
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Replicate prediction started: {prediction.id} ({prediction.status})")
|
||||||
|
deadline = time.monotonic() + PREDICTION_TIMEOUT
|
||||||
|
last_status = prediction.status
|
||||||
|
|
||||||
|
while prediction.status not in {"succeeded", "failed", "canceled"}:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Timed out waiting for Replicate prediction {prediction.id} "
|
||||||
|
f"after {PREDICTION_TIMEOUT // 60} minutes. It may still be running."
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(client.poll_interval)
|
||||||
|
prediction.reload()
|
||||||
|
|
||||||
|
if prediction.status != last_status:
|
||||||
|
print(f"Replicate prediction {prediction.id}: {prediction.status}")
|
||||||
|
last_status = prediction.status
|
||||||
|
|
||||||
|
if prediction.status != "succeeded":
|
||||||
|
detail = prediction.error or prediction.logs or f"status={prediction.status}"
|
||||||
|
raise RuntimeError(f"Replicate prediction {prediction.id} failed: {detail}")
|
||||||
|
|
||||||
|
return prediction.output
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_output_file(output: Any) -> Any:
|
||||||
|
if isinstance(output, (list, tuple)):
|
||||||
|
if not output:
|
||||||
|
raise RuntimeError("Replicate returned an empty output.")
|
||||||
|
return output[0]
|
||||||
|
|
||||||
|
if isinstance(output, dict):
|
||||||
|
for key in ("output", "model", "mesh", "glb", "url"):
|
||||||
|
if output.get(key):
|
||||||
|
return _extract_output_file(output[key])
|
||||||
|
raise RuntimeError(f"Replicate returned an unsupported output shape: {output}")
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes_from_url(url: str) -> bytes:
|
||||||
|
if url.startswith("data:"):
|
||||||
|
_, encoded = url.split(",", 1)
|
||||||
|
return base64.b64decode(encoded)
|
||||||
|
|
||||||
|
response = requests.get(url, timeout=TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
|
||||||
|
def write_output(output: Any, output_path: str) -> None:
|
||||||
|
output_file = _extract_output_file(output)
|
||||||
|
|
||||||
|
if hasattr(output_file, "read"):
|
||||||
|
data = output_file.read()
|
||||||
|
elif hasattr(output_file, "url"):
|
||||||
|
data = _bytes_from_url(str(output_file.url))
|
||||||
|
elif isinstance(output_file, str):
|
||||||
|
data = _bytes_from_url(output_file)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Replicate returned an unsupported output type: {type(output_file)!r}")
|
||||||
|
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(img_path: str, api_token: str | None = None) -> str | None:
|
||||||
|
api_token = (api_token or os.environ.get("REPLICATE_API_TOKEN", "")).strip()
|
||||||
|
if not api_token:
|
||||||
|
msg = "Missing Replicate API token. Add it in app settings or set REPLICATE_API_TOKEN."
|
||||||
|
print(msg)
|
||||||
|
notify("3D conversion error", msg)
|
||||||
|
return None
|
||||||
|
|
||||||
|
img_path = os.path.abspath(img_path)
|
||||||
|
if not os.path.isfile(img_path):
|
||||||
|
msg = f"Image not found: {img_path}"
|
||||||
|
print(msg)
|
||||||
|
notify("3D conversion error", msg)
|
||||||
|
return None
|
||||||
|
|
||||||
|
temp_paths: list[str] = []
|
||||||
|
try:
|
||||||
|
input_path = prepare_input_image(img_path, temp_paths)
|
||||||
|
base_name = os.path.splitext(os.path.basename(img_path))[0]
|
||||||
|
output_path = os.path.join(os.path.dirname(img_path), f"{base_name}.glb")
|
||||||
|
|
||||||
|
print(f"Running {MODEL_NAME} on Replicate...")
|
||||||
|
output = run_replicate(input_path, api_token)
|
||||||
|
write_output(output, output_path)
|
||||||
|
|
||||||
|
msg = f"3D model saved: {output_path}"
|
||||||
|
print(msg)
|
||||||
|
notify("3D conversion complete", msg)
|
||||||
|
return output_path
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"3D conversion failed for {img_path}: {e}"
|
||||||
|
print(msg)
|
||||||
|
notify("3D conversion error", msg)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
for temp_path in temp_paths:
|
||||||
|
try:
|
||||||
|
os.remove(temp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
msg = "Usage: python image_to_3d.py <image1> [image2 ...]"
|
||||||
|
notify("3D conversion error", "No image files provided.")
|
||||||
|
print(msg)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
for img_path in sys.argv[1:]:
|
||||||
|
print(f"\nProcessing: {img_path}")
|
||||||
|
result = process_image(img_path)
|
||||||
|
if result:
|
||||||
|
outputs.append(result)
|
||||||
|
|
||||||
|
if not outputs:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
notify("3D conversion", f"Finished {len(outputs)} model(s).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
numpy<2
|
||||||
|
|
||||||
|
accelerate==0.31.0
|
||||||
|
diffusers==0.27.2
|
||||||
|
huggingface-hub==0.23.4
|
||||||
|
Pillow==12.0.0
|
||||||
|
pywebview==5.4
|
||||||
|
replicate==1.0.7
|
||||||
|
requests==2.32.5
|
||||||
|
safetensors==0.7.0
|
||||||
|
torch==2.9.1
|
||||||
|
transformers==4.41.2
|
||||||
30
run.sh
Executable file
30
run.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
export HF_HOME="${HF_HOME:-$PWD/.hf_cache}"
|
||||||
|
export HUGGINGFACE_HUB_CACHE="${HUGGINGFACE_HUB_CACHE:-$HF_HOME/hub}"
|
||||||
|
mkdir -p "$HUGGINGFACE_HUB_CACHE"
|
||||||
|
|
||||||
|
if [ -n "${PYTHON:-}" ]; then
|
||||||
|
python_bin="$PYTHON"
|
||||||
|
elif command -v python3.11 >/dev/null 2>&1; then
|
||||||
|
python_bin="python3.11"
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
python_bin="python3"
|
||||||
|
else
|
||||||
|
echo "Could not find python3.11 or python3. Install Python 3.11, then run this script again." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d ".venv" ]; then
|
||||||
|
"$python_bin" -m venv .venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
exec python main.py
|
||||||
77
scene_setup.py
Normal file
77
scene_setup.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import bpy
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
argv = sys.argv
|
||||||
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||||
|
|
||||||
|
glb_path = argv[0] if len(argv) > 0 else None
|
||||||
|
hdri_path = argv[1] if len(argv) > 1 else None
|
||||||
|
|
||||||
|
#bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
if "Cube" in bpy.data.objects:
|
||||||
|
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
|
||||||
|
|
||||||
|
# GLB importieren
|
||||||
|
if glb_path and os.path.isfile(glb_path):
|
||||||
|
bpy.ops.import_scene.gltf(filepath=glb_path)
|
||||||
|
else:
|
||||||
|
print("GLB file missing:", glb_path)
|
||||||
|
|
||||||
|
# HDRI oder Sonne
|
||||||
|
hdri_loaded = False
|
||||||
|
if hdri_path and os.path.isfile(hdri_path):
|
||||||
|
try:
|
||||||
|
world = bpy.data.worlds.new("World") if not bpy.data.worlds else bpy.data.worlds[0]
|
||||||
|
bpy.context.scene.world = world
|
||||||
|
world.use_nodes = True
|
||||||
|
ntree = world.node_tree
|
||||||
|
nodes = ntree.nodes
|
||||||
|
for node in nodes: nodes.remove(node)
|
||||||
|
node_bg = nodes.new(type='ShaderNodeBackground')
|
||||||
|
node_env = nodes.new(type='ShaderNodeTexEnvironment')
|
||||||
|
node_out = nodes.new(type='ShaderNodeOutputWorld')
|
||||||
|
node_env.image = bpy.data.images.load(hdri_path)
|
||||||
|
node_env.location = (-300, 0)
|
||||||
|
node_bg.location = (0, 0)
|
||||||
|
node_out.location = (300, 0)
|
||||||
|
ntree.links.new(node_env.outputs['Color'], node_bg.inputs['Color'])
|
||||||
|
ntree.links.new(node_bg.outputs['Background'], node_out.inputs['Surface'])
|
||||||
|
hdri_loaded = True
|
||||||
|
except Exception as e:
|
||||||
|
print("Failed to load HDRI:", e)
|
||||||
|
hdri_loaded = False
|
||||||
|
|
||||||
|
if not hdri_loaded:
|
||||||
|
# Schöne Sonnenlampe (leicht schräg von oben)
|
||||||
|
light_data = bpy.data.lights.new(name="Sun", type='SUN')
|
||||||
|
light_data.energy = 4.5
|
||||||
|
light = bpy.data.objects.new(name="Sun", object_data=light_data)
|
||||||
|
bpy.context.collection.objects.link(light)
|
||||||
|
light.location = (4, 10, 10)
|
||||||
|
light.rotation_euler = (0.8, 0.3, 0.1) # leicht schräg
|
||||||
|
# Optionale Fill-Light/Soft-Ambient
|
||||||
|
light_data2 = bpy.data.lights.new(name="Fill", type='SUN')
|
||||||
|
light_data2.energy = 1.1
|
||||||
|
light2 = bpy.data.objects.new(name="Fill", object_data=light_data2)
|
||||||
|
bpy.context.collection.objects.link(light2)
|
||||||
|
light2.location = (-8, -6, 4)
|
||||||
|
light2.rotation_euler = (1.4, -0.8, -0.2)
|
||||||
|
|
||||||
|
for window in bpy.context.window_manager.windows:
|
||||||
|
for area in window.screen.areas:
|
||||||
|
if area.type == 'VIEW_3D':
|
||||||
|
for space in area.spaces:
|
||||||
|
if space.type == 'VIEW_3D':
|
||||||
|
space.shading.type = 'RENDERED'
|
||||||
|
|
||||||
|
for obj in bpy.data.objects:
|
||||||
|
# Entparenten, falls Parent ein Empty namens "world" ist
|
||||||
|
if obj.parent and obj.parent.name == "world":
|
||||||
|
obj.parent = None
|
||||||
|
|
||||||
|
if "world" in bpy.data.objects and bpy.data.objects["world"].type == "EMPTY":
|
||||||
|
bpy.data.objects.remove(bpy.data.objects["world"], do_unlink=True)
|
||||||
|
|
||||||
|
if "Cube" in bpy.data.objects:
|
||||||
|
bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
|
||||||
2375
web/index.html
Normal file
2375
web/index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user