Localize tile graph candidate placement
This commit is contained in:
parent
2a00fe7b02
commit
4d53e20732
5 changed files with 96 additions and 11 deletions
|
|
@ -41,8 +41,10 @@ Current tile-graph status:
|
|||
- it now builds literal source-pixel candidates from connected source clusters plus lattice-aligned fallback coverage
|
||||
- it learns directional adjacency preferences between candidates and places them with a soft discrete propagation loop
|
||||
- it no longer invents averaged patch colors during candidate extraction; the current regression suite now checks that tile-graph candidates stay on real source colors
|
||||
- accepted component candidates now consume a cell-sized source footprint, and far-away candidates are no longer globally admissible during placement
|
||||
- it already beats naive resize on the repo's synthetic thin-feature regression
|
||||
- it still is not good enough to replace the default continuous path on the cleaned AI badge stress case, though the raw-pixel rewrite materially improved that badge probe and stopped the mode from jumping to an oversized lattice candidate
|
||||
- it no longer fails by flooding the canvas with one opaque black tile on the cleaned AI badge stress case
|
||||
- it still is not good enough to replace the default continuous path on the cleaned AI badge stress case; the latest localized pass fixes the black-canvas failure but still produces poor motif structure and a much worse score than the continuous path
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ Current implementation note:
|
|||
Experimental tile-graph note:
|
||||
- there is now a separate `tile-graph` reconstruction mode that extracts literal source-pixel candidates from connected source clusters, fills gaps with lattice-aligned fallback pixels, learns candidate adjacency, and places tiles with a soft propagation loop
|
||||
- the raw-pixel rewrite fixed an implementation mismatch where candidates had drifted into cell-averaged patch colors instead of actual source pixels
|
||||
- that path is still promising on synthetic thin-feature cases and now stays on the conservative `126x126` badge lattice instead of jumping to `154x154`, but it still underperforms the default continuous path on the cleaned badge fixture (`0.1676` source-fidelity score on the latest probe versus `0.0832` for the continuous path)
|
||||
- accepted component candidates now consume their cell-sized source footprint, and placement no longer allows one distant candidate to act as a globally reusable label everywhere on the raster
|
||||
- that localized pass fixed the glaring badge failure where one opaque black candidate could flood most of the canvas, but it still underperforms the default continuous path badly on the cleaned badge fixture and currently over-fragments local motifs even while background transparency is restored
|
||||
|
||||
Next after that:
|
||||
- rerank low-confidence top lattice candidates with short real solver probes instead of relying only on the cheapest preview
|
||||
|
|
@ -115,6 +116,7 @@ What future work should improve here:
|
|||
- some form of coordinated local relaxation so nearby cells can move together toward a globally better contour
|
||||
- optional background suppression or de-weighting when a baked checkerboard is clearly not semantic content
|
||||
- for the experimental tile-graph path specifically, better local candidate coverage and stronger adjacency evidence are still needed even after switching candidates back to literal source pixels
|
||||
- for the experimental tile-graph path specifically, the next useful step is probably a true local candidate-set solver instead of a single global candidate matrix with coord-gating heuristics; the current architecture is still too eager to reuse a small vocabulary and too weak at preserving local motif identity
|
||||
|
||||
Current optimizer diagnosis:
|
||||
- the shared source-lattice reference, source-first snap/refine scoring, and relaxed-mode handoff are now in place, so the biggest remaining losses are no longer caused by the old representative-lattice collapse
|
||||
|
|
|
|||
|
|
@ -73,12 +73,14 @@ class SolverHyperParams:
|
|||
phase_rerank_confidence_threshold: float = 0.12
|
||||
phase_rerank_max_size_delta_ratio: float = 0.40
|
||||
phase_rerank_margin: float = 0.004
|
||||
tile_graph_max_candidates: int = 256
|
||||
tile_graph_max_candidates: int = 768
|
||||
tile_graph_max_candidates_per_coord: int = 2
|
||||
tile_graph_component_min_area_ratio: float = 0.10
|
||||
tile_graph_large_component_ratio: float = 1.35
|
||||
tile_graph_window_coverage_threshold: float = 0.16
|
||||
tile_graph_coordinate_weight: float = 0.08
|
||||
tile_graph_coord_gate_slack: float = 2.0
|
||||
tile_graph_coord_gate_penalty: float = 8.0
|
||||
tile_graph_area_weight: float = 0.03
|
||||
tile_graph_count_weight: float = 0.55
|
||||
tile_graph_delta_weight: float = 0.45
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ class _Component:
|
|||
coords: np.ndarray
|
||||
bbox: tuple[int, int, int, int]
|
||||
mask: np.ndarray
|
||||
integral: np.ndarray
|
||||
available_mask: np.ndarray
|
||||
available_integral: np.ndarray
|
||||
area: int
|
||||
centroid_y: float
|
||||
centroid_x: float
|
||||
|
|
@ -114,23 +115,56 @@ def _sample_source_pixel(source_rgba: np.ndarray, center_y: float, center_x: flo
|
|||
|
||||
|
||||
def _window_component_coverage(component: _Component, center_y: float, center_x: float, cell_h: float, cell_w: float) -> float:
|
||||
y0, x0, y1, x1 = _window_bounds(center_y, center_x, cell_h, cell_w, component.mask.shape[0] + component.bbox[0], component.mask.shape[1] + component.bbox[1])
|
||||
y0, x0, y1, x1 = _window_bounds(
|
||||
center_y,
|
||||
center_x,
|
||||
cell_h,
|
||||
cell_w,
|
||||
component.available_mask.shape[0] + component.bbox[0],
|
||||
component.available_mask.shape[1] + component.bbox[1],
|
||||
)
|
||||
bbox_y0, bbox_x0, _, _ = component.bbox
|
||||
local_y0 = max(0, y0 - bbox_y0)
|
||||
local_x0 = max(0, x0 - bbox_x0)
|
||||
local_y1 = min(component.mask.shape[0], y1 - bbox_y0)
|
||||
local_x1 = min(component.mask.shape[1], x1 - bbox_x0)
|
||||
local_y1 = min(component.available_mask.shape[0], y1 - bbox_y0)
|
||||
local_x1 = min(component.available_mask.shape[1], x1 - bbox_x0)
|
||||
if local_y1 <= local_y0 or local_x1 <= local_x0:
|
||||
return 0.0
|
||||
covered = _integral_sum(component.integral, local_y0, local_x0, local_y1, local_x1)
|
||||
covered = _integral_sum(component.available_integral, local_y0, local_x0, local_y1, local_x1)
|
||||
total = max(1, (local_y1 - local_y0) * (local_x1 - local_x0))
|
||||
return float(covered / total)
|
||||
|
||||
|
||||
def _nearest_component_pixel(component: _Component, center_y: float, center_x: float) -> tuple[float, float]:
|
||||
deltas = component.coords.astype(np.float32) - np.asarray([center_y, center_x], dtype=np.float32)[None, :]
|
||||
bbox_y0, bbox_x0, _, _ = component.bbox
|
||||
local = component.coords - np.asarray([bbox_y0, bbox_x0], dtype=np.int32)[None, :]
|
||||
available = component.available_mask[local[:, 0], local[:, 1]]
|
||||
coords = component.coords[available]
|
||||
if coords.shape[0] == 0:
|
||||
return float(center_y), float(center_x)
|
||||
deltas = coords.astype(np.float32) - np.asarray([center_y, center_x], dtype=np.float32)[None, :]
|
||||
best = int(np.argmin(np.sum(deltas * deltas, axis=1)))
|
||||
return float(component.coords[best, 0]), float(component.coords[best, 1])
|
||||
return float(coords[best, 0]), float(coords[best, 1])
|
||||
|
||||
|
||||
def _consume_component_window(component: _Component, center_y: float, center_x: float, cell_h: float, cell_w: float) -> None:
|
||||
y0, x0, y1, x1 = _window_bounds(
|
||||
center_y,
|
||||
center_x,
|
||||
cell_h,
|
||||
cell_w,
|
||||
component.available_mask.shape[0] + component.bbox[0],
|
||||
component.available_mask.shape[1] + component.bbox[1],
|
||||
)
|
||||
bbox_y0, bbox_x0, _, _ = component.bbox
|
||||
local_y0 = max(0, y0 - bbox_y0)
|
||||
local_x0 = max(0, x0 - bbox_x0)
|
||||
local_y1 = min(component.available_mask.shape[0], y1 - bbox_y0)
|
||||
local_x1 = min(component.available_mask.shape[1], x1 - bbox_x0)
|
||||
if local_y1 <= local_y0 or local_x1 <= local_x0:
|
||||
return
|
||||
component.available_mask[local_y0:local_y1, local_x0:local_x1] = False
|
||||
component.available_integral = _component_integral(component.available_mask)
|
||||
|
||||
|
||||
def _best_stepped_center(
|
||||
|
|
@ -230,7 +264,8 @@ def _extract_components(cluster_map: np.ndarray, alpha_map: np.ndarray, alpha_th
|
|||
coords=coords_array,
|
||||
bbox=(min_y, min_x, max_y + 1, max_x + 1),
|
||||
mask=mask,
|
||||
integral=_component_integral(mask),
|
||||
available_mask=mask.copy(),
|
||||
available_integral=_component_integral(mask),
|
||||
area=int(coords_array.shape[0]),
|
||||
centroid_y=float(np.mean(coords_array[:, 0])),
|
||||
centroid_x=float(np.mean(coords_array[:, 1])),
|
||||
|
|
@ -273,6 +308,8 @@ def build_tile_graph_model(
|
|||
step_y = max(1.0, cell_h)
|
||||
step_x = max(1.0, cell_w)
|
||||
for component in components:
|
||||
if not np.any(component.available_mask):
|
||||
continue
|
||||
area_ratio = float(component.area / max(target_area, 1e-4))
|
||||
if area_ratio < solver_params.tile_graph_component_min_area_ratio:
|
||||
continue
|
||||
|
|
@ -324,6 +361,7 @@ def build_tile_graph_model(
|
|||
candidate_coverage.append(coverage)
|
||||
candidate_deltas.append(delta_features)
|
||||
coord_counts[coord] = coord_counts.get(coord, 0) + 1
|
||||
_consume_component_window(component, center_y, center_x, cell_h, cell_w)
|
||||
added += 1
|
||||
if area_ratio < solver_params.tile_graph_large_component_ratio:
|
||||
continue
|
||||
|
|
@ -458,10 +496,16 @@ def _tile_graph_unary_cost(model: TileGraphModel, solver_params: SolverHyperPara
|
|||
np.abs(grid_y[..., None] - model.candidate_coords[None, None, :, 0].astype(np.float32))
|
||||
+ np.abs(grid_x[..., None] - model.candidate_coords[None, None, :, 1].astype(np.float32))
|
||||
) / max(1.0, float(height + width))
|
||||
nearest_coord_error = np.min(coord_error, axis=2, keepdims=True)
|
||||
coord_gate = coord_error <= nearest_coord_error + (
|
||||
float(solver_params.tile_graph_coord_gate_slack) / max(1.0, float(height + width))
|
||||
)
|
||||
coord_gate[..., 0] = model.reference_mean_rgba[..., 3] <= 0.35
|
||||
area_error = np.abs(np.log(np.clip(model.candidate_area_ratio, 1e-4, None) + 1e-4))[None, None, :]
|
||||
return (
|
||||
color_error
|
||||
+ coord_error * solver_params.tile_graph_coordinate_weight
|
||||
+ (~coord_gate).astype(np.float32) * solver_params.tile_graph_coord_gate_penalty
|
||||
+ area_error * solver_params.tile_graph_area_weight
|
||||
).astype(np.float32)
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,41 @@ def test_tile_graph_reconstructs_synthetic_thin_feature_better_than_naive() -> N
|
|||
assert tile_score <= naive_score
|
||||
|
||||
|
||||
def test_tile_graph_preserves_transparent_background_on_sparse_detail() -> None:
|
||||
source = np.zeros((12, 12, 4), dtype=np.float32)
|
||||
source[5:7, 5:7] = np.asarray([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
|
||||
|
||||
fake = fake_pixelize(
|
||||
source,
|
||||
upscale=8,
|
||||
phase_x=0.12,
|
||||
phase_y=-0.08,
|
||||
blur_radius=0.35,
|
||||
warp_strength=0.1,
|
||||
warp_detail=4,
|
||||
seed=5,
|
||||
)
|
||||
artifacts, _ = optimize_tile_graph(
|
||||
fake,
|
||||
inference=InferenceResult(
|
||||
target_width=12,
|
||||
target_height=12,
|
||||
phase_x=0.12,
|
||||
phase_y=-0.08,
|
||||
confidence=1.0,
|
||||
top_candidates=[],
|
||||
),
|
||||
analysis=analyze_source(fake, seed=7),
|
||||
steps=0,
|
||||
seed=7,
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
alpha_zero_ratio = float((artifacts.target_rgba[..., 3] <= 0.05).mean())
|
||||
|
||||
assert alpha_zero_ratio >= 0.75
|
||||
|
||||
|
||||
def test_pipeline_tile_graph_mode_writes_reconstruction_diagnostics(tmp_path: Path) -> None:
|
||||
source = make_emblem(24, 24)
|
||||
fake = fake_pixelize(source, upscale=10, phase_x=0.15, phase_y=0.2, blur_radius=0.6, warp_strength=0.2, warp_detail=5)
|
||||
|
|
|
|||
Loading…
Reference in a new issue