- Stylized 3D ink-painting map of China (12 provinces, 32 cities, 17 factions) - Custom warlord creation (8 origins, banner, starting city) or historical factions - City management: 7 buildings, 5 dev tiers, recruitment from levies - Character system: stats, traits, loyalty, relationships, wounds, capture, death, succession - Turn-based tactical battles with formations, stances, hero skills, cinematic 3D replay - Sieges: assault, starvation, bribery, infiltration - Diplomacy with trust memory, alliances, NAPs, trade, marriage, espionage, betrayal - Scripted diverging history (Dong Zhuo, Guandu, Red Cliffs...) + world crises + court events - AI factions with distinct personalities; prisoners (execute/release/recruit/ransom) - Procedural guqin/taiko WebAudio score; save/load; victory + dynasty chronicle screens - View-relative camera controls; headless test suites (smoke, stress, map validator)
161 lines
6.4 KiB
Python
161 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Programmatic visual review of screenshots: OCR geometry, contrast, palette, composition."""
|
|
import sys, subprocess, csv, io, math
|
|
from PIL import Image
|
|
import numpy as np
|
|
|
|
SHOTS = sys.argv[1:]
|
|
LANG = "eng+chi_sim"
|
|
|
|
def rel_lum(arr):
|
|
a = arr.astype(np.float64) / 255.0
|
|
def f(c): return np.where(c <= 0.03928, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
|
return 0.2126 * f(a[..., 0]) + 0.7152 * f(a[..., 1]) + 0.0722 * f(a[..., 2])
|
|
|
|
def cr(l1, l2):
|
|
hi, lo = max(l1, l2), min(l1, l2)
|
|
return (hi + 0.05) / (lo + 0.05)
|
|
|
|
def hexc(rgb):
|
|
return "#%02x%02x%02x" % tuple(int(x) for x in rgb)
|
|
|
|
def ocr_lines(img_path):
|
|
"""Return list of (text, x,y,w,h, conf) grouped per line."""
|
|
p = subprocess.run(
|
|
["tesseract", img_path, "stdout", "-l", LANG, "--psm", "11", "tsv"],
|
|
capture_output=True, text=True)
|
|
rows = list(csv.DictReader(io.StringIO(p.stdout), delimiter="\t"))
|
|
lines = {}
|
|
for r in rows:
|
|
try:
|
|
conf = float(r["conf"])
|
|
except Exception:
|
|
continue
|
|
if conf < 35 or not r["text"].strip():
|
|
continue
|
|
key = (r["block_num"], r["par_num"], r["line_num"])
|
|
e = lines.setdefault(key, {"words": [], "x0": 10**9, "y0": 10**9, "x1": -1, "y1": -1, "confs": []})
|
|
x, y, w, h = int(r["left"]), int(r["top"]), int(r["width"]), int(r["height"])
|
|
e["words"].append((r["text"], conf))
|
|
e["confs"].append(conf)
|
|
e["x0"] = min(e["x0"], x); e["y0"] = min(e["y0"], y)
|
|
e["x1"] = max(e["x1"], x + w); e["y1"] = max(e["y1"], y + h)
|
|
out = []
|
|
for k in sorted(lines, key=lambda k: (lines[k]["y0"], lines[k]["x0"])):
|
|
e = lines[k]
|
|
out.append({"text": " ".join(w for w, _ in e["words"]),
|
|
"bbox": (e["x0"], e["y0"], e["x1"] - e["x0"], e["y1"] - e["y0"]),
|
|
"conf": sum(e["confs"]) / len(e["confs"])})
|
|
return out
|
|
|
|
def analyze(path):
|
|
im = Image.open(path).convert("RGB")
|
|
W, H = im.size
|
|
arr = np.asarray(im)
|
|
L = rel_lum(arr)
|
|
gray = np.asarray(im.convert("L"))
|
|
|
|
print(f"\n{'='*78}\n{path} ({W}x{H})\n{'='*78}")
|
|
|
|
# Global tone
|
|
print(f"luminance mean={L.mean():.3f} p5={np.percentile(L,5):.3f} p50={np.percentile(L,50):.3f} "
|
|
f"p95={np.percentile(L,95):.3f} | dark(<0.15)={100*(L<0.15).mean():.0f}% bright(>0.85)={100*(L>0.85).mean():.0f}%")
|
|
|
|
# Palette
|
|
q = im.quantize(colors=8, method=Image.Quantize.MEDIANCUT)
|
|
pal = q.getpalette()
|
|
counts = sorted(q.getcolors(W * H), reverse=True)
|
|
tot = W * H
|
|
tops = []
|
|
for cnt, idx in counts[:8]:
|
|
rgb = pal[idx*3:idx*3+3]
|
|
tops.append(f"{hexc(rgb)} {100*cnt/tot:.0f}%")
|
|
print("palette: " + " ".join(tops))
|
|
|
|
# Composition grid: 16 cols x 10 rows -> mean luminance (0-9) and dominant hue class letter
|
|
GX, GY = 16, 10
|
|
print("composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):")
|
|
hsv = np.asarray(im.convert("HSV")).astype(int)
|
|
hh, ss, vv = hsv[..., 0], hsv[..., 1], hsv[..., 2]
|
|
for gy in range(GY):
|
|
row = ""
|
|
for gx in range(GX):
|
|
cell = L[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX]
|
|
m = cell.mean()
|
|
# dominant hue of saturated pixels
|
|
ch = hh[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX]
|
|
cs = ss[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX]
|
|
sat = cs > 60
|
|
if m > 0.85: c = "W"
|
|
elif sat.mean() > 0.12:
|
|
ang = np.median(ch[sat]) * 360 / 255
|
|
c = "R" if ang < 25 or ang >= 330 else "Y" if ang < 70 else "G" if ang < 160 else "C" if ang < 200 else "B" if ang < 270 else "M"
|
|
elif m > 0.45: c = "N"
|
|
else: c = "K"
|
|
row += str(min(9, int(m * 10))) + c
|
|
print(" " + row)
|
|
|
|
# Low-variance (flat) regions — candidate dead space
|
|
flat = []
|
|
for gy in range(GY):
|
|
for gx in range(GX):
|
|
cell = gray[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX]
|
|
if cell.std() < 6:
|
|
flat.append((gx, gy, round(float(cell.mean()))))
|
|
print(f"flat cells (std<6): {len(flat)}/{GX*GY}" + (f" at {flat[:12]}" if flat else ""))
|
|
|
|
# OCR line analysis
|
|
lines = ocr_lines(path)
|
|
print(f"OCR: {len(lines)} lines (conf>=35)")
|
|
low_contrast, overlaps, tiny = [], [], []
|
|
boxes = []
|
|
for ln in lines:
|
|
x, y, w, h = ln["bbox"]
|
|
if w <= 1 or h <= 1 or x < 0 or y < 0 or x + w > W or y + h > H:
|
|
continue
|
|
pad = 2
|
|
x0, y0 = max(0, x - pad), max(0, y - pad)
|
|
x1, y1 = min(W, x + w + pad), min(H, y + h + pad)
|
|
reg = L[y0:y1, x0:x1]
|
|
lo, hi = np.percentile(reg, 5), np.percentile(reg, 95)
|
|
ratio = cr(hi, lo)
|
|
boxes.append((x, y, w, h, ln["text"]))
|
|
if h < 9 and ln["conf"] > 50:
|
|
tiny.append(ln)
|
|
if ratio < 4.0 and len(ln["text"].strip()) > 1:
|
|
low_contrast.append((ln["text"][:40], (x, y, w, h), round(ratio, 2)))
|
|
# overlap between different lines (>25% of smaller area)
|
|
for i in range(len(boxes)):
|
|
for j in range(i + 1, len(boxes)):
|
|
ax, ay, aw, ah, at = boxes[i]; bx, by, bw, bh, bt = boxes[j]
|
|
ox = max(0, min(ax+aw, bx+bw) - max(ax, bx)); oy = max(0, min(ay+ah, by+bh) - max(ay, by))
|
|
inter = ox * oy
|
|
small = min(aw*ah, bw*bh)
|
|
if small and inter / small > 0.30 and at != bt:
|
|
overlaps.append((at[:28], bt[:28], round(100*inter/small)))
|
|
if low_contrast:
|
|
print("LOW CONTRAST (<4:1):")
|
|
for t, b, r in low_contrast[:14]:
|
|
print(f" {r:>5}:1 @({b[0]},{b[1]},{b[2]}x{b[3]}) '{t}'")
|
|
if len(low_contrast) > 14: print(f" ... +{len(low_contrast)-14} more")
|
|
else:
|
|
print("LOW CONTRAST: none flagged")
|
|
if overlaps:
|
|
print("OVERLAPPING TEXT BOXES:")
|
|
for a, b, pct in overlaps[:10]:
|
|
print(f" {pct}% '{a}' vs '{b}'")
|
|
if tiny:
|
|
print(f"VERY SMALL TEXT (<9px tall): {len(tiny)} e.g. " + "; ".join(t['text'][:18] for t in tiny[:5]))
|
|
|
|
# Print all OCR lines with geometry so reviewer can reconstruct layout
|
|
print("--- OCR transcript (x,y w h | conf | text) ---")
|
|
for ln in lines:
|
|
x, y, w, h = ln["bbox"]
|
|
print(f" ({x:>4},{y:>4} {w:>4}x{h:<3}|{ln['conf']:.0f}) {ln['text'][:90]}")
|
|
|
|
for p in SHOTS:
|
|
try:
|
|
analyze(p)
|
|
except Exception as e:
|
|
print(f"\nFAILED {p}: {type(e).__name__}: {e}")
|