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.
- {devbits-1.0.0 → devbits-1.1.0}/PKG-INFO +10 -1
- {devbits-1.0.0 → devbits-1.1.0}/README.md +9 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits/__init__.py +1 -1
- {devbits-1.0.0 → devbits-1.1.0}/devbits/cli.py +33 -1
- {devbits-1.0.0 → devbits-1.1.0}/devbits/image.py +53 -1
- {devbits-1.0.0 → devbits-1.1.0}/devbits/scripts.py +4 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits/utils.py +27 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits.egg-info/PKG-INFO +10 -1
- {devbits-1.0.0 → devbits-1.1.0}/devbits.egg-info/SOURCES.txt +2 -1
- {devbits-1.0.0 → devbits-1.1.0}/devbits.egg-info/entry_points.txt +1 -0
- {devbits-1.0.0 → devbits-1.1.0}/pyproject.toml +2 -1
- devbits-1.1.0/tests/test_recolor.py +67 -0
- {devbits-1.0.0 → devbits-1.1.0}/LICENSE +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits/cache.py +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits/gui.py +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits/media.py +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits/project.py +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits.egg-info/dependency_links.txt +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits.egg-info/requires.txt +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/devbits.egg-info/top_level.txt +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/setup.cfg +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/tests/test_cli.py +0 -0
- {devbits-1.0.0 → devbits-1.1.0}/tests/test_gui_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devbits
|
|
3
|
-
Version: 1.
|
|
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
|
|
|
@@ -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] = []
|
|
@@ -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.
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|