Logo from a domain, programmatic generate()

- logo_fetch.py: find a company's logo from its domain (homepage logo candidates, staging
  URLs, touch icons, favicon services), cached by domain
- generate_sprite.py: --domain / --logo-cache, domain column in --batch, logos as PIL
  images, generate(..., write_files=False) for use as a library
This commit is contained in:
=
2026-10-02 15:20:35 -05:00
parent b9d634882c
commit 9e3e7c3e94
3 changed files with 765 additions and 17 deletions

View File

@@ -17,8 +17,9 @@ Usage
python generate_sprite.py "Rosas Landscaping" logos/rosas.png # Jev picks the type
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --type grass
python generate_sprite.py "Rosas Landscaping" --type grass --colors "#2E7D32,#FBC02D"
python generate_sprite.py "Rosas Landscaping" --domain rosaslandscaping.com # logo from the website
python generate_sprite.py "Rosas Landscaping" logos/rosas.png --compare # both libraries side by side
python generate_sprite.py --batch businesses.csv # columns: name,logo,type,description
python generate_sprite.py --batch businesses.csv # columns: name,logo,domain,type,description
python generate_sprite.py --library classic --make-placeholders # test art for every part
python generate_sprite.py --sprite-list SPRITES.md # art checklist + weight report
@@ -445,8 +446,9 @@ def _interior(arr, tol=24):
return keep
def extract_logo_colors(path, n=6):
"""Return [(rgb, fraction)] of a logo's brand colours, most important first.
def extract_logo_colors(logo, n=6):
"""Return [(rgb, fraction)] of a logo's brand colours, most important first. `logo` is a
path or a PIL image.
- Transparent pixels and anti-aliased edge blends are ignored.
- A solid white/black/gray background (the colour filling most of the image border) is
@@ -456,7 +458,7 @@ def extract_logo_colors(path, n=6):
- If nothing but the background is left (e.g. a plain logo on its own colour), the
background colour is used rather than nothing.
"""
img = Image.open(path).convert("RGBA")
img = (logo.copy() if isinstance(logo, Image.Image) else Image.open(logo)).convert("RGBA")
img.thumbnail((256, 256))
arr = np.array(img)
opaque = arr[:, :, 3] >= 200
@@ -545,9 +547,11 @@ def resolve_type(name, ptype=None, description=None):
def generate(lib, name, out_dir, ptype, probs=None, logo=None, colors=None, reroll=0, scale=4,
parts_dir=None, suffix=""):
"""Render one sprite with library `lib`; writes <slug><suffix>.png/.json (+ upscaled copy).
Returns (info, sprite)."""
parts_dir=None, suffix="", write_files=True):
"""Render one sprite with library `lib`; writes <slug><suffix>.png/.json (+ upscaled copy)
into `out_dir` and prints a summary line. With write_files=False nothing is written or
printed (out_dir may be None): the caller gets the image and info. `logo` is a path or a
PIL image. Returns (info, sprite)."""
rng = make_rng(name, reroll)
plan, chosen, markings, tags = pick_parts(lib, ptype, rng)
@@ -564,13 +568,6 @@ def generate(lib, name, out_dir, ptype, probs=None, logo=None, colors=None, rero
sprite = recolor(place_on_canvas(compose(parts_dir or lib.PARTS_DIR, chosen, markings, rng), lib), palette)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
slug = slugify(name) + (f"-r{reroll}" if reroll else "") + suffix
sprite.save(out_dir / f"{slug}.png")
if scale > 1:
sprite.resize((sprite.width * scale, sprite.height * scale), Image.NEAREST).save(out_dir / f"{slug}@{scale}x.png")
info = {
"name": name, "library": lib.NAME, "type": ptype, "type_probabilities": probs, "plan": plan,
"parts": {s: p["name"] for s, p in chosen.items()}, "markings": markings,
@@ -578,6 +575,16 @@ def generate(lib, name, out_dir, ptype, probs=None, logo=None, colors=None, rero
"colors": {"main": rgb_to_hex(main), "accent": rgb_to_hex(accent),
"type": TYPE_COLORS[ptype], "outline": rgb_to_hex(palette["outline"])},
}
if not write_files:
return info, sprite
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
slug = slugify(name) + (f"-r{reroll}" if reroll else "") + suffix
sprite.save(out_dir / f"{slug}.png")
if scale > 1:
sprite.resize((sprite.width * scale, sprite.height * scale), Image.NEAREST).save(out_dir / f"{slug}@{scale}x.png")
(out_dir / f"{slug}.json").write_text(json.dumps(info, indent=2))
print(f"{name} [{lib.NAME}]: {ptype.upper()} {plan} | {', '.join(info['attributes']) or 'no attributes'} | "
f"main {info['colors']['main']} accent {info['colors']['accent']} -> {out_dir / slug}.png")
@@ -755,6 +762,17 @@ def write_sprite_list(lib, path):
# ============================================================ CLI
def logo_for_domain(a, domain):
"""Logo found from the business's website (logo_fetch.py, cached in --logo-cache), or None."""
from logo_fetch import fetch_logo_info
path, method = fetch_logo_info(domain, a.logo_cache)
if path:
print(f"logo for {domain}: {path} ({method})")
else:
warn(f"no logo found for {domain}, using the type colors")
return path
def run_one(a, name, ptype=None, description=None, logo=None, colors=None, reroll=0):
"""Render `name` with --library, or with every library plus a comparison sheet (--compare)."""
ptype, probs = resolve_type(name, ptype, description)
@@ -783,7 +801,9 @@ def main():
ap.add_argument("--parts", help="Parts folder (default: the library's own)")
ap.add_argument("--out", default="sprites", help="Output folder")
ap.add_argument("--scale", type=int, default=4, help="Also save an upscaled copy (1 to disable)")
ap.add_argument("--batch", help="CSV with columns name,logo,type,description (only name required)")
ap.add_argument("--domain", help="Business website: find its logo there when no logo file is given")
ap.add_argument("--logo-cache", default="logos", help="Folder for logos found with --domain (default logos/)")
ap.add_argument("--batch", help="CSV with columns name,logo,domain,type,description (only name required)")
ap.add_argument("--make-placeholders", action="store_true", help="Create test art for every part")
ap.add_argument("--overwrite", action="store_true", help="With --make-placeholders, replace existing files")
ap.add_argument("--sprite-list", metavar="PATH", help="Write the art checklist and weight report")
@@ -802,12 +822,18 @@ def main():
for row in csv.DictReader(f):
if not (row.get("name") or "").strip():
continue
logo = (row.get("logo") or "").strip() or None
domain = (row.get("domain") or "").strip()
if not logo and domain:
logo = logo_for_domain(a, domain)
run_one(a, row["name"].strip(), (row.get("type") or "").strip() or None,
(row.get("description") or "").strip() or None, (row.get("logo") or "").strip() or None)
(row.get("description") or "").strip() or None, logo)
elif a.name:
logo = a.logo or a.logo_file
if logo and not Path(logo).exists():
sys.exit(f"Logo not found: {logo}")
if not logo and not colors and a.domain:
logo = logo_for_domain(a, a.domain)
run_one(a, a.name, a.type, a.desc, logo, colors, a.reroll)
elif not (a.make_placeholders or a.sprite_list):
ap.print_help()