From d9fa411daf4d6c862a302029f8edc8990332aa87 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 23 Apr 2026 23:45:52 +0100 Subject: [PATCH] Tune phase-field evidence and spacing --- docs/implementation-plan.md | 13 +++++++ src/repixelizer/params.py | 13 ++++--- src/repixelizer/phase_field.py | 70 ++++++++++++++++++++++++++++------ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 038ea19..6536330 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -242,6 +242,19 @@ Important metric correction: - exact-match ratio - on the pinned badge comparison under `artifacts/phase-field-metric-check/`, that new metric finally agrees with human judgment: `phase-field` scores `0.4244` versus continuous `0.4291` where lower is better +First focused blemish pass: + +- the tracked sword-tip focus fixture now lives in `tests/fixtures/real/ai-badge-tip-focus.json` +- the next phase-field pass tried exactly the three obvious fixes: + - stronger local evidence + - less timid edge-aware smoothness gating + - a better spacing story with both preferred spacing and an upper spacing clamp +- the resulting pinned badge run is under `artifacts/phase-field-v2-badge-126/` +- this did move the specific blemish a little in the right direction, but only a little: + - on the tip focus crop, `source_structure` improved from `0.4187` to `0.4179` + - on the whole image, `source_structure` regressed slightly from `0.4244` to `0.4248` +- honest read: the pass shaved the targeted contour swelling a bit, but not enough to call the issue solved, and it did so by spending a little of the whole-image win + ## High-value regression case ### AI badge emblem with baked checkerboard background diff --git a/src/repixelizer/params.py b/src/repixelizer/params.py index 6e171df..eecaa81 100644 --- a/src/repixelizer/params.py +++ b/src/repixelizer/params.py @@ -6,12 +6,15 @@ from dataclasses import asdict, dataclass @dataclass(slots=True) class SolverHyperParams: phase_field_patch_extent: float = 0.18 - phase_field_data_coherence_weight: float = 1.00 - phase_field_data_edge_weight: float = 0.35 - phase_field_smoothness_weight: float = 0.28 - phase_field_edge_gate_strength: float = 6.0 + phase_field_data_coherence_weight: float = 1.10 + phase_field_data_edge_weight: float = 0.55 + phase_field_data_center_edge_weight: float = 0.90 + phase_field_spacing_weight: float = 0.24 + phase_field_smoothness_weight: float = 0.26 + phase_field_edge_gate_strength: float = 11.0 phase_field_collapse_weight: float = 1.20 - phase_field_min_spacing_ratio: float = 0.18 + phase_field_min_spacing_ratio: float = 0.10 + phase_field_max_spacing_ratio: float = 1.28 phase_field_magnitude_weight: float = 0.08 phase_field_learning_rate: float = 0.10 phase_field_max_displacement_ratio: float = 0.48 diff --git a/src/repixelizer/phase_field.py b/src/repixelizer/phase_field.py index 058476f..5efc773 100644 --- a/src/repixelizer/phase_field.py +++ b/src/repixelizer/phase_field.py @@ -25,6 +25,10 @@ class _PhaseFieldPrep: height: int +def _charbonnier(torch, value, epsilon: float = 1e-4): + return torch.sqrt(value.square() + epsilon) + + def _require_torch(): try: import torch @@ -119,6 +123,11 @@ def _sample_patch_scalar(F, scalar_t, coords_px, offsets_t, *, width: int, heigh return sampled.reshape(batch, out_h, out_w, samples) +def _spacing_loss(torch, step, target_step: float): + normalized = step / max(target_step, 1e-4) + return _charbonnier(torch, normalized - 1.0).mean() + + def _displacement_diagnostics(uv_field: np.ndarray, selected_x, selected_y, *, width: int, height: int) -> dict[str, np.ndarray | float]: uv_x = (uv_field[..., 0] + 1.0) * 0.5 * max(1.0, float(width - 1)) uv_y = (uv_field[..., 1] + 1.0) * 0.5 * max(1.0, float(height - 1)) @@ -166,19 +175,41 @@ def _displacement_diagnostics(uv_field: np.ndarray, selected_x, selected_y, *, w } -def _project_displacements_in_place(torch, disp_t, base_x_t, base_y_t, *, min_dx: float, min_dy: float, max_dx: float, max_dy: float, width: int, height: int) -> None: +def _project_displacements_in_place( + torch, + disp_t, + base_x_t, + base_y_t, + *, + min_dx: float, + min_dy: float, + max_step_x: float, + max_step_y: float, + max_dx: float, + max_dy: float, + width: int, + height: int, +) -> None: with torch.no_grad(): pos_x = (base_x_t + disp_t[..., 0]).clamp(0.0, max(0.0, float(width - 1))) pos_y = (base_y_t + disp_t[..., 1]).clamp(0.0, max(0.0, float(height - 1))) for index in range(1, pos_x.shape[2]): - pos_x[:, :, index] = torch.maximum(pos_x[:, :, index], pos_x[:, :, index - 1] + min_dx) + lower = pos_x[:, :, index - 1] + min_dx + upper = pos_x[:, :, index - 1] + max_step_x + pos_x[:, :, index] = torch.minimum(torch.maximum(pos_x[:, :, index], lower), upper) for index in range(pos_x.shape[2] - 2, -1, -1): - pos_x[:, :, index] = torch.minimum(pos_x[:, :, index], pos_x[:, :, index + 1] - min_dx) + lower = pos_x[:, :, index + 1] - max_step_x + upper = pos_x[:, :, index + 1] - min_dx + pos_x[:, :, index] = torch.maximum(torch.minimum(pos_x[:, :, index], upper), lower) for index in range(1, pos_y.shape[1]): - pos_y[:, index, :] = torch.maximum(pos_y[:, index, :], pos_y[:, index - 1, :] + min_dy) + lower = pos_y[:, index - 1, :] + min_dy + upper = pos_y[:, index - 1, :] + max_step_y + pos_y[:, index, :] = torch.minimum(torch.maximum(pos_y[:, index, :], lower), upper) for index in range(pos_y.shape[1] - 2, -1, -1): - pos_y[:, index, :] = torch.minimum(pos_y[:, index, :], pos_y[:, index + 1, :] - min_dy) + lower = pos_y[:, index + 1, :] - max_step_y + upper = pos_y[:, index + 1, :] - min_dy + pos_y[:, index, :] = torch.maximum(torch.minimum(pos_y[:, index, :], upper), lower) pos_x = pos_x.clamp(0.0, max(0.0, float(width - 1))) pos_y = pos_y.clamp(0.0, max(0.0, float(height - 1))) @@ -275,8 +306,16 @@ def _phase_field_loss(torch, F, prep: _PhaseFieldPrep, disp_t, solver_params: So center_rgba = patch_rgba[..., 0, :] neighbor_rgba = patch_rgba[..., 1:, :] - local_coherence = (neighbor_rgba - center_rgba[..., None, :]).abs().mean() - local_edge = patch_edge.mean() + neighbor_weights = patch_rgba.new_tensor([1.0, 1.0, 1.0, 1.0, 0.75, 0.75, 0.75, 0.75]).view(1, 1, 1, -1) + color_delta = (neighbor_rgba - center_rgba[..., None, :]).abs().mean(dim=-1) + local_coherence = (color_delta * neighbor_weights).sum() / neighbor_weights.sum().clamp_min(1e-6) / max( + 1.0, float(color_delta.shape[0] * color_delta.shape[1] * color_delta.shape[2]) + ) + center_edge = patch_edge[..., 0].mean() + ring_edge = ((patch_edge[..., 1:] * neighbor_weights).sum() / neighbor_weights.sum().clamp_min(1e-6)) / max( + 1.0, float(patch_edge.shape[0] * patch_edge.shape[1] * patch_edge.shape[2]) + ) + local_edge = center_edge * solver_params.phase_field_data_center_edge_weight + ring_edge disp_norm = disp_t.clone() disp_norm[..., 0] = disp_norm[..., 0] / max(prep.cell_x, 1e-4) @@ -287,24 +326,27 @@ def _phase_field_loss(torch, F, prep: _PhaseFieldPrep, disp_t, solver_params: So smoothness = sampled_rgba.new_tensor(0.0) if pos_mid_x is not None: edge_x = _sample_scalar(F, prep.edge_t, pos_mid_x, width=prep.width, height=prep.height) - weight_x = torch.exp(-solver_params.phase_field_edge_gate_strength * edge_x) + weight_x = torch.exp(-solver_params.phase_field_edge_gate_strength * edge_x.clamp_min(0.0)) delta_x = disp_norm[:, :, 1:, :] - disp_norm[:, :, :-1, :] - smoothness = smoothness + (weight_x * torch.sqrt(delta_x.square().sum(dim=-1) + 1e-6)).mean() + smoothness = smoothness + (weight_x * _charbonnier(torch, delta_x, epsilon=1e-4).mean(dim=-1)).mean() if pos_mid_y is not None: edge_y = _sample_scalar(F, prep.edge_t, pos_mid_y, width=prep.width, height=prep.height) - weight_y = torch.exp(-solver_params.phase_field_edge_gate_strength * edge_y) + weight_y = torch.exp(-solver_params.phase_field_edge_gate_strength * edge_y.clamp_min(0.0)) delta_y = disp_norm[:, 1:, :, :] - disp_norm[:, :-1, :, :] - smoothness = smoothness + (weight_y * torch.sqrt(delta_y.square().sum(dim=-1) + 1e-6)).mean() + smoothness = smoothness + (weight_y * _charbonnier(torch, delta_y, epsilon=1e-4).mean(dim=-1)).mean() collapse = sampled_rgba.new_tensor(0.0) + spacing = sampled_rgba.new_tensor(0.0) min_dx = solver_params.phase_field_min_spacing_ratio * prep.cell_x min_dy = solver_params.phase_field_min_spacing_ratio * prep.cell_y if pos_px.shape[2] > 1: step_x = pos_px[:, :, 1:, 0] - pos_px[:, :, :-1, 0] collapse = collapse + torch.relu(min_dx - step_x).square().mean() + spacing = spacing + _spacing_loss(torch, step_x, prep.cell_x) if pos_px.shape[1] > 1: step_y = pos_px[:, 1:, :, 1] - pos_px[:, :-1, :, 1] collapse = collapse + torch.relu(min_dy - step_y).square().mean() + spacing = spacing + _spacing_loss(torch, step_y, prep.cell_y) magnitude = ( (disp_t[..., 0] / max(prep.cell_x, 1e-4)).square() @@ -315,6 +357,7 @@ def _phase_field_loss(torch, F, prep: _PhaseFieldPrep, disp_t, solver_params: So local_coherence * solver_params.phase_field_data_coherence_weight + local_edge * solver_params.phase_field_data_edge_weight + smoothness * solver_params.phase_field_smoothness_weight + + spacing * solver_params.phase_field_spacing_weight + collapse * solver_params.phase_field_collapse_weight + magnitude * solver_params.phase_field_magnitude_weight ) @@ -322,6 +365,7 @@ def _phase_field_loss(torch, F, prep: _PhaseFieldPrep, disp_t, solver_params: So "local_coherence": float(local_coherence.detach().cpu().item()), "local_edge": float(local_edge.detach().cpu().item()), "smoothness": float(smoothness.detach().cpu().item()), + "spacing": float(spacing.detach().cpu().item()), "collapse": float(collapse.detach().cpu().item()), "magnitude": float(magnitude.detach().cpu().item()), } @@ -369,6 +413,8 @@ def optimize_phase_field( final_terms: dict[str, float] = {} min_dx = solver_params.phase_field_min_spacing_ratio * prep.cell_x min_dy = solver_params.phase_field_min_spacing_ratio * prep.cell_y + max_step_x = solver_params.phase_field_max_spacing_ratio * prep.cell_x + max_step_y = solver_params.phase_field_max_spacing_ratio * prep.cell_y max_dx = solver_params.phase_field_max_displacement_ratio * prep.cell_x max_dy = solver_params.phase_field_max_displacement_ratio * prep.cell_y @@ -384,6 +430,8 @@ def optimize_phase_field( prep.base_y_t, min_dx=min_dx, min_dy=min_dy, + max_step_x=max_step_x, + max_step_y=max_step_y, max_dx=max_dx, max_dy=max_dy, width=prep.width,