diff --git a/server/operators/sd_local/manifest.json b/server/operators/sd_local/manifest.json new file mode 100644 index 0000000..0cfb772 --- /dev/null +++ b/server/operators/sd_local/manifest.json @@ -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} + } + } +} diff --git a/server/operators/sd_local/run.py b/server/operators/sd_local/run.py new file mode 100644 index 0000000..1fc74ec --- /dev/null +++ b/server/operators/sd_local/run.py @@ -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) diff --git a/server/operators/stereo_depth/manifest.json b/server/operators/stereo_depth/manifest.json new file mode 100644 index 0000000..6e74349 --- /dev/null +++ b/server/operators/stereo_depth/manifest.json @@ -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"} + } + } +} diff --git a/server/operators/stereo_depth/run.py b/server/operators/stereo_depth/run.py new file mode 100644 index 0000000..2503665 --- /dev/null +++ b/server/operators/stereo_depth/run.py @@ -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)