Tune phase-field evidence and spacing

This commit is contained in:
Meta Vens 2026-04-23 23:45:52 +01:00
commit d9fa411daf
3 changed files with 80 additions and 16 deletions

View file

@ -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

View file

@ -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

View file

@ -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,