Akash S P commited on
Commit ·
17ead0c
0
Parent(s):
Hello World !
Browse filesRandom Image as a Service — a FastAPI + Gradio server that streams procedurally generated PNG gradients and emoji art on every GET /image request. Nine styles, pool-backed ~1ms responses, and a self-waking embed for Hugging Face Spaces.
- .gitignore +73 -0
- README.md +227 -0
- api.py +23 -0
- app.py +110 -0
- banner.svg +309 -0
- core/__init__.py +0 -0
- core/base.py +12 -0
- core/color_utils.py +63 -0
- core/config.py +20 -0
- core/registry.py +30 -0
- core/service.py +21 -0
- requirements.txt +5 -0
- styles/__init__.py +0 -0
- styles/duotone.py +65 -0
- styles/emoji.py +144 -0
- styles/emoji_hex.py +115 -0
- styles/freeform.py +50 -0
- styles/geometric.py +87 -0
- styles/gradient_ramp.py +58 -0
- styles/linear.py +53 -0
- styles/mesh.py +53 -0
- styles/multicolor.py +68 -0
- styles/radial.py +55 -0
- styles/shape_blur.py +71 -0
.gitignore
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
*.so
|
| 7 |
+
*.egg
|
| 8 |
+
*.egg-info/
|
| 9 |
+
dist/
|
| 10 |
+
build/
|
| 11 |
+
wheels/
|
| 12 |
+
*.whl
|
| 13 |
+
.eggs/
|
| 14 |
+
pip-wheel-metadata/
|
| 15 |
+
|
| 16 |
+
# Virtual environments
|
| 17 |
+
.venv/
|
| 18 |
+
venv/
|
| 19 |
+
env/
|
| 20 |
+
ENV/
|
| 21 |
+
.env
|
| 22 |
+
|
| 23 |
+
# Distribution / packaging
|
| 24 |
+
sdist/
|
| 25 |
+
var/
|
| 26 |
+
lib/
|
| 27 |
+
lib64/
|
| 28 |
+
|
| 29 |
+
# Unit test / coverage
|
| 30 |
+
.tox/
|
| 31 |
+
.coverage
|
| 32 |
+
.coverage.*
|
| 33 |
+
.cache
|
| 34 |
+
.pytest_cache/
|
| 35 |
+
htmlcov/
|
| 36 |
+
nosetests.xml
|
| 37 |
+
coverage.xml
|
| 38 |
+
*.cover
|
| 39 |
+
|
| 40 |
+
# IDE
|
| 41 |
+
.vscode/
|
| 42 |
+
.idea/
|
| 43 |
+
*.swp
|
| 44 |
+
*.swo
|
| 45 |
+
*~
|
| 46 |
+
|
| 47 |
+
# macOS
|
| 48 |
+
.DS_Store
|
| 49 |
+
.AppleDouble
|
| 50 |
+
.LSOverride
|
| 51 |
+
|
| 52 |
+
# Windows
|
| 53 |
+
Thumbs.db
|
| 54 |
+
ehthumbs.db
|
| 55 |
+
Desktop.ini
|
| 56 |
+
|
| 57 |
+
# Gradio
|
| 58 |
+
gradio_cached_examples/
|
| 59 |
+
flagged/
|
| 60 |
+
|
| 61 |
+
# HuggingFace Spaces
|
| 62 |
+
.space_history/
|
| 63 |
+
|
| 64 |
+
# Claude Code
|
| 65 |
+
CLAUDE.md
|
| 66 |
+
|
| 67 |
+
# Output artefacts
|
| 68 |
+
output/
|
| 69 |
+
*.png
|
| 70 |
+
*.jpg
|
| 71 |
+
*.jpeg
|
| 72 |
+
*.gif
|
| 73 |
+
*.webp
|
README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<img src="banner.svg" alt="Random Image as a Service" width="100%">
|
| 2 |
+
|
| 3 |
+
Point an `<img>` tag at it and get a gradient. Different every time, or pinned with a seed.
|
| 4 |
+
|
| 5 |
+
Nine styles: linear fades, radial blends, multi-stop color fields, freeform blobs, glassmorphism, banded ramps, low-poly geometry, and emoji — single or tiled in a hexagonal honeycomb.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## HTML
|
| 10 |
+
|
| 11 |
+
```html
|
| 12 |
+
<img src="https://aakkaasshh-random-image-as-a-service.hf.space/image" alt="">
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
That's the whole thing. Add parameters to control what you get:
|
| 16 |
+
|
| 17 |
+
> **Hugging Face free-tier Spaces go to sleep after 48 h of inactivity.** Any HTTP request wakes the Space, but HF's proxy handles the first ~30–60 s of boot itself — your `<img>` gets an HTML "waking up" page instead of a PNG. Use the self-waking embed below to handle this automatically.
|
| 18 |
+
|
| 19 |
+
```html
|
| 20 |
+
<!-- pick a size -->
|
| 21 |
+
<img src="https://aakkaasshh-random-image-as-a-service.hf.space/image?width=1200&height=630">
|
| 22 |
+
|
| 23 |
+
<!-- pick a style -->
|
| 24 |
+
<img src="https://aakkaasshh-random-image-as-a-service.hf.space/image?style=geometric">
|
| 25 |
+
|
| 26 |
+
<!-- pin it — same seed always returns the same image -->
|
| 27 |
+
<img src="https://aakkaasshh-random-image-as-a-service.hf.space/image?style=emoji&seed=42">
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
As a CSS background:
|
| 31 |
+
|
| 32 |
+
```css
|
| 33 |
+
.hero {
|
| 34 |
+
background-image: url("https://aakkaasshh-random-image-as-a-service.hf.space/image?width=1920&height=1080&style=shape_blur");
|
| 35 |
+
background-size: cover;
|
| 36 |
+
}
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
In Markdown:
|
| 40 |
+
|
| 41 |
+
```markdown
|
| 42 |
+

|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
As an OG image:
|
| 46 |
+
|
| 47 |
+
```html
|
| 48 |
+
<meta property="og:image" content="https://aakkaasshh-random-image-as-a-service.hf.space/image?width=1200&height=630&seed=7">
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### Self-waking embed
|
| 52 |
+
|
| 53 |
+
If the Space might be sleeping, use this instead of a bare `<img>`:
|
| 54 |
+
|
| 55 |
+
```html
|
| 56 |
+
<img src="https://aakkaasshh-random-image-as-a-service.hf.space/image"
|
| 57 |
+
onerror="var e=this;setTimeout(function(){e.src=e.dataset.src+'?t='+Date.now()},10000)"
|
| 58 |
+
data-src="https://aakkaasshh-random-image-as-a-service.hf.space/image"
|
| 59 |
+
alt="">
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
How it works:
|
| 63 |
+
- `onerror` fires when the browser gets HF's HTML wake-page instead of a PNG
|
| 64 |
+
- After 10 s it retries with a cache-busting `?t=` param
|
| 65 |
+
- Keeps retrying every 10 s until the Space is awake and returns a real image
|
| 66 |
+
- Once the Space is up the image loads and `onerror` never fires again
|
| 67 |
+
|
| 68 |
+
To never hit the sleep state at all, point an uptime monitor (UptimeRobot free tier) at `https://aakkaasshh-random-image-as-a-service.hf.space/image` every 5 minutes. Any hit counts as activity to HF, so the Space never goes idle.
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## API
|
| 73 |
+
|
| 74 |
+
`GET /image` returns a PNG. All parameters are optional.
|
| 75 |
+
|
| 76 |
+
| Parameter | Default | Allowed values |
|
| 77 |
+
|---|---|---|
|
| 78 |
+
| `width` | 1024 | 64 – 4096 |
|
| 79 |
+
| `height` | 768 | 64 – 4096 |
|
| 80 |
+
| `style` | random | `linear` `radial` `multicolor` `freeform` `shape_blur` `gradient_ramp` `geometric` `emoji` `emoji_hex` |
|
| 81 |
+
| `seed` | random | any integer |
|
| 82 |
+
|
| 83 |
+
Bad dimensions return HTTP 400 with a JSON error body. Everything else is a PNG byte stream.
|
| 84 |
+
|
| 85 |
+
### Speed
|
| 86 |
+
|
| 87 |
+
When hosted on Hugging Face Spaces (or run locally via `python app.py`), the Gradio server pre-generates a pool of 50 images at startup. Bare requests with no parameters are served from that pool — **~1ms turnaround**, no generation overhead:
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
GET /image → pool hit (X-RIaaS-Source: pool) ~1ms
|
| 91 |
+
GET /image?style=linear → generated (X-RIaaS-Source: live) ~50–200ms
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
The pool builds in the background; during the first ~15 seconds the server falls through to live generation automatically.
|
| 95 |
+
|
| 96 |
+
### curl
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
# save one
|
| 100 |
+
curl "https://aakkaasshh-random-image-as-a-service.hf.space/image?style=geometric&width=800&height=600" -o banner.png
|
| 101 |
+
|
| 102 |
+
# batch — 20 different seeds
|
| 103 |
+
for i in $(seq 1 20); do
|
| 104 |
+
curl "https://aakkaasshh-random-image-as-a-service.hf.space/image?seed=$i" -o "img_$i.png"
|
| 105 |
+
done
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Python
|
| 109 |
+
|
| 110 |
+
```python
|
| 111 |
+
import io, urllib.request
|
| 112 |
+
from PIL import Image
|
| 113 |
+
|
| 114 |
+
with urllib.request.urlopen("https://aakkaasshh-random-image-as-a-service.hf.space/image?style=emoji_hex&seed=9") as r:
|
| 115 |
+
img = Image.open(io.BytesIO(r.read()))
|
| 116 |
+
|
| 117 |
+
img.save("out.png")
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
Interactive docs at `/docs` (Swagger) and `/redoc`.
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## Gradio
|
| 125 |
+
|
| 126 |
+
The Gradio UI is at `https://aakkaasshh-random-image-as-a-service.hf.space/` (or `http://localhost:7860` locally). Sliders for width and height, a style dropdown, a seed box, and a live preview.
|
| 127 |
+
|
| 128 |
+
Gradio also exposes its own inference API on the same server:
|
| 129 |
+
|
| 130 |
+
```bash
|
| 131 |
+
curl -X POST "https://aakkaasshh-random-image-as-a-service.hf.space/run/generate_image" \
|
| 132 |
+
-H "Content-Type: application/json" \
|
| 133 |
+
-d '{"data": [800, 600, "geometric", "42"]}'
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
The four values in `data` are width, height, style, and seed. Pass `""` for a random seed.
|
| 137 |
+
|
| 138 |
+
Or use `gradio_client`:
|
| 139 |
+
|
| 140 |
+
```python
|
| 141 |
+
from gradio_client import Client
|
| 142 |
+
|
| 143 |
+
client = Client("https://aakkaasshh-random-image-as-a-service.hf.space")
|
| 144 |
+
image_path, info = client.predict(
|
| 145 |
+
800, 600, "emoji_hex", "",
|
| 146 |
+
api_name="/generate_image",
|
| 147 |
+
)
|
| 148 |
+
# image_path is a local temp file
|
| 149 |
+
# info is "Style: emoji_hex | Seed: 1837492"
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
## Running locally
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
git clone https://github.com/AkashSCIENTIST/Random-Image-as-a-Service
|
| 158 |
+
cd Random-Image-as-a-Service
|
| 159 |
+
pip install -r requirements.txt
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
**Gradio + API server** (one command gives you both the UI and `GET /image`):
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
python app.py
|
| 166 |
+
# UI → http://localhost:7860
|
| 167 |
+
# API → http://localhost:7860/image
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
Or with uvicorn for production:
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
uvicorn app:app --host 0.0.0.0 --port 7860
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
**FastAPI-only server** (lighter, no UI):
|
| 177 |
+
|
| 178 |
+
```bash
|
| 179 |
+
uvicorn api:app --reload
|
| 180 |
+
# http://localhost:8000/image
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
**Without a server** — call the generator directly:
|
| 184 |
+
|
| 185 |
+
```python
|
| 186 |
+
from core.service import generate
|
| 187 |
+
|
| 188 |
+
img, meta = generate(width=1024, height=768, style="emoji_hex", seed=42)
|
| 189 |
+
img.save("out.png")
|
| 190 |
+
print(meta)
|
| 191 |
+
# {'style': 'emoji_hex', 'width': 1024, 'height': 768, 'seed': 42, 'params': {...}}
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
**Deploy to Hugging Face Spaces** — create a Space with SDK: Gradio, then:
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
git remote add space https://huggingface.co/spaces/aakkaasshh/Random-Image-as-a-Service
|
| 198 |
+
git push space main
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
Spaces detects the FastAPI `app` object in `app.py` and serves it with uvicorn. You get the Gradio UI at `/` and `GET /image` on the same URL — pool-backed, ~1ms after warmup.
|
| 202 |
+
|
| 203 |
+
---
|
| 204 |
+
|
| 205 |
+
## Styles
|
| 206 |
+
|
| 207 |
+
| Name | Description |
|
| 208 |
+
|---|---|
|
| 209 |
+
| `linear` | Two colors fading at any angle |
|
| 210 |
+
| `radial` | Circular fade from center |
|
| 211 |
+
| `multicolor` | 3–5 color stops, linear or radial |
|
| 212 |
+
| `freeform` | Two colors blended through scattered control points |
|
| 213 |
+
| `shape_blur` | Blurred translucent blobs on a light base |
|
| 214 |
+
| `gradient_ramp` | Gradient cut into hard color bands |
|
| 215 |
+
| `geometric` | Low-poly triangles in a coordinated palette |
|
| 216 |
+
| `emoji` | Single emoji on a plain background with a soft oval shadow |
|
| 217 |
+
| `emoji_hex` | Two emojis tiled on a honeycomb lattice — each surrounded entirely by the other |
|
| 218 |
+
|
| 219 |
+
---
|
| 220 |
+
|
| 221 |
+
## Adding a style
|
| 222 |
+
|
| 223 |
+
1. Add `styles/my_style.py` — extend `GradientStyle`, implement `render()`, add `random_params()`.
|
| 224 |
+
2. Register it in `core/registry.py`.
|
| 225 |
+
3. Add the name to `VALID_STYLES` in `core/config.py`.
|
| 226 |
+
|
| 227 |
+
It shows up in the API, the Gradio dropdown, and `generate()` automatically.
|
api.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
from fastapi import FastAPI, Query, Response, HTTPException
|
| 3 |
+
from core import service, config
|
| 4 |
+
|
| 5 |
+
app = FastAPI(title="RIaaS — Random Image as a Service")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@app.get("/image")
|
| 9 |
+
def get_image(
|
| 10 |
+
width: int = Query(config.DEFAULT_WIDTH, ge=config.MIN_WIDTH, le=config.MAX_WIDTH),
|
| 11 |
+
height: int = Query(config.DEFAULT_HEIGHT, ge=config.MIN_HEIGHT, le=config.MAX_HEIGHT),
|
| 12 |
+
style: str = Query(None),
|
| 13 |
+
seed: int = Query(None),
|
| 14 |
+
) -> Response:
|
| 15 |
+
try:
|
| 16 |
+
image, meta = service.generate(width, height, style, seed)
|
| 17 |
+
except ValueError as exc:
|
| 18 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 19 |
+
|
| 20 |
+
buf = io.BytesIO()
|
| 21 |
+
image.save(buf, format="PNG")
|
| 22 |
+
buf.seek(0)
|
| 23 |
+
return Response(content=buf.read(), media_type="image/png")
|
app.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import random
|
| 3 |
+
import threading
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
from fastapi import FastAPI, HTTPException, Query
|
| 7 |
+
from fastapi.responses import Response
|
| 8 |
+
from PIL import Image
|
| 9 |
+
|
| 10 |
+
from core import config, service
|
| 11 |
+
|
| 12 |
+
# ── Pre-generated image pool ──────────────────────────────────────────────────
|
| 13 |
+
# Images are rendered once at startup and kept as PNG bytes in memory.
|
| 14 |
+
# GET /image with no params picks one at random: memory lookup + HTTP write ≈ 1ms.
|
| 15 |
+
POOL_SIZE = 50
|
| 16 |
+
_pool: list[bytes] = []
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _build_pool() -> None:
|
| 20 |
+
# Append one-by-one so the pool is usable after the very first image (~200ms),
|
| 21 |
+
# not only after all 50 are done. Critical for cold-start / post-sleep behaviour.
|
| 22 |
+
for _ in range(POOL_SIZE):
|
| 23 |
+
img, _ = service.generate(config.DEFAULT_WIDTH, config.DEFAULT_HEIGHT, None, None)
|
| 24 |
+
buf = io.BytesIO()
|
| 25 |
+
img.save(buf, "PNG", compress_level=1)
|
| 26 |
+
_pool.append(buf.getvalue())
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
threading.Thread(target=_build_pool, daemon=True).start()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ── FastAPI app ───────────────────────────────────────────────────────────────
|
| 33 |
+
app = FastAPI(title="RIaaS — Random Image as a Service")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@app.get("/image")
|
| 37 |
+
async def image_api(
|
| 38 |
+
width: int | None = Query(None, ge=config.MIN_WIDTH, le=config.MAX_WIDTH),
|
| 39 |
+
height: int | None = Query(None, ge=config.MIN_HEIGHT, le=config.MAX_HEIGHT),
|
| 40 |
+
style: str | None = Query(None),
|
| 41 |
+
seed: int | None = Query(None),
|
| 42 |
+
):
|
| 43 |
+
# Default request (no params) → serve from pool; ~1ms once pool is warm.
|
| 44 |
+
# While pool is still building the first time, fall through to live generation.
|
| 45 |
+
if _pool and width is None and height is None and style is None and seed is None:
|
| 46 |
+
return Response(
|
| 47 |
+
content=random.choice(_pool),
|
| 48 |
+
media_type="image/png",
|
| 49 |
+
headers={"X-RIaaS-Source": "pool"},
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Custom params → generate on demand
|
| 53 |
+
try:
|
| 54 |
+
img, _ = service.generate(
|
| 55 |
+
width or config.DEFAULT_WIDTH,
|
| 56 |
+
height or config.DEFAULT_HEIGHT,
|
| 57 |
+
style or "random",
|
| 58 |
+
seed,
|
| 59 |
+
)
|
| 60 |
+
except ValueError as exc:
|
| 61 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 62 |
+
|
| 63 |
+
buf = io.BytesIO()
|
| 64 |
+
img.save(buf, "PNG", compress_level=1)
|
| 65 |
+
return Response(
|
| 66 |
+
content=buf.getvalue(),
|
| 67 |
+
media_type="image/png",
|
| 68 |
+
headers={"X-RIaaS-Source": "live"},
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ── Gradio UI ─────────────────────────────────────────────────────────────────
|
| 73 |
+
STYLE_CHOICES = ["random"] + config.VALID_STYLES
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def generate_image(width: int, height: int, style: str, seed: str) -> tuple[Image.Image, str]:
|
| 77 |
+
parsed_seed = int(seed) if seed.strip() else None
|
| 78 |
+
try:
|
| 79 |
+
image, meta = service.generate(int(width), int(height), style, parsed_seed)
|
| 80 |
+
except ValueError as exc:
|
| 81 |
+
return None, str(exc)
|
| 82 |
+
return image, f"Style: {meta['style']} | Seed: {meta['seed']}"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
with gr.Blocks(title="RIaaS — Random Image as a Service") as demo:
|
| 86 |
+
gr.Markdown("# Random Image as a Service\nGenerate beautiful gradient images on demand.")
|
| 87 |
+
with gr.Row():
|
| 88 |
+
with gr.Column(scale=1):
|
| 89 |
+
width_slider = gr.Slider(config.MIN_WIDTH, config.MAX_WIDTH, value=config.DEFAULT_WIDTH, step=8, label="Width")
|
| 90 |
+
height_slider = gr.Slider(config.MIN_HEIGHT, config.MAX_HEIGHT, value=config.DEFAULT_HEIGHT, step=8, label="Height")
|
| 91 |
+
style_dropdown = gr.Dropdown(STYLE_CHOICES, value="random", label="Style")
|
| 92 |
+
seed_input = gr.Textbox(value="", placeholder="Leave blank for random", label="Seed")
|
| 93 |
+
generate_btn = gr.Button("Generate", variant="primary")
|
| 94 |
+
with gr.Column(scale=2):
|
| 95 |
+
output_image = gr.Image(label="Result", type="pil")
|
| 96 |
+
output_info = gr.Textbox(label="Info", interactive=False)
|
| 97 |
+
|
| 98 |
+
generate_btn.click(
|
| 99 |
+
generate_image,
|
| 100 |
+
inputs=[width_slider, height_slider, style_dropdown, seed_input],
|
| 101 |
+
outputs=[output_image, output_info],
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Mount Gradio at root. Routes defined on `app` above (/image) are matched
|
| 105 |
+
# before the wildcard Gradio mount, so both coexist without conflict.
|
| 106 |
+
app = gr.mount_gradio_app(app, demo, path="/")
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
import uvicorn
|
| 110 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
banner.svg
ADDED
|
|
core/__init__.py
ADDED
|
File without changes
|
core/base.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from PIL import Image
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class GradientStyle(ABC):
|
| 6 |
+
def __init__(self, width: int, height: int):
|
| 7 |
+
self.width = width
|
| 8 |
+
self.height = height
|
| 9 |
+
|
| 10 |
+
@abstractmethod
|
| 11 |
+
def render(self) -> Image.Image:
|
| 12 |
+
...
|
core/color_utils.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import colorsys
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def random_hex_color(rng: random.Random) -> str:
|
| 6 |
+
r, g, b = (rng.randint(0, 255) for _ in range(3))
|
| 7 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
|
| 11 |
+
h = hex_color.lstrip("#")
|
| 12 |
+
return tuple(int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _rgb_to_hex(r: float, g: float, b: float) -> str:
|
| 16 |
+
return "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def complementary(hex_color: str) -> str:
|
| 20 |
+
r, g, b = _hex_to_rgb(hex_color)
|
| 21 |
+
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
| 22 |
+
h = (h + 0.5) % 1.0
|
| 23 |
+
return _rgb_to_hex(*colorsys.hsv_to_rgb(h, max(s, 0.65), max(v, 0.75)))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def analogous(hex_color: str, n: int = 2, spread_deg: float = 30.0) -> list[str]:
|
| 27 |
+
r, g, b = _hex_to_rgb(hex_color)
|
| 28 |
+
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
| 29 |
+
spread = spread_deg / 360.0
|
| 30 |
+
step = spread / max(n - 1, 1)
|
| 31 |
+
start = h - spread / 2
|
| 32 |
+
return [_rgb_to_hex(*colorsys.hsv_to_rgb((start + i * step) % 1.0, s, v)) for i in range(n)]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def vibrant_analogous(rng: random.Random, n: int, spread_deg: float = 50.0) -> list[str]:
|
| 36 |
+
"""Analogous palette with forced vibrancy and alternating lightness for differentiability."""
|
| 37 |
+
base_h = rng.random()
|
| 38 |
+
base_s = rng.uniform(0.60, 0.88)
|
| 39 |
+
base_v = rng.uniform(0.72, 0.94)
|
| 40 |
+
spread = spread_deg / 360.0
|
| 41 |
+
step = spread / max(n - 1, 1)
|
| 42 |
+
start = base_h - spread / 2
|
| 43 |
+
colors = []
|
| 44 |
+
for i in range(n):
|
| 45 |
+
hue = (start + i * step) % 1.0
|
| 46 |
+
# Alternate value high/low so adjacent colors are clearly distinct
|
| 47 |
+
v = base_v - 0.18 if i % 2 == 1 else base_v
|
| 48 |
+
s = min(0.95, base_s + 0.08) if i % 2 == 1 else base_s
|
| 49 |
+
colors.append(_rgb_to_hex(*colorsys.hsv_to_rgb(hue, s, max(0.55, v))))
|
| 50 |
+
return colors
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def random_palette(rng: random.Random, n: int, scheme: str = "random") -> list[str]:
|
| 54 |
+
base = random_hex_color(rng)
|
| 55 |
+
if scheme == "complementary":
|
| 56 |
+
r, g, b = _hex_to_rgb(base)
|
| 57 |
+
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
| 58 |
+
# Force both colors to be vibrant
|
| 59 |
+
base = _rgb_to_hex(*colorsys.hsv_to_rgb(h, max(s, 0.65), max(v, 0.72)))
|
| 60 |
+
return [base, complementary(base)][:n]
|
| 61 |
+
if scheme == "analogous":
|
| 62 |
+
return vibrant_analogous(rng, n, spread_deg=rng.uniform(30, 60))
|
| 63 |
+
return [random_hex_color(rng) for _ in range(n)]
|
core/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIN_WIDTH = 64
|
| 2 |
+
MAX_WIDTH = 4096
|
| 3 |
+
MIN_HEIGHT = 64
|
| 4 |
+
MAX_HEIGHT = 4096
|
| 5 |
+
|
| 6 |
+
DEFAULT_WIDTH = 1024
|
| 7 |
+
DEFAULT_HEIGHT = 768
|
| 8 |
+
DEFAULT_SEED = None
|
| 9 |
+
|
| 10 |
+
VALID_STYLES = [
|
| 11 |
+
"linear",
|
| 12 |
+
"radial",
|
| 13 |
+
"multicolor",
|
| 14 |
+
"freeform",
|
| 15 |
+
"shape_blur",
|
| 16 |
+
"gradient_ramp",
|
| 17 |
+
"geometric",
|
| 18 |
+
"emoji",
|
| 19 |
+
"emoji_hex",
|
| 20 |
+
]
|
core/registry.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from styles import linear, radial, multicolor, freeform, shape_blur, gradient_ramp, geometric, emoji, emoji_hex
|
| 3 |
+
|
| 4 |
+
STYLE_TABLE: dict[str, dict] = {
|
| 5 |
+
"linear": {"cls": linear.LinearGradient, "params": linear.random_params},
|
| 6 |
+
"radial": {"cls": radial.RadialGradient, "params": radial.random_params},
|
| 7 |
+
"multicolor": {"cls": multicolor.MulticolorGradient, "params": multicolor.random_params},
|
| 8 |
+
"freeform": {"cls": freeform.FreeformGradient, "params": freeform.random_params},
|
| 9 |
+
"shape_blur": {"cls": shape_blur.ShapeBlurGradient, "params": shape_blur.random_params},
|
| 10 |
+
"gradient_ramp": {"cls": gradient_ramp.GradientRampStyle, "params": gradient_ramp.random_params},
|
| 11 |
+
"geometric": {"cls": geometric.GeometricStyle, "params": geometric.random_params},
|
| 12 |
+
"emoji": {"cls": emoji.EmojiStyle, "params": emoji.random_params},
|
| 13 |
+
"emoji_hex": {"cls": emoji_hex.EmojiHexStyle, "params": emoji_hex.random_params},
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def pick_random_style(
|
| 18 |
+
width: int,
|
| 19 |
+
height: int,
|
| 20 |
+
style_name: str = None,
|
| 21 |
+
seed: int = None,
|
| 22 |
+
) -> tuple:
|
| 23 |
+
rng = random.Random(seed)
|
| 24 |
+
if style_name is None or style_name not in STYLE_TABLE:
|
| 25 |
+
style_name = rng.choice(list(STYLE_TABLE.keys()))
|
| 26 |
+
|
| 27 |
+
entry = STYLE_TABLE[style_name]
|
| 28 |
+
params = entry["params"](width, height, rng)
|
| 29 |
+
instance = entry["cls"](width=width, height=height, **params)
|
| 30 |
+
return instance, style_name, params
|
core/service.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
from . import config, registry
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def generate(
|
| 6 |
+
width: int,
|
| 7 |
+
height: int,
|
| 8 |
+
style: str = None,
|
| 9 |
+
seed: int = None,
|
| 10 |
+
) -> tuple[Image.Image, dict]:
|
| 11 |
+
if not (config.MIN_WIDTH <= width <= config.MAX_WIDTH):
|
| 12 |
+
raise ValueError(f"width must be between {config.MIN_WIDTH} and {config.MAX_WIDTH}")
|
| 13 |
+
if not (config.MIN_HEIGHT <= height <= config.MAX_HEIGHT):
|
| 14 |
+
raise ValueError(f"height must be between {config.MIN_HEIGHT} and {config.MAX_HEIGHT}")
|
| 15 |
+
|
| 16 |
+
style_arg = None if style in (None, "random") else style
|
| 17 |
+
instance, chosen_style, params = registry.pick_random_style(width, height, style_arg, seed)
|
| 18 |
+
image = instance.render()
|
| 19 |
+
|
| 20 |
+
meta = {"style": chosen_style, "width": width, "height": height, "seed": seed, "params": params}
|
| 21 |
+
return image, meta
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pillow
|
| 2 |
+
numpy
|
| 3 |
+
fastapi
|
| 4 |
+
uvicorn
|
| 5 |
+
gradio
|
styles/__init__.py
ADDED
|
File without changes
|
styles/duotone.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import colorsys
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image, ImageFilter
|
| 5 |
+
from core.base import GradientStyle
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 9 |
+
h = hex_color.lstrip("#")
|
| 10 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _hsv_to_hex(h: float, s: float, v: float) -> str:
|
| 14 |
+
r, g, b = colorsys.hsv_to_rgb(h, s, v)
|
| 15 |
+
return "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class DuotoneGradient(GradientStyle):
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
width: int,
|
| 22 |
+
height: int,
|
| 23 |
+
shadow_color: str,
|
| 24 |
+
highlight_color: str,
|
| 25 |
+
noise_seed: int = None,
|
| 26 |
+
):
|
| 27 |
+
super().__init__(width, height)
|
| 28 |
+
self.shadow_color = shadow_color
|
| 29 |
+
self.highlight_color = highlight_color
|
| 30 |
+
self.noise_seed = noise_seed
|
| 31 |
+
|
| 32 |
+
def render(self) -> Image.Image:
|
| 33 |
+
rng = np.random.default_rng(self.noise_seed)
|
| 34 |
+
|
| 35 |
+
# Multi-scale noise gives a more organic, interesting luminance base
|
| 36 |
+
lum = np.zeros((self.height, self.width), dtype=np.float32)
|
| 37 |
+
for scale in [4, 8, 16]:
|
| 38 |
+
th, tw = max(2, self.height // scale), max(2, self.width // scale)
|
| 39 |
+
layer = rng.random((th, tw)).astype(np.float32)
|
| 40 |
+
layer_img = Image.fromarray((layer * 255).astype(np.uint8), "L")
|
| 41 |
+
layer_img = layer_img.resize((self.width, self.height), Image.BILINEAR)
|
| 42 |
+
layer_img = layer_img.filter(ImageFilter.GaussianBlur(radius=max(2, min(self.width, self.height) // (scale * 2))))
|
| 43 |
+
lum += np.array(layer_img, dtype=np.float32) / 255.0
|
| 44 |
+
lum /= 3.0
|
| 45 |
+
# Boost contrast so midtones don't dominate
|
| 46 |
+
lum = np.clip((lum - 0.5) * 1.5 + 0.5, 0, 1)
|
| 47 |
+
|
| 48 |
+
shadow = np.array(_hex_to_rgb(self.shadow_color), dtype=np.float32)
|
| 49 |
+
highlight = np.array(_hex_to_rgb(self.highlight_color), dtype=np.float32)
|
| 50 |
+
img_arr = (shadow * (1 - lum[..., None]) + highlight * lum[..., None]).astype(np.uint8)
|
| 51 |
+
return Image.fromarray(img_arr, "RGB")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 55 |
+
# Shadow: very dark, richly saturated
|
| 56 |
+
h_shadow = rng.random()
|
| 57 |
+
shadow = _hsv_to_hex(h_shadow, rng.uniform(0.70, 0.90), rng.uniform(0.08, 0.20))
|
| 58 |
+
# Highlight: bright, vivid, complementary hue
|
| 59 |
+
h_highlight = (h_shadow + rng.uniform(0.40, 0.60)) % 1.0
|
| 60 |
+
highlight = _hsv_to_hex(h_highlight, rng.uniform(0.75, 0.95), rng.uniform(0.88, 1.00))
|
| 61 |
+
return {
|
| 62 |
+
"shadow_color": shadow,
|
| 63 |
+
"highlight_color": highlight,
|
| 64 |
+
"noise_seed": rng.randint(0, 2 ** 31),
|
| 65 |
+
}
|
styles/emoji.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import colorsys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
| 6 |
+
from core.base import GradientStyle
|
| 7 |
+
|
| 8 |
+
EMOJI_POOL = [
|
| 9 |
+
# Nature & weather
|
| 10 |
+
"🌊", "🔥", "⚡", "🌸", "🌺", "🍀", "🌙", "✨", "🌈", "🍄",
|
| 11 |
+
"🌻", "🏔️", "🌿", "🌴", "🍁", "🌞", "🌵", "🌾", "🌷", "🌋",
|
| 12 |
+
"❄️", "🌏", "🏵️", "🌬️", "🌪️", "🌠", "☄️", "⛅", "🌑", "🌊",
|
| 13 |
+
# Animals
|
| 14 |
+
"🦋", "🐉", "🦄", "🦅", "🦊", "🦁", "🐯", "🐸", "🦩", "🐬",
|
| 15 |
+
"🦜", "🦀", "🐙", "🦚", "🪸", "🦭", "🦦", "🦥", "🦘", "🦒",
|
| 16 |
+
"🐘", "🦏", "🐊", "🦈", "🐋", "🦑", "🦩", "🦢", "🦋", "🐓",
|
| 17 |
+
# Objects — Accessories & Jewelry
|
| 18 |
+
"💎", "👑", "🎩", "🕶️", "💍", "🪬", "🧿",
|
| 19 |
+
# Objects — Music
|
| 20 |
+
"🎵", "🎶", "🎷", "🎸", "🎺", "🎻", "🥁", "🪗", "🪘", "🎹",
|
| 21 |
+
# Objects — Light & Optics
|
| 22 |
+
"📷", "🎬", "📽️", "🔭", "💡", "🔦", "🕯️", "🪔",
|
| 23 |
+
# Objects — Science
|
| 24 |
+
"🔬", "⚗️", "🧬", "🧪", "🔮",
|
| 25 |
+
# Objects — Tools
|
| 26 |
+
"⚙️", "🔧", "🔩", "⚒️", "🛠️", "🪛", "🧲",
|
| 27 |
+
# Objects — Games & Sport trophies
|
| 28 |
+
"🎯", "🎮", "🕹️", "🎲", "🎳", "🎱", "🏆", "🥇", "🎪",
|
| 29 |
+
"⚽", "🏀", "🎾", "🏈", "⚾", "🏓", "🥊", "🎿",
|
| 30 |
+
# Objects — Celebration & Misc
|
| 31 |
+
"🎨", "🎭", "🎁", "🎀", "🎊", "🎉", "🧨", "🪆", "🎠",
|
| 32 |
+
"🎋", "🪩", "🎆", "🎇", "🎑",
|
| 33 |
+
# Objects — Food (iconic shapes)
|
| 34 |
+
"🍉", "🎂", "🍕", "🍦", "🍩", "🍣", "🍭", "🧁",
|
| 35 |
+
# Objects — Vehicles & Transport (iconic shapes)
|
| 36 |
+
"🚀", "✈️", "🛸", "🚁", "⛵", "🏎️"
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
_FONT_CANDIDATES = [
|
| 40 |
+
"C:/Windows/Fonts/seguiemj.ttf",
|
| 41 |
+
r"C:\Windows\Fonts\seguiemj.ttf",
|
| 42 |
+
"/System/Library/Fonts/Apple Color Emoji.ttc",
|
| 43 |
+
"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
|
| 44 |
+
"/usr/share/fonts/noto/NotoColorEmoji.ttf",
|
| 45 |
+
"/usr/share/fonts/google-noto-emoji/NotoColorEmoji.ttf",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _load_emoji_font(size: int) -> ImageFont.FreeTypeFont | None:
|
| 50 |
+
for path in _FONT_CANDIDATES:
|
| 51 |
+
if Path(path).exists():
|
| 52 |
+
try:
|
| 53 |
+
return ImageFont.truetype(path, size)
|
| 54 |
+
except Exception:
|
| 55 |
+
continue
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _detect_is_light(emoji: str, font: ImageFont.FreeTypeFont) -> bool:
|
| 60 |
+
"""Render emoji on black; bright result → emoji is light-coloured."""
|
| 61 |
+
probe = Image.new("RGB", (80, 80), (0, 0, 0))
|
| 62 |
+
d = ImageDraw.Draw(probe)
|
| 63 |
+
d.text((8, 8), emoji, font=font, embedded_color=True)
|
| 64 |
+
return np.array(probe, dtype=np.float32).mean() > 145
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class EmojiStyle(GradientStyle):
|
| 68 |
+
def __init__(
|
| 69 |
+
self,
|
| 70 |
+
width: int,
|
| 71 |
+
height: int,
|
| 72 |
+
emoji: str,
|
| 73 |
+
bg_light: str,
|
| 74 |
+
bg_dark: str,
|
| 75 |
+
seed: int = None,
|
| 76 |
+
):
|
| 77 |
+
super().__init__(width, height)
|
| 78 |
+
self.emoji = emoji
|
| 79 |
+
self.bg_light = bg_light
|
| 80 |
+
self.bg_dark = bg_dark
|
| 81 |
+
self.seed = seed
|
| 82 |
+
|
| 83 |
+
def _parse_hex(self, hex_color: str) -> tuple:
|
| 84 |
+
h = hex_color.lstrip("#")
|
| 85 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 86 |
+
|
| 87 |
+
def render(self) -> Image.Image:
|
| 88 |
+
font_size = int(min(self.width, self.height) * 0.52)
|
| 89 |
+
font = _load_emoji_font(font_size)
|
| 90 |
+
|
| 91 |
+
if font is None:
|
| 92 |
+
return Image.new("RGB", (self.width, self.height), self._parse_hex(self.bg_light))
|
| 93 |
+
|
| 94 |
+
probe_font = _load_emoji_font(64)
|
| 95 |
+
is_light = _detect_is_light(self.emoji, probe_font) if probe_font else False
|
| 96 |
+
bg = self._parse_hex(self.bg_dark if is_light else self.bg_light)
|
| 97 |
+
|
| 98 |
+
canvas = Image.new("RGBA", (self.width, self.height), (*bg, 255))
|
| 99 |
+
|
| 100 |
+
# Measure emoji to find centre position
|
| 101 |
+
m = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
| 102 |
+
bbox = m.textbbox((0, 0), self.emoji, font=font, embedded_color=True)
|
| 103 |
+
ew, eh = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
| 104 |
+
# Shift emoji slightly above centre so the shadow shows below it
|
| 105 |
+
lift = int(min(self.width, self.height) * 0.025)
|
| 106 |
+
tx = (self.width - ew) // 2 - bbox[0]
|
| 107 |
+
ty = (self.height - eh) // 2 - bbox[1] - lift
|
| 108 |
+
|
| 109 |
+
# Shadow: a soft oval disc directly below the emoji — NOT shape-matched.
|
| 110 |
+
# This gives a "floating above the surface" look regardless of emoji shape.
|
| 111 |
+
shadow_r_x = int(min(self.width, self.height) * 0.30)
|
| 112 |
+
shadow_r_y = int(shadow_r_x * 0.30)
|
| 113 |
+
shadow_drop = int(min(self.width, self.height) * 0.06)
|
| 114 |
+
scx = self.width // 2
|
| 115 |
+
scy = self.height // 2 + lift + shadow_drop
|
| 116 |
+
shadow = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
|
| 117 |
+
ImageDraw.Draw(shadow).ellipse(
|
| 118 |
+
[scx - shadow_r_x, scy - shadow_r_y, scx + shadow_r_x, scy + shadow_r_y],
|
| 119 |
+
fill=(0, 0, 0, 90),
|
| 120 |
+
)
|
| 121 |
+
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=shadow_r_x // 3))
|
| 122 |
+
canvas = Image.alpha_composite(canvas, shadow)
|
| 123 |
+
|
| 124 |
+
# Emoji
|
| 125 |
+
emoji_layer = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
|
| 126 |
+
ImageDraw.Draw(emoji_layer).text((tx, ty), self.emoji, font=font, embedded_color=True)
|
| 127 |
+
canvas = Image.alpha_composite(canvas, emoji_layer)
|
| 128 |
+
|
| 129 |
+
return canvas.convert("RGB")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def random_params(_width: int, _height: int, rng: random.Random) -> dict:
|
| 133 |
+
emoji = rng.choice(EMOJI_POOL)
|
| 134 |
+
h = rng.random()
|
| 135 |
+
r, g, b = colorsys.hsv_to_rgb(h, rng.uniform(0.06, 0.16), rng.uniform(0.93, 0.99))
|
| 136 |
+
bg_light = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
|
| 137 |
+
r2, g2, b2 = colorsys.hsv_to_rgb((h + 0.5) % 1.0, rng.uniform(0.55, 0.80), rng.uniform(0.08, 0.18))
|
| 138 |
+
bg_dark = "#{:02x}{:02x}{:02x}".format(int(r2 * 255), int(g2 * 255), int(b2 * 255))
|
| 139 |
+
return {
|
| 140 |
+
"emoji": emoji,
|
| 141 |
+
"bg_light": bg_light,
|
| 142 |
+
"bg_dark": bg_dark,
|
| 143 |
+
"seed": rng.randint(0, 2 ** 31),
|
| 144 |
+
}
|
styles/emoji_hex.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import random
|
| 3 |
+
import colorsys
|
| 4 |
+
from PIL import Image, ImageDraw
|
| 5 |
+
from core.base import GradientStyle
|
| 6 |
+
from styles.emoji import EMOJI_POOL, _load_emoji_font
|
| 7 |
+
|
| 8 |
+
_SQRT3 = math.sqrt(3)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class EmojiHexStyle(GradientStyle):
|
| 12 |
+
"""
|
| 13 |
+
Emojis placed at the vertices of a honeycomb tiling.
|
| 14 |
+
The honeycomb vertex graph is bipartite: alternate corners of every hexagon
|
| 15 |
+
belong to sublattice A (emoji_0) and sublattice B (emoji_1), so adjacent
|
| 16 |
+
vertices are always opposite emojis — exactly the 'alternate corners' pattern.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
width: int,
|
| 22 |
+
height: int,
|
| 23 |
+
emojis: list[str], # exactly 2 entries
|
| 24 |
+
bg_color: str,
|
| 25 |
+
emoji_size: int,
|
| 26 |
+
seed: int = None,
|
| 27 |
+
):
|
| 28 |
+
super().__init__(width, height)
|
| 29 |
+
self.emojis = emojis[:2]
|
| 30 |
+
self.bg_color = bg_color
|
| 31 |
+
self.emoji_size = emoji_size
|
| 32 |
+
self.seed = seed
|
| 33 |
+
|
| 34 |
+
def _parse_hex(self, hex_color: str) -> tuple:
|
| 35 |
+
h = hex_color.lstrip("#")
|
| 36 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 37 |
+
|
| 38 |
+
def render(self) -> Image.Image:
|
| 39 |
+
bg = self._parse_hex(self.bg_color)
|
| 40 |
+
img = Image.new("RGBA", (self.width, self.height), (*bg, 255))
|
| 41 |
+
|
| 42 |
+
font = _load_emoji_font(self.emoji_size)
|
| 43 |
+
if font is None:
|
| 44 |
+
return img.convert("RGB")
|
| 45 |
+
|
| 46 |
+
emo_a, emo_b = self.emojis[0], self.emojis[1]
|
| 47 |
+
m = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
| 48 |
+
ba = m.textbbox((0, 0), emo_a, font=font, embedded_color=True)
|
| 49 |
+
bb = m.textbbox((0, 0), emo_b, font=font, embedded_color=True)
|
| 50 |
+
# Pre-compute draw offset so each emoji is centred on its lattice point
|
| 51 |
+
oa = (-(ba[2] - ba[0]) // 2 - ba[0], -(ba[3] - ba[1]) // 2 - ba[1])
|
| 52 |
+
ob = (-(bb[2] - bb[0]) // 2 - bb[0], -(bb[3] - bb[1]) // 2 - bb[1])
|
| 53 |
+
|
| 54 |
+
layer = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
|
| 55 |
+
draw = ImageDraw.Draw(layer)
|
| 56 |
+
|
| 57 |
+
# R = circumradius of each hexagonal cell = nearest-neighbour distance
|
| 58 |
+
# in the honeycomb. Set > emoji_size so adjacent emojis don't overlap.
|
| 59 |
+
R = int(self.emoji_size * 1.30)
|
| 60 |
+
|
| 61 |
+
# Triangular lattice of hex *centres*:
|
| 62 |
+
# a1 = (3R/2, R√3/2) a2 = (0, R√3)
|
| 63 |
+
a1x, a1y = 1.5 * R, 0.5 * R * _SQRT3
|
| 64 |
+
a2x, a2y = 0.0, R * _SQRT3
|
| 65 |
+
|
| 66 |
+
# Vertex offsets from each hex centre.
|
| 67 |
+
# Even corners (k=0,2,4) → sublattice A (alternate corners)
|
| 68 |
+
# Odd corners (k=1,3,5) → sublattice B (the other alternate corners)
|
| 69 |
+
half = R * 0.5
|
| 70 |
+
h32 = R * _SQRT3 * 0.5
|
| 71 |
+
offsets_a = [(R, 0.0), (-half, h32), (-half, -h32)]
|
| 72 |
+
offsets_b = [(half, h32), (-R, 0.0), (half, -h32)]
|
| 73 |
+
|
| 74 |
+
margin = 3
|
| 75 |
+
pad = self.emoji_size
|
| 76 |
+
m_max = int(self.width / a1x) + margin + 2
|
| 77 |
+
n_max = int(self.height / a2y) + margin + 2
|
| 78 |
+
|
| 79 |
+
seen_a: set[tuple[int, int]] = set()
|
| 80 |
+
seen_b: set[tuple[int, int]] = set()
|
| 81 |
+
|
| 82 |
+
for mi in range(-margin, m_max):
|
| 83 |
+
for ni in range(-margin, n_max):
|
| 84 |
+
cx = mi * a1x + ni * a2x
|
| 85 |
+
cy = mi * a1y + ni * a2y
|
| 86 |
+
|
| 87 |
+
for offsets, seen, emo, off in (
|
| 88 |
+
(offsets_a, seen_a, emo_a, oa),
|
| 89 |
+
(offsets_b, seen_b, emo_b, ob),
|
| 90 |
+
):
|
| 91 |
+
for dx, dy in offsets:
|
| 92 |
+
pos = (round(cx + dx), round(cy + dy))
|
| 93 |
+
if pos in seen:
|
| 94 |
+
continue
|
| 95 |
+
seen.add(pos)
|
| 96 |
+
px, py = pos
|
| 97 |
+
if -pad <= px <= self.width + pad and -pad <= py <= self.height + pad:
|
| 98 |
+
draw.text((px + off[0], py + off[1]), emo, font=font, embedded_color=True)
|
| 99 |
+
|
| 100 |
+
return Image.alpha_composite(img, layer).convert("RGB")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def random_params(_width: int, _height: int, rng: random.Random) -> dict:
|
| 104 |
+
emojis = rng.sample(EMOJI_POOL, 2)
|
| 105 |
+
h = rng.random()
|
| 106 |
+
r, g, b = colorsys.hsv_to_rgb(h, rng.uniform(0.04, 0.12), rng.uniform(0.93, 0.99))
|
| 107 |
+
bg_color = "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
|
| 108 |
+
# emoji_size drives both font render size and cell spacing
|
| 109 |
+
emoji_size = max(48, min(_width, _height) // rng.randint(5, 8))
|
| 110 |
+
return {
|
| 111 |
+
"emojis": emojis,
|
| 112 |
+
"bg_color": bg_color,
|
| 113 |
+
"emoji_size": emoji_size,
|
| 114 |
+
"seed": rng.randint(0, 2 ** 31),
|
| 115 |
+
}
|
styles/freeform.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from core.base import GradientStyle
|
| 5 |
+
from core import color_utils
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 9 |
+
h = hex_color.lstrip("#")
|
| 10 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _idw_blend(xs: np.ndarray, ys: np.ndarray, points: np.ndarray, colors: np.ndarray, power: float = 2.0) -> np.ndarray:
|
| 14 |
+
coords = np.stack([xs.ravel(), ys.ravel()], axis=-1).astype(np.float32)
|
| 15 |
+
diffs = coords[:, None, :] - points[None, :, :]
|
| 16 |
+
dist2 = (diffs ** 2).sum(axis=-1)
|
| 17 |
+
dist2 = np.maximum(dist2, 1e-10)
|
| 18 |
+
weights = 1.0 / dist2 ** (power / 2)
|
| 19 |
+
weights /= weights.sum(axis=-1, keepdims=True)
|
| 20 |
+
blended = (weights[:, :, None] * colors[None, :, :]).sum(axis=1)
|
| 21 |
+
return blended.reshape(*xs.shape, 3).astype(np.uint8)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class FreeformGradient(GradientStyle):
|
| 25 |
+
def __init__(self, width: int, height: int, colors: list[str], num_points: int = 5, seed: int = None):
|
| 26 |
+
super().__init__(width, height)
|
| 27 |
+
self.colors = colors
|
| 28 |
+
self.num_points = num_points
|
| 29 |
+
self.seed = seed
|
| 30 |
+
|
| 31 |
+
def render(self) -> Image.Image:
|
| 32 |
+
rng = np.random.default_rng(self.seed)
|
| 33 |
+
px = rng.uniform(0, self.width - 1, self.num_points)
|
| 34 |
+
py = rng.uniform(0, self.height - 1, self.num_points)
|
| 35 |
+
points = np.stack([px, py], axis=-1).astype(np.float32)
|
| 36 |
+
|
| 37 |
+
rgbs = [_hex_to_rgb(self.colors[i % len(self.colors)]) for i in range(self.num_points)]
|
| 38 |
+
colors_arr = np.array(rgbs, dtype=np.float32)
|
| 39 |
+
|
| 40 |
+
ys, xs = np.mgrid[0:self.height, 0:self.width]
|
| 41 |
+
img_arr = _idw_blend(xs, ys, points, colors_arr)
|
| 42 |
+
return Image.fromarray(img_arr, "RGB")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 46 |
+
return {
|
| 47 |
+
"colors": color_utils.random_palette(rng, 2, scheme="analogous"),
|
| 48 |
+
"num_points": rng.randint(5, 9),
|
| 49 |
+
"seed": rng.randint(0, 2 ** 31),
|
| 50 |
+
}
|
styles/geometric.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image, ImageDraw
|
| 4 |
+
from core.base import GradientStyle
|
| 5 |
+
from core import color_utils
|
| 6 |
+
|
| 7 |
+
SUPERSAMPLE = 2
|
| 8 |
+
PAD_FRAC = 0.20 # render 20% larger on each side, then crop
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 12 |
+
h = hex_color.lstrip("#")
|
| 13 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _avg_color(palette: list[str]) -> tuple[int, int, int]:
|
| 17 |
+
rgbs = [_hex_to_rgb(c) for c in palette]
|
| 18 |
+
return tuple(sum(ch) // len(ch) for ch in zip(*rgbs))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class GeometricStyle(GradientStyle):
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
width: int,
|
| 25 |
+
height: int,
|
| 26 |
+
palette: list[str],
|
| 27 |
+
rows: int = 6,
|
| 28 |
+
cols: int = 6,
|
| 29 |
+
jitter: float = 0.3,
|
| 30 |
+
seed: int = None,
|
| 31 |
+
):
|
| 32 |
+
super().__init__(width, height)
|
| 33 |
+
self.palette = palette
|
| 34 |
+
self.rows = rows
|
| 35 |
+
self.cols = cols
|
| 36 |
+
self.jitter = jitter
|
| 37 |
+
self.seed = seed
|
| 38 |
+
|
| 39 |
+
def render(self) -> Image.Image:
|
| 40 |
+
rng = random.Random(self.seed)
|
| 41 |
+
|
| 42 |
+
# Draw on a padded, 2× supersampled canvas then crop to final size
|
| 43 |
+
pad_w = int(self.width * PAD_FRAC)
|
| 44 |
+
pad_h = int(self.height * PAD_FRAC)
|
| 45 |
+
canvas_w = (self.width + 2 * pad_w) * SUPERSAMPLE
|
| 46 |
+
canvas_h = (self.height + 2 * pad_h) * SUPERSAMPLE
|
| 47 |
+
|
| 48 |
+
cell_w = canvas_w / self.cols
|
| 49 |
+
cell_h = canvas_h / self.rows
|
| 50 |
+
|
| 51 |
+
def jittered(x: float, y: float) -> tuple[float, float]:
|
| 52 |
+
return (
|
| 53 |
+
x + rng.uniform(-self.jitter, self.jitter) * cell_w,
|
| 54 |
+
y + rng.uniform(-self.jitter, self.jitter) * cell_h,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
pts = [
|
| 58 |
+
[jittered(c * cell_w, r * cell_h) for c in range(self.cols + 1)]
|
| 59 |
+
for r in range(self.rows + 1)
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
# Fill with blended average color so jitter gaps show a sensible color
|
| 63 |
+
img = Image.new("RGB", (canvas_w, canvas_h), _avg_color(self.palette))
|
| 64 |
+
draw = ImageDraw.Draw(img)
|
| 65 |
+
|
| 66 |
+
for r in range(self.rows):
|
| 67 |
+
for c in range(self.cols):
|
| 68 |
+
tl, tr = pts[r][c], pts[r][c + 1]
|
| 69 |
+
bl, br = pts[r + 1][c], pts[r + 1][c + 1]
|
| 70 |
+
draw.polygon([tl, tr, br], fill=_hex_to_rgb(rng.choice(self.palette)))
|
| 71 |
+
draw.polygon([tl, bl, br], fill=_hex_to_rgb(rng.choice(self.palette)))
|
| 72 |
+
|
| 73 |
+
# Crop to the non-padded region, then downsample
|
| 74 |
+
x0, y0 = pad_w * SUPERSAMPLE, pad_h * SUPERSAMPLE
|
| 75 |
+
img = img.crop((x0, y0, x0 + self.width * SUPERSAMPLE, y0 + self.height * SUPERSAMPLE))
|
| 76 |
+
return img.resize((self.width, self.height), Image.LANCZOS)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 80 |
+
n = rng.randint(4, 7)
|
| 81 |
+
return {
|
| 82 |
+
"palette": color_utils.random_palette(rng, n, scheme="analogous"),
|
| 83 |
+
"rows": rng.randint(9, 18),
|
| 84 |
+
"cols": rng.randint(9, 18),
|
| 85 |
+
"jitter": rng.uniform(0.08, 0.22),
|
| 86 |
+
"seed": rng.randint(0, 2 ** 31),
|
| 87 |
+
}
|
styles/gradient_ramp.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import random
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from core.base import GradientStyle
|
| 6 |
+
from core import color_utils
|
| 7 |
+
|
| 8 |
+
SUPERSAMPLE = 3
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 12 |
+
h = hex_color.lstrip("#")
|
| 13 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _interpolate_stops(t: np.ndarray, colors: list[str]) -> np.ndarray:
|
| 17 |
+
n = len(colors)
|
| 18 |
+
rgbs = np.array([_hex_to_rgb(c) for c in colors], dtype=np.float32)
|
| 19 |
+
stops = np.linspace(0, 1, n)
|
| 20 |
+
out = np.zeros((*t.shape, 3), dtype=np.float32)
|
| 21 |
+
for i in range(n - 1):
|
| 22 |
+
mask = (t >= stops[i]) & (t <= stops[i + 1])
|
| 23 |
+
lt = (t[mask] - stops[i]) / (stops[i + 1] - stops[i])
|
| 24 |
+
out[mask] = rgbs[i] * (1 - lt[..., None]) + rgbs[i + 1] * lt[..., None]
|
| 25 |
+
return out.astype(np.uint8)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class GradientRampStyle(GradientStyle):
|
| 29 |
+
def __init__(self, width: int, height: int, colors: list[str], steps: int = 6, angle: float = 0.0):
|
| 30 |
+
super().__init__(width, height)
|
| 31 |
+
self.colors = colors
|
| 32 |
+
self.steps = steps
|
| 33 |
+
self.angle = angle
|
| 34 |
+
|
| 35 |
+
def render(self) -> Image.Image:
|
| 36 |
+
W, H = self.width * SUPERSAMPLE, self.height * SUPERSAMPLE
|
| 37 |
+
rad = math.radians(self.angle)
|
| 38 |
+
cos_a, sin_a = math.cos(rad), math.sin(rad)
|
| 39 |
+
ys, xs = np.mgrid[0:H, 0:W]
|
| 40 |
+
cx, cy = W / 2, H / 2
|
| 41 |
+
proj = (xs - cx) * cos_a + (ys - cy) * sin_a
|
| 42 |
+
proj -= proj.min()
|
| 43 |
+
denom = proj.max()
|
| 44 |
+
t_cont = proj / denom if denom != 0 else proj
|
| 45 |
+
|
| 46 |
+
t_quantized = np.clip(np.floor(t_cont * self.steps) / self.steps, 0, 1)
|
| 47 |
+
img_arr = _interpolate_stops(t_quantized.astype(np.float32), self.colors)
|
| 48 |
+
big = Image.fromarray(img_arr, "RGB")
|
| 49 |
+
return big.resize((self.width, self.height), Image.LANCZOS)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 53 |
+
n = rng.randint(2, 4)
|
| 54 |
+
return {
|
| 55 |
+
"colors": color_utils.random_palette(rng, n, scheme="analogous"),
|
| 56 |
+
"steps": rng.randint(4, 10),
|
| 57 |
+
"angle": rng.uniform(0, 360),
|
| 58 |
+
}
|
styles/linear.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import random
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from core.base import GradientStyle
|
| 6 |
+
from core import color_utils
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 10 |
+
h = hex_color.lstrip("#")
|
| 11 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _to_linear(v: np.ndarray) -> np.ndarray:
|
| 15 |
+
"""sRGB → linear light (gamma decode)."""
|
| 16 |
+
v = v / 255.0
|
| 17 |
+
return np.where(v <= 0.04045, v / 12.92, ((v + 0.055) / 1.055) ** 2.4)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _to_srgb(v: np.ndarray) -> np.ndarray:
|
| 21 |
+
"""Linear light → sRGB (gamma encode)."""
|
| 22 |
+
return np.where(v <= 0.0031308, v * 12.92, 1.055 * v ** (1.0 / 2.4) - 0.055)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class LinearGradient(GradientStyle):
|
| 26 |
+
def __init__(self, width: int, height: int, colors: list[str], angle: float = 0.0):
|
| 27 |
+
super().__init__(width, height)
|
| 28 |
+
self.colors = colors
|
| 29 |
+
self.angle = angle
|
| 30 |
+
|
| 31 |
+
def render(self) -> Image.Image:
|
| 32 |
+
rad = math.radians(self.angle)
|
| 33 |
+
cos_a, sin_a = math.cos(rad), math.sin(rad)
|
| 34 |
+
|
| 35 |
+
ys, xs = np.mgrid[0:self.height, 0:self.width]
|
| 36 |
+
cx, cy = self.width / 2, self.height / 2
|
| 37 |
+
proj = (xs - cx) * cos_a + (ys - cy) * sin_a
|
| 38 |
+
proj -= proj.min()
|
| 39 |
+
denom = proj.max()
|
| 40 |
+
t = (proj / denom if denom != 0 else proj).astype(np.float32)
|
| 41 |
+
|
| 42 |
+
c0 = _to_linear(np.array(_hex_to_rgb(self.colors[0]), dtype=np.float32))
|
| 43 |
+
c1 = _to_linear(np.array(_hex_to_rgb(self.colors[1]), dtype=np.float32))
|
| 44 |
+
blended_lin = c0 * (1 - t[..., None]) + c1 * t[..., None]
|
| 45 |
+
img_arr = np.clip(_to_srgb(blended_lin) * 255, 0, 255).astype(np.uint8)
|
| 46 |
+
return Image.fromarray(img_arr, "RGB")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 50 |
+
return {
|
| 51 |
+
"colors": color_utils.random_palette(rng, 2, scheme="complementary"),
|
| 52 |
+
"angle": rng.uniform(0, 360),
|
| 53 |
+
}
|
styles/mesh.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from core.base import GradientStyle
|
| 5 |
+
from core import color_utils
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 9 |
+
h = hex_color.lstrip("#")
|
| 10 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _idw_blend(xs: np.ndarray, ys: np.ndarray, points: np.ndarray, colors: np.ndarray, power: float = 3.0) -> np.ndarray:
|
| 14 |
+
coords = np.stack([xs.ravel(), ys.ravel()], axis=-1).astype(np.float32)
|
| 15 |
+
diffs = coords[:, None, :] - points[None, :, :]
|
| 16 |
+
dist2 = (diffs ** 2).sum(axis=-1)
|
| 17 |
+
dist2 = np.maximum(dist2, 1e-10)
|
| 18 |
+
weights = 1.0 / dist2 ** (power / 2)
|
| 19 |
+
weights /= weights.sum(axis=-1, keepdims=True)
|
| 20 |
+
blended = (weights[:, :, None] * colors[None, :, :]).sum(axis=1)
|
| 21 |
+
return blended.reshape(*xs.shape, 3).astype(np.uint8)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MeshGradient(GradientStyle):
|
| 25 |
+
def __init__(self, width: int, height: int, colors: list[str], grid: tuple[int, int] = (3, 3)):
|
| 26 |
+
super().__init__(width, height)
|
| 27 |
+
self.colors = colors
|
| 28 |
+
self.grid = grid
|
| 29 |
+
|
| 30 |
+
def render(self) -> Image.Image:
|
| 31 |
+
rows, cols = self.grid
|
| 32 |
+
n_points = rows * cols
|
| 33 |
+
gx = np.linspace(0, self.width - 1, cols)
|
| 34 |
+
gy = np.linspace(0, self.height - 1, rows)
|
| 35 |
+
gxx, gyy = np.meshgrid(gx, gy)
|
| 36 |
+
points = np.stack([gxx.ravel(), gyy.ravel()], axis=-1)
|
| 37 |
+
|
| 38 |
+
rgbs = [_hex_to_rgb(self.colors[i % len(self.colors)]) for i in range(n_points)]
|
| 39 |
+
colors_arr = np.array(rgbs, dtype=np.float32)
|
| 40 |
+
|
| 41 |
+
ys, xs = np.mgrid[0:self.height, 0:self.width]
|
| 42 |
+
img_arr = _idw_blend(xs, ys, points, colors_arr)
|
| 43 |
+
return Image.fromarray(img_arr, "RGB")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 47 |
+
rows = rng.randint(2, 5)
|
| 48 |
+
cols = rng.randint(2, 5)
|
| 49 |
+
n = rows * cols
|
| 50 |
+
return {
|
| 51 |
+
"colors": color_utils.random_palette(rng, n, scheme="analogous"),
|
| 52 |
+
"grid": (rows, cols),
|
| 53 |
+
}
|
styles/multicolor.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import random
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from core.base import GradientStyle
|
| 6 |
+
from core import color_utils
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 10 |
+
h = hex_color.lstrip("#")
|
| 11 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _to_linear(v: np.ndarray) -> np.ndarray:
|
| 15 |
+
v = v / 255.0
|
| 16 |
+
return np.where(v <= 0.04045, v / 12.92, ((v + 0.055) / 1.055) ** 2.4)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _to_srgb(v: np.ndarray) -> np.ndarray:
|
| 20 |
+
return np.where(v <= 0.0031308, v * 12.92, 1.055 * v ** (1.0 / 2.4) - 0.055)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _interpolate_stops_linear_light(t: np.ndarray, colors: list[str]) -> np.ndarray:
|
| 24 |
+
n = len(colors)
|
| 25 |
+
rgbs_lin = np.array([_to_linear(np.array(_hex_to_rgb(c), dtype=np.float32)) for c in colors])
|
| 26 |
+
stops = np.linspace(0, 1, n)
|
| 27 |
+
out = np.zeros((*t.shape, 3), dtype=np.float32)
|
| 28 |
+
for i in range(n - 1):
|
| 29 |
+
mask = (t >= stops[i]) & (t <= stops[i + 1])
|
| 30 |
+
lt = (t[mask] - stops[i]) / (stops[i + 1] - stops[i])
|
| 31 |
+
out[mask] = rgbs_lin[i] * (1 - lt[..., None]) + rgbs_lin[i + 1] * lt[..., None]
|
| 32 |
+
return np.clip(_to_srgb(out) * 255, 0, 255).astype(np.uint8)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class MulticolorGradient(GradientStyle):
|
| 36 |
+
def __init__(self, width: int, height: int, colors: list[str], angle: float = 0.0, mode: str = "linear"):
|
| 37 |
+
super().__init__(width, height)
|
| 38 |
+
self.colors = colors
|
| 39 |
+
self.angle = angle
|
| 40 |
+
self.mode = mode
|
| 41 |
+
|
| 42 |
+
def render(self) -> Image.Image:
|
| 43 |
+
ys, xs = np.mgrid[0:self.height, 0:self.width]
|
| 44 |
+
if self.mode == "radial":
|
| 45 |
+
cx, cy = self.width / 2, self.height / 2
|
| 46 |
+
dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
|
| 47 |
+
r = max(self.width, self.height) * 0.72
|
| 48 |
+
t = np.clip(dist / r, 0, 1).astype(np.float32)
|
| 49 |
+
else:
|
| 50 |
+
rad = math.radians(self.angle)
|
| 51 |
+
cos_a, sin_a = math.cos(rad), math.sin(rad)
|
| 52 |
+
cx, cy = self.width / 2, self.height / 2
|
| 53 |
+
proj = (xs - cx) * cos_a + (ys - cy) * sin_a
|
| 54 |
+
proj -= proj.min()
|
| 55 |
+
denom = proj.max()
|
| 56 |
+
t = (proj / denom if denom != 0 else proj).astype(np.float32)
|
| 57 |
+
|
| 58 |
+
return Image.fromarray(_interpolate_stops_linear_light(t, self.colors), "RGB")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 62 |
+
mode = rng.choice(["linear", "radial"])
|
| 63 |
+
n = 2 if mode == "radial" else rng.randint(3, 5)
|
| 64 |
+
return {
|
| 65 |
+
"colors": color_utils.random_palette(rng, n, scheme="analogous"),
|
| 66 |
+
"angle": rng.uniform(0, 360),
|
| 67 |
+
"mode": mode,
|
| 68 |
+
}
|
styles/radial.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from core.base import GradientStyle
|
| 5 |
+
from core import color_utils
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 9 |
+
h = hex_color.lstrip("#")
|
| 10 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _to_linear(v: np.ndarray) -> np.ndarray:
|
| 14 |
+
v = v / 255.0
|
| 15 |
+
return np.where(v <= 0.04045, v / 12.92, ((v + 0.055) / 1.055) ** 2.4)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _to_srgb(v: np.ndarray) -> np.ndarray:
|
| 19 |
+
return np.where(v <= 0.0031308, v * 12.92, 1.055 * v ** (1.0 / 2.4) - 0.055)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RadialGradient(GradientStyle):
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
width: int,
|
| 26 |
+
height: int,
|
| 27 |
+
colors: list[str],
|
| 28 |
+
center: tuple[float, float] = (0.5, 0.5),
|
| 29 |
+
radius: float = None,
|
| 30 |
+
):
|
| 31 |
+
super().__init__(width, height)
|
| 32 |
+
self.colors = colors
|
| 33 |
+
self.center = center
|
| 34 |
+
self.radius = radius if radius is not None else max(width, height) * 0.7
|
| 35 |
+
|
| 36 |
+
def render(self) -> Image.Image:
|
| 37 |
+
cx = self.center[0] * self.width
|
| 38 |
+
cy = self.center[1] * self.height
|
| 39 |
+
ys, xs = np.mgrid[0:self.height, 0:self.width]
|
| 40 |
+
dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
|
| 41 |
+
t = np.clip(dist / self.radius, 0, 1).astype(np.float32)
|
| 42 |
+
|
| 43 |
+
c0 = _to_linear(np.array(_hex_to_rgb(self.colors[0]), dtype=np.float32))
|
| 44 |
+
c1 = _to_linear(np.array(_hex_to_rgb(self.colors[1]), dtype=np.float32))
|
| 45 |
+
blended_lin = c0 * (1 - t[..., None]) + c1 * t[..., None]
|
| 46 |
+
img_arr = np.clip(_to_srgb(blended_lin) * 255, 0, 255).astype(np.uint8)
|
| 47 |
+
return Image.fromarray(img_arr, "RGB")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 51 |
+
return {
|
| 52 |
+
"colors": color_utils.random_palette(rng, 2, scheme="analogous"),
|
| 53 |
+
"center": (rng.uniform(0.25, 0.75), rng.uniform(0.25, 0.75)),
|
| 54 |
+
"radius": max(width, height) * rng.uniform(0.45, 0.85),
|
| 55 |
+
}
|
styles/shape_blur.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import colorsys
|
| 3 |
+
import numpy as np
|
| 4 |
+
from PIL import Image, ImageDraw, ImageFilter
|
| 5 |
+
from core.base import GradientStyle
|
| 6 |
+
from core import color_utils
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
| 10 |
+
h = hex_color.lstrip("#")
|
| 11 |
+
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ShapeBlurGradient(GradientStyle):
|
| 15 |
+
def __init__(
|
| 16 |
+
self,
|
| 17 |
+
width: int,
|
| 18 |
+
height: int,
|
| 19 |
+
bg_color: str,
|
| 20 |
+
blob_colors: list[str],
|
| 21 |
+
num_blobs: int = 5,
|
| 22 |
+
blur_radius: int = 80,
|
| 23 |
+
seed: int = None,
|
| 24 |
+
):
|
| 25 |
+
super().__init__(width, height)
|
| 26 |
+
self.bg_color = bg_color
|
| 27 |
+
self.blob_colors = blob_colors
|
| 28 |
+
self.num_blobs = num_blobs
|
| 29 |
+
self.blur_radius = blur_radius
|
| 30 |
+
self.seed = seed
|
| 31 |
+
|
| 32 |
+
def render(self) -> Image.Image:
|
| 33 |
+
rng = random.Random(self.seed)
|
| 34 |
+
|
| 35 |
+
base = Image.new("RGBA", (self.width, self.height), (*_hex_to_rgb(self.bg_color), 255))
|
| 36 |
+
|
| 37 |
+
blob_layer = Image.new("RGBA", (self.width, self.height), (0, 0, 0, 0))
|
| 38 |
+
draw = ImageDraw.Draw(blob_layer)
|
| 39 |
+
|
| 40 |
+
for i in range(self.num_blobs):
|
| 41 |
+
color = self.blob_colors[i % len(self.blob_colors)]
|
| 42 |
+
r, g, b = _hex_to_rgb(color)
|
| 43 |
+
alpha = rng.randint(160, 220)
|
| 44 |
+
cx = rng.randint(-self.width // 6, self.width + self.width // 6)
|
| 45 |
+
cy = rng.randint(-self.height // 6, self.height + self.height // 6)
|
| 46 |
+
rx = rng.randint(self.width // 5, self.width // 2)
|
| 47 |
+
ry = rng.randint(self.height // 5, self.height // 2)
|
| 48 |
+
draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=(r, g, b, alpha))
|
| 49 |
+
|
| 50 |
+
blob_layer = blob_layer.filter(ImageFilter.GaussianBlur(radius=self.blur_radius))
|
| 51 |
+
composite = Image.alpha_composite(base, blob_layer)
|
| 52 |
+
return composite.convert("RGB")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def random_params(width: int, height: int, rng: random.Random) -> dict:
|
| 56 |
+
# Light, desaturated background — gives the "frosted glass" look
|
| 57 |
+
h = rng.random()
|
| 58 |
+
bg_r, bg_g, bg_b = colorsys.hsv_to_rgb(h, 0.08, 0.96)
|
| 59 |
+
bg_color = "#{:02x}{:02x}{:02x}".format(int(bg_r * 255), int(bg_g * 255), int(bg_b * 255))
|
| 60 |
+
|
| 61 |
+
# Blob colors: analogous family, vivid so they show through the blur
|
| 62 |
+
blob_colors = color_utils.random_palette(rng, rng.randint(3, 5), scheme="analogous")
|
| 63 |
+
|
| 64 |
+
blur = max(30, min(width, height) // 6)
|
| 65 |
+
return {
|
| 66 |
+
"bg_color": bg_color,
|
| 67 |
+
"blob_colors": blob_colors,
|
| 68 |
+
"num_blobs": rng.randint(4, 7),
|
| 69 |
+
"blur_radius": rng.randint(blur, blur * 2),
|
| 70 |
+
"seed": rng.randint(0, 2 ** 31),
|
| 71 |
+
}
|