Spaces:
Sleeping
Sleeping
File size: 5,863 Bytes
672c1ec | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | """ROICropper CLI — crop images to any aspect ratio."""
from __future__ import annotations
from pathlib import Path
import cv2
import numpy as np
import typer
from rich.console import Console
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
app = typer.Typer(help="Crop image objects to any aspect ratio focused on the main object.", add_completion=False)
console = Console()
def _parse_aspect_ratio(ctx: str) -> tuple[int, int]:
"""Parse aspect ratio string like '16:9' into (16, 9)."""
parts = ctx.split(":")
if len(parts) != 2:
raise typer.BadParameter(f"Invalid aspect ratio '{ctx}'. Expected format: W:H (e.g., 16:9)")
try:
w, h = int(parts[0]), int(parts[1])
if w <= 0 or h <= 0:
raise typer.BadParameter("Width and height must be positive integers.")
return (w, h)
except ValueError:
raise typer.BadParameter(f"Invalid aspect ratio '{ctx}'. Expected format: W:H (e.g., 16:9)")
def _determine_extension(fmt: str) -> str:
"""Return cv2-compatible extension based on format."""
fmt_lower = fmt.lower()
if fmt_lower == "jpg":
return ".jpg"
return ".png"
def _save_result(img: np.ndarray, output_path: str, fmt: str) -> None:
"""Save numpy array to file using cv2."""
outpath = Path(output_path)
outpath.parent.mkdir(parents=True, exist_ok=True)
ext = _determine_extension(fmt)
if ext == ".jpg":
cv2.imwrite(str(outpath.with_suffix(ext)), img, [cv2.IMWRITE_JPEG_QUALITY, 95])
else:
cv2.imwrite(str(outpath.with_suffix(".png")), img)
@app.command()
def crop(
input_path: str = typer.Argument(..., help="Input image path"),
output_path: str = typer.Argument(..., help="Output image path"),
size: int = typer.Option(640, "--size", "-s", help="Output size for square crop"),
aspect_ratio: str = typer.Option("1:1", "--aspect-ratio", "-a", help="Aspect ratio, e.g. 16:9"),
padding: int = typer.Option(10, "--padding", "-p", help="Padding in pixels around object"),
fmt: str = typer.Option("png", "--format", "-f", help="Output format: jpg or png"),
) -> None:
"""Crop a single image to a square or custom aspect ratio."""
from roicropper.core import crop_to_aspect_ratio, crop_to_square
ar = _parse_aspect_ratio(aspect_ratio)
if ar == (1, 1):
result = crop_to_square(input_path, target_size=size, padding_pixels=padding)
else:
result = crop_to_aspect_ratio(input_path, aspect_ratio=ar, max_size=size, padding_pixels=padding)
if result is None:
console.print(f"[red]Error:[/] Could not process {input_path}. No object found or file unreadable.")
raise typer.Exit(1)
_save_result(result, output_path, fmt)
console.print(f"[green]Processed:[/] {input_path} -> {output_path}")
@app.command()
def batch(
input_dir: str = typer.Argument(..., help="Input directory containing images"),
output_dir: str = typer.Argument(..., help="Output directory for processed images"),
size: int = typer.Option(640, "--size", "-s", help="Output size for square crop"),
aspect_ratio: str = typer.Option("1:1", "--aspect-ratio", "-a", help="Aspect ratio, e.g. 16:9"),
padding: int = typer.Option(10, "--padding", "-p", help="Padding in pixels around object"),
fmt: str = typer.Option("png", "--format", "-f", help="Output format: jpg or png"),
) -> None:
"""Process all images in a directory."""
from roicropper.core import crop_to_aspect_ratio, crop_to_square
ar = _parse_aspect_ratio(aspect_ratio)
input_path = Path(input_dir)
output_path = Path(output_dir)
output_path.mkdir(exist_ok=True, parents=True)
supported_exts = (".jpg", ".jpeg", ".png", ".bmp", ".tiff")
images = []
for ext in supported_exts:
images.extend(input_path.glob(f"*{ext}"))
if not images:
console.print(f"[yellow]Warning:[/] No supported images found in {input_dir}")
return
count = 0
failed = 0
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TextColumn("{task.completed}/{task.total}"), console=console) as progress:
task = progress.add_task("Processing...", total=len(images))
for img_path in sorted(images):
try:
if ar == (1, 1):
result = crop_to_square(str(img_path), target_size=size, padding_pixels=padding)
else:
result = crop_to_aspect_ratio(str(img_path), aspect_ratio=ar, max_size=size, padding_pixels=padding)
if result is None:
console.print(f" [red]Skipped:[/] {img_path.name} (no object)")
failed += 1
continue
out_file = output_path / img_path.name
_save_result(result, str(out_file), fmt)
count += 1
progress.advance(task)
except OSError as e:
console.print(f" [red]Error processing {img_path.name}:[/] {e}")
failed += 1
progress.advance(task)
status = f"[green]Done:[/] {count} processed, {failed} skipped."
console.print(status)
@app.command()
def web(
host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to"),
port: int = typer.Option(7860, "--port", "-p", help="Port to listen on"),
) -> None:
"""Launch the Gradio web interface."""
try:
import gradio
except ImportError:
console.print("[red]Error:[/] Gradio is not installed. Run: pip install 'roicropper[web]'")
raise typer.Exit(1)
from roicropper.web import launch
console.print(f"[green]Starting:[/] ROICropper web UI at http://{host}:{port}")
launch(server_name=host, server_port=port)
if __name__ == "__main__":
app()
|