├── requirements.txt ├── preset.example.json ├── README.md ├── classes.py ├── LICENSE ├── .gitignore ├── utils.py └── main.py /requirements.txt: -------------------------------------------------------------------------------- 1 | InquirerPy==0.3.4 2 | Pillow==10.3.0 3 | tqdm==4.66.2 4 | -------------------------------------------------------------------------------- /preset.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "img_size": 3200, 3 | "img_format": "webp", 4 | "img_quality": 90, 5 | "keep_alpha": true, 6 | "concurrency": "max" 7 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image-Resample 2 | 3 | 自用的批量图片重采样工具,可限制图片大小和进行图片格式转换。 4 | 5 | 一般来说,图片压缩会损失一部分画质,但是对于大多数应用场景来说,这种损失是无法察觉的。例如,某画廊原图为 4000px 的 png 图片,总大小 9GB。在压缩为 3200px 的 90% 质量的 jpg 图片后,总大小 526MB,同时几乎无法察觉到区别。 6 | 7 | 特性: 8 | 9 | - 可限制图片长边大小进行等比缩放 10 | - 输入格式:jpg/jpeg, png, webp, bmp, gif, tiff 11 | - 输出格式:jpg/jpeg, png, webp 12 | - png, webp 支持保留透明度 13 | - jpg/jpeg, webp 支持设置质量 (0~100) 14 | - 转换时原样保留文件夹结构 15 | - 支持多进程并行处理 16 | - 输入与输出均可为 zip 文件 17 | - 支持交互式命令行设置,也支持读取 json 格式预设 18 | - ~~完美解决 pixiv 画师的图片文件无比巨大的问题~~ -------------------------------------------------------------------------------- /classes.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | 4 | from InquirerPy.validator import PathValidator 5 | 6 | 7 | @dataclass 8 | class Config: 9 | input_path: Path = Path() 10 | output_path: Path = Path() 11 | input_tmp_path: Path = None 12 | output_tmp_path: Path = None 13 | img_size: int = 2400 14 | img_format: str = "jpg" 15 | img_quality: int = -1 # quality = -1 特指 png 格式 16 | keep_alpha: bool = False 17 | concurrency: int = 8 18 | 19 | 20 | class PathValidatorWithoutQuote(PathValidator): 21 | def validate(self, document) -> None: 22 | """注意,该验证器强制修改了私有变量_text,因此输出时也不会带引号""" 23 | document._text = document.text.strip("\"").strip("\'") 24 | super().validate(document) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Haotian Zou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | preset.json 3 | 4 | ### Python template 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | 144 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import tempfile 4 | import zipfile 5 | from concurrent.futures import ProcessPoolExecutor, as_completed 6 | from pathlib import Path 7 | 8 | from PIL import Image 9 | from tqdm import tqdm 10 | 11 | from classes import Config 12 | 13 | 14 | def resample_img(img_path: Path, save_path: Path, limit: int = 2400, quality: int = 100, 15 | keep_alpha: bool = False) -> str: 16 | """ 17 | 重采样图片,基于pillow库 18 | :param img_path: 原始图片路径 19 | :param save_path: 保存图片路径 20 | :param limit: 长边限制 21 | :param quality: 压缩质量 22 | :param keep_alpha: 是否保留透明度 23 | :return: 保存图片的文件名,如果以[ERR]开头则表示出错 24 | """ 25 | try: 26 | img = Image.open(img_path) 27 | width, height = img.size 28 | channel = len(img.getbands()) 29 | if width > height and width > limit: 30 | img = img.resize((limit, int(limit / width * height))) 31 | elif width <= height and height > limit: 32 | img = img.resize((int(limit / height * width), limit)) 33 | img = img.convert("RGBA" if (keep_alpha and channel == 4) else "RGB") 34 | img.save(save_path, **({"quality": quality} if quality != -1 else {})) 35 | return save_path.name 36 | except Exception as e: 37 | return f"[ERR] {e}" 38 | 39 | 40 | def prepare_resample_tasks(config: Config, img_list: list[Path]) -> list[tuple]: 41 | """ 42 | 生成重采样任务列表 43 | :param config: 任务配置 44 | :param img_list: 图片列表 45 | :return: 任务列表 46 | """ 47 | tasks = [] 48 | with tqdm(total=len(img_list), dynamic_ncols=True) as pbar: 49 | for img_path in img_list: 50 | input_path = config.input_path if config.input_path.is_dir() else config.input_tmp_path 51 | output_path = config.output_path if config.output_path.is_dir() else config.output_tmp_path 52 | 53 | old_path = img_path.parent 54 | new_path = output_path / old_path.relative_to(input_path) # 保留原文件夹结构 55 | if not new_path.exists(): 56 | new_path.mkdir(parents=True) 57 | 58 | file_name, file_ext = img_path.stem, img_path.suffix 59 | save_img_path = new_path / f"{file_name}.{config.img_format}" 60 | 61 | tasks.append((resample_img, img_path, save_img_path, 62 | config.img_size, config.img_quality, config.keep_alpha)) 63 | pbar.set_description(f"{file_name}.{file_ext}".ljust(24)[:24]) 64 | pbar.update(1) 65 | return tasks 66 | 67 | 68 | def execute_tasks(config: Config, tasks: list) -> list: 69 | """ 70 | 执行任务列表 71 | :param config: 任务配置 72 | :param tasks: 任务列表 73 | :return: 错误列表 74 | """ 75 | err = [] 76 | with ProcessPoolExecutor(max_workers=config.concurrency) as executor: 77 | futures = [executor.submit(*task) for task in tasks] 78 | with tqdm(total=len(futures), dynamic_ncols=True) as pbar: 79 | for future in as_completed(futures): 80 | res = future.result() 81 | if res.startswith("[ERR] "): 82 | err.append(res[6:]) 83 | pbar.set_description(f"{res}".ljust(24)[:24]) 84 | pbar.update(1) 85 | return err 86 | 87 | 88 | def list_all_files(directory: Path) -> list[Path]: 89 | """ 90 | 列出文件夹下所有文件 91 | :param directory: 文件夹路径 92 | :return: 文件列表 93 | """ 94 | return [file for file in Path(directory).rglob('*') if file.is_file()] 95 | 96 | 97 | def filter_images(file_list: list[Path]) -> list[Path]: 98 | """ 99 | 过滤出图片文件 100 | :param file_list: 原始文件列表 101 | :return: 仅包含图片文件的文件列表 102 | """ 103 | image_extensions = [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp"] 104 | return [filepath for filepath in file_list if (filepath.suffix.lower() in image_extensions)] 105 | 106 | 107 | def load_preset(preset_path: str = "preset.json") -> dict: 108 | """ 109 | 读取预设配置 110 | :param preset_path: 配置文件路径 111 | :return: 预设配置 112 | """ 113 | if not os.path.exists(preset_path): 114 | return {} 115 | with open(preset_path, "r", encoding="utf-8") as f: 116 | return json.load(f) 117 | 118 | 119 | def parse_concurrency(concurrency: str | int) -> int: 120 | """ 121 | 解析并行数 122 | :param concurrency: 预设中的并行数字符串/整数 123 | :return: 并行数 124 | """ 125 | if isinstance(concurrency, int): 126 | return concurrency if concurrency > 0 else 1 127 | if concurrency == "max": 128 | return os.cpu_count() or 1 129 | elif concurrency == "half": 130 | return (os.cpu_count() // 2) or 1 131 | elif concurrency.isdigit(): 132 | return int(concurrency) 133 | else: 134 | return 1 135 | 136 | 137 | def unzip_to_tmp(zip_path: Path) -> Path: 138 | """ 139 | 解压zip文件到临时文件夹 140 | :param zip_path: zip文件路径 141 | :return: 临时文件夹路径 142 | """ 143 | with zipfile.ZipFile(zip_path, "r") as zip_ref: 144 | tmp_dir = Path(tempfile.mkdtemp()) 145 | zip_ref.extractall(tmp_dir) 146 | return tmp_dir 147 | 148 | 149 | def make_zip(zip_path: Path, dir_path: Path) -> Path: 150 | """ 151 | 压缩文件夹到zip 152 | :param zip_path: zip文件路径 153 | :param dir_path: 文件夹路径 154 | :return: zip文件路径 155 | """ 156 | with zipfile.ZipFile(zip_path, "w") as zip_ref: 157 | for root, _, files in os.walk(dir_path): 158 | for file in files: 159 | zip_ref.write(os.path.join(root, file), os.path.relpath(os.path.join(root, file), dir_path)) 160 | return zip_path 161 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import os 3 | import shutil 4 | import tempfile 5 | from pathlib import Path 6 | 7 | from InquirerPy import inquirer 8 | from InquirerPy.utils import color_print 9 | from InquirerPy.validator import NumberValidator 10 | 11 | from classes import Config, PathValidatorWithoutQuote 12 | from utils import list_all_files, filter_images, load_preset, prepare_resample_tasks, execute_tasks, parse_concurrency, \ 13 | unzip_to_tmp, make_zip 14 | 15 | 16 | def get_input_output() -> tuple[None, None] | tuple[Path, Path]: 17 | input_path = inquirer.filepath( 18 | message="原图文件夹或压缩包:", 19 | validate=PathValidatorWithoutQuote(message="请输入合法路径"), 20 | ).execute() 21 | output_path = inquirer.filepath( 22 | message="目标文件夹或压缩包:", 23 | ).execute() 24 | input_path = input_path.strip("\"").strip("\'") 25 | output_path = output_path.strip("\"").strip("\'") 26 | 27 | input_path = Path(input_path).resolve() 28 | output_path = Path(output_path).resolve() 29 | 30 | if input_path == output_path: 31 | color_print([("red", "[x] 安全起见,输入和输出路径不能相同")]) 32 | return None, None 33 | 34 | if not input_path.exists(): 35 | color_print([("red", "[x] 输入路径不存在")]) 36 | return None, None 37 | # 此时 input_path 一定存在 38 | 39 | if input_path.is_file() and input_path.suffix.lower() != ".zip": 40 | color_print([("red", "[x] 压缩包必须是 zip 格式")]) 41 | return None, None 42 | # 此时 input_path 一定是文件夹或 zip 压缩包 43 | 44 | if output_path.exists(): 45 | if output_path.is_file(): 46 | confirm = inquirer.confirm( 47 | message="输出文件已存在,是否覆盖?", 48 | default=False, 49 | ).execute() 50 | if not confirm: 51 | return None, None 52 | if output_path.is_dir() and any(output_path.iterdir()): 53 | confirm = inquirer.confirm( 54 | message="输出文件夹非空,是否继续?", 55 | default=False, 56 | ).execute() 57 | if not confirm: 58 | return None, None 59 | else: # output_path 不存在 60 | if output_path.suffix.lower() == ".zip": 61 | output_path.touch() 62 | assert output_path.is_file() 63 | else: 64 | output_path.mkdir(parents=True) 65 | assert output_path.is_dir() 66 | # 此时 output_path 一定存在,且是文件夹或 zip 压缩包(零大小) 67 | 68 | return input_path, output_path 69 | 70 | 71 | def get_config() -> Config: 72 | input_path, output_path = None, None 73 | while input_path is None or output_path is None: 74 | input_path, output_path = get_input_output() 75 | 76 | preset = load_preset() 77 | config = Config(input_path=input_path, output_path=output_path) 78 | 79 | if preset != {}: 80 | color_print([("green", "[*] 检测到预设配置如下:")]) 81 | for k, v in preset.items(): 82 | color_print([("green", f" - {k}: {v}")]) 83 | confirm = inquirer.confirm( 84 | message="是否使用?", 85 | default=True, 86 | ).execute() 87 | if not confirm: 88 | preset = {} 89 | 90 | config.img_size = inquirer.text( 91 | message="尺寸限制 (限制长边,单位像素):", 92 | validate=NumberValidator(message="请输入合法数字"), 93 | default="2400", 94 | filter=lambda result: int(result), 95 | ).execute() if ("img_size" not in preset) else preset["img_size"] 96 | 97 | config.img_format = inquirer.select( 98 | message="压缩格式:", 99 | choices=["jpg", "webp", "png"], 100 | ).execute() if ("img_format" not in preset) else preset["img_format"] 101 | 102 | if config.img_format != "png": 103 | config.img_quality = inquirer.text( 104 | message="压缩质量 (1-100):", 105 | validate=NumberValidator(message="请输入合法数字"), 106 | default="90", 107 | filter=lambda result: int(result), 108 | ).execute() if ("img_quality" not in preset) else preset["img_quality"] 109 | 110 | if config.img_format != "jpg": 111 | config.keep_alpha = inquirer.confirm( 112 | message="是否保留透明度?", 113 | default=True, 114 | ).execute() if ("keep_alpha" not in preset) else preset["keep_alpha"] 115 | 116 | concurrency = inquirer.text( 117 | message="并行数:", 118 | default="max", 119 | ).execute() if ("concurrency" not in preset) else preset["concurrency"] 120 | config.concurrency = parse_concurrency(concurrency) 121 | 122 | return config 123 | 124 | 125 | def get_image_list(config: Config) -> list[Path]: 126 | directory = config.input_path 127 | if config.input_path.is_file(): 128 | assert config.input_path.suffix.lower() == ".zip" 129 | color_print([("green", "[*] 解压中...")]) 130 | config.input_tmp_path = unzip_to_tmp(config.input_path) 131 | color_print([("green", "[*] 解压完成")]) 132 | directory = config.input_tmp_path 133 | 134 | color_print([("green", "[*] 遍历文件夹中...")]) 135 | img_list = list_all_files(directory) 136 | color_print([("green", "[*] 遍历完成: "), ("yellow", f"共 {len(img_list)} 个文件")]) 137 | 138 | color_print([("green", "[*] 过滤非图片...")]) 139 | img_list = filter_images(img_list) 140 | color_print([("green", "[*] 过滤完成: "), ("yellow", f"共 {len(img_list)} 个图片")]) 141 | 142 | return img_list 143 | 144 | 145 | def start_process(config: Config, img_list: list[Path]) -> None: 146 | if config.output_path.is_file(): 147 | assert config.output_path.suffix.lower() == ".zip" 148 | config.output_tmp_path = Path(tempfile.mkdtemp()) 149 | 150 | color_print([("green", "[*] 生成任务...")]) 151 | tasks = prepare_resample_tasks(config, img_list) 152 | color_print([("green", "[*] 开始任务...")]) 153 | err = execute_tasks(config, tasks) 154 | color_print([("green", "[*] 处理完成")]) 155 | 156 | for i, e in enumerate(err): # 打印错误信息 157 | color_print([("red", f"[x] #{i} {e}")]) 158 | 159 | 160 | def make_zip_result(config: Config) -> None: 161 | color_print([("green", "[*] 压缩中...")]) 162 | make_zip(config.output_path, config.output_tmp_path) 163 | color_print([("green", "[*] 压缩完成")]) 164 | 165 | 166 | def cleanup(config: Config) -> None: 167 | color_print([("green", "[*] 清理中...")]) 168 | if (config.input_tmp_path is not None and 169 | inquirer.confirm(message=f"确认删除{config.input_tmp_path}?", default=True).execute()): 170 | shutil.rmtree(config.input_tmp_path) 171 | if (config.output_tmp_path is not None and 172 | inquirer.confirm(message=f"确认删除{config.output_tmp_path}?", default=True).execute()): 173 | shutil.rmtree(config.output_tmp_path) 174 | color_print([("green", "[*] 清理完成")]) 175 | 176 | 177 | def main() -> None: 178 | os.system("clear" if os.name == "posix" else "cls") 179 | color_print([("green", "图片重采样工具 v3.1.4π"), ("yellow", " @ChrisKimZHT")]) 180 | config: Config = get_config() 181 | img_list = get_image_list(config) 182 | 183 | if not inquirer.confirm(message="确认开始处理吗?", default=True).execute(): 184 | return 185 | 186 | start_process(config, img_list) 187 | 188 | if config.output_path.is_file(): 189 | make_zip_result(config) 190 | 191 | cleanup(config) 192 | 193 | if not inquirer.confirm(message="是否继续?", default=False).execute(): 194 | exit(0) 195 | 196 | 197 | if __name__ == '__main__': 198 | multiprocessing.freeze_support() 199 | while True: 200 | main() 201 | --------------------------------------------------------------------------------