strokemap 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- strokemap-0.1.0/AUTHORS.md +11 -0
- strokemap-0.1.0/LICENSE +21 -0
- strokemap-0.1.0/PKG-INFO +137 -0
- strokemap-0.1.0/README.md +114 -0
- strokemap-0.1.0/pyproject.toml +73 -0
- strokemap-0.1.0/setup.cfg +4 -0
- strokemap-0.1.0/src/strokemap/__init__.py +4 -0
- strokemap-0.1.0/src/strokemap/cli.py +67 -0
- strokemap-0.1.0/src/strokemap/generator.py +350 -0
- strokemap-0.1.0/src/strokemap/pdf_generator.py +231 -0
- strokemap-0.1.0/src/strokemap.egg-info/PKG-INFO +137 -0
- strokemap-0.1.0/src/strokemap.egg-info/SOURCES.txt +15 -0
- strokemap-0.1.0/src/strokemap.egg-info/dependency_links.txt +1 -0
- strokemap-0.1.0/src/strokemap.egg-info/entry_points.txt +2 -0
- strokemap-0.1.0/src/strokemap.egg-info/requires.txt +12 -0
- strokemap-0.1.0/src/strokemap.egg-info/top_level.txt +1 -0
- strokemap-0.1.0/tests/test_generator.py +107 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Authors
|
|
2
|
+
|
|
3
|
+
## Primary Author
|
|
4
|
+
|
|
5
|
+
- [Dipin Nair](https://github.com/dipinknair)
|
|
6
|
+
|
|
7
|
+
## Acknowledgements
|
|
8
|
+
|
|
9
|
+
Thank you to everyone who contributes bug reports, documentation updates, and feature improvements.
|
|
10
|
+
|
|
11
|
+
If you would like to be listed here, please contribute to the project and open a pull request with your details.
|
strokemap-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dipin K Nair
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
strokemap-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: strokemap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert any image into a Paint by Numbers printable PDF
|
|
5
|
+
Author-email: Developer <developer@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: AUTHORS.md
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: pillow
|
|
13
|
+
Requires-Dist: opencv-python
|
|
14
|
+
Requires-Dist: scikit-learn
|
|
15
|
+
Requires-Dist: reportlab
|
|
16
|
+
Requires-Dist: scikit-image
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
20
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Strokemap - Paint by Numbers Generator
|
|
25
|
+
|
|
26
|
+
A Python package and CLI tool to convert any image into a high-quality, print-ready "Paint by Numbers" PDF template.
|
|
27
|
+
|
|
28
|
+
The generated PDF matches premium standards, split into four cleanly laid-out pages:
|
|
29
|
+
1. **Page 1 - Numbered Template**: Light gray outlines with a small, centered index number in each region.
|
|
30
|
+
2. **Page 2 - Clean Outlines**: Clean black outlines without any numbers, perfect for clean canvas painting.
|
|
31
|
+
3. **Page 3 - Colorized Preview**: A color reference picture showing what the finished painting should look like.
|
|
32
|
+
4. **Page 4 - Color Palette Sheet**: A beautifully aligned grid of color swatches showing index numbers, hex codes, paint color blocks, and step-by-step instructions.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Preview
|
|
37
|
+
|
|
38
|
+
Here is an example of the generator's output using the standard **Lenna** test image:
|
|
39
|
+
|
|
40
|
+
| Original Image | Numbered Template (Page 1) | Clean Outlines (Page 2) | Colorized Preview (Page 3) |
|
|
41
|
+
| :---: | :---: | :---: | :---: |
|
|
42
|
+
|  |  |  |  |
|
|
43
|
+
|
|
44
|
+
> [!NOTE]
|
|
45
|
+
> **Image Citation**: Lenna (or Lena) is a standard digital image processing test image, originally from the USC-SIPI Image Database. It is widely used for testing image processing algorithms.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Create a virtual environment and install the package locally:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python3 -m venv .venv
|
|
55
|
+
source .venv/bin/activate
|
|
56
|
+
pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Dependencies
|
|
60
|
+
The package relies on the following standard python packages:
|
|
61
|
+
- `numpy`
|
|
62
|
+
- `pillow`
|
|
63
|
+
- `opencv-python`
|
|
64
|
+
- `scikit-learn`
|
|
65
|
+
- `reportlab`
|
|
66
|
+
- `scikit-image`
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
### Command Line Interface
|
|
73
|
+
|
|
74
|
+
You can convert any image directly from your terminal:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
strokemap input.jpg output.pdf --colors 20 --difficulty medium
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### CLI Options:
|
|
81
|
+
* `image_path` (required): Path to the input image file.
|
|
82
|
+
* `output_pdf` (required): Path where the final PDF should be saved.
|
|
83
|
+
* `-c`, `--colors` (optional): Target number of colors (default: 20).
|
|
84
|
+
* `-d`, `--difficulty` (optional): Level of region detail (`easy`, `medium`, `hard`) (default: `medium`).
|
|
85
|
+
|
|
86
|
+
### Python API
|
|
87
|
+
|
|
88
|
+
You can also use the package programmatically:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from strokemap import PaintByNumbersGenerator, generate_pdf
|
|
92
|
+
|
|
93
|
+
# 1. Initialize generator with difficulty settings
|
|
94
|
+
generator = PaintByNumbersGenerator(difficulty="medium")
|
|
95
|
+
|
|
96
|
+
# 2. Process image to get templates and palette
|
|
97
|
+
numbered_img, clean_img, colorized_img, palette = generator.process("input.jpg", n_colors=20)
|
|
98
|
+
|
|
99
|
+
# 3. Compile everything into a 4-page A4 PDF
|
|
100
|
+
generate_pdf(
|
|
101
|
+
output_pdf_path="output.pdf",
|
|
102
|
+
numbered_img=numbered_img,
|
|
103
|
+
clean_img=clean_img,
|
|
104
|
+
colorized_img=colorized_img,
|
|
105
|
+
palette=palette,
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Algorithms Used
|
|
112
|
+
|
|
113
|
+
1. **Superpixel Segmentation (SLIC)**: Uses the Simple Linear Iterative Clustering (SLIC) algorithm to cluster pixels into contiguous, edge-conforming "superpixels". This ensures that the boundaries of regions natively stick to the actual physical boundaries and details in the image (such as eyes, text, and fine lines).
|
|
114
|
+
2. **Color Quantization**: Performs K-Means clustering on the average colors of the superpixels in the CIELAB color space. Working in CIELAB space allows color distances to match human perception, resulting in a vibrant and accurate palette.
|
|
115
|
+
3. **Detail Reduction & Region Merging**: Small, hard-to-paint micro-regions are intelligently merged into their dominant neighbor using connected components analysis, with thresholds dynamically adjusted by the selected difficulty level.
|
|
116
|
+
4. **Outline Extraction**: Computes a pixel-wise transition grid to produce clean, single-pixel outlines.
|
|
117
|
+
5. **Optimal Label Placement**: Uses a distance transform (`cv2.distanceTransform`) to find the center of the largest inscribed circle within each region, placing number labels at the most readable point.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Authors
|
|
122
|
+
|
|
123
|
+
- Developer
|
|
124
|
+
|
|
125
|
+
For additional author and maintainer details, see [AUTHORS.md](AUTHORS.md).
|
|
126
|
+
|
|
127
|
+
## Contributors
|
|
128
|
+
|
|
129
|
+
This project is maintained by the community. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the current list of contributors and how to get involved.
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request.
|
|
134
|
+
|
|
135
|
+
## Security
|
|
136
|
+
|
|
137
|
+
If you discover a vulnerability, please do not publish it publicly. Refer to [SECURITY.md](SECURITY.md) for our security reporting policy.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Strokemap - Paint by Numbers Generator
|
|
2
|
+
|
|
3
|
+
A Python package and CLI tool to convert any image into a high-quality, print-ready "Paint by Numbers" PDF template.
|
|
4
|
+
|
|
5
|
+
The generated PDF matches premium standards, split into four cleanly laid-out pages:
|
|
6
|
+
1. **Page 1 - Numbered Template**: Light gray outlines with a small, centered index number in each region.
|
|
7
|
+
2. **Page 2 - Clean Outlines**: Clean black outlines without any numbers, perfect for clean canvas painting.
|
|
8
|
+
3. **Page 3 - Colorized Preview**: A color reference picture showing what the finished painting should look like.
|
|
9
|
+
4. **Page 4 - Color Palette Sheet**: A beautifully aligned grid of color swatches showing index numbers, hex codes, paint color blocks, and step-by-step instructions.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Preview
|
|
14
|
+
|
|
15
|
+
Here is an example of the generator's output using the standard **Lenna** test image:
|
|
16
|
+
|
|
17
|
+
| Original Image | Numbered Template (Page 1) | Clean Outlines (Page 2) | Colorized Preview (Page 3) |
|
|
18
|
+
| :---: | :---: | :---: | :---: |
|
|
19
|
+
|  |  |  |  |
|
|
20
|
+
|
|
21
|
+
> [!NOTE]
|
|
22
|
+
> **Image Citation**: Lenna (or Lena) is a standard digital image processing test image, originally from the USC-SIPI Image Database. It is widely used for testing image processing algorithms.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
Create a virtual environment and install the package locally:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python3 -m venv .venv
|
|
32
|
+
source .venv/bin/activate
|
|
33
|
+
pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Dependencies
|
|
37
|
+
The package relies on the following standard python packages:
|
|
38
|
+
- `numpy`
|
|
39
|
+
- `pillow`
|
|
40
|
+
- `opencv-python`
|
|
41
|
+
- `scikit-learn`
|
|
42
|
+
- `reportlab`
|
|
43
|
+
- `scikit-image`
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### Command Line Interface
|
|
50
|
+
|
|
51
|
+
You can convert any image directly from your terminal:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
strokemap input.jpg output.pdf --colors 20 --difficulty medium
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
#### CLI Options:
|
|
58
|
+
* `image_path` (required): Path to the input image file.
|
|
59
|
+
* `output_pdf` (required): Path where the final PDF should be saved.
|
|
60
|
+
* `-c`, `--colors` (optional): Target number of colors (default: 20).
|
|
61
|
+
* `-d`, `--difficulty` (optional): Level of region detail (`easy`, `medium`, `hard`) (default: `medium`).
|
|
62
|
+
|
|
63
|
+
### Python API
|
|
64
|
+
|
|
65
|
+
You can also use the package programmatically:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from strokemap import PaintByNumbersGenerator, generate_pdf
|
|
69
|
+
|
|
70
|
+
# 1. Initialize generator with difficulty settings
|
|
71
|
+
generator = PaintByNumbersGenerator(difficulty="medium")
|
|
72
|
+
|
|
73
|
+
# 2. Process image to get templates and palette
|
|
74
|
+
numbered_img, clean_img, colorized_img, palette = generator.process("input.jpg", n_colors=20)
|
|
75
|
+
|
|
76
|
+
# 3. Compile everything into a 4-page A4 PDF
|
|
77
|
+
generate_pdf(
|
|
78
|
+
output_pdf_path="output.pdf",
|
|
79
|
+
numbered_img=numbered_img,
|
|
80
|
+
clean_img=clean_img,
|
|
81
|
+
colorized_img=colorized_img,
|
|
82
|
+
palette=palette,
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Algorithms Used
|
|
89
|
+
|
|
90
|
+
1. **Superpixel Segmentation (SLIC)**: Uses the Simple Linear Iterative Clustering (SLIC) algorithm to cluster pixels into contiguous, edge-conforming "superpixels". This ensures that the boundaries of regions natively stick to the actual physical boundaries and details in the image (such as eyes, text, and fine lines).
|
|
91
|
+
2. **Color Quantization**: Performs K-Means clustering on the average colors of the superpixels in the CIELAB color space. Working in CIELAB space allows color distances to match human perception, resulting in a vibrant and accurate palette.
|
|
92
|
+
3. **Detail Reduction & Region Merging**: Small, hard-to-paint micro-regions are intelligently merged into their dominant neighbor using connected components analysis, with thresholds dynamically adjusted by the selected difficulty level.
|
|
93
|
+
4. **Outline Extraction**: Computes a pixel-wise transition grid to produce clean, single-pixel outlines.
|
|
94
|
+
5. **Optimal Label Placement**: Uses a distance transform (`cv2.distanceTransform`) to find the center of the largest inscribed circle within each region, placing number labels at the most readable point.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Authors
|
|
99
|
+
|
|
100
|
+
- Developer
|
|
101
|
+
|
|
102
|
+
For additional author and maintainer details, see [AUTHORS.md](AUTHORS.md).
|
|
103
|
+
|
|
104
|
+
## Contributors
|
|
105
|
+
|
|
106
|
+
This project is maintained by the community. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the current list of contributors and how to get involved.
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
|
|
110
|
+
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request.
|
|
111
|
+
|
|
112
|
+
## Security
|
|
113
|
+
|
|
114
|
+
If you discover a vulnerability, please do not publish it publicly. Refer to [SECURITY.md](SECURITY.md) for our security reporting policy.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "strokemap"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Convert any image into a Paint by Numbers printable PDF"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Developer", email = "developer@example.com" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"numpy",
|
|
17
|
+
"pillow",
|
|
18
|
+
"opencv-python",
|
|
19
|
+
"scikit-learn",
|
|
20
|
+
"reportlab",
|
|
21
|
+
"scikit-image",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=7.0",
|
|
27
|
+
"pytest-cov",
|
|
28
|
+
"pre-commit",
|
|
29
|
+
"ruff",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
strokemap = "strokemap.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
include = ["strokemap*"]
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Ruff – linter + formatter
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
line-length = 100
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = [
|
|
48
|
+
"E", # pycodestyle errors
|
|
49
|
+
"W", # pycodestyle warnings
|
|
50
|
+
"F", # pyflakes
|
|
51
|
+
"I", # isort
|
|
52
|
+
"B", # flake8-bugbear
|
|
53
|
+
"UP", # pyupgrade
|
|
54
|
+
"C4", # flake8-comprehensions
|
|
55
|
+
"SIM", # flake8-simplify
|
|
56
|
+
]
|
|
57
|
+
ignore = [
|
|
58
|
+
"E501", # line too long – handled by formatter
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint.isort]
|
|
62
|
+
known-first-party = ["strokemap"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.format]
|
|
65
|
+
quote-style = "double"
|
|
66
|
+
indent-style = "space"
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Pytest
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
testpaths = ["tests"]
|
|
73
|
+
addopts = "-v"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import traceback
|
|
5
|
+
|
|
6
|
+
from .generator import PaintByNumbersGenerator
|
|
7
|
+
from .pdf_generator import generate_pdf
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
description="Convert any image to a print-ready Paint by Numbers PDF."
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument("image_path", help="Path to the input image file (JPEG, PNG, etc.)")
|
|
15
|
+
parser.add_argument("output_pdf", help="Path where the output PDF should be saved")
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"-c",
|
|
18
|
+
"--colors",
|
|
19
|
+
type=int,
|
|
20
|
+
default=20,
|
|
21
|
+
help="Number of colors to use for the painting (default: 20)",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-d",
|
|
25
|
+
"--difficulty",
|
|
26
|
+
choices=["easy", "medium", "hard"],
|
|
27
|
+
default="medium",
|
|
28
|
+
help="Difficulty level which controls region sizes and details (default: medium)",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
if not os.path.exists(args.image_path):
|
|
34
|
+
print(f"Error: Input image file '{args.image_path}' does not exist.", file=sys.stderr)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
print(f"Processing image: {args.image_path}")
|
|
38
|
+
print(f"Difficulty level: {args.difficulty}")
|
|
39
|
+
print(f"Target colors: {args.colors}")
|
|
40
|
+
|
|
41
|
+
# Run the generator pipeline
|
|
42
|
+
generator = PaintByNumbersGenerator(difficulty=args.difficulty)
|
|
43
|
+
try:
|
|
44
|
+
numbered_img, clean_img, colorized_img, palette = generator.process(
|
|
45
|
+
args.image_path, args.colors
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
print(f"Generated outlines with {len(palette)} final colors.")
|
|
49
|
+
print(f"Compiling PDF to: {args.output_pdf}")
|
|
50
|
+
|
|
51
|
+
generate_pdf(
|
|
52
|
+
output_pdf_path=args.output_pdf,
|
|
53
|
+
numbered_img=numbered_img,
|
|
54
|
+
clean_img=clean_img,
|
|
55
|
+
colorized_img=colorized_img,
|
|
56
|
+
palette=palette,
|
|
57
|
+
)
|
|
58
|
+
print("Success! Paint by Numbers PDF generated successfully.")
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
print(f"Error generating Paint by Numbers: {e}", file=sys.stderr)
|
|
62
|
+
traceback.print_exc()
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
from skimage.segmentation import slic
|
|
7
|
+
from sklearn.cluster import KMeans
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PaintByNumbersGenerator:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
difficulty="medium",
|
|
14
|
+
outline_color=(180, 185, 200),
|
|
15
|
+
number_color=(100, 105, 115),
|
|
16
|
+
clean_outline_color=(0, 0, 0),
|
|
17
|
+
):
|
|
18
|
+
self.difficulty = difficulty.lower()
|
|
19
|
+
self.outline_color = outline_color
|
|
20
|
+
self.number_color = number_color
|
|
21
|
+
self.clean_outline_color = clean_outline_color
|
|
22
|
+
|
|
23
|
+
# Difficulty parameters
|
|
24
|
+
if self.difficulty == "easy":
|
|
25
|
+
self.n_segments_base = 800
|
|
26
|
+
self.slic_compactness = 5.0
|
|
27
|
+
self.slic_sigma = 3.0
|
|
28
|
+
self.min_area_fraction = 0.0005
|
|
29
|
+
elif self.difficulty == "hard":
|
|
30
|
+
self.n_segments_base = 4000
|
|
31
|
+
self.slic_compactness = 10.0
|
|
32
|
+
self.slic_sigma = 1.0
|
|
33
|
+
self.min_area_fraction = 0.00005
|
|
34
|
+
else: # medium
|
|
35
|
+
self.n_segments_base = 2000
|
|
36
|
+
self.slic_compactness = 8.0
|
|
37
|
+
self.slic_sigma = 2.0
|
|
38
|
+
self.min_area_fraction = 0.0002
|
|
39
|
+
|
|
40
|
+
def load_image(self, image_path):
|
|
41
|
+
"""Loads image, handles transparency, and returns RGB numpy array."""
|
|
42
|
+
# Read with cv2 (BGR)
|
|
43
|
+
img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
|
|
44
|
+
if img is None:
|
|
45
|
+
raise FileNotFoundError(f"Could not load image from {image_path}")
|
|
46
|
+
|
|
47
|
+
# Convert BGR/BGRA to RGB
|
|
48
|
+
if len(img.shape) == 3 and img.shape[2] == 4:
|
|
49
|
+
# BGRA to BGR blending with white background
|
|
50
|
+
alpha = img[:, :, 3:] / 255.0
|
|
51
|
+
img = (img[:, :, :3] * alpha + 255.0 * (1.0 - alpha)).astype(np.uint8)
|
|
52
|
+
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
53
|
+
elif len(img.shape) == 3 and img.shape[2] == 3:
|
|
54
|
+
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
|
55
|
+
else:
|
|
56
|
+
# Grayscale to RGB
|
|
57
|
+
img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
|
|
58
|
+
|
|
59
|
+
return img
|
|
60
|
+
|
|
61
|
+
def preprocess_image(self, img):
|
|
62
|
+
"""Applies median filtering to remove noise before segmentation."""
|
|
63
|
+
# Using a small median filter, cv2.medianBlur is very fast
|
|
64
|
+
ksize = 5 if self.difficulty == "easy" else 3
|
|
65
|
+
return cv2.medianBlur(img, ksize)
|
|
66
|
+
|
|
67
|
+
def quantize_colors(self, img, n_colors):
|
|
68
|
+
"""Segments image using SLIC superpixels and clusters their mean colors."""
|
|
69
|
+
h, w, c = img.shape
|
|
70
|
+
|
|
71
|
+
# Calculate number of segments based on image resolution
|
|
72
|
+
scale_factor = (h * w) / 1000000.0
|
|
73
|
+
n_segments = int(self.n_segments_base * max(0.5, scale_factor))
|
|
74
|
+
|
|
75
|
+
# 1. SLIC Superpixels
|
|
76
|
+
segments = slic(
|
|
77
|
+
img,
|
|
78
|
+
n_segments=n_segments,
|
|
79
|
+
compactness=self.slic_compactness,
|
|
80
|
+
sigma=self.slic_sigma,
|
|
81
|
+
start_label=0,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
num_segments = np.max(segments) + 1
|
|
85
|
+
|
|
86
|
+
# Convert to LAB space for perceptual operations
|
|
87
|
+
lab_img = cv2.cvtColor(img, cv2.COLOR_RGB2LAB)
|
|
88
|
+
|
|
89
|
+
# 2. Compute mean color for each segment
|
|
90
|
+
segment_means = np.zeros((num_segments, 3), dtype=np.float32)
|
|
91
|
+
|
|
92
|
+
# Fast mean computation using bincount
|
|
93
|
+
for channel in range(3):
|
|
94
|
+
channel_sums = np.bincount(
|
|
95
|
+
segments.ravel(), weights=lab_img[:, :, channel].ravel(), minlength=num_segments
|
|
96
|
+
)
|
|
97
|
+
pixel_counts = np.bincount(segments.ravel(), minlength=num_segments)
|
|
98
|
+
pixel_counts[pixel_counts == 0] = 1 # Avoid division by zero
|
|
99
|
+
segment_means[:, channel] = channel_sums / pixel_counts
|
|
100
|
+
|
|
101
|
+
# 3. KMeans clustering on the segment means
|
|
102
|
+
kmeans = KMeans(n_clusters=n_colors, random_state=42, n_init="auto")
|
|
103
|
+
segment_labels = kmeans.fit_predict(segment_means)
|
|
104
|
+
centers_lab = kmeans.cluster_centers_
|
|
105
|
+
|
|
106
|
+
# 4. Map superpixel labels back to image
|
|
107
|
+
labels_2d = segment_labels[segments]
|
|
108
|
+
|
|
109
|
+
# Build palette
|
|
110
|
+
palette = []
|
|
111
|
+
for i, center in enumerate(centers_lab):
|
|
112
|
+
center_pixel = center.reshape(1, 1, 3).astype(np.uint8)
|
|
113
|
+
rgb_pixel = cv2.cvtColor(center_pixel, cv2.COLOR_LAB2RGB)[0, 0]
|
|
114
|
+
rgb = (int(rgb_pixel[0]), int(rgb_pixel[1]), int(rgb_pixel[2]))
|
|
115
|
+
hex_code = f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
|
|
116
|
+
palette.append({"old_index": i, "rgb": rgb, "hex": hex_code})
|
|
117
|
+
|
|
118
|
+
# Sort palette by perceived brightness (luminance)
|
|
119
|
+
palette.sort(key=lambda x: 0.299 * x["rgb"][0] + 0.587 * x["rgb"][1] + 0.114 * x["rgb"][2])
|
|
120
|
+
|
|
121
|
+
# Create a remapping array for speed
|
|
122
|
+
remap_arr = np.zeros(n_colors, dtype=np.int32)
|
|
123
|
+
for new_idx, item in enumerate(palette):
|
|
124
|
+
remap_arr[item["old_index"]] = new_idx
|
|
125
|
+
|
|
126
|
+
labels_2d_sorted = remap_arr[labels_2d]
|
|
127
|
+
|
|
128
|
+
# Clean palette data structure
|
|
129
|
+
sorted_palette = [{"rgb": item["rgb"], "hex": item["hex"]} for item in palette]
|
|
130
|
+
|
|
131
|
+
return labels_2d_sorted, sorted_palette
|
|
132
|
+
|
|
133
|
+
def merge_small_regions(self, labels_2d, n_colors, min_area):
|
|
134
|
+
"""Iteratively merges components smaller than min_area into their largest neighbors."""
|
|
135
|
+
h, w = labels_2d.shape
|
|
136
|
+
result = labels_2d.copy()
|
|
137
|
+
|
|
138
|
+
iteration = 0
|
|
139
|
+
max_iterations = 10
|
|
140
|
+
|
|
141
|
+
while iteration < max_iterations:
|
|
142
|
+
changed = False
|
|
143
|
+
small_components = []
|
|
144
|
+
|
|
145
|
+
# Find connected components for each color index
|
|
146
|
+
unique_colors = np.unique(result)
|
|
147
|
+
for c in unique_colors:
|
|
148
|
+
mask = (result == c).astype(np.uint8)
|
|
149
|
+
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
|
150
|
+
mask, connectivity=4
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
for label_idx in range(1, num_labels):
|
|
154
|
+
area = stats[label_idx, cv2.CC_STAT_AREA]
|
|
155
|
+
if area < min_area:
|
|
156
|
+
comp_mask = labels == label_idx
|
|
157
|
+
small_components.append({"color": c, "area": area, "mask": comp_mask})
|
|
158
|
+
|
|
159
|
+
if not small_components:
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
# Sort by area ascending to merge smallest first
|
|
163
|
+
small_components.sort(key=lambda x: x["area"])
|
|
164
|
+
|
|
165
|
+
for comp in small_components:
|
|
166
|
+
comp_mask = comp["mask"]
|
|
167
|
+
current_vals = result[comp_mask]
|
|
168
|
+
if len(current_vals) == 0:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
val = current_vals[0]
|
|
172
|
+
if not np.all(current_vals == val):
|
|
173
|
+
# Component has already been modified in this iteration, skip
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Dilate mask to find neighbor pixels
|
|
177
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
|
178
|
+
dilated = cv2.dilate(comp_mask.astype(np.uint8), kernel)
|
|
179
|
+
border = (dilated == 1) & (~comp_mask)
|
|
180
|
+
|
|
181
|
+
neighbor_colors = result[border]
|
|
182
|
+
if len(neighbor_colors) == 0:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Count frequencies of neighboring colors
|
|
186
|
+
counts = np.bincount(neighbor_colors, minlength=val + 1)
|
|
187
|
+
counts[val] = 0 # Ignore current color to force merge with a neighbor
|
|
188
|
+
|
|
189
|
+
if np.max(counts) == 0:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
best_neighbor = np.argmax(counts)
|
|
193
|
+
result[comp_mask] = best_neighbor
|
|
194
|
+
changed = True
|
|
195
|
+
|
|
196
|
+
if not changed:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
iteration += 1
|
|
200
|
+
|
|
201
|
+
# Re-index remaining colors to start from 1 to N without gaps
|
|
202
|
+
unique_remaining = sorted(np.unique(result).tolist())
|
|
203
|
+
label_map = {old_lbl: new_lbl + 1 for new_lbl, old_lbl in enumerate(unique_remaining)}
|
|
204
|
+
|
|
205
|
+
final_labels = np.vectorize(label_map.get)(result)
|
|
206
|
+
|
|
207
|
+
return final_labels, unique_remaining
|
|
208
|
+
|
|
209
|
+
def get_outlines(self, final_labels):
|
|
210
|
+
"""Generates 1-pixel boundary edge map."""
|
|
211
|
+
h, w = final_labels.shape
|
|
212
|
+
edges = np.zeros((h, w), dtype=bool)
|
|
213
|
+
|
|
214
|
+
# Compare each pixel with its right and bottom neighbors
|
|
215
|
+
edges[:-1, :] |= final_labels[:-1, :] != final_labels[1:, :]
|
|
216
|
+
edges[:, :-1] |= final_labels[:, :-1] != final_labels[:, 1:]
|
|
217
|
+
|
|
218
|
+
# Add outer canvas border
|
|
219
|
+
edges[0, :] = True
|
|
220
|
+
edges[-1, :] = True
|
|
221
|
+
edges[:, 0] = True
|
|
222
|
+
edges[:, -1] = True
|
|
223
|
+
|
|
224
|
+
return edges
|
|
225
|
+
|
|
226
|
+
def generate_templates(self, final_labels, edges, num_colors):
|
|
227
|
+
"""Creates the numbered template and the clean outlines template images."""
|
|
228
|
+
h, w = final_labels.shape
|
|
229
|
+
|
|
230
|
+
# Determine scaling for drawing (lines, fonts)
|
|
231
|
+
max_dim = max(h, w)
|
|
232
|
+
outline_thickness = max(1, int(max_dim / 1500))
|
|
233
|
+
font_size = max(8, int(max_dim * 0.0055))
|
|
234
|
+
|
|
235
|
+
# Build dilated edges for template drawing
|
|
236
|
+
if outline_thickness > 1:
|
|
237
|
+
kernel = cv2.getStructuringElement(
|
|
238
|
+
cv2.MORPH_RECT, (outline_thickness, outline_thickness)
|
|
239
|
+
)
|
|
240
|
+
edges_drawn = cv2.dilate(edges.astype(np.uint8), kernel) > 0
|
|
241
|
+
else:
|
|
242
|
+
edges_drawn = edges
|
|
243
|
+
|
|
244
|
+
# 1. Clean outline image (solid black lines on white)
|
|
245
|
+
clean_np = np.ones((h, w, 3), dtype=np.uint8) * 255
|
|
246
|
+
clean_np[edges_drawn] = self.clean_outline_color
|
|
247
|
+
clean_img = Image.fromarray(clean_np)
|
|
248
|
+
|
|
249
|
+
# 2. Numbered outline image (gray lines on white)
|
|
250
|
+
numbered_np = np.ones((h, w, 3), dtype=np.uint8) * 255
|
|
251
|
+
numbered_np[edges_drawn] = self.outline_color
|
|
252
|
+
numbered_img = Image.fromarray(numbered_np)
|
|
253
|
+
|
|
254
|
+
draw = ImageDraw.Draw(numbered_img)
|
|
255
|
+
|
|
256
|
+
# Load clean sans-serif font
|
|
257
|
+
font = None
|
|
258
|
+
font_paths = [
|
|
259
|
+
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
260
|
+
"/System/Library/Fonts/Helvetica.ttc",
|
|
261
|
+
"/System/Library/Fonts/SFNS.ttf",
|
|
262
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
263
|
+
"C:\\Windows\\Fonts\\arial.ttf",
|
|
264
|
+
]
|
|
265
|
+
for path in font_paths:
|
|
266
|
+
if os.path.exists(path):
|
|
267
|
+
try:
|
|
268
|
+
font = ImageFont.truetype(path, size=font_size)
|
|
269
|
+
break
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
if font is None:
|
|
273
|
+
font = ImageFont.load_default()
|
|
274
|
+
|
|
275
|
+
min_dist_to_draw = font_size * 0.45
|
|
276
|
+
|
|
277
|
+
# Place numbers using distance transform on connected components
|
|
278
|
+
for v in range(1, num_colors + 1):
|
|
279
|
+
mask = (final_labels == v).astype(np.uint8)
|
|
280
|
+
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
|
281
|
+
mask, connectivity=8
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
for i in range(1, num_labels):
|
|
285
|
+
comp_mask = (labels == i).astype(np.uint8)
|
|
286
|
+
|
|
287
|
+
# Compute distance transform of component mask
|
|
288
|
+
dist_transform = cv2.distanceTransform(comp_mask, cv2.DIST_L2, 5)
|
|
289
|
+
_, max_val, _, max_loc = cv2.minMaxLoc(dist_transform)
|
|
290
|
+
|
|
291
|
+
# Only write number if it fits nicely inside the region
|
|
292
|
+
if max_val >= min_dist_to_draw:
|
|
293
|
+
cx, cy = max_loc
|
|
294
|
+
text_str = str(v)
|
|
295
|
+
|
|
296
|
+
# Center the text
|
|
297
|
+
bbox = draw.textbbox((0, 0), text_str, font=font)
|
|
298
|
+
text_w = bbox[2] - bbox[0]
|
|
299
|
+
text_h = bbox[3] - bbox[1]
|
|
300
|
+
|
|
301
|
+
text_x = cx - text_w / 2
|
|
302
|
+
text_y = cy - text_h / 2
|
|
303
|
+
|
|
304
|
+
draw.text((text_x, text_y), text_str, fill=self.number_color, font=font)
|
|
305
|
+
|
|
306
|
+
return numbered_img, clean_img
|
|
307
|
+
|
|
308
|
+
def process(self, image_path, n_colors):
|
|
309
|
+
"""Runs the entire pipeline on the input image and returns output images and palette."""
|
|
310
|
+
# 1. Load & preprocess
|
|
311
|
+
img = self.load_image(image_path)
|
|
312
|
+
smoothed = self.preprocess_image(img)
|
|
313
|
+
|
|
314
|
+
# 2. Quantize
|
|
315
|
+
labels_2d, sorted_palette = self.quantize_colors(smoothed, n_colors)
|
|
316
|
+
|
|
317
|
+
# Smooth the initial labels to create curvy, organic boundaries
|
|
318
|
+
h, w = labels_2d.shape
|
|
319
|
+
k_size = max(5, int(max(h, w) * 0.005))
|
|
320
|
+
if k_size % 2 == 0:
|
|
321
|
+
k_size += 1
|
|
322
|
+
labels_2d = cv2.medianBlur(labels_2d.astype(np.uint8), k_size).astype(np.int32)
|
|
323
|
+
|
|
324
|
+
# 3. Merge small regions
|
|
325
|
+
min_area = int(h * w * self.min_area_fraction)
|
|
326
|
+
final_labels, unique_remaining = self.merge_small_regions(labels_2d, n_colors, min_area)
|
|
327
|
+
|
|
328
|
+
# 4. Filter palette to remaining colors
|
|
329
|
+
final_palette = []
|
|
330
|
+
for i, old_idx in enumerate(unique_remaining):
|
|
331
|
+
color_info = sorted_palette[old_idx]
|
|
332
|
+
final_palette.append(
|
|
333
|
+
{"index": i + 1, "rgb": color_info["rgb"], "hex": color_info["hex"]}
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
num_final_colors = len(final_palette)
|
|
337
|
+
|
|
338
|
+
# 5. Extract outlines and draw templates
|
|
339
|
+
edges = self.get_outlines(final_labels)
|
|
340
|
+
numbered_img, clean_img = self.generate_templates(final_labels, edges, num_final_colors)
|
|
341
|
+
|
|
342
|
+
# 6. Build colorized preview image
|
|
343
|
+
colorized_np = np.zeros((h, w, 3), dtype=np.uint8)
|
|
344
|
+
for color_info in final_palette:
|
|
345
|
+
idx = color_info["index"]
|
|
346
|
+
rgb = color_info["rgb"]
|
|
347
|
+
colorized_np[final_labels == idx] = rgb
|
|
348
|
+
colorized_img = Image.fromarray(colorized_np)
|
|
349
|
+
|
|
350
|
+
return numbered_img, clean_img, colorized_img, final_palette
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
from reportlab.lib.colors import HexColor
|
|
4
|
+
from reportlab.lib.enums import TA_CENTER
|
|
5
|
+
from reportlab.lib.pagesizes import A4
|
|
6
|
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
|
7
|
+
from reportlab.platypus import Image as RLImage
|
|
8
|
+
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
|
9
|
+
from reportlab.platypus.flowables import Flowable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ColorSwatch(Flowable):
|
|
13
|
+
def __init__(self, index, rgb, hex_code, width=80, height=70):
|
|
14
|
+
super().__init__()
|
|
15
|
+
self.index = index
|
|
16
|
+
self.rgb = rgb # tuple (r, g, b)
|
|
17
|
+
self.hex_code = hex_code
|
|
18
|
+
self.width = width
|
|
19
|
+
self.height = height
|
|
20
|
+
|
|
21
|
+
def wrap(self, availWidth, availHeight):
|
|
22
|
+
return self.width, self.height
|
|
23
|
+
|
|
24
|
+
def draw(self):
|
|
25
|
+
# Draw color box
|
|
26
|
+
# Normalize color to 0.0 - 1.0 for reportlab
|
|
27
|
+
r, g, b = (val / 255.0 for val in self.rgb)
|
|
28
|
+
self.canv.setFillColorRGB(r, g, b)
|
|
29
|
+
self.canv.setStrokeColorRGB(0.1, 0.1, 0.1)
|
|
30
|
+
self.canv.setLineWidth(1)
|
|
31
|
+
|
|
32
|
+
# Center square horizontally
|
|
33
|
+
square_size = 36
|
|
34
|
+
sq_x = (self.width - square_size) / 2
|
|
35
|
+
sq_y = self.height - square_size - 2
|
|
36
|
+
|
|
37
|
+
self.canv.rect(sq_x, sq_y, square_size, square_size, fill=1, stroke=1)
|
|
38
|
+
|
|
39
|
+
# Draw color index number
|
|
40
|
+
self.canv.setFont("Helvetica-Bold", 10)
|
|
41
|
+
self.canv.setFillColorRGB(0.1, 0.1, 0.1)
|
|
42
|
+
self.canv.drawCentredString(self.width / 2, sq_y - 12, str(self.index))
|
|
43
|
+
|
|
44
|
+
# Draw Hex Code
|
|
45
|
+
self.canv.setFont("Helvetica", 8)
|
|
46
|
+
self.canv.setFillColorRGB(0.4, 0.4, 0.4)
|
|
47
|
+
self.canv.drawCentredString(self.width / 2, sq_y - 24, self.hex_code)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate_pdf(
|
|
51
|
+
output_pdf_path,
|
|
52
|
+
numbered_img,
|
|
53
|
+
clean_img,
|
|
54
|
+
colorized_img,
|
|
55
|
+
palette,
|
|
56
|
+
):
|
|
57
|
+
# Margins and page size
|
|
58
|
+
margin = 36 # 0.5 inch
|
|
59
|
+
doc = SimpleDocTemplate(
|
|
60
|
+
output_pdf_path,
|
|
61
|
+
pagesize=A4,
|
|
62
|
+
leftMargin=margin,
|
|
63
|
+
rightMargin=margin,
|
|
64
|
+
topMargin=margin,
|
|
65
|
+
bottomMargin=margin,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
page_w, page_h = A4
|
|
69
|
+
max_w = page_w - 2 * margin
|
|
70
|
+
max_h = page_h - 2 * margin
|
|
71
|
+
|
|
72
|
+
story = []
|
|
73
|
+
|
|
74
|
+
# ----------------------------------------------------
|
|
75
|
+
# Page 1: Numbered Template Image
|
|
76
|
+
# ----------------------------------------------------
|
|
77
|
+
num_buf = io.BytesIO()
|
|
78
|
+
numbered_img.save(num_buf, format="PNG")
|
|
79
|
+
num_buf.seek(0)
|
|
80
|
+
|
|
81
|
+
# Calculate dimensions to maintain aspect ratio
|
|
82
|
+
img_w, img_h = numbered_img.size
|
|
83
|
+
aspect_ratio = img_w / img_h
|
|
84
|
+
|
|
85
|
+
if max_w / aspect_ratio <= max_h:
|
|
86
|
+
draw_w = max_w
|
|
87
|
+
draw_h = max_w / aspect_ratio
|
|
88
|
+
else:
|
|
89
|
+
draw_h = max_h
|
|
90
|
+
draw_w = max_h * aspect_ratio
|
|
91
|
+
|
|
92
|
+
# Spacer to vertically center the image on the page
|
|
93
|
+
v_spacer_1 = (max_h - draw_h) / 2
|
|
94
|
+
if v_spacer_1 > 0:
|
|
95
|
+
story.append(Spacer(1, v_spacer_1))
|
|
96
|
+
|
|
97
|
+
story.append(RLImage(num_buf, width=draw_w, height=draw_h))
|
|
98
|
+
story.append(PageBreak())
|
|
99
|
+
|
|
100
|
+
# ----------------------------------------------------
|
|
101
|
+
# Page 2: Clean Outline Image
|
|
102
|
+
# ----------------------------------------------------
|
|
103
|
+
clean_buf = io.BytesIO()
|
|
104
|
+
clean_img.save(clean_buf, format="PNG")
|
|
105
|
+
clean_buf.seek(0)
|
|
106
|
+
|
|
107
|
+
v_spacer_2 = (max_h - draw_h) / 2
|
|
108
|
+
if v_spacer_2 > 0:
|
|
109
|
+
story.append(Spacer(1, v_spacer_2))
|
|
110
|
+
|
|
111
|
+
story.append(RLImage(clean_buf, width=draw_w, height=draw_h))
|
|
112
|
+
story.append(PageBreak())
|
|
113
|
+
|
|
114
|
+
# ----------------------------------------------------
|
|
115
|
+
# Page 3: Color Preview Image (How it should look)
|
|
116
|
+
# ----------------------------------------------------
|
|
117
|
+
preview_buf = io.BytesIO()
|
|
118
|
+
colorized_img.save(preview_buf, format="PNG")
|
|
119
|
+
preview_buf.seek(0)
|
|
120
|
+
|
|
121
|
+
v_spacer_3 = (max_h - draw_h) / 2
|
|
122
|
+
if v_spacer_3 > 0:
|
|
123
|
+
story.append(Spacer(1, v_spacer_3))
|
|
124
|
+
|
|
125
|
+
story.append(RLImage(preview_buf, width=draw_w, height=draw_h))
|
|
126
|
+
story.append(PageBreak())
|
|
127
|
+
|
|
128
|
+
# ----------------------------------------------------
|
|
129
|
+
# Page 4: Palette Sheet
|
|
130
|
+
# ----------------------------------------------------
|
|
131
|
+
styles = getSampleStyleSheet()
|
|
132
|
+
|
|
133
|
+
# Custom typography styles
|
|
134
|
+
title_style = ParagraphStyle(
|
|
135
|
+
"PaletteTitle",
|
|
136
|
+
parent=styles["Normal"],
|
|
137
|
+
fontName="Helvetica-Bold",
|
|
138
|
+
fontSize=26,
|
|
139
|
+
leading=32,
|
|
140
|
+
alignment=TA_CENTER,
|
|
141
|
+
spaceAfter=6,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
instruction_title_style = ParagraphStyle(
|
|
145
|
+
"InstructionTitle",
|
|
146
|
+
parent=styles["Normal"],
|
|
147
|
+
fontName="Helvetica-Bold",
|
|
148
|
+
fontSize=11,
|
|
149
|
+
leading=15,
|
|
150
|
+
spaceAfter=6,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
instruction_body_style = ParagraphStyle(
|
|
154
|
+
"InstructionBody",
|
|
155
|
+
parent=styles["Normal"],
|
|
156
|
+
fontName="Helvetica",
|
|
157
|
+
fontSize=9.5,
|
|
158
|
+
leading=14,
|
|
159
|
+
textColor=HexColor("#333333"),
|
|
160
|
+
spaceAfter=4,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Title
|
|
164
|
+
story.append(Paragraph("Strokemap", title_style))
|
|
165
|
+
|
|
166
|
+
# Color palette grid
|
|
167
|
+
num_cols = 6
|
|
168
|
+
col_w = max_w / num_cols
|
|
169
|
+
|
|
170
|
+
swatches = []
|
|
171
|
+
for color in palette:
|
|
172
|
+
swatches.append(
|
|
173
|
+
ColorSwatch(
|
|
174
|
+
index=color["index"],
|
|
175
|
+
rgb=color["rgb"],
|
|
176
|
+
hex_code=color["hex"],
|
|
177
|
+
width=col_w,
|
|
178
|
+
height=70,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Arrange into grid matrix
|
|
183
|
+
table_data = []
|
|
184
|
+
current_row = []
|
|
185
|
+
for _, swatch in enumerate(swatches):
|
|
186
|
+
current_row.append(swatch)
|
|
187
|
+
if len(current_row) == num_cols:
|
|
188
|
+
table_data.append(current_row)
|
|
189
|
+
current_row = []
|
|
190
|
+
if current_row:
|
|
191
|
+
# Pad last row with empty flowables
|
|
192
|
+
while len(current_row) < num_cols:
|
|
193
|
+
current_row.append("")
|
|
194
|
+
table_data.append(current_row)
|
|
195
|
+
|
|
196
|
+
palette_table = Table(table_data, colWidths=[col_w] * num_cols)
|
|
197
|
+
palette_table.setStyle(
|
|
198
|
+
TableStyle(
|
|
199
|
+
[
|
|
200
|
+
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
201
|
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
202
|
+
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
|
203
|
+
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
|
204
|
+
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
205
|
+
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
story.append(palette_table)
|
|
211
|
+
story.append(Spacer(1, 24))
|
|
212
|
+
|
|
213
|
+
# Instructions at the bottom
|
|
214
|
+
story.append(Paragraph("Instructions:", instruction_title_style))
|
|
215
|
+
|
|
216
|
+
bullets = [
|
|
217
|
+
"<b>Page 1 - Numbered template:</b> Find numbered areas and match to colors on page 4.",
|
|
218
|
+
"<b>Page 2 - Clean borders:</b> For those who want a clean painting experience.",
|
|
219
|
+
"<b>Page 3 - Colored preview:</b> A reference picture showing how the final painting looks.",
|
|
220
|
+
"1. Choose your preferred template (numbered or clean borders).",
|
|
221
|
+
"2. Match the numbers to the colors on the palette sheet (page 4).",
|
|
222
|
+
"3. Paint each area with the corresponding color.",
|
|
223
|
+
"4. Use the colored preview page as a reference.",
|
|
224
|
+
"5. Take your time and enjoy the process!",
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
for bullet in bullets:
|
|
228
|
+
story.append(Paragraph(bullet, instruction_body_style))
|
|
229
|
+
|
|
230
|
+
# Build Document
|
|
231
|
+
doc.build(story)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: strokemap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert any image into a Paint by Numbers printable PDF
|
|
5
|
+
Author-email: Developer <developer@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: AUTHORS.md
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: pillow
|
|
13
|
+
Requires-Dist: opencv-python
|
|
14
|
+
Requires-Dist: scikit-learn
|
|
15
|
+
Requires-Dist: reportlab
|
|
16
|
+
Requires-Dist: scikit-image
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
20
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Strokemap - Paint by Numbers Generator
|
|
25
|
+
|
|
26
|
+
A Python package and CLI tool to convert any image into a high-quality, print-ready "Paint by Numbers" PDF template.
|
|
27
|
+
|
|
28
|
+
The generated PDF matches premium standards, split into four cleanly laid-out pages:
|
|
29
|
+
1. **Page 1 - Numbered Template**: Light gray outlines with a small, centered index number in each region.
|
|
30
|
+
2. **Page 2 - Clean Outlines**: Clean black outlines without any numbers, perfect for clean canvas painting.
|
|
31
|
+
3. **Page 3 - Colorized Preview**: A color reference picture showing what the finished painting should look like.
|
|
32
|
+
4. **Page 4 - Color Palette Sheet**: A beautifully aligned grid of color swatches showing index numbers, hex codes, paint color blocks, and step-by-step instructions.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Preview
|
|
37
|
+
|
|
38
|
+
Here is an example of the generator's output using the standard **Lenna** test image:
|
|
39
|
+
|
|
40
|
+
| Original Image | Numbered Template (Page 1) | Clean Outlines (Page 2) | Colorized Preview (Page 3) |
|
|
41
|
+
| :---: | :---: | :---: | :---: |
|
|
42
|
+
|  |  |  |  |
|
|
43
|
+
|
|
44
|
+
> [!NOTE]
|
|
45
|
+
> **Image Citation**: Lenna (or Lena) is a standard digital image processing test image, originally from the USC-SIPI Image Database. It is widely used for testing image processing algorithms.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Create a virtual environment and install the package locally:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python3 -m venv .venv
|
|
55
|
+
source .venv/bin/activate
|
|
56
|
+
pip install -e .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Dependencies
|
|
60
|
+
The package relies on the following standard python packages:
|
|
61
|
+
- `numpy`
|
|
62
|
+
- `pillow`
|
|
63
|
+
- `opencv-python`
|
|
64
|
+
- `scikit-learn`
|
|
65
|
+
- `reportlab`
|
|
66
|
+
- `scikit-image`
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
### Command Line Interface
|
|
73
|
+
|
|
74
|
+
You can convert any image directly from your terminal:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
strokemap input.jpg output.pdf --colors 20 --difficulty medium
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### CLI Options:
|
|
81
|
+
* `image_path` (required): Path to the input image file.
|
|
82
|
+
* `output_pdf` (required): Path where the final PDF should be saved.
|
|
83
|
+
* `-c`, `--colors` (optional): Target number of colors (default: 20).
|
|
84
|
+
* `-d`, `--difficulty` (optional): Level of region detail (`easy`, `medium`, `hard`) (default: `medium`).
|
|
85
|
+
|
|
86
|
+
### Python API
|
|
87
|
+
|
|
88
|
+
You can also use the package programmatically:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from strokemap import PaintByNumbersGenerator, generate_pdf
|
|
92
|
+
|
|
93
|
+
# 1. Initialize generator with difficulty settings
|
|
94
|
+
generator = PaintByNumbersGenerator(difficulty="medium")
|
|
95
|
+
|
|
96
|
+
# 2. Process image to get templates and palette
|
|
97
|
+
numbered_img, clean_img, colorized_img, palette = generator.process("input.jpg", n_colors=20)
|
|
98
|
+
|
|
99
|
+
# 3. Compile everything into a 4-page A4 PDF
|
|
100
|
+
generate_pdf(
|
|
101
|
+
output_pdf_path="output.pdf",
|
|
102
|
+
numbered_img=numbered_img,
|
|
103
|
+
clean_img=clean_img,
|
|
104
|
+
colorized_img=colorized_img,
|
|
105
|
+
palette=palette,
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Algorithms Used
|
|
112
|
+
|
|
113
|
+
1. **Superpixel Segmentation (SLIC)**: Uses the Simple Linear Iterative Clustering (SLIC) algorithm to cluster pixels into contiguous, edge-conforming "superpixels". This ensures that the boundaries of regions natively stick to the actual physical boundaries and details in the image (such as eyes, text, and fine lines).
|
|
114
|
+
2. **Color Quantization**: Performs K-Means clustering on the average colors of the superpixels in the CIELAB color space. Working in CIELAB space allows color distances to match human perception, resulting in a vibrant and accurate palette.
|
|
115
|
+
3. **Detail Reduction & Region Merging**: Small, hard-to-paint micro-regions are intelligently merged into their dominant neighbor using connected components analysis, with thresholds dynamically adjusted by the selected difficulty level.
|
|
116
|
+
4. **Outline Extraction**: Computes a pixel-wise transition grid to produce clean, single-pixel outlines.
|
|
117
|
+
5. **Optimal Label Placement**: Uses a distance transform (`cv2.distanceTransform`) to find the center of the largest inscribed circle within each region, placing number labels at the most readable point.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Authors
|
|
122
|
+
|
|
123
|
+
- Developer
|
|
124
|
+
|
|
125
|
+
For additional author and maintainer details, see [AUTHORS.md](AUTHORS.md).
|
|
126
|
+
|
|
127
|
+
## Contributors
|
|
128
|
+
|
|
129
|
+
This project is maintained by the community. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the current list of contributors and how to get involved.
|
|
130
|
+
|
|
131
|
+
## Contributing
|
|
132
|
+
|
|
133
|
+
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an issue or pull request.
|
|
134
|
+
|
|
135
|
+
## Security
|
|
136
|
+
|
|
137
|
+
If you discover a vulnerability, please do not publish it publicly. Refer to [SECURITY.md](SECURITY.md) for our security reporting policy.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
AUTHORS.md
|
|
2
|
+
LICENSE
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
src/strokemap/__init__.py
|
|
6
|
+
src/strokemap/cli.py
|
|
7
|
+
src/strokemap/generator.py
|
|
8
|
+
src/strokemap/pdf_generator.py
|
|
9
|
+
src/strokemap.egg-info/PKG-INFO
|
|
10
|
+
src/strokemap.egg-info/SOURCES.txt
|
|
11
|
+
src/strokemap.egg-info/dependency_links.txt
|
|
12
|
+
src/strokemap.egg-info/entry_points.txt
|
|
13
|
+
src/strokemap.egg-info/requires.txt
|
|
14
|
+
src/strokemap.egg-info/top_level.txt
|
|
15
|
+
tests/test_generator.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
strokemap
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit and integration tests for strokemap.generator.
|
|
3
|
+
|
|
4
|
+
The Lenna (Lena) standard test image (512×512) is used as the canonical sample input.
|
|
5
|
+
Source: USC SIPI Image Database – http://sipi.usc.edu/database/
|
|
6
|
+
Image: assets/lenna.png
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from strokemap.generator import PaintByNumbersGenerator
|
|
15
|
+
|
|
16
|
+
# Path to the canonical test asset – lives in tests/assets/
|
|
17
|
+
ASSETS_DIR = Path(__file__).parent / "assets"
|
|
18
|
+
LENNA_PATH = ASSETS_DIR / "lenna.png"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Fixtures
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(scope="module")
|
|
27
|
+
def lenna_rgb():
|
|
28
|
+
"""Load Lenna once per test module."""
|
|
29
|
+
gen = PaintByNumbersGenerator()
|
|
30
|
+
return gen.load_image(str(LENNA_PATH))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(scope="module")
|
|
34
|
+
def generator_medium():
|
|
35
|
+
return PaintByNumbersGenerator(difficulty="medium")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Initialisation tests
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestPaintByNumbersGeneratorInit:
|
|
44
|
+
"""Tests for correct initialization of PaintByNumbersGenerator."""
|
|
45
|
+
|
|
46
|
+
def test_default_difficulty_is_medium(self):
|
|
47
|
+
gen = PaintByNumbersGenerator()
|
|
48
|
+
assert gen.difficulty == "medium"
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize("difficulty", ["easy", "medium", "hard"])
|
|
51
|
+
def test_valid_difficulties_accepted(self, difficulty):
|
|
52
|
+
gen = PaintByNumbersGenerator(difficulty=difficulty)
|
|
53
|
+
assert gen.difficulty == difficulty
|
|
54
|
+
|
|
55
|
+
def test_slic_params_set_for_medium(self):
|
|
56
|
+
gen = PaintByNumbersGenerator(difficulty="medium")
|
|
57
|
+
assert gen.n_segments_base > 0
|
|
58
|
+
assert gen.slic_compactness > 0
|
|
59
|
+
assert gen.slic_sigma > 0
|
|
60
|
+
|
|
61
|
+
def test_slic_params_set_for_easy(self):
|
|
62
|
+
gen = PaintByNumbersGenerator(difficulty="easy")
|
|
63
|
+
gen_medium = PaintByNumbersGenerator(difficulty="medium")
|
|
64
|
+
# Easy should produce fewer superpixels (simpler output)
|
|
65
|
+
assert gen.n_segments_base < gen_medium.n_segments_base
|
|
66
|
+
|
|
67
|
+
def test_slic_params_set_for_hard(self):
|
|
68
|
+
gen = PaintByNumbersGenerator(difficulty="hard")
|
|
69
|
+
gen_medium = PaintByNumbersGenerator(difficulty="medium")
|
|
70
|
+
# Hard should produce more superpixels (more detailed output)
|
|
71
|
+
assert gen.n_segments_base > gen_medium.n_segments_base
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# load_image tests
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestLoadImage:
|
|
80
|
+
def test_lenna_loads_correctly(self, lenna_rgb):
|
|
81
|
+
assert lenna_rgb.shape == (512, 512, 3)
|
|
82
|
+
assert lenna_rgb.dtype == np.uint8
|
|
83
|
+
|
|
84
|
+
def test_raises_on_missing_file(self):
|
|
85
|
+
gen = PaintByNumbersGenerator()
|
|
86
|
+
with pytest.raises(FileNotFoundError):
|
|
87
|
+
gen.load_image("does_not_exist.jpg")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# preprocess_image tests
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestPreprocessImage:
|
|
96
|
+
def test_returns_same_shape(self, lenna_rgb, generator_medium):
|
|
97
|
+
result = generator_medium.preprocess_image(lenna_rgb)
|
|
98
|
+
assert result.shape == lenna_rgb.shape
|
|
99
|
+
|
|
100
|
+
def test_returns_uint8(self, lenna_rgb, generator_medium):
|
|
101
|
+
result = generator_medium.preprocess_image(lenna_rgb)
|
|
102
|
+
assert result.dtype == np.uint8
|
|
103
|
+
|
|
104
|
+
def test_output_differs_from_input(self, lenna_rgb, generator_medium):
|
|
105
|
+
"""Median blur should change at least some pixel values."""
|
|
106
|
+
result = generator_medium.preprocess_image(lenna_rgb)
|
|
107
|
+
assert not np.array_equal(result, lenna_rgb)
|