Files
lhns--steam-frame-nix/modules/pet/skin.py
T
Pierre Kisters 4034fd4ded VR pet: a cat or dog in SteamVR's scene (steamFrame.pet)
A SteamVR dashboard patch (vr-pet) drawing a baked 3D pet next to the
windows: the Toon Cat in five coats, a Shiba Inu, a Fox and a Dachshund,
plus models from steamFrame.pet.extraModels. It walks around, follows,
sits, lies and sleeps, can be picked up by its grip bar, petted and
switched in its menu; its spot, pose and model are saved state.

Also the vr-pet command, "Pet" in the "+" menu with the current model's
icon (steam-frame-nix-pet-icon), the flake outputs pet-models,
pet-icons and pet-preview, and the check pet. A built-in model with
"private": true fails evaluation; private models belong in extraModels.
2026-10-01 04:33:39 +02:00

88 lines
3.2 KiB
Python

#!/usr/bin/env python3
"""Recolour the Toon Cat's texture (bake.py's cat.png) into another coat.
usage: skin.py base.png model.json out.png
model.json (models/<id>/model.json, kind "recolor"):
{ "name": "Tuxedo", "kind": "recolor",
"fur": ["#18181c", "#55555e"], dark and light end of the coat
"white": ["chest", "muzzle", "paws"] } optional white markings
The texture is a gradient atlas (64 x 64): each vertex samples one texel,
and the body's texels are laid out like a side view of the cat (columns
along its length, rows by height), each column a dark-to-light ramp that
bakes the shading in. Fur texels are the ginger ones (hue 0.03-0.14,
saturation > 0.15); each is replaced by the new coat's ramp at the same
brightness (its place between the darkest and lightest fur texel), so the
baked shading stays. White markings are texel rectangles of that side view
(PATTERNS, checked against renders of the baked frames), painted from a
white ramp at the same brightness. Everything else (eyes, nose, mouth,
whisker and inner-ear swatches) is kept as it is.
"""
import json
import sys
import numpy as np
from PIL import Image
HUE = (0.03, 0.14)
SAT = 0.15
# White markings, texel rectangles (x0, x1, y0, y1 inclusive) of the Toon
# Cat's 64 x 64 atlas: columns 0-4 face / throat, 5-6 chest and front legs,
# 9-11 hind legs; rows ~14 (floor) to ~40 (head top).
PATTERNS = {
'muzzle': [(0, 3, 24, 45)], # chin, mouth, muzzle and cheeks up between the eyes
'chest': [(4, 4, 22, 34), (5, 5, 20, 33), (6, 6, 20, 30), (7, 8, 22, 29)], # throat, chest, front legs' fronts, belly
'paws': [(5, 6, 13, 19), (9, 11, 13, 18)], # front and hind paws
}
WHITE = ('#dcdcdf', '#ffffff')
def rgb(c):
c = c.lstrip('#')
return np.array([int(c[i:i + 2], 16) for i in (0, 2, 4)], dtype=float) / 255
def hsv(a):
mx, mn = a.max(-1), a.min(-1)
d = mx - mn
s = np.where(mx > 0, d / np.maximum(mx, 1e-9), 0)
r, g, b = a[..., 0], a[..., 1], a[..., 2]
dd = np.maximum(d, 1e-9)
h = np.where(mx == r, ((g - b) / dd) % 6, np.where(mx == g, (b - r) / dd + 2, (r - g) / dd + 4)) / 6
return np.where(d > 0, h, 0), s, mx
def luma(a):
return a @ np.array([0.2126, 0.7152, 0.0722])
def fur_mask(a):
h, s, _ = hsv(a)
return (h >= HUE[0]) & (h <= HUE[1]) & (s > SAT)
def recolor(base, spec):
a = np.asarray(base.convert('RGB')).astype(float) / 255
fur = fur_mask(a)
lum = luma(a)
lo, hi = lum[fur].min(), lum[fur].max()
t = np.clip((lum - lo) / (hi - lo), 0, 1)[..., None]
dark, light = (rgb(c) for c in spec['fur'])
out = a.copy()
out[fur] = (dark + (light - dark) * t)[fur]
marks = np.zeros(fur.shape, bool)
for name in spec.get('white', []):
for x0, x1, y0, y1 in PATTERNS[name]:
marks[y0:y1 + 1, x0:x1 + 1] = True
marks &= fur
wd, wl = (rgb(c) for c in WHITE)
out[marks] = (wd + (wl - wd) * t)[marks]
return Image.fromarray((np.clip(out, 0, 1) * 255).round().astype(np.uint8), 'RGB'), fur
if __name__ == '__main__':
base, spec_path, out = sys.argv[1:4]
spec = json.load(open(spec_path))
img, _ = recolor(Image.open(base), spec)
img.save(out, optimize=True)