Files

312 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
pose_align_utils.py – Core utilities and helper functions for pose alignment
"""
from __future__ import annotations
import cv2
import numpy as np
import torch
import math
import time
from typing import List, Dict, Any, Tuple, Optional
from PIL import Image
# ─────────────────────────── Constants ────────────────────────────
NOSE, NECK, R_SH, L_SH, MIDHIP = 0, 1, 2, 5, 8
NUM_BODY_JOINTS = 18
FULL_TORSO = [NOSE, NECK, R_SH, L_SH, MIDHIP]
UPPER_TORSO = [NOSE, NECK, R_SH, L_SH]
TORSO = np.asarray(FULL_TORSO)
# Extra segments and joints we trust for robust estimates
HEAD_SEG = [(15, 16), (0, 15), (0, 16)] # ear-to-ear, nose-ear
TORSO_SEG = [(R_SH, L_SH), (NECK, MIDHIP)] # shoulder width, neck-hip
STABLE_SEGMENTS = HEAD_SEG + TORSO_SEG
ROBUST_JOINTS = np.asarray([NOSE, NECK, R_SH, L_SH, MIDHIP, 15, 16])
# OpenPose colour map (RGB 0-255) used by the mask extractor
OPENPOSE_COLOUR_MAP = {
0:(255,0,0),1:(255,85,0),2:(255,170,0),3:(255,255,0),4:(170,255,0),5:(85,255,0),6:(0,255,0),
7:(0,255,85),8:(0,255,170),9:(0,255,255),10:(0,170,255),11:(0,85,255),12:(0,0,255),13:(85,0,255),
14:(170,0,255),15:(255,0,255),16:(255,0,170),17:(255,0,85)
}
# BODY-25 limb pairs for the viewer debug node
BODY_25_PAIRS = [(1,8),(1,2),(1,5),(2,3),(3,4),(5,6),(6,7),(8,9),(9,10),(10,11),
(8,12),(12,13),(13,14),(1,0),(0,15),(15,17),(0,16),(16,18),
(14,19),(19,20),(14,21),(11,22),(22,23),(11,24)]
# Type aliases
Keypoints = np.ndarray
# ───────────────── Tensor ↔ OpenCV Convenience Functions ─────────────────────────────
def _to_chw(img: torch.Tensor) -> torch.Tensor:
"""Convert image tensor to CHW format"""
if img.dim() == 4: # (1,3,H,W) from ComfyUI
img = img[0]
return img if img.shape[0] == 3 else img.permute(2,0,1)
def torch_to_u8(t: torch.Tensor) -> np.ndarray:
"""Convert torch tensor to uint8 BGR numpy array"""
chw = _to_chw(t) # (3,H,W)
rgb = chw.permute(1,2,0).cpu().numpy()
return cv2.cvtColor((rgb*255).clip(0,255).astype(np.uint8), cv2.COLOR_RGB2BGR)
def u8_to_torch(bgr: np.ndarray) -> torch.Tensor:
"""Convert uint8 BGR numpy array to torch tensor"""
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.
return torch.from_numpy(rgb[None]) # (1,H,W,3) for ComfyUI
def torch_to_pil(tensor: torch.Tensor) -> Image.Image:
"""Convert torch tensor to PIL Image for saving"""
chw = _to_chw(tensor)
rgb = chw.permute(1,2,0).cpu().numpy()
rgb_uint8 = (rgb * 255).clip(0, 255).astype(np.uint8)
return Image.fromarray(rgb_uint8)
# ───────────────── Keypoint Processing Functions ─────────────────────────────
def _reshape(flat: List[float]) -> np.ndarray:
"""Reshape flat list of coordinates to Nx3 array"""
return np.asarray(flat, np.float32).reshape(-1, 3)[:, :3]
def _coords_to_xy(arr: np.ndarray, thr: float = 0.15) -> Keypoints:
"""Convert coordinate array to xy keypoints with confidence thresholding"""
kps = np.full((NUM_BODY_JOINTS,2), np.nan, np.float32)
J = min(NUM_BODY_JOINTS, arr.shape[0])
for j in range(J):
x, y, c = arr[j]
if c >= thr:
kps[j] = (x, y)
return kps
def dict_to_kps_single(p: Dict[str,Any], w: int, h: int) -> Keypoints:
"""Convert single pose dictionary to keypoints"""
raw = _reshape(p["pose_keypoints_2d"] if "pose_keypoints_2d" in p else p["keypoints"])
if raw[:, :2].max() <= 1.01: # 0-1 normalised coords
raw[:, 0] *= w
raw[:, 1] *= h
return _coords_to_xy(raw)
def kps_from_pose_json(js: List[Dict[str,Any]]) -> List[Keypoints]:
"""Extract keypoints from pose JSON data"""
if not (js and isinstance(js, list)):
return []
frame = js[0]
w, h = frame.get("canvas_width",1), frame.get("canvas_height",1)
if "people" in frame: # OpenPose
return [dict_to_kps_single(p,w,h) for p in frame["people"]]
if "animals" in frame: # AP-10K
return [dict_to_kps_single({"pose_keypoints_2d":a[:NUM_BODY_JOINTS*3]}, w, h)
for a in frame["animals"]]
return []
def extract_kps_from_mask(img: np.ndarray, mask: Optional[np.ndarray]=None, tol: int = 10) -> Keypoints:
"""Extract keypoints from colored mask image"""
if mask is None:
mask = np.ones(img.shape[:2], bool)
kps = np.full((NUM_BODY_JOINTS,2), np.nan, np.float32)
for j,(r,g,b) in OPENPOSE_COLOUR_MAP.items():
m = ((abs(img[...,2]-r)<tol)&(abs(img[...,1]-g)<tol)&
(abs(img[...,0]-b)<tol)&mask)
if m.any():
ys,xs = np.nonzero(m)
kps[j]=(xs.mean(),ys.mean())
return kps
# ───────────────── Transformation and Alignment Functions ─────────────────────────────
def estimate_translation(kps_json: Keypoints, kps_img: Keypoints,
focus: np.ndarray = TORSO, min_pairs: int = 2) -> np.ndarray:
"""Estimate translation between two sets of keypoints"""
vis = (~np.isnan(kps_json[:,0]) & ~np.isnan(kps_img[:,0]) &
np.isin(np.arange(NUM_BODY_JOINTS), focus))
if vis.sum() < min_pairs:
return np.zeros(2, np.float32)
return np.nanmedian(kps_img[vis] - kps_json[vis], 0).astype(np.float32)
def correct_json_offset(kps_json: Keypoints, kps_img: Keypoints) -> Keypoints:
"""Correct JSON keypoints offset using image keypoints"""
return kps_json + estimate_translation(kps_json, kps_img)
def robust_scale(src: Keypoints, dst: Keypoints) -> Optional[float]:
"""Compute robust scale estimate using stable segments"""
ratios = []
for i,j in STABLE_SEGMENTS:
if not (np.isnan(src[i]).any() or np.isnan(src[j]).any() or
np.isnan(dst[i]).any() or np.isnan(dst[j]).any()):
d_src = np.linalg.norm(src[i]-src[j])
d_dst = np.linalg.norm(dst[i]-dst[j])
if d_src > 1e-3:
ratios.append(d_dst / d_src)
return np.median(ratios) if ratios else None
def robust_translation(src: Keypoints, dst: Keypoints,
s: float, R: np.ndarray,
joints: np.ndarray = ROBUST_JOINTS) -> np.ndarray:
"""Compute robust translation estimate"""
deltas = []
for j in joints:
if j < NUM_BODY_JOINTS and not (np.isnan(src[j]).any() or np.isnan(dst[j]).any()):
deltas.append(dst[j] - s*(R @ src[j]))
return (np.median(deltas,0).astype(np.float32)
if deltas else np.zeros(2,np.float32))
def refine_translation(src: Keypoints, dst: Keypoints,
s: float, R: np.ndarray, t0: np.ndarray,
max_iter: int = 3, tol: float = 0.05) -> np.ndarray:
"""Iteratively refine translation estimate"""
vis = (~np.isnan(src[:,0]) & ~np.isnan(dst[:,0]) &
np.isin(np.arange(NUM_BODY_JOINTS), TORSO))
if vis.sum() < 2:
return t0
t = t0.copy()
for _ in range(max_iter):
warped = (s*(R @ src[vis].T)).T + t
delta = np.nanmedian(dst[vis]-warped,0).astype(np.float32)
if np.linalg.norm(delta) < tol:
break
t += delta
return t
def fit_pair(src: Keypoints, dst: Keypoints) -> Tuple[float,np.ndarray,np.ndarray,float]:
"""
Fit similarity transformation from source to destination keypoints
Returns: (scale, rotation_matrix, translation, mse_error)
"""
vis = ~np.isnan(src[:,0]) & ~np.isnan(dst[:,0])
if vis.sum() < 2:
return 1., np.eye(2,dtype=np.float32), np.zeros(2,np.float32), float("inf")
# 1) Scale estimation
s = robust_scale(src, dst) or 1.0
# 2) Rotation from Procrustes at that scale
muX, muY = src[vis].mean(0), dst[vis].mean(0)
Xc, Yc = src[vis]-muX, dst[vis]-muY
U,_,Vt = np.linalg.svd(Xc.T @ Yc)
R = Vt.T @ U.T
if np.linalg.det(R) < 0:
Vt[1] *= -1
R = Vt.T @ U.T
# 3) Translation (robust, then iteratively polished)
t = robust_translation(src, dst, s, R)
t = refine_translation(src, dst, s, R, t)
# 4) Residual
recon = (s*(R @ src[vis].T)).T + t
mse = float(np.mean(np.linalg.norm(recon-dst[vis],axis=1)**2))
return s, R.astype(np.float32), t.astype(np.float32), mse
# ───────────────── Image Processing Utilities ─────────────────────────────
def two_largest(mask: np.ndarray) -> Tuple[np.ndarray,np.ndarray]:
"""Find the two largest connected components in a mask"""
n,lab = cv2.connectedComponents(mask.astype(np.uint8), 8)
if n<=2:
return [mask], [np.zeros_like(mask,bool)]
areas=[(lab==i).sum() for i in range(1,n)]
a,b = np.argsort(areas)[-2:]
return [(lab==a+1),(lab==b+1)]
# ───────────────── Affine Transformation Utilities ─────────────────────────────
def normalize_angle(angle_deg: float) -> float:
"""Normalize angle to 0-360 degrees range"""
return ((angle_deg % 360) + 360) % 360
def _build_affine(scale: float, angle_deg: float,
tx: float, ty: float,
cx: float, cy: float) -> np.ndarray:
"""Build affine transformation matrix from parameters"""
# Normalize angle to prevent overflow issues
angle_deg = normalize_angle(angle_deg)
th = math.radians(angle_deg)
R = np.array([[math.cos(th), -math.sin(th)],
[math.sin(th), math.cos(th)]], np.float32) * scale
t = np.array([tx, ty], np.float32) + np.array([cx, cy]) - R @ np.array([cx, cy])
return np.hstack([R, t[:, None]])
def decompose_affine_matrix(matrix: np.ndarray, cx: float, cy: float) -> Tuple[float, float, float, float]:
"""
Decompose an affine transformation matrix back into scale, rotation, and translation components.
Args:
matrix: 2x3 affine transformation matrix [R|t] where R is 2x2 rotation+scale, t is 2x1 translation
cx, cy: Center of rotation used in the original transformation
Returns:
(scale, angle_deg, tx, ty) - Individual transformation components
"""
# Extract rotation+scale matrix and translation vector
R = matrix[:2, :2] # 2x2 rotation+scale matrix
t = matrix[:2, 2] # 2x1 translation vector
# Decompose scale and rotation from the 2x2 matrix
# For a rotation+scale matrix: R = scale * [[cos(θ), -sin(θ)], [sin(θ), cos(θ)]]
scale = np.sqrt(np.linalg.det(R)) # Determinant gives scale^2 for rotation matrix
# Handle potential negative scales (reflections)
if np.linalg.det(R) < 0:
scale = -scale
# Extract rotation matrix by normalizing out the scale
R_normalized = R / scale
# Extract angle from normalized rotation matrix
# cos(θ) = R_normalized[0,0], sin(θ) = R_normalized[1,0]
angle_rad = math.atan2(R_normalized[1, 0], R_normalized[0, 0])
angle_deg = math.degrees(angle_rad)
# Recover the original translation (tx, ty) by reversing the center compensation
# In _build_affine: final_t = t + center - R @ center
# So: original_t = final_t - center + R @ center
center = np.array([cx, cy])
original_t = t - center + R @ center
tx, ty = original_t[0], original_t[1]
# Normalize angle to 0-360 range
angle_deg = normalize_angle(angle_deg)
return float(scale), float(angle_deg), float(tx), float(ty)
# ───────────────── Data Storage for API Access ─────────────────────────────
# Global storage for transformation data (for API access)
_transform_data_cache = {}
def store_transform_data(node_id: str, data: Dict[str, Any]):
"""Store transformation data for API access - simplified to handle new structure"""
global _transform_data_cache
# Handle both old and new calling conventions
if isinstance(data, dict) and 'matrices' in data:
# New format: data is already structured
matrices = data['matrices']
offset_corrections = data.get('offsetCorrections', {'A': {'x': 0, 'y': 0}, 'B': {'x': 0, 'y': 0}})
input_dimensions = data.get('inputDimensions', {})
else:
# Old format: data contains matrices directly, second param might be offset_corrections
matrices = data
offset_corrections = {'A': {'x': 0, 'y': 0}, 'B': {'x': 0, 'y': 0}}
input_dimensions = {}
# Convert numpy arrays to lists for JSON serialization
serializable_matrices = {}
for key, matrix in matrices.items():
if matrix is not None:
serializable_matrices[key] = matrix.tolist()
else:
serializable_matrices[key] = None
_transform_data_cache[node_id] = {
'timestamp': time.time(),
'matrices': serializable_matrices,
'offsetCorrections': offset_corrections,
'inputDimensions': input_dimensions # NEW: Store input image dimensions for bounding boxes
}
def get_transform_data(node_id: str) -> Optional[Dict]:
"""Retrieve transformation data for API access"""
global _transform_data_cache
return _transform_data_cache.get(node_id)