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>
157 lines
6.7 KiB
Python
157 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""imgharvest — build the hh card-art library from web *image* search.
|
|
|
|
Complements `charvest.py` (which pulls frames from a video URL). Instead of one
|
|
subject per video, this queries a keyless image engine (DuckDuckGo) for a
|
|
character, downloads many candidate stills, and runs each through the *same*
|
|
auto-crop + quality pipeline as charvest — then keeps the sharpest, most
|
|
distinct portraits. Static official art tends to sit on clean backgrounds, so
|
|
the saliency crop lands far better than on motion-blurred video frames, and one
|
|
query yields several usable, non-duplicate faces at once.
|
|
|
|
Mix genres freely — Pokémon, Digimon, Yu-Gi-Oh — tagging each with the card
|
|
`--element`(s) so `cmd_chat/chardex.py` can match a VM to a fitting face.
|
|
|
|
Run with the anaconda interpreter (needs cv2 + numpy, like charvest):
|
|
|
|
python3 scripts/imgharvest.py "Gardevoir official art" \
|
|
--name Gardevoir --element Psychic Fairy --tier Epic \
|
|
--keywords psychic elegant guardian embrace --max 3 --credit Pokemon
|
|
|
|
⚠️ 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 re
|
|
import sys
|
|
import tempfile
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
|
|
# reuse the proven crop + quality + library-write pipeline
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import charvest as cv # noqa: E402
|
|
|
|
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/124.0 Safari/537.36")
|
|
IMG_EXT = cv.IMG_EXT
|
|
|
|
|
|
# ── keyless DuckDuckGo image search ───────────────────────────────────────────
|
|
def _vqd(query: str) -> str | None:
|
|
req = urllib.request.Request(
|
|
"https://duckduckgo.com/?q=" + urllib.parse.quote(query) + "&iax=images&ia=images",
|
|
headers={"User-Agent": UA})
|
|
html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", "ignore")
|
|
for pat in (r'vqd=([\d-]+)\&', r'vqd="([\d-]+)"', r"vqd='([\d-]+)'",
|
|
r'vqd=([\d-]+)'):
|
|
m = re.search(pat, html)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def ddg_image_urls(query: str, limit: int) -> list[str]:
|
|
"""Return direct image URLs for a query via DuckDuckGo's i.js endpoint."""
|
|
vqd = _vqd(query)
|
|
if not vqd:
|
|
print(f"[search] no vqd token for {query!r}", file=sys.stderr)
|
|
return []
|
|
api = ("https://duckduckgo.com/i.js?l=us-en&o=json&q="
|
|
+ urllib.parse.quote(query) + f"&vqd={vqd}&f=,,,,,&p=1")
|
|
req = urllib.request.Request(api, headers={
|
|
"User-Agent": UA, "Referer": "https://duckduckgo.com/",
|
|
"Accept": "application/json, text/javascript, */*; q=0.01"})
|
|
data = json.loads(urllib.request.urlopen(req, timeout=20).read().decode("utf-8", "ignore"))
|
|
urls = [r["image"] for r in data.get("results", []) if r.get("image")]
|
|
return urls[:limit]
|
|
|
|
|
|
def download(url: str, dst: Path) -> Path | None:
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
raw = urllib.request.urlopen(req, timeout=20).read()
|
|
except Exception:
|
|
return None
|
|
if len(raw) < 4096: # too small to be useful
|
|
return None
|
|
ext = Path(urllib.parse.urlparse(url).path).suffix.lower()
|
|
dst = dst.with_suffix(ext if ext in IMG_EXT else ".jpg")
|
|
dst.write_bytes(raw)
|
|
return dst
|
|
|
|
|
|
# ── harvest ───────────────────────────────────────────────────────────────────
|
|
def harvest(args) -> int:
|
|
queries = args.query if isinstance(args.query, list) else [args.query]
|
|
with tempfile.TemporaryDirectory(prefix="imgharvest-") as td:
|
|
work = Path(td)
|
|
candidates: list[Path] = []
|
|
for q in queries:
|
|
urls = ddg_image_urls(q, args.candidates)
|
|
print(f"[search] {len(urls)} image url(s) for {q!r}")
|
|
for i, u in enumerate(urls):
|
|
fp = download(u, work / f"{cv._slug(q)}-{i:02d}")
|
|
if fp:
|
|
candidates.append(fp)
|
|
print(f"[crop] {len(candidates)} downloaded candidate(s)")
|
|
|
|
scored: list[tuple[float, cv.np.ndarray]] = []
|
|
seen: list[int] = []
|
|
for fp in candidates:
|
|
img = cv2.imread(str(fp))
|
|
if img is None:
|
|
continue
|
|
port = cv.autocrop(img)
|
|
if port is None:
|
|
continue
|
|
bs = cv.blur_score(port)
|
|
if bs < args.min_sharpness:
|
|
continue
|
|
hh = cv.ahash(port)
|
|
if any(cv._hamming(hh, s) <= 6 for s in seen): # near-duplicate
|
|
continue
|
|
seen.append(hh)
|
|
scored.append((bs, 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 = cv.append_library(args.name, args.element, args.tier,
|
|
[k.lower() for k in args.keywords],
|
|
queries[0], args.credit, picks)
|
|
print(f"[done] {len(slugs)} portrait(s) → {cv.LIB}: {', '.join(slugs)}")
|
|
return 0
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
p = argparse.ArgumentParser(prog="imgharvest", description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("query", nargs="+", help="image-search query/queries")
|
|
p.add_argument("--name", required=True, help="character name (e.g. 'Gardevoir')")
|
|
p.add_argument("--element", nargs="*", default=[],
|
|
help="card type(s) it fits: Normal Psychic Flying 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("--candidates", type=int, default=20,
|
|
help="image results to download per query before filtering")
|
|
p.add_argument("--min-sharpness", type=float, default=45.0,
|
|
help="reject portraits below this Laplacian variance")
|
|
return harvest(p.parse_args(argv))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|