Files
suaveplan/packages/media/image/emblem-solver/src/solver/greedy/greedy.ts
2026-05-27 23:47:53 +01:00

123 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { pack, unpackX, unpackY } from "../../helpers/helpers.js";
import type { Rect } from "../../types/types.js";
/**
* Greedy set-cover with a deterministic three-key score:
*
* 1. number of uncovered required pixels covered (primary)
* 2. covered / area ratio (efficiency — prefer compact rects)
* 3. proximity of rect centroid to the centroid of the remaining required
* pixels (tiebreaker — keep selections clustered)
*
* When no candidate rect intersects the remaining uncovered set, fall back
* to emitting 1×1 rects for every residual pixel. This guarantees the cover
* is always complete even on pathological inputs.
*/
export function enhancedGreedyCover(
candidates: ReadonlyArray<Rect>,
required: boolean[][],
width: number,
height: number
): Rect[] {
const uncovered = new Set<number>();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (required[y]?.[x]) uncovered.add(pack(x, y, width));
}
}
if (uncovered.size === 0) return [];
const selected: Rect[] = [];
const maxDist = Math.sqrt(width ** 2 + height ** 2);
while (uncovered.size > 0) {
let cx = 0;
let cy = 0;
for (const idx of uncovered) {
cx += unpackX(idx, width);
cy += unpackY(idx, width);
}
cx /= uncovered.size;
cy /= uncovered.size;
let bestRect: Rect | null = null;
let bestScore = Number.NEGATIVE_INFINITY;
for (const rect of candidates) {
let covered = 0;
for (let ry = rect.y; ry < rect.y + rect.h; ry++) {
for (let rx = rect.x; rx < rect.x + rect.w; rx++) {
if (uncovered.has(pack(rx, ry, width))) covered++;
}
}
if (covered === 0) continue;
const area = rect.w * rect.h;
const efficiency = covered / area;
const rcx = rect.x + rect.w / 2;
const rcy = rect.y + rect.h / 2;
const dist = Math.sqrt((rcx - cx) ** 2 + (rcy - cy) ** 2);
const proximity = 1 - dist / maxDist;
const score = covered * 1000 + efficiency * 100 + proximity * 10;
if (score > bestScore) {
bestScore = score;
bestRect = rect;
}
}
if (!bestRect) {
for (const idx of uncovered) {
selected.push({
x: unpackX(idx, width),
y: unpackY(idx, width),
w: 1,
h: 1,
});
}
uncovered.clear();
break;
}
selected.push(bestRect);
for (let ry = bestRect.y; ry < bestRect.y + bestRect.h; ry++) {
for (let rx = bestRect.x; rx < bestRect.x + bestRect.w; rx++) {
uncovered.delete(pack(rx, ry, width));
}
}
}
return selected;
}
/**
* Per-island independent cover: build a local `required` mask for each
* island and run `enhancedGreedyCover` against the subset of candidates
* that overlap it. This avoids wasting selection budget on spanning rects
* between disconnected islands.
*/
export function perIslandCover(
islands: ReadonlyArray<{ readonly pixels: Set<number> }>,
candidates: ReadonlyArray<Rect>,
width: number,
height: number
): Rect[] {
const out: Rect[] = [];
for (const island of islands) {
const local: boolean[][] = Array.from({ length: height }, () =>
Array(width).fill(false)
);
for (const idx of island.pixels) {
(local[unpackY(idx, width)] as boolean[])[unpackX(idx, width)] =
true;
}
const localCandidates = candidates.filter((r) => {
for (let ry = r.y; ry < r.y + r.h; ry++) {
for (let rx = r.x; rx < r.x + r.w; rx++) {
if (local[ry]?.[rx]) return true;
}
}
return false;
});
out.push(...enhancedGreedyCover(localCandidates, local, width, height));
}
return out;
}