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

1from __future__ import annotations 

2 

3import logging 

4from pathlib import Path 

5 

6from typer import Context, Exit, Option, Typer 

7 

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 

16 

17app = Typer(add_completion=True, no_args_is_help=True) 

18 

19 

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 

26 

27 

28def _default_output_path(input_dir: Path) -> Path: 

29 return Path.cwd() / f"{_output_stem(input_dir)}-reconstructed.png" 

30 

31 

32def _default_animation_frames_dir(input_dir: Path) -> Path: 

33 return Path.cwd() / f"{_output_stem(input_dir)}-frames" 

34 

35 

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 ) 

83 

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) 

97 

98 total_steps = 6 if animation is not None else 5 

99 

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") 

102 

103 def _advance(stage: str) -> None: 

104 logger.debug(stage) 

105 progress.advance(stage) 

106 

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 

113 

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 ) 

123 

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) 

129 

130 console.print(info_panel(summary, console=console))