import json import struct import numpy as np from PIL import Image import io import logging def normalize(v): norm = np.linalg.norm(v) if norm == 0: return v return v / norm def create_unit_sphere(segments=16, rings=16): verts = [] normals = [] uvs = [] indices = [] for i in range(rings + 1): lat = np.pi * i / rings y = np.cos(lat) r = np.sin(lat) for j in range(segments + 1): lon = 2 * np.pi * j / segments x = r * np.cos(lon) z = r * np.sin(lon) p = [x, y, z] verts.append(p) normals.append(p) # Unit sphere normal is position uvs.append([j / segments, i / rings]) for i in range(rings): for j in range(segments): idx0 = i * (segments + 1) + j idx1 = idx0 + 1 idx2 = (i + 1) * (segments + 1) + j idx3 = idx2 + 1 indices.append(idx0); indices.append(idx2); indices.append(idx1) indices.append(idx1); indices.append(idx2); indices.append(idx3) return np.array(verts, dtype=np.float32), np.array(normals, dtype=np.float32), np.array(uvs, dtype=np.float32), np.array(indices, dtype=np.uint32) def create_open_cylinder(segments=16): # Unit cylinder along Y axis, from 0 to 1. Radius 1. # No caps. verts = [] normals = [] uvs = [] indices = [] for i in range(segments + 1): theta = 2 * np.pi * i / segments x = np.cos(theta) z = np.sin(theta) n = [x, 0, z] # Bottom vertex (y=0) verts.append([x, 0, z]) normals.append(n) uvs.append([i / segments, 0.0]) # Top vertex (y=1) verts.append([x, 1, z]) normals.append(n) uvs.append([i / segments, 1.0]) for i in range(segments): idx0 = 2 * i idx1 = 2 * i + 1 idx2 = 2 * (i + 1) idx3 = 2 * (i + 1) + 1 # Triangle 1 indices.append(idx0); indices.append(idx2); indices.append(idx1) # Triangle 2 indices.append(idx1); indices.append(idx2); indices.append(idx3) return np.array(verts, dtype=np.float32), np.array(normals, dtype=np.float32), np.array(uvs, dtype=np.float32), np.array(indices, dtype=np.uint32) def align_to_4bytes(b, pad_char=b'\x00'): padding = (4 - (len(b) % 4)) % 4 return b + pad_char * padding def save_cylinder_specs_as_glb_animation(cylinder_specs_list, filepath, radius=21.5, fps=30.0): # 1. Create Meshes # Sphere for Joints s_verts, s_norms, s_uvs, s_indices = create_unit_sphere(segments=16, rings=16) # Cylinder for Bones c_verts, c_norms, c_uvs, c_indices = create_open_cylinder(segments=16) # 2. Analyze Topology from Frame 0 num_frames = len(cylinder_specs_list) if num_frames == 0: return [] # We assume the number of cylinders is constant num_cylinders = len(cylinder_specs_list[0]) if num_cylinders == 0: return [] # Collect colors unique_colors = [] color_map = {} for specs in cylinder_specs_list: for spec in specs: color = tuple(spec[2]) if color not in color_map: color_map[color] = len(unique_colors) unique_colors.append(color) if not unique_colors: unique_colors = [(1.0, 1.0, 1.0, 1.0)] color_map = {(1.0, 1.0, 1.0, 1.0): 0} tex_width = len(unique_colors) texture_img = Image.new('RGBA', (tex_width, 1)) pixels = [] for c in unique_colors: pixels.append((int(c[0]*255), int(c[1]*255), int(c[2]*255), int(c[3]*255))) texture_img.putdata(pixels) # Calculate global bounding box (using all frames) all_points = [] for specs in cylinder_specs_list: for spec in specs: start, end, _ = spec if np.linalg.norm(end - start) > 1e-6: # Apply Y-flip and Z-flip s = np.array(start); s[1] = -s[1]; s[2] = -s[2] e = np.array(end); e[1] = -e[1]; e[2] = -e[2] all_points.append(s) all_points.append(e) if not all_points: center = np.array([0, 0, 0]) scale_factor = 1.0 else: all_points_np = np.array(all_points) min_coords = np.min(all_points_np, axis=0) max_coords = np.max(all_points_np, axis=0) center = (min_coords + max_coords) / 2 size = max_coords - min_coords max_dim = np.max(size) scale_factor = 1.8 / max_dim if max_dim > 10 else 1.0 logging.info(f"Centering at {center}, Scaling by {scale_factor}") # Build Skeleton Topology from Frame 0 # We identify unique joints by position joints = [] # List of { 'pos': np.array, 'color': tuple } # Map from (cyl_idx, endpoint_type) -> joint_idx # endpoint_type: 0=start, 1=end cyl_to_joint = {} # Helper to find or add joint def get_joint_idx(pos, color): # Simple distance check pos_np = np.array(pos) # Apply transforms for consistency with animation loop pos_np[1] = -pos_np[1] pos_np[2] = -pos_np[2] pos_np = (pos_np - center) * scale_factor for idx, j in enumerate(joints): if np.linalg.norm(j['pos'] - pos_np) < 1e-4: # Tolerance return idx joints.append({'pos': pos_np, 'color': color}) return len(joints) - 1 frame0 = cylinder_specs_list[0] for i, spec in enumerate(frame0): start, end, color = spec s_idx = get_joint_idx(start, tuple(color)) e_idx = get_joint_idx(end, tuple(color)) cyl_to_joint[(i, 0)] = s_idx cyl_to_joint[(i, 1)] = e_idx num_joints = len(joints) logging.info(f"Identified {num_joints} unique joints from {num_cylinders} bones.") # 3. Prepare Animation Data # Nodes: # - Joints (Spheres): 0 to num_joints-1 # - Bones (Cylinders): num_joints to num_joints + num_cylinders - 1 total_nodes = num_joints + num_cylinders translations = [] # total_nodes * num_frames * 3 rotations = [] # total_nodes * num_frames * 4 scales = [] # total_nodes * num_frames * 3 # Pre-fill joint colors joint_colors = [j['color'] for j in joints] # Bone colors bone_colors = [] for i in range(num_cylinders): # Find first valid color for this bone across frames c = (1,1,1,1) for f in range(num_frames): if len(cylinder_specs_list[f]) > i: spec = cylinder_specs_list[f][i] if np.linalg.norm(spec[2]) > 0: c = tuple(spec[2]) break bone_colors.append(c) for f in range(num_frames): specs = cylinder_specs_list[f] # 1. Calculate Joint Positions for this frame # We use the first bone that references a joint to define its position current_joint_positions = [None] * num_joints # Pad specs if missing if len(specs) < num_cylinders: specs = specs + [(np.zeros(3), np.zeros(3), (0,0,0,0))] * (num_cylinders - len(specs)) for i in range(num_cylinders): start, end, _ = specs[i] # Transform s = np.array(start); s[1] = -s[1]; s[2] = -s[2] e = np.array(end); e[1] = -e[1]; e[2] = -e[2] s = (s - center) * scale_factor e = (e - center) * scale_factor s_j_idx = cyl_to_joint.get((i, 0)) e_j_idx = cyl_to_joint.get((i, 1)) if s_j_idx is not None and current_joint_positions[s_j_idx] is None: current_joint_positions[s_j_idx] = s if e_j_idx is not None and current_joint_positions[e_j_idx] is None: current_joint_positions[e_j_idx] = e # Fill missing joints (if any) with 0 or previous? # Just use 0 if missing (shouldn't happen if topology is constant) for j in range(num_joints): if current_joint_positions[j] is None: current_joint_positions[j] = np.zeros(3) # 2. Update Joint Nodes scaled_radius = radius * scale_factor for j in range(num_joints): translations.append(current_joint_positions[j].tolist()) rotations.append([0,0,0,1]) scales.append([scaled_radius, scaled_radius, scaled_radius]) # 3. Update Bone Nodes for i in range(num_cylinders): s_j_idx = cyl_to_joint.get((i, 0)) e_j_idx = cyl_to_joint.get((i, 1)) if s_j_idx is not None and e_j_idx is not None: p_start = current_joint_positions[s_j_idx] p_end = current_joint_positions[e_j_idx] vec = p_end - p_start length = np.linalg.norm(vec) if length < 1e-6: translations.append([0,0,0]) rotations.append([0,0,0,1]) scales.append([0,0,0]) else: translations.append(p_start.tolist()) scales.append([scaled_radius, length, scaled_radius]) # Rotation v_from = np.array([0.0, 1.0, 0.0]) v_to = vec / length d = np.dot(v_from, v_to) if d < -0.999999: tmp = np.cross(np.array([1.0, 0.0, 0.0]), v_from) if np.linalg.norm(tmp) < 1e-6: tmp = np.cross(np.array([0.0, 0.0, 1.0]), v_from) tmp = normalize(tmp) q = [tmp[0], tmp[1], tmp[2], 0.0] elif d > 0.999999: q = [0.0, 0.0, 0.0, 1.0] else: s = np.sqrt((1+d) * 2) invs = 1 / s c = np.cross(v_from, v_to) q = [c[0] * invs, c[1] * invs, c[2] * invs, s * 0.5] q_norm = np.linalg.norm(q) q = [x / q_norm for x in q] rotations.append(q) else: # Orphaned bone? translations.append([0,0,0]) rotations.append([0,0,0,1]) scales.append([0,0,0]) # 4. Construct GLB unique_color_indices = sorted(list(set([color_map[c] for c in (joint_colors + bone_colors)]))) mesh_indices_by_color_idx = {} # color_idx -> {"cyl": idx, "sph": idx} meshes = [] accessors = [] buffer_views = [] offset = 0 # Create meshes for each unique color for c_idx in unique_color_indices: u = (c_idx + 0.5) / tex_width v = 0.5 mesh_pair = {} # Cylinder Mesh m_verts = c_verts m_norms = c_norms m_uvs = np.array([[u, v]] * len(c_verts), dtype=np.float32) m_indices = c_indices # Add to buffer (Indices) bv_ind_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(m_indices.tobytes()), "target": 34963}) acc_ind_idx = len(accessors) accessors.append({"bufferView": bv_ind_idx, "byteOffset": 0, "componentType": 5125, "count": len(m_indices), "type": "SCALAR", "min": [int(np.min(m_indices))], "max": [int(np.max(m_indices))]}) offset += len(m_indices.tobytes()); offset = (offset + 3) & ~3 # Vertices bv_vert_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(m_verts.tobytes()), "target": 34962}) acc_vert_idx = len(accessors) accessors.append({"bufferView": bv_vert_idx, "byteOffset": 0, "componentType": 5126, "count": len(m_verts), "type": "VEC3", "min": np.min(m_verts, axis=0).tolist(), "max": np.max(m_verts, axis=0).tolist()}) offset += len(m_verts.tobytes()) # Normals bv_norm_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(m_norms.tobytes()), "target": 34962}) acc_norm_idx = len(accessors) accessors.append({"bufferView": bv_norm_idx, "byteOffset": 0, "componentType": 5126, "count": len(m_norms), "type": "VEC3"}) offset += len(m_norms.tobytes()) # UVs bv_uv_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(m_uvs.tobytes()), "target": 34962}) acc_uv_idx = len(accessors) accessors.append({"bufferView": bv_uv_idx, "byteOffset": 0, "componentType": 5126, "count": len(m_uvs), "type": "VEC2"}) offset += len(m_uvs.tobytes()) mesh_pair["cyl"] = len(meshes) meshes.append({"primitives": [{"attributes": {"POSITION": acc_vert_idx, "NORMAL": acc_norm_idx, "TEXCOORD_0": acc_uv_idx}, "indices": acc_ind_idx, "material": 0}]}) # Sphere Mesh m_verts = s_verts m_norms = s_norms m_uvs = np.array([[u, v]] * len(s_verts), dtype=np.float32) m_indices = s_indices # Add to buffer (Indices) bv_ind_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(m_indices.tobytes()), "target": 34963}) acc_ind_idx = len(accessors) accessors.append({"bufferView": bv_ind_idx, "byteOffset": 0, "componentType": 5125, "count": len(m_indices), "type": "SCALAR", "min": [int(np.min(m_indices))], "max": [int(np.max(m_indices))]}) offset += len(m_indices.tobytes()); offset = (offset + 3) & ~3 # Vertices bv_vert_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(m_verts.tobytes()), "target": 34962}) acc_vert_idx = len(accessors) accessors.append({"bufferView": bv_vert_idx, "byteOffset": 0, "componentType": 5126, "count": len(m_verts), "type": "VEC3", "min": np.min(m_verts, axis=0).tolist(), "max": np.max(m_verts, axis=0).tolist()}) offset += len(m_verts.tobytes()) # Normals bv_norm_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(m_norms.tobytes()), "target": 34962}) acc_norm_idx = len(accessors) accessors.append({"bufferView": bv_norm_idx, "byteOffset": 0, "componentType": 5126, "count": len(m_norms), "type": "VEC3"}) offset += len(m_norms.tobytes()) # UVs bv_uv_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(m_uvs.tobytes()), "target": 34962}) acc_uv_idx = len(accessors) accessors.append({"bufferView": bv_uv_idx, "byteOffset": 0, "componentType": 5126, "count": len(m_uvs), "type": "VEC2"}) offset += len(m_uvs.tobytes()) mesh_pair["sph"] = len(meshes) meshes.append({"primitives": [{"attributes": {"POSITION": acc_vert_idx, "NORMAL": acc_norm_idx, "TEXCOORD_0": acc_uv_idx}, "indices": acc_ind_idx, "material": 0}]}) mesh_indices_by_color_idx[c_idx] = mesh_pair # Animation Data times = np.array([i / fps for i in range(num_frames)], dtype=np.float32) bv_time_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(times.tobytes())}) acc_time_idx = len(accessors) accessors.append({"bufferView": bv_time_idx, "byteOffset": 0, "componentType": 5126, "count": len(times), "type": "SCALAR", "min": [float(times[0])], "max": [float(times[-1])]}) offset += len(times.tobytes()) translations_np = np.array(translations, dtype=np.float32).reshape(num_frames, total_nodes, 3) rotations_np = np.array(rotations, dtype=np.float32).reshape(num_frames, total_nodes, 4) scales_np = np.array(scales, dtype=np.float32).reshape(num_frames, total_nodes, 3) animations = [{"channels": [], "samplers": []}] nodes = [] scene_nodes = [] # Create Nodes # 1. Joints for i in range(num_joints): c_idx = color_map[joint_colors[i]] mesh_idx = mesh_indices_by_color_idx[c_idx]["sph"] node_idx = len(nodes) nodes.append({ "mesh": mesh_idx, "name": f"joint_{i}", "translation": translations_np[0, i].tolist(), "rotation": rotations_np[0, i].tolist(), "scale": scales_np[0, i].tolist() }) scene_nodes.append(node_idx) # Animation # Translation t_data = translations_np[:, i, :].flatten().tobytes() bv_t_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(t_data)}) acc_t_idx = len(accessors) accessors.append({"bufferView": bv_t_idx, "byteOffset": 0, "componentType": 5126, "count": num_frames, "type": "VEC3"}) offset += len(t_data) sampler_t_idx = len(animations[0]["samplers"]) animations[0]["samplers"].append({"input": acc_time_idx, "interpolation": "LINEAR", "output": acc_t_idx}) animations[0]["channels"].append({"sampler": sampler_t_idx, "target": {"node": node_idx, "path": "translation"}}) # Scale (Joints scale is constant, but we animate it just in case or to simplify loop) s_data = scales_np[:, i, :].flatten().tobytes() bv_s_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(s_data)}) acc_s_idx = len(accessors) accessors.append({"bufferView": bv_s_idx, "byteOffset": 0, "componentType": 5126, "count": num_frames, "type": "VEC3"}) offset += len(s_data) sampler_s_idx = len(animations[0]["samplers"]) animations[0]["samplers"].append({"input": acc_time_idx, "interpolation": "LINEAR", "output": acc_s_idx}) animations[0]["channels"].append({"sampler": sampler_s_idx, "target": {"node": node_idx, "path": "scale"}}) # 2. Bones for i in range(num_cylinders): c_idx = color_map[bone_colors[i]] mesh_idx = mesh_indices_by_color_idx[c_idx]["cyl"] node_idx = len(nodes) nodes.append({ "mesh": mesh_idx, "name": f"bone_{i}", "translation": translations_np[0, num_joints + i].tolist(), "rotation": rotations_np[0, num_joints + i].tolist(), "scale": scales_np[0, num_joints + i].tolist() }) scene_nodes.append(node_idx) # Animation # Translation t_data = translations_np[:, num_joints + i, :].flatten().tobytes() bv_t_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(t_data)}) acc_t_idx = len(accessors) accessors.append({"bufferView": bv_t_idx, "byteOffset": 0, "componentType": 5126, "count": num_frames, "type": "VEC3"}) offset += len(t_data) sampler_t_idx = len(animations[0]["samplers"]) animations[0]["samplers"].append({"input": acc_time_idx, "interpolation": "LINEAR", "output": acc_t_idx}) animations[0]["channels"].append({"sampler": sampler_t_idx, "target": {"node": node_idx, "path": "translation"}}) # Rotation r_data = rotations_np[:, num_joints + i, :].flatten().tobytes() bv_r_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(r_data)}) acc_r_idx = len(accessors) accessors.append({"bufferView": bv_r_idx, "byteOffset": 0, "componentType": 5126, "count": num_frames, "type": "VEC4"}) offset += len(r_data) sampler_r_idx = len(animations[0]["samplers"]) animations[0]["samplers"].append({"input": acc_time_idx, "interpolation": "LINEAR", "output": acc_r_idx}) animations[0]["channels"].append({"sampler": sampler_r_idx, "target": {"node": node_idx, "path": "rotation"}}) # Scale s_data = scales_np[:, num_joints + i, :].flatten().tobytes() bv_s_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(s_data)}) acc_s_idx = len(accessors) accessors.append({"bufferView": bv_s_idx, "byteOffset": 0, "componentType": 5126, "count": num_frames, "type": "VEC3"}) offset += len(s_data) sampler_s_idx = len(animations[0]["samplers"]) animations[0]["samplers"].append({"input": acc_time_idx, "interpolation": "LINEAR", "output": acc_s_idx}) animations[0]["channels"].append({"sampler": sampler_s_idx, "target": {"node": node_idx, "path": "scale"}}) # Texture img_byte_arr = io.BytesIO() texture_img.save(img_byte_arr, format='PNG') texture_bin = img_byte_arr.getvalue() bv_tex_idx = len(buffer_views) buffer_views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(texture_bin)}) offset += len(texture_bin) total_length = offset binary_data = bytearray(total_length) current_ptr = 0 # Fill Buffer for c_idx in unique_color_indices: u = (c_idx + 0.5) / tex_width v = 0.5 # Cylinder m_verts = c_verts m_norms = c_norms m_uvs = np.array([[u, v]] * len(c_verts), dtype=np.float32) m_indices = c_indices d = m_indices.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d); current_ptr = (current_ptr + 3) & ~3 d = m_verts.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) d = m_norms.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) d = m_uvs.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) # Sphere m_verts = s_verts m_norms = s_norms m_uvs = np.array([[u, v]] * len(s_verts), dtype=np.float32) m_indices = s_indices d = m_indices.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d); current_ptr = (current_ptr + 3) & ~3 d = m_verts.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) d = m_norms.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) d = m_uvs.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) # Time d = times.tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) # Animation Data # Joints for i in range(num_joints): d = translations_np[:, i, :].flatten().tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) d = scales_np[:, i, :].flatten().tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) # Bones for i in range(num_cylinders): d = translations_np[:, num_joints + i, :].flatten().tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) d = rotations_np[:, num_joints + i, :].flatten().tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) d = scales_np[:, num_joints + i, :].flatten().tobytes(); binary_data[current_ptr:current_ptr+len(d)] = d; current_ptr += len(d) # Texture binary_data[current_ptr:current_ptr+len(texture_bin)] = texture_bin; current_ptr += len(texture_bin) # JSON gltf = { "asset": {"version": "2.0", "generator": "ComfyUI-SCAIL-Pose"}, "buffers": [{"byteLength": total_length}], "bufferViews": buffer_views, "accessors": accessors, "images": [{"bufferView": bv_tex_idx, "mimeType": "image/png"}], "textures": [{"source": 0}], "materials": [{"pbrMetallicRoughness": {"baseColorTexture": {"index": 0}, "baseColorFactor": [1, 1, 1, 1], "metallicFactor": 0.0, "roughnessFactor": 0.3}, "doubleSided": True}], "meshes": meshes, "nodes": nodes, "scenes": [{"nodes": scene_nodes}], "scene": 0, "animations": animations } json_str = json.dumps(gltf) json_bytes = align_to_4bytes(json_str.encode('utf-8'), pad_char=b' ') binary_data = align_to_4bytes(binary_data, pad_char=b'\x00') total_file_size = 12 + 8 + len(json_bytes) + 8 + len(binary_data) with open(filepath, 'wb') as f: f.write(b'glTF') f.write(struct.pack('