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