File size: 12,249 Bytes
3050f1b
 
 
3f8604c
 
3050f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f8604c
 
 
 
 
 
 
 
3050f1b
 
 
3f8604c
3050f1b
3f8604c
 
3050f1b
 
3f8604c
3050f1b
 
 
 
3f8604c
3050f1b
 
 
 
 
3f8604c
 
3050f1b
 
 
 
 
 
 
 
 
 
3f8604c
3050f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f8604c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3050f1b
 
 
 
 
 
 
3f8604c
 
 
 
3050f1b
 
3f8604c
 
 
3050f1b
 
 
3f8604c
 
 
3050f1b
 
3f8604c
 
 
 
 
 
3050f1b
3f8604c
 
 
3050f1b
3f8604c
 
 
 
 
 
3050f1b
 
 
3f8604c
 
3050f1b
3f8604c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3050f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f8604c
 
 
 
3050f1b
 
 
 
 
3f8604c
 
 
 
 
3050f1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f8604c
3050f1b
 
 
 
3f8604c
3050f1b
 
 
 
 
 
 
 
 
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
"""Process a directory of images through NisabaRelief and save as PNG."""

import argparse
import warnings
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

from PIL import Image
from rich.console import Console
from rich.progress import (
    BarColumn,
    MofNCompleteColumn,
    Progress,
    ProgressColumn,
    SpinnerColumn,
    Task,
    TextColumn,
    TimeElapsedColumn,
)
from rich.text import Text

from nisaba_relief import NisabaRelief
from nisaba_relief.constants import MAX_TILE, MIN_IMAGE_DIMENSION

Image.MAX_IMAGE_PIXELS = None

IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp", ".gif"}

SKIP_LABELS = {
    "small": "image(s) smaller than {min_size}px",
    "empty": "mostly-empty image(s)",
    "bw": "black-and-white image(s)",
    "corrupt": "corrupt/truncated image(s)",
}


class SimpleTimeRemainingColumn(ProgressColumn):
    """Estimates remaining time from the average duration of recent iterations.

    The window is 0.5% of the task total (minimum 1, maximum 200). Only recomputes when a new
    step completes so the display is stable.
    """

    def __init__(self) -> None:
        super().__init__()
        self._last_completed: float = 0
        self._last_elapsed: float = 0.0
        self._durations: list[float] = []
        self._window: int = 0
        self._cached: Text = Text("-:--:--", style="progress.remaining")

    def render(self, task: Task) -> Text:
        if task.completed <= self._last_completed:
            return self._cached
        if not self._window and task.total:
            self._window = min(max(1, int(task.total * 0.005)), 200)
        elapsed = task.finished_time if task.finished else task.elapsed
        if not elapsed or not task.completed:
            self._last_completed = task.completed
            self._cached = Text("-:--:--", style="progress.remaining")
            return self._cached
        step_duration = elapsed - self._last_elapsed
        steps = task.completed - self._last_completed
        if steps > 0 and self._last_completed > 0:
            per_step = step_duration / steps
            self._durations.append(per_step)
            if self._window and len(self._durations) > self._window:
                self._durations = self._durations[-self._window :]
        self._last_completed = task.completed
        self._last_elapsed = elapsed
        if not self._durations:
            self._cached = Text("-:--:--", style="progress.remaining")
            return self._cached
        avg = sum(self._durations) / len(self._durations)
        remaining = task.total - task.completed
        eta_seconds = avg * remaining
        hours, rem = divmod(int(eta_seconds), 3600)
        minutes, seconds = divmod(rem, 60)
        if hours:
            self._cached = Text(
                f"{hours}:{minutes:02d}:{seconds:02d}", style="progress.remaining"
            )
        else:
            self._cached = Text(f"{minutes}:{seconds:02d}", style="progress.remaining")
        return self._cached


def _make_progress(label: str) -> Progress:
    """Build a Progress bar with the standard column layout."""
    return Progress(
        SpinnerColumn(),
        TextColumn(label),
        BarColumn(),
        MofNCompleteColumn(),
        TimeElapsedColumn(),
        TextColumn("eta"),
        SimpleTimeRemainingColumn(),
    )


def _classify_histogram(
    img: Image.Image,
    uniform_threshold: float,
    sat_threshold: float = 0.03,
    mid_threshold: float = 0.28,
    sample_size: int = 256,
) -> str | None:
    """Classify an image by its grayscale histogram. Returns a skip reason or None.

    Builds a single thumbnail + histogram and runs two checks:
    1. Black and White: lacking saturated colors and mid tones.
    2. Mostly-empty: a single non-black color dominates (±5 sliding window).
    """
    # JPEG: decode at reduced resolution via libjpeg DCT scaling (fast, low memory)
    # Other formats: no-op, thumbnail handles resize after full load
    img.draft("RGB", (sample_size, sample_size))
    img.thumbnail((sample_size, sample_size), Image.NEAREST)
    hist = img.convert("L").histogram()
    total = sum(hist)

    # Check if it contains only black and white with no midtones (eg: lineart, text screenshots)
    sat_hist = img.convert("HSV").split()[1].histogram()
    high_sat = sum(sat_hist[31:]) / total
    if high_sat < sat_threshold and sum(hist[45:205]) / total < mid_threshold:
        return "bw"

    # Check for dominant single color (sliding window of width 11, ±5)
    if uniform_threshold < 1:
        window = 11
        half = window // 2
        running = sum(hist[:window])
        best_count = running
        best_center = half
        for center in range(half + 1, 256 - half):
            running += hist[center + half] - hist[center - half - 1]
            if running > best_count:
                best_count = running
                best_center = center
        if best_center >= 10 and best_count / total >= uniform_threshold:
            return "empty"

    return None


def _check_image(
    src: Path, dst: Path, min_size: int, max_uniform: float
) -> tuple[Path, Path, str]:
    """Classify a single image for filtering. Returns (src, dst, status)."""
    try:
        with warnings.catch_warnings(), Image.open(src) as img:
            warnings.simplefilter("ignore", UserWarning)
            if max(img.size) < min_size or min(img.size) < MIN_IMAGE_DIMENSION:
                return src, dst, "small"
            reason = _classify_histogram(img, max_uniform)
            if reason:
                return src, dst, reason
    except (OSError, SyntaxError):
        return src, dst, "corrupt"
    return src, dst, "process"


def _parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Process images through NisabaRelief and save as PNG."
    )
    parser.add_argument(
        "--input-dir", type=Path, required=True, help="Source image directory"
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        required=True,
        help="Destination directory (created if needed)",
    )
    parser.add_argument(
        "--max-size",
        type=int,
        default=MAX_TILE * 5,
        help="Downsample images larger than this before processing (default: %(default)s)",
    )
    parser.add_argument(
        "--min-size",
        type=int,
        default=1536,
        help="Skip images where max dimension < this (default: %(default)s)",
    )
    parser.add_argument(
        "--max-uniform",
        type=float,
        default=0.65,
        help="Skip images where this fraction of pixels share a single non-black color (default: %(default)s, set to 1 to disable)",
    )
    parser.add_argument("--seed", type=int, default=None, help="Reproducibility seed")
    parser.add_argument(
        "--weights-dir", type=Path, default=None, help="Local weights directory"
    )
    parser.add_argument("--batch-size", type=int, default=None, help="Tile batch size")
    parser.add_argument(
        "--num-steps", type=int, default=2, help="Solver steps (default: %(default)s)"
    )
    parser.add_argument(
        "--device", default="cuda", help="Torch device (default: %(default)s)"
    )
    parser.add_argument(
        "--overwrite", action="store_true", help="Re-process even if output file exists"
    )
    return parser.parse_args()


def _gather_candidates(
    input_images: list[Path], output_dir: Path, overwrite: bool
) -> tuple[list[tuple[Path, Path]], int]:
    """Scan filesystem for images that need processing. Returns (candidates, skipped_existing)."""
    candidates = []
    skipped_existing = 0
    with _make_progress("Gathering candidates") as progress:
        task = progress.add_task("Scanning", total=len(input_images))
        for src in input_images:
            dst = output_dir / (src.stem + ".png")
            if not overwrite and dst.exists():
                skipped_existing += 1
            else:
                candidates.append((src, dst))
            progress.advance(task)
    return candidates, skipped_existing


def _filter_candidates(
    candidates: list[tuple[Path, Path]], min_size: int, max_uniform: float
) -> tuple[list[tuple[Path, Path]], dict[str, int]]:
    """Run parallel image checks (size + histogram). Returns (to_process, skipped_counts)."""
    to_process = []
    skipped: dict[str, int] = {}
    executor = ThreadPoolExecutor(max_workers=8)
    futures = [
        executor.submit(_check_image, src, dst, min_size, max_uniform)
        for src, dst in candidates
    ]
    with _make_progress("Filtering candidates") as progress:
        task = progress.add_task("Filtering", total=len(futures))
        try:
            for future in as_completed(futures):
                src, dst, status = future.result()
                if status == "process":
                    to_process.append((src, dst))
                else:
                    skipped[status] = skipped.get(status, 0) + 1
                progress.advance(task)
        except KeyboardInterrupt:
            executor.shutdown(wait=False, cancel_futures=True)
            raise
    executor.shutdown()
    to_process.sort()
    return to_process, skipped


def _process_image(src: Path, dst: Path, model: NisabaRelief, max_size: int) -> None:
    """Load, optionally downsample, run model, restore size, and save a single image."""
    image = Image.open(src).convert("RGB")
    original_size = image.size
    if max(image.size) > max_size:
        scale = max_size / max(image.size)
        new_size = (
            round(image.width * scale) // 16 * 16,
            round(image.height * scale) // 16 * 16,
        )
        image = image.resize(new_size, Image.LANCZOS)
    result = model.process(image, show_pbar=False)
    if result.size != original_size:
        result = result.resize(original_size, Image.LANCZOS)
    result.save(dst)


def main():
    args = _parse_args()
    console = Console()

    input_dir: Path = args.input_dir
    output_dir: Path = args.output_dir

    if not input_dir.is_dir():
        console.print(f"[red]Input directory not found:[/red] [cyan]{input_dir}[/cyan]")
        return

    input_images = sorted(
        p for p in input_dir.iterdir() if p.suffix.lower() in IMAGE_EXTENSIONS
    )
    if not input_images:
        console.print(f"[red]No images found in[/red] [cyan]{input_dir}[/cyan]")
        return

    output_dir.mkdir(parents=True, exist_ok=True)

    candidates, skipped_existing = _gather_candidates(
        input_images, output_dir, args.overwrite
    )
    to_process, skipped = _filter_candidates(candidates, args.min_size, args.max_uniform)

    if skipped_existing:
        console.print(
            f"[dim]Skipping {skipped_existing} already-processed image(s)[/dim]"
        )
    for reason, label in SKIP_LABELS.items():
        if count := skipped.get(reason):
            console.print(
                f"[dim]Skipping {count} {label.format(min_size=args.min_size)}[/dim]"
            )

    if not to_process:
        console.print("[green]All images already processed.[/green]")
        return

    console.print(
        f"Processing [bold]{len(to_process)}[/bold] / {len(input_images)} images  "
        f"[dim]({input_dir}{output_dir})[/dim]"
    )

    model_kwargs = dict(num_steps=args.num_steps, device=args.device)
    if args.seed is not None:
        model_kwargs["seed"] = args.seed
    if args.weights_dir is not None:
        model_kwargs["weights_dir"] = args.weights_dir
    if args.batch_size is not None:
        model_kwargs["batch_size"] = args.batch_size
    model = NisabaRelief(**model_kwargs)

    progress = _make_progress("[progress.description]{task.description}")
    with progress:
        task = progress.add_task("Processing", total=len(to_process))
        for src, dst in to_process:
            progress.update(task, description=f"[cyan]{src.name}[/cyan]")
            _process_image(src, dst, model, args.max_size)
            progress.advance(task)

    console.print(
        f"[green]Done.[/green] {len(to_process)} image(s) saved to [cyan]{output_dir}[/cyan]"
    )


if __name__ == "__main__":
    main()