184 lines
4.9 KiB
Python
Executable File
184 lines
4.9 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# dependencies = []
|
|
# ///
|
|
|
|
import argparse
|
|
import logging
|
|
import subprocess
|
|
from threading import Thread
|
|
from typing import Callable
|
|
from pathlib import Path
|
|
|
|
GIGAPIXEL_EXE = Path(
|
|
"/Applications/Topaz Gigapixel AI.app/Contents/MacOS/Topaz Gigapixel AI"
|
|
)
|
|
MAX_LENGTH = 4000
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Upscale images using Topaz Gigapixel AI CLI.",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"images",
|
|
nargs="+",
|
|
type=Path,
|
|
help="One or more image files to upscale.",
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=("jpg", "png"),
|
|
default="jpg",
|
|
help="Output image format.",
|
|
)
|
|
parser.add_argument(
|
|
"--max-length",
|
|
type=int,
|
|
default=4000,
|
|
help="Maximum length of the longest side of the output image.",
|
|
)
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Regenerate images even if upscaled outputs already exist.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def filter_out_upscaled(
|
|
images: list[Path], force: bool, output_format: str
|
|
) -> list[Path]:
|
|
if force:
|
|
return images
|
|
|
|
filtered: list[Path] = []
|
|
for img_path in images:
|
|
if img_path.stem.endswith("-upscaled"):
|
|
continue
|
|
output_dir = img_path.parent
|
|
output_stem = f"{img_path.stem}-upscaled"
|
|
upscaled_candidates = [output_dir / f"{output_stem}.{output_format}"]
|
|
if output_format == "jpg":
|
|
upscaled_candidates.append(output_dir / f"{output_stem}.jpeg")
|
|
if any(candidate.exists() for candidate in upscaled_candidates):
|
|
continue
|
|
filtered.append(img_path)
|
|
|
|
return filtered
|
|
|
|
|
|
def upscale_images(
|
|
images: list[Path],
|
|
output_dir: Path,
|
|
output_format: str,
|
|
on_progress: Callable[[Path], None],
|
|
) -> None:
|
|
cmd = [
|
|
str(GIGAPIXEL_EXE),
|
|
"--cli",
|
|
"--verbose",
|
|
"--parallel",
|
|
"2",
|
|
"--input",
|
|
*[str(img) for img in images],
|
|
"--output",
|
|
str(output_dir),
|
|
"--height",
|
|
str(MAX_LENGTH),
|
|
"--image-format",
|
|
output_format,
|
|
"--suffix",
|
|
"-upscaled",
|
|
]
|
|
|
|
if output_format == "jpg":
|
|
cmd.extend(["--jpeg-quality", "75"])
|
|
|
|
def stream_stdout(process: subprocess.Popen[str]) -> None:
|
|
if process.stdout is None:
|
|
return
|
|
for line in process.stdout:
|
|
if "Saved file:" not in line:
|
|
continue
|
|
parts = line.split('"')
|
|
if len(parts) < 3:
|
|
continue
|
|
saved_path = parts[-2].strip()
|
|
if saved_path:
|
|
on_progress(Path(saved_path))
|
|
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
stdout_thread = Thread(target=stream_stdout, args=(process,), daemon=True)
|
|
stdout_thread.start()
|
|
stderr = process.stderr.read() if process.stderr is not None else ""
|
|
return_code = process.wait()
|
|
stdout_thread.join()
|
|
if return_code != 0:
|
|
raise subprocess.CalledProcessError(return_code, cmd, stderr=stderr)
|
|
|
|
|
|
def main() -> None:
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s"
|
|
)
|
|
args = parse_args()
|
|
global MAX_LENGTH
|
|
MAX_LENGTH = args.max_length
|
|
|
|
if not GIGAPIXEL_EXE.exists():
|
|
logging.error(f"Gigapixel executable not found at {GIGAPIXEL_EXE}")
|
|
return
|
|
|
|
valid_images = [
|
|
img_path.resolve()
|
|
for img_path in args.images
|
|
if img_path.is_file()
|
|
and img_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp", ".webm"}
|
|
]
|
|
if not valid_images:
|
|
logging.warning("No valid image files found")
|
|
return
|
|
|
|
filtered_images = filter_out_upscaled(valid_images, args.force, args.format)
|
|
skipped_count = len(valid_images) - len(filtered_images)
|
|
if skipped_count:
|
|
logging.info(f"Skipping {skipped_count} already upscaled image(s)")
|
|
|
|
if not filtered_images:
|
|
logging.warning("No images left to process")
|
|
return
|
|
|
|
output_dir = filtered_images[0].parent
|
|
|
|
total_images = len(filtered_images)
|
|
completed_images = 0
|
|
|
|
def on_progress(path: Path) -> None:
|
|
nonlocal completed_images
|
|
completed_images += 1
|
|
logging.info(f"Saved file: {path.name} [{completed_images}/{total_images}]")
|
|
|
|
logging.info(f"Processing {total_images} image(s)")
|
|
try:
|
|
upscale_images(
|
|
filtered_images,
|
|
output_dir,
|
|
args.format,
|
|
on_progress,
|
|
)
|
|
logging.info("All images upscaled successfully")
|
|
except subprocess.CalledProcessError as e:
|
|
logging.error(f"Error processing images: {e.stderr}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|