shadepix 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shadepix-0.3.0/LICENSE +21 -0
- shadepix-0.3.0/PKG-INFO +109 -0
- shadepix-0.3.0/README.md +96 -0
- shadepix-0.3.0/pyproject.toml +22 -0
- shadepix-0.3.0/setup.cfg +4 -0
- shadepix-0.3.0/shade.py +875 -0
- shadepix-0.3.0/shadepix.egg-info/PKG-INFO +109 -0
- shadepix-0.3.0/shadepix.egg-info/SOURCES.txt +9 -0
- shadepix-0.3.0/shadepix.egg-info/dependency_links.txt +1 -0
- shadepix-0.3.0/shadepix.egg-info/entry_points.txt +2 -0
- shadepix-0.3.0/shadepix.egg-info/top_level.txt +1 -0
shadepix-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bastien Cochet
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
shadepix-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shadepix
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Faceless pixel avatars from your terminal. Zero dependencies.
|
|
5
|
+
Author: Bastien Cochet
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bstcoc/shade
|
|
8
|
+
Keywords: pixel-art,avatar,cli,terminal,generator
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="assets/wordmark.png" width="420" alt="shade">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<b>Faceless pixel avatars from your terminal.</b><br>
|
|
20
|
+
One Python file. Zero dependencies. Every face stays blank.<br><br>
|
|
21
|
+
<a href="https://bstcoc.github.io/shade/"><b>Try it in your browser →</b></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<img src="assets/examples.png" alt="Avatars made with shade">
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
## Why
|
|
29
|
+
|
|
30
|
+
Most avatar makers draw a face. shade draws everything else: hair, beard, hat, clothes, background.
|
|
31
|
+
Eyes are always covered (shades, visor, goggles, a band), and there is no nose and no mouth.
|
|
32
|
+
You get an avatar that feels like you without showing a face.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uv tool install git+https://github.com/bstcoc/shade
|
|
38
|
+
# or: pipx install git+https://github.com/bstcoc/shade
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Needs Python 3.10+ and nothing else. Works on macOS, Linux and Windows. The command is `shade`.
|
|
42
|
+
|
|
43
|
+
## Use it
|
|
44
|
+
|
|
45
|
+
Run `shade` to open the studio:
|
|
46
|
+
|
|
47
|
+
<p align="center"><img src="assets/demo.gif" width="760" alt="The shade studio"></p>
|
|
48
|
+
|
|
49
|
+
- `↑` `↓` pick a setting, `←` `→` change it, and the preview updates live
|
|
50
|
+
- `space` shuffles the look and keeps your size, shape and detail level
|
|
51
|
+
- `⏎` saves a PNG and `g` saves an animated GIF, then you can keep going
|
|
52
|
+
|
|
53
|
+
You can also make an avatar without the studio:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
shade curly visor hoodie # words in any order
|
|
57
|
+
shade topcolor=navy bg=night stars # colors take group=value
|
|
58
|
+
shade random beanie # random look, with a beanie
|
|
59
|
+
shade curly visor gif # animated GIF: a light streak sweeps the lenses
|
|
60
|
+
shade @egtuw4yi # rebuild an avatar from its code
|
|
61
|
+
shade help # every group and value
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every avatar has a short code like `@egtuw4yi`. Share it and anyone can rebuild the same avatar.
|
|
65
|
+
|
|
66
|
+
### What you can change
|
|
67
|
+
|
|
68
|
+
17 settings: head shape, skin, hair, hair color, beard, hat, hat color, eyewear, lens color, top, top color, extra, background, pattern, detail level (`rich`, `shaded`, `flat`), shape (`rounded`, `circle`, `square`) and size (200, 400 or 1200 px).
|
|
69
|
+
|
|
70
|
+
## Use it from an agent
|
|
71
|
+
|
|
72
|
+
shade ships a skill for Claude Code. Install it once and ask for a pixel avatar in plain words:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
mkdir -p ~/.claude/skills/shade
|
|
76
|
+
curl -fsSL -o ~/.claude/skills/shade/SKILL.md https://raw.githubusercontent.com/bstcoc/shade/main/skills/shade/SKILL.md
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Let any AI design one
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
shade agent
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This copies a ready-made prompt to your clipboard. Paste it to any AI and it replies with a `shade` command, or with a pixel grid you render using `shade draw`.
|
|
86
|
+
|
|
87
|
+
## Draw your own
|
|
88
|
+
|
|
89
|
+
A grid is a text file. A line like `K #0d0d14` sets a color, every other line is a row of pixels, and `.` is transparent.
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
K #0d0d14
|
|
93
|
+
r #ff0000
|
|
94
|
+
.K.
|
|
95
|
+
KrK
|
|
96
|
+
.K.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
shade draw art.txt
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Releases
|
|
104
|
+
|
|
105
|
+
Every merged release PR publishes a new version to PyPI and GitHub, and the [web demo](https://bstcoc.github.io/shade/) picks it up with its notes. See [CHANGELOG.md](CHANGELOG.md).
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
[MIT](LICENSE)
|
shadepix-0.3.0/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/wordmark.png" width="420" alt="shade">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<b>Faceless pixel avatars from your terminal.</b><br>
|
|
7
|
+
One Python file. Zero dependencies. Every face stays blank.<br><br>
|
|
8
|
+
<a href="https://bstcoc.github.io/shade/"><b>Try it in your browser →</b></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<img src="assets/examples.png" alt="Avatars made with shade">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
Most avatar makers draw a face. shade draws everything else: hair, beard, hat, clothes, background.
|
|
18
|
+
Eyes are always covered (shades, visor, goggles, a band), and there is no nose and no mouth.
|
|
19
|
+
You get an avatar that feels like you without showing a face.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv tool install git+https://github.com/bstcoc/shade
|
|
25
|
+
# or: pipx install git+https://github.com/bstcoc/shade
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Needs Python 3.10+ and nothing else. Works on macOS, Linux and Windows. The command is `shade`.
|
|
29
|
+
|
|
30
|
+
## Use it
|
|
31
|
+
|
|
32
|
+
Run `shade` to open the studio:
|
|
33
|
+
|
|
34
|
+
<p align="center"><img src="assets/demo.gif" width="760" alt="The shade studio"></p>
|
|
35
|
+
|
|
36
|
+
- `↑` `↓` pick a setting, `←` `→` change it, and the preview updates live
|
|
37
|
+
- `space` shuffles the look and keeps your size, shape and detail level
|
|
38
|
+
- `⏎` saves a PNG and `g` saves an animated GIF, then you can keep going
|
|
39
|
+
|
|
40
|
+
You can also make an avatar without the studio:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
shade curly visor hoodie # words in any order
|
|
44
|
+
shade topcolor=navy bg=night stars # colors take group=value
|
|
45
|
+
shade random beanie # random look, with a beanie
|
|
46
|
+
shade curly visor gif # animated GIF: a light streak sweeps the lenses
|
|
47
|
+
shade @egtuw4yi # rebuild an avatar from its code
|
|
48
|
+
shade help # every group and value
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Every avatar has a short code like `@egtuw4yi`. Share it and anyone can rebuild the same avatar.
|
|
52
|
+
|
|
53
|
+
### What you can change
|
|
54
|
+
|
|
55
|
+
17 settings: head shape, skin, hair, hair color, beard, hat, hat color, eyewear, lens color, top, top color, extra, background, pattern, detail level (`rich`, `shaded`, `flat`), shape (`rounded`, `circle`, `square`) and size (200, 400 or 1200 px).
|
|
56
|
+
|
|
57
|
+
## Use it from an agent
|
|
58
|
+
|
|
59
|
+
shade ships a skill for Claude Code. Install it once and ask for a pixel avatar in plain words:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
mkdir -p ~/.claude/skills/shade
|
|
63
|
+
curl -fsSL -o ~/.claude/skills/shade/SKILL.md https://raw.githubusercontent.com/bstcoc/shade/main/skills/shade/SKILL.md
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Let any AI design one
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
shade agent
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This copies a ready-made prompt to your clipboard. Paste it to any AI and it replies with a `shade` command, or with a pixel grid you render using `shade draw`.
|
|
73
|
+
|
|
74
|
+
## Draw your own
|
|
75
|
+
|
|
76
|
+
A grid is a text file. A line like `K #0d0d14` sets a color, every other line is a row of pixels, and `.` is transparent.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
K #0d0d14
|
|
80
|
+
r #ff0000
|
|
81
|
+
.K.
|
|
82
|
+
KrK
|
|
83
|
+
.K.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
shade draw art.txt
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Releases
|
|
91
|
+
|
|
92
|
+
Every merged release PR publishes a new version to PyPI and GitHub, and the [web demo](https://bstcoc.github.io/shade/) picks it up with its notes. See [CHANGELOG.md](CHANGELOG.md).
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "shadepix"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Faceless pixel avatars from your terminal. Zero dependencies."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [{ name = "Bastien Cochet" }]
|
|
9
|
+
keywords = ["pixel-art", "avatar", "cli", "terminal", "generator"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/bstcoc/shade"
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
shade = "shade:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["setuptools>=61"]
|
|
19
|
+
build-backend = "setuptools.build_meta"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
py-modules = ["shade"]
|
shadepix-0.3.0/setup.cfg
ADDED
shadepix-0.3.0/shade.py
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""shade: faceless pixel avatars from your terminal. Zero dependencies.
|
|
3
|
+
|
|
4
|
+
House rule: no eyes, no nose, no mouth. Eyes are always covered and faces stay blank.
|
|
5
|
+
"""
|
|
6
|
+
import os, random, re, shutil, struct, sys, time, zlib
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.0" # x-release-please-version
|
|
9
|
+
|
|
10
|
+
N, C = 40, 20 # canvas is N x N; a half-width hw covers columns C-hw .. C+hw-1
|
|
11
|
+
FRAMES = 24 # one animation loop; every moving pattern repeats within it
|
|
12
|
+
|
|
13
|
+
# ── options ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
SKIN = {"porcelain": "f7dcc9", "light": "edc4a3", "sand": "dcaa80", "tan": "c48c5f",
|
|
16
|
+
"brown": "93603e", "deep": "62402a", "cocoa": "432c1e"}
|
|
17
|
+
HAIR = {"black": "26232f", "brown": "5a3722", "auburn": "8a422b", "blonde": "e3bd62",
|
|
18
|
+
"ginger": "d26b33", "gray": "a0a0aa", "white": "ececf2", "blue": "4874e2",
|
|
19
|
+
"pink": "ec74b2", "green": "45b06f", "purple": "8658da"}
|
|
20
|
+
FABRIC = {"black": "2a2933", "white": "ecebf0", "red": "dc4b4b", "orange": "f28e3e",
|
|
21
|
+
"yellow": "f3cc4f", "green": "43a96e", "teal": "2fb6ab", "blue": "4074e3",
|
|
22
|
+
"navy": "2c3a66", "purple": "8254da", "pink": "ef74b2", "olive": "6e7c3e"}
|
|
23
|
+
LENS = {"black": "18171f", "smoke": "40434f", "blue": "2f7bff", "red": "ff3d5e",
|
|
24
|
+
"gold": "ffb627", "green": "22d18c", "purple": "9b5cff"}
|
|
25
|
+
BG = {"lilac": "b9a3ff", "sky": "6aa8e8", "mint": "8fe3c0", "peach": "ffb38a",
|
|
26
|
+
"lemon": "ffe27a", "coral": "ff7a7a", "cream": "f3ead8", "night": "22254a",
|
|
27
|
+
"forest": "2f5d48", "ink": "15141c"}
|
|
28
|
+
|
|
29
|
+
OPTIONS = {
|
|
30
|
+
"head": ["round", "oval", "square", "long", "wide"],
|
|
31
|
+
"skin": SKIN,
|
|
32
|
+
"hair": ["short", "side", "buzz", "curly", "long", "bun", "mohawk", "spiky", "bald"],
|
|
33
|
+
"haircolor": HAIR,
|
|
34
|
+
"beard": ["none", "stubble", "short", "full", "long"],
|
|
35
|
+
"hat": ["none", "cap", "beanie", "bandana", "headphones"],
|
|
36
|
+
"hatcolor": FABRIC,
|
|
37
|
+
"eyes": ["shades", "round", "visor", "band", "goggles", "vr"],
|
|
38
|
+
"lens": LENS,
|
|
39
|
+
"top": ["hoodie", "tee", "jacket", "turtleneck"],
|
|
40
|
+
"topcolor": FABRIC,
|
|
41
|
+
"extra": ["none", "earring", "chain"],
|
|
42
|
+
"bg": BG,
|
|
43
|
+
"pattern": ["plain", "gradient", "dots", "stripes", "stars", "grid"],
|
|
44
|
+
"detail": ["rich", "shaded", "flat"],
|
|
45
|
+
"shape": ["rounded", "circle", "square"],
|
|
46
|
+
"size": ["400px", "1200px", "200px"],
|
|
47
|
+
}
|
|
48
|
+
LABELS = {"haircolor": "hair color", "hatcolor": "hat color", "topcolor": "top color", "bg": "background"}
|
|
49
|
+
DEFAULT = {"head": "round", "skin": "sand", "hair": "side", "haircolor": "brown", "beard": "short",
|
|
50
|
+
"hat": "none", "hatcolor": "red", "eyes": "shades", "lens": "blue", "top": "hoodie",
|
|
51
|
+
"topcolor": "orange", "extra": "none", "bg": "lilac", "pattern": "plain",
|
|
52
|
+
"detail": "rich", "shape": "rounded", "size": "400px"}
|
|
53
|
+
KEEP_ON_SHUFFLE = ("detail", "shape", "size")
|
|
54
|
+
|
|
55
|
+
# (top row, half-width of each row)
|
|
56
|
+
HEADS = {"round": (9, [4, 6, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 7, 7, 6, 5, 4]),
|
|
57
|
+
"oval": (9, [3, 5, 6, 7, 7, 8, 8, 8, 8, 8, 8, 8, 7, 7, 7, 6, 6, 5, 4, 3]),
|
|
58
|
+
"square": (9, [6, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 7, 7, 6]),
|
|
59
|
+
"long": (8, [4, 5, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 6, 5, 4, 3]),
|
|
60
|
+
"wide": (10, [5, 7, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8, 8, 7, 6, 5])}
|
|
61
|
+
BODY = [9, 12, 13, 14, 14, 15, 15, 15, 15, 15] # rows 30..39
|
|
62
|
+
|
|
63
|
+
# Parts whose edges draw an inner outline on what lies beneath them.
|
|
64
|
+
CASTERS = {"head", "hair", "hat", "top", "eyes"}
|
|
65
|
+
STARS = [(4, 5), (33, 4), (36, 15), (3, 22), (35, 27), (8, 13), (29, 9), (5, 34), (13, 3)]
|
|
66
|
+
BAYER = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── colors ───────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def rgb(h):
|
|
72
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mix(a, b, t):
|
|
76
|
+
return "".join(f"{round(x + (y - x) * t):02x}" for x, y in zip(rgb(a), rgb(b)))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def luma(h):
|
|
80
|
+
r, g, b = rgb(h)
|
|
81
|
+
return 0.299 * r + 0.587 * g + 0.114 * b
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tone(base, suffix):
|
|
85
|
+
"""'-' shadow (hue-shifted to violet), '--' deep shadow, '+' warm highlight."""
|
|
86
|
+
return {"": base, "-": mix(base, "2b1f4f", .32), "--": mix(base, "2b1f4f", .5),
|
|
87
|
+
"+": mix(base, "fff6dc", .35)}[suffix]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def palette(o):
|
|
91
|
+
p = {"skin": SKIN[o["skin"]], "hair": HAIR[o["haircolor"]], "top": FABRIC[o["topcolor"]],
|
|
92
|
+
"hat": FABRIC[o["hatcolor"]], "lens": LENS[o["lens"]], "frame": "1d1b26",
|
|
93
|
+
"white": "f1f0f5", "gold": "f6c445", "vr": "d8dae3", "line": "1b1724", "bg": BG[o["bg"]]}
|
|
94
|
+
p["glint"] = mix(p["lens"], "ffffff", .75)
|
|
95
|
+
p["stubble"] = mix(p["skin"], p["hair"], .45)
|
|
96
|
+
p["bg2"] = mix(p["bg"], "ffffff", .18) if luma(p["bg"]) < 110 else mix(p["bg"], "2b1f4f", .12)
|
|
97
|
+
return p
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── drawing ──────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def compose(o):
|
|
103
|
+
"""Paint every part into {(x, y): [key, z, part]}; higher z wins."""
|
|
104
|
+
px = {}
|
|
105
|
+
y0, hws = HEADS[o["head"]]
|
|
106
|
+
chin, W0, W = y0 + len(hws) - 1, hws[0], max(hws)
|
|
107
|
+
ey = y0 + 8 # eye line, always covered
|
|
108
|
+
hw = lambda y: hws[y - y0] if y0 <= y <= chin else 0
|
|
109
|
+
|
|
110
|
+
def put(x, y, key, z, part):
|
|
111
|
+
if 0 <= x < N and 0 <= y < N and z >= px.get((x, y), ("", -1, ""))[1]:
|
|
112
|
+
px[x, y] = [key, z, part]
|
|
113
|
+
|
|
114
|
+
def band(y, h, key, z, part):
|
|
115
|
+
for x in range(C - h, C + h):
|
|
116
|
+
put(x, y, key, z, part)
|
|
117
|
+
|
|
118
|
+
def disc(cx, cy, r, key, z, part, ymax=N):
|
|
119
|
+
for y in range(max(0, int(cy - r) - 1), min(ymax, int(cy + r) + 2)):
|
|
120
|
+
for x in range(int(cx - r) - 1, int(cx + r) + 2):
|
|
121
|
+
if (x + .5 - cx) ** 2 + (y + .5 - cy) ** 2 <= r * r:
|
|
122
|
+
put(x, y, key, z, part)
|
|
123
|
+
|
|
124
|
+
def mirror(x, y, key, z, part): # x measured from the left edge of the canvas
|
|
125
|
+
put(x, y, key, z, part)
|
|
126
|
+
put(2 * C - 1 - x, y, key, z, part)
|
|
127
|
+
|
|
128
|
+
# body, neck, head
|
|
129
|
+
for i, h in enumerate(BODY):
|
|
130
|
+
band(30 + i, h, "top", 3, "top")
|
|
131
|
+
for y in range(chin - 2, 32):
|
|
132
|
+
band(y, 3, "skin", 2, "neck")
|
|
133
|
+
for i, h in enumerate(hws):
|
|
134
|
+
band(y0 + i, h, "skin", 4, "head")
|
|
135
|
+
for y in range(ey + 1, ey + 4):
|
|
136
|
+
mirror(C - hw(y) - 1, y, "skin", 4, "head")
|
|
137
|
+
|
|
138
|
+
# top
|
|
139
|
+
top = o["top"]
|
|
140
|
+
if top in ("tee", "hoodie"):
|
|
141
|
+
band(30, 3, "skin", 3.5, "neck")
|
|
142
|
+
band(31, 2, "skin", 3.5, "neck")
|
|
143
|
+
if top == "hoodie":
|
|
144
|
+
for y, h in ((chin - 1, 6), (chin, 7), (chin + 1, 7), (29, 7)):
|
|
145
|
+
band(y, h, "top", 1.5, "top")
|
|
146
|
+
for y in range(31, 35):
|
|
147
|
+
mirror(C - 2, y, "white", 3.2, "top")
|
|
148
|
+
if top == "jacket":
|
|
149
|
+
for i, h in enumerate((4, 3, 3, 2, 2, 1)):
|
|
150
|
+
band(30 + i, h, "white", 3.2, "shirt")
|
|
151
|
+
for y in range(36, N):
|
|
152
|
+
put(C - 1, y, "top--", 3.2, "top")
|
|
153
|
+
if top == "turtleneck":
|
|
154
|
+
for y in range(chin - 1, 32):
|
|
155
|
+
band(y, 4, "top", 3.6, "top")
|
|
156
|
+
|
|
157
|
+
# beard
|
|
158
|
+
beard, B = o["beard"], ("hair", 5, "beard")
|
|
159
|
+
if beard in ("short", "full", "long"):
|
|
160
|
+
for y in range(ey + 3, ey + 6):
|
|
161
|
+
h = hw(y)
|
|
162
|
+
for x in (C - h, C - h + 1, C + h - 2, C + h - 1):
|
|
163
|
+
put(x, y, *B)
|
|
164
|
+
for y in range(ey + 6, chin + 1):
|
|
165
|
+
band(y, hw(y), *B)
|
|
166
|
+
tail = {"short": [hw(chin) - 2], "full": [hw(chin) - 1, hw(chin) - 3],
|
|
167
|
+
"long": [hw(chin) - 1, hw(chin) - 2, 3, 3, 2, 2, 1]}[beard]
|
|
168
|
+
for i, h in enumerate(tail):
|
|
169
|
+
band(chin + 1 + i, max(h, 1), *B)
|
|
170
|
+
if beard == "stubble":
|
|
171
|
+
for y in range(ey + 5, chin + 1):
|
|
172
|
+
for x in range(C - hw(y), C + hw(y)):
|
|
173
|
+
if (x + y) % 2 == 0:
|
|
174
|
+
put(x, y, "stubble", 5, "beard")
|
|
175
|
+
|
|
176
|
+
# hair
|
|
177
|
+
hair, H, Hb = o["hair"], ("hair", 6, "hair"), ("hair", 1, "back")
|
|
178
|
+
|
|
179
|
+
def scalp(extra=1):
|
|
180
|
+
band(y0 - 2, W0 - 1, *H)
|
|
181
|
+
band(y0 - 1, W0 + 1, *H)
|
|
182
|
+
for y in range(y0, y0 + 4):
|
|
183
|
+
band(y, hw(y) + extra, *H)
|
|
184
|
+
|
|
185
|
+
def sideburns():
|
|
186
|
+
for y in range(y0 + 4, ey + 2):
|
|
187
|
+
h = hw(y)
|
|
188
|
+
for x in (C - h - 1, C - h, C + h - 1, C + h):
|
|
189
|
+
put(x, y, *H)
|
|
190
|
+
|
|
191
|
+
if hair in ("short", "side", "spiky", "bun", "long"):
|
|
192
|
+
scalp()
|
|
193
|
+
sideburns()
|
|
194
|
+
if hair in ("short", "spiky", "bun"):
|
|
195
|
+
for x in range(C - hw(y0 + 4), C + hw(y0 + 4)):
|
|
196
|
+
if x % 3 != 1:
|
|
197
|
+
put(x, y0 + 4, *H)
|
|
198
|
+
if hair == "side":
|
|
199
|
+
for y, (a, b) in ((y0 + 4, (-9, 5)), (y0 + 5, (-9, 1)), (y0 + 6, (-9, -4))):
|
|
200
|
+
for x in range(C + a, C + b):
|
|
201
|
+
put(x, y, *H)
|
|
202
|
+
if hair == "buzz":
|
|
203
|
+
band(y0 - 1, W0 - 1, *H)
|
|
204
|
+
for y in range(y0, y0 + 3):
|
|
205
|
+
band(y, hw(y), *H)
|
|
206
|
+
if hair == "curly":
|
|
207
|
+
disc(C, y0 + 6, W + 4, *Hb, ymax=chin - 3)
|
|
208
|
+
band(y0 - 3, W0, *H)
|
|
209
|
+
scalp(extra=2)
|
|
210
|
+
for x in range(C - hw(y0 + 4), C + hw(y0 + 4)):
|
|
211
|
+
if x % 2 == 0:
|
|
212
|
+
put(x, y0 + 4, *H)
|
|
213
|
+
if hair == "long":
|
|
214
|
+
band(y0 - 1, W0 + 2, *Hb)
|
|
215
|
+
for y in range(y0, y0 + 22):
|
|
216
|
+
band(y, max(hw(y), W - 1) + 2, *Hb)
|
|
217
|
+
for y in range(y0 + 4, y0 + 20):
|
|
218
|
+
h = hw(y) or W - 1
|
|
219
|
+
for x in (C - h - 2, C - h - 1, C - h, C + h - 1, C + h, C + h + 1):
|
|
220
|
+
put(x, y, *H)
|
|
221
|
+
if hair == "bun":
|
|
222
|
+
disc(C, y0 - 3, 3.3, *H)
|
|
223
|
+
if hair == "mohawk":
|
|
224
|
+
for y in range(y0 - 5, y0 + 5):
|
|
225
|
+
band(y, 1 if y == y0 - 5 else 2, *H)
|
|
226
|
+
if hair == "spiky":
|
|
227
|
+
for sx, h in ((C - 8, 3), (C - 5, 4), (C - 1, 5), (C + 3, 4), (C + 6, 3)):
|
|
228
|
+
for j in range(h):
|
|
229
|
+
r = (h - 1 - j) // 2
|
|
230
|
+
for x in range(sx - r, sx + r + 2):
|
|
231
|
+
put(x, y0 - 2 - j, *H)
|
|
232
|
+
|
|
233
|
+
# hat
|
|
234
|
+
hat, T = o["hat"], ("hat", 7, "hat")
|
|
235
|
+
if hat in ("cap", "beanie"):
|
|
236
|
+
for k in [k for k, v in px.items() if v[2] == "hair" and k[1] < y0 + 2]:
|
|
237
|
+
del px[k]
|
|
238
|
+
if hat == "cap":
|
|
239
|
+
band(y0 - 3, W0 - 1, *T)
|
|
240
|
+
band(y0 - 2, W0 + 1, *T)
|
|
241
|
+
band(y0 - 1, W0 + 2, *T)
|
|
242
|
+
for y in range(y0, y0 + 4):
|
|
243
|
+
band(y, hw(y) + 1, *T)
|
|
244
|
+
band(y0 + 4, W + 2, *T)
|
|
245
|
+
band(y0 + 5, W, "hat-", 7, "hat")
|
|
246
|
+
if hat == "beanie":
|
|
247
|
+
disc(C, y0 - 5, 2.3, "hat+", 7, "hat")
|
|
248
|
+
band(y0 - 4, W - 3, *T)
|
|
249
|
+
band(y0 - 3, W - 1, *T)
|
|
250
|
+
band(y0 - 2, W, *T)
|
|
251
|
+
for y in range(y0 - 1, y0 + 6):
|
|
252
|
+
h = W + 1
|
|
253
|
+
for x in range(C - h, C + h):
|
|
254
|
+
put(x, y, "hat-" if y >= y0 + 3 and x % 2 == 0 else "hat", 7, "hat")
|
|
255
|
+
if hat == "bandana":
|
|
256
|
+
for y in range(y0 + 1, y0 + 4):
|
|
257
|
+
for x in range(C - hw(y) - 1, C + hw(y) + 1):
|
|
258
|
+
put(x, y, "hat+" if y == y0 + 2 and x % 3 == 0 else "hat", 7, "hat")
|
|
259
|
+
h = hw(y0 + 3)
|
|
260
|
+
for dx, dy in ((1, 3), (2, 3), (2, 4), (3, 5), (1, 4), (1, 5), (2, 6)):
|
|
261
|
+
put(C + h + dx, y0 + dy, *T)
|
|
262
|
+
if hat == "headphones":
|
|
263
|
+
r = W + 2.5
|
|
264
|
+
for y in range(y0 - 4, ey + 1):
|
|
265
|
+
for x in range(N):
|
|
266
|
+
d = ((x + .5 - C) ** 2 + (y + .5 - ey - 1) ** 2) ** .5
|
|
267
|
+
if r - 1.4 < d <= r:
|
|
268
|
+
put(x, y, *T)
|
|
269
|
+
h = hw(ey)
|
|
270
|
+
for y in range(ey - 1, ey + 5):
|
|
271
|
+
for i in range(3):
|
|
272
|
+
mirror(C - h - 3 + i, y, "hat-" if i == 2 else "hat", 9.5, "hat")
|
|
273
|
+
|
|
274
|
+
# eyes: the slot is always covered
|
|
275
|
+
eyes, h = o["eyes"], hw(ey)
|
|
276
|
+
E = ("frame", 8, "eyes")
|
|
277
|
+
if eyes == "shades":
|
|
278
|
+
band(ey, h + 1, *E)
|
|
279
|
+
for x in range(C - h + 1, C - 1):
|
|
280
|
+
mirror(x, ey + 1, "lens", 8, "eyes")
|
|
281
|
+
for x in range(C - h + 2, C - 2):
|
|
282
|
+
mirror(x, ey + 2, "lens", 8, "eyes")
|
|
283
|
+
put(C - h + 1, ey + 1, "glint", 8, "eyes")
|
|
284
|
+
put(C - h + 2, ey + 1, "glint", 8, "eyes")
|
|
285
|
+
if eyes == "round":
|
|
286
|
+
for cx in (C - 4, C + 4):
|
|
287
|
+
disc(cx, ey + 1.5, 2.7, "lens", 8, "eyes")
|
|
288
|
+
put(C - 6, ey + 1, "glint", 8, "eyes")
|
|
289
|
+
put(C - 5, ey, "glint", 8, "eyes")
|
|
290
|
+
for x in range(C - h - 1, C - 6):
|
|
291
|
+
mirror(x, ey + 1, *E)
|
|
292
|
+
mirror(C - 1, ey + 1, *E)
|
|
293
|
+
if eyes == "visor":
|
|
294
|
+
for y in range(ey, ey + 3):
|
|
295
|
+
band(y, h + 1, "lens", 8, "eyes")
|
|
296
|
+
for x in range(C - h + 1, C - h + 5):
|
|
297
|
+
put(x, ey + 1, "glint", 8, "eyes")
|
|
298
|
+
if eyes == "band":
|
|
299
|
+
for y in range(ey, ey + 3):
|
|
300
|
+
band(y, h + 1, "lens", 8, "eyes")
|
|
301
|
+
for dx, dy in ((1, 2), (2, 3), (1, 4), (3, 4), (2, 5)):
|
|
302
|
+
put(C + h + dx, ey + dy, "lens", 8, "eyes")
|
|
303
|
+
if eyes == "goggles":
|
|
304
|
+
band(ey + 1, h + 1, *E)
|
|
305
|
+
for cx in (C - 4, C + 4):
|
|
306
|
+
disc(cx, ey + 1.5, 3.2, "lens", 8, "eyes")
|
|
307
|
+
put(C - 6, ey + 1, "glint", 8, "eyes")
|
|
308
|
+
put(C - 5, ey, "glint", 8, "eyes")
|
|
309
|
+
if eyes == "vr":
|
|
310
|
+
for y in range(ey - 1, ey + 4):
|
|
311
|
+
band(y, h + 1, "vr", 8, "eyes")
|
|
312
|
+
for y in range(ey, ey + 3):
|
|
313
|
+
band(y, h - 1, "lens", 8, "eyes")
|
|
314
|
+
put(C + h - 3, ey + 1, "glint", 8, "eyes")
|
|
315
|
+
|
|
316
|
+
# extras
|
|
317
|
+
if o["extra"] == "earring":
|
|
318
|
+
put(C - hw(ey + 3) - 1, ey + 4, "gold", 9, "extra")
|
|
319
|
+
if o["extra"] == "chain":
|
|
320
|
+
for x, y in ((C - 4, 30), (C - 3, 31), (C - 2, 32), (C - 1, 32)):
|
|
321
|
+
mirror(x, y, "gold", 9, "extra")
|
|
322
|
+
band(33, 1, "gold", 9, "extra")
|
|
323
|
+
return px
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def light(px, o):
|
|
327
|
+
"""Cel shading: right side in shadow, cast shadow under hair, highlights at 'rich'."""
|
|
328
|
+
if o["detail"] == "flat":
|
|
329
|
+
return
|
|
330
|
+
rich = o["detail"] == "rich"
|
|
331
|
+
boxes = {}
|
|
332
|
+
for (x, y), (_, _, part) in px.items():
|
|
333
|
+
b = boxes.setdefault(part, [x, y, x, y])
|
|
334
|
+
b[:] = min(b[0], x), min(b[1], y), max(b[2], x), max(b[3], y)
|
|
335
|
+
for (x, y), v in px.items():
|
|
336
|
+
key, _, part = v
|
|
337
|
+
if key not in ("skin", "hair", "top", "hat"):
|
|
338
|
+
continue
|
|
339
|
+
x0, y0, x1, y1 = boxes[part]
|
|
340
|
+
cut = x0 + .62 * (x1 - x0 + 1)
|
|
341
|
+
above = px.get((x, y - 1), ("", 0, ""))[2]
|
|
342
|
+
if part == "neck" or (part == "head" and above in ("hair", "hat")):
|
|
343
|
+
v[0] = key + "-"
|
|
344
|
+
elif x >= cut or (rich and part in ("hair", "back", "hat") and x == int(cut) - 1 and (x + y) % 2 == 0):
|
|
345
|
+
v[0] = key + "-"
|
|
346
|
+
elif rich and part in ("hair", "hat", "back") and x < x0 + .3 * (x1 - x0) and y < y0 + .35 * (y1 - y0):
|
|
347
|
+
v[0] = key + "+"
|
|
348
|
+
elif rich and o["hair"] == "curly" and key == "hair" and (3 * x + 5 * y) % 7 == 0:
|
|
349
|
+
v[0] = key + "-"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def background(x, y, o, t=0):
|
|
353
|
+
pat = o["pattern"]
|
|
354
|
+
if pat == "stripes":
|
|
355
|
+
x -= t // 3
|
|
356
|
+
if pat == "dots":
|
|
357
|
+
x, y = x + t // 6, y + t // 6
|
|
358
|
+
if pat == "grid":
|
|
359
|
+
x, y = x - t // 4, y - t // 4
|
|
360
|
+
alt = {"plain": False, "gradient": BAYER[y % 4][x % 4] < y / N * 18 - 1,
|
|
361
|
+
"dots": x % 4 == 1 and y % 4 == 1, "stripes": (x + y) % 8 < 2,
|
|
362
|
+
"grid": x % 6 == 0 or y % 6 == 0,
|
|
363
|
+
"stars": any((x, y) in ((sx, sy), (sx + 1, sy), (sx - 1, sy), (sx, sy + 1), (sx, sy - 1))
|
|
364
|
+
and (sx + sy + t // 6) % 2 == 0 or (x, y) == (sx, sy) for sx, sy in STARS)}[pat]
|
|
365
|
+
return "bg2" if alt else "bg"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def inside(x, y, shape, n=N):
|
|
369
|
+
if shape == "circle":
|
|
370
|
+
return (x + .5 - n / 2) ** 2 + (y + .5 - n / 2) ** 2 <= (n / 2) ** 2
|
|
371
|
+
if shape == "rounded":
|
|
372
|
+
r = n / 8
|
|
373
|
+
cx, cy = min(max(x + .5, r), n - r), min(max(y + .5, r), n - r)
|
|
374
|
+
return (x + .5 - cx) ** 2 + (y + .5 - cy) ** 2 <= r * r
|
|
375
|
+
return True
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def render(o, t=0):
|
|
379
|
+
"""Options -> N x N grid of hex colors (None = transparent). t is the animation frame."""
|
|
380
|
+
px, pal = compose(o), palette(o)
|
|
381
|
+
light(px, o)
|
|
382
|
+
lens = [k for k, v in px.items() if v[0] == "lens"]
|
|
383
|
+
if t and lens and o["eyes"] != "band": # a light streak sweeps across the lenses
|
|
384
|
+
lo, hi = min(x + y for x, y in lens), max(x + y for x, y in lens)
|
|
385
|
+
p = lo - 3 + t * (hi - lo + 6) / 10
|
|
386
|
+
for x, y in lens:
|
|
387
|
+
if p <= x + y < p + 2:
|
|
388
|
+
px[x, y][0] = "glint"
|
|
389
|
+
color = lambda key: tone(pal[key.rstrip("+-")], key[len(key.rstrip("+-")):])
|
|
390
|
+
grid = [[None] * N for _ in range(N)]
|
|
391
|
+
for y in range(N):
|
|
392
|
+
for x in range(N):
|
|
393
|
+
if not inside(x, y, o["shape"]):
|
|
394
|
+
continue
|
|
395
|
+
key, z, part = px.get((x, y), (background(x, y, o, t), 0, "bg"))
|
|
396
|
+
caster = None
|
|
397
|
+
for dx, dy in ((0, -1), (-1, 0), (1, 0), (0, 1)):
|
|
398
|
+
q = px.get((x + dx, y + dy))
|
|
399
|
+
if q and q[1] > z and (part == "bg" or q[2] in CASTERS and (q[2] != "eyes" or dy <= 0)):
|
|
400
|
+
caster = q
|
|
401
|
+
break
|
|
402
|
+
if caster is None:
|
|
403
|
+
grid[y][x] = color(key)
|
|
404
|
+
elif o["detail"] == "rich":
|
|
405
|
+
grid[y][x] = mix(color(caster[0]), "120e1c", .7) # colored outline
|
|
406
|
+
else:
|
|
407
|
+
grid[y][x] = pal["line"]
|
|
408
|
+
return grid
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def png(grid, scale):
|
|
412
|
+
"""Grid of hex/None -> RGBA PNG bytes."""
|
|
413
|
+
lines = []
|
|
414
|
+
for row in grid:
|
|
415
|
+
line = b"".join((bytes.fromhex(c) + b"\xff" if c else b"\0\0\0\0") * scale for c in row)
|
|
416
|
+
lines += [line] * scale
|
|
417
|
+
w, h = len(grid[0]) * scale, len(lines)
|
|
418
|
+
chunk = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
|
|
419
|
+
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
|
|
420
|
+
+ chunk(b"IDAT", zlib.compress(b"".join(b"\0" + l for l in lines), 9)) + chunk(b"IEND", b""))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def lzw(data, min_size):
|
|
424
|
+
"""GIF flavored LZW: variable code size, table reset at 4096 codes."""
|
|
425
|
+
clear = 1 << min_size
|
|
426
|
+
out, buf, nbits = bytearray(), 0, 0
|
|
427
|
+
|
|
428
|
+
def emit(code):
|
|
429
|
+
nonlocal buf, nbits
|
|
430
|
+
buf |= code << nbits
|
|
431
|
+
nbits += size
|
|
432
|
+
while nbits >= 8:
|
|
433
|
+
out.append(buf & 255)
|
|
434
|
+
buf >>= 8
|
|
435
|
+
nbits -= 8
|
|
436
|
+
|
|
437
|
+
size, nxt, table = min_size + 1, clear + 2, {}
|
|
438
|
+
emit(clear)
|
|
439
|
+
w = data[0]
|
|
440
|
+
for b in data[1:]:
|
|
441
|
+
if (w, b) in table:
|
|
442
|
+
w = table[w, b]
|
|
443
|
+
continue
|
|
444
|
+
emit(w)
|
|
445
|
+
if nxt < 4096:
|
|
446
|
+
table[w, b] = nxt
|
|
447
|
+
nxt += 1
|
|
448
|
+
if nxt > 1 << size:
|
|
449
|
+
size += 1
|
|
450
|
+
else:
|
|
451
|
+
emit(clear)
|
|
452
|
+
size, nxt, table = min_size + 1, clear + 2, {}
|
|
453
|
+
w = b
|
|
454
|
+
emit(w)
|
|
455
|
+
emit(clear + 1)
|
|
456
|
+
if nbits:
|
|
457
|
+
out.append(buf & 255)
|
|
458
|
+
return bytes([min_size]) + b"".join(bytes([len(out[i:i + 255])]) + out[i:i + 255]
|
|
459
|
+
for i in range(0, len(out), 255)) + b"\0"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def gif(grids, scale, delay=8):
|
|
463
|
+
"""Grids of hex/None -> looping animated GIF bytes. delay is in 1/100 s per grid."""
|
|
464
|
+
runs = [] # merge identical consecutive frames into one longer frame
|
|
465
|
+
for g in grids:
|
|
466
|
+
if runs and runs[-1][0] == g:
|
|
467
|
+
runs[-1][1] += delay
|
|
468
|
+
else:
|
|
469
|
+
runs.append([g, delay])
|
|
470
|
+
colors = sorted({c for g, _ in runs for row in g for c in row if c})
|
|
471
|
+
index = {c: i + 1 for i, c in enumerate(colors)} # 0 is transparent
|
|
472
|
+
bits = max(2, len(colors).bit_length())
|
|
473
|
+
table = b"\0\0\0" + b"".join(bytes.fromhex(c) for c in colors) + b"\0\0\0" * ((1 << bits) - len(colors) - 1)
|
|
474
|
+
w, h = len(grids[0][0]) * scale, len(grids[0]) * scale
|
|
475
|
+
out = b"GIF89a" + struct.pack("<HHBBB", w, h, 0xF0 | (bits - 1), 0, 0) + table
|
|
476
|
+
out += b"\x21\xff\x0bNETSCAPE2.0\x03\x01\0\0\0"
|
|
477
|
+
for g, d in runs:
|
|
478
|
+
pixels = b"".join(bytes(index.get(c, 0) for c in row for _ in range(scale)) * scale for row in g)
|
|
479
|
+
out += b"\x21\xf9\x04" + struct.pack("<BHBB", 0x09, d, 0, 0)
|
|
480
|
+
out += b"\x2c" + struct.pack("<HHHHB", 0, 0, w, h, 0) + lzw(pixels, bits)
|
|
481
|
+
return out + b"\x3b"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def animate(o):
|
|
485
|
+
return gif([render(o, t) for t in range(FRAMES)], min(int(o["size"][:-2]), 400) // N) # ponytail: pure-Python LZW, GIF capped at 400px
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ── avatar codes: every look fits in a short shareable code ──────────────────
|
|
489
|
+
|
|
490
|
+
DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def encode(o):
|
|
494
|
+
n = 0
|
|
495
|
+
for k, v in OPTIONS.items():
|
|
496
|
+
n = n * len(v) + list(v).index(o[k])
|
|
497
|
+
s = ""
|
|
498
|
+
while n:
|
|
499
|
+
n, r = divmod(n, 36)
|
|
500
|
+
s = DIGITS[r] + s
|
|
501
|
+
return s or "0"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def decode(code):
|
|
505
|
+
n, o = int(code, 36), {}
|
|
506
|
+
for k, v in reversed(OPTIONS.items()):
|
|
507
|
+
n, r = divmod(n, len(v))
|
|
508
|
+
o[k] = list(v)[r]
|
|
509
|
+
if n:
|
|
510
|
+
raise ValueError(code)
|
|
511
|
+
return o
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def save(o, animated=False):
|
|
515
|
+
name = f"shade-{encode(o)}.{'gif' if animated else 'png'}"
|
|
516
|
+
with open(name, "wb") as f:
|
|
517
|
+
f.write(animate(o) if animated else png(render(o), int(o["size"][:-2]) // N))
|
|
518
|
+
return name
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def shuffle(o):
|
|
522
|
+
return {k: o[k] if k in KEEP_ON_SHUFFLE else random.choice(list(v)) for k, v in OPTIONS.items()}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ── terminal ─────────────────────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
R = "\x1b[0m"
|
|
528
|
+
fg = lambda h: "\x1b[38;2;%d;%d;%dm" % rgb(h)
|
|
529
|
+
bgc = lambda h: "\x1b[48;2;%d;%d;%dm" % rgb(h)
|
|
530
|
+
DIM, ACCENT, OK = fg("6f6a85"), fg("c4a8ff"), fg("5ee6a0")
|
|
531
|
+
GRADIENT = ["ff6ec7", "b388ff", "6ec8ff"]
|
|
532
|
+
|
|
533
|
+
FONT = {"S": [".####", "#....", ".###.", "....#", "####."],
|
|
534
|
+
"H": ["#...#", "#...#", "#####", "#...#", "#...#"],
|
|
535
|
+
"A": [".###.", "#...#", "#####", "#...#", "#...#"],
|
|
536
|
+
"D": ["####.", "#...#", "#...#", "#...#", "####."],
|
|
537
|
+
"E": ["#####", "#....", "####.", "#....", "#####"]}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def ramp(t):
|
|
541
|
+
t = min(max(t, 0), 1) * (len(GRADIENT) - 1)
|
|
542
|
+
i = min(int(t), len(GRADIENT) - 2)
|
|
543
|
+
return mix(GRADIENT[i], GRADIENT[i + 1], t - i)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def wordmark_grid(word="SHADE", scale=1, shadow="2a2140"):
|
|
547
|
+
w = len(word) * 6 * scale
|
|
548
|
+
grid = [[None] * (w + 1) for _ in range(5 * scale + 1)]
|
|
549
|
+
for i, ch in enumerate(word):
|
|
550
|
+
for y, row in enumerate(FONT[ch]):
|
|
551
|
+
for x, c in enumerate(row):
|
|
552
|
+
if c != "#":
|
|
553
|
+
continue
|
|
554
|
+
for sy in range(scale):
|
|
555
|
+
for sx in range(scale):
|
|
556
|
+
X, Y = (i * 6 + x) * scale + sx, y * scale + sy
|
|
557
|
+
grid[Y + 1][X + 1] = grid[Y + 1][X + 1] or shadow
|
|
558
|
+
grid[Y][X] = ramp(X / w)
|
|
559
|
+
return grid
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def halfblocks(grid, xs=1):
|
|
563
|
+
out = []
|
|
564
|
+
for y in range(0, len(grid), 2):
|
|
565
|
+
top, bot = grid[y], grid[y + 1] if y + 1 < len(grid) else [None] * len(grid[y])
|
|
566
|
+
s = ""
|
|
567
|
+
for t, b in zip(top, bot):
|
|
568
|
+
if t and b:
|
|
569
|
+
s += fg(t) + bgc(b) + "▀" * xs + R
|
|
570
|
+
elif t or b:
|
|
571
|
+
s += fg(t or b) + ("▀" if t else "▄") * xs + R
|
|
572
|
+
else:
|
|
573
|
+
s += " " * xs
|
|
574
|
+
out.append(s)
|
|
575
|
+
return out
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def card(o):
|
|
579
|
+
edge = fg("3d3852")
|
|
580
|
+
code = f" @{encode(o)} "
|
|
581
|
+
rows = [edge + "╭" + "─" * N + "╮" + R]
|
|
582
|
+
rows += [edge + "│" + R + line + edge + "│" + R for line in halfblocks(render(o))]
|
|
583
|
+
rows.append(edge + "╰" + "─" * (N - len(code) - 2) + R + DIM + code + edge + "──╯" + R)
|
|
584
|
+
return rows
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def visible(s):
|
|
588
|
+
return len(re.sub(r"\x1b\[[0-9;?]*[a-zA-Z]", "", s))
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def frame(lines):
|
|
592
|
+
sys.stdout.write("\x1b[H" + "\x1b[K\n".join(lines) + "\x1b[K\x1b[J")
|
|
593
|
+
sys.stdout.flush()
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
KEYS = {"\x1b[A": "up", "\xe0H": "up", "k": "up", "\x1b[B": "down", "\xe0P": "down", "j": "down",
|
|
597
|
+
"\x1b[C": "right", "\xe0M": "right", "l": "right", "\x1b[D": "left", "\xe0K": "left", "h": "left",
|
|
598
|
+
"\r": "enter", "\n": "enter", " ": "space", "\x1b": "back", "q": "quit", "\x03": "quit"}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def key(timeout=None):
|
|
602
|
+
"""Wait for one keypress and name it. Returns None when timeout runs out."""
|
|
603
|
+
if os.name == "nt":
|
|
604
|
+
import msvcrt
|
|
605
|
+
end = None if timeout is None else time.monotonic() + timeout
|
|
606
|
+
while not msvcrt.kbhit():
|
|
607
|
+
if end is not None and time.monotonic() >= end:
|
|
608
|
+
return None
|
|
609
|
+
time.sleep(0.01)
|
|
610
|
+
k = msvcrt.getwch()
|
|
611
|
+
if k in "\x00\xe0": # arrows arrive as a prefix plus a letter
|
|
612
|
+
k = "\xe0" + msvcrt.getwch()
|
|
613
|
+
return KEYS.get(k, k)
|
|
614
|
+
import select, termios, tty
|
|
615
|
+
fd = sys.stdin.fileno()
|
|
616
|
+
old = termios.tcgetattr(fd)
|
|
617
|
+
try:
|
|
618
|
+
tty.setraw(fd)
|
|
619
|
+
if not select.select([fd], [], [], timeout)[0]:
|
|
620
|
+
return None
|
|
621
|
+
k = os.read(fd, 8).decode(errors="ignore")
|
|
622
|
+
finally:
|
|
623
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
624
|
+
return KEYS.get(k, k)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def hint(*pairs):
|
|
628
|
+
return " " + " ".join(f"{fg('d9d4ee')}{k}{R} {DIM}{v}{R}" for k, v in pairs)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def value(o, k, selected):
|
|
632
|
+
v = o[k]
|
|
633
|
+
text = v
|
|
634
|
+
if isinstance(OPTIONS[k], dict):
|
|
635
|
+
text = fg(OPTIONS[k][v]) + "██" + R + " " + v
|
|
636
|
+
if k == "hatcolor" and o["hat"] == "none":
|
|
637
|
+
return f" {DIM}—{R}"
|
|
638
|
+
if selected:
|
|
639
|
+
return f"{ACCENT}◂{R} {text} {ACCENT}▸{R}"
|
|
640
|
+
return f" {text}"
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def editor(o, saved):
|
|
644
|
+
keys, sel, toast = list(OPTIONS), 0, None
|
|
645
|
+
while True:
|
|
646
|
+
cols, rows = shutil.get_terminal_size()
|
|
647
|
+
if cols < 78 or rows < 23:
|
|
648
|
+
frame(["", f" Make the window a bit bigger ({cols}x{rows}, needs 78x23).", "",
|
|
649
|
+
hint(("esc", "back"))])
|
|
650
|
+
if key() in ("back", "quit"):
|
|
651
|
+
return "back"
|
|
652
|
+
continue
|
|
653
|
+
title = "".join(f"\x1b[1m{fg(ramp(i / 4))}{c}{R} " for i, c in enumerate("SHADE"))
|
|
654
|
+
panel = [" " + title, f" {DIM}faceless pixel avatars{R}", ""]
|
|
655
|
+
for i, k in enumerate(keys):
|
|
656
|
+
label = LABELS.get(k, k)
|
|
657
|
+
if i == sel:
|
|
658
|
+
panel.append(f"{ACCENT}›{R} {fg('ffffff')}{label:<11}{R}{value(o, k, True)}")
|
|
659
|
+
else:
|
|
660
|
+
panel.append(f" {DIM}{label:<11}{R}{value(o, k, False)}")
|
|
661
|
+
left = card(o)
|
|
662
|
+
lines = [""] + [left[i] + " " + (panel[i] if i < len(panel) else "") for i in range(len(left))]
|
|
663
|
+
lines.append(toast or hint(("↑↓", "choose"), ("←→", "change"), ("space", "shuffle"), ("⏎", "png"), ("g", "gif"), ("esc", "menu")))
|
|
664
|
+
frame(lines)
|
|
665
|
+
k, toast = key(), None
|
|
666
|
+
opts = list(OPTIONS[keys[sel]])
|
|
667
|
+
if k == "up":
|
|
668
|
+
sel = (sel - 1) % len(keys)
|
|
669
|
+
elif k == "down":
|
|
670
|
+
sel = (sel + 1) % len(keys)
|
|
671
|
+
elif k in ("left", "right"):
|
|
672
|
+
o[keys[sel]] = opts[(opts.index(o[keys[sel]]) + (1 if k == "right" else -1)) % len(opts)]
|
|
673
|
+
elif k == "space":
|
|
674
|
+
o.update(shuffle(o))
|
|
675
|
+
elif k in ("enter", "g"):
|
|
676
|
+
saved.append(save(o, animated=k == "g"))
|
|
677
|
+
toast = f" {OK}✓ saved{R} {saved[-1]} {DIM}keep going or esc{R}"
|
|
678
|
+
elif k in ("back", "quit"):
|
|
679
|
+
return k
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def splash():
|
|
683
|
+
grid = wordmark_grid()
|
|
684
|
+
w = len(grid[0])
|
|
685
|
+
for f in range(1, 13):
|
|
686
|
+
t = 1 - (1 - f / 12) ** 3 # ease-out: fast start, gentle stop
|
|
687
|
+
cut = int(w * t)
|
|
688
|
+
frame(["", ""] + [" " + l for l in halfblocks([row[:cut] for row in grid], 2)])
|
|
689
|
+
time.sleep(0.016)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def menu():
|
|
693
|
+
saved, items, sel = [], ["create", "surprise me", "agent prompt", "quit"], 0
|
|
694
|
+
desc = {"create": "build your avatar, piece by piece", "surprise me": "start from a random look",
|
|
695
|
+
"agent prompt": "let an AI design one for you", "quit": ""}
|
|
696
|
+
sys.stdout.write("\x1b[?1049h\x1b[?25l")
|
|
697
|
+
try:
|
|
698
|
+
splash()
|
|
699
|
+
while True:
|
|
700
|
+
lines = ["", ""] + [" " + l for l in halfblocks(wordmark_grid(), 2)]
|
|
701
|
+
lines += ["", f" {DIM}faceless pixel avatars · no eyes, no nose, no mouth{R}", ""]
|
|
702
|
+
for i, it in enumerate(items):
|
|
703
|
+
if i == sel:
|
|
704
|
+
lines.append(f" {ACCENT}›{R} {fg('ffffff')}{it:<14}{R}{DIM}{desc[it]}{R}")
|
|
705
|
+
else:
|
|
706
|
+
lines.append(f" {DIM}{it}{R}")
|
|
707
|
+
lines += ["", hint(("↑↓", "choose"), ("⏎", "open"), ("q", "quit"))]
|
|
708
|
+
frame(lines)
|
|
709
|
+
k = key()
|
|
710
|
+
if k == "up":
|
|
711
|
+
sel = (sel - 1) % len(items)
|
|
712
|
+
elif k == "down":
|
|
713
|
+
sel = (sel + 1) % len(items)
|
|
714
|
+
elif k in ("quit", "back") or (k == "enter" and items[sel] == "quit"):
|
|
715
|
+
break
|
|
716
|
+
elif k == "enter" and items[sel] == "agent prompt":
|
|
717
|
+
sys.stdout.write("\x1b[?25h\x1b[?1049l")
|
|
718
|
+
return agent()
|
|
719
|
+
elif k == "enter":
|
|
720
|
+
o = dict(DEFAULT) if items[sel] == "create" else shuffle(DEFAULT)
|
|
721
|
+
if editor(o, saved) == "quit":
|
|
722
|
+
break
|
|
723
|
+
except KeyboardInterrupt:
|
|
724
|
+
pass
|
|
725
|
+
sys.stdout.write("\x1b[?25h\x1b[?1049l")
|
|
726
|
+
for name in saved:
|
|
727
|
+
print(f"{OK}✓{R} {name}")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# ── commands ─────────────────────────────────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
def parse(words, o):
|
|
733
|
+
for w in words:
|
|
734
|
+
w = w.lower() + ("px" if w.isdigit() else "")
|
|
735
|
+
if w.startswith("@"):
|
|
736
|
+
try:
|
|
737
|
+
o.update(decode(w[1:]))
|
|
738
|
+
except ValueError:
|
|
739
|
+
sys.exit(f"shade: '{w}' is not an avatar code")
|
|
740
|
+
continue
|
|
741
|
+
if "=" in w:
|
|
742
|
+
k, v = w.split("=", 1)
|
|
743
|
+
k = {"background": "bg", "color": "topcolor"}.get(k, k)
|
|
744
|
+
if k not in OPTIONS or v not in OPTIONS[k]:
|
|
745
|
+
sys.exit(f"shade: unknown '{w}'. Run: shade help")
|
|
746
|
+
o[k] = v
|
|
747
|
+
continue
|
|
748
|
+
hits = [k for k, v in OPTIONS.items() if w in v]
|
|
749
|
+
if not hits:
|
|
750
|
+
sys.exit(f"shade: unknown word '{w}'. Run: shade help")
|
|
751
|
+
if len(hits) > 1:
|
|
752
|
+
sys.exit(f"shade: '{w}' fits several groups, say which: " + ", ".join(f"{k}={w}" for k in hits))
|
|
753
|
+
o[hits[0]] = w
|
|
754
|
+
return o
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def show(o, name):
|
|
758
|
+
print("\n".join(" " + l for l in halfblocks(render(o))) + f"\n\n {OK}✓ saved{R} {name} {DIM}@{encode(o)}{R}")
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def draw(path, size="400px"):
|
|
762
|
+
"""Render a text grid: 'X #rrggbb' lines set colors, other lines are pixels."""
|
|
763
|
+
colors, rows = {".": None}, []
|
|
764
|
+
for line in open(path).read().splitlines():
|
|
765
|
+
parts = line.split()
|
|
766
|
+
if len(parts) == 2 and len(parts[0]) == 1:
|
|
767
|
+
colors[parts[0]] = parts[1].lstrip("#").lower()
|
|
768
|
+
elif len(parts) == 1:
|
|
769
|
+
rows.append(parts[0])
|
|
770
|
+
if not rows or len({len(r) for r in rows}) > 1:
|
|
771
|
+
sys.exit(f"shade: {path}: grid lines must all have the same length")
|
|
772
|
+
if bad := [k for k, v in colors.items() if v is not None and not re.fullmatch(r"[0-9a-f]{6}", v)]:
|
|
773
|
+
sys.exit(f"shade: {path}: bad color for {' '.join(bad)}. Use a hex color like: X #ff0000")
|
|
774
|
+
if missing := set("".join(rows)) - set(colors):
|
|
775
|
+
sys.exit(f"shade: {path}: no color for {' '.join(sorted(missing))}. Add a line like: X #ff0000")
|
|
776
|
+
grid = [[colors[c] for c in r] for r in rows]
|
|
777
|
+
out = os.path.splitext(os.path.basename(path))[0] + ".png"
|
|
778
|
+
with open(out, "wb") as f:
|
|
779
|
+
f.write(png(grid, max(1, int(size[:-2]) // max(len(rows), len(rows[0])))))
|
|
780
|
+
print("\n".join(" " + l for l in halfblocks(grid)) + f"\n\n {OK}✓ saved{R} {out}")
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def groups():
|
|
784
|
+
return "\n".join(f" {k:<10} {' '.join(v)}" for k, v in OPTIONS.items())
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
AGENT_PROMPT = f"""\
|
|
788
|
+
Design a faceless pixel avatar with `shade`, a terminal tool.
|
|
789
|
+
|
|
790
|
+
House rule: no eyes, no nose, no mouth. Eyes are always covered and faces stay blank.
|
|
791
|
+
|
|
792
|
+
Option A (best): reply with one command made of words, one per group, any order.
|
|
793
|
+
Words that exist in several groups need group=value (example: topcolor=navy).
|
|
794
|
+
{groups()}
|
|
795
|
+
Example: shade curly haircolor=pink visor lens=blue hoodie topcolor=navy bg=night stars
|
|
796
|
+
|
|
797
|
+
Option B: draw it pixel by pixel. Reply with a text file in one code block:
|
|
798
|
+
- color lines: one character, a space, a hex color, like `K #0d0d14`
|
|
799
|
+
- then the grid: one character per pixel, no spaces, every line the same length
|
|
800
|
+
- `.` is transparent. Aim for 32x32. Respect the house rule.
|
|
801
|
+
I render it with: shade draw art.txt
|
|
802
|
+
"""
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def clipboard():
|
|
806
|
+
"""Absolute path of the system clipboard tool and its encoding. Never resolved from the working folder."""
|
|
807
|
+
if os.name == "nt":
|
|
808
|
+
return os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "clip.exe"), [], "utf-16"
|
|
809
|
+
for cmd, *args in (["pbcopy"], ["wl-copy"], ["xclip", "-selection", "clipboard"]):
|
|
810
|
+
path = shutil.which(cmd)
|
|
811
|
+
if path and os.path.isabs(path):
|
|
812
|
+
return path, args, "utf-8"
|
|
813
|
+
return None, [], ""
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def agent():
|
|
817
|
+
path, args, encoding = clipboard()
|
|
818
|
+
if path and os.path.exists(path):
|
|
819
|
+
import subprocess
|
|
820
|
+
subprocess.run([path, *args], input=AGENT_PROMPT.encode(encoding))
|
|
821
|
+
print(f"{OK}✓ copied to clipboard.{R} Paste it to your AI.\n")
|
|
822
|
+
print(AGENT_PROMPT)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def windows_console():
|
|
826
|
+
"""Turn on UTF-8 output and ANSI colors, which older Windows consoles leave off."""
|
|
827
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
828
|
+
import ctypes
|
|
829
|
+
kernel = ctypes.windll.kernel32
|
|
830
|
+
handle, mode = kernel.GetStdHandle(-11), ctypes.c_uint32()
|
|
831
|
+
if kernel.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
832
|
+
kernel.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
HELP = f"""\
|
|
836
|
+
shade: faceless pixel avatars
|
|
837
|
+
|
|
838
|
+
shade open the studio
|
|
839
|
+
shade curly visor hoodie make one now, words in any order
|
|
840
|
+
shade topcolor=navy bg=night colors take group=value
|
|
841
|
+
shade random surprise me (add words to lock some)
|
|
842
|
+
shade curly visor gif animated GIF instead of a PNG
|
|
843
|
+
shade @code rebuild an avatar from its code
|
|
844
|
+
shade draw art.txt render your own grid
|
|
845
|
+
shade agent prompt that lets an AI design one
|
|
846
|
+
|
|
847
|
+
groups:
|
|
848
|
+
{groups()}
|
|
849
|
+
"""
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def main():
|
|
853
|
+
if os.name == "nt":
|
|
854
|
+
windows_console()
|
|
855
|
+
args = sys.argv[1:]
|
|
856
|
+
if not args:
|
|
857
|
+
return menu() if sys.stdin.isatty() and sys.stdout.isatty() else print(HELP)
|
|
858
|
+
cmd = args[0]
|
|
859
|
+
if cmd in ("version", "-v", "--version"):
|
|
860
|
+
print(f"shade {__version__}")
|
|
861
|
+
elif cmd in ("help", "-h", "--help"):
|
|
862
|
+
print(HELP)
|
|
863
|
+
elif cmd == "agent":
|
|
864
|
+
agent()
|
|
865
|
+
elif cmd == "draw" and len(args) > 1:
|
|
866
|
+
draw(args[1], parse(args[2:], dict(DEFAULT))["size"])
|
|
867
|
+
else:
|
|
868
|
+
animated = "gif" in args
|
|
869
|
+
args = [a for a in args if a != "gif"]
|
|
870
|
+
o = parse(args[1:], shuffle(DEFAULT)) if cmd == "random" else parse(args, dict(DEFAULT))
|
|
871
|
+
show(o, save(o, animated))
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
if __name__ == "__main__":
|
|
875
|
+
main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shadepix
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Faceless pixel avatars from your terminal. Zero dependencies.
|
|
5
|
+
Author: Bastien Cochet
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bstcoc/shade
|
|
8
|
+
Keywords: pixel-art,avatar,cli,terminal,generator
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="assets/wordmark.png" width="420" alt="shade">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<b>Faceless pixel avatars from your terminal.</b><br>
|
|
20
|
+
One Python file. Zero dependencies. Every face stays blank.<br><br>
|
|
21
|
+
<a href="https://bstcoc.github.io/shade/"><b>Try it in your browser →</b></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<p align="center">
|
|
25
|
+
<img src="assets/examples.png" alt="Avatars made with shade">
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
## Why
|
|
29
|
+
|
|
30
|
+
Most avatar makers draw a face. shade draws everything else: hair, beard, hat, clothes, background.
|
|
31
|
+
Eyes are always covered (shades, visor, goggles, a band), and there is no nose and no mouth.
|
|
32
|
+
You get an avatar that feels like you without showing a face.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uv tool install git+https://github.com/bstcoc/shade
|
|
38
|
+
# or: pipx install git+https://github.com/bstcoc/shade
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Needs Python 3.10+ and nothing else. Works on macOS, Linux and Windows. The command is `shade`.
|
|
42
|
+
|
|
43
|
+
## Use it
|
|
44
|
+
|
|
45
|
+
Run `shade` to open the studio:
|
|
46
|
+
|
|
47
|
+
<p align="center"><img src="assets/demo.gif" width="760" alt="The shade studio"></p>
|
|
48
|
+
|
|
49
|
+
- `↑` `↓` pick a setting, `←` `→` change it, and the preview updates live
|
|
50
|
+
- `space` shuffles the look and keeps your size, shape and detail level
|
|
51
|
+
- `⏎` saves a PNG and `g` saves an animated GIF, then you can keep going
|
|
52
|
+
|
|
53
|
+
You can also make an avatar without the studio:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
shade curly visor hoodie # words in any order
|
|
57
|
+
shade topcolor=navy bg=night stars # colors take group=value
|
|
58
|
+
shade random beanie # random look, with a beanie
|
|
59
|
+
shade curly visor gif # animated GIF: a light streak sweeps the lenses
|
|
60
|
+
shade @egtuw4yi # rebuild an avatar from its code
|
|
61
|
+
shade help # every group and value
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every avatar has a short code like `@egtuw4yi`. Share it and anyone can rebuild the same avatar.
|
|
65
|
+
|
|
66
|
+
### What you can change
|
|
67
|
+
|
|
68
|
+
17 settings: head shape, skin, hair, hair color, beard, hat, hat color, eyewear, lens color, top, top color, extra, background, pattern, detail level (`rich`, `shaded`, `flat`), shape (`rounded`, `circle`, `square`) and size (200, 400 or 1200 px).
|
|
69
|
+
|
|
70
|
+
## Use it from an agent
|
|
71
|
+
|
|
72
|
+
shade ships a skill for Claude Code. Install it once and ask for a pixel avatar in plain words:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
mkdir -p ~/.claude/skills/shade
|
|
76
|
+
curl -fsSL -o ~/.claude/skills/shade/SKILL.md https://raw.githubusercontent.com/bstcoc/shade/main/skills/shade/SKILL.md
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Let any AI design one
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
shade agent
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This copies a ready-made prompt to your clipboard. Paste it to any AI and it replies with a `shade` command, or with a pixel grid you render using `shade draw`.
|
|
86
|
+
|
|
87
|
+
## Draw your own
|
|
88
|
+
|
|
89
|
+
A grid is a text file. A line like `K #0d0d14` sets a color, every other line is a row of pixels, and `.` is transparent.
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
K #0d0d14
|
|
93
|
+
r #ff0000
|
|
94
|
+
.K.
|
|
95
|
+
KrK
|
|
96
|
+
.K.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
shade draw art.txt
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Releases
|
|
104
|
+
|
|
105
|
+
Every merged release PR publishes a new version to PyPI and GitHub, and the [web demo](https://bstcoc.github.io/shade/) picks it up with its notes. See [CHANGELOG.md](CHANGELOG.md).
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
shade
|