fotolab 0.28.5__py3-none-any.whl → 0.29.0__py3-none-any.whl
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/__init__.py +5 -5
- fotolab/cli.py +27 -18
- fotolab/subcommands/halftone.py +59 -13
- fotolab/subcommands/watermark.py +44 -29
- {fotolab-0.28.5.dist-info → fotolab-0.29.0.dist-info}/METADATA +1 -1
- {fotolab-0.28.5.dist-info → fotolab-0.29.0.dist-info}/RECORD +9 -9
- {fotolab-0.28.5.dist-info → fotolab-0.29.0.dist-info}/LICENSE.md +0 -0
- {fotolab-0.28.5.dist-info → fotolab-0.29.0.dist-info}/WHEEL +0 -0
- {fotolab-0.28.5.dist-info → fotolab-0.29.0.dist-info}/entry_points.txt +0 -0
fotolab/__init__.py
CHANGED
@@ -17,13 +17,14 @@
|
|
17
17
|
|
18
18
|
import argparse
|
19
19
|
import logging
|
20
|
+
import os
|
20
21
|
import subprocess
|
21
22
|
import sys
|
22
23
|
from pathlib import Path
|
23
24
|
|
24
25
|
from PIL import Image
|
25
26
|
|
26
|
-
__version__ = "0.
|
27
|
+
__version__ = "0.29.0"
|
27
28
|
|
28
29
|
log = logging.getLogger(__name__)
|
29
30
|
|
@@ -98,10 +99,9 @@ def _open_image(filename):
|
|
98
99
|
subprocess.call(["xdg-open", filename])
|
99
100
|
elif sys.platform == "darwin":
|
100
101
|
subprocess.call(["open", filename])
|
101
|
-
elif sys.platform == "
|
102
|
-
|
103
|
-
proc.wait()
|
102
|
+
elif sys.platform == "win32":
|
103
|
+
os.startfile(filename)
|
104
104
|
log.info("open image: %s", filename.resolve())
|
105
105
|
|
106
|
-
except OSError as error:
|
106
|
+
except (OSError, FileNotFoundError) as error:
|
107
107
|
log.error("Error opening image: %s -> %s", filename, error)
|
fotolab/cli.py
CHANGED
@@ -1,17 +1,17 @@
|
|
1
1
|
# Copyright (C) 2024,2025 Kian-Meng Ang
|
2
2
|
|
3
|
-
# This program is free software: you can redistribute it and/or modify
|
4
|
-
#
|
5
|
-
#
|
6
|
-
#
|
7
|
-
|
8
|
-
# This program is distributed in the hope that it will be useful,
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
|
13
|
-
# You should have received a copy of the GNU General Public License
|
14
|
-
# along with this program.
|
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
15
|
|
16
16
|
"""A console program to manipulate photos.
|
17
17
|
|
@@ -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
|
|
fotolab/subcommands/halftone.py
CHANGED
@@ -69,6 +69,24 @@ def build_subparser(subparsers) -> None:
|
|
69
69
|
help="set default output folder (default: '%(default)s')",
|
70
70
|
)
|
71
71
|
|
72
|
+
halftone_parser.add_argument(
|
73
|
+
"-c",
|
74
|
+
"--cells",
|
75
|
+
dest="cells",
|
76
|
+
type=int,
|
77
|
+
default=50,
|
78
|
+
help="set number of cells across the image width (default: %(default)s)",
|
79
|
+
)
|
80
|
+
|
81
|
+
halftone_parser.add_argument(
|
82
|
+
"-g",
|
83
|
+
"--grayscale",
|
84
|
+
default=False,
|
85
|
+
action="store_true",
|
86
|
+
dest="grayscale",
|
87
|
+
help="convert image to grayscale before applying halftone",
|
88
|
+
)
|
89
|
+
|
72
90
|
|
73
91
|
def run(args: argparse.Namespace) -> None:
|
74
92
|
"""Run halftone subcommand.
|
@@ -83,7 +101,9 @@ def run(args: argparse.Namespace) -> None:
|
|
83
101
|
|
84
102
|
for image_filename in args.image_filenames:
|
85
103
|
original_image = Image.open(image_filename)
|
86
|
-
halftone_image = create_halftone_image(
|
104
|
+
halftone_image = create_halftone_image(
|
105
|
+
original_image, args.cells, args.grayscale
|
106
|
+
)
|
87
107
|
|
88
108
|
if args.before_after:
|
89
109
|
save_gif_image(
|
@@ -98,7 +118,7 @@ def run(args: argparse.Namespace) -> None:
|
|
98
118
|
|
99
119
|
|
100
120
|
def create_halftone_image(
|
101
|
-
original_image: Image.Image, cell_count: int =
|
121
|
+
original_image: Image.Image, cell_count: int, grayscale: bool = False
|
102
122
|
) -> Image.Image:
|
103
123
|
"""Create a halftone version of the input image.
|
104
124
|
|
@@ -107,15 +127,26 @@ def create_halftone_image(
|
|
107
127
|
|
108
128
|
Args:
|
109
129
|
original_image: The source image to convert
|
110
|
-
cell_count: Number of cells across the width
|
130
|
+
cell_count: Number of cells across the width
|
131
|
+
grayscale: Whether to convert to grayscale first (default: False)
|
111
132
|
|
112
133
|
Returns:
|
113
134
|
Image.Image: The halftone converted image
|
114
135
|
"""
|
115
|
-
|
136
|
+
if grayscale:
|
137
|
+
source_image = original_image.convert("L")
|
138
|
+
output_mode = "L"
|
139
|
+
fill_color_black = 0
|
140
|
+
fill_color_dot = 255
|
141
|
+
else:
|
142
|
+
source_image = original_image.convert("RGB")
|
143
|
+
output_mode = "RGB"
|
144
|
+
fill_color_black = (0, 0, 0)
|
145
|
+
|
116
146
|
width, height = original_image.size
|
117
147
|
|
118
|
-
|
148
|
+
# Create a new image for the output, initialized to black
|
149
|
+
halftone_image = Image.new(output_mode, (width, height), fill_color_black)
|
119
150
|
draw = ImageDraw.Draw(halftone_image)
|
120
151
|
|
121
152
|
cellsize = width / cell_count
|
@@ -127,19 +158,34 @@ def create_halftone_image(
|
|
127
158
|
x = int(col * cellsize + cellsize / 2)
|
128
159
|
y = int(row * cellsize + cellsize / 2)
|
129
160
|
|
130
|
-
# Get brightness
|
131
|
-
|
132
|
-
|
161
|
+
# Get pixel value (brightness or color) from the source image
|
162
|
+
pixel_value = source_image.getpixel((x, y))
|
163
|
+
|
164
|
+
if grayscale:
|
165
|
+
brightness = pixel_value
|
166
|
+
dot_fill = fill_color_dot # Use white for grayscale dots
|
167
|
+
else:
|
168
|
+
# Calculate brightness (luminance) from the RGB color
|
169
|
+
brightness = int(
|
170
|
+
0.299 * pixel_value[0]
|
171
|
+
+ 0.587 * pixel_value[1]
|
172
|
+
+ 0.114 * pixel_value[2]
|
173
|
+
)
|
174
|
+
dot_fill = pixel_value # Use original color for color dots
|
175
|
+
|
176
|
+
# Calculate dot radius relative to cell size based on brightness
|
177
|
+
# Max radius is half the cell size. Scale by brightness (0-255).
|
178
|
+
dot_radius = (brightness / 255.0) * (cellsize / 2)
|
133
179
|
|
134
180
|
# Draw the dot
|
135
181
|
draw.ellipse(
|
136
182
|
[
|
137
|
-
x -
|
138
|
-
y -
|
139
|
-
x +
|
140
|
-
y +
|
183
|
+
x - dot_radius,
|
184
|
+
y - dot_radius,
|
185
|
+
x + dot_radius,
|
186
|
+
y + dot_radius,
|
141
187
|
],
|
142
|
-
fill=
|
188
|
+
fill=dot_fill,
|
143
189
|
)
|
144
190
|
|
145
191
|
return halftone_image
|
fotolab/subcommands/watermark.py
CHANGED
@@ -19,23 +19,24 @@ import argparse
|
|
19
19
|
import logging
|
20
20
|
import math
|
21
21
|
from pathlib import Path
|
22
|
+
from typing import Tuple
|
22
23
|
|
23
24
|
from PIL import Image, ImageColor, ImageDraw, ImageFont, ImageSequence
|
24
25
|
|
25
26
|
from fotolab import save_image
|
26
27
|
from fotolab.subcommands.info import camera_metadata
|
27
28
|
|
28
|
-
log = logging.getLogger(__name__)
|
29
|
+
log: logging.Logger = logging.getLogger(__name__)
|
29
30
|
|
30
|
-
FONT_SIZE_ASPECT_RATIO = 12 / 600
|
31
|
-
FONT_PADDING_ASPECT_RATIO = 15 / 600
|
32
|
-
FONT_OUTLINE_WIDTH_ASPECT_RATIO = 2 / 600
|
33
|
-
POSITIONS = ["top-left", "top-right", "bottom-left", "bottom-right"]
|
31
|
+
FONT_SIZE_ASPECT_RATIO: float = 12 / 600
|
32
|
+
FONT_PADDING_ASPECT_RATIO: float = 15 / 600
|
33
|
+
FONT_OUTLINE_WIDTH_ASPECT_RATIO: float = 2 / 600
|
34
|
+
POSITIONS: list[str] = ["top-left", "top-right", "bottom-left", "bottom-right"]
|
34
35
|
|
35
36
|
|
36
|
-
def build_subparser(subparsers) -> None:
|
37
|
+
def build_subparser(subparsers: argparse._SubParsersAction) -> None:
|
37
38
|
"""Build the subparser."""
|
38
|
-
watermark_parser = subparsers.add_parser(
|
39
|
+
watermark_parser: argparse.ArgumentParser = subparsers.add_parser(
|
39
40
|
"watermark", help="watermark an image"
|
40
41
|
)
|
41
42
|
|
@@ -175,11 +176,13 @@ def run(args: argparse.Namespace) -> None:
|
|
175
176
|
log.debug(args)
|
176
177
|
|
177
178
|
for image_filename in args.image_filenames:
|
178
|
-
image = Image.open(image_filename)
|
179
|
+
image: Image.Image = Image.open(image_filename)
|
179
180
|
if image.format == "GIF":
|
180
181
|
watermark_gif_image(image, image_filename, args)
|
181
182
|
else:
|
182
|
-
watermarked_image = watermark_non_gif_image(
|
183
|
+
watermarked_image: Image.Image = watermark_non_gif_image(
|
184
|
+
image, args
|
185
|
+
)
|
183
186
|
save_image(args, watermarked_image, image_filename, "watermark")
|
184
187
|
|
185
188
|
|
@@ -196,15 +199,17 @@ def watermark_gif_image(
|
|
196
199
|
Returns:
|
197
200
|
None
|
198
201
|
"""
|
199
|
-
frames = []
|
202
|
+
frames: list[Image.Image] = []
|
200
203
|
for frame in ImageSequence.Iterator(original_image):
|
201
|
-
watermarked_frame = watermark_image(
|
204
|
+
watermarked_frame: Image.Image = watermark_image(
|
205
|
+
args, frame.convert("RGBA")
|
206
|
+
)
|
202
207
|
frames.append(watermarked_frame)
|
203
208
|
|
204
|
-
image_file = Path(output_filename)
|
209
|
+
image_file: Path = Path(output_filename)
|
205
210
|
|
206
211
|
if args.overwrite:
|
207
|
-
new_filename =
|
212
|
+
new_filename: Path = image_file.with_name(image_file.name)
|
208
213
|
else:
|
209
214
|
new_filename = Path(
|
210
215
|
args.output_dir,
|
@@ -243,16 +248,18 @@ def watermark_image(
|
|
243
248
|
args: argparse.Namespace, original_image: Image.Image
|
244
249
|
) -> Image.Image:
|
245
250
|
"""Watermark an image."""
|
246
|
-
watermarked_image = original_image.copy()
|
247
|
-
draw = ImageDraw.Draw(watermarked_image)
|
251
|
+
watermarked_image: Image.Image = original_image.copy()
|
252
|
+
draw: ImageDraw.ImageDraw = ImageDraw.Draw(watermarked_image)
|
248
253
|
|
249
|
-
font = ImageFont.load_default(
|
254
|
+
font: ImageFont.FreeTypeFont = ImageFont.load_default(
|
255
|
+
calc_font_size(original_image, args)
|
256
|
+
)
|
250
257
|
log.debug("default font: %s", " ".join(font.getname()))
|
251
258
|
|
252
|
-
text = prepare_text(args, original_image)
|
259
|
+
text: str = prepare_text(args, original_image)
|
253
260
|
(left, top, right, bottom) = draw.textbbox(xy=(0, 0), text=text, font=font)
|
254
|
-
text_width = right - left
|
255
|
-
text_height = bottom - top
|
261
|
+
text_width: int = right - left
|
262
|
+
text_height: int = bottom - top
|
256
263
|
(position_x, position_y) = calc_position(
|
257
264
|
watermarked_image,
|
258
265
|
text_width,
|
@@ -272,9 +279,9 @@ def watermark_image(
|
|
272
279
|
return watermarked_image
|
273
280
|
|
274
281
|
|
275
|
-
def prepare_text(args, image) -> str:
|
282
|
+
def prepare_text(args: argparse.Namespace, image: Image.Image) -> str:
|
276
283
|
"""Prepare the watermark text."""
|
277
|
-
text = args.text
|
284
|
+
text: str = args.text
|
278
285
|
if args.camera and camera_metadata(image):
|
279
286
|
text = camera_metadata(image)
|
280
287
|
|
@@ -284,10 +291,10 @@ def prepare_text(args, image) -> str:
|
|
284
291
|
return text
|
285
292
|
|
286
293
|
|
287
|
-
def calc_font_size(image, args) -> int:
|
294
|
+
def calc_font_size(image: Image.Image, args: argparse.Namespace) -> int:
|
288
295
|
"""Calculate the font size based on the width of the image."""
|
289
296
|
width, _height = image.size
|
290
|
-
new_font_size = args.font_size
|
297
|
+
new_font_size: int = args.font_size
|
291
298
|
if width > 600:
|
292
299
|
new_font_size = math.floor(FONT_SIZE_ASPECT_RATIO * width)
|
293
300
|
|
@@ -295,10 +302,12 @@ def calc_font_size(image, args) -> int:
|
|
295
302
|
return new_font_size
|
296
303
|
|
297
304
|
|
298
|
-
def calc_font_outline_width(
|
305
|
+
def calc_font_outline_width(
|
306
|
+
image: Image.Image, args: argparse.Namespace
|
307
|
+
) -> int:
|
299
308
|
"""Calculate the font padding based on the width of the image."""
|
300
309
|
width, _height = image.size
|
301
|
-
new_font_outline_width = args.outline_width
|
310
|
+
new_font_outline_width: int = args.outline_width
|
302
311
|
if width > 600:
|
303
312
|
new_font_outline_width = math.floor(
|
304
313
|
FONT_OUTLINE_WIDTH_ASPECT_RATIO * width
|
@@ -308,10 +317,10 @@ def calc_font_outline_width(image, args) -> int:
|
|
308
317
|
return new_font_outline_width
|
309
318
|
|
310
319
|
|
311
|
-
def calc_padding(image, args) -> int:
|
320
|
+
def calc_padding(image: Image.Image, args: argparse.Namespace) -> int:
|
312
321
|
"""Calculate the font padding based on the width of the image."""
|
313
322
|
width, _height = image.size
|
314
|
-
new_padding = args.padding
|
323
|
+
new_padding: int = args.padding
|
315
324
|
if width > 600:
|
316
325
|
new_padding = math.floor(FONT_PADDING_ASPECT_RATIO * width)
|
317
326
|
|
@@ -319,9 +328,15 @@ def calc_padding(image, args) -> int:
|
|
319
328
|
return new_padding
|
320
329
|
|
321
330
|
|
322
|
-
def calc_position(
|
331
|
+
def calc_position(
|
332
|
+
image: Image.Image,
|
333
|
+
text_width: int,
|
334
|
+
text_height: int,
|
335
|
+
position: str,
|
336
|
+
padding: int,
|
337
|
+
) -> Tuple[int, int]:
|
323
338
|
"""Calculate the boundary coordinates of the watermark text."""
|
324
|
-
positions = {
|
339
|
+
positions: dict[str, Tuple[int, int]] = {
|
325
340
|
"top-left": (padding, padding),
|
326
341
|
"top-right": (image.width - text_width - padding, padding),
|
327
342
|
"bottom-left": (padding, image.height - text_height - padding),
|
@@ -1,21 +1,21 @@
|
|
1
|
-
fotolab/__init__.py,sha256=
|
1
|
+
fotolab/__init__.py,sha256=E76E5fEYHIL3PDbEllLqBALGLmzPYpgtdk4B1-ujU_g,3262
|
2
2
|
fotolab/__main__.py,sha256=aboOURPs_snOXTEWYR0q8oq1UTY9e-NxCd1j33V0wHI,833
|
3
|
-
fotolab/cli.py,sha256=
|
3
|
+
fotolab/cli.py,sha256=NH_u73SJhzwDJSRqpi1I4dc8wBfcqbDxNne3cio163A,4411
|
4
4
|
fotolab/subcommands/__init__.py,sha256=l3DlIaJ3u3jGjnC1H1yV8LZ_nPqOLJ6gikD4BCaMAQ0,1129
|
5
5
|
fotolab/subcommands/animate.py,sha256=OcS6Q6JM1TW6HF0dSgBsV9mpEGwDMcJlQssupkf0_ZA,3393
|
6
6
|
fotolab/subcommands/auto.py,sha256=ia-xegV1Z4HvYsbKgmTzf1NfNFdTDPWfZe7vQ1_90Ik,2425
|
7
7
|
fotolab/subcommands/border.py,sha256=BS3BHytdWiNumxdKulKYK-WigbsKtPxECdvInUhUjSQ,4608
|
8
8
|
fotolab/subcommands/contrast.py,sha256=ZHTvAJhRYZjNTrkHRZq3O6wba3LS7QjH1BfiGf4ZvGY,3005
|
9
9
|
fotolab/subcommands/env.py,sha256=QoxRvzZKgmoHTUxDV4QYhdChCpMWs5TbXFY_qIpIQpE,1469
|
10
|
-
fotolab/subcommands/halftone.py,sha256=
|
10
|
+
fotolab/subcommands/halftone.py,sha256=dtqmyZyGC8huzSGRCRgp8t11m51hFC15kvgfEJBm_0c,5714
|
11
11
|
fotolab/subcommands/info.py,sha256=Qp-Zu7Xp1ptLK211hOkFM5oxZWGTrlNBqCpbx2IjL9Y,3371
|
12
12
|
fotolab/subcommands/montage.py,sha256=d_3EcyRSFS8fKkczlHO8IRP-mrKhQUtkQndjfd0MKsc,2566
|
13
13
|
fotolab/subcommands/resize.py,sha256=UOb2rg_5ArRj0gxPTDOww_ix3tqJRC0W5b_S-Lt1G4w,5444
|
14
14
|
fotolab/subcommands/rotate.py,sha256=uBFjHyjiBSQLtrtH1p9myODIHUDr1gkL4PpU-6Y1Ofo,2575
|
15
15
|
fotolab/subcommands/sharpen.py,sha256=YNho2IPbc-lPvSy3Bsjehc2JOEy27LPqFSGRULs9MyY,3492
|
16
|
-
fotolab/subcommands/watermark.py,sha256=
|
17
|
-
fotolab-0.
|
18
|
-
fotolab-0.
|
19
|
-
fotolab-0.
|
20
|
-
fotolab-0.
|
21
|
-
fotolab-0.
|
16
|
+
fotolab/subcommands/watermark.py,sha256=iEuek0BM2PJlGMLq8VZhHxaQ8BInuN2_mvssA7Dhgy8,10032
|
17
|
+
fotolab-0.29.0.dist-info/entry_points.txt,sha256=mvw7AY_yZkIyjAxPtHNed9X99NZeLnMxEeAfEJUbrCM,44
|
18
|
+
fotolab-0.29.0.dist-info/LICENSE.md,sha256=tGtFDwxWTjuR9syrJoSv1Hiffd2u8Tu8cYClfrXS_YU,31956
|
19
|
+
fotolab-0.29.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
|
20
|
+
fotolab-0.29.0.dist-info/METADATA,sha256=h3twSy_UW9ri5GZ0_Xyh0s2IYH6AdVCAll-TuKYc-Xg,12970
|
21
|
+
fotolab-0.29.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|