From aaa509806733ed4d48f22c3c24466070944e011b Mon Sep 17 00:00:00 2001 From: enne2 Date: Fri, 11 Sep 2026 23:48:27 +0200 Subject: [PATCH] fix: calibrate submenu tent lights from asset coordinates --- .../shaders/circus_sign_lights.gdshader.uid | 1 + .../shaders/submenu_tent_lights.gdshader | 103 ++++++ .../shaders/submenu_tent_lights.gdshader.uid | 1 + menus/assets/shaders/tent_lights.gdshader.uid | 1 + menus/scripts/components/menu_ui.gd | 9 + .../scripts/screens/p2a_juggle_menu_screen.gd | 1 + menus/scripts/screens/p3a_gym_menu_screen.gd | 1 + tools/analyze_tent_lights.py | 297 ++++++++++++++++++ 8 files changed, 414 insertions(+) create mode 100644 menus/assets/shaders/circus_sign_lights.gdshader.uid create mode 100644 menus/assets/shaders/submenu_tent_lights.gdshader create mode 100644 menus/assets/shaders/submenu_tent_lights.gdshader.uid create mode 100644 menus/assets/shaders/tent_lights.gdshader.uid create mode 100644 tools/analyze_tent_lights.py diff --git a/menus/assets/shaders/circus_sign_lights.gdshader.uid b/menus/assets/shaders/circus_sign_lights.gdshader.uid new file mode 100644 index 0000000..9c70c4f --- /dev/null +++ b/menus/assets/shaders/circus_sign_lights.gdshader.uid @@ -0,0 +1 @@ +uid://bk0kwrttwfdl3 diff --git a/menus/assets/shaders/submenu_tent_lights.gdshader b/menus/assets/shaders/submenu_tent_lights.gdshader new file mode 100644 index 0000000..a0fce67 --- /dev/null +++ b/menus/assets/shaders/submenu_tent_lights.gdshader @@ -0,0 +1,103 @@ +shader_type canvas_item; +// Shader per i menu di secondo livello P2A e P3A. +// Coordinate UV relative al TextureRect del mockup (2796x1290). +// Tracciate e verificate ad alto contrasto con fondo nero: +// 1. Stella centrata sul numero 1 +// 2. 20 lucine lungo le due falde del tetto a cono +// 3. 4 grandi punti di ancoraggio sulla balza ondulata + +uniform float lights_strength : hint_range(0.0, 1.5) = 1.0; +uniform float star_strength : hint_range(0.0, 1.5) = 1.0; +uniform vec2 star_center = vec2(0.269671, 0.155039); +uniform float garland_fps : hint_range(1.0, 60.0) = 12.5; + +const vec3 GOLD_CORE = vec3(0.945, 0.80, 0.385); +const vec3 GOLD_HALO = vec3(1.0, 0.78, 0.35); + +// 20 lampadine lungo le due falde del tetto a cono +// Coordinate UV nella texture del mockup (2796x1290), z = fase del loop (4, 2, 0) +// Coordinate rilevate automaticamente dal PNG 2a_tendone.png: +// dodici lampadine reali per lato, non dieci e non interpolate su una retta. +const vec3 p2a_bulbs[24] = vec3[24]( + // Sinistra, dal colletto verso il bordo esterno + vec3(0.251808, 0.249698, 4.0), // L0 + vec3(0.234388, 0.289171, 2.0), // L1 + vec3(0.214765, 0.320653, 0.0), // L2 + vec3(0.194605, 0.356896, 4.0), // L3 + vec3(0.181462, 0.375494, 2.0), // L4 + vec3(0.167428, 0.398533, 0.0), // L5 + vec3(0.150578, 0.433674, 4.0), // L6 + vec3(0.129082, 0.459304, 2.0), // L7 + vec3(0.107434, 0.492707, 0.0), // L8 + vec3(0.088943, 0.512666, 4.0), // L9 + vec3(0.070246, 0.536780, 2.0), // L10 + vec3(0.042324, 0.560880, 0.0), // L11 + // Destra, dal colletto verso il bordo esterno + vec3(0.285755, 0.241321, 0.0), // R0 + vec3(0.300670, 0.272138, 2.0), // R1 + vec3(0.320168, 0.303235, 4.0), // R2 + vec3(0.339147, 0.339426, 0.0), // R3 + vec3(0.357906, 0.370273, 2.0), // R4 + vec3(0.376152, 0.403834, 4.0), // R5 + vec3(0.393310, 0.431054, 0.0), // R6 + vec3(0.413618, 0.461451, 2.0), // R7 + vec3(0.439441, 0.496182, 4.0), // R8 + vec3(0.462399, 0.522573, 0.0), // R9 + vec3(0.482340, 0.544497, 2.0), // R10 + vec3(0.500324, 0.560728, 4.0) // R11 +); + +// 4 grandi punti di ancoraggio lungo la balza ondulata del tendone +const vec3 p2a_anchor_bulbs[4] = vec3[4]( + vec3(0.08727, 0.46822, 4.0), // A0 (Estremità sinistra della balza) + vec3(0.19170, 0.51783, 0.0), // A1 (Angolo ingresso SX) + vec3(0.27182, 0.51783, 2.0), // A2 (Angolo ingresso DX) + vec3(0.37697, 0.46822, 4.0) // A3 (Estremità destra della balza) +); + +float chase_intensity(float d) { + if (d < 1.0) return 0.30; + if (d < 2.0) return 0.60; + if (d < 3.0) return 1.00; + if (d < 4.0) return 0.95; + return 0.0; +} + +void fragment() { + vec4 color = texture(TEXTURE, UV); + + // 1. Stella grande in cima al tendone + if (star_strength > 0.001) { + vec2 aspect_diff = (UV - star_center) * vec2(2.1674, 1.0); + float star_dist = length(aspect_diff); + float star_area = 1.0 - smoothstep(0.020, 0.060, star_dist); + float pulse = 0.88 + 0.12 * sin(TIME * 5.0); + vec3 gold_glow = vec3(1.0, 0.75, 0.20) * star_area * star_strength * pulse; + color.rgb += gold_glow; + } + + // 2. Lucine lungo le falde del tetto e punti sulla balza + if (lights_strength > 0.001) { + float frame_time = TIME * garland_fps; + float cycle = mod(floor(frame_time), 6.0); + + // 20 Lucine lungo le due falde del tetto a cono + for (int i = 0; i < 24; i++) { + float d = mod(cycle - p2a_bulbs[i].z + 6.0, 6.0); + float intensity = chase_intensity(d); + if (intensity <= 0.0) { + continue; + } + vec2 delta = (UV - p2a_bulbs[i].xy) * vec2(2796.0, 1290.0); + float r = length(delta); + float core = 1.0 - smoothstep(4.0, 10.0, r); + float halo = exp(-r / 15.0); + float ga = clamp(intensity * lights_strength, 0.0, 1.0); + color.rgb = mix(color.rgb, GOLD_CORE, core * ga); + color.rgb += GOLD_HALO * (halo * ga * 0.75 * (1.0 - core)); + } + } + + color.rgb = min(color.rgb, vec3(1.0)); + COLOR = color; +} diff --git a/menus/assets/shaders/submenu_tent_lights.gdshader.uid b/menus/assets/shaders/submenu_tent_lights.gdshader.uid new file mode 100644 index 0000000..17e9491 --- /dev/null +++ b/menus/assets/shaders/submenu_tent_lights.gdshader.uid @@ -0,0 +1 @@ +uid://cw1cvmopgvx1n diff --git a/menus/assets/shaders/tent_lights.gdshader.uid b/menus/assets/shaders/tent_lights.gdshader.uid new file mode 100644 index 0000000..b906798 --- /dev/null +++ b/menus/assets/shaders/tent_lights.gdshader.uid @@ -0,0 +1 @@ +uid://c06koaxcymtib diff --git a/menus/scripts/components/menu_ui.gd b/menus/scripts/components/menu_ui.gd index 0d92355..2fa1caa 100644 --- a/menus/scripts/components/menu_ui.gd +++ b/menus/scripts/components/menu_ui.gd @@ -7,6 +7,7 @@ const MENU_SHADER := preload("res://menus/assets/shaders/ambient_drift.gdshader" const TENT_STAR_SHADER := preload("res://menus/assets/shaders/tent_star_glow.gdshader") const TENT_LIGHTS_SHADER := preload("res://menus/assets/shaders/tent_lights.gdshader") const SIGN_LIGHTS_SHADER := preload("res://menus/assets/shaders/circus_sign_lights.gdshader") +const SUBMENU_LIGHTS_SHADER := preload("res://menus/assets/shaders/submenu_tent_lights.gdshader") # Posizione e ingombro condivisi della freccia indietro in tutte le schermate. const BACK_BUTTON_CENTER := Vector2(0.12, 0.14) @@ -60,6 +61,14 @@ static func sign_lights_material() -> ShaderMaterial: return material +static func submenu_lights_material() -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = SUBMENU_LIGHTS_SHADER + material.set_shader_parameter("lights_strength", 1.0) + material.set_shader_parameter("star_strength", 1.0) + return material + + static func add_image(parent: Control, texture: Texture2D, center: Vector2, normalized_size: Vector2, material: Material = null) -> TextureRect: var image := TextureRect.new() image.texture = texture diff --git a/menus/scripts/screens/p2a_juggle_menu_screen.gd b/menus/scripts/screens/p2a_juggle_menu_screen.gd index ba16418..a66dd5f 100644 --- a/menus/scripts/screens/p2a_juggle_menu_screen.gd +++ b/menus/scripts/screens/p2a_juggle_menu_screen.gd @@ -28,6 +28,7 @@ func _build() -> void: artwork.expand_mode = TextureRect.EXPAND_IGNORE_SIZE artwork.stretch_mode = TextureRect.STRETCH_SCALE artwork.mouse_filter = Control.MOUSE_FILTER_IGNORE + artwork.material = MenuUI.submenu_lights_material() add_child(artwork) _add_hotspot(MenuUI.BACK_BUTTON_CENTER, MenuUI.BACK_BUTTON_SIZE, func(): back_requested.emit()) diff --git a/menus/scripts/screens/p3a_gym_menu_screen.gd b/menus/scripts/screens/p3a_gym_menu_screen.gd index 2fea45e..6628ca2 100644 --- a/menus/scripts/screens/p3a_gym_menu_screen.gd +++ b/menus/scripts/screens/p3a_gym_menu_screen.gd @@ -25,6 +25,7 @@ func _build() -> void: artwork.expand_mode = TextureRect.EXPAND_IGNORE_SIZE artwork.stretch_mode = TextureRect.STRETCH_SCALE artwork.mouse_filter = Control.MOUSE_FILTER_IGNORE + artwork.material = MenuUI.submenu_lights_material() add_child(artwork) _add_hotspot(MenuUI.BACK_BUTTON_CENTER, MenuUI.BACK_BUTTON_SIZE, func(): back_requested.emit()) diff --git a/tools/analyze_tent_lights.py b/tools/analyze_tent_lights.py new file mode 100644 index 0000000..cc51f21 --- /dev/null +++ b/tools/analyze_tent_lights.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Rileva con precisione stella e lampadine nel PNG RGBA del tendone. + +Il rilevamento non stima rette: segmenta direttamente il colore oro, ricompone +le pennellate di ogni lampadina e usa il centroide del blob reale. Può inoltre +registrare il PNG su un mockup tramite SIFT+RANSAC e trasferire le coordinate. + +Esempio: + python3 tools/analyze_tent_lights.py \ + menus/assets/p2a/2a_tendone.png \ + --reference menus/assets/p2a/p2a_menu_mockup.jpg.bak \ + --output-dir /tmp/tent-lights-analysis \ + --debug-scene-asset menus/assets/p2a/p2a_menu_mockup.jpg +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image, ImageDraw + + +GOLD_HSV_LOW = np.array((12, 45, 90), dtype=np.uint8) +GOLD_HSV_HIGH = np.array((45, 255, 255), dtype=np.uint8) + + +def gold_mask(rgba: np.ndarray) -> np.ndarray: + hsv = cv2.cvtColor(rgba[:, :, :3], cv2.COLOR_RGB2HSV) + color = cv2.inRange(hsv, GOLD_HSV_LOW, GOLD_HSV_HIGH) > 0 + return (color & (rgba[:, :, 3] > 15)).astype(np.uint8) * 255 + + +def connected_gold_blobs(mask: np.ndarray) -> list[dict]: + # Chiude soltanto le micro-interruzioni della pennellata, senza fondere + # lampadine adiacenti. La dilatazione rende stabile il centro del blob. + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) + merged = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + merged = cv2.dilate( + merged, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) + ) + count, _, stats, centroids = cv2.connectedComponentsWithStats(merged, 8) + blobs: list[dict] = [] + for label in range(1, count): + x, y, width, height, area = map(int, stats[label]) + blobs.append( + { + "x": float(centroids[label][0]), + "y": float(centroids[label][1]), + "bbox": [x, y, width, height], + "area": area, + } + ) + return blobs + + +def detect_star(mask: np.ndarray) -> dict: + height, _ = mask.shape + top = mask[: int(height * 0.25)].copy() + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11)) + top = cv2.morphologyEx(top, cv2.MORPH_CLOSE, kernel) + blobs = connected_gold_blobs(top) + if not blobs: + raise RuntimeError("Stella non rilevata nella parte alta dell'asset") + star = max(blobs, key=lambda blob: blob["area"]) + x, y, width, height = star["bbox"] + # Per il bagliore serve il centro geometrico della stella, non il centroide + # colorimetrico (spostato dalle cinque punte e dal numero nero interno). + star["x"] = x + (width - 1) / 2.0 + star["y"] = y + (height - 1) / 2.0 + return star + + +def detect_bulbs(mask: np.ndarray, star: dict) -> tuple[list[dict], list[dict]]: + height, width = mask.shape + roi = np.zeros_like(mask) + # Le garlande finiscono prima della modanatura dorata della balza. Fermarsi + # al 59% evita che frammenti orizzontali del bordo vengano scambiati per + # lampadine (nel PNG 2796x1290 il bordo comincia attorno a y=768). + roi[int(height * 0.16) : int(height * 0.59), : int(width * 0.60)] = 255 + blobs = connected_gold_blobs(cv2.bitwise_and(mask, roi)) + + candidates = [] + for blob in blobs: + _, _, blob_width, blob_height = blob["bbox"] + if ( + 6 <= blob_width <= 80 + and 6 <= blob_height <= 80 + and 10 <= blob["area"] <= 3000 + and blob["y"] > star["bbox"][1] + star["bbox"][3] + ): + candidates.append(blob) + + center_x = star["x"] + left = sorted((b for b in candidates if b["x"] < center_x), key=lambda b: b["y"]) + right = sorted((b for b in candidates if b["x"] > center_x), key=lambda b: b["y"]) + + if len(left) != 12 or len(right) != 12: + raise RuntimeError( + "Rilevamento ambiguo: attese 12 lampadine per lato, " + f"rilevate {len(left)} a sinistra e {len(right)} a destra" + ) + return left, right + + +def estimate_transform(source_rgba: np.ndarray, reference_rgb: np.ndarray) -> tuple[np.ndarray, dict]: + source_gray = cv2.cvtColor(source_rgba[:, :, :3], cv2.COLOR_RGB2GRAY) + reference_gray = cv2.cvtColor(reference_rgb, cv2.COLOR_RGB2GRAY) + source_mask = (source_rgba[:, :, 3] > 128).astype(np.uint8) * 255 + reference_mask = np.zeros_like(reference_gray) + reference_mask[:, : int(reference_gray.shape[1] * 0.60)] = 255 + + sift = cv2.SIFT_create(nfeatures=10000, contrastThreshold=0.02) + source_keys, source_desc = sift.detectAndCompute(source_gray, source_mask) + reference_keys, reference_desc = sift.detectAndCompute(reference_gray, reference_mask) + if source_desc is None or reference_desc is None: + raise RuntimeError("Feature insufficienti per registrare asset e mockup") + + matches = cv2.BFMatcher().knnMatch(source_desc, reference_desc, k=2) + good = [first for first, second in matches if first.distance < 0.70 * second.distance] + if len(good) < 20: + raise RuntimeError(f"Registrazione instabile: soltanto {len(good)} match validi") + + source_points = np.float32([source_keys[m.queryIdx].pt for m in good]) + reference_points = np.float32([reference_keys[m.trainIdx].pt for m in good]) + matrix, inliers = cv2.estimateAffine2D( + source_points, + reference_points, + method=cv2.RANSAC, + ransacReprojThreshold=3.0, + maxIters=10000, + confidence=0.999, + ) + if matrix is None or inliers is None: + raise RuntimeError("RANSAC non ha trovato una trasformazione affine") + + inlier_count = int(inliers.sum()) + if inlier_count < 100 or inlier_count / len(good) < 0.80: + raise RuntimeError( + f"Registrazione non affidabile: {inlier_count}/{len(good)} inlier" + ) + return matrix, { + "source_features": len(source_keys), + "reference_features": len(reference_keys), + "matches": len(good), + "inliers": inlier_count, + "inlier_ratio": inlier_count / len(good), + } + + +def transform_point(point: dict, matrix: np.ndarray) -> dict: + mapped = matrix @ np.array((point["x"], point["y"], 1.0)) + result = dict(point) + result["x"] = float(mapped[0]) + result["y"] = float(mapped[1]) + return result + + +def normalized(point: dict, width: int, height: int) -> list[float]: + return [point["x"] / width, point["y"] / height] + + +def draw_marker(draw: ImageDraw.ImageDraw, point: dict, label: str, radius: int) -> None: + x, y = point["x"], point["y"] + draw.ellipse( + (x - radius, y - radius, x + radius, y + radius), + outline=(255, 0, 0, 255), + width=max(4, radius // 5), + ) + cross = max(7, radius // 3) + draw.line((x - cross, y, x + cross, y), fill=(0, 255, 255, 255), width=3) + draw.line((x, y - cross, x, y + cross), fill=(0, 255, 255, 255), width=3) + draw.text((x + radius + 4, y - radius), label, fill=(255, 255, 255, 255), stroke_width=2, stroke_fill=(0, 0, 0, 255)) + + +def source_overlay(rgba: np.ndarray, star: dict, left: list[dict], right: list[dict]) -> Image.Image: + alpha = rgba[:, :, 3:4].astype(np.float32) / 255.0 + black = np.zeros_like(rgba[:, :, :3], dtype=np.float32) + composited = rgba[:, :, :3] * alpha + black * (1.0 - alpha) + image = Image.fromarray(np.uint8(np.clip(composited, 0, 255)), "RGB").convert("RGBA") + draw = ImageDraw.Draw(image) + draw_marker(draw, star, "STAR", 60) + for index, point in enumerate(left): + draw_marker(draw, point, f"L{index:02d}", 25) + for index, point in enumerate(right): + draw_marker(draw, point, f"R{index:02d}", 25) + return image + + +def reference_overlay( + reference_rgb: np.ndarray, + source_rgba: np.ndarray, + star: dict, + left: list[dict], + right: list[dict], +) -> Image.Image: + # Nero fuori dalla sagoma del PNG: impedisce al cielo di mascherare gli errori. + image = reference_rgb.copy() + foreground = source_rgba[:, :, 3] > 15 + image[~foreground] = 0 + result = Image.fromarray(image, "RGB").convert("RGBA") + draw = ImageDraw.Draw(result) + draw_marker(draw, star, "STAR", 60) + for index, point in enumerate(left): + draw_marker(draw, point, f"L{index:02d}", 25) + for index, point in enumerate(right): + draw_marker(draw, point, f"R{index:02d}", 25) + return result + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("asset", type=Path, help="PNG RGBA contenente il tendone") + parser.add_argument("--reference", type=Path, help="Mockup sul quale trasferire i punti") + parser.add_argument("--output-dir", type=Path, default=Path("/tmp/tent-lights-analysis")) + parser.add_argument( + "--debug-scene-asset", + type=Path, + help="Scrive una copia JPEG di debug per la scena Godot (sfondo nero e marker)", + ) + args = parser.parse_args() + + source_rgba = np.array(Image.open(args.asset).convert("RGBA")) + height, width = source_rgba.shape[:2] + mask = gold_mask(source_rgba) + star = detect_star(mask) + left, right = detect_bulbs(mask, star) + + args.output_dir.mkdir(parents=True, exist_ok=True) + overlay = source_overlay(source_rgba, star, left, right) + overlay_path = args.output_dir / "detected_on_source.png" + overlay.save(overlay_path) + + report: dict = { + "asset": str(args.asset), + "size": [width, height], + "star": {"pixel": [star["x"], star["y"]], "uv": normalized(star, width, height)}, + "left": [ + {"pixel": [p["x"], p["y"]], "uv": normalized(p, width, height), "bbox": p["bbox"]} + for p in left + ], + "right": [ + {"pixel": [p["x"], p["y"]], "uv": normalized(p, width, height), "bbox": p["bbox"]} + for p in right + ], + } + + if args.reference: + reference_rgb = np.array(Image.open(args.reference).convert("RGB")) + matrix, diagnostics = estimate_transform(source_rgba, reference_rgb) + mapped_star = transform_point(star, matrix) + mapped_left = [transform_point(p, matrix) for p in left] + mapped_right = [transform_point(p, matrix) for p in right] + ref_height, ref_width = reference_rgb.shape[:2] + mapped_overlay = reference_overlay( + reference_rgb, source_rgba, mapped_star, mapped_left, mapped_right + ) + reference_path = args.output_dir / "detected_on_reference.png" + mapped_overlay.save(reference_path) + if args.debug_scene_asset: + mapped_overlay.convert("RGB").save(args.debug_scene_asset, quality=96) + + report["reference"] = { + "path": str(args.reference), + "size": [ref_width, ref_height], + "affine_matrix": matrix.tolist(), + "diagnostics": diagnostics, + "star": { + "pixel": [mapped_star["x"], mapped_star["y"]], + "uv": normalized(mapped_star, ref_width, ref_height), + }, + "left": [ + {"pixel": [p["x"], p["y"]], "uv": normalized(p, ref_width, ref_height)} + for p in mapped_left + ], + "right": [ + {"pixel": [p["x"], p["y"]], "uv": normalized(p, ref_width, ref_height)} + for p in mapped_right + ], + } + + report_path = args.output_dir / "coordinates.json" + report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + + print(f"Asset: {width}x{height}") + print(f"Stella: ({star['x']:.2f}, {star['y']:.2f})") + print("Sinistra:", ", ".join(f"({p['x']:.2f},{p['y']:.2f})" for p in left)) + print("Destra:", ", ".join(f"({p['x']:.2f},{p['y']:.2f})" for p in right)) + print(f"Overlay: {overlay_path}") + print(f"Coordinate: {report_path}") + + +if __name__ == "__main__": + main()