521 lines
21 KiB
Python
521 lines
21 KiB
Python
"""
|
|
Asset Manager for Catser.
|
|
|
|
Handles downloading, caching, rasterizing, and color-tinting all sprite assets
|
|
from Workcat (https://workcat.app).
|
|
|
|
Assets managed:
|
|
- Walk frames: 30 SVG polygon paths rasterized with antialiasing.
|
|
- Paw frames: 17 WebP images for the strike animation.
|
|
- Scruff frame: WebP image used when cat is grabbed and dragged.
|
|
- Tail frames: 37 WebP images for idle tail flick.
|
|
- Stretch frames: 37 WebP images for idle stretch.
|
|
- Poses & Faces: Sit, sleep, open eyes, blink, happy face, mouth, alert '!', and heart '♥'.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
import math
|
|
import logging
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple, Any
|
|
|
|
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageColor
|
|
|
|
from .config import (
|
|
GAIT_FRAME_COUNT,
|
|
PAW_FRAME_COUNT,
|
|
LIFE_MOTION_FRAME_COUNT,
|
|
CAT_BOX_WIDTH,
|
|
CAT_BOX_HEIGHT,
|
|
Coat,
|
|
COATS,
|
|
)
|
|
|
|
logger = logging.getLogger("catser.assets")
|
|
|
|
ASSET_BASE_URL = "https://workcat.app/assets/cat"
|
|
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
|
|
|
|
|
|
def _download_file(url: str, dest_path: Path) -> bool:
|
|
"""
|
|
Downloads a file with custom User-Agent to avoid Cloudflare 403 blocks.
|
|
Returns True on success, False otherwise.
|
|
"""
|
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if dest_path.exists() and dest_path.stat().st_size > 0:
|
|
return True
|
|
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
content = resp.read()
|
|
with open(dest_path, "wb") as f:
|
|
f.write(content)
|
|
logger.info(f"Downloaded {dest_path.name} ({len(content)} bytes)")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to download {url}: {e}")
|
|
return False
|
|
|
|
|
|
def _parse_svg_polygon_points(path_str: str) -> List[Tuple[float, float]]:
|
|
"""
|
|
Extracts (x, y) coordinate pairs from an SVG path consisting of M and L commands.
|
|
Used for walk frame silhouettes.
|
|
"""
|
|
coords = re.findall(r"[-+]?\d*\.?\d+", path_str)
|
|
points = []
|
|
for i in range(0, len(coords) - 1, 2):
|
|
points.append((float(coords[i]), float(coords[i + 1])))
|
|
return points
|
|
|
|
|
|
class AssetsManager:
|
|
"""
|
|
Central asset pipeline for Catser.
|
|
Manages downloading, caching, rasterizing, and scaling of all cat sprites.
|
|
"""
|
|
|
|
def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144):
|
|
self.assets_dir = assets_dir or (Path(__file__).parent / "assets")
|
|
self.cat_width = cat_width
|
|
self.cat_height = int(round(cat_width * (CAT_BOX_HEIGHT / CAT_BOX_WIDTH)))
|
|
self.scale = self.cat_width / CAT_BOX_WIDTH
|
|
|
|
# Memory cache for rendered frames keyed by (coat_name, frame_id, facing_left)
|
|
self._cache: Dict[str, Image.Image] = {}
|
|
|
|
# Raw data loaded from disk
|
|
self.walk_path_strings: List[str] = []
|
|
self.paw_images: List[Image.Image] = []
|
|
self.tail_images: List[Image.Image] = []
|
|
self.stretch_images: List[Image.Image] = []
|
|
self.scruff_image: Optional[Image.Image] = None
|
|
|
|
def initialize(self) -> None:
|
|
"""Downloads all missing assets and prepares raw frames."""
|
|
self.assets_dir.mkdir(parents=True, exist_ok=True)
|
|
self._ensure_assets_downloaded()
|
|
self._load_raw_assets()
|
|
|
|
def _ensure_assets_downloaded(self) -> None:
|
|
"""Verifies and downloads all animation assets from workcat.app."""
|
|
# 1. Walk frames JSON
|
|
walk_json_path = self.assets_dir / "walk-frames.json"
|
|
_download_file(f"{ASSET_BASE_URL}/walk-frames.json", walk_json_path)
|
|
|
|
# 2. Paw frames (f001.webp - f017.webp)
|
|
paw_dir = self.assets_dir / "paw"
|
|
for i in range(1, PAW_FRAME_COUNT + 1):
|
|
name = f"f{i:03d}.webp"
|
|
_download_file(f"{ASSET_BASE_URL}/paw/{name}", paw_dir / name)
|
|
|
|
# 3. Scruff frame
|
|
_download_file(f"{ASSET_BASE_URL}/scruff.webp", self.assets_dir / "scruff.webp")
|
|
|
|
# 4. Tail frames (f001.webp - f037.webp)
|
|
tail_dir = self.assets_dir / "tail"
|
|
for i in range(1, LIFE_MOTION_FRAME_COUNT + 1):
|
|
name = f"f{i:03d}.webp"
|
|
_download_file(f"{ASSET_BASE_URL}/tail/{name}", tail_dir / name)
|
|
|
|
# 5. Stretch frames (f001.webp - f037.webp)
|
|
stretch_dir = self.assets_dir / "stretch"
|
|
for i in range(1, LIFE_MOTION_FRAME_COUNT + 1):
|
|
name = f"f{i:03d}.webp"
|
|
_download_file(f"{ASSET_BASE_URL}/stretch/{name}", stretch_dir / name)
|
|
|
|
def _load_raw_assets(self) -> None:
|
|
"""Loads JSON and WebP images into memory."""
|
|
walk_json_path = self.assets_dir / "walk-frames.json"
|
|
if walk_json_path.exists():
|
|
try:
|
|
with open(walk_json_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
self.walk_path_strings = data.get("frames", [])
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse walk-frames.json: {e}")
|
|
|
|
# Paw
|
|
paw_dir = self.assets_dir / "paw"
|
|
self.paw_images = []
|
|
for i in range(1, PAW_FRAME_COUNT + 1):
|
|
p = paw_dir / f"f{i:03d}.webp"
|
|
if p.exists():
|
|
try:
|
|
self.paw_images.append(Image.open(p).convert("RGBA"))
|
|
except Exception as e:
|
|
logger.warning(f"Error opening {p}: {e}")
|
|
|
|
# Scruff
|
|
scruff_path = self.assets_dir / "scruff.webp"
|
|
if scruff_path.exists():
|
|
try:
|
|
self.scruff_image = Image.open(scruff_path).convert("RGBA")
|
|
except Exception as e:
|
|
logger.warning(f"Error opening scruff: {e}")
|
|
|
|
# Tail
|
|
tail_dir = self.assets_dir / "tail"
|
|
self.tail_images = []
|
|
for i in range(1, LIFE_MOTION_FRAME_COUNT + 1):
|
|
p = tail_dir / f"f{i:03d}.webp"
|
|
if p.exists():
|
|
try:
|
|
self.tail_images.append(Image.open(p).convert("RGBA"))
|
|
except Exception as e:
|
|
logger.warning(f"Error opening {p}: {e}")
|
|
|
|
# Stretch
|
|
stretch_dir = self.assets_dir / "stretch"
|
|
self.stretch_images = []
|
|
for i in range(1, LIFE_MOTION_FRAME_COUNT + 1):
|
|
p = stretch_dir / f"f{i:03d}.webp"
|
|
if p.exists():
|
|
try:
|
|
self.stretch_images.append(Image.open(p).convert("RGBA"))
|
|
except Exception as e:
|
|
logger.warning(f"Error opening {p}: {e}")
|
|
|
|
# ==========================================================================
|
|
# Frame Rendering & Raster Generation
|
|
# ==========================================================================
|
|
|
|
def get_walk_frame(
|
|
self,
|
|
frame_index: int,
|
|
coat: Coat,
|
|
facing_left: bool = False,
|
|
face_type: str = "open", # "open", "blink", "happy"
|
|
) -> Image.Image:
|
|
"""
|
|
Renders a walk cycle frame with antialiased polygon vector and face features.
|
|
Face types: 'open' (default eyes), 'blink' (lines), 'happy' (upward curves).
|
|
"""
|
|
frame_index = frame_index % max(len(self.walk_path_strings), 1)
|
|
cache_key = f"walk_{frame_index}_{coat.name}_{facing_left}_{face_type}"
|
|
if cache_key in self._cache:
|
|
return self._cache[cache_key]
|
|
|
|
target_w = self.cat_width
|
|
target_h = self.cat_height
|
|
|
|
# 4x Supersampling for clean anti-aliased polygon rasterization
|
|
SS = 4
|
|
canvas_w = target_w * SS
|
|
canvas_h = target_h * SS
|
|
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Scale from SVG viewBox (150x120) to supersampled canvas
|
|
# Note: In cat.js viewBox is 0 0 150 120 and CAT_BOX is 121.2x92.4 with shift
|
|
# Shift in cat.js: translate(GAIT_SHIFT_X * unit, GAIT_SHIFT_Y * unit)
|
|
unit = (target_w / CAT_BOX_WIDTH) * SS
|
|
shift_x = -6.383 * unit
|
|
shift_y = 3.76 * unit
|
|
scale_x = (target_w / 150.0) * SS * 1.25 # Aspect ratio fitting
|
|
scale_y = (target_h / 120.0) * SS * 1.25
|
|
|
|
if self.walk_path_strings:
|
|
pts = _parse_svg_polygon_points(self.walk_path_strings[frame_index])
|
|
ss_points = [(p[0] * scale_x + shift_x, p[1] * scale_y + shift_y) for p in pts]
|
|
fur_rgba = (*coat.fur_color, 255)
|
|
draw.polygon(ss_points, fill=fur_rgba)
|
|
|
|
# Draw Face Features
|
|
# Face coordinates in 150x120 viewBox:
|
|
# Eye L: cx=120.4, cy=46.2, r=2.2
|
|
# Eye R: cx=140.3, cy=46.2, r=2.2
|
|
# Mouth: Q curves around x=127-135, y=50-52
|
|
ink_rgba = (*coat.ink_color, 255)
|
|
|
|
eye_l_cx = 120.4 * scale_x + shift_x
|
|
eye_l_cy = 46.2 * scale_y + shift_y
|
|
eye_r_cx = 140.3 * scale_x + shift_x
|
|
eye_r_cy = 46.2 * scale_y + shift_y
|
|
eye_r = 2.2 * scale_x
|
|
|
|
if face_type == "open":
|
|
draw.ellipse(
|
|
[eye_l_cx - eye_r, eye_l_cy - eye_r, eye_l_cx + eye_r, eye_l_cy + eye_r],
|
|
fill=ink_rgba,
|
|
)
|
|
draw.ellipse(
|
|
[eye_r_cx - eye_r, eye_r_cy - eye_r, eye_r_cx + eye_r, eye_r_cy + eye_r],
|
|
fill=ink_rgba,
|
|
)
|
|
elif face_type == "blink":
|
|
# Downward blink lines
|
|
w_eye = eye_r * 1.5
|
|
draw.line([(eye_l_cx - w_eye, eye_l_cy), (eye_l_cx + w_eye, eye_l_cy)], fill=ink_rgba, width=int(2 * SS))
|
|
draw.line([(eye_r_cx - w_eye, eye_r_cy), (eye_r_cx + w_eye, eye_r_cy)], fill=ink_rgba, width=int(2 * SS))
|
|
elif face_type == "happy":
|
|
# Upward arc happy eyes (^^)
|
|
w_eye = eye_r * 1.6
|
|
h_eye = eye_r * 1.3
|
|
draw.arc([eye_l_cx - w_eye, eye_l_cy - h_eye, eye_l_cx + w_eye, eye_l_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
|
|
draw.arc([eye_r_cx - w_eye, eye_r_cy - h_eye, eye_r_cx + w_eye, eye_r_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
|
|
|
|
# Mouth (w shape)
|
|
mouth_cx = 131.6 * scale_x + shift_x
|
|
mouth_cy = 51.0 * scale_y + shift_y
|
|
mw = 4.0 * scale_x
|
|
mh = 2.5 * scale_y
|
|
draw.arc([mouth_cx - mw, mouth_cy - mh, mouth_cx, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
|
|
draw.arc([mouth_cx, mouth_cy - mh, mouth_cx + mw, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
|
|
|
|
# Downsample back to target dimensions with high-quality Lanczos resampling
|
|
final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
|
|
|
if facing_left:
|
|
final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
|
|
|
self._cache[cache_key] = final_img
|
|
return final_img
|
|
|
|
def get_paw_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
|
|
"""
|
|
Retrieves and tints a frame from the paw strike animation (f001-f017).
|
|
Applies Workcat paw crop geometry:
|
|
left: 2.265%, top: -3.083%, width: 106.72%, height: 96.833%
|
|
"""
|
|
frame_index = max(0, min(frame_index, len(self.paw_images) - 1))
|
|
cache_key = f"paw_{frame_index}_{coat.name}_{facing_left}"
|
|
if cache_key in self._cache:
|
|
return self._cache[cache_key]
|
|
|
|
raw = self.paw_images[frame_index] if self.paw_images else self._get_fallback_cat(coat)
|
|
tinted = self._tint_raster(raw, coat)
|
|
|
|
# Scale according to paw geometry
|
|
pw = int(round(self.cat_width * 1.0672))
|
|
ph = int(round(self.cat_height * 0.96833))
|
|
resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS)
|
|
|
|
# Composite onto standard cat canvas with offsets
|
|
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
|
offset_x = int(round(self.cat_width * 0.02265))
|
|
offset_y = int(round(self.cat_height * -0.03083))
|
|
canvas.paste(resized, (offset_x, offset_y), resized)
|
|
|
|
if facing_left:
|
|
canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
|
|
|
self._cache[cache_key] = canvas
|
|
return canvas
|
|
|
|
def get_scruff_frame(self, coat: Coat) -> Image.Image:
|
|
"""
|
|
Frame displayed when the cat is dragged by the neck scruff.
|
|
Scale factor is 1.29 (Workcat standard).
|
|
"""
|
|
cache_key = f"scruff_{coat.name}"
|
|
if cache_key in self._cache:
|
|
return self._cache[cache_key]
|
|
|
|
raw = self.scruff_image if self.scruff_image else self._get_fallback_cat(coat)
|
|
tinted = self._tint_raster(raw, coat)
|
|
|
|
sw = int(round(self.cat_width * 1.29))
|
|
sh = int(round(self.cat_height * 1.29))
|
|
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
|
|
|
|
# Center in canvas
|
|
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
|
ox = (self.cat_width - sw) // 2
|
|
oy = (self.cat_height - sh) // 2
|
|
canvas.paste(resized, (ox, oy), resized)
|
|
|
|
self._cache[cache_key] = canvas
|
|
return canvas
|
|
|
|
def get_tail_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
|
|
"""
|
|
Frame from the 37-frame tail flick life motion.
|
|
Geometry: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692%
|
|
"""
|
|
frame_index = max(0, min(frame_index, len(self.tail_images) - 1)) if self.tail_images else 0
|
|
cache_key = f"tail_{frame_index}_{coat.name}_{facing_left}"
|
|
if cache_key in self._cache:
|
|
return self._cache[cache_key]
|
|
|
|
raw = self.tail_images[frame_index] if self.tail_images else self._get_fallback_cat(coat)
|
|
tinted = self._tint_raster(raw, coat)
|
|
|
|
tw = int(round(self.cat_width * 1.175657))
|
|
th = int(round(self.cat_height * 1.153692))
|
|
resized = tinted.resize((tw, th), Image.Resampling.LANCZOS)
|
|
|
|
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
|
ox = int(round(self.cat_width * -0.150564))
|
|
oy = int(round(self.cat_height * -0.142679))
|
|
canvas.paste(resized, (ox, oy), resized)
|
|
|
|
if facing_left:
|
|
canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
|
|
|
self._cache[cache_key] = canvas
|
|
return canvas
|
|
|
|
def get_stretch_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
|
|
"""
|
|
Frame from the 37-frame stretch life motion.
|
|
Geometry: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865%
|
|
"""
|
|
frame_index = max(0, min(frame_index, len(self.stretch_images) - 1)) if self.stretch_images else 0
|
|
cache_key = f"stretch_{frame_index}_{coat.name}_{facing_left}"
|
|
if cache_key in self._cache:
|
|
return self._cache[cache_key]
|
|
|
|
raw = self.stretch_images[frame_index] if self.stretch_images else self._get_fallback_cat(coat)
|
|
tinted = self._tint_raster(raw, coat)
|
|
|
|
sw = int(round(self.cat_width * 1.070263))
|
|
sh = int(round(self.cat_height * 1.008865))
|
|
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
|
|
|
|
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
|
ox = int(round(self.cat_width * -0.045169))
|
|
oy = int(round(self.cat_height * 0.00215))
|
|
canvas.paste(resized, (ox, oy), resized)
|
|
|
|
if facing_left:
|
|
canvas = canvas.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
|
|
|
self._cache[cache_key] = canvas
|
|
return canvas
|
|
|
|
# ==========================================================================
|
|
# Overlays & Indicators
|
|
# ==========================================================================
|
|
|
|
def get_alert_bubble(self, size: int = 36) -> Image.Image:
|
|
"""Draws the red '!' alert exclamation indicator with drop shadow."""
|
|
cache_key = f"alert_{size}"
|
|
if cache_key in self._cache:
|
|
return self._cache[cache_key]
|
|
|
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Red alert circle
|
|
cx, cy = size // 2, size // 2
|
|
r = size // 2 - 2
|
|
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 69, 58, 240), outline=(255, 255, 255, 255), width=2)
|
|
|
|
# White '!' in center
|
|
bar_w = max(2, size // 10)
|
|
draw.rounded_rectangle([cx - bar_w // 2, cy - r // 2, cx + bar_w // 2, cy + r // 5], radius=2, fill=(255, 255, 255, 255))
|
|
dot_r = max(2, size // 12)
|
|
draw.ellipse([cx - dot_r, cy + r // 3, cx + dot_r, cy + r // 3 + dot_r * 2], fill=(255, 255, 255, 255))
|
|
|
|
self._cache[cache_key] = img
|
|
return img
|
|
|
|
def get_heart_particle(self, size: int = 32, alpha: float = 1.0) -> Image.Image:
|
|
"""Draws a floating pink heart particle for the petting reaction."""
|
|
cache_key = f"heart_{size}_{int(alpha * 100)}"
|
|
if cache_key in self._cache:
|
|
return self._cache[cache_key]
|
|
|
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
a = int(round(255 * max(0.0, min(1.0, alpha))))
|
|
fill_color = (240, 100, 130, a)
|
|
|
|
# Mathematical heart curve:
|
|
# x = 16 sin^3(t)
|
|
# y = 13 cos(t) - 5 cos(2t) - 2 cos(3t) - cos(4t)
|
|
pts = []
|
|
steps = 60
|
|
scale = size / 38.0
|
|
cx, cy = size / 2.0, size / 2.0 - 2.0 * scale
|
|
for s in range(steps):
|
|
t = (math.pi * 2 * s) / steps
|
|
x = 16 * (math.sin(t) ** 3)
|
|
y = -(13 * math.cos(t) - 5 * math.cos(2 * t) - 2 * math.cos(3 * t) - math.cos(4 * t))
|
|
pts.append((cx + x * scale, cy + y * scale))
|
|
|
|
draw.polygon(pts, fill=fill_color)
|
|
self._cache[cache_key] = img
|
|
return img
|
|
|
|
def get_zzz_indicator(self, size: int = 32, step: int = 0) -> Image.Image:
|
|
"""Draws floating 'z' snoring indicators."""
|
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
gold = (122, 165, 214, 220)
|
|
|
|
# Draw a stylish 'z'
|
|
margin = size // 5
|
|
draw.line([(margin, margin), (size - margin, margin)], fill=gold, width=3)
|
|
draw.line([(size - margin, margin), (margin, size - margin)], fill=gold, width=3)
|
|
draw.line([(margin, size - margin), (size - margin, size - margin)], fill=gold, width=3)
|
|
return img
|
|
|
|
def get_shadow(self, width: int, lift_ratio: float = 0.0) -> Image.Image:
|
|
"""Draws a realistic floor contact shadow that fades and shrinks when lifted."""
|
|
sh_w = int(round(width * max(0.3, 0.8 - lift_ratio * 0.3)))
|
|
sh_h = max(4, int(round(sh_w * 0.16)))
|
|
opacity = max(0.0, min(0.7, 0.7 * (1.0 - lift_ratio * 0.85)))
|
|
|
|
img = Image.new("RGBA", (sh_w, sh_h), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
alpha = int(255 * opacity)
|
|
draw.ellipse([0, 0, sh_w, sh_h], fill=(0, 0, 0, alpha))
|
|
return img.filter(ImageFilter.GaussianBlur(radius=2))
|
|
|
|
# ==========================================================================
|
|
# Private Helpers
|
|
# ==========================================================================
|
|
|
|
def _tint_raster(self, img: Image.Image, coat: Coat) -> Image.Image:
|
|
"""
|
|
Applies color shifting to raw WebP assets to match the selected coat color.
|
|
Maps closely to Workcat's CSS rasterFilter:
|
|
- ivory: original
|
|
- charcoal: dark grey
|
|
- grey: medium neutral grey
|
|
- apricot: warm golden amber
|
|
- sage: soft muted green
|
|
- plum: purple-rose tint
|
|
"""
|
|
if coat.name == "ivory":
|
|
return img
|
|
|
|
# Split RGBA channels
|
|
r, g, b, a = img.split()
|
|
target_r, target_g, target_b = coat.fur_color
|
|
|
|
# Calculate luminance
|
|
# L = 0.299 R + 0.587 G + 0.114 B
|
|
def tint_pixel(val: int, factor: float) -> int:
|
|
return int(round(val * factor))
|
|
|
|
# Color tint multiplier relative to ivory (232, 229, 218)
|
|
rf = target_r / 232.0
|
|
gf = target_g / 229.0
|
|
bf = target_b / 218.0
|
|
|
|
r_tint = r.point(lambda v: min(255, int(v * rf)))
|
|
g_tint = g.point(lambda v: min(255, int(v * gf)))
|
|
b_tint = b.point(lambda v: min(255, int(v * bf)))
|
|
|
|
return Image.merge("RGBA", (r_tint, g_tint, b_tint, a))
|
|
|
|
def _get_fallback_cat(self, coat: Coat) -> Image.Image:
|
|
"""Fallback simple silhouette if WebP files are not yet downloaded."""
|
|
img = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
# Simple rounded cat shape
|
|
draw.rounded_rectangle([10, 20, self.cat_width - 10, self.cat_height - 10], radius=16, fill=(*coat.fur_color, 255))
|
|
# Ears
|
|
draw.polygon([(20, 25), (35, 5), (50, 25)], fill=(*coat.fur_color, 255))
|
|
draw.polygon([(self.cat_width - 50, 25), (self.cat_width - 35, 5), (self.cat_width - 20, 25)], fill=(*coat.fur_color, 255))
|
|
return img
|