Workcat's tail flick (-15% left), paw reach (+9% right), and stretch motions extend beyond the nominal cat bounding box. Previously, frames were drawn onto a compact 144x110 canvas without padding, causing negative offsets (e.g. ox=-22px on tail) to truncate the tail tip and create mismatched scales. Fixes: - Added padding (pad_x=20%, pad_y=20%) to the render canvas so all extended poses (tail swings to x=16, paw strikes to x=185) fit completely without clipping. - Unified the coordinate system: walk, tail, stretch, paw, and scruff now share the exact same body scale (height ~93-94px) and feet baseline (y=128).
527 lines
21 KiB
Python
527 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,
|
||
GAIT_SHIFT_X,
|
||
GAIT_SHIFT_Y,
|
||
Coat,
|
||
COATS,
|
||
Config,
|
||
)
|
||
|
||
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, config: Optional[Config] = None):
|
||
self.assets_dir = assets_dir or (Path(__file__).parent / "assets")
|
||
self.config = config or Config(cat_width=cat_width)
|
||
self.cat_width = self.config.cat_width
|
||
self.cat_height = self.config.cat_height
|
||
self.canvas_width = self.config.canvas_width
|
||
self.canvas_height = self.config.canvas_height
|
||
self.pad_x = self.config.pad_x
|
||
self.pad_y = self.config.pad_y
|
||
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
|
||
canvas_w = self.canvas_width
|
||
canvas_h = self.canvas_height
|
||
pad_x = self.pad_x
|
||
pad_y = self.pad_y
|
||
|
||
# 4× supersampling for clean anti-aliased polygon edges
|
||
SS = 4
|
||
ss_w = canvas_w * SS
|
||
ss_h = canvas_h * SS
|
||
img = Image.new("RGBA", (ss_w, ss_h), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# In Workcat:
|
||
# The SVG viewBox is 0 0 150 120, scaled to target_w x target_h,
|
||
# shifted by GAIT_SHIFT_X/Y relative to the cat unit (target_w / CAT_BOX_WIDTH),
|
||
# and placed with pad_x / pad_y inside the overlay canvas.
|
||
unit = (target_w / CAT_BOX_WIDTH) * SS
|
||
shift_x = pad_x * SS + GAIT_SHIFT_X * unit
|
||
shift_y = pad_y * SS + GAIT_SHIFT_Y * unit
|
||
scale_x = (target_w / 150.0) * SS
|
||
scale_y = (target_h / 120.0) * SS
|
||
|
||
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)
|
||
|
||
# Face features — coordinates in 150x120 viewBox shifted into position
|
||
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":
|
||
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":
|
||
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_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 to canvas resolution with high-quality Lanczos resampling
|
||
final_img = img.resize((canvas_w, canvas_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 canvas with pad offsets
|
||
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
|
||
offset_x = self.pad_x + int(round(self.cat_width * 0.02265))
|
||
offset_y = self.pad_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)
|
||
|
||
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
|
||
ox = self.pad_x + (self.cat_width - sw) // 2
|
||
oy = self.pad_y + (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 from Workcat: 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)
|
||
|
||
# Composite onto padded canvas — tail extension is fully preserved
|
||
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
|
||
ox = self.pad_x + int(round(self.cat_width * -0.150564))
|
||
oy = self.pad_y + 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 from Workcat: 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.canvas_width, self.canvas_height), (0, 0, 0, 0))
|
||
ox = self.pad_x + int(round(self.cat_width * -0.045169))
|
||
oy = self.pad_y + 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
|