devbits 1.0.0__tar.gz → 1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -64,6 +64,7 @@ clipvideo --help
64
64
  | Command | Description |
65
65
  |---------|-------------|
66
66
  | `resizeimage` | Resize a single image (preserves aspect ratio by default). |
67
+ | `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
67
68
  | `image2ico` | Convert an image to a multi-size ICO file. |
68
69
  | `batchimages` | Batch resize or convert all images in a folder. |
69
70
  | `checkimages` | Scan for broken / corrupt image files. |
@@ -94,6 +95,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
94
95
  # Extract every 5th frame as PNG
95
96
  video2images movie.mp4 --every 5 --format png
96
97
 
98
+ # Recolor a logo's foreground to black (keeps the background)
99
+ recolor logo.png
100
+
101
+ # Recolor a logo's foreground to a custom color (hex or R,G,B)
102
+ recolor logo.png --color '#1a73e8'
103
+ recolor logo.png --color 0,178,179
104
+
97
105
  # Batch resize images to 800×600
98
106
  batchimages ./photos -o ./resized --size 800,600
99
107
 
@@ -109,6 +117,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
109
117
  clipvideo movie.mp4 → movie_clip.mp4
110
118
  video2gif movie.mp4 → movie.gif
111
119
  resizeimage photo.jpg → photo_resized.jpg
120
+ recolor logo.png → logo_revised.png
112
121
  contactsheet ./photos → photos_sheet.jpg
113
122
  ```
114
123
 
@@ -47,6 +47,7 @@ clipvideo --help
47
47
  | Command | Description |
48
48
  |---------|-------------|
49
49
  | `resizeimage` | Resize a single image (preserves aspect ratio by default). |
50
+ | `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
50
51
  | `image2ico` | Convert an image to a multi-size ICO file. |
51
52
  | `batchimages` | Batch resize or convert all images in a folder. |
52
53
  | `checkimages` | Scan for broken / corrupt image files. |
@@ -77,6 +78,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
77
78
  # Extract every 5th frame as PNG
78
79
  video2images movie.mp4 --every 5 --format png
79
80
 
81
+ # Recolor a logo's foreground to black (keeps the background)
82
+ recolor logo.png
83
+
84
+ # Recolor a logo's foreground to a custom color (hex or R,G,B)
85
+ recolor logo.png --color '#1a73e8'
86
+ recolor logo.png --color 0,178,179
87
+
80
88
  # Batch resize images to 800×600
81
89
  batchimages ./photos -o ./resized --size 800,600
82
90
 
@@ -92,6 +100,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
92
100
  clipvideo movie.mp4 → movie_clip.mp4
93
101
  video2gif movie.mp4 → movie.gif
94
102
  resizeimage photo.jpg → photo_resized.jpg
103
+ recolor logo.png → logo_revised.png
95
104
  contactsheet ./photos → photos_sheet.jpg
96
105
  ```
97
106
 
@@ -1,3 +1,3 @@
1
1
  """devbits: A lightweight CLI toolkit for daily development utilities."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
@@ -5,7 +5,7 @@ import sys
5
5
  from pathlib import Path
6
6
 
7
7
  from .cache import clear_cache
8
- from .image import batch_images, check_images, contact_sheet, image_to_ico, resize_image
8
+ from .image import batch_images, check_images, contact_sheet, image_to_ico, recolor_image, resize_image
9
9
  from .media import clip_video, images_to_gif, images_to_video, resize_video, video_to_gif, video_to_images
10
10
  from .project import print_tree, rename_files, sample_files, top_sizes
11
11
  from .utils import ensure_exists
@@ -237,6 +237,32 @@ def build_parser() -> argparse.ArgumentParser:
237
237
  help="Do not preserve aspect ratio; stretch to exact size.")
238
238
  p.set_defaults(func=cmd_resizeimage)
239
239
 
240
+ # ── recolor ────────────────────────────────────────────────
241
+ p = sub.add_parser(
242
+ "recolor",
243
+ help="Recolor the foreground of a logo / icon image.",
244
+ formatter_class=argparse.RawDescriptionHelpFormatter,
245
+ description=(
246
+ "Recolor a logo or icon. The background (transparent or a lighter\n"
247
+ "surrounding color) is detected automatically and left untouched,\n"
248
+ "while every foreground pixel is repainted with the target color.\n"
249
+ "The result is always saved as an RGBA PNG.\n\n"
250
+ "Examples:\n"
251
+ " devbits recolor logo.png\n"
252
+ " devbits recolor logo.png --color '#1a73e8'\n"
253
+ " devbits recolor logo.png --color 0,178,179\n"
254
+ " devbits recolor icon.jpg --color white --threshold 90"
255
+ ),
256
+ )
257
+ p.add_argument("image", type=Path, help="Input logo / icon image.")
258
+ p.add_argument("-o", "--output", type=Path, default=None,
259
+ help="Output image path. Default: <image_stem>_revised.png")
260
+ p.add_argument("--color", default="black",
261
+ help="Target foreground color: name, hex, or R,G,B (e.g. black, '#1a73e8', 0,178,179). Default: black")
262
+ p.add_argument("--threshold", type=int, default=60,
263
+ help="Color distance from the background for opaque images. Default: 60")
264
+ p.set_defaults(func=cmd_recolor)
265
+
240
266
  # ── batchimages ────────────────────────────────────────────
241
267
  p = sub.add_parser(
242
268
  "batchimages",
@@ -452,6 +478,12 @@ def cmd_resizeimage(args: argparse.Namespace) -> None:
452
478
  print(resize_image(image, output, args.size, not args.no_keep_ratio))
453
479
 
454
480
 
481
+ def cmd_recolor(args: argparse.Namespace) -> None:
482
+ image = ensure_exists(args.image)
483
+ output = args.output or _derive_output(image, ".png", "revised")
484
+ print(recolor_image(image, output, args.color, args.threshold))
485
+
486
+
455
487
  def cmd_batchimages(args: argparse.Namespace) -> None:
456
488
  outputs = batch_images(ensure_exists(args.folder), args.output, args.size, args.format)
457
489
  print(f"Saved {len(outputs)} image(s) to {args.output}")
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError
6
6
 
7
- from .utils import ensure_dir, list_images, parse_size
7
+ from .utils import ensure_dir, list_images, parse_color, parse_size
8
8
 
9
9
 
10
10
  def image_to_ico(input_path: Path, output_path: Path, sizes: str = "16,32,48,64,128,256") -> Path:
@@ -81,6 +81,58 @@ def resize_image(input_path: Path, output_path: Path, size: str, keep_ratio: boo
81
81
  return output_path
82
82
 
83
83
 
84
+ def recolor_image(
85
+ input_path: Path,
86
+ output_path: Path,
87
+ color: str = "black",
88
+ threshold: int = 60,
89
+ ) -> Path:
90
+ """Recolor the foreground of a logo / icon while keeping its background.
91
+
92
+ The background is detected either from transparency (transparent pixels are
93
+ treated as background) or, for fully opaque images, from the dominant border
94
+ color (the lighter surrounding area). Every foreground pixel is repainted
95
+ with ``color`` (a name like ``black``, a hex value like ``#1a73e8``, or an
96
+ ``R,G,B`` triple like ``0,178,179``). The result is always saved as an RGBA
97
+ PNG so soft, anti-aliased edges are kept.
98
+ """
99
+ import numpy as np
100
+
101
+ output_path.parent.mkdir(parents=True, exist_ok=True)
102
+
103
+ target_rgb = parse_color(color)
104
+
105
+ with Image.open(input_path) as image:
106
+ arr = np.array(image.convert("RGBA"))
107
+
108
+ rgb = arr[..., :3].astype(np.int16)
109
+ alpha = arr[..., 3]
110
+
111
+ # Step 1: distinguish background from foreground.
112
+ transparent = alpha < 16
113
+ if transparent.mean() > 0.02:
114
+ # Transparency marks the background; opaque pixels are the foreground.
115
+ foreground = alpha >= 16
116
+ else:
117
+ # Opaque image: estimate the (lighter) background from the border color.
118
+ border = np.concatenate(
119
+ [rgb[0, :, :], rgb[-1, :, :], rgb[:, 0, :], rgb[:, -1, :]], axis=0
120
+ )
121
+ bg_color = np.median(border, axis=0)
122
+ distance = np.sqrt(((rgb - bg_color) ** 2).sum(axis=2))
123
+ foreground = distance > threshold
124
+
125
+ # Step 2: paint the foreground with the target color (alpha is preserved,
126
+ # so anti-aliased edges keep their soft blend).
127
+ arr[..., 0][foreground] = target_rgb[0]
128
+ arr[..., 1][foreground] = target_rgb[1]
129
+ arr[..., 2][foreground] = target_rgb[2]
130
+
131
+ # Step 3: export as RGBA PNG.
132
+ Image.fromarray(arr, mode="RGBA").save(output_path, format="PNG")
133
+ return output_path
134
+
135
+
84
136
  def batch_images(folder: Path, output_folder: Path, size: str | None = None, fmt: str | None = None) -> list[Path]:
85
137
  ensure_dir(output_folder)
86
138
  outputs: list[Path] = []
@@ -47,6 +47,10 @@ def resizeimage() -> int:
47
47
  return _run("resizeimage")
48
48
 
49
49
 
50
+ def recolor() -> int:
51
+ return _run("recolor")
52
+
53
+
50
54
  def batchimages() -> int:
51
55
  return _run("batchimages")
52
56
 
@@ -46,6 +46,33 @@ def parse_size(size: str) -> tuple[int, int]:
46
46
  return width_i, height_i
47
47
 
48
48
 
49
+ def parse_color(value: str) -> tuple[int, int, int]:
50
+ """Parse a color into an ``(R, G, B)`` tuple.
51
+
52
+ Accepts a CSS name (``black``), a hex value (``#1a73e8``), or a comma-/
53
+ space-separated ``R,G,B`` triple (``0,178,179``).
54
+ """
55
+ from PIL import ImageColor
56
+
57
+ text = value.strip()
58
+ if "," in text:
59
+ parts = [p.strip() for p in text.split(",") if p.strip()]
60
+ if len(parts) != 3:
61
+ raise ValueError(f"RGB color must have 3 components, got: {value!r}")
62
+ try:
63
+ channels = tuple(int(p) for p in parts)
64
+ except ValueError as exc:
65
+ raise ValueError(f"RGB components must be integers: {value!r}") from exc
66
+ if any(c < 0 or c > 255 for c in channels):
67
+ raise ValueError(f"RGB components must be in 0-255: {value!r}")
68
+ return channels # type: ignore[return-value]
69
+
70
+ try:
71
+ return ImageColor.getrgb(text)[:3]
72
+ except ValueError as exc:
73
+ raise ValueError(f"Unrecognized color: {value!r}") from exc
74
+
75
+
49
76
  def parse_int_tuple(value: str, expected: int, name: str) -> tuple[int, ...]:
50
77
  try:
51
78
  items = tuple(int(x.strip()) for x in value.split(","))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devbits
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: A lightweight CLI toolkit for daily development utilities.
5
5
  Author: Bruce Chuang
6
6
  License-Expression: MIT
@@ -64,6 +64,7 @@ clipvideo --help
64
64
  | Command | Description |
65
65
  |---------|-------------|
66
66
  | `resizeimage` | Resize a single image (preserves aspect ratio by default). |
67
+ | `recolor` | Recolor a logo/icon foreground, leaving the background intact. |
67
68
  | `image2ico` | Convert an image to a multi-size ICO file. |
68
69
  | `batchimages` | Batch resize or convert all images in a folder. |
69
70
  | `checkimages` | Scan for broken / corrupt image files. |
@@ -94,6 +95,13 @@ video2gif movie.mp4 --start 3.5 --end 10.0 --fps 15
94
95
  # Extract every 5th frame as PNG
95
96
  video2images movie.mp4 --every 5 --format png
96
97
 
98
+ # Recolor a logo's foreground to black (keeps the background)
99
+ recolor logo.png
100
+
101
+ # Recolor a logo's foreground to a custom color (hex or R,G,B)
102
+ recolor logo.png --color '#1a73e8'
103
+ recolor logo.png --color 0,178,179
104
+
97
105
  # Batch resize images to 800×600
98
106
  batchimages ./photos -o ./resized --size 800,600
99
107
 
@@ -109,6 +117,7 @@ When `-o` / `--output` is omitted, the output filename is derived from the input
109
117
  clipvideo movie.mp4 → movie_clip.mp4
110
118
  video2gif movie.mp4 → movie.gif
111
119
  resizeimage photo.jpg → photo_resized.jpg
120
+ recolor logo.png → logo_revised.png
112
121
  contactsheet ./photos → photos_sheet.jpg
113
122
  ```
114
123
 
@@ -17,4 +17,5 @@ devbits.egg-info/entry_points.txt
17
17
  devbits.egg-info/requires.txt
18
18
  devbits.egg-info/top_level.txt
19
19
  tests/test_cli.py
20
- tests/test_gui_cli.py
20
+ tests/test_gui_cli.py
21
+ tests/test_recolor.py
@@ -8,6 +8,7 @@ devbits = devbits.cli:main
8
8
  image2ico = devbits.scripts:image2ico
9
9
  images2gif = devbits.scripts:images2gif
10
10
  images2video = devbits.scripts:images2video
11
+ recolor = devbits.scripts:recolor
11
12
  renamefiles = devbits.scripts:renamefiles
12
13
  resizeimage = devbits.scripts:resizeimage
13
14
  resizevideo = devbits.scripts:resizevideo
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devbits"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  description = "A lightweight CLI toolkit for daily development utilities."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -30,6 +30,7 @@ clipvideo = "devbits.scripts:clipvideo"
30
30
  resizevideo = "devbits.scripts:resizevideo"
31
31
  image2ico = "devbits.scripts:image2ico"
32
32
  resizeimage = "devbits.scripts:resizeimage"
33
+ recolor = "devbits.scripts:recolor"
33
34
  batchimages = "devbits.scripts:batchimages"
34
35
  checkimages = "devbits.scripts:checkimages"
35
36
  contactsheet = "devbits.scripts:contactsheet"
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from PIL import Image
4
+
5
+ from devbits.cli import main
6
+ from devbits.image import recolor_image
7
+
8
+
9
+ def _make_transparent_logo(path) -> None:
10
+ """A transparent background with an opaque red square in the middle."""
11
+ img = Image.new("RGBA", (20, 20), (0, 0, 0, 0))
12
+ for y in range(5, 15):
13
+ for x in range(5, 15):
14
+ img.putpixel((x, y), (200, 0, 0, 255))
15
+ img.save(path)
16
+
17
+
18
+ def _make_opaque_logo(path) -> None:
19
+ """A light-gray background with a dark blue square in the middle."""
20
+ img = Image.new("RGB", (20, 20), (230, 230, 230))
21
+ for y in range(5, 15):
22
+ for x in range(5, 15):
23
+ img.putpixel((x, y), (10, 10, 120))
24
+ img.save(path)
25
+
26
+
27
+ def test_recolor_transparent(tmp_path) -> None:
28
+ src = tmp_path / "logo.png"
29
+ _make_transparent_logo(src)
30
+ out = recolor_image(src, tmp_path / "logo_revised.png", "black")
31
+
32
+ result = Image.open(out)
33
+ assert result.mode == "RGBA"
34
+ # Foreground becomes black and stays opaque.
35
+ assert result.getpixel((10, 10)) == (0, 0, 0, 255)
36
+ # Background stays transparent.
37
+ assert result.getpixel((0, 0))[3] == 0
38
+
39
+
40
+ def test_recolor_opaque_custom_color(tmp_path) -> None:
41
+ src = tmp_path / "icon.jpg"
42
+ _make_opaque_logo(src)
43
+ out = recolor_image(src, tmp_path / "icon_revised.png", "#00ff00")
44
+
45
+ result = Image.open(out)
46
+ assert result.mode == "RGBA"
47
+ # Foreground becomes the requested green.
48
+ assert result.getpixel((10, 10))[:3] == (0, 255, 0)
49
+ # Light background is left roughly unchanged.
50
+ assert result.getpixel((0, 0))[0] > 200
51
+
52
+
53
+ def test_recolor_rgb_triple(tmp_path) -> None:
54
+ src = tmp_path / "logo.png"
55
+ _make_transparent_logo(src)
56
+ out = recolor_image(src, tmp_path / "logo_revised.png", "0,178,179")
57
+
58
+ result = Image.open(out)
59
+ assert result.mode == "RGBA"
60
+ assert result.getpixel((10, 10)) == (0, 178, 179, 255)
61
+
62
+
63
+ def test_recolor_cli_default_output(tmp_path) -> None:
64
+ src = tmp_path / "brand.png"
65
+ _make_transparent_logo(src)
66
+ assert main(["recolor", str(src)]) == 0
67
+ assert (tmp_path / "brand_revised.png").exists()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes