import json import os import time from typing import Tuple, List, Dict import numpy as np import torch from PIL import Image, ImageDraw from folder_paths import get_temp_directory def _clamp_int(v: int, lo: int, hi: int) -> int: return max(lo, min(hi, v)) def _grid_in_rect_local(w: int, h: int, density_per_100px: float, margin_px: int, jitter_px: float, seed: int) -> np.ndarray: """ Generate grid points in local coordinate space, centered at origin. Returns points in range [-w/2, w/2] x [-h/2, h/2]. """ w_eff = max(1, w - 2 * margin_px) h_eff = max(1, h - 2 * margin_px) cols = max(1, int(round((w_eff / 100.0) * float(density_per_100px)))) rows = max(1, int(round((h_eff / 100.0) * float(density_per_100px)))) if cols == 1: xs = np.array([0.0], dtype=np.float32) else: xs = np.linspace(-w_eff / 2.0, w_eff / 2.0, cols, dtype=np.float32) if rows == 1: ys = np.array([0.0], dtype=np.float32) else: ys = np.linspace(-h_eff / 2.0, h_eff / 2.0, rows, dtype=np.float32) xv, yv = np.meshgrid(xs, ys) pts = np.stack([xv.flatten(), yv.flatten()], axis=-1).astype(np.float32) if jitter_px and jitter_px > 0: rng = np.random.RandomState(seed) jitter = (rng.rand(*pts.shape).astype(np.float32) * 2.0 - 1.0) * float(jitter_px) pts = pts + jitter return pts def _transform_points(points: np.ndarray, cx: float, cy: float, rotation: float) -> np.ndarray: """ Rotate points around origin, then translate to (cx, cy). points: [N, 2] in local space rotation: angle in radians """ if rotation != 0: cos_a = np.cos(rotation) sin_a = np.sin(rotation) x_rot = points[:, 0] * cos_a - points[:, 1] * sin_a y_rot = points[:, 0] * sin_a + points[:, 1] * cos_a points = np.stack([x_rot, y_rot], axis=-1) points[:, 0] += cx points[:, 1] += cy return points def _draw_overlay_multi(img: torch.Tensor, rectangles: List[Dict], density_per_100px: float, margin_px: int, jitter_px: float, seed: int, color_rect=(0, 255, 255), color_pts=(0, 255, 0)) -> torch.Tensor: """ Draw multiple rotated rectangles + points on IMAGE [T,H,W,C] float 0..1. Returns IMAGE [1,H,W,C] float 0..1. """ if img.ndim != 4: raise ValueError("image must be IMAGE [T,H,W,C]") base = (img[0].detach().cpu().numpy().clip(0, 1) * 255.0).astype(np.uint8) pil = Image.fromarray(base, mode="RGB") draw = ImageDraw.Draw(pil) for rect_data in rectangles: x = float(rect_data.get("x", 0)) y = float(rect_data.get("y", 0)) w = float(rect_data.get("w", 100)) h = float(rect_data.get("h", 100)) rotation = float(rect_data.get("rotation", 0)) cx = x + w / 2.0 cy = y + h / 2.0 # Draw rotated rectangle outline corners = np.array([ [-w/2, -h/2], [ w/2, -h/2], [ w/2, h/2], [-w/2, h/2] ], dtype=np.float32) if rotation != 0: cos_a = np.cos(rotation) sin_a = np.sin(rotation) x_rot = corners[:, 0] * cos_a - corners[:, 1] * sin_a y_rot = corners[:, 0] * sin_a + corners[:, 1] * cos_a corners = np.stack([x_rot, y_rot], axis=-1) corners[:, 0] += cx corners[:, 1] += cy for i in range(4): p1 = (int(corners[i, 0]), int(corners[i, 1])) p2 = (int(corners[(i+1) % 4, 0]), int(corners[(i+1) % 4, 1])) draw.line([p1, p2], fill=tuple(color_rect), width=2) # Generate and draw points for this rectangle local_pts = _grid_in_rect_local(int(w), int(h), density_per_100px, margin_px, jitter_px, seed) world_pts = _transform_points(local_pts, cx, cy, rotation) r = 2 for p in world_pts: px = int(round(p[0])) py = int(round(p[1])) if 0 <= px < pil.width and 0 <= py < pil.height: draw.ellipse([px - r, py - r, px + r, py + r], fill=tuple(color_pts)) out = torch.from_numpy(np.asarray(pil).astype(np.float32) / 255.0).unsqueeze(0) return out def _save_png(image_bhwc_float: torch.Tensor, prefix="lt_grid_editor") -> str: """ Save IMAGE [1,H,W,C] float 0..1 to temp as PNG and return filename (not full path). """ save_dir = get_temp_directory() os.makedirs(save_dir, exist_ok=True) ts = int(time.time() * 1000) filename = f"{prefix}_{ts}.png" path = os.path.join(save_dir, filename) im = (image_bhwc_float[0].clamp(0, 1).cpu().numpy() * 255.0).astype(np.uint8) Image.fromarray(im, mode="RGB").save(path, format="PNG") return filename class RectEditor: """ LiteTracker: Grid Editor (Multi-Rectangle with Rotation) - Click 'Open Grid Editor' to open modal - Click-drag to draw rectangles (adds to array) - Drag rotation handle (small circle at top) to rotate each rectangle - Right-click on rectangle to delete it - Undo button removes last rectangle - Clear button removes all rectangles - Generates grid points for all rectangles combined """ @classmethod def INPUT_TYPES(cls): return { "required": { "image": ("IMAGE",), "density_per_100px": ("FLOAT", {"default": 5.0, "min": 0.1, "max": 100.0, "step": 0.1}), }, "optional": { "rect_json": ("STRING", {"default": ""}), "x": ("INT", {"default": 100, "min": 0, "max": 100000}), "y": ("INT", {"default": 100, "min": 0, "max": 100000}), "w": ("INT", {"default": 400, "min": 1, "max": 100000}), "h": ("INT", {"default": 400, "min": 1, "max": 100000}), "margin_px": ("INT", {"default": 0, "min": 0, "max": 100000}), "random_jitter_px": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10000.0, "step": 0.1}), "seed": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1}), } } RETURN_TYPES = ("TENSOR", "IMAGE", "BBOX", "STRING", "STRING") RETURN_NAMES = ("points", "preview_png", "bbox", "rect_json", "bg_base64") FUNCTION = "run" CATEGORY = "LiteTracker" def run(self, image: torch.Tensor, density_per_100px: float, rect_json: str = "", x: int = 100, y: int = 100, w: int = 400, h: int = 400, margin_px: int = 0, random_jitter_px: float = 0.0, seed: int = 0): if image.ndim != 4: raise ValueError("image must be IMAGE [T,H,W,C]") H = int(image.shape[1]) W = int(image.shape[2]) # Parse rectangles array from rect_json rectangles = [] if rect_json and rect_json.strip(): try: parsed = json.loads(rect_json) if isinstance(parsed, list): rectangles = parsed elif isinstance(parsed, dict): # Old single-rectangle format rectangles = [parsed] except Exception: pass # Fallback: use manual x/y/w/h inputs if not rectangles: rectangles = [{"x": x, "y": y, "w": w, "h": h, "rotation": 0}] # Clamp margin/jitter max_margin = max(0, min(W, H) // 2) max_jitter = max(1.0, min(float(min(W, H)) / 4.0, 1000.0)) margin_px = int(max(0, min(int(margin_px), max_margin))) random_jitter_px = float(max(0.0, min(float(random_jitter_px), max_jitter))) # Generate points for all rectangles all_points = [] for rect_data in rectangles: rx = float(rect_data.get("x", 100)) ry = float(rect_data.get("y", 100)) rw = float(rect_data.get("w", 400)) rh = float(rect_data.get("h", 400)) rotation = float(rect_data.get("rotation", 0)) # Clamp rectangle to image bounds (approximately) rx = max(0, min(rx, W - 1)) ry = max(0, min(ry, H - 1)) rw = max(1, min(rw, W)) rh = max(1, min(rh, H)) cx = rx + rw / 2.0 cy = ry + rh / 2.0 # Generate points in local space local_pts = _grid_in_rect_local( int(rw), int(rh), density_per_100px, margin_px, random_jitter_px, seed ) # Transform to world space world_pts = _transform_points(local_pts, cx, cy, rotation) all_points.append(world_pts) # Combine all points if all_points: combined_points = np.concatenate(all_points, axis=0) else: combined_points = np.zeros((0, 2), dtype=np.float32) points_tensor = torch.from_numpy(combined_points.astype(np.float32)) # Build overlay image overlay = _draw_overlay_multi( image, rectangles, density_per_100px, margin_px, random_jitter_px, seed ) # Compute bounding box of all points if len(combined_points) > 0: bbox_out = { "x": int(np.min(combined_points[:, 0])), "y": int(np.min(combined_points[:, 1])), "w": int(np.max(combined_points[:, 0]) - np.min(combined_points[:, 0])), "h": int(np.max(combined_points[:, 1]) - np.min(combined_points[:, 1])) } else: bbox_out = {"x": 0, "y": 0, "w": 0, "h": 0} rect_json_out = json.dumps(rectangles) # Encode INPUT image as base64 (not overlay) import base64 import io buffer = io.BytesIO() im = (image[0].clamp(0, 1).cpu().numpy() * 255.0).astype(np.uint8) # Changed: image instead of overlay Image.fromarray(im, mode="RGB").save(buffer, format="PNG") base64_str = base64.b64encode(buffer.getvalue()).decode("ascii") return { "ui": {"bg_base64": [base64_str]}, "result": (points_tensor, overlay, bbox_out, rect_json_out, base64_str) } NODE_CLASS_MAPPINGS = { "RectEditor": RectEditor, } NODE_DISPLAY_NAME_MAPPINGS = { "RectEditor": "LiteTracker: Grid Editor", }