| """Depth + pose estimation helpers for the Depth/Pose tab. |
| |
| Two heavy models are lazy-loaded on first use, both inside @spaces.GPU |
| functions so ZeroGPU schedules them as normal short-lived workloads: |
| - Depth-Anything-V2-Small via transformers' depth-estimation pipeline |
| - OpenposeDetector (lllyasviel/Annotators) via controlnet_aux |
| |
| Pose keypoints round-trip through a plain JSON-friendly list-of-dicts so they |
| serialise cleanly into gr.State β that's what makes click-to-edit feasible. |
| """ |
| from __future__ import annotations |
|
|
| import numpy as np |
| from PIL import Image, ImageDraw, ImageEnhance |
| import gradio as gr |
| import torch |
| import spaces |
|
|
|
|
| |
| OPENPOSE_KEYPOINT_NAMES = [ |
| "nose", "neck", |
| "right_shoulder", "right_elbow", "right_wrist", |
| "left_shoulder", "left_elbow", "left_wrist", |
| "right_hip", "right_knee", "right_ankle", |
| "left_hip", "left_knee", "left_ankle", |
| "right_eye", "left_eye", "right_ear", "left_ear", |
| ] |
|
|
| |
| |
| |
| SKELETON_EDGES = [ |
| (1, 2), (1, 5), (2, 3), (3, 4), (5, 6), (6, 7), |
| (1, 8), (8, 9), (9, 10), (1, 11), (11, 12), (12, 13), |
| (1, 0), (0, 14), (14, 16), (0, 15), (15, 17), |
| ] |
|
|
| |
| JOINT_COLORS = [ |
| (255, 0, 0), (255, 85, 0), (255, 170, 0), (255, 255, 0), (170, 255, 0), |
| (85, 255, 0), (0, 255, 0), (0, 255, 85), (0, 255, 170), (0, 255, 255), |
| (0, 170, 255), (0, 85, 255), (0, 0, 255), (85, 0, 255), (170, 0, 255), |
| (255, 0, 255), (255, 0, 170), (255, 0, 85), |
| ] |
|
|
|
|
| |
|
|
| _depth_pipeline = None |
| _pose_detector = None |
|
|
|
|
| def _load_depth_pipeline(): |
| """Loaded inside the first @spaces.GPU call, then cached for the rest of |
| the worker's lifetime. Avoids paying the cold-start cost for users who |
| only ever use depth or only ever use pose.""" |
| global _depth_pipeline |
| if _depth_pipeline is None: |
| from transformers import pipeline |
| _depth_pipeline = pipeline( |
| "depth-estimation", |
| model="depth-anything/Depth-Anything-V2-Small-hf", |
| device=0 if torch.cuda.is_available() else -1, |
| ) |
| return _depth_pipeline |
|
|
|
|
| def _load_pose_detector(): |
| global _pose_detector |
| if _pose_detector is None: |
| try: |
| from controlnet_aux import OpenposeDetector |
| except ImportError: |
| raise gr.Error( |
| "controlnet_aux is not installed. Add `controlnet_aux` to requirements.txt." |
| ) |
| _pose_detector = OpenposeDetector.from_pretrained("lllyasviel/Annotators") |
| if torch.cuda.is_available(): |
| _pose_detector = _pose_detector.to("cuda") |
| return _pose_detector |
|
|
|
|
| |
|
|
| @spaces.GPU |
| def generate_depthmap(image: Image.Image) -> Image.Image: |
| if image is None: |
| raise gr.Error("Upload a source image first.") |
| pipe = _load_depth_pipeline() |
| depth = pipe(image.convert("RGB"))["depth"] |
| return depth.convert("RGB") |
|
|
|
|
| |
|
|
| @spaces.GPU |
| def detect_pose(image: Image.Image) -> tuple[list[list[dict]], int, int]: |
| """Run pose detection and return: |
| - poses : list of dicts, one per detected person. Each pose is a list of |
| 18 entries {"name", "x", "y", "visible"} β in *pixel* coords |
| (not normalised), so the editor can pass click positions in |
| directly without a coordinate transform. |
| - w, h : source image dimensions. |
| """ |
| if image is None: |
| raise gr.Error("Upload a source image first.") |
| img = image.convert("RGB") |
| w, h = img.size |
| detector = _load_pose_detector() |
| try: |
| poses_raw = detector.detect_poses(np.array(img)) |
| except Exception as e: |
| raise gr.Error(f"Pose detection failed: {e}") |
|
|
| out = [] |
| for pose in poses_raw: |
| body_kps = pose.body.keypoints if pose.body else [None] * 18 |
| person = [] |
| for i in range(18): |
| kp = body_kps[i] if i < len(body_kps) else None |
| name = OPENPOSE_KEYPOINT_NAMES[i] |
| |
| |
| if kp is None or (getattr(kp, "score", 1.0) or 0) < 0.3: |
| person.append({"name": name, "x": 0.0, "y": 0.0, "visible": False}) |
| else: |
| person.append({ |
| "name": name, |
| "x": float(kp.x) * w, |
| "y": float(kp.y) * h, |
| "visible": True, |
| }) |
| out.append(person) |
| return out, w, h |
|
|
|
|
| |
|
|
| def render_pose_skeleton(poses: list[list[dict]], width: int, height: int) -> Image.Image: |
| """Skeleton on a black canvas β this is what ControlNet/RefControl-Pose wants.""" |
| canvas = Image.new("RGB", (width, height), (0, 0, 0)) |
| draw = ImageDraw.Draw(canvas) |
| for pose in poses: |
| for a, b in SKELETON_EDGES: |
| ka, kb = pose[a], pose[b] |
| if not (ka["visible"] and kb["visible"]): |
| continue |
| draw.line([ka["x"], ka["y"], kb["x"], kb["y"]], |
| fill=JOINT_COLORS[a], width=4) |
| for i, kp in enumerate(pose): |
| if not kp["visible"]: |
| continue |
| r = 4 |
| draw.ellipse([kp["x"] - r, kp["y"] - r, kp["x"] + r, kp["y"] + r], |
| fill=JOINT_COLORS[i]) |
| return canvas |
|
|
|
|
| def render_pose_overlay( |
| source: Image.Image, |
| poses: list[list[dict]], |
| active_person: int | None = None, |
| active_joint: int | None = None, |
| ) -> Image.Image | None: |
| """Skeleton on top of a dimmed source β the editing view. The active joint |
| gets a white-ringed highlight so the user can see what their next click |
| will move.""" |
| if source is None: |
| return None |
| w, h = source.size |
| base = ImageEnhance.Brightness(source.convert("RGB")).enhance(0.45) |
| skel = render_pose_skeleton(poses, w, h) |
| np_base, np_skel = np.array(base), np.array(skel) |
| mask = np_skel.any(axis=2) |
| np_base[mask] = np_skel[mask] |
| out = Image.fromarray(np_base) |
|
|
| if (active_person is not None and 0 <= active_person < len(poses) |
| and active_joint is not None and 0 <= active_joint < 18): |
| kp = poses[active_person][active_joint] |
| if kp["visible"]: |
| d = ImageDraw.Draw(out) |
| x, y = kp["x"], kp["y"] |
| |
| for r, col in [(10, (255, 255, 255)), (7, (0, 0, 0))]: |
| d.ellipse([x - r, y - r, x + r, y + r], outline=col, width=2) |
| return out |
|
|
|
|
| |
|
|
| def move_joint(poses, person_idx, joint_idx, x, y, width, height): |
| """Move (and make visible) a joint. Clamped to image bounds so clicks just |
| outside the canvas don't fly off.""" |
| if (not poses or person_idx is None or joint_idx is None |
| or not (0 <= person_idx < len(poses) and 0 <= joint_idx < 18)): |
| return poses |
| x = float(max(0, min(x, width - 1))) |
| y = float(max(0, min(y, height - 1))) |
| new = [list(p) for p in poses] |
| new[person_idx][joint_idx] = { |
| **new[person_idx][joint_idx], "x": x, "y": y, "visible": True, |
| } |
| return new |
|
|
|
|
| def hide_joint(poses, person_idx, joint_idx): |
| if (not poses or person_idx is None or joint_idx is None |
| or not (0 <= person_idx < len(poses) and 0 <= joint_idx < 18)): |
| return poses |
| new = [list(p) for p in poses] |
| new[person_idx][joint_idx] = {**new[person_idx][joint_idx], "visible": False} |
| return new |
|
|
|
|
| def clear_all_joints(poses): |
| return [[{**kp, "visible": False} for kp in pose] for pose in (poses or [])] |
|
|
|
|
| def default_pose_template(width: int, height: int) -> list[dict]: |
| """Standing-figure skeleton centred in the canvas β handy when detection |
| misses everyone, or when you want to draw a pose from scratch.""" |
| cx = width / 2 |
| s = min(width, height) * 0.40 |
| top = height / 2 - s |
| def y(f): return top + f * (2 * s) |
| return [ |
| {"name": "nose", "x": cx, "y": y(0.05), "visible": True}, |
| {"name": "neck", "x": cx, "y": y(0.15), "visible": True}, |
| {"name": "right_shoulder", "x": cx + s * 0.18, "y": y(0.18), "visible": True}, |
| {"name": "right_elbow", "x": cx + s * 0.25, "y": y(0.35), "visible": True}, |
| {"name": "right_wrist", "x": cx + s * 0.30, "y": y(0.50), "visible": True}, |
| {"name": "left_shoulder", "x": cx - s * 0.18, "y": y(0.18), "visible": True}, |
| {"name": "left_elbow", "x": cx - s * 0.25, "y": y(0.35), "visible": True}, |
| {"name": "left_wrist", "x": cx - s * 0.30, "y": y(0.50), "visible": True}, |
| {"name": "right_hip", "x": cx + s * 0.12, "y": y(0.55), "visible": True}, |
| {"name": "right_knee", "x": cx + s * 0.13, "y": y(0.75), "visible": True}, |
| {"name": "right_ankle", "x": cx + s * 0.14, "y": y(0.95), "visible": True}, |
| {"name": "left_hip", "x": cx - s * 0.12, "y": y(0.55), "visible": True}, |
| {"name": "left_knee", "x": cx - s * 0.13, "y": y(0.75), "visible": True}, |
| {"name": "left_ankle", "x": cx - s * 0.14, "y": y(0.95), "visible": True}, |
| {"name": "right_eye", "x": cx + s * 0.025, "y": y(0.03), "visible": True}, |
| {"name": "left_eye", "x": cx - s * 0.025, "y": y(0.03), "visible": True}, |
| {"name": "right_ear", "x": cx + s * 0.05, "y": y(0.05), "visible": True}, |
| {"name": "left_ear", "x": cx - s * 0.05, "y": y(0.05), "visible": True}, |
| ] |
|
|
|
|
| |
|
|
| def person_choices(poses): |
| return [f"Person {i+1}" for i in range(len(poses or []))] |
|
|
| def parse_person_idx(label: str | None) -> int | None: |
| if not label: |
| return None |
| try: |
| return int(label.replace("Person ", "")) - 1 |
| except ValueError: |
| return None |
|
|
| def joint_name_to_index(name: str | None) -> int: |
| if not name: |
| return -1 |
| try: |
| return OPENPOSE_KEYPOINT_NAMES.index(name) |
| except ValueError: |
| return -1 |
|
|