Skip to content

Commit 295edb0

Browse files
authored
Merge pull request #4 from john-liu2/task/more_upd
Add `resize.py` and related tests. Add new dependencies
2 parents e281c50 + 24c5356 commit 295edb0

File tree

13 files changed

+345
-16
lines changed

13 files changed

+345
-16
lines changed

.github/workflows/pylint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- name: Install dependencies
1818
run: |
1919
python -m pip install --upgrade pip
20-
pip install pylint ruff click loguru pillow
20+
pip install pylint ruff click loguru piexif pillow pillow-heif
2121
- name: Analysing the code with pylint
2222
run: |
2323
pylint $(git ls-files '*.py')

.github/workflows/pytest.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- name: Install dependencies
2525
run: |
2626
python -m pip install --upgrade pip
27-
pip install pytest-cov click loguru pillow
27+
pip install pytest-cov click loguru piexif pillow pillow-heif
2828
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
2929
- name: Test with pytest
3030
run: |

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ Usage: batch_img resize [OPTIONS] SRC_PATH
7272
Resize image file(s)
7373
7474
Options:
75-
-w, --width INTEGER Resize image file(s) on current aspect ratio to the
76-
width. 0 - no resize [default: 0]
77-
--help Show this message and exit.
75+
-l, --length INTEGER Resize image file(s) on current aspect ratio to the
76+
length. 0 - no resize [default: 0]
77+
--help Show this message and exit.
7878
```
7979

8080
#### The `rotate` sub-command CLI options:

batch_img/common.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,22 @@
22
Copyright © 2025 John Liu
33
"""
44

5+
import json
56
import subprocess
67
import tomllib
8+
from datetime import datetime
79
from importlib.metadata import version
10+
from os.path import getmtime, getsize
811
from pathlib import Path
912

13+
import piexif
14+
import pillow_heif
1015
from loguru import logger
16+
from PIL import Image, ImageChops
1117

12-
from batch_img.const import PKG_NAME, VER
18+
from batch_img.const import PKG_NAME, TS_FORMAT, VER
19+
20+
pillow_heif.register_heif_opener() # allow Pillow to open HEIC files
1321

1422

1523
class Common:
@@ -51,3 +59,115 @@ def run_cmd(cmd: str) -> tuple:
5159
except subprocess.CalledProcessError as e:
5260
logger.exception(e)
5361
raise e
62+
63+
@staticmethod
64+
def readable_file_size(in_bytes: int) -> str:
65+
"""Convert bytes to human-readable KB, MB, or GB
66+
67+
Args:
68+
in_bytes: input bytes integer
69+
70+
Returns:
71+
str
72+
"""
73+
for _unit in ["B", "KB", "MB", "GB"]:
74+
if in_bytes < 1024:
75+
break
76+
in_bytes /= 1024
77+
res = f"{in_bytes} B" if _unit == "B" else f"{in_bytes:.1f} {_unit}"
78+
return res
79+
80+
@staticmethod
81+
def decode_exif(exif_data: str) -> dict:
82+
"""Decode the EXIF data
83+
84+
Args:
85+
exif_data: str
86+
87+
Returns:
88+
dict
89+
"""
90+
exif_dict = piexif.load(exif_data)
91+
_dict = {}
92+
for ifd_name, val in exif_dict.items():
93+
if not val:
94+
continue
95+
for tag_id, value in val.items():
96+
tag_name = piexif.TAGS[ifd_name].get(tag_id, {}).get("name", tag_id)
97+
_dict[tag_name] = value
98+
for key in (
99+
"FNumber",
100+
"FocalLength",
101+
"MakerNote",
102+
"SceneType",
103+
"SubjectArea",
104+
"Software",
105+
"HostComputer",
106+
):
107+
if key in _dict:
108+
_dict.pop(key)
109+
keys = list(_dict.keys())
110+
for keyword in (
111+
"DateTime",
112+
"GPS",
113+
"OffsetTime",
114+
"SubSecTime",
115+
"Tile",
116+
"Pixel",
117+
"Lens",
118+
"Resolution",
119+
"Value",
120+
):
121+
for key in keys:
122+
if key.startswith(keyword) or key.endswith(keyword):
123+
_dict.pop(key)
124+
_res = {
125+
k: (v.decode() if isinstance(v, bytes) else v) for k, v in _dict.items()
126+
}
127+
logger.info(f"{_res=}")
128+
return _res
129+
130+
@staticmethod
131+
def are_images_equal(path1: Path | str, path2: Path | str) -> bool:
132+
"""Check if two image files are visually equal pixel-wise
133+
134+
Args:
135+
path1: image1 file path
136+
path2: image2 file path
137+
138+
Returns:
139+
bool: True - visually equal, False - not visually equal
140+
"""
141+
size1 = getsize(path1)
142+
m_ts1 = datetime.fromtimestamp(getmtime(path1)).strftime(TS_FORMAT)
143+
with Image.open(path1) as img1:
144+
data1 = img1.convert("RGB")
145+
meta1 = {
146+
"file_size": Common.readable_file_size(size1),
147+
"file_ts": m_ts1,
148+
"format": img1.format,
149+
"size": img1.size,
150+
"mode": img1.mode,
151+
"info": img1.info,
152+
}
153+
if "exif" in img1.info:
154+
meta1["info"] = Common.decode_exif(img1.info["exif"])
155+
156+
size2 = getsize(path2)
157+
m_ts2 = datetime.fromtimestamp(getmtime(path2)).strftime(TS_FORMAT)
158+
with Image.open(path2) as img2:
159+
data2 = img2.convert("RGB")
160+
meta2 = {
161+
"file_size": Common.readable_file_size(size2),
162+
"file_ts": m_ts2,
163+
"format": img2.format,
164+
"size": img2.size,
165+
"mode": img2.mode,
166+
"info": img2.info,
167+
}
168+
if "exif" in img2.info:
169+
meta2["info"] = Common.decode_exif(img2.info["exif"])
170+
171+
logger.info(f"Meta of {path1}:\n{json.dumps(meta1, indent=2)}")
172+
logger.info(f"Meta of {path2}:\n{json.dumps(meta2, indent=2)}")
173+
return ImageChops.difference(data1, data2).getbbox() is None

batch_img/interface.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def border(src_path, border_width, border_color):
5858
)
5959
def defaults(src_path):
6060
"""Do the default action on the image file(s):
61-
* Resize to 1280 pixels as the max width
61+
* Resize to 1280 pixels as the max length
6262
* Add a border: 5 pixel width, gray color
6363
* No rotate
6464
"""
@@ -76,18 +76,18 @@ def defaults(src_path):
7676
required=True,
7777
)
7878
@click.option(
79-
"-w",
80-
"--width",
79+
"-l",
80+
"--length",
8181
is_flag=False,
8282
default=0,
8383
show_default=True,
8484
type=int,
85-
help="Resize image file(s) on current aspect ratio to the width. 0 - no resize",
85+
help="Resize image file(s) on current aspect ratio to the length. 0 - no resize",
8686
)
87-
def resize(src_path, width):
87+
def resize(src_path, length):
8888
options = {
8989
"src_path": src_path,
90-
"width": width,
90+
"length": length,
9191
}
9292
res = Main.resize(options)
9393
msg = MSG_OK if res else MSG_BAD

batch_img/main.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
import json
66
import os
77
from datetime import datetime
8+
from pathlib import Path
89

910
from loguru import logger
1011

1112
from batch_img.const import PKG_NAME, TS_FORMAT
13+
from batch_img.resize import Resize
1214

1315

1416
class Main:
@@ -37,7 +39,13 @@ def resize(options: dict) -> bool:
3739
"""
3840
logger.info(f"{json.dumps(options, indent=2)}")
3941
Main.init_log_file()
40-
# To-do
42+
# Resize one file
43+
in_path = Path(options["src_path"])
44+
length = options["length"]
45+
if not length:
46+
logger.warning(f"No resize due to bad {length=}")
47+
return False
48+
Resize.resize_an_image(in_path, Path(os.getcwd()), length)
4149
return True
4250

4351
@staticmethod
@@ -73,7 +81,7 @@ def rotate(options: dict) -> bool:
7381
@staticmethod
7482
def default_run(options: dict) -> bool:
7583
"""Do the default action on the image file(s):
76-
1) Resize to 1280 pixels as the max width
84+
1) Resize to 1280 pixels as the max length
7785
2) Add a border: 5 pixel width, gray color
7886
3) Not rotate
7987
"""

batch_img/resize.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""class Resize: resize the image file(s)
2+
Copyright © 2025 John Liu
3+
"""
4+
5+
from pathlib import Path
6+
7+
import piexif
8+
import pillow_heif
9+
from loguru import logger
10+
from PIL import Image
11+
12+
pillow_heif.register_heif_opener() # allow Pillow to open HEIC files
13+
14+
15+
class Resize:
16+
@staticmethod
17+
def resize_an_image(in_path: Path | str, out_path: Path | str, length: int) -> Path:
18+
"""Resize one image file
19+
20+
Args:
21+
in_path: input file path
22+
out_path: output dir path
23+
length: max length (width or height) in pixels
24+
25+
Returns:
26+
Path: output file path
27+
"""
28+
try:
29+
with Image.open(in_path) as img:
30+
width, height = img.size
31+
aspect_ratio = width / height
32+
33+
exif_dict = None
34+
if "exif" in img.info:
35+
exif_dict = piexif.load(img.info["exif"])
36+
37+
# Calculate max_size by max_side and aspect_ratio
38+
if aspect_ratio > 1:
39+
max_size = (length, int(length / aspect_ratio))
40+
else:
41+
max_size = (int(length * aspect_ratio), length)
42+
43+
# Keep the aspect ratio. Use LANCZOS for high-quality downsampling
44+
img.thumbnail(max_size, Image.Resampling.LANCZOS)
45+
46+
# Save back to HEIC format with EXIF
47+
out_path.mkdir(exist_ok=True)
48+
out_file = out_path
49+
if out_path.is_dir():
50+
filename = f"{in_path.stem}_{length}{in_path.suffix}"
51+
out_file = Path(f"{out_path}/{filename}")
52+
if exif_dict:
53+
exif_bytes = piexif.dump(exif_dict)
54+
img.save(out_file, format="HEIF", exif=exif_bytes)
55+
else:
56+
img.save(out_file, format="HEIF")
57+
logger.info(f"Saved {out_file}")
58+
return out_file
59+
except Exception as e:
60+
logger.exception(e)
61+
raise e

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ requires-python = ">=3.12"
2020
dependencies = [
2121
"click",
2222
"loguru",
23+
"piexif",
2324
"pillow",
25+
"pillow-heif",
2426
]
2527
[project.scripts]
2628
batch_img = "batch_img.interface:cli"

pyproject_release.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ requires-python = ">=3.12"
2020
dependencies = [
2121
"click",
2222
"loguru",
23+
"piexif",
2324
"pillow",
25+
"pillow-heif",
2426
]
2527
[project.scripts]
2628
batch_img = "batch_img.interface:cli"

tests/data/HEIC/IMG_0070.HEIC

197 KB
Binary file not shown.

0 commit comments

Comments
 (0)