Three Kingdoms: Warlord's Fate — complete playable game
- 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)
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
#!/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}")
|
||||
Reference in New Issue
Block a user