diff --git a/src/repixelizer/benchmark.py b/src/repixelizer/benchmark.py index c7ebcd8..1366b86 100644 --- a/src/repixelizer/benchmark.py +++ b/src/repixelizer/benchmark.py @@ -132,6 +132,8 @@ def run_roundtrip_benchmark( "warp_strength": settings["warp_strength"], "warp_detail": settings["warp_detail"], "warp_sample_mode": settings["warp_sample_mode"], + "artifact_density": settings["artifact_density"], + "artifact_strength": settings["artifact_strength"], "primary_metric": "foreground_premultiplied_mae", "reference_foreground_coverage": foreground_coverage(original, original), "target_size_locked": not infer_size, @@ -217,6 +219,8 @@ def _variant_settings(seed: int, profile: str) -> dict[str, Any]: "warp_strength": float(rng.uniform(0.14, 0.46)), "warp_detail": int(rng.integers(5, 9)), "warp_sample_mode": "nearest", + "artifact_density": 0.0, + "artifact_strength": 0.0, } if profile == "soft": return { @@ -227,6 +231,20 @@ def _variant_settings(seed: int, profile: str) -> dict[str, Any]: "warp_strength": float(rng.uniform(0.18, 0.55)), "warp_detail": int(rng.integers(4, 8)), "warp_sample_mode": "bilinear", + "artifact_density": 0.0, + "artifact_strength": 0.0, + } + if profile == "ai": + return { + "upscale": int(rng.integers(8, 15)), + "phase_x": float(rng.uniform(-0.48, 0.48)), + "phase_y": float(rng.uniform(-0.48, 0.48)), + "blur_radius": float(rng.uniform(0.18, 0.95)), + "warp_strength": float(rng.uniform(0.24, 0.68)), + "warp_detail": int(rng.integers(5, 10)), + "warp_sample_mode": "bilinear" if rng.random() < 0.7 else "nearest", + "artifact_density": float(rng.uniform(0.08, 0.24)), + "artifact_strength": float(rng.uniform(0.35, 0.95)), } raise ValueError(f"Unsupported benchmark profile: {profile}") diff --git a/src/repixelizer/cli.py b/src/repixelizer/cli.py index 5075674..d83c7a1 100644 --- a/src/repixelizer/cli.py +++ b/src/repixelizer/cli.py @@ -46,7 +46,7 @@ def build_parser() -> argparse.ArgumentParser: benchmark_parser.add_argument( "--profile", action="append", - choices=("soft", "crisp"), + choices=("soft", "crisp", "ai"), default=None, help="Corruption profile to include; may be repeated. Defaults to both soft and crisp.", ) @@ -78,7 +78,7 @@ def build_parser() -> argparse.ArgumentParser: tune_parser.add_argument( "--profile", action="append", - choices=("soft", "crisp"), + choices=("soft", "crisp", "ai"), default=None, help="Corruption profile to include; may be repeated. Defaults to soft only for tuning.", ) diff --git a/src/repixelizer/synthetic.py b/src/repixelizer/synthetic.py index 01a12fb..17859ea 100644 --- a/src/repixelizer/synthetic.py +++ b/src/repixelizer/synthetic.py @@ -37,6 +37,8 @@ def fake_pixelize( warp_strength: float = 0.0, warp_detail: int = 4, warp_sample_mode: str = "bilinear", + artifact_density: float = 0.0, + artifact_strength: float = 0.0, seed: int = 0, ) -> np.ndarray: source = Image.fromarray(np.clip(np.rint(rgba * 255.0), 0, 255).astype(np.uint8), mode="RGBA") @@ -53,7 +55,17 @@ def fake_pixelize( warp_x, warp_y = _warp_fields(width_px, height_px, upscale=upscale, detail=warp_detail, rng=rng) sample_x -= warp_x * strength_px sample_y -= warp_y * strength_px + else: + rng = np.random.default_rng(seed) warped = _sample_rgba(target_rgba, sample_x, sample_y, mode=warp_sample_mode) + if artifact_density > 0.0 and artifact_strength > 0.0: + warped = _inject_fake_cell_artifacts( + warped, + upscale=upscale, + density=artifact_density, + strength=artifact_strength, + rng=rng, + ) if blur_radius <= 0.0: return warped blurred = Image.fromarray(np.clip(np.rint(warped * 255.0), 0, 255).astype(np.uint8), mode="RGBA").filter( @@ -62,6 +74,118 @@ def fake_pixelize( return np.asarray(blurred, dtype=np.float32) / 255.0 +def _inject_fake_cell_artifacts( + rgba: np.ndarray, + *, + upscale: int, + density: float, + strength: float, + rng: np.random.Generator, +) -> np.ndarray: + premult = premultiply(rgba) + out = premult.copy() + height_px, width_px = premult.shape[:2] + cell_w = max(1, int(upscale)) + cell_h = max(1, int(upscale)) + cells_x = max(1, width_px // cell_w) + cells_y = max(1, height_px // cell_h) + density = float(np.clip(density, 0.0, 1.0)) + strength = float(np.clip(strength, 0.0, 1.0)) + op_count = max(1, int(round(cells_x * cells_y * density * 1.8))) + max_sliver = max(1, int(round(cell_w * (0.08 + 0.18 * strength)))) + max_patch = max(1, int(round(cell_w * (0.10 + 0.22 * strength)))) + blend_alpha = 0.55 + 0.35 * strength + + def cell_bounds(cx: int, cy: int) -> tuple[int, int, int, int]: + x0 = cx * cell_w + y0 = cy * cell_h + return x0, y0, min(width_px, x0 + cell_w), min(height_px, y0 + cell_h) + + def neighbors(cx: int, cy: int) -> list[tuple[int, int]]: + items: list[tuple[int, int]] = [] + for oy in (-1, 0, 1): + for ox in (-1, 0, 1): + if ox == 0 and oy == 0: + continue + nx = cx + ox + ny = cy + oy + if 0 <= nx < cells_x and 0 <= ny < cells_y: + items.append((nx, ny)) + return items + + for _ in range(op_count): + cx = int(rng.integers(0, cells_x)) + cy = int(rng.integers(0, cells_y)) + x0, y0, x1, y1 = cell_bounds(cx, cy) + if x1 - x0 < 2 or y1 - y0 < 2: + continue + choices = neighbors(cx, cy) + if not choices: + continue + nx, ny = choices[int(rng.integers(0, len(choices)))] + nx0, ny0, nx1, ny1 = cell_bounds(nx, ny) + op = str(rng.choice(["sliver", "ghost", "facet"])) + + if op == "sliver": + strip = int(rng.integers(1, max_sliver + 1)) + dx = nx - cx + dy = ny - cy + if abs(dx) >= abs(dy): + strip = min(strip, x1 - x0, nx1 - nx0) + if strip < 1: + continue + if dx < 0: + src = premult[ny0:ny1, nx1 - strip : nx1] + out[y0:y1, x0 : x0 + strip] = src[: y1 - y0, :strip] + else: + src = premult[ny0:ny1, nx0 : nx0 + strip] + out[y0:y1, x1 - strip : x1] = src[: y1 - y0, :strip] + else: + strip = min(strip, y1 - y0, ny1 - ny0) + if strip < 1: + continue + if dy < 0: + src = premult[ny1 - strip : ny1, nx0:nx1] + out[y0 : y0 + strip, x0:x1] = src[:strip, : x1 - x0] + else: + src = premult[ny0 : ny0 + strip, nx0:nx1] + out[y1 - strip : y1, x0:x1] = src[:strip, : x1 - x0] + continue + + if op == "ghost": + patch_w = min(x1 - x0 - 1, nx1 - nx0, max_patch) + patch_h = min(y1 - y0 - 1, ny1 - ny0, max_patch) + if patch_w < 1 or patch_h < 1: + continue + patch_w = int(rng.integers(1, patch_w + 1)) + patch_h = int(rng.integers(1, patch_h + 1)) + dst_x = int(rng.integers(x0, x1 - patch_w + 1)) + dst_y = int(rng.integers(y0, y1 - patch_h + 1)) + src_x = int(rng.integers(nx0, nx1 - patch_w + 1)) + src_y = int(rng.integers(ny0, ny1 - patch_h + 1)) + patch = premult[src_y : src_y + patch_h, src_x : src_x + patch_w] + existing = out[dst_y : dst_y + patch_h, dst_x : dst_x + patch_w] + out[dst_y : dst_y + patch_h, dst_x : dst_x + patch_w] = existing * (1.0 - blend_alpha) + patch * blend_alpha + continue + + yy, xx = np.mgrid[0 : y1 - y0, 0 : x1 - x0] + band = max(1.0, float(max_sliver)) + slope = float(rng.uniform(-0.55, 0.55)) + bias = float(rng.uniform(-0.2 * cell_w, 1.2 * cell_w)) + if rng.random() < 0.5: + seam = xx.astype(np.float32) - (yy.astype(np.float32) * slope + bias) + else: + seam = xx.astype(np.float32) + (yy.astype(np.float32) * slope - bias) + mask = np.abs(seam) <= band + patch = premult[ny0:ny1, nx0:nx1][: y1 - y0, : x1 - x0] + existing = out[y0:y1, x0:x1] + mix = np.where(mask[..., None], patch, existing) + blend = 0.35 * strength + out[y0:y1, x0:x1] = existing * (1.0 - blend) + mix * blend + + return unpremultiply(np.clip(out, 0.0, 1.0)) + + def _warp_fields(width: int, height: int, *, upscale: int, detail: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]: smooth_tile = max(float(upscale * (detail + 2)), 2.0) block_tile = max(float(upscale * max(1.5, 7.5 - detail)), 2.0) diff --git a/src/repixelizer/tuning.py b/src/repixelizer/tuning.py index e96a168..e74c186 100644 --- a/src/repixelizer/tuning.py +++ b/src/repixelizer/tuning.py @@ -23,7 +23,7 @@ def _row_score(row: dict[str, Any]) -> float: float(row["naive_adjacency_error_to_original"]), ) motif_ratio = _safe_ratio(float(row["optimized_motif_error_to_original"]), float(row["naive_motif_error_to_original"])) - return error_ratio * 0.50 + adjacency_ratio * 0.25 + motif_ratio * 0.25 + return error_ratio * 0.20 + adjacency_ratio * 0.45 + motif_ratio * 0.35 def _score_summary(summary: dict[str, Any]) -> dict[str, float]: @@ -140,10 +140,98 @@ def _mutate_params(base: SolverHyperParams, rng: np.random.Generator, scale: flo low=0.02, high=0.18, ), + refine_motif_weight=_mutate_linear( + rng, + base.refine_motif_weight, + scale=scale, + sigma=0.05, + low=0.02, + high=0.40, + ), + refine_line_weight=_mutate_linear( + rng, + base.refine_line_weight, + scale=scale, + sigma=0.04, + low=0.02, + high=0.30, + ), + relax_anchor_scale=_mutate_linear( + rng, + base.relax_anchor_scale, + scale=scale, + sigma=0.16, + low=0.15, + high=1.0, + ), + relax_orthogonal_weight=_mutate_linear( + rng, + base.relax_orthogonal_weight, + scale=scale, + sigma=0.08, + low=0.10, + high=0.70, + ), + relax_diagonal_weight=_mutate_linear( + rng, + base.relax_diagonal_weight, + scale=scale, + sigma=0.05, + low=0.02, + high=0.35, + ), + relax_source_adjacency_weight=_mutate_linear( + rng, + base.relax_source_adjacency_weight, + scale=scale, + sigma=0.10, + low=0.05, + high=0.80, + ), + relax_source_motif_weight=_mutate_linear( + rng, + base.relax_source_motif_weight, + scale=scale, + sigma=0.10, + low=0.05, + high=0.80, + ), + relax_source_line_weight=_mutate_linear( + rng, + base.relax_source_line_weight, + scale=scale, + sigma=0.08, + low=0.02, + high=0.45, + ), structure_boundary_weight=float(structure_group[0]), structure_anchor_adjacency_weight=float(structure_group[1]), structure_anchor_motif_weight=float(structure_group[2]), structure_representative_weight=float(structure_group[3]), + structure_source_adjacency_weight=_mutate_linear( + rng, + base.structure_source_adjacency_weight, + scale=scale, + sigma=0.12, + low=0.05, + high=0.90, + ), + structure_source_motif_weight=_mutate_linear( + rng, + base.structure_source_motif_weight, + scale=scale, + sigma=0.12, + low=0.05, + high=0.90, + ), + structure_source_line_weight=_mutate_linear( + rng, + base.structure_source_line_weight, + scale=scale, + sigma=0.08, + low=0.02, + high=0.50, + ), phase_rerank_margin=_mutate_linear( rng, base.phase_rerank_margin, @@ -261,7 +349,7 @@ def tune_solver_hyperparams( "infer_size": infer_size, "include_cases": include_cases or [], "limit_cases_effective": effective_limit, - "objective": "0.5 * error_ratio_to_naive + 0.25 * adjacency_ratio_to_naive + 0.25 * motif_ratio_to_naive; lower is better", + "objective": "0.20 * error_ratio_to_naive + 0.45 * adjacency_ratio_to_naive + 0.35 * motif_ratio_to_naive; lower is better", "trial_results": trials_payload, "best_score": final_score["score"], "best_mean_row_score": final_score["mean_row_score"], diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 8d54a01..e82c988 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -69,6 +69,30 @@ def test_roundtrip_benchmark_can_filter_cases(tmp_path: Path) -> None: assert payload["rows"][0]["profile"] == "crisp" +def test_roundtrip_benchmark_supports_ai_profile(tmp_path: Path) -> None: + corpus_dir = tmp_path / "corpus" + originals_dir = corpus_dir / "originals" + originals_dir.mkdir(parents=True) + save_rgba(originals_dir / "crest.png", make_emblem(16, 16)) + + out_dir = tmp_path / "benchmark-out" + payload = run_roundtrip_benchmark( + corpus_dir, + out_dir, + variants=1, + profiles=["ai"], + steps=2, + seed=5, + ) + + assert payload["profiles"] == ["ai"] + assert payload["row_count"] == 1 + row = payload["rows"][0] + assert row["profile"] == "ai" + assert row["artifact_density"] > 0.0 + assert row["artifact_strength"] > 0.0 + + def test_roundtrip_benchmark_clears_existing_output_dir_by_default(tmp_path: Path) -> None: corpus_dir = tmp_path / "corpus" originals_dir = corpus_dir / "originals"