feat(cardex): image-search harvester + genre-diverse character library

Grow the hh card-art library so common VM types stop reusing one portrait.

- scripts/imgharvest.py: keyless DuckDuckGo image-search harvester — downloads
  many stills per character and runs them through charvest's crop/quality/dedup
  pipeline (static art crops far cleaner than motion-blurred video frames).
- scripts/harvest_roster.sh: batch roster (Pokemon/Digimon/Yu-Gi-Oh) sized to
  the live VM card types. Library now 37 characters / 99 portraits.
- charvest: top-anchor the head+shoulders crop for tall full-body art so faces
  are no longer clipped.

chardex matcher — stop same-type VMs collapsing onto a single face:
- fold a per-(VM,character) hash jitter INTO the score, not just a tie-break
- diminishing returns on extra type matches so a strong single-type face can
  compete with a dual-type one
- halve tier affinity and cap the keyword bonus
- stop-word rarity/boilerplate words that were polluting keyword overlap
Result: 20 distinct faces across the library. The one remaining cluster
(Sigilyph on the Psychic/Flying recon-scanner VMs) is coherent purpose-
matching — a scanner-themed bird on the scanner VMs — not a bug.

NOTE: assets/characters/ are third-party franchise IP with NO established reuse
license — quarantined, internal WIP only, do not distribute/merge/publish
(licensing record in docs/character-art-licensing.md).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-03 11:04:37 -07:00
parent 987985b203
commit 2cfa388879
104 changed files with 2562 additions and 0 deletions
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""charvest — harvest + auto-crop iconic character portraits for the hh card art.
Pipeline (all local, free):
source ──▶ frames ──▶ auto-crop subject ──▶ quality filter ──▶ library
(URL/video/ (cv2: anime+face (blur + near-dup
image/dir) (ffmpeg cascades, else rejection)
scene) edge-energy saliency)
Acquisition reuses the `web-video-grab` skill (yt-dlp first, Playwright screen-
capture fallback) so YouTube / Instagram / X / TikTok all work; a local file or
a directory of images is taken as-is. Each kept portrait is squared, upscaled
and written into the chardex library (`assets/characters/`) with the metadata
you supply, so `cmd_chat/chardex.py` can match VMs to faces.
Run with the anaconda interpreter (has cv2 + numpy):
python3 scripts/charvest.py URL_OR_PATH --name "Charizard" \
--element Fire Dragon --tier Legendary \
--keywords dragon fire wings aggressive flagship --max 3
⚠️ Harvested characters are likely third-party IP — see
docs/character-art-licensing.md before any public/commercial use.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
from pathlib import Path
import cv2
import numpy as np
REPO = Path(__file__).resolve().parent.parent
LIB = Path(os.environ.get("HH_CHARDEX_DIR", REPO / "assets" / "characters"))
GRAB = Path.home() / ".claude" / "skills" / "web-video-grab" / "grab.sh"
CACHE = Path.home() / ".cache" / "hh-charvest"
ANIMEFACE_URL = ("https://raw.githubusercontent.com/nagadomi/"
"lbpcascade_animeface/master/lbpcascade_animeface.xml")
OUT_PX = 640
ACQUIRE_SECONDS = 30
IMG_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
VID_EXT = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".gif"}
# ── acquisition ───────────────────────────────────────────────────────────────
def acquire(src: str, work: Path) -> tuple[str, Path]:
"""Return ('image'|'video'|'dir', path). URLs are fetched to a clean mp4."""
p = Path(src)
if p.exists():
if p.is_dir():
return "dir", p
ext = p.suffix.lower()
if ext in IMG_EXT:
return "image", p
if ext in VID_EXT:
return "video", p
raise SystemExit(f"unsupported local file type: {ext}")
# remote URL → grab just a short, low-res section fast via yt-dlp (no full
# download, no Playwright). Falls back to the web-video-grab skill if needed.
out = work / "grab.mp4"
print(f"[acquire] yt-dlp (≤{ACQUIRE_SECONDS}s clip) ← {src}")
r = subprocess.run(
["yt-dlp", "--no-playlist", "--no-warnings",
"-f", "bv*[height<=720][ext=mp4]/bv*[height<=720]/b[height<=720]/best",
"--download-sections", f"*0:00-0:{ACQUIRE_SECONDS:02d}",
"-o", str(out), src],
timeout=150)
if r.returncode == 0 and out.exists():
return "video", out
if GRAB.exists():
print("[acquire] yt-dlp failed; web-video-grab fallback", file=sys.stderr)
r = subprocess.run(["bash", str(GRAB), "--out", str(out), src], timeout=180)
if r.returncode == 0 and out.exists():
return "video", out
raise SystemExit(f"could not acquire media from {src}")
def extract_frames(video: Path, outdir: Path, scene: float, fps_cap: float,
max_frames: int) -> list[Path]:
"""ffmpeg: prefer scene-change frames; fall back to a steady fps sample."""
outdir.mkdir(parents=True, exist_ok=True)
vf = f"select='gt(scene,{scene})',scale=720:-2"
subprocess.run(["ffmpeg", "-y", "-i", str(video), "-vf", vf, "-vsync", "vfr",
"-frames:v", str(max_frames * 3), str(outdir / "s%04d.png")],
stderr=subprocess.DEVNULL)
frames = sorted(outdir.glob("s*.png"))
if len(frames) < 3: # sparse scene cuts → steady sample
subprocess.run(["ffmpeg", "-y", "-i", str(video), "-vf",
f"fps={fps_cap},scale=720:-2", "-frames:v",
str(max_frames * 3), str(outdir / "f%04d.png")],
stderr=subprocess.DEVNULL)
frames = sorted(outdir.glob("[sf]*.png"))
return frames
# ── subject detection + cropping ──────────────────────────────────────────────
def _animeface_cascade():
CACHE.mkdir(parents=True, exist_ok=True)
dst = CACHE / "lbpcascade_animeface.xml"
if not dst.exists():
try:
urllib.request.urlretrieve(ANIMEFACE_URL, dst)
except Exception as e:
print(f"[crop] anime cascade unavailable ({e}); faces only", file=sys.stderr)
return None
c = cv2.CascadeClassifier(str(dst))
return None if c.empty() else c
_FRONTAL = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
_ANIME = _animeface_cascade()
def _face_bbox(gray: np.ndarray):
"""Largest face (anime cascade first, then frontal); None if no face."""
best = None
for cas, mn in ((_ANIME, 24), (_FRONTAL, 30)):
if cas is None:
continue
for (x, y, w, h) in cas.detectMultiScale(gray, 1.1, 4, minSize=(mn, mn)):
if best is None or w * h > best[2] * best[3]:
best = (x, y, w, h)
if best is not None:
break
return best
def _saliency_bbox(gray: np.ndarray):
"""Edge-energy fallback for creatures/objects with no detectable face.
Sobel magnitude → blur to an energy map → threshold at mean+std → bounding
box of the largest salient blob."""
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
mag = cv2.magnitude(gx, gy)
mag = cv2.GaussianBlur(mag, (0, 0), sigmaX=gray.shape[1] / 40.0)
thr = mag.mean() + mag.std()
mask = (mag > thr).astype(np.uint8) * 255
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE,
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)))
cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts:
return None
return cv2.boundingRect(max(cnts, key=cv2.contourArea))
def _square_from_face(bbox, W, H):
x, y, w, h = bbox
cx = x + w / 2
side = min(max(w * 2.4, h * 2.4), min(W, H)) # head+shoulders portrait
top = y - h * 0.9 # leave headroom above face
x0 = int(np.clip(cx - side / 2, 0, W - side))
y0 = int(np.clip(top, 0, H - side))
return x0, y0, int(side)
def _square_from_bbox(bbox, W, H, pad=0.18):
x, y, w, h = bbox
cx, cy = x + w / 2, y + h / 2
if h > w * 1.25:
# tall standing figure (full-body art): frame head + upper body from the
# top instead of centering on the waist, so the face is always in shot.
side = float(np.clip(max(w * 1.5, h * 0.55), 0.42 * min(W, H), min(W, H)))
y0 = int(np.clip(y - side * 0.06, 0, H - side))
else:
side = min(max(w, h) * (1 + pad), min(W, H))
y0 = int(np.clip(cy - side / 2, 0, H - side))
x0 = int(np.clip(cx - side / 2, 0, W - side))
return x0, y0, int(side)
def autocrop(img: np.ndarray):
"""Return a squared OUT_PX×OUT_PX portrait centered on the subject, or None."""
H, W = img.shape[:2]
if min(H, W) < 120:
return None
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
face = _face_bbox(gray)
if face is not None:
x0, y0, side = _square_from_face(face, W, H)
else:
bb = _saliency_bbox(gray)
if bb is None:
return None
x0, y0, side = _square_from_bbox(bb, W, H)
if side < 100:
return None
crop = img[y0:y0 + side, x0:x0 + side]
crop = cv2.resize(crop, (OUT_PX, OUT_PX), interpolation=cv2.INTER_LANCZOS4)
# gentle unsharp for upscaled frames
blur = cv2.GaussianBlur(crop, (0, 0), 1.2)
return cv2.addWeighted(crop, 1.4, blur, -0.4, 0)
# ── quality filters ───────────────────────────────────────────────────────────
def blur_score(img: np.ndarray) -> float:
return cv2.Laplacian(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), cv2.CV_64F).var()
def ahash(img: np.ndarray) -> int:
g = cv2.resize(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), (8, 8))
bits = (g > g.mean()).flatten()
h = 0
for b in bits:
h = (h << 1) | int(b)
return h
def _hamming(a: int, b: int) -> int:
return bin(a ^ b).count("1")
# ── library write ─────────────────────────────────────────────────────────────
def _slug(name: str) -> str:
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return s or "char"
def append_library(name, element, tier, keywords, source, credit,
portraits: list[np.ndarray]) -> list[str]:
LIB.mkdir(parents=True, exist_ok=True)
idx_path = LIB / "index.json"
index = json.loads(idx_path.read_text()) if idx_path.exists() else []
base = _slug(name)
written = []
for i, port in enumerate(portraits):
slug = base if len(portraits) == 1 else f"{base}-{i+1}"
index = [e for e in index if e["slug"] != slug] # replace same slug
fn = f"{slug}.png"
cv2.imwrite(str(LIB / fn), port, [cv2.IMWRITE_PNG_COMPRESSION, 6])
index.append({"slug": slug, "name": name, "file": fn,
"element": element, "tier": tier or "",
"keywords": keywords, "source": source, "credit": credit})
written.append(slug)
idx_path.write_text(json.dumps(index, indent=2))
return written
def harvest(args) -> int:
with tempfile.TemporaryDirectory(prefix="charvest-") as td:
work = Path(td)
kind, path = acquire(args.url, work)
if kind == "image":
frames = [path]
elif kind == "dir":
frames = sorted(p for p in path.iterdir() if p.suffix.lower() in IMG_EXT)
else:
frames = extract_frames(path, work / "frames", args.scene,
args.fps, args.max)
print(f"[crop] {len(frames)} candidate frame(s)")
scored: list[tuple[float, int, np.ndarray]] = []
seen: list[int] = []
for fp in frames:
img = cv2.imread(str(fp))
if img is None:
continue
port = autocrop(img)
if port is None:
continue
bs = blur_score(port)
if bs < args.min_sharpness:
continue
hh = ahash(port)
if any(_hamming(hh, s) <= 6 for s in seen): # near-duplicate
continue
seen.append(hh)
scored.append((bs, len(scored), port))
if not scored:
print("[crop] no usable portraits (no subject detected / too blurry)",
file=sys.stderr)
return 1
scored.sort(key=lambda t: -t[0]) # sharpest first
picks = [p for _, _, p in scored[:args.max]]
slugs = append_library(args.name, args.element, args.tier,
[k.lower() for k in args.keywords],
args.url, args.credit, picks)
print(f"[done] {len(slugs)} portrait(s) → {LIB}: {', '.join(slugs)}")
return 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="charvest", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("url", help="YouTube/Instagram/web URL, or a local video/image/dir")
p.add_argument("--name", required=True, help="character name (e.g. 'Charizard')")
p.add_argument("--element", nargs="*", default=[],
help="card type(s) it fits: Fire Dragon Psychic Steel …")
p.add_argument("--tier", default="", help="rarity affinity (Common..Legendary)")
p.add_argument("--keywords", nargs="*", default=[],
help="descriptive words used to match VM purpose")
p.add_argument("--credit", default="", help="franchise / attribution")
p.add_argument("--max", type=int, default=3, help="max portraits to keep")
p.add_argument("--scene", type=float, default=0.30, help="ffmpeg scene-cut threshold")
p.add_argument("--fps", type=float, default=0.5, help="fallback sample fps")
p.add_argument("--min-sharpness", type=float, default=40.0,
help="reject portraits below this Laplacian variance")
return harvest(p.parse_args(argv))
if __name__ == "__main__":
raise SystemExit(main())