Files
autgame/tools/analyze_tent_lights.py
T

298 lines
12 KiB
Python

#!/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()