2cfa388879
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>
314 lines
13 KiB
Python
314 lines
13 KiB
Python
#!/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())
|