Coverage for src / puzzletree / cli / commands / reconstruct / __init__.py: 85.71%
48 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 20:35 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 20:35 +0000
1from __future__ import annotations
3import logging
4from pathlib import Path
6from typer import Context, Exit, Option, Typer
8from puzzletree.cli.messages import error_panel, info_panel
9from puzzletree.reconstruct import (
10 ReconstructionRunWithHistory,
11 ReconstructOptions,
12 run_from_options,
13)
14from puzzletree.utils.logging import get_logger_console
15from puzzletree.utils.progress_bar import StageProgressBar
17app = Typer(add_completion=True, no_args_is_help=True)
20def _output_stem(input_dir: Path) -> str:
21 name = input_dir.name
22 for suffix in ("-tiles", "_tiles"): 22 ↛ 25line 22 didn't jump to line 25 because the loop on line 22 didn't complete
23 if name.endswith(suffix): 23 ↛ 22line 23 didn't jump to line 22 because the condition on line 23 was always true
24 return name[: -len(suffix)]
25 return name
28def _default_output_path(input_dir: Path) -> Path:
29 return Path.cwd() / f"{_output_stem(input_dir)}-reconstructed.png"
32def _default_animation_frames_dir(input_dir: Path) -> Path:
33 return Path.cwd() / f"{_output_stem(input_dir)}-frames"
36@app.callback(invoke_without_command=True, no_args_is_help=True)
37def reconstruct(
38 ctx: Context,
39 input_dir: Path = Option(..., "--input-dir", "-i", help="Directory of tile images (bmp/png/jpg)."),
40 output: Path | None = Option(
41 None,
42 "--output",
43 "-o",
44 help="Output image path. Defaults to ./<tile-dir-stem>-reconstructed.png.",
45 ),
46 r: float = Option(12.0, "--r", help="Gaussian sigma used in edge correlation."),
47 minset: float = Option(0.1, "--minset", help="Stop threshold on edge weights."),
48 animation: Path | None = Option(
49 None,
50 "--animation",
51 help="Optional output GIF path for tree-building animation.",
52 ),
53 animation_seed: int = Option(0, "--animation-seed", help="Random seed for animation rotations/packing."),
54 animation_size: int = Option(1024, "--animation-size", help="Minimum animation frame size (square pixels)."),
55 animation_max_angle: float = Option(
56 35.0,
57 "--animation-max-angle",
58 help="Maximum absolute random rotation angle in degrees.",
59 ),
60 animation_duration_ms: int = Option(
61 1000,
62 "--animation-duration-ms",
63 help="Animation frame duration in milliseconds.",
64 ),
65 animation_frames_dir: Path | None = Option(
66 None,
67 "--animation-frames-dir",
68 help="Directory to save every animation frame as PNG. Defaults to ./<tile-dir-stem>-frames when --animation is set.",
69 ),
70) -> None:
71 """Reconstruct shredded page tiles using the MSGT algorithm."""
72 verbose = bool(ctx.obj.get("verbose", False)) if isinstance(ctx.obj, dict) else False
73 log_level = logging.DEBUG if verbose else logging.INFO
74 logger, console = get_logger_console("puzzletree.reconstruct", log_level=log_level)
75 resolved_output = output if output is not None else _default_output_path(input_dir)
76 resolved_animation_frames_dir = (
77 animation_frames_dir
78 if animation_frames_dir is not None
79 else _default_animation_frames_dir(input_dir)
80 if animation is not None
81 else None
82 )
84 options = ReconstructOptions(
85 input_dir=input_dir,
86 output=resolved_output,
87 r=r,
88 minset=minset,
89 animation=animation,
90 animation_seed=animation_seed,
91 animation_size=animation_size,
92 animation_max_angle=animation_max_angle,
93 animation_duration_ms=animation_duration_ms,
94 animation_frames_dir=resolved_animation_frames_dir,
95 )
96 logger.debug("Reconstruction options: %s", options)
98 total_steps = 6 if animation is not None else 5
100 with StageProgressBar(console=console, use_progress_bar=bool(getattr(console, "is_terminal", True))) as progress:
101 progress.start(total_steps=total_steps, description="Starting reconstruction")
103 def _advance(stage: str) -> None:
104 logger.debug(stage)
105 progress.advance(stage)
107 try:
108 result = run_from_options(options, progress_callback=_advance)
109 except Exception as exc:
110 logger.exception("Reconstruction failed")
111 console.print(error_panel(str(exc), console=console))
112 raise Exit(1) from exc
114 edge_count = sum(len(edges) for edges in result.adjs)
115 summary = "\n".join(
116 [
117 f"Tiles: {len(result.placements)}",
118 f"Edges accepted: {edge_count}",
119 f"Placed tiles: {len(result.placements)}",
120 f"Saved: {resolved_output}",
121 ],
122 )
124 if animation is not None and isinstance(result, ReconstructionRunWithHistory):
125 summary_lines = [summary, f"Saved animation: {animation}"]
126 if resolved_animation_frames_dir is not None: 126 ↛ 128line 126 didn't jump to line 128 because the condition on line 126 was always true
127 summary_lines.append(f"Saved frames: {resolved_animation_frames_dir}")
128 summary = "\n".join(summary_lines)
130 console.print(info_panel(summary, console=console))