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:
@@ -0,0 +1,188 @@
|
||||
"""hh-chardex — a library of harvested character portraits + a VM→character matcher.
|
||||
|
||||
The cardex (`cardex.py`) judges a VM's value and assigns it an elemental *type*
|
||||
and a *purpose*. This module gives each VM a **face**: it indexes a library of
|
||||
real, auto-cropped character portraits (harvested by `scripts/charvest.py` from
|
||||
YouTube / Instagram / image sources) and picks the one whose element + keywords
|
||||
best fit the VM — deterministically, so a VM always wears the same character.
|
||||
|
||||
Pure stdlib so `cardimg` can import it offline. The library lives under
|
||||
`assets/characters/` (override with `$HH_CHARDEX_DIR`):
|
||||
|
||||
assets/characters/
|
||||
index.json # [{slug,name,element,tier,keywords,source,credit,file}]
|
||||
<slug>.png # the cropped square portrait
|
||||
|
||||
Matching is a transparent additive score (type overlap dominates, then keyword
|
||||
overlap with the VM's tags + purpose, then a rarity/tier affinity), with a
|
||||
hash-of-label tie-break so picks are stable and well-distributed. No model,
|
||||
no network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def chardex_dir() -> Path:
|
||||
env = os.environ.get("HH_CHARDEX_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path(__file__).resolve().parent.parent / "assets" / "characters"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Character:
|
||||
slug: str
|
||||
name: str
|
||||
file: str # portrait filename within the library dir
|
||||
element: list = field(default_factory=list) # card types this fits, e.g. ["Fire","Dragon"]
|
||||
tier: str = "" # optional rarity affinity (Common..Legendary)
|
||||
keywords: list = field(default_factory=list) # descriptive words for purpose-match
|
||||
source: str = "" # acquisition URL
|
||||
credit: str = "" # attribution / franchise
|
||||
|
||||
def path(self, base: Path) -> Path:
|
||||
return base / self.file
|
||||
|
||||
|
||||
def load_library(base: Path | None = None) -> list[Character]:
|
||||
base = base or chardex_dir()
|
||||
idx = base / "index.json"
|
||||
if not idx.exists():
|
||||
return []
|
||||
raw = json.loads(idx.read_text())
|
||||
out = []
|
||||
for e in raw:
|
||||
out.append(Character(
|
||||
slug=e["slug"], name=e.get("name", e["slug"]),
|
||||
file=e.get("file", e["slug"] + ".png"),
|
||||
element=e.get("element") or [], tier=e.get("tier", ""),
|
||||
keywords=[k.lower() for k in (e.get("keywords") or [])],
|
||||
source=e.get("source", ""), credit=e.get("credit", "")))
|
||||
return out
|
||||
|
||||
|
||||
def save_library(chars: list[Character], base: Path | None = None) -> Path:
|
||||
base = base or chardex_dir()
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
idx = base / "index.json"
|
||||
idx.write_text(json.dumps([{
|
||||
"slug": c.slug, "name": c.name, "file": c.file, "element": c.element,
|
||||
"tier": c.tier, "keywords": c.keywords, "source": c.source,
|
||||
"credit": c.credit} for c in chars], indent=2))
|
||||
return idx
|
||||
|
||||
|
||||
_TIER_RANK = {"Common": 0, "Uncommon": 1, "Rare": 2, "Epic": 3, "Legendary": 4}
|
||||
_STOP = {"a", "an", "the", "for", "and", "of", "to", "vm", "with", "via", "on",
|
||||
"shared", "dev", "sandbox", "unknown", "from", "into", "kit", "lab",
|
||||
# rarity / flavor-boilerplate words — rarity is handled by tier affinity,
|
||||
# not keyword overlap, so these must not drive the character match.
|
||||
"legendary", "epic", "rare", "uncommon", "common", "mythical",
|
||||
"born", "base", "stat", "total", "type"}
|
||||
|
||||
|
||||
def _purpose_words(purpose: str) -> set[str]:
|
||||
toks = "".join(c if c.isalnum() else " " for c in (purpose or "").lower()).split()
|
||||
return {t for t in toks if len(t) > 2 and t not in _STOP}
|
||||
|
||||
|
||||
def _seed(s: str) -> int:
|
||||
return int(hashlib.sha256(s.encode()).hexdigest(), 16)
|
||||
|
||||
|
||||
def score(card, ch: Character) -> float:
|
||||
"""Additive, transparent fit score between a cardex Card and a Character."""
|
||||
s = 0.0
|
||||
types = set(getattr(card, "types", []) or [])
|
||||
elem = set(ch.element)
|
||||
matched = len(types & elem)
|
||||
# elemental fit dominates, but with diminishing returns on extra matches so a
|
||||
# strong single-type face can still compete (via jitter) with a dual-type one
|
||||
# — otherwise every same-typed VM collapses onto the one dual-type character.
|
||||
if matched:
|
||||
s += 6.0 + 3.0 * (matched - 1) # 1→6.0, 2→9.0, 3→12.0
|
||||
# keyword overlap with the VM's tags + purpose prose (capped so purpose fit
|
||||
# nudges but never overrides elemental fit)
|
||||
vocab = {t.lower() for t in (getattr(card, "label", "").replace("-", " ").split())}
|
||||
vocab |= _purpose_words(getattr(card, "flavor", ""))
|
||||
# cardex Card has no raw tags, but flavor embeds purpose; also use name root
|
||||
kw = set(ch.keywords)
|
||||
s += min(1.5 * len(kw & vocab), 3.0)
|
||||
# rarity / tier affinity — a gentle nudge so legendary characters lean toward
|
||||
# legendary VMs, kept small enough that the per-VM jitter still spreads picks
|
||||
# across same-type faces (see match()) instead of collapsing onto one.
|
||||
if ch.tier and getattr(card, "rarity", ""):
|
||||
dist = abs(_TIER_RANK.get(ch.tier, 2) - _TIER_RANK.get(card.rarity, 2))
|
||||
s += max(0.0, 1.5 - 0.75 * dist)
|
||||
return s
|
||||
|
||||
|
||||
# Jitter folded INTO the score so that among characters of the *same* element
|
||||
# (equal 6.0-per-type base) the winner varies per VM instead of every same-type
|
||||
# VM collapsing onto the single highest-tier character. Kept below JITTER_MAX <
|
||||
# one type-match unit (6.0) so a better *elemental* fit always still wins.
|
||||
JITTER_MAX = 4.0
|
||||
|
||||
|
||||
def match(card, library: list[Character] | None = None) -> Character | None:
|
||||
"""Pick the best-fitting character for a VM card; stable per VM label.
|
||||
|
||||
Elemental fit dominates; within an element, a per-(VM,character) hash jitter
|
||||
spreads picks across all equally-fitting faces so a common VM type doesn't
|
||||
reuse one portrait. Deterministic — a given VM always wears the same face."""
|
||||
lib = library if library is not None else load_library()
|
||||
if not lib:
|
||||
return None
|
||||
label = getattr(card, "label", "vm")
|
||||
best, best_key = None, None
|
||||
for ch in lib:
|
||||
sc = score(card, ch)
|
||||
jitter = JITTER_MAX * ((_seed(label + "\x00" + ch.slug) % 10000) / 10000.0)
|
||||
key = sc + jitter
|
||||
if best_key is None or key > best_key:
|
||||
best, best_key = ch, key
|
||||
return best
|
||||
|
||||
|
||||
# ── CLI: inspect the library / preview matches ────────────────────────────────
|
||||
def main(argv=None) -> int:
|
||||
import argparse
|
||||
from .cardex import card_from_entry, load_entries, _registry_path
|
||||
|
||||
p = argparse.ArgumentParser(prog="hh-chardex",
|
||||
description="Inspect the character library and preview VM→character matches.")
|
||||
p.add_argument("--dir", default=str(chardex_dir()), help="character library dir")
|
||||
p.add_argument("--registry", default=str(_registry_path()))
|
||||
p.add_argument("--matches", action="store_true",
|
||||
help="show the matched character for every VM in the registry")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
base = Path(args.dir)
|
||||
lib = load_library(base)
|
||||
if not lib:
|
||||
print(f"empty character library at {base} — run scripts/charvest.py first")
|
||||
return 1
|
||||
if not args.matches:
|
||||
print(f"{len(lib)} characters in {base}:")
|
||||
for c in lib:
|
||||
print(f" {c.slug:<22} {c.name:<20} {'/'.join(c.element):<16} "
|
||||
f"{c.tier:<10} {' '.join(c.keywords[:6])}")
|
||||
return 0
|
||||
|
||||
entries = load_entries(Path(args.registry))
|
||||
for e in entries:
|
||||
card = card_from_entry(e)
|
||||
ch = match(card, lib)
|
||||
print(f"{card.label:<22} {card.rarity:<10} {'/'.join(card.types):<16} "
|
||||
f"→ {ch.name if ch else '(none)'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user