Publish ember-colorized source code
This commit is contained in:
commit
8fa16635a5
14 changed files with 2173 additions and 0 deletions
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.14
|
||||||
165
LICENSE
Normal file
165
LICENSE
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
|
||||||
|
This version of the GNU Lesser General Public License incorporates
|
||||||
|
the terms and conditions of version 3 of the GNU General Public
|
||||||
|
License, supplemented by the additional permissions listed below.
|
||||||
|
|
||||||
|
0. Additional Definitions.
|
||||||
|
|
||||||
|
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||||
|
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||||
|
General Public License.
|
||||||
|
|
||||||
|
"The Library" refers to a covered work governed by this License,
|
||||||
|
other than an Application or a Combined Work as defined below.
|
||||||
|
|
||||||
|
An "Application" is any work that makes use of an interface provided
|
||||||
|
by the Library, but which is not otherwise based on the Library.
|
||||||
|
Defining a subclass of a class defined by the Library is deemed a mode
|
||||||
|
of using an interface provided by the Library.
|
||||||
|
|
||||||
|
A "Combined Work" is a work produced by combining or linking an
|
||||||
|
Application with the Library. The particular version of the Library
|
||||||
|
with which the Combined Work was made is also called the "Linked
|
||||||
|
Version".
|
||||||
|
|
||||||
|
The "Minimal Corresponding Source" for a Combined Work means the
|
||||||
|
Corresponding Source for the Combined Work, excluding any source code
|
||||||
|
for portions of the Combined Work that, considered in isolation, are
|
||||||
|
based on the Application, and not on the Linked Version.
|
||||||
|
|
||||||
|
The "Corresponding Application Code" for a Combined Work means the
|
||||||
|
object code and/or source code for the Application, including any data
|
||||||
|
and utility programs needed for reproducing the Combined Work from the
|
||||||
|
Application, but excluding the System Libraries of the Combined Work.
|
||||||
|
|
||||||
|
1. Exception to Section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
You may convey a covered work under sections 3 and 4 of this License
|
||||||
|
without being bound by section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
2. Conveying Modified Versions.
|
||||||
|
|
||||||
|
If you modify a copy of the Library, and, in your modifications, a
|
||||||
|
facility refers to a function or data to be supplied by an Application
|
||||||
|
that uses the facility (other than as an argument passed when the
|
||||||
|
facility is invoked), then you may convey a copy of the modified
|
||||||
|
version:
|
||||||
|
|
||||||
|
a) under this License, provided that you make a good faith effort to
|
||||||
|
ensure that, in the event an Application does not supply the
|
||||||
|
function or data, the facility still operates, and performs
|
||||||
|
whatever part of its purpose remains meaningful, or
|
||||||
|
|
||||||
|
b) under the GNU GPL, with none of the additional permissions of
|
||||||
|
this License applicable to that copy.
|
||||||
|
|
||||||
|
3. Object Code Incorporating Material from Library Header Files.
|
||||||
|
|
||||||
|
The object code form of an Application may incorporate material from
|
||||||
|
a header file that is part of the Library. You may convey such object
|
||||||
|
code under terms of your choice, provided that, if the incorporated
|
||||||
|
material is not limited to numerical parameters, data structure
|
||||||
|
layouts and accessors, or small macros, inline functions and templates
|
||||||
|
(ten or fewer lines in length), you do both of the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the object code that the
|
||||||
|
Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
4. Combined Works.
|
||||||
|
|
||||||
|
You may convey a Combined Work under terms of your choice that,
|
||||||
|
taken together, effectively do not restrict modification of the
|
||||||
|
portions of the Library contained in the Combined Work and reverse
|
||||||
|
engineering for debugging such modifications, if you also do each of
|
||||||
|
the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the Combined Work that
|
||||||
|
the Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
c) For a Combined Work that displays copyright notices during
|
||||||
|
execution, include the copyright notice for the Library among
|
||||||
|
these notices, as well as a reference directing the user to the
|
||||||
|
copies of the GNU GPL and this license document.
|
||||||
|
|
||||||
|
d) Do one of the following:
|
||||||
|
|
||||||
|
0) Convey the Minimal Corresponding Source under the terms of this
|
||||||
|
License, and the Corresponding Application Code in a form
|
||||||
|
suitable for, and under terms that permit, the user to
|
||||||
|
recombine or relink the Application with a modified version of
|
||||||
|
the Linked Version to produce a modified Combined Work, in the
|
||||||
|
manner specified by section 6 of the GNU GPL for conveying
|
||||||
|
Corresponding Source.
|
||||||
|
|
||||||
|
1) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (a) uses at run time
|
||||||
|
a copy of the Library already present on the user's computer
|
||||||
|
system, and (b) will operate properly with a modified version
|
||||||
|
of the Library that is interface-compatible with the Linked
|
||||||
|
Version.
|
||||||
|
|
||||||
|
e) Provide Installation Information, but only if you would otherwise
|
||||||
|
be required to provide such information under section 6 of the
|
||||||
|
GNU GPL, and only to the extent that such information is
|
||||||
|
necessary to install and execute a modified version of the
|
||||||
|
Combined Work produced by recombining or relinking the
|
||||||
|
Application with a modified version of the Linked Version. (If
|
||||||
|
you use option 4d0, the Installation Information must accompany
|
||||||
|
the Minimal Corresponding Source and Corresponding Application
|
||||||
|
Code. If you use option 4d1, you must provide the Installation
|
||||||
|
Information in the manner specified by section 6 of the GNU GPL
|
||||||
|
for conveying Corresponding Source.)
|
||||||
|
|
||||||
|
5. Combined Libraries.
|
||||||
|
|
||||||
|
You may place library facilities that are a work based on the
|
||||||
|
Library side by side in a single library together with other library
|
||||||
|
facilities that are not Applications and are not covered by this
|
||||||
|
License, and convey such a combined library under terms of your
|
||||||
|
choice, if you do both of the following:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work based
|
||||||
|
on the Library, uncombined with any other library facilities,
|
||||||
|
conveyed under the terms of this License.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library that part of it
|
||||||
|
is a work based on the Library, and explaining where to find the
|
||||||
|
accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
6. Revised Versions of the GNU Lesser General Public License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the GNU Lesser General Public License from time to time. Such new
|
||||||
|
versions will be similar in spirit to the present version, but may
|
||||||
|
differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Library as you received it specifies that a certain numbered version
|
||||||
|
of the GNU Lesser General Public License "or any later version"
|
||||||
|
applies to it, you have the option of following the terms and
|
||||||
|
conditions either of that published version or of any later version
|
||||||
|
published by the Free Software Foundation. If the Library as you
|
||||||
|
received it does not specify a version number of the GNU Lesser
|
||||||
|
General Public License, you may choose any version of the GNU Lesser
|
||||||
|
General Public License ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Library as you received it specifies that a proxy can decide
|
||||||
|
whether future versions of the GNU Lesser General Public License shall
|
||||||
|
apply, that proxy's public statement of acceptance of any version is
|
||||||
|
permanent authorization for you to choose that version for the
|
||||||
|
Library.
|
||||||
138
README.es.md
Normal file
138
README.es.md
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Ember Colorizer
|
||||||
|
|
||||||
|
[English](README.md) · [Español](README.es.md)
|
||||||
|
|
||||||
|
Aplica paletas de colores Ember a imágenes con alta precisión.
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aceleración GPU (opcional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apple Silicon (MPS) o NVIDIA (CUDA)
|
||||||
|
pip install 'ember-colorized[gpu]'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nombre de salida automático: filename-{palette}-colorized.ext
|
||||||
|
ember-colorizer --colors=ember-light /ruta/imagen.png
|
||||||
|
|
||||||
|
# Ruta de salida personalizada
|
||||||
|
ember-colorizer --colors=ember -o /ruta/salida.png /ruta/imagen.jpg
|
||||||
|
|
||||||
|
# Modo rápido (mapeo directo RGB, sin K-means)
|
||||||
|
ember-colorizer --colors=ember-soft -m fast /ruta/imagen.png
|
||||||
|
|
||||||
|
# Ajustar fuerza de recolor (0.0 = original, 1.0 = completo)
|
||||||
|
ember-colorizer --colors=ember-light -s 0.7 /ruta/imagen.png
|
||||||
|
|
||||||
|
# Más clusters para mayor precisión (modo aggressive)
|
||||||
|
ember-colorizer --colors=ember -k 20 /ruta/imagen.png
|
||||||
|
|
||||||
|
# Usar aceleración GPU (solo modo aggressive)
|
||||||
|
ember-colorizer --colors=ember --use-gpu /ruta/imagen.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paletas
|
||||||
|
|
||||||
|
| Paleta | Fondo | Tipo |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `ember` | `#1c1b19` | oscuro |
|
||||||
|
| `ember-soft` | `#242320` | oscuro |
|
||||||
|
| `ember-light` | `#e6dac4` | claro |
|
||||||
|
| `ember-lighter` | `#e8e4de` | claro |
|
||||||
|
|
||||||
|
## Modos
|
||||||
|
|
||||||
|
### Modo aggressive (`-m aggressive`)
|
||||||
|
|
||||||
|
Usa **clustering K-means** para analizar la imagen antes de recolorear.
|
||||||
|
|
||||||
|
**¿Qué es K-means?** Es un algoritmo que agrupa colores similares. Imagina lanzar 12 dardos a un círculo de colores — cada dardo se mueve hacia el "centro de gravedad" de los colores más cercanos. Tras varias rondas, los dardos se asientan en los colores más representativos de la imagen.
|
||||||
|
|
||||||
|
**Cómo funciona:**
|
||||||
|
1. K-means encuentra los 12 colores dominantes de tu imagen (configurable con `-k`)
|
||||||
|
2. Cada color dominante se mapea al color Ember más cercano (distancia Euclídea RGB)
|
||||||
|
3. Cada pixel se reasigna al color de paleta de su cluster
|
||||||
|
4. La fuerza controla la mezcla entre original y recoloreado
|
||||||
|
|
||||||
|
**Resultado:** Los colores se agrupan naturalmente — el cielo mantiene coherencia, los tonos de piel se unifican. Ideal para fotos e imágenes complejas.
|
||||||
|
|
||||||
|
### Modo fast (`-m fast`)
|
||||||
|
|
||||||
|
**Mapeo directo pixel por pixel RGB** — sin clustering, sin filtros. Cada pixel se mapea independientemente al color Ember más cercano usando distancia Euclídea en espacio RGB.
|
||||||
|
|
||||||
|
**Cómo funciona:**
|
||||||
|
1. Para cada pixel, calcula la distancia Euclídea a todos los colores de la paleta en espacio RGB
|
||||||
|
2. Reemplaza el pixel con el color de paleta más cercano
|
||||||
|
|
||||||
|
**Resultado:** Preservación máxima del detalle — bordes, gradientes, líneas finas (cabello de anime, contornos) se mantienen nítidos. Ideal para anime, ilustraciones, arte con líneas, o cuando importa la velocidad.
|
||||||
|
|
||||||
|
### Aceleración GPU (`--use-gpu`)
|
||||||
|
|
||||||
|
Cuando se pasa `--use-gpu`, el clustering K-means se ejecuta en la GPU con downsample inteligente — clusteriza en un subconjunto de 200K pixeles, luego asigna todos los pixeles vectorizadamente. Esto acelera enormemente el tiempo de recoloreado de una imágen, ideal para procesar imágenes complejas y con altas resoluciónes
|
||||||
|
|
||||||
|
**Backends soportados** (auto-detectados):
|
||||||
|
| Backend | Hardware | Paquete |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| cuML | NVIDIA GPU | `cuml-cu12` |
|
||||||
|
| PyTorch MPS | Apple Silicon | `torch` |
|
||||||
|
| PyTorch CUDA | NVIDIA GPU | `torch` |
|
||||||
|
| sklearn | CPU (fallback) | (siempre disponible) |
|
||||||
|
|
||||||
|
**Rendimiento** (imagen 5304×7952, 12 clusters):
|
||||||
|
| Backend | Tiempo |
|
||||||
|
|---------|--------|
|
||||||
|
| CPU (sklearn) | ~127s |
|
||||||
|
| GPU (Apple MPS) | ~8s |
|
||||||
|
|
||||||
|
**Ejemplo de salida:**
|
||||||
|
```
|
||||||
|
Input: foto.png
|
||||||
|
Palette: ember (Ember)
|
||||||
|
Mode: aggressive
|
||||||
|
Strength: 1.0
|
||||||
|
GPU: yes (Apple (arm64))
|
||||||
|
Output: foto-ember-colorized.png
|
||||||
|
⠹ Clustering colors...
|
||||||
|
Success! in 7.818s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diferencias clave
|
||||||
|
|
||||||
|
| | Aggressive | Fast |
|
||||||
|
|---|---|---|
|
||||||
|
| Algoritmo | K-means → mapeo por cluster | RGB nearest-color directo |
|
||||||
|
| Velocidad | Más lento (paso de clustering) | Más rápido (vectorizado) |
|
||||||
|
| Precisión | Mayor (coherencia de cluster) | Buena (por pixel) |
|
||||||
|
| Ideal para | Fotos, escenas complejas | Anime, ilustraciones, líneas |
|
||||||
|
| Control | `-k` clusters, `-s` fuerza | `-s` fuerza únicamente |
|
||||||
|
| GPU | `--use-gpu` soportado | Solo CPU |
|
||||||
|
|
||||||
|
## Showcase
|
||||||
|
|
||||||
|
- Recoloreado de una imágen de alta resolución usando GPU --use-gpu
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Recoloreado de la misma imágen usando solo CPU
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Recoloreado de una imágen simple usando el modo fast
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Como puedes observar, al procesar una imágen de alta reslución y peso, el modo GPU acelera el proceso enormemente.
|
||||||
|
|
||||||
|
De momento esta característica ha sido probada en GPUs de Apple Silicon, debería funcionar también en NVIDIA, si encuentras un problema con tu GPU, no dudes en abrir una [incidencia.](https://openlat.dev/JesusChapman/ember-colorized/issues/new)
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
LGPL-3.0
|
||||||
138
README.md
Normal file
138
README.md
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Ember Colorizer
|
||||||
|
|
||||||
|
[English](README.md) · [Español](README.es.md)
|
||||||
|
|
||||||
|
Apply Ember color palettes to images with high precision.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### GPU acceleration (optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apple Silicon (MPS) or NVIDIA (CUDA)
|
||||||
|
pip install 'ember-colorized[gpu]'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-generated output: filename-{palette}-colorized.ext
|
||||||
|
ember-colorizer --colors=ember-light /path/to/image.png
|
||||||
|
|
||||||
|
# Custom output path
|
||||||
|
ember-colorizer --colors=ember -o /path/to/output.png /path/to/image.jpg
|
||||||
|
|
||||||
|
# Fast mode (direct RGB mapping, no K-means)
|
||||||
|
ember-colorizer --colors=ember-soft -m fast /path/to/image.png
|
||||||
|
|
||||||
|
# Adjust recolor strength (0.0 = original, 1.0 = full)
|
||||||
|
ember-colorizer --colors=ember-light -s 0.7 /path/to/image.png
|
||||||
|
|
||||||
|
# More clusters for better accuracy (aggressive mode)
|
||||||
|
ember-colorizer --colors=ember -k 20 /path/to/image.png
|
||||||
|
|
||||||
|
# Use GPU acceleration (aggressive mode only)
|
||||||
|
ember-colorizer --colors=ember --use-gpu /path/to/image.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Palettes
|
||||||
|
|
||||||
|
| Palette | Background | Type |
|
||||||
|
|---------|-----------|------|
|
||||||
|
| `ember` | `#1c1b19` | dark |
|
||||||
|
| `ember-soft` | `#242320` | dark |
|
||||||
|
| `ember-light` | `#e6dac4` | light |
|
||||||
|
| `ember-lighter` | `#e8e4de` | light |
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
### Aggressive mode (`-m aggressive`)
|
||||||
|
|
||||||
|
Uses **K-means clustering** to analyze the image before recoloring.
|
||||||
|
|
||||||
|
**What is K-means?** It's an algorithm that groups similar colors together. Imagine throwing 12 darts at a color wheel — each dart moves to the "center of gravity" of the colors closest to it. After several rounds, the darts settle on the most representative colors in the image.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. K-means finds the 12 dominant colors in your image (configurable with `-k`)
|
||||||
|
2. Each dominant color is mapped to its nearest Ember palette color (RGB Euclidean distance)
|
||||||
|
3. Every pixel is reassigned to its cluster's mapped palette color
|
||||||
|
4. Strength controls the blend between original and recolored
|
||||||
|
|
||||||
|
**Result:** Colors group naturally — sky areas stay coherent, skin tones stay unified. Best for photos and complex images.
|
||||||
|
|
||||||
|
### Fast mode (`-m fast`)
|
||||||
|
|
||||||
|
**Direct pixel-by-pixel RGB nearest-color mapping** — no clustering, no filters. Each pixel independently maps to the closest Ember palette color using Euclidean distance in RGB space.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. For each pixel, compute Euclidean distance to all palette colors in RGB space
|
||||||
|
2. Replace the pixel with the nearest palette color
|
||||||
|
|
||||||
|
**Result:** Maximum detail preservation — edges, gradients, fine lines (anime hair, outlines) stay sharp. Best for anime, illustrations, line art, or when speed matters.
|
||||||
|
|
||||||
|
### GPU acceleration (`--use-gpu`)
|
||||||
|
|
||||||
|
When `--use-gpu` is passed, K-means clustering runs on the GPU with smart downsampling — clusters on a 200K-pixel subset, then assigns all pixels vectorized. This greatly accelerates the recoloring time for complex, high-resolution images.
|
||||||
|
|
||||||
|
**Supported backends** (auto-detected):
|
||||||
|
| Backend | Hardware | Package |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| cuML | NVIDIA GPU | `cuml-cu12` |
|
||||||
|
| PyTorch MPS | Apple Silicon | `torch` |
|
||||||
|
| PyTorch CUDA | NVIDIA GPU | `torch` |
|
||||||
|
| sklearn | CPU fallback | (always available) |
|
||||||
|
|
||||||
|
**Performance** (5304×7952 image, 12 clusters):
|
||||||
|
| Backend | Time |
|
||||||
|
|---------|------|
|
||||||
|
| CPU (sklearn) | ~127s |
|
||||||
|
| GPU (Apple MPS) | ~8s |
|
||||||
|
|
||||||
|
**Output example:**
|
||||||
|
```
|
||||||
|
Input: photo.png
|
||||||
|
Palette: ember (Ember)
|
||||||
|
Mode: aggressive
|
||||||
|
Strength: 1.0
|
||||||
|
GPU: yes (Apple (arm64))
|
||||||
|
Output: photo-ember-colorized.png
|
||||||
|
⠹ Clustering colors...
|
||||||
|
Success! in 7.818s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key differences
|
||||||
|
|
||||||
|
| | Aggressive | Fast |
|
||||||
|
|---|---|---|
|
||||||
|
| Algorithm | K-means → cluster mapping | Direct RGB nearest-color |
|
||||||
|
| Speed | Slower (clustering pass) | Fastest (vectorized) |
|
||||||
|
| Accuracy | Higher (cluster coherence) | Good (per-pixel) |
|
||||||
|
| Best for | Photos, complex scenes | Anime, illustrations, line art |
|
||||||
|
| Control | `-k` clusters, `-s` strength | `-s` strength only |
|
||||||
|
| GPU | `--use-gpu` supported | CPU only |
|
||||||
|
|
||||||
|
## Showcase
|
||||||
|
|
||||||
|
- High-resolution image recoloring using GPU --use-gpu
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Same image recoloring using CPU only
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- Simple image recoloring using fast mode
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
As you can see, when processing a high-resolution image, GPU mode significantly accelerates the process.
|
||||||
|
|
||||||
|
This feature has been tested on Apple Silicon GPUs; it should also work on NVIDIA. If you encounter any issues with your GPU, feel free to open an [issue](https://openlat.dev/JesusChapman/ember-colorized/issues/new).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LGPL-3.0
|
||||||
42
pyproject.toml
Normal file
42
pyproject.toml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
[project]
|
||||||
|
name = "ember-colorized"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Apply Ember color palettes to images with high precision"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "LGPL-3.0-only"
|
||||||
|
authors = [
|
||||||
|
{ name = "JesusChapman", email = "jesuschapman@openlat.dev" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Multimedia :: Graphics",
|
||||||
|
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"numpy>=2.2.6",
|
||||||
|
"pillow>=12.3.0",
|
||||||
|
"scikit-learn>=1.7.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
gpu = [
|
||||||
|
"torch>=2.7.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://openlat.dev/jesuschapman/ember-colorized"
|
||||||
|
Repository = "https://openlat.dev/jesuschapman/ember-colorized"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
ember-colorizer = "ember_colorized.cli:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.12.5,<0.13.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
BIN
showcase/fast_mode.png
Normal file
BIN
showcase/fast_mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
BIN
showcase/use_cpu.png
Normal file
BIN
showcase/use_cpu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
BIN
showcase/use_gpu.png
Normal file
BIN
showcase/use_gpu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
3
src/ember_colorized/__init__.py
Normal file
3
src/ember_colorized/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Ember Colorizer — Apply Ember palettes to images."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
170
src/ember_colorized/cli.py
Normal file
170
src/ember_colorized/cli.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
# Copyright JesusChapman <jesuschapman@openlat.dev>
|
||||||
|
# 2026
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .colorizer import colorize, colorize_fast, gpu_info
|
||||||
|
from .palettes import PALETTES
|
||||||
|
|
||||||
|
|
||||||
|
SPINNERS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||||
|
|
||||||
|
|
||||||
|
class Spinner:
|
||||||
|
"""Animated terminal spinner shown during processing."""
|
||||||
|
|
||||||
|
def __init__(self, message: str):
|
||||||
|
self.message = message
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._idx = 0
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
while not self._stop.is_set():
|
||||||
|
sys.stdout.write(f"\r {SPINNERS[self._idx % len(SPINNERS)]} {
|
||||||
|
self.message} ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
self._idx += 1
|
||||||
|
self._stop.wait(0.08)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def update(self, message: str):
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
self._thread.join(timeout=0.2)
|
||||||
|
sys.stdout.write(f"\r{'':60}\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog="ember-colorizer",
|
||||||
|
description="Apply Ember color palettes to images with high precision.",
|
||||||
|
)
|
||||||
|
p.add_argument("input", type=Path,
|
||||||
|
help="Input image path (PNG, JPG, JPEG, WEBP, BMP)")
|
||||||
|
p.add_argument(
|
||||||
|
"-c", "--colors",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
choices=list(PALETTES.keys()),
|
||||||
|
help="Ember palette to apply",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-o", "--output",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Output file path (overrides auto-generated name)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-s", "--strength",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help="Recolor strength: 0.0 = original, 1.0 = full recolor (default: 1.0)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-m", "--mode",
|
||||||
|
type=str,
|
||||||
|
default="aggressive",
|
||||||
|
choices=["aggressive", "fast"],
|
||||||
|
help="Algorithm: aggressive (K-means) or fast (direct mapping, default: aggressive)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"-k", "--clusters",
|
||||||
|
type=int,
|
||||||
|
default=12,
|
||||||
|
help="K-means clusters for aggressive mode (default: 12)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--use-gpu",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Use GPU acceleration (Apple Silicon MPS / NVIDIA CUDA / cuML)",
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _output_path(input_path: Path, palette_name: str) -> Path:
|
||||||
|
return input_path.parent / f"{input_path.stem}-{palette_name}-colorized{input_path.suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
parser = _build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
input_path: Path = args.input
|
||||||
|
if not input_path.exists():
|
||||||
|
print(f"Error: file not found: {input_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if input_path.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"}:
|
||||||
|
print(f"Error: unsupported file type: {
|
||||||
|
input_path.suffix}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
has_gpu, gpu_name = gpu_info()
|
||||||
|
|
||||||
|
if args.use_gpu and not has_gpu:
|
||||||
|
print("Warning: no GPU backend available, falling back to CPU.",
|
||||||
|
file=sys.stderr)
|
||||||
|
print(
|
||||||
|
" Install with: pip install 'ember-colorized[gpu]'", file=sys.stderr)
|
||||||
|
args.use_gpu = False
|
||||||
|
|
||||||
|
output_path = args.output or _output_path(input_path, args.colors)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Input: {input_path}")
|
||||||
|
print(f"Palette: {args.colors} ({PALETTES[args.colors]['name']})")
|
||||||
|
print(f"Mode: {args.mode}")
|
||||||
|
print(f"Strength: {args.strength}")
|
||||||
|
if args.use_gpu:
|
||||||
|
print(f"GPU: yes ({gpu_name})")
|
||||||
|
print(f"Output: {output_path}")
|
||||||
|
|
||||||
|
img = Image.open(input_path)
|
||||||
|
print(f"Loaded: {img.size[0]}x{img.size[1]} {img.mode}")
|
||||||
|
|
||||||
|
spinner = Spinner("Initializing...")
|
||||||
|
spinner.start()
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
def _progress(msg: str):
|
||||||
|
spinner.update(msg)
|
||||||
|
|
||||||
|
if args.mode == "aggressive":
|
||||||
|
result = colorize(
|
||||||
|
img, args.colors,
|
||||||
|
strength=args.strength,
|
||||||
|
n_clusters=args.clusters,
|
||||||
|
use_gpu=args.use_gpu,
|
||||||
|
progress_callback=_progress,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = colorize_fast(
|
||||||
|
img, args.colors, strength=args.strength, progress_callback=_progress)
|
||||||
|
|
||||||
|
spinner.stop()
|
||||||
|
|
||||||
|
result.save(str(output_path))
|
||||||
|
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
sec = int(elapsed)
|
||||||
|
ms = int((elapsed - sec) * 1000)
|
||||||
|
if sec > 0:
|
||||||
|
print(f"Success! in {sec}.{ms:03d}s")
|
||||||
|
else:
|
||||||
|
print(f"Success! in {ms}ms")
|
||||||
252
src/ember_colorized/colorizer.py
Normal file
252
src/ember_colorized/colorizer.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# Copyright JesusChapman <jesuschapman@openlat.dev>
|
||||||
|
# 2026
|
||||||
|
|
||||||
|
"""Core colorization engine — maps image colors to ember palettes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from .palettes import get_palette_rgb
|
||||||
|
|
||||||
|
# --- Backend detection ---
|
||||||
|
|
||||||
|
_backend = "cpu"
|
||||||
|
_gpu_name = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cuml.cluster import KMeans as CuMLKMeans
|
||||||
|
from cuml.common.device_selection import DeviceProperties
|
||||||
|
_backend = "cuml"
|
||||||
|
_gpu_name = f"NVIDIA ({DeviceProperties().name})"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if _backend == "cpu":
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
_backend = "torch-cuda"
|
||||||
|
_gpu_name = f"NVIDIA ({torch.cuda.get_device_name(0)})"
|
||||||
|
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||||
|
_backend = "torch-mps"
|
||||||
|
import platform
|
||||||
|
_gpu_name = f"Apple ({platform.machine()})"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def gpu_info() -> tuple[bool, str]:
|
||||||
|
"""Return (is_gpu, gpu_name_or_empty)."""
|
||||||
|
return _backend != "cpu", _gpu_name or ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- K-means backends ---
|
||||||
|
|
||||||
|
def _kmeans_cuml(pixels: np.ndarray, n_clusters: int):
|
||||||
|
from cuml.common.frame_utils import input_to_cuml_array
|
||||||
|
cu_pixels, _ = input_to_cuml_array(pixels)
|
||||||
|
km = CuMLKMeans(n_clusters=n_clusters, n_init=5,
|
||||||
|
max_iter=200, random_state=42)
|
||||||
|
km.fit(cu_pixels)
|
||||||
|
return km.labels_.to_numpy().astype(np.int32), km.cluster_centers_.to_numpy()
|
||||||
|
|
||||||
|
|
||||||
|
def _kmeans_torch_gpu(pixels: np.ndarray, n_clusters: int, device: str):
|
||||||
|
"""GPU K-means with smart downsampling for large images.
|
||||||
|
|
||||||
|
Strategy: cluster on a downsampled subset, then assign all pixels to
|
||||||
|
nearest center using vectorized distance. This avoids the O(N×K) memory
|
||||||
|
and compute overhead of full K-means on GPU for large N.
|
||||||
|
"""
|
||||||
|
import torch
|
||||||
|
|
||||||
|
n = len(pixels)
|
||||||
|
|
||||||
|
# For large images, downsample for clustering (GPU wins on small N)
|
||||||
|
MAX_CLUSTER = 200_000
|
||||||
|
if n > MAX_CLUSTER:
|
||||||
|
idx = np.random.default_rng(42).choice(n, MAX_CLUSTER, replace=False)
|
||||||
|
sample = pixels[idx]
|
||||||
|
else:
|
||||||
|
sample = pixels
|
||||||
|
|
||||||
|
t_sample = torch.tensor(sample, dtype=torch.float32, device=device)
|
||||||
|
|
||||||
|
# K-means++ init on CPU
|
||||||
|
ns = len(sample)
|
||||||
|
centers_np = np.empty((n_clusters, sample.shape[1]), dtype=np.float32)
|
||||||
|
rng = np.random.default_rng(42)
|
||||||
|
centers_np[0] = sample[rng.integers(0, ns)]
|
||||||
|
for i in range(1, n_clusters):
|
||||||
|
c = torch.tensor(centers_np[:i], dtype=torch.float32, device=device)
|
||||||
|
dists = torch.cdist(t_sample.unsqueeze(
|
||||||
|
0), c.unsqueeze(0)).squeeze(0).min(dim=1).values
|
||||||
|
dists_np = dists.cpu().numpy()
|
||||||
|
dists_np = dists_np - dists_np.min()
|
||||||
|
total = dists_np.sum()
|
||||||
|
probs = dists_np / \
|
||||||
|
total if total > 0 else np.ones(ns, dtype=np.float32) / ns
|
||||||
|
centers_np[i] = sample[rng.choice(ns, p=probs)]
|
||||||
|
|
||||||
|
centers = torch.tensor(centers_np, dtype=torch.float32, device=device)
|
||||||
|
|
||||||
|
# Vectorized K-means on sample
|
||||||
|
for _ in range(200):
|
||||||
|
dists = torch.cdist(t_sample.unsqueeze(
|
||||||
|
0), centers.unsqueeze(0)).squeeze(0)
|
||||||
|
labels = dists.argmin(dim=1)
|
||||||
|
new_centers = torch.zeros_like(centers)
|
||||||
|
counts = torch.zeros(n_clusters, device=device)
|
||||||
|
new_centers.scatter_add_(0, labels.unsqueeze(
|
||||||
|
1).expand(-1, sample.shape[1]), t_sample)
|
||||||
|
counts.scatter_add_(0, labels, torch.ones(ns, device=device))
|
||||||
|
counts = counts.clamp(min=1)
|
||||||
|
new_centers /= counts.unsqueeze(1)
|
||||||
|
if torch.allclose(new_centers, centers, atol=1e-4):
|
||||||
|
break
|
||||||
|
centers = new_centers
|
||||||
|
|
||||||
|
# Assign ALL pixels to nearest center (vectorized, fast)
|
||||||
|
t_all = torch.tensor(pixels, dtype=torch.float32, device=device)
|
||||||
|
dists = torch.cdist(t_all.unsqueeze(0), centers.unsqueeze(0)).squeeze(0)
|
||||||
|
labels = dists.argmin(dim=1)
|
||||||
|
|
||||||
|
return labels.cpu().numpy().astype(np.int32), centers.cpu().numpy()
|
||||||
|
|
||||||
|
|
||||||
|
def _kmeans_fit(pixels: np.ndarray, n_clusters: int, use_gpu: bool = True):
|
||||||
|
"""Run K-means on the best available backend."""
|
||||||
|
if use_gpu:
|
||||||
|
if _backend == "cuml":
|
||||||
|
return _kmeans_cuml(pixels, n_clusters)
|
||||||
|
if _backend in ("torch-cuda", "torch-mps"):
|
||||||
|
return _kmeans_torch_gpu(pixels, n_clusters, "cuda" if _backend == "torch-cuda" else "mps")
|
||||||
|
km = KMeans(n_clusters=n_clusters, n_init=5, max_iter=200, random_state=42)
|
||||||
|
km.fit(pixels)
|
||||||
|
return km.labels_.astype(np.int32), km.cluster_centers_
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Aggressive mode — K-means clustering → nearest palette
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def colorize(
|
||||||
|
img: Image.Image,
|
||||||
|
palette_name: str,
|
||||||
|
strength: float = 1.0,
|
||||||
|
n_clusters: int = 12,
|
||||||
|
use_gpu: bool = False,
|
||||||
|
progress_callback=None,
|
||||||
|
) -> Image.Image:
|
||||||
|
"""K-means clusters image colors, then maps each cluster to nearest palette color."""
|
||||||
|
palette = np.array(get_palette_rgb(palette_name), dtype=np.float64)
|
||||||
|
|
||||||
|
rgba = img.convert("RGBA")
|
||||||
|
rgb = np.array(rgba, dtype=np.float64)
|
||||||
|
alpha = rgb[:, :, 3:4] if rgb.shape[2] == 4 else np.ones(
|
||||||
|
(*rgb.shape[:2], 1))
|
||||||
|
rgb = rgb[:, :, :3]
|
||||||
|
|
||||||
|
h, w, _ = rgb.shape
|
||||||
|
pixels = rgb.reshape(-1, 3)
|
||||||
|
|
||||||
|
opaque_mask = alpha.reshape(-1) > 127
|
||||||
|
opaque_pixels = pixels[opaque_mask]
|
||||||
|
|
||||||
|
if len(opaque_pixels) == 0:
|
||||||
|
return img
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("Clustering colors...")
|
||||||
|
|
||||||
|
n_clusters = min(n_clusters, len(opaque_pixels))
|
||||||
|
labels, centers = _kmeans_fit(opaque_pixels, n_clusters, use_gpu)
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("Mapping to palette...")
|
||||||
|
|
||||||
|
diffs = centers[:, None, :] - palette[None, :, :]
|
||||||
|
dists = np.sum(diffs ** 2, axis=2)
|
||||||
|
nearest = np.argmin(dists, axis=1)
|
||||||
|
|
||||||
|
cluster_to_palette = {i: palette[nearest[i]] for i in range(len(nearest))}
|
||||||
|
mapped = np.array([cluster_to_palette[l]
|
||||||
|
for l in labels], dtype=np.float64)
|
||||||
|
|
||||||
|
if strength < 1.0:
|
||||||
|
mapped = opaque_pixels * (1 - strength) + mapped * strength
|
||||||
|
|
||||||
|
result = pixels.copy()
|
||||||
|
result[opaque_mask] = mapped
|
||||||
|
result = result.reshape(h, w, 3)
|
||||||
|
result_img = Image.fromarray(result.astype(np.uint8), "RGB")
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("Done")
|
||||||
|
|
||||||
|
if img.mode == "RGBA":
|
||||||
|
result_img = Image.merge(
|
||||||
|
"RGBA", (*result_img.split(), rgba.split()[3]))
|
||||||
|
|
||||||
|
return result_img
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fast mode — pure RGB nearest-palette (gruvboxify approach)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def colorize_fast(
|
||||||
|
img: Image.Image,
|
||||||
|
palette_name: str,
|
||||||
|
strength: float = 1.0,
|
||||||
|
progress_callback=None,
|
||||||
|
) -> Image.Image:
|
||||||
|
"""Pixel-by-pixel RGB Euclidean distance to nearest palette color."""
|
||||||
|
palette = np.array(get_palette_rgb(palette_name), dtype=np.float64)
|
||||||
|
|
||||||
|
rgba = img.convert("RGBA")
|
||||||
|
rgb = np.array(rgba, dtype=np.float64)
|
||||||
|
alpha = rgb[:, :, 3:4] if rgb.shape[2] == 4 else np.ones(
|
||||||
|
(*rgb.shape[:2], 1))
|
||||||
|
rgb = rgb[:, :, :3]
|
||||||
|
|
||||||
|
h, w, _ = rgb.shape
|
||||||
|
pixels = rgb.reshape(-1, 3)
|
||||||
|
|
||||||
|
opaque_mask = alpha.reshape(-1)
|
||||||
|
opaque_bool = opaque_mask > 127
|
||||||
|
|
||||||
|
if not opaque_bool.any():
|
||||||
|
return img
|
||||||
|
|
||||||
|
opaque_pixels = pixels[opaque_bool]
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("Mapping colors...")
|
||||||
|
|
||||||
|
diffs = opaque_pixels[:, None, :] - palette[None, :, :]
|
||||||
|
dists = np.sum(diffs ** 2, axis=2)
|
||||||
|
nearest_idx = np.argmin(dists, axis=1)
|
||||||
|
|
||||||
|
mapped = palette[nearest_idx]
|
||||||
|
|
||||||
|
if strength < 1.0:
|
||||||
|
mapped = opaque_pixels * (1 - strength) + mapped * strength
|
||||||
|
|
||||||
|
result = pixels.copy()
|
||||||
|
result[opaque_bool] = mapped
|
||||||
|
|
||||||
|
result = result.reshape(h, w, 3)
|
||||||
|
result_img = Image.fromarray(result.astype(np.uint8), "RGB")
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("Done")
|
||||||
|
|
||||||
|
if img.mode == "RGBA":
|
||||||
|
result_img = Image.merge(
|
||||||
|
"RGBA", (*result_img.split(), rgba.split()[3]))
|
||||||
|
|
||||||
|
return result_img
|
||||||
62
src/ember_colorized/palettes.py
Normal file
62
src/ember_colorized/palettes.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Copyright JesusChapman <jesuschapman@openlat.dev>
|
||||||
|
# 2026
|
||||||
|
|
||||||
|
"""Ember color palettes for image colorization."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
PALETTES: dict[str, dict] = {
|
||||||
|
"ember": {
|
||||||
|
"name": "Ember",
|
||||||
|
"type": "dark",
|
||||||
|
"colors": [
|
||||||
|
"#1c1b19", "#242320", "#252422", "#2e2d2a", "#3e3c38",
|
||||||
|
"#585550", "#706c61", "#908a7e", "#b8b0a0", "#d8d0c0",
|
||||||
|
"#b0a898", "#e08060", "#c09058", "#c8b468", "#8a9868",
|
||||||
|
"#80a090", "#7890a0", "#b07878", "#988090",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"ember-soft": {
|
||||||
|
"name": "Ember Soft",
|
||||||
|
"type": "dark",
|
||||||
|
"colors": [
|
||||||
|
"#242320", "#2a2927", "#2c2b28", "#353430", "#444240",
|
||||||
|
"#585550", "#706c61", "#908a7e", "#b8b0a0", "#d8d0c0",
|
||||||
|
"#b0a898", "#e08060", "#c09058", "#c8b468", "#8a9868",
|
||||||
|
"#80a090", "#7890a0", "#b07878", "#988090",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"ember-light": {
|
||||||
|
"name": "Ember Light",
|
||||||
|
"type": "light",
|
||||||
|
"colors": [
|
||||||
|
"#e6dac4", "#ddd0b8", "#d8ccb0", "#cec2a8", "#b8ac96",
|
||||||
|
"#989080", "#787060", "#605848", "#484030", "#282418",
|
||||||
|
"#585040", "#b84c30", "#946030", "#7a6820", "#4a6830",
|
||||||
|
"#386858", "#3a6080", "#905050", "#706070",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"ember-lighter": {
|
||||||
|
"name": "Ember Lighter",
|
||||||
|
"type": "light",
|
||||||
|
"colors": [
|
||||||
|
"#e8e4de", "#dfd9d4", "#f2efec", "#d0ccc6", "#c8c2b8",
|
||||||
|
"#a09484", "#807868", "#585040", "#3a3428", "#3a3428",
|
||||||
|
"#585040", "#b84c30", "#946030", "#7a6820", "#4a6830",
|
||||||
|
"#386858", "#3a6080", "#905050", "#706070",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
||||||
|
h = hex_color.lstrip("#")
|
||||||
|
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||||
|
|
||||||
|
|
||||||
|
def get_palette_rgb(name: str) -> list[tuple[int, int, int]]:
|
||||||
|
if name not in PALETTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown palette: {name}. Available: {', '.join(PALETTES.keys())}"
|
||||||
|
)
|
||||||
|
return [hex_to_rgb(c) for c in PALETTES[name]["colors"]]
|
||||||
Loading…
Reference in a new issue