feat: sd_local (SD1.5 + anatomy LoRAs + pose/IP-Adapter) + stereo_depth (SBS → SGBM depth/cloud)
sd_local: the repose stack as an operator — Hyper_Realism + repose/models/lora + optional OpenPose T-pose template + IP-Adapter ref. Absolute venv path = primary-only by design. Verified: 512x640 portrait in one shot. stereo_depth: side-by-side stereo video/frame → per-frame SGBM disparity maps + colored midframe .ply for Blender displace/point-cloud work. venvs/stereo (opencv-headless). Verified on a synthetic 24px-disparity plate: subject bright, zero-disparity bg black, 1.4MB cloud. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fcb5e80702
commit
95708ad220
26
server/operators/sd_local/manifest.json
Normal file
26
server/operators/sd_local/manifest.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "sd_local",
|
||||||
|
"name": "SD1.5 + anatomy LoRAs (local, primary-only)",
|
||||||
|
"category": "generate",
|
||||||
|
"description": "Prompt → image via the repose stack on the primary: SD1.5 Hyper_Realism + John's anatomy LoRAs (~/Documents/repose/models/lora) + optional OpenPose T-pose template (front/back/side) + optional IP-Adapter reference image. The no-cloud, no-content-filter lane FLUX can't cover — LoRA-styled anatomy, exact pose control, reference-guided identity. Primary-only: needs ~/Documents/repose (absolute venv path keeps it off remote dispatch by design).",
|
||||||
|
"accepts": ["image"],
|
||||||
|
"produces": ["image"],
|
||||||
|
"resources": "gpu",
|
||||||
|
"entry": "run.py",
|
||||||
|
"python": "/Users/m3ultra/Documents/repose/venv/bin/python",
|
||||||
|
"params_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {"type": "string", "default": "", "description": "What to generate"},
|
||||||
|
"negative": {"type": "string", "default": "deformed, extra limbs, extra fingers, mutated hands, missing limbs, cropped, blurry, lowres, watermark, text, multiple people, child", "description": "Negative prompt"},
|
||||||
|
"loras": {"type": "array", "items": {"type": "string"}, "default": [], "description": "'stem=weight' from repose/models/lora, e.g. 'GodPussy1 v4=0.6'"},
|
||||||
|
"pose": {"type": "string", "enum": ["none", "front", "back", "side"], "default": "none", "description": "OpenPose T-pose template via ControlNet"},
|
||||||
|
"ip": {"type": "number", "default": 0.7, "description": "IP-Adapter strength when a reference image is attached"},
|
||||||
|
"steps": {"type": "integer", "default": 28, "minimum": 1, "maximum": 60},
|
||||||
|
"cfg": {"type": "number", "default": 7.0},
|
||||||
|
"seed": {"type": "integer", "default": 7},
|
||||||
|
"width": {"type": "integer", "default": 512, "description": "SD1.5 native ≈512-768; upscale after via seedvr2"},
|
||||||
|
"height": {"type": "integer", "default": 768}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
server/operators/sd_local/run.py
Normal file
78
server/operators/sd_local/run.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""sd_local — SD1.5 Hyper_Realism + anatomy LoRAs (+ optional pose ControlNet / IP-Adapter ref).
|
||||||
|
|
||||||
|
Runs UNDER the repose venv (manifest python is that venv, absolute → primary-only).
|
||||||
|
Generalizes scripts/repose.py from MESHGOD: pose template optional, plain txt2img otherwise.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
||||||
|
|
||||||
|
import torch # noqa: E402
|
||||||
|
from diffusers import DPMSolverMultistepScheduler # noqa: E402
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--input", action="append", default=[])
|
||||||
|
ap.add_argument("--outdir", required=True)
|
||||||
|
ap.add_argument("--params", default="{}")
|
||||||
|
a = ap.parse_args()
|
||||||
|
p = json.loads(a.params)
|
||||||
|
|
||||||
|
HERE = os.path.expanduser("~/Documents/repose")
|
||||||
|
BASE = os.path.join(HERE, "models", "Hyper_Realism_1.2_fp16.safetensors")
|
||||||
|
LORA_DIR = os.path.join(HERE, "models", "lora")
|
||||||
|
if not os.path.exists(BASE):
|
||||||
|
print(f"ERROR: {BASE} missing — sd_local runs on the primary only")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
dev = "mps" if torch.backends.mps.is_available() else "cpu"
|
||||||
|
dtype = torch.float16 if dev == "mps" else torch.float32
|
||||||
|
pose = p.get("pose", "none")
|
||||||
|
kw = {}
|
||||||
|
|
||||||
|
if pose != "none":
|
||||||
|
from diffusers import ControlNetModel, StableDiffusionControlNetPipeline
|
||||||
|
tpl = os.path.join(HERE, f"tpose_{pose}.png")
|
||||||
|
cn = ControlNetModel.from_pretrained("lllyasviel/control_v11p_sd15_openpose", torch_dtype=dtype)
|
||||||
|
pipe = StableDiffusionControlNetPipeline.from_single_file(
|
||||||
|
BASE, controlnet=cn, torch_dtype=dtype, safety_checker=None, requires_safety_checker=False)
|
||||||
|
kw["image"] = Image.open(tpl).convert("RGB")
|
||||||
|
else:
|
||||||
|
from diffusers import StableDiffusionPipeline
|
||||||
|
pipe = StableDiffusionPipeline.from_single_file(
|
||||||
|
BASE, torch_dtype=dtype, safety_checker=None, requires_safety_checker=False)
|
||||||
|
|
||||||
|
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config, use_karras_sigmas=True)
|
||||||
|
pipe = pipe.to(dev)
|
||||||
|
# no attention slicing — it crashes IP-Adapter attn-processor injection (see repose.py)
|
||||||
|
|
||||||
|
if p.get("loras"):
|
||||||
|
names, weights = [], []
|
||||||
|
for spec in p["loras"]:
|
||||||
|
stem, _, w = str(spec).partition("=")
|
||||||
|
pipe.load_lora_weights(LORA_DIR, weight_name=f"{stem}.safetensors",
|
||||||
|
adapter_name=stem.replace(" ", "_"))
|
||||||
|
names.append(stem.replace(" ", "_"))
|
||||||
|
weights.append(float(w or 0.6))
|
||||||
|
pipe.set_adapters(names, weights)
|
||||||
|
print(f"[sd] loras: {dict(zip(names, weights))}", flush=True)
|
||||||
|
|
||||||
|
if a.input: # reference image → IP-Adapter
|
||||||
|
pipe.load_ip_adapter("h94/IP-Adapter", subfolder="models", weight_name="ip-adapter_sd15.bin")
|
||||||
|
pipe.set_ip_adapter_scale(float(p.get("ip", 0.7)))
|
||||||
|
kw["ip_adapter_image"] = Image.open(a.input[0]).convert("RGB")
|
||||||
|
print(f"[sd] ip-adapter ref @ {p.get('ip', 0.7)}", flush=True)
|
||||||
|
|
||||||
|
gen = torch.Generator("cpu").manual_seed(int(p.get("seed", 7)))
|
||||||
|
img = pipe(prompt=p.get("prompt", ""), negative_prompt=p.get("negative", ""),
|
||||||
|
num_inference_steps=int(p.get("steps", 28)), guidance_scale=float(p.get("cfg", 7.0)),
|
||||||
|
generator=gen, width=int(p.get("width", 512)), height=int(p.get("height", 768)),
|
||||||
|
**kw).images[0]
|
||||||
|
out = os.path.join(a.outdir, "sd_local.png")
|
||||||
|
img.save(out)
|
||||||
|
json.dump({"outputs": [{"path": "sd_local.png", "meta": {"tool": "sd_local"}}]},
|
||||||
|
open(os.path.join(a.outdir, "result.json"), "w"))
|
||||||
|
print("done: sd_local.png", flush=True)
|
||||||
20
server/operators/stereo_depth/manifest.json
Normal file
20
server/operators/stereo_depth/manifest.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"id": "stereo_depth",
|
||||||
|
"name": "Stereo SBS → depth + point cloud (local)",
|
||||||
|
"category": "video-prep",
|
||||||
|
"description": "Side-by-side stereoscopic video (or one SBS frame) → per-frame disparity/depth maps via OpenCV StereoSGBM + a colored .ply point cloud of the middle frame for Blender. TRUE geometric depth from the L/R eye shift — no AI hallucination. Uncalibrated SBS rips aren't rectified, so expect relief-map depth (great for Displace-modifier planes), not survey-grade geometry. Output: depth preview mp4 + mid-frame .ply + zipped depth PNGs.",
|
||||||
|
"accepts": ["video", "image"],
|
||||||
|
"produces": ["archive"],
|
||||||
|
"resources": "cpu",
|
||||||
|
"entry": "run.py",
|
||||||
|
"python": "venvs/stereo/bin/python",
|
||||||
|
"params_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"max_frames": {"type": "integer", "default": 120, "description": "Cap processed frames"},
|
||||||
|
"num_disp": {"type": "integer", "default": 128, "description": "Disparity search range (multiple of 16; raise for close subjects)"},
|
||||||
|
"block": {"type": "integer", "default": 5, "description": "SGBM block size (odd, 3-11)"},
|
||||||
|
"layout": {"type": "string", "enum": ["sbs", "tab"], "default": "sbs", "description": "side-by-side or top-and-bottom stereo"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
server/operators/stereo_depth/run.py
Normal file
106
server/operators/stereo_depth/run.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""stereo_depth — SBS stereoscopic video/frame → SGBM disparity maps + mid-frame point cloud.
|
||||||
|
|
||||||
|
True geometric depth from the built-in L/R parallax (cv2.StereoSGBM). ffmpeg (system)
|
||||||
|
decodes/splits; depth PNGs + preview mp4 + a colored .ply land as the outputs.
|
||||||
|
ponytail: uncalibrated disparity (SBS rips have no camera intrinsics) — z is relative,
|
||||||
|
perfect for Blender displace planes; not metric reconstruction.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
FFMPEG = shutil.which("ffmpeg") or "/opt/homebrew/bin/ffmpeg" # headless ssh has no brew PATH
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--input", action="append", default=[])
|
||||||
|
ap.add_argument("--outdir", required=True)
|
||||||
|
ap.add_argument("--params", default="{}")
|
||||||
|
a = ap.parse_args()
|
||||||
|
p = json.loads(a.params)
|
||||||
|
|
||||||
|
if not a.input:
|
||||||
|
print("ERROR: no input")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
src = a.input[0]
|
||||||
|
outdir = a.outdir
|
||||||
|
max_frames = int(p.get("max_frames", 120))
|
||||||
|
num_disp = max(16, (int(p.get("num_disp", 128)) // 16) * 16)
|
||||||
|
block = int(p.get("block", 5)) | 1
|
||||||
|
tab = p.get("layout", "sbs") == "tab"
|
||||||
|
|
||||||
|
work = tempfile.mkdtemp(prefix="stereo_")
|
||||||
|
frames_dir = os.path.join(work, "frames")
|
||||||
|
depth_dir = os.path.join(work, "depth")
|
||||||
|
os.makedirs(frames_dir)
|
||||||
|
os.makedirs(depth_dir)
|
||||||
|
|
||||||
|
# decode → frames (a single image just becomes frame 1)
|
||||||
|
subprocess.run([FFMPEG, "-y", "-loglevel", "error", "-i", src,
|
||||||
|
"-frames:v", str(max_frames), os.path.join(frames_dir, "f%05d.png")], check=True)
|
||||||
|
frames = sorted(glob.glob(os.path.join(frames_dir, "*.png")))
|
||||||
|
print(f"[stereo] {len(frames)} frames, num_disp={num_disp} block={block}", flush=True)
|
||||||
|
|
||||||
|
sgbm = cv2.StereoSGBM_create(
|
||||||
|
minDisparity=0, numDisparities=num_disp, blockSize=block,
|
||||||
|
P1=8 * 3 * block * block, P2=32 * 3 * block * block,
|
||||||
|
disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=2,
|
||||||
|
mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY)
|
||||||
|
|
||||||
|
mid = frames[len(frames) // 2]
|
||||||
|
for i, f in enumerate(frames):
|
||||||
|
img = cv2.imread(f)
|
||||||
|
h, w = img.shape[:2]
|
||||||
|
if tab:
|
||||||
|
L, R = img[: h // 2], img[h // 2:]
|
||||||
|
else:
|
||||||
|
L, R = img[:, : w // 2], img[:, w // 2:]
|
||||||
|
gl, gr = cv2.cvtColor(L, cv2.COLOR_BGR2GRAY), cv2.cvtColor(R, cv2.COLOR_BGR2GRAY)
|
||||||
|
disp = sgbm.compute(gl, gr).astype(np.float32) / 16.0
|
||||||
|
disp[disp < 0] = 0
|
||||||
|
norm = (disp / max(1e-6, disp.max()) * 255).astype(np.uint8)
|
||||||
|
cv2.imwrite(os.path.join(depth_dir, os.path.basename(f)), norm)
|
||||||
|
if f == mid: # colored point cloud of the middle frame
|
||||||
|
ys, xs = np.where(disp > 1)
|
||||||
|
step = max(1, len(ys) // 400_000) # cap cloud size
|
||||||
|
ys, xs = ys[::step], xs[::step]
|
||||||
|
z = disp[ys, xs]
|
||||||
|
rgb = L[ys, xs][:, ::-1] # BGR→RGB
|
||||||
|
ply = os.path.join(outdir, "midframe_cloud.ply")
|
||||||
|
with open(ply, "w") as fh:
|
||||||
|
fh.write("ply\nformat ascii 1.0\n"
|
||||||
|
f"element vertex {len(ys)}\n"
|
||||||
|
"property float x\nproperty float y\nproperty float z\n"
|
||||||
|
"property uchar red\nproperty uchar green\nproperty uchar blue\nend_header\n")
|
||||||
|
for (yy, xx, zz, (r, g, b)) in zip(ys, xs, z, rgb):
|
||||||
|
fh.write(f"{xx} {-yy} {zz:.1f} {r} {g} {b}\n")
|
||||||
|
print(f"[stereo] wrote midframe_cloud.ply ({len(ys)} pts)", flush=True)
|
||||||
|
if i % 24 == 0:
|
||||||
|
print(f"[stereo] frame {i + 1}/{len(frames)}", flush=True)
|
||||||
|
|
||||||
|
outputs = [{"path": "midframe_cloud.ply", "meta": {"tool": "stereo_sgbm"}}]
|
||||||
|
if len(frames) > 1: # depth preview video
|
||||||
|
prev = os.path.join(outdir, "depth_preview.mp4")
|
||||||
|
subprocess.run([FFMPEG, "-y", "-loglevel", "error", "-framerate", "24",
|
||||||
|
"-i", os.path.join(depth_dir, "f%05d.png"),
|
||||||
|
"-pix_fmt", "yuv420p", prev], check=True)
|
||||||
|
outputs.append({"path": "depth_preview.mp4", "meta": {"tool": "stereo_sgbm"}})
|
||||||
|
|
||||||
|
zpath = os.path.join(outdir, "depth_frames.zip")
|
||||||
|
with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for f in sorted(glob.glob(os.path.join(depth_dir, "*.png"))):
|
||||||
|
zf.write(f, os.path.basename(f))
|
||||||
|
outputs.append({"path": "depth_frames.zip", "meta": {"tool": "stereo_sgbm"}})
|
||||||
|
|
||||||
|
json.dump({"outputs": outputs}, open(os.path.join(outdir, "result.json"), "w"))
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
print("done:", ", ".join(o["path"] for o in outputs), flush=True)
|
||||||
Loading…
Reference in New Issue
Block a user