fotolab 0.28.6__tar.gz → 0.29.1__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.
- {fotolab-0.28.6 → fotolab-0.29.1}/CHANGELOG.md +17 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/PKG-INFO +4 -4
- {fotolab-0.28.6 → fotolab-0.29.1}/Pipfile.lock +1 -1
- {fotolab-0.28.6 → fotolab-0.29.1}/README.md +3 -3
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/__init__.py +1 -1
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/cli.py +15 -6
- fotolab-0.29.1/fotolab/subcommands/halftone.py +229 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/info.py +35 -17
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/watermark.py +11 -3
- fotolab-0.28.6/fotolab/subcommands/halftone.py +0 -145
- {fotolab-0.28.6 → fotolab-0.29.1}/.coveragerc +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/.gitignore +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/.pre-commit-config.yaml +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/.python-version +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/CONTRIBUTING.md +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/LICENSE.md +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/Pipfile +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/Makefile +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/make.bat +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/source/CHANGELOG.md +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/source/CONTRIBUTING.md +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/source/LICENSE.md +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/source/README.md +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/source/_static/logo.jpg +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/source/conf.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/docs/source/index.rst +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/__main__.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/__init__.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/animate.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/auto.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/border.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/contrast.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/env.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/montage.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/resize.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/rotate.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/fotolab/subcommands/sharpen.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/generate +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/noxfile.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/pyproject.toml +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/tests/__init__.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/tests/conftest.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/tests/test_env_subcommand.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/tests/test_help_flag.py +0 -0
- {fotolab-0.28.6 → fotolab-0.29.1}/tests/test_quiet_flag.py +0 -0
@@ -7,6 +7,23 @@ and this project adheres to [0-based versioning](https://0ver.org/).
|
|
7
7
|
|
8
8
|
## [Unreleased]
|
9
9
|
|
10
|
+
## v0.29.1 (2025-05-04)
|
11
|
+
|
12
|
+
- Improve camera metadata handling in watermark subcommand.
|
13
|
+
- Refactor halftone dot drawing into a separate function for clarity
|
14
|
+
- Refactor halftone dot drawing to use NamedTuple for cell data
|
15
|
+
- Refactor info subcommand to handle specific metadata requests.
|
16
|
+
- Update help message in readme
|
17
|
+
|
18
|
+
## v0.29.0 (2025-04-27)
|
19
|
+
|
20
|
+
- Add `cells` argument to halftone subcommand and function
|
21
|
+
- Add grayscale option to halftone subcommand for grayscale conversion
|
22
|
+
- Bump deps
|
23
|
+
- Calculate dot radius relative to cell size based on brightness.
|
24
|
+
- Refactor CLI to handle missing subcommand execution function gracefully
|
25
|
+
- Require a subcommand to be specified and remove redundant checks
|
26
|
+
|
10
27
|
## v0.28.6 (2025-04-20)
|
11
28
|
|
12
29
|
- Add missing typehints for `watermark` subcommand
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: fotolab
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.29.1
|
4
4
|
Summary: A console program that manipulate images.
|
5
5
|
Keywords: photography,photo
|
6
6
|
Author-email: Kian-Meng Ang <kianmeng@cpan.org>
|
@@ -75,7 +75,7 @@ positional arguments:
|
|
75
75
|
animate animate an image
|
76
76
|
auto auto adjust (resize, contrast, and watermark) a photo
|
77
77
|
border add border to image
|
78
|
-
contrast contrast an image
|
78
|
+
contrast contrast an image.
|
79
79
|
env print environment information for bug reporting
|
80
80
|
halftone halftone an image
|
81
81
|
info info an image
|
@@ -200,8 +200,8 @@ positional arguments:
|
|
200
200
|
|
201
201
|
options:
|
202
202
|
-h, --help show this help message and exit
|
203
|
-
-c, --cutoff CUTOFF set the percentage of lightest or darkest
|
204
|
-
discard from histogram (default: '1')
|
203
|
+
-c, --cutoff CUTOFF set the percentage (0-50) of lightest or darkest
|
204
|
+
pixels to discard from histogram (default: '1.0')
|
205
205
|
-op, --open open the image using default program (default:
|
206
206
|
'False')
|
207
207
|
-od, --output-dir OUTPUT_DIR
|
@@ -52,7 +52,7 @@ positional arguments:
|
|
52
52
|
animate animate an image
|
53
53
|
auto auto adjust (resize, contrast, and watermark) a photo
|
54
54
|
border add border to image
|
55
|
-
contrast contrast an image
|
55
|
+
contrast contrast an image.
|
56
56
|
env print environment information for bug reporting
|
57
57
|
halftone halftone an image
|
58
58
|
info info an image
|
@@ -177,8 +177,8 @@ positional arguments:
|
|
177
177
|
|
178
178
|
options:
|
179
179
|
-h, --help show this help message and exit
|
180
|
-
-c, --cutoff CUTOFF set the percentage of lightest or darkest
|
181
|
-
discard from histogram (default: '1')
|
180
|
+
-c, --cutoff CUTOFF set the percentage (0-50) of lightest or darkest
|
181
|
+
pixels to discard from histogram (default: '1.0')
|
182
182
|
-op, --open open the image using default program (default:
|
183
183
|
'False')
|
184
184
|
-od, --output-dir OUTPUT_DIR
|
@@ -115,6 +115,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
115
115
|
subparsers = parser.add_subparsers(
|
116
116
|
help="sub-command help",
|
117
117
|
dest="command",
|
118
|
+
required=True,
|
118
119
|
)
|
119
120
|
fotolab.subcommands.build_subparser(subparsers)
|
120
121
|
|
@@ -128,16 +129,24 @@ def main(args: Optional[Sequence[str]] = None) -> None:
|
|
128
129
|
|
129
130
|
try:
|
130
131
|
parser = build_parser()
|
131
|
-
if len(args) == 0:
|
132
|
-
parser.print_help(sys.stderr)
|
133
|
-
return
|
134
|
-
|
135
132
|
parsed_args = parser.parse_args(args)
|
136
133
|
setup_logging(parsed_args)
|
137
134
|
|
138
|
-
if
|
135
|
+
if parsed_args.command is not None:
|
139
136
|
log.debug(parsed_args)
|
140
|
-
|
137
|
+
# Ensure the function attribute exists (set by set_defaults in
|
138
|
+
# subcommands)
|
139
|
+
if hasattr(parsed_args, "func"):
|
140
|
+
parsed_args.func(parsed_args)
|
141
|
+
else:
|
142
|
+
# This case should ideally not happen if subcommands are set up
|
143
|
+
# correctly
|
144
|
+
log.error(
|
145
|
+
"subcommand '%s' is missing its execution function.",
|
146
|
+
parsed_args.command
|
147
|
+
)
|
148
|
+
parser.print_help(sys.stderr)
|
149
|
+
raise SystemExit(1)
|
141
150
|
else:
|
142
151
|
parser.print_help(sys.stderr)
|
143
152
|
|
@@ -0,0 +1,229 @@
|
|
1
|
+
# Copyright (C) 2024,2025 Kian-Meng Ang
|
2
|
+
#
|
3
|
+
# This program is free software: you can redistribute it and/or modify it under
|
4
|
+
# the terms of the GNU Affero General Public License as published by the Free
|
5
|
+
# Software Foundation, either version 3 of the License, or (at your option) any
|
6
|
+
# later version.
|
7
|
+
#
|
8
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
9
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
10
|
+
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
11
|
+
# details.
|
12
|
+
#
|
13
|
+
# You should have received a copy of the GNU Affero General Public License
|
14
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
15
|
+
|
16
|
+
"""Halftone subcommand."""
|
17
|
+
|
18
|
+
import argparse
|
19
|
+
import logging
|
20
|
+
import math
|
21
|
+
from typing import NamedTuple
|
22
|
+
|
23
|
+
from PIL import Image, ImageDraw
|
24
|
+
|
25
|
+
from fotolab import save_gif_image, save_image
|
26
|
+
|
27
|
+
log = logging.getLogger(__name__)
|
28
|
+
|
29
|
+
|
30
|
+
class HalftoneCell(NamedTuple):
|
31
|
+
"""Represents a cell in the halftone grid."""
|
32
|
+
|
33
|
+
col: int
|
34
|
+
row: int
|
35
|
+
cellsize: float
|
36
|
+
|
37
|
+
|
38
|
+
def build_subparser(subparsers) -> None:
|
39
|
+
"""Build the subparser."""
|
40
|
+
halftone_parser = subparsers.add_parser(
|
41
|
+
"halftone", help="halftone an image"
|
42
|
+
)
|
43
|
+
|
44
|
+
halftone_parser.set_defaults(func=run)
|
45
|
+
|
46
|
+
halftone_parser.add_argument(
|
47
|
+
dest="image_filenames",
|
48
|
+
help="set the image filename",
|
49
|
+
nargs="+",
|
50
|
+
type=str,
|
51
|
+
default=None,
|
52
|
+
metavar="IMAGE_FILENAMES",
|
53
|
+
)
|
54
|
+
|
55
|
+
halftone_parser.add_argument(
|
56
|
+
"-ba",
|
57
|
+
"--before-after",
|
58
|
+
default=False,
|
59
|
+
action="store_true",
|
60
|
+
dest="before_after",
|
61
|
+
help="generate a GIF showing before and after changes",
|
62
|
+
)
|
63
|
+
|
64
|
+
halftone_parser.add_argument(
|
65
|
+
"-op",
|
66
|
+
"--open",
|
67
|
+
default=False,
|
68
|
+
action="store_true",
|
69
|
+
dest="open",
|
70
|
+
help="open the image using default program (default: '%(default)s')",
|
71
|
+
)
|
72
|
+
|
73
|
+
halftone_parser.add_argument(
|
74
|
+
"-od",
|
75
|
+
"--output-dir",
|
76
|
+
dest="output_dir",
|
77
|
+
default="output",
|
78
|
+
help="set default output folder (default: '%(default)s')",
|
79
|
+
)
|
80
|
+
|
81
|
+
halftone_parser.add_argument(
|
82
|
+
"-c",
|
83
|
+
"--cells",
|
84
|
+
dest="cells",
|
85
|
+
type=int,
|
86
|
+
default=50,
|
87
|
+
help="set number of cells across the image width (default: %(default)s)",
|
88
|
+
)
|
89
|
+
|
90
|
+
halftone_parser.add_argument(
|
91
|
+
"-g",
|
92
|
+
"--grayscale",
|
93
|
+
default=False,
|
94
|
+
action="store_true",
|
95
|
+
dest="grayscale",
|
96
|
+
help="convert image to grayscale before applying halftone",
|
97
|
+
)
|
98
|
+
|
99
|
+
|
100
|
+
def run(args: argparse.Namespace) -> None:
|
101
|
+
"""Run halftone subcommand.
|
102
|
+
|
103
|
+
Args:
|
104
|
+
args (argparse.Namespace): Config from command line arguments
|
105
|
+
|
106
|
+
Returns:
|
107
|
+
None
|
108
|
+
"""
|
109
|
+
log.debug(args)
|
110
|
+
|
111
|
+
for image_filename in args.image_filenames:
|
112
|
+
original_image = Image.open(image_filename)
|
113
|
+
halftone_image = create_halftone_image(
|
114
|
+
original_image, args.cells, args.grayscale
|
115
|
+
)
|
116
|
+
|
117
|
+
if args.before_after:
|
118
|
+
save_gif_image(
|
119
|
+
args,
|
120
|
+
image_filename,
|
121
|
+
original_image,
|
122
|
+
halftone_image,
|
123
|
+
"halftone",
|
124
|
+
)
|
125
|
+
else:
|
126
|
+
save_image(args, halftone_image, image_filename, "halftone")
|
127
|
+
|
128
|
+
|
129
|
+
def _draw_halftone_dot(
|
130
|
+
draw: ImageDraw.ImageDraw,
|
131
|
+
source_image: Image.Image,
|
132
|
+
cell: HalftoneCell,
|
133
|
+
grayscale: bool,
|
134
|
+
fill_color_dot,
|
135
|
+
) -> None:
|
136
|
+
"""Calculate properties and draw a single halftone dot."""
|
137
|
+
# Calculate center point of current cell
|
138
|
+
img_width, img_height = source_image.size
|
139
|
+
|
140
|
+
# Calculate center point of current cell and clamp to image bounds
|
141
|
+
x = min(int(cell.col * cell.cellsize + cell.cellsize / 2), img_width - 1)
|
142
|
+
y = min(int(cell.row * cell.cellsize + cell.cellsize / 2), img_height - 1)
|
143
|
+
|
144
|
+
# Ensure coordinates are non-negative (shouldn't happen with current logic, but safe)
|
145
|
+
x = max(0, x)
|
146
|
+
y = max(0, y)
|
147
|
+
|
148
|
+
# Get pixel value (brightness or color) from the source image using clamped coordinates
|
149
|
+
pixel_value = source_image.getpixel((x, y))
|
150
|
+
|
151
|
+
if grayscale:
|
152
|
+
brightness = pixel_value
|
153
|
+
dot_fill = fill_color_dot # Use white for grayscale dots
|
154
|
+
else:
|
155
|
+
# Calculate brightness (luminance) from the RGB color
|
156
|
+
brightness = int(
|
157
|
+
0.299 * pixel_value[0]
|
158
|
+
+ 0.587 * pixel_value[1]
|
159
|
+
+ 0.114 * pixel_value[2]
|
160
|
+
)
|
161
|
+
dot_fill = pixel_value # Use original color for color dots
|
162
|
+
|
163
|
+
# Calculate dot radius relative to cell size based on brightness
|
164
|
+
# Max radius is half the cell size. Scale by brightness (0-255).
|
165
|
+
dot_radius = (brightness / 255.0) * (cell.cellsize / 2)
|
166
|
+
|
167
|
+
# Draw the dot
|
168
|
+
draw.ellipse(
|
169
|
+
[
|
170
|
+
x - dot_radius,
|
171
|
+
y - dot_radius,
|
172
|
+
x + dot_radius,
|
173
|
+
y + dot_radius,
|
174
|
+
],
|
175
|
+
fill=dot_fill,
|
176
|
+
)
|
177
|
+
|
178
|
+
|
179
|
+
def create_halftone_image(
|
180
|
+
original_image: Image.Image, cell_count: int, grayscale: bool = False
|
181
|
+
) -> Image.Image:
|
182
|
+
"""Create a halftone version of the input image.
|
183
|
+
|
184
|
+
Modified from the circular halftone effect processing.py example from
|
185
|
+
https://tabreturn.github.io/code/processing/python/2019/02/09/processing.py_in_ten_lessons-6.3-_halftones.html
|
186
|
+
|
187
|
+
Args:
|
188
|
+
original_image: The source image to convert
|
189
|
+
cell_count: Number of cells across the width
|
190
|
+
grayscale: Whether to convert to grayscale first (default: False)
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
Image.Image: The halftone converted image
|
194
|
+
"""
|
195
|
+
if grayscale:
|
196
|
+
source_image = original_image.convert("L")
|
197
|
+
output_mode = "L"
|
198
|
+
fill_color_black = 0
|
199
|
+
fill_color_dot = 255
|
200
|
+
else:
|
201
|
+
source_image = original_image.convert("RGB")
|
202
|
+
output_mode = "RGB"
|
203
|
+
fill_color_black = (0, 0, 0)
|
204
|
+
fill_color_dot = None # Will be set inside the loop for color images
|
205
|
+
|
206
|
+
width, height = original_image.size
|
207
|
+
|
208
|
+
# Create a new image for the output, initialized to black
|
209
|
+
halftone_image = Image.new(output_mode, (width, height), fill_color_black)
|
210
|
+
draw = ImageDraw.Draw(halftone_image)
|
211
|
+
|
212
|
+
cellsize = width / cell_count
|
213
|
+
rowtotal = math.ceil(height / cellsize)
|
214
|
+
|
215
|
+
# Determine the fill color for dots once if grayscale
|
216
|
+
grayscale_fill_color_dot = fill_color_dot if grayscale else None
|
217
|
+
|
218
|
+
for row in range(rowtotal):
|
219
|
+
for col in range(cell_count):
|
220
|
+
cell = HalftoneCell(col=col, row=row, cellsize=cellsize)
|
221
|
+
_draw_halftone_dot(
|
222
|
+
draw,
|
223
|
+
source_image,
|
224
|
+
cell,
|
225
|
+
grayscale,
|
226
|
+
grayscale_fill_color_dot,
|
227
|
+
)
|
228
|
+
|
229
|
+
return halftone_image
|
@@ -74,25 +74,41 @@ def run(args: argparse.Namespace) -> None:
|
|
74
74
|
"""
|
75
75
|
log.debug(args)
|
76
76
|
|
77
|
-
|
77
|
+
# TODO: Add error handling for file open
|
78
|
+
# TODO: Use context manager `with Image.open(...)`
|
78
79
|
image = Image.open(args.image_filename)
|
79
80
|
|
81
|
+
exif_tags = extract_exif_tags(image, args.sort)
|
82
|
+
|
83
|
+
if not exif_tags:
|
84
|
+
print("No metadata found!")
|
85
|
+
# Close the image if opened outside a 'with' block
|
86
|
+
image.close()
|
87
|
+
return
|
88
|
+
|
89
|
+
output_info = []
|
90
|
+
specific_info_requested = False
|
91
|
+
|
80
92
|
if args.camera:
|
81
|
-
|
93
|
+
specific_info_requested = True
|
94
|
+
# TODO: Add error handling for missing keys
|
95
|
+
output_info.append(camera_metadata(exif_tags))
|
82
96
|
|
83
97
|
if args.datetime:
|
84
|
-
|
98
|
+
specific_info_requested = True
|
99
|
+
# TODO: Add error handling for missing keys
|
100
|
+
output_info.append(datetime(exif_tags))
|
85
101
|
|
86
|
-
if
|
87
|
-
print("\n".join(
|
102
|
+
if specific_info_requested:
|
103
|
+
print("\n".join(output_info))
|
88
104
|
else:
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
105
|
+
# Print all tags if no specific info was requested
|
106
|
+
tag_name_width = max(map(len, exif_tags))
|
107
|
+
for tag_name, tag_value in exif_tags.items():
|
108
|
+
print(f"{tag_name:<{tag_name_width}}: {tag_value}")
|
109
|
+
|
110
|
+
# Close the image if opened outside a 'with' block
|
111
|
+
image.close()
|
96
112
|
|
97
113
|
|
98
114
|
def extract_exif_tags(image: Image.Image, sort: bool = False) -> dict:
|
@@ -117,14 +133,16 @@ def extract_exif_tags(image: Image.Image, sort: bool = False) -> dict:
|
|
117
133
|
return filtered_info
|
118
134
|
|
119
135
|
|
120
|
-
def datetime(
|
136
|
+
def datetime(exif_tags: dict):
|
121
137
|
"""Extract datetime metadata."""
|
122
|
-
|
138
|
+
# TODO: Add error handling for missing key
|
123
139
|
return exif_tags["DateTime"]
|
124
140
|
|
125
141
|
|
126
|
-
def camera_metadata(
|
142
|
+
def camera_metadata(exif_tags: dict):
|
127
143
|
"""Extract camera and model metadata."""
|
128
|
-
|
129
|
-
|
144
|
+
# TODO: Add error handling for missing keys
|
145
|
+
make = exif_tags.get("Make", "")
|
146
|
+
model = exif_tags.get("Model", "")
|
147
|
+
metadata = f"{make} {model}"
|
130
148
|
return metadata.strip()
|
@@ -281,9 +281,17 @@ def watermark_image(
|
|
281
281
|
|
282
282
|
def prepare_text(args: argparse.Namespace, image: Image.Image) -> str:
|
283
283
|
"""Prepare the watermark text."""
|
284
|
-
text: str = args.text
|
285
|
-
if args.camera
|
286
|
-
|
284
|
+
text: str = args.text # Default text
|
285
|
+
if args.camera:
|
286
|
+
metadata_text: str | None = camera_metadata(image)
|
287
|
+
if metadata_text: # Use metadata only if it's not None or empty
|
288
|
+
text = metadata_text
|
289
|
+
else:
|
290
|
+
log.warning(
|
291
|
+
"Camera metadata requested but not found or empty; "
|
292
|
+
"falling back to default text: '%s'",
|
293
|
+
args.text,
|
294
|
+
)
|
287
295
|
|
288
296
|
if args.lowercase:
|
289
297
|
text = text.lower()
|
@@ -1,145 +0,0 @@
|
|
1
|
-
# Copyright (C) 2024,2025 Kian-Meng Ang
|
2
|
-
#
|
3
|
-
# This program is free software: you can redistribute it and/or modify it under
|
4
|
-
# the terms of the GNU Affero General Public License as published by the Free
|
5
|
-
# Software Foundation, either version 3 of the License, or (at your option) any
|
6
|
-
# later version.
|
7
|
-
#
|
8
|
-
# This program is distributed in the hope that it will be useful, but WITHOUT
|
9
|
-
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
10
|
-
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
11
|
-
# details.
|
12
|
-
#
|
13
|
-
# You should have received a copy of the GNU Affero General Public License
|
14
|
-
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
15
|
-
|
16
|
-
"""Halftone subcommand."""
|
17
|
-
|
18
|
-
import argparse
|
19
|
-
import logging
|
20
|
-
import math
|
21
|
-
|
22
|
-
from PIL import Image, ImageDraw
|
23
|
-
|
24
|
-
from fotolab import save_gif_image, save_image
|
25
|
-
|
26
|
-
log = logging.getLogger(__name__)
|
27
|
-
|
28
|
-
|
29
|
-
def build_subparser(subparsers) -> None:
|
30
|
-
"""Build the subparser."""
|
31
|
-
halftone_parser = subparsers.add_parser(
|
32
|
-
"halftone", help="halftone an image"
|
33
|
-
)
|
34
|
-
|
35
|
-
halftone_parser.set_defaults(func=run)
|
36
|
-
|
37
|
-
halftone_parser.add_argument(
|
38
|
-
dest="image_filenames",
|
39
|
-
help="set the image filename",
|
40
|
-
nargs="+",
|
41
|
-
type=str,
|
42
|
-
default=None,
|
43
|
-
metavar="IMAGE_FILENAMES",
|
44
|
-
)
|
45
|
-
|
46
|
-
halftone_parser.add_argument(
|
47
|
-
"-ba",
|
48
|
-
"--before-after",
|
49
|
-
default=False,
|
50
|
-
action="store_true",
|
51
|
-
dest="before_after",
|
52
|
-
help="generate a GIF showing before and after changes",
|
53
|
-
)
|
54
|
-
|
55
|
-
halftone_parser.add_argument(
|
56
|
-
"-op",
|
57
|
-
"--open",
|
58
|
-
default=False,
|
59
|
-
action="store_true",
|
60
|
-
dest="open",
|
61
|
-
help="open the image using default program (default: '%(default)s')",
|
62
|
-
)
|
63
|
-
|
64
|
-
halftone_parser.add_argument(
|
65
|
-
"-od",
|
66
|
-
"--output-dir",
|
67
|
-
dest="output_dir",
|
68
|
-
default="output",
|
69
|
-
help="set default output folder (default: '%(default)s')",
|
70
|
-
)
|
71
|
-
|
72
|
-
|
73
|
-
def run(args: argparse.Namespace) -> None:
|
74
|
-
"""Run halftone subcommand.
|
75
|
-
|
76
|
-
Args:
|
77
|
-
args (argparse.Namespace): Config from command line arguments
|
78
|
-
|
79
|
-
Returns:
|
80
|
-
None
|
81
|
-
"""
|
82
|
-
log.debug(args)
|
83
|
-
|
84
|
-
for image_filename in args.image_filenames:
|
85
|
-
original_image = Image.open(image_filename)
|
86
|
-
halftone_image = create_halftone_image(original_image)
|
87
|
-
|
88
|
-
if args.before_after:
|
89
|
-
save_gif_image(
|
90
|
-
args,
|
91
|
-
image_filename,
|
92
|
-
original_image,
|
93
|
-
halftone_image,
|
94
|
-
"halftone",
|
95
|
-
)
|
96
|
-
else:
|
97
|
-
save_image(args, halftone_image, image_filename, "halftone")
|
98
|
-
|
99
|
-
|
100
|
-
def create_halftone_image(
|
101
|
-
original_image: Image.Image, cell_count: int = 50
|
102
|
-
) -> Image.Image:
|
103
|
-
"""Create a halftone version of the input image.
|
104
|
-
|
105
|
-
Modified from the circular halftone effect processing.py example from
|
106
|
-
https://tabreturn.github.io/code/processing/python/2019/02/09/processing.py_in_ten_lessons-6.3-_halftones.html
|
107
|
-
|
108
|
-
Args:
|
109
|
-
original_image: The source image to convert
|
110
|
-
cell_count: Number of cells across the width (default: 50)
|
111
|
-
|
112
|
-
Returns:
|
113
|
-
Image.Image: The halftone converted image
|
114
|
-
"""
|
115
|
-
grayscale_image = original_image.convert("L")
|
116
|
-
width, height = original_image.size
|
117
|
-
|
118
|
-
halftone_image = Image.new("L", (width, height), "black")
|
119
|
-
draw = ImageDraw.Draw(halftone_image)
|
120
|
-
|
121
|
-
cellsize = width / cell_count
|
122
|
-
rowtotal = math.ceil(height / cellsize)
|
123
|
-
|
124
|
-
for row in range(rowtotal):
|
125
|
-
for col in range(cell_count):
|
126
|
-
# Calculate center point of current cell
|
127
|
-
x = int(col * cellsize + cellsize / 2)
|
128
|
-
y = int(row * cellsize + cellsize / 2)
|
129
|
-
|
130
|
-
# Get brightness and calculate dot size
|
131
|
-
brightness = grayscale_image.getpixel((x, y))
|
132
|
-
dot_size = 10 * brightness / 200
|
133
|
-
|
134
|
-
# Draw the dot
|
135
|
-
draw.ellipse(
|
136
|
-
[
|
137
|
-
x - dot_size / 2,
|
138
|
-
y - dot_size / 2,
|
139
|
-
x + dot_size / 2,
|
140
|
-
y + dot_size / 2,
|
141
|
-
],
|
142
|
-
fill=255,
|
143
|
-
)
|
144
|
-
|
145
|
-
return halftone_image
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|