fotolab 0.28.6__py3-none-any.whl → 0.29.1__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 +1 -1
- fotolab/cli.py +15 -6
- fotolab/subcommands/halftone.py +106 -22
- fotolab/subcommands/info.py +35 -17
- fotolab/subcommands/watermark.py +11 -3
- {fotolab-0.28.6.dist-info → fotolab-0.29.1.dist-info}/METADATA +4 -4
- {fotolab-0.28.6.dist-info → fotolab-0.29.1.dist-info}/RECORD +10 -10
- {fotolab-0.28.6.dist-info → fotolab-0.29.1.dist-info}/LICENSE.md +0 -0
- {fotolab-0.28.6.dist-info → fotolab-0.29.1.dist-info}/WHEEL +0 -0
- {fotolab-0.28.6.dist-info → fotolab-0.29.1.dist-info}/entry_points.txt +0 -0
fotolab/__init__.py
CHANGED
fotolab/cli.py
CHANGED
@@ -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
@@ -18,6 +18,7 @@
|
|
18
18
|
import argparse
|
19
19
|
import logging
|
20
20
|
import math
|
21
|
+
from typing import NamedTuple
|
21
22
|
|
22
23
|
from PIL import Image, ImageDraw
|
23
24
|
|
@@ -26,6 +27,14 @@ from fotolab import save_gif_image, save_image
|
|
26
27
|
log = logging.getLogger(__name__)
|
27
28
|
|
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
|
+
|
29
38
|
def build_subparser(subparsers) -> None:
|
30
39
|
"""Build the subparser."""
|
31
40
|
halftone_parser = subparsers.add_parser(
|
@@ -69,6 +78,24 @@ def build_subparser(subparsers) -> None:
|
|
69
78
|
help="set default output folder (default: '%(default)s')",
|
70
79
|
)
|
71
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
|
+
|
72
99
|
|
73
100
|
def run(args: argparse.Namespace) -> None:
|
74
101
|
"""Run halftone subcommand.
|
@@ -83,7 +110,9 @@ def run(args: argparse.Namespace) -> None:
|
|
83
110
|
|
84
111
|
for image_filename in args.image_filenames:
|
85
112
|
original_image = Image.open(image_filename)
|
86
|
-
halftone_image = create_halftone_image(
|
113
|
+
halftone_image = create_halftone_image(
|
114
|
+
original_image, args.cells, args.grayscale
|
115
|
+
)
|
87
116
|
|
88
117
|
if args.before_after:
|
89
118
|
save_gif_image(
|
@@ -97,8 +126,58 @@ def run(args: argparse.Namespace) -> None:
|
|
97
126
|
save_image(args, halftone_image, image_filename, "halftone")
|
98
127
|
|
99
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
|
+
|
100
179
|
def create_halftone_image(
|
101
|
-
original_image: Image.Image, cell_count: int =
|
180
|
+
original_image: Image.Image, cell_count: int, grayscale: bool = False
|
102
181
|
) -> Image.Image:
|
103
182
|
"""Create a halftone version of the input image.
|
104
183
|
|
@@ -107,39 +186,44 @@ def create_halftone_image(
|
|
107
186
|
|
108
187
|
Args:
|
109
188
|
original_image: The source image to convert
|
110
|
-
cell_count: Number of cells across the width
|
189
|
+
cell_count: Number of cells across the width
|
190
|
+
grayscale: Whether to convert to grayscale first (default: False)
|
111
191
|
|
112
192
|
Returns:
|
113
193
|
Image.Image: The halftone converted image
|
114
194
|
"""
|
115
|
-
|
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
|
+
|
116
206
|
width, height = original_image.size
|
117
207
|
|
118
|
-
|
208
|
+
# Create a new image for the output, initialized to black
|
209
|
+
halftone_image = Image.new(output_mode, (width, height), fill_color_black)
|
119
210
|
draw = ImageDraw.Draw(halftone_image)
|
120
211
|
|
121
212
|
cellsize = width / cell_count
|
122
213
|
rowtotal = math.ceil(height / cellsize)
|
123
214
|
|
215
|
+
# Determine the fill color for dots once if grayscale
|
216
|
+
grayscale_fill_color_dot = fill_color_dot if grayscale else None
|
217
|
+
|
124
218
|
for row in range(rowtotal):
|
125
219
|
for col in range(cell_count):
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
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,
|
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,
|
143
227
|
)
|
144
228
|
|
145
229
|
return halftone_image
|
fotolab/subcommands/info.py
CHANGED
@@ -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()
|
fotolab/subcommands/watermark.py
CHANGED
@@ -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,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
|
@@ -1,21 +1,21 @@
|
|
1
|
-
fotolab/__init__.py,sha256=
|
1
|
+
fotolab/__init__.py,sha256=KwMFpmP-DDvkiBIx6zr9XGWSONGzZo5Yr0aHKjYUmlg,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=
|
11
|
-
fotolab/subcommands/info.py,sha256=
|
10
|
+
fotolab/subcommands/halftone.py,sha256=YOGtdl6N3aC4E47ac1rGzHU0HczVsujKNIBMyiRf2-c,6681
|
11
|
+
fotolab/subcommands/info.py,sha256=EYuU06-rrnZZMY0XKwaIiqLypbd6x9KmCydiB1UhtTU,4010
|
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=qRGUp1Lc22fZSJDFRqQGPiz8RSB293ebvOVTdsDLUE4,10351
|
17
|
+
fotolab-0.29.1.dist-info/entry_points.txt,sha256=mvw7AY_yZkIyjAxPtHNed9X99NZeLnMxEeAfEJUbrCM,44
|
18
|
+
fotolab-0.29.1.dist-info/LICENSE.md,sha256=tGtFDwxWTjuR9syrJoSv1Hiffd2u8Tu8cYClfrXS_YU,31956
|
19
|
+
fotolab-0.29.1.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
|
20
|
+
fotolab-0.29.1.dist-info/METADATA,sha256=0jUgjFRshumTtyFXLKe6_sQVnpyGLNez-Fl-KB5vHMk,12980
|
21
|
+
fotolab-0.29.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|