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 CHANGED
@@ -24,7 +24,7 @@ from pathlib import Path
24
24
 
25
25
  from PIL import Image
26
26
 
27
- __version__ = "0.28.6"
27
+ __version__ = "0.29.1"
28
28
 
29
29
  log = logging.getLogger(__name__)
30
30
 
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 hasattr(parsed_args, "func"):
135
+ if parsed_args.command is not None:
139
136
  log.debug(parsed_args)
140
- parsed_args.func(parsed_args)
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
 
@@ -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(original_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 = 50
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 (default: 50)
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
- grayscale_image = original_image.convert("L")
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
- halftone_image = Image.new("L", (width, height), "black")
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
- # 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,
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
@@ -74,25 +74,41 @@ def run(args: argparse.Namespace) -> None:
74
74
  """
75
75
  log.debug(args)
76
76
 
77
- info = []
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
- info.append(camera_metadata(image))
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
- info.append(datetime(image))
98
+ specific_info_requested = True
99
+ # TODO: Add error handling for missing keys
100
+ output_info.append(datetime(exif_tags))
85
101
 
86
- if info:
87
- print("\n".join(info))
102
+ if specific_info_requested:
103
+ print("\n".join(output_info))
88
104
  else:
89
- exif_tags = extract_exif_tags(image)
90
- if exif_tags:
91
- tag_name_width = max(map(len, exif_tags))
92
- for tag_name, tag_value in exif_tags.items():
93
- print(f"{tag_name:<{tag_name_width}}: {tag_value}")
94
- else:
95
- print("No metadata found!")
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(image: Image.Image):
136
+ def datetime(exif_tags: dict):
121
137
  """Extract datetime metadata."""
122
- exif_tags = extract_exif_tags(image)
138
+ # TODO: Add error handling for missing key
123
139
  return exif_tags["DateTime"]
124
140
 
125
141
 
126
- def camera_metadata(image: Image.Image):
142
+ def camera_metadata(exif_tags: dict):
127
143
  """Extract camera and model metadata."""
128
- exif_tags = extract_exif_tags(image)
129
- metadata = f'{exif_tags["Make"]} {exif_tags["Model"]}'
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 and camera_metadata(image):
286
- text = camera_metadata(image)
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.28.6
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 pixels to
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=yiRE-ZW8LgYnZAJ7wKNY_XdaaPC9IbHRMnlRKLYjsdA,3262
1
+ fotolab/__init__.py,sha256=KwMFpmP-DDvkiBIx6zr9XGWSONGzZo5Yr0aHKjYUmlg,3262
2
2
  fotolab/__main__.py,sha256=aboOURPs_snOXTEWYR0q8oq1UTY9e-NxCd1j33V0wHI,833
3
- fotolab/cli.py,sha256=ffT3CbMN37Qehw4l7YvwUbPFY-SezLLn4Xthi0ejOmk,3956
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=bRGG_Bg1T5ZV3UWsdMszOdPmc6g9_R0yNp5mb0g8hcE,4152
11
- fotolab/subcommands/info.py,sha256=Qp-Zu7Xp1ptLK211hOkFM5oxZWGTrlNBqCpbx2IjL9Y,3371
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=iEuek0BM2PJlGMLq8VZhHxaQ8BInuN2_mvssA7Dhgy8,10032
17
- fotolab-0.28.6.dist-info/entry_points.txt,sha256=mvw7AY_yZkIyjAxPtHNed9X99NZeLnMxEeAfEJUbrCM,44
18
- fotolab-0.28.6.dist-info/LICENSE.md,sha256=tGtFDwxWTjuR9syrJoSv1Hiffd2u8Tu8cYClfrXS_YU,31956
19
- fotolab-0.28.6.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
20
- fotolab-0.28.6.dist-info/METADATA,sha256=3E5duvvv00UeRQoiWsueL2cdsEj1l17jdkwxY7Nal68,12970
21
- fotolab-0.28.6.dist-info/RECORD,,
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,,