File size: 7,444 Bytes
97418de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
""" Color-by-Numbers generator Gradio app for HuggingFace Spaces

Upload an image (photo or pixel art)
Quantize colors to a limited palette (default 8 colors)
Create a printable "color-by-numbers" page where each pixel/block contains the color's number
Also export a legend showing each number and its representative color (hex)

Dependencies: pillow, numpy, gradio
Run: pip install pillow numpy gradio
then python huggingface_color_by_numbers_app.py
When deployed to Hugging Face Spaces, set runtime to use the same file.
"""

from PIL import Image, ImageDraw, ImageFont
import numpy as np
import gradio as gr
import io

def quantize_image(img: Image.Image, n_colors: int):
    """Quantize the image to n_colors using PIL adaptive palette.
    Returns: label_map (h,w) of 0..(k-1) and palette list of (r,g,b).
    """
    # convert to RGBA and remove alpha by compositing on white background
    img = img.convert("RGBA")
    background = Image.new("RGBA", img.size, (255, 255, 255, 255))
    img = Image.alpha_composite(background, img).convert("RGB")

    # Use PIL quantize to reduce to n_colors
    q = img.convert('P', palette=Image.ADAPTIVE, colors=n_colors)
    palette = q.getpalette()  # flat list r,g,b,r,g,b,... length >= n_colors*3

    # build palette list from used palette indices
    used_indices = list(sorted(set(q.getdata())))
    # extract first n_colors entries from palette
    pal = []
    for i in range(n_colors):
        r = palette[3 * i]
        g = palette[3 * i + 1]
        b = palette[3 * i + 2]
        pal.append((r, g, b))

    # Convert paletted image to numpy labels: each pixel index corresponds to 0..(n_colors-1)
    label_map = np.array(q, dtype=np.int32)

    return label_map, pal


def sort_palette_by_brightness(palette):
    # compute luminance and sort (brighter later) so numbers have consistent ordering
    lum = [0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] for c in palette]
    order = np.argsort(lum)
    sorted_palette = [palette[i] for i in order]
    return sorted_palette, order


def remap_labels(label_map, order):
    # order says old_index -> new position in sorted palette
    # produce mapping from old index to new index
    inv = np.empty_like(order)
    for new_idx, old_idx in enumerate(order):
        inv[old_idx] = new_idx
    remapped = inv[label_map]
    return remapped


def rgb_to_hex(rgb):
    return '#%02x%02x%02x' % rgb


def generate_color_by_numbers_page(label_map: np.ndarray, palette: list, block_size: int = 24, outline: bool = True, font_path: str = None):
    """Generate a color-by-numbers image where each block contains the number (1..k).
    - label_map: 2D array of labels 0..k-1
    - palette: list of RGB colors for legend ordering
    - block_size: pixel size of a single input pixel when scaled up
    """
    h, w = label_map.shape
    k = len(palette)

    # Create canvas
    canvas_w = w * block_size
    canvas_h = h * block_size
    canvas = Image.new('RGB', (canvas_w, canvas_h), (255, 255, 255))
    draw = ImageDraw.Draw(canvas)

    # Load font
    try:
        if font_path:
            font = ImageFont.truetype(font_path, int(block_size * 0.6))
        else:
            font = ImageFont.load_default()
    except Exception:
        font = ImageFont.load_default()

    # draw each cell
    for y in range(h):
        for x in range(w):
            label = int(label_map[y, x])  # 0..k-1
            left = x * block_size
            top = y * block_size
            right = left + block_size
            bottom = top + block_size

            # fill with white
            draw.rectangle([left, top, right, bottom], fill=(255, 255, 255))

            # draw outline grid
            if outline:
                draw.rectangle([left, top, right - 1, bottom - 1], outline=(200, 200, 200))

            # draw number centered
            number = str(label + 1)
            bbox = draw.textbbox((0, 0), number, font=font)
            tw = bbox[2] - bbox[0]
            th = bbox[3] - bbox[1]
            nx = left + (block_size - tw) / 2
            ny = top + (block_size - th) / 2 - 1
            draw.text((nx, ny), number, fill=(0, 0, 0), font=font)

    return canvas


def generate_palette_legend(palette: list, block_size: int = 48):
    k = len(palette)
    padding = 10
    sw = block_size * k + padding * (k + 1)
    sh = block_size + 2 * padding + 20  # extra space for hex text
    legend = Image.new('RGB', (sw, sh), (255, 255, 255))
    d = ImageDraw.Draw(legend)

    try:
        font = ImageFont.load_default()
    except Exception:
        font = ImageFont.load_default()

    for i, rgb in enumerate(palette):
        x = padding + i * (block_size + padding)
        y = padding
        d.rectangle([x, y, x + block_size, y + block_size], fill=rgb, outline=(0, 0, 0))
        label = str(i + 1)
        bbox = d.textbbox((0, 0), label, font=font)
        tw = bbox[2] - bbox[0]
        th = bbox[3] - bbox[1]
        d.text((x + (block_size - tw) / 2, y + (block_size - th) / 2), label, fill=(0, 0, 0), font=font)
        # hex text below
        hexcode = rgb_to_hex(rgb)
        d.text((x, y + block_size + 2), hexcode, fill=(0, 0, 0), font=font)

    return legend


def process_image_to_color_by_numbers(pil_img: Image.Image, n_colors: int = 8, block_size: int = 24, outline: bool = True):
    # 1) quantize
    label_map, pal = quantize_image(pil_img, n_colors)

    # 2) sort palette by brightness
    sorted_pal, order = sort_palette_by_brightness(pal)
    remapped = remap_labels(label_map, order)

    # 3) generate page
    page = generate_color_by_numbers_page(remapped, sorted_pal, block_size=block_size, outline=outline)

    # 4) generate legend
    legend = generate_palette_legend(sorted_pal, block_size=max(32, block_size * 2))

    return page, legend


# -------------------- Gradio interface --------------------

def process_and_return(inp_img, n_colors, block_size, show_legend):
    if inp_img is None:
        return None

    pil = Image.open(inp_img)  # inp_img is file path string
    page, legend = process_image_to_color_by_numbers(pil, n_colors=n_colors, block_size=block_size)

    # combine vertically
    if show_legend:
        combined_w = max(page.width, legend.width)
        combined_h = page.height + legend.height + 20
        combined = Image.new('RGB', (combined_w, combined_h), (255, 255, 255))
        combined.paste(page, (0, 0))
        combined.paste(legend, (0, page.height + 20))
    else:
        combined = page

    bio = io.BytesIO()
    combined.save(bio, format='PNG')
    bio.seek(0)
    return bio


demo = gr.Interface(
    fn=process_and_return,
    inputs=[
        gr.Image(type='filepath', label='Upload image'),  # Changed to 'filepath' for clarity
        gr.Slider(minimum=2, maximum=12, step=1, value=8, label='Number of colors (reduction)'),
        gr.Slider(minimum=8, maximum=64, step=1, value=24, label='Block size (pixels)'),
        gr.Checkbox(label='Show legend with color swatches and hex codes', value=True)
    ],
    outputs=[gr.File(label='Download Color-by-Numbers Page (PNG)')],  # Changed to File for download
    title='🎨 Color-by-Numbers Generator',
    description='Upload an image and get a printable color-by-numbers page. The app quantizes colors to a small palette and writes numbers in each pixel block so children (or adults) can color by numbers.',
    allow_flagging="never"
)

if __name__ == "__main__":
    demo.launch()