diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9994a39..ef45441 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,12 +9,9 @@ jobs: pypi-publish: name: Build and publish to PyPI runs-on: ubuntu-latest -# environment: -# name: pypi -# url: https://pypi.org/p/batch-img + permissions: id-token: write # Mandatory for PyPI trusted publishing -# contents: read steps: - uses: actions/checkout@v5 diff --git a/batch_img/common.py b/batch_img/common.py index 4e01270..95e399f 100644 --- a/batch_img/common.py +++ b/batch_img/common.py @@ -33,6 +33,7 @@ PKG_NAME, REPLACE, TS_2_MINUTE, + TS_FORMAT2, UNKNOWN, VER, ) @@ -199,20 +200,20 @@ def file_to_base64(file: Path) -> str: return b64encode(data).decode("utf-8") @staticmethod - def easy_file_sz(in_bytes: int) -> str: + def easy_file_sz(byte: int) -> str: """Convert bytes to human-readable KB, MB, or GB Args: - in_bytes: input bytes integer + byte: input bytes integer Returns: str """ for _unit in ["B", "KB", "MB", "GB"]: - if in_bytes < 1024: + if byte < 1024: break - in_bytes /= 1024 - res = f"{in_bytes} B" if _unit == "B" else f"{in_bytes:.1f} {_unit}" + byte /= 1024.0 + res = f"{int(byte)} {_unit}" if _unit in {"B", "KB"} else f"{byte:.1f} {_unit}" return res @staticmethod @@ -326,6 +327,11 @@ def get_image_data(file: Path) -> tuple: if EXIF in img.info: exif_data = img.info.pop(EXIF) d_info[EXIF] = Common.decode_exif(exif_data) + # Convert "2025:05:29 12:00:48" to "2025-05-29 12:00" + if "DateTime" in d_info[EXIF]: + dt_str = d_info[EXIF]["DateTime"] + tmp = datetime.strptime(dt_str, TS_FORMAT2).strftime(TS_2_MINUTE) + d_info[EXIF]["DateTime"] = tmp return data, Common.sort_nested_dict(d_info) diff --git a/batch_img/const.py b/batch_img/const.py index 774908f..1f0ddce 100644 --- a/batch_img/const.py +++ b/batch_img/const.py @@ -14,6 +14,7 @@ TS_FORMAT = "%Y-%m-%d_%H-%M-%S" TS_2_MINUTE = "%Y-%m-%d %H:%M" +TS_FORMAT2 = "%Y:%m:%d %H:%M:%S" PATTERNS = ( "*.HEIC", "*.heic", diff --git a/batch_img/info.py b/batch_img/info.py index b7c4c22..c07a87b 100644 --- a/batch_img/info.py +++ b/batch_img/info.py @@ -51,6 +51,39 @@ def exif_output_path() -> Path: """Return the quiet-mode EXIF report path in the working directory.""" return Path.cwd() / INFO_TXT_FILE + @staticmethod + def do_output(success_cnt: int, total: int, results: dict, quiet: bool) -> bool: + """ + Output EXIF metadata + + Args: + success_cnt: success readings count + total: total input files count + results: Dictionary of file paths to results + quiet: If True, write formatted results to a file instead of stdout + + Returns: + bool: True if all images were processed successfully, False otherwise + """ + if quiet: # dump to a file if --quiet + output_file = Info.exif_output_path() + try: + with open(output_file, "w", encoding="utf-8") as output: + Info._write_formatted_info(output, total, results) + log.info(f"EXIF information written to {output_file}") + return success_cnt == total + except OSError as exc: + log.error(f"Failed to write EXIF information to {output_file}: {exc}") + return False + + # Print to stdout + idx = 1 + for file, data in results.items(): + Info._output_exif_info(file, data, idx, total) + idx += 1 + log.info(f"\nRead meta info from {success_cnt}/{total} files") + return success_cnt == total + @staticmethod def read_exif(in_path: Path, quiet: bool = False) -> bool: """Read EXIF metadata for an image or all supported images in a directory. @@ -96,38 +129,23 @@ def read_exif(in_path: Path, quiet: bool = False) -> bool: log.error(f"Error reading {file}: {exc}") success_count = len(results) - - if quiet: - output_file = Info.exif_output_path() - try: - with open(output_file, "w", encoding="utf-8") as output: - Info._write_formatted_info(output, files, results) - log.info(f"EXIF information written to {output_file}") - return success_count == len(files) - except OSError as exc: - log.error(f"Failed to write EXIF information to {output_file}: {exc}") - return False - - # Print results in input order - for idx, file in enumerate(files, 1): - if file in results: - Info._output_exif_info(file, results[file], idx, len(files)) - - log.info(f"\nRead meta info from {success_count}/{len(files)} files") - return success_count == len(files) + total = len(files) + results = dict(sorted(results.items())) # sort results for deterministic output + return Info.do_output(success_count, total, results, quiet) @staticmethod - def _write_formatted_info(output: TextIO, files: list, results: dict) -> None: + def _write_formatted_info(output: TextIO, total: int, results: dict) -> None: """Write formatted EXIF information to a file. Args: output: File object to write to - files: List of image files + total: total input files count results: Dictionary of file paths to results """ - for idx, file in enumerate(files, 1): - if file in results: - Info._output_exif_info(file, results[file], idx, len(files), output) + idx = 1 + for file, data in results.items(): + Info._output_exif_info(file, data, idx, total, output) + idx += 1 @staticmethod def _output_exif_info( @@ -197,9 +215,9 @@ def _out(text: str) -> None: else: value = f"{value} s" elif key == "FNumber" and isinstance(value, tuple): - value = f"f/{value[0] / value[1]:.2f}" + value = f"f/{value[0] / value[1]:.1f}" elif key == "FNumber" and isinstance(value, (float, int)): - value = f"f/{value:.2f}" + value = f"f/{value:.1f}" elif key == "FocalLength" and isinstance(value, tuple): value = f"{value[0] / value[1]:.2f} mm" elif key == "FocalLength" and isinstance(value, (float, int)): @@ -208,3 +226,4 @@ def _out(text: str) -> None: value = f"ISO {value}" _out(f" {label:<15}: {value}") + _out("") diff --git a/pyproject.toml b/pyproject.toml index 9b8003b..bf35dc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "batch_img" -version = "1.4.1" +version = "1.4.2" description = "Batch process (resize, rotate, remove background, remove GPS, add border, set transparency, auto do all) image files (HEIC, JPG, PNG)" readme = "README.md" authors = [{ name = "John Liu", email = "rim2rim@gmail.com" }] @@ -27,7 +27,7 @@ dependencies = [ "httpx", "loguru", "numpy", - "onnxruntime==1.24.4", + "onnxruntime", "opencv-python==4.13.0.92", # 4.x version has cv2.CascadeClassifier() "packaging", "piexif", diff --git a/tests/test_common.py b/tests/test_common.py index 4cf1654..c3fb3b4 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -33,7 +33,7 @@ def os_platform(request): return request.param -@pytest.fixture(params=[(PKG_NAME, "1.4.1"), ("", "1.4.1")]) +@pytest.fixture(params=[(PKG_NAME, "1.4.2"), ("", "1.4.2")]) def ver_data(request): return request.param @@ -49,7 +49,7 @@ def test_get_version(ver_data): ( "1.9.9", PKG_NAME, - f"🔔 Update available: 1.4.1 → 1.9.9\nRun '{PKG_NAME} --update'", + f"🔔 Update available: 1.4.2 → 1.9.9\nRun '{PKG_NAME} --update'", ), ] ) @@ -67,7 +67,7 @@ def test_check_latest_version(mock_get_latest_pypi, data_check_latest_version): @pytest.fixture( params=[ - (PKG_NAME, 0, "0.3.9"), + (PKG_NAME, 0, "1.4.1"), ("bad_bogus", 1, UNKNOWN), ] ) @@ -245,10 +245,10 @@ def test_file_to_base64(data_file_to_base64): @pytest.fixture( params=[ (1023, "1023 B"), - (1025, "1.0 KB"), - (101988, "99.6 KB"), - (201554, "196.8 KB"), - (1024**2 + 99, "1.0 MB"), + (1025, "1 KB"), + (101988, "99 KB"), + (201554, "196 KB"), + (1024**2 + 1024 * 60, "1.1 MB"), (2 * (1024**3) + 99, "2.0 GB"), ] ) @@ -509,7 +509,7 @@ def test_sort_nested_dict(data_nested_dict): ( Path(f"{_dir}/data/HEIC/Cartoon.heic"), { - "file_size": "44.6 KB", + "file_size": "44 KB", "file_ts": "2025-08-16 23:44", "format": "HEIF", "mode": "RGB", @@ -531,7 +531,7 @@ def test_sort_nested_dict(data_nested_dict): ( Path(f"{_dir}/data/HEIC/Cartoon_180cw.heic"), { - "file_size": "42.4 KB", + "file_size": "42 KB", "file_ts": "2025-08-17 11:05", "format": "HEIF", "mode": "RGB", @@ -550,6 +550,58 @@ def test_sort_nested_dict(data_nested_dict): "exif": {"ExifTag": 114, "Orientation": 1}, }, ), + ( + Path(f"{_dir}/data/HEIC/IMG_2530.HEIC"), + { + "file_size": "143 KB", + "file_ts": "2025-08-17 11:05", + "format": "HEIF", + "mode": "RGB", + "size": (1920, 1440), + "info": { + "aux": {}, + "bit_depth": 8, + "chroma": 420, + "depth_images": [], + "icc_profile_type": "prof", + "metadata": [], + "original_orientation": None, + "primary": True, + "thumbnails": [], + }, + "exif": { + "ColorSpace": 65535, + "DateTime": "2023-12-31 16:00", + "DateTimeDigitized": "2023:12:31 16:00:41", + "DateTimeOriginal": "2023:12:31 16:00:41", + "ExifTag": 242, + "ExifVersion": "0232", + "ExposureMode": 0, + "ExposureProgram": 2, + "ExposureTime": ( + 1, + 1689, + ), + "FNumber": ( + 14, + 5, + ), + "Flash": 16, + "FocalLength": ( + 2052196, + 131047, + ), + "FocalLengthIn35mmFilm": 120, + "ISOSpeedRatings": 50, + "Make": "Apple", + "MeteringMode": 3, + "Model": "iPhone 15 Pro Max", + "Orientation": 1, + "SensingMethod": 2, + "WhiteBalance": 0, + }, + }, + ), ] ) def data_get_image(request): diff --git a/tests/test_info.py b/tests/test_info.py index 230c287..37dcea4 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -43,7 +43,7 @@ def mock_meta_data(): Path(f"{_dir}/data/HEIC/Cartoon_180cw.heic"), { "file_info": { - "file_size": "42.4 KB", + "file_size": "42 KB", "last_modified": "2025-08-17 11:05", "format": "HEIF", "dimensions": "758 x 758", @@ -59,7 +59,7 @@ def mock_meta_data(): Path(f"{_dir}/data/HEIC/IMG_2527.HEIC"), { "file_info": { - "file_size": "153.4 KB", + "file_size": "153 KB", "last_modified": "2026-03-04 11:53", "format": "HEIF", "dimensions": "1920 x 1440", @@ -70,7 +70,7 @@ def mock_meta_data(): }, EXIF: { "ColorSpace": 65535, - "DateTime": "2023:12:31 15:57:52", + "DateTime": "2023-12-31 15:57", "DateTimeDigitized": "2023:12:31 15:57:52", "DateTimeOriginal": "2023:12:31 15:57:52", "ExifTag": 242,