fix: align default pipeline config with docs and split large modules

- Fix DEFAULT_PIPELINE_CONFIG from PIPELINE_HIGH (1440) to PIPELINE_MEDIUM
  (720) to match documented performance estimates and test expectations
- Extract geometry/hit-detection utilities from MaskSegmentCanvas.tsx into
  canvasGeometry.ts (~380 lines)
- Extract outline path functions from maskSegmentation.ts into
  maskOutlinePaths.ts (~470 lines)
- Net reduction of ~1,700 lines across the two core files

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
a1518 2026-07-01 02:13:38 -07:00
parent 2dd744de0f
commit 843544b9e8
23 changed files with 1901 additions and 1730 deletions

View File

@ -1 +1 @@
{"version":3,"file":"MaskSegmentCanvas.d.ts","sourceRoot":"","sources":["../../src/components/MaskSegmentCanvas.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AA8Cf,OAAO,KAAK,EAEV,sBAAsB,EACtB,oBAAoB,EAMrB,MAAM,2BAA2B,CAAC;AAcnC,YAAY,EACV,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,QAAQ,EACR,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AA0cnC,QAAA,MAAM,iBAAiB,qGAo/DrB,CAAC;AAmBH,eAAe,iBAAiB,CAAC"} {"version":3,"file":"MaskSegmentCanvas.d.ts","sourceRoot":"","sources":["../../src/components/MaskSegmentCanvas.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AA6Cf,OAAO,KAAK,EAEV,sBAAsB,EACtB,oBAAoB,EAMrB,MAAM,2BAA2B,CAAC;AAcnC,YAAY,EACV,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,QAAQ,EACR,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AA+BnC,QAAA,MAAM,iBAAiB,qGAo/DrB,CAAC;AAmBH,eAAe,iBAAiB,CAAC"}

View File

@ -4,7 +4,7 @@ import { View, StyleSheet, } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated'; import { runOnJS } from 'react-native-reanimated';
import cv from '../utils/opencvAdapter'; import cv from '../utils/opencvAdapter';
import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, isBaseboardMaskPixel, upscaleBinaryMask, } from '../utils/maskSegmentation'; import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, upscaleBinaryMask, } from '../utils/maskSegmentation';
import { splitWallRegionsByTexture } from '../utils/wallTextureSplit'; import { splitWallRegionsByTexture } from '../utils/wallTextureSplit';
import { clearDerivedImageCache, readPngBgrBuffer, prewarmPngBgrCache, resizeBgrBuffer, } from '../utils/pngImage'; import { clearDerivedImageCache, readPngBgrBuffer, prewarmPngBgrCache, resizeBgrBuffer, } from '../utils/pngImage';
import { hashUrl, resolveImageUrl } from '../utils/resolveImageUrl'; import { hashUrl, resolveImageUrl } from '../utils/resolveImageUrl';
@ -14,292 +14,7 @@ import { preparePaintResourcesFromWorkBuffer, releaseFreqLayerImages, } from '..
import { PaintShaderLayer, createPaintColorMapForPaint, } from '../utils/paintShaderRuntime'; import { PaintShaderLayer, createPaintColorMapForPaint, } from '../utils/paintShaderRuntime';
import { createRuntimeConfig, getMaskRuntimeRevision, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from '../utils/maskSegmentRuntime'; import { createRuntimeConfig, getMaskRuntimeRevision, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from '../utils/maskSegmentRuntime';
import { Canvas, Image as SkiaImage, Path, Group, DashPathEffect, Rect, useCanvasRef, Skia, } from '@shopify/react-native-skia'; import { Canvas, Image as SkiaImage, Path, Group, DashPathEffect, Rect, useCanvasRef, Skia, } from '@shopify/react-native-skia';
function bgrColorEquals(a, b) { import { bgrColorEquals, rectsEqual, getContainRect, canvasToNormalized, buildZoomPanMatrix, clampPanOffset, screenToCanvasCoords, resolveRegionHit, pickKickRegionFromMask, pickKickNearStrip, lookupRegionFromPickMap, releasePaintResourceLayers, releaseOriginSkImage, prepareWorkScaledBgrBuffer, timeLog, } from '../utils/canvasGeometry';
return a.b === b.b && a.g === b.g && a.r === b.r;
}
/* ==========================================================================
* 几何工具
* ========================================================================== */
function rectsEqual(a, b) {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
function getContainRect(canvasW, canvasH, imgW, imgH) {
const imgAspect = imgW / imgH;
const canvasAspect = canvasW / canvasH;
if (imgAspect > canvasAspect) {
const w = canvasW;
const h = canvasW / imgAspect;
return { x: 0, y: (canvasH - h) / 2, w, h };
}
const h = canvasH;
const w = canvasH * imgAspect;
return { x: (canvasW - w) / 2, y: 0, w, h };
}
function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
if (cx < rect.x ||
cx > rect.x + rect.w ||
cy < rect.y ||
cy > rect.y + rect.h) {
return null;
}
return {
x: (cx - rect.x) / rect.w,
y: (cy - rect.y) / rect.h,
};
}
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
function buildZoomPanMatrix(panX, panY, scale, canvasW, canvasH) {
const cx = canvasW / 2;
const cy = canvasH / 2;
const m = Skia.Matrix();
m.translate(panX, panY);
m.translate(cx, cy);
m.scale(scale, scale);
m.translate(-cx, -cy);
return m;
}
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
function clampPanOffset(pan, scale, canvasW, canvasH, containRect) {
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
return { x: 0, y: 0 };
}
const cx = canvasW / 2;
const cy = canvasH / 2;
const r = containRect;
const scaledMinX = cx + scale * (r.x - cx);
const scaledMaxX = cx + scale * (r.x + r.w - cx);
const scaledMinY = cy + scale * (r.y - cy);
const scaledMaxY = cy + scale * (r.y + r.h - cy);
let x = pan.x;
let y = pan.y;
if (scaledMaxX - scaledMinX > canvasW) {
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
}
else {
x = 0;
}
if (scaledMaxY - scaledMinY > canvasH) {
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
}
else {
y = 0;
}
return { x, y };
}
/**
* Inverse of the Skia Group transform applied during pinch-zoom.
* Converts a raw touch point (screen pixels) back to the canvas coordinate
* space where the image and regions are positioned before any scale/pan.
* When zoomScale 1 (no zoom), returns the input unchanged.
*/
function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) {
if (zoomScale <= 1)
return { x: screenX, y: screenY };
return {
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
};
}
function pointInPolygon(x, y, points) {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x;
const yi = points[i].y;
const xj = points[j].x;
const yj = points[j].y;
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect)
inside = !inside;
}
return inside;
}
function pointInPolygonWithPadding(x, y, points, padding) {
if (points.length < 3) {
return false;
}
let minX = points[0].x;
let maxX = points[0].x;
let minY = points[0].y;
let maxY = points[0].y;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
if (x >= minX - padding &&
x <= maxX + padding &&
y >= minY - padding &&
y <= maxY + padding) {
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
return true;
}
}
return pointInPolygon(x, y, points);
}
function getRegionHitPolygons(reg) {
return reg.hitPolygons && reg.hitPolygons.length > 0
? reg.hitPolygons
: reg.polygons;
}
function pointHitsRegion(x, y, reg, options) {
const interaction = getMaskSegmentRuntimeConfig().interaction;
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding));
}
function pointStrictlyHitsRegion(x, y, reg) {
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygon(x, y, poly));
}
function resolveRegionHit(regions, x, y) {
const hits = [];
for (const reg of regions) {
const bboxPad = reg.thinStrip ? 0.005 : 0;
const b = reg.bbox;
if (x < b.x - bboxPad ||
x > b.x + b.w + bboxPad ||
y < b.y - bboxPad ||
y > b.y + b.h + bboxPad) {
continue;
}
if (pointHitsRegion(x, y, reg)) {
hits.push(reg);
}
}
if (hits.length === 0) {
return null;
}
if (hits.length === 1) {
return hits[0].id;
}
const strictNonThin = hits.filter(reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
if (strictNonThin.length > 0) {
strictNonThin.sort((a, b) => a.area - b.area);
return strictNonThin[0].id;
}
const strictThin = hits.filter(reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
if (strictThin.length > 0) {
strictThin.sort((a, b) => a.area - b.area);
return strictThin[0].id;
}
const nonThin = hits.filter(reg => !reg.thinStrip);
if (nonThin.length > 0) {
nonThin.sort((a, b) => a.area - b.area);
return nonThin[0].id;
}
hits.sort((a, b) => a.area - b.area);
return hits[0].id;
}
function pickKickRegionFromMask(normX, normY, pick, kickRegionId, baseboardPickMask, strict = false) {
const cx = Math.floor(normX * pick.cols);
const cy = Math.floor(normY * pick.rows);
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
return null;
}
if (strict) {
if (baseboardPickMask) {
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
}
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
? kickRegionId
: null;
}
const interaction = getMaskSegmentRuntimeConfig().interaction;
const radius = Math.max(interaction.kickMaskPickRadiusPx, Math.floor(pick.cols * 0.022));
const radiusSq = radius * radius;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radiusSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
if (baseboardPickMask) {
if (baseboardPickMask[y * pick.cols + x]) {
return kickRegionId;
}
continue;
}
if (isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, x, y)) {
return kickRegionId;
}
}
}
return null;
}
function pickKickNearStrip(normX, normY, kickReg) {
const polys = kickReg.hitPolygons ?? kickReg.polygons;
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
return polys.some(poly => poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad));
}
function lookupRegionFromPickMap(normX, normY, pick, radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx) {
const cx = Math.min(pick.cols - 1, Math.max(0, Math.floor(normX * pick.cols)));
const cy = Math.min(pick.rows - 1, Math.max(0, Math.floor(normY * pick.rows)));
const readCode = (x, y) => pick.buffer[y * pick.cols + x];
const center = readCode(cx, cy);
if (center > 0) {
return center - 1;
}
if (radiusPx <= 0) {
return null;
}
const r = Math.max(4, radiusPx);
const rSq = r * r;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > rSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
const code = readCode(x, y);
if (code > 0) {
return code - 1;
}
}
}
return null;
}
function releasePaintResourceLayers(layers) {
if (!layers) {
return;
}
layers.lowFreqImage.dispose();
layers.highFreqImage.dispose();
}
function releaseOriginSkImage(image) {
if (image) {
image.dispose();
}
}
async function prepareWorkScaledBgrBuffer(bgrBuffer, cols, rows, workScale) {
if (workScale >= 1) {
return { buffer: bgrBuffer, cols, rows };
}
const workCols = Math.floor(cols * workScale);
const workRows = Math.floor(rows * workScale);
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
return { buffer, cols: workCols, rows: workRows };
}
/* ==========================================================================
* 分段计时工具仅开发环境生效
* ========================================================================== */
let _timeLogTs = 0;
function timeLog(tag) {
if (!__DEV__)
return;
const now = performance.now();
const dt = _timeLogTs ? now - _timeLogTs : 0;
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
_timeLogTs = now;
}
/* ========================================================================== /* ==========================================================================
* 组件主体 * 组件主体
* ========================================================================== */ * ========================================================================== */

File diff suppressed because one or more lines are too long

81
dist/utils/canvasGeometry.d.ts vendored Normal file
View File

@ -0,0 +1,81 @@
import { type SkImage } from '@shopify/react-native-skia';
import type { SegmentRegion } from './maskSegmentation';
import type { BgrColor } from '../components/MaskSegmentCanvas.types';
export type PaintResourceLayers = {
lowFreqImage: SkImage;
highFreqImage: SkImage;
};
export type ContainRect = {
x: number;
y: number;
w: number;
h: number;
};
export type WorkScaledBgr = {
buffer: Uint8Array;
cols: number;
rows: number;
};
export declare function bgrColorEquals(a: BgrColor, b: BgrColor): boolean;
export declare function rectsEqual(a: ContainRect, b: ContainRect): boolean;
export declare function getContainRect(canvasW: number, canvasH: number, imgW: number, imgH: number): ContainRect;
export declare function canvasToNormalized(cx: number, cy: number, canvasW: number, canvasH: number, imgW: number, imgH: number): {
x: number;
y: number;
} | null;
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
export declare function buildZoomPanMatrix(panX: number, panY: number, scale: number, canvasW: number, canvasH: number): import("@shopify/react-native-skia").SkMatrix;
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
export declare function clampPanOffset(pan: {
x: number;
y: number;
}, scale: number, canvasW: number, canvasH: number, containRect: ContainRect | null): {
x: number;
y: number;
};
/**
* Inverse of the Skia Group transform applied during pinch-zoom.
* Converts a raw touch point (screen pixels) back to the canvas coordinate
* space where the image and regions are positioned before any scale/pan.
* When zoomScale 1 (no zoom), returns the input unchanged.
*/
export declare function screenToCanvasCoords(screenX: number, screenY: number, canvasW: number, canvasH: number, zoomScale: number, panOffset: {
x: number;
y: number;
}): {
x: number;
y: number;
};
export declare function pointInPolygon(x: number, y: number, points: {
x: number;
y: number;
}[]): boolean;
export declare function pointInPolygonWithPadding(x: number, y: number, points: {
x: number;
y: number;
}[], padding: number): boolean;
export declare function getRegionHitPolygons(reg: SegmentRegion): {
x: number;
y: number;
}[][];
export declare function pointHitsRegion(x: number, y: number, reg: SegmentRegion, options?: {
thinPadding?: number;
}): boolean;
export declare function pointStrictlyHitsRegion(x: number, y: number, reg: SegmentRegion): boolean;
export declare function resolveRegionHit(regions: SegmentRegion[], x: number, y: number): number | null;
export declare function pickKickRegionFromMask(normX: number, normY: number, pick: {
buffer: Uint8Array;
cols: number;
rows: number;
}, kickRegionId: number, baseboardPickMask?: Uint8Array | null, strict?: boolean): number | null;
export declare function pickKickNearStrip(normX: number, normY: number, kickReg: SegmentRegion): boolean;
export declare function lookupRegionFromPickMap(normX: number, normY: number, pick: {
buffer: Uint8Array;
cols: number;
rows: number;
}, radiusPx?: number): number | null;
export declare function releasePaintResourceLayers(layers: PaintResourceLayers | null): void;
export declare function releaseOriginSkImage(image: SkImage | null): void;
export declare function prepareWorkScaledBgrBuffer(bgrBuffer: Uint8Array, cols: number, rows: number, workScale: number): Promise<WorkScaledBgr>;
export declare function timeLog(tag: string): void;
//# sourceMappingURL=canvasGeometry.d.ts.map

1
dist/utils/canvasGeometry.d.ts.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"canvasGeometry.d.ts","sourceRoot":"","sources":["../../src/utils/canvasGeometry.ts"],"names":[],"mappings":"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"}

306
dist/utils/canvasGeometry.js vendored Normal file
View File

@ -0,0 +1,306 @@
import { Skia } from '@shopify/react-native-skia';
import { isBaseboardMaskPixel } from './maskSegmentation';
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
import { resizeBgrBuffer } from './pngImage';
/* ==========================================================================
* 颜色工具
* ========================================================================== */
export function bgrColorEquals(a, b) {
return a.b === b.b && a.g === b.g && a.r === b.r;
}
/* ==========================================================================
* 几何工具
* ========================================================================== */
export function rectsEqual(a, b) {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
export function getContainRect(canvasW, canvasH, imgW, imgH) {
const imgAspect = imgW / imgH;
const canvasAspect = canvasW / canvasH;
if (imgAspect > canvasAspect) {
const w = canvasW;
const h = canvasW / imgAspect;
return { x: 0, y: (canvasH - h) / 2, w, h };
}
const h = canvasH;
const w = canvasH * imgAspect;
return { x: (canvasW - w) / 2, y: 0, w, h };
}
export function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
if (cx < rect.x ||
cx > rect.x + rect.w ||
cy < rect.y ||
cy > rect.y + rect.h) {
return null;
}
return {
x: (cx - rect.x) / rect.w,
y: (cy - rect.y) / rect.h,
};
}
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
export function buildZoomPanMatrix(panX, panY, scale, canvasW, canvasH) {
const cx = canvasW / 2;
const cy = canvasH / 2;
const m = Skia.Matrix();
m.translate(panX, panY);
m.translate(cx, cy);
m.scale(scale, scale);
m.translate(-cx, -cy);
return m;
}
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
export function clampPanOffset(pan, scale, canvasW, canvasH, containRect) {
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
return { x: 0, y: 0 };
}
const cx = canvasW / 2;
const cy = canvasH / 2;
const r = containRect;
const scaledMinX = cx + scale * (r.x - cx);
const scaledMaxX = cx + scale * (r.x + r.w - cx);
const scaledMinY = cy + scale * (r.y - cy);
const scaledMaxY = cy + scale * (r.y + r.h - cy);
let x = pan.x;
let y = pan.y;
if (scaledMaxX - scaledMinX > canvasW) {
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
}
else {
x = 0;
}
if (scaledMaxY - scaledMinY > canvasH) {
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
}
else {
y = 0;
}
return { x, y };
}
/**
* Inverse of the Skia Group transform applied during pinch-zoom.
* Converts a raw touch point (screen pixels) back to the canvas coordinate
* space where the image and regions are positioned before any scale/pan.
* When zoomScale 1 (no zoom), returns the input unchanged.
*/
export function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) {
if (zoomScale <= 1)
return { x: screenX, y: screenY };
return {
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
};
}
/* ==========================================================================
* 点击检测
* ========================================================================== */
export function pointInPolygon(x, y, points) {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x;
const yi = points[i].y;
const xj = points[j].x;
const yj = points[j].y;
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect)
inside = !inside;
}
return inside;
}
export function pointInPolygonWithPadding(x, y, points, padding) {
if (points.length < 3) {
return false;
}
let minX = points[0].x;
let maxX = points[0].x;
let minY = points[0].y;
let maxY = points[0].y;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
if (x >= minX - padding &&
x <= maxX + padding &&
y >= minY - padding &&
y <= maxY + padding) {
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
return true;
}
}
return pointInPolygon(x, y, points);
}
export function getRegionHitPolygons(reg) {
return reg.hitPolygons && reg.hitPolygons.length > 0
? reg.hitPolygons
: reg.polygons;
}
export function pointHitsRegion(x, y, reg, options) {
const interaction = getMaskSegmentRuntimeConfig().interaction;
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding));
}
export function pointStrictlyHitsRegion(x, y, reg) {
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygon(x, y, poly));
}
export function resolveRegionHit(regions, x, y) {
const hits = [];
for (const reg of regions) {
const bboxPad = reg.thinStrip ? 0.005 : 0;
const b = reg.bbox;
if (x < b.x - bboxPad ||
x > b.x + b.w + bboxPad ||
y < b.y - bboxPad ||
y > b.y + b.h + bboxPad) {
continue;
}
if (pointHitsRegion(x, y, reg)) {
hits.push(reg);
}
}
if (hits.length === 0) {
return null;
}
if (hits.length === 1) {
return hits[0].id;
}
const strictNonThin = hits.filter(reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
if (strictNonThin.length > 0) {
strictNonThin.sort((a, b) => a.area - b.area);
return strictNonThin[0].id;
}
const strictThin = hits.filter(reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
if (strictThin.length > 0) {
strictThin.sort((a, b) => a.area - b.area);
return strictThin[0].id;
}
const nonThin = hits.filter(reg => !reg.thinStrip);
if (nonThin.length > 0) {
nonThin.sort((a, b) => a.area - b.area);
return nonThin[0].id;
}
hits.sort((a, b) => a.area - b.area);
return hits[0].id;
}
/* ==========================================================================
* 踢脚线/掩码拾取
* ========================================================================== */
export function pickKickRegionFromMask(normX, normY, pick, kickRegionId, baseboardPickMask, strict = false) {
const cx = Math.floor(normX * pick.cols);
const cy = Math.floor(normY * pick.rows);
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
return null;
}
if (strict) {
if (baseboardPickMask) {
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
}
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
? kickRegionId
: null;
}
const interaction = getMaskSegmentRuntimeConfig().interaction;
const radius = Math.max(interaction.kickMaskPickRadiusPx, Math.floor(pick.cols * 0.022));
const radiusSq = radius * radius;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radiusSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
if (baseboardPickMask) {
if (baseboardPickMask[y * pick.cols + x]) {
return kickRegionId;
}
continue;
}
if (isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, x, y)) {
return kickRegionId;
}
}
}
return null;
}
export function pickKickNearStrip(normX, normY, kickReg) {
const polys = kickReg.hitPolygons ?? kickReg.polygons;
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
return polys.some(poly => poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad));
}
export function lookupRegionFromPickMap(normX, normY, pick, radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx) {
const cx = Math.min(pick.cols - 1, Math.max(0, Math.floor(normX * pick.cols)));
const cy = Math.min(pick.rows - 1, Math.max(0, Math.floor(normY * pick.rows)));
const readCode = (x, y) => pick.buffer[y * pick.cols + x];
const center = readCode(cx, cy);
if (center > 0) {
return center - 1;
}
if (radiusPx <= 0) {
return null;
}
const r = Math.max(4, radiusPx);
const rSq = r * r;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > rSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
const code = readCode(x, y);
if (code > 0) {
return code - 1;
}
}
}
return null;
}
/* ==========================================================================
* 资源释放
* ========================================================================== */
export function releasePaintResourceLayers(layers) {
if (!layers) {
return;
}
layers.lowFreqImage.dispose();
layers.highFreqImage.dispose();
}
export function releaseOriginSkImage(image) {
if (image) {
image.dispose();
}
}
/* ==========================================================================
* 工作缓冲区缩放
* ========================================================================== */
export async function prepareWorkScaledBgrBuffer(bgrBuffer, cols, rows, workScale) {
if (workScale >= 1) {
return { buffer: bgrBuffer, cols, rows };
}
const workCols = Math.floor(cols * workScale);
const workRows = Math.floor(rows * workScale);
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
return { buffer, cols: workCols, rows: workRows };
}
/* ==========================================================================
* 分段计时工具仅开发环境生效
* ========================================================================== */
let _timeLogTs = 0;
export function timeLog(tag) {
if (!__DEV__)
return;
const now = performance.now();
const dt = _timeLogTs ? now - _timeLogTs : 0;
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
_timeLogTs = now;
}
//# sourceMappingURL=canvasGeometry.js.map

1
dist/utils/canvasGeometry.js.map vendored Normal file

File diff suppressed because one or more lines are too long

18
dist/utils/maskOutlinePaths.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
import { type SkPath } from '@shopify/react-native-skia';
import type { SegmentRegion, RegionMaskData } from './maskSegmentation';
export declare function buildRegionOutlinePathForRegion(regionId: number, regions: SegmentRegion[], maskData: RegionMaskData, rect: {
x: number;
y: number;
w: number;
h: number;
}, normSeed?: {
x: number;
y: number;
}): SkPath;
export declare function buildAllRegionOutlinePaths(regions: SegmentRegion[], maskData: RegionMaskData, rect: {
x: number;
y: number;
w: number;
h: number;
}): Map<number, SkPath>;
//# sourceMappingURL=maskOutlinePaths.d.ts.map

1
dist/utils/maskOutlinePaths.d.ts.map vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"maskOutlinePaths.d.ts","sourceRoot":"","sources":["../../src/utils/maskOutlinePaths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,MAAM,EAAE,MAAM,4BAA4B,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAqcxE,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,aAAa,EAAE,EACxB,QAAQ,EAAE,cAAc,EACxB,IAAI,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,EACpD,QAAQ,CAAC,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GAClC,MAAM,CAUR;AA4DD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,aAAa,EAAE,EACxB,QAAQ,EAAE,cAAc,EACxB,IAAI,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAoBrB"}

419
dist/utils/maskOutlinePaths.js vendored Normal file
View File

@ -0,0 +1,419 @@
import { Skia } from '@shopify/react-native-skia';
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
function isMaskPixelOn(binary, cols, rows, x, y) {
return (x >= 0 && x < cols && y >= 0 && y < rows && binary[y * cols + x] > 0);
}
function collectBoundaryEdges(binary, cols, rows) {
const edges = [];
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
if (!binary[row + x]) {
continue;
}
if (!isMaskPixelOn(binary, cols, rows, x, y - 1)) {
edges.push({ x0: x, y0: y, x1: x + 1, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x, y + 1)) {
edges.push({ x0: x + 1, y0: y + 1, x1: x, y1: y + 1 });
}
if (!isMaskPixelOn(binary, cols, rows, x - 1, y)) {
edges.push({ x0: x, y0: y + 1, x1: x, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x + 1, y)) {
edges.push({ x0: x + 1, y0: y, x1: x + 1, y1: y + 1 });
}
}
}
return edges;
}
function chainBoundaryLoops(edges) {
const outgoing = new Map();
const edgeKey = (edge) => `${edge.x0},${edge.y0}->${edge.x1},${edge.y1}`;
for (const edge of edges) {
const key = `${edge.x0},${edge.y0}`;
const list = outgoing.get(key);
if (list) {
list.push(edge);
}
else {
outgoing.set(key, [edge]);
}
}
const used = new Set();
const loops = [];
for (const edge of edges) {
const startEdgeKey = edgeKey(edge);
if (used.has(startEdgeKey)) {
continue;
}
const loop = [{ x: edge.x0, y: edge.y0 }];
let current = edge;
used.add(startEdgeKey);
loop.push({ x: current.x1, y: current.y1 });
while (true) {
const endKey = `${current.x1},${current.y1}`;
const startKey = `${loop[0].x},${loop[0].y}`;
if (endKey === startKey && loop.length > 2) {
break;
}
const candidates = outgoing.get(endKey);
const next = candidates?.find(candidate => !used.has(edgeKey(candidate)));
if (!next) {
break;
}
current = next;
used.add(edgeKey(current));
loop.push({ x: current.x1, y: current.y1 });
if (loop.length > edges.length + 1) {
break;
}
}
if (loop.length >= 4) {
loops.push(loop);
}
}
return loops;
}
function simplifyOrthogonalLoop(points) {
if (points.length <= 3) {
return points;
}
const out = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
const prev = out[out.length - 1];
const curr = points[i];
const next = points[i + 1];
const collinearX = prev.x === curr.x && curr.x === next.x;
const collinearY = prev.y === curr.y && curr.y === next.y;
if (!collinearX && !collinearY) {
out.push(curr);
}
}
out.push(points[points.length - 1]);
return out;
}
function perpendicularDistance2(point, lineStart, lineEnd) {
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - lineStart.x, point.y - lineStart.y);
}
const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) /
(dx * dx + dy * dy);
const projX = lineStart.x + t * dx;
const projY = lineStart.y + t * dy;
return Math.hypot(point.x - projX, point.y - projY);
}
/**
* Ramer-Douglas-Peucker simplification for outline loops.
* Reduces stair-step jagginess from grid boundary tracing so that dashed
* outlines render smoothly instead of zigzagging across every pixel edge.
* Epsilon is in grid-pixel units (1.0 = one mask pixel).
*/
function simplifyLoopRdp(points, epsilon) {
if (points.length <= 2) {
return points;
}
let maxDist = 0;
let index = 0;
const end = points.length - 1;
const lineStart = points[0];
const lineEnd = points[end];
for (let i = 1; i < end; i++) {
const p = points[i];
const dist = perpendicularDistance2(p, lineStart, lineEnd);
if (dist > maxDist) {
maxDist = dist;
index = i;
}
}
if (maxDist > epsilon) {
const left = simplifyLoopRdp(points.slice(0, index + 1), epsilon);
const right = simplifyLoopRdp(points.slice(index), epsilon);
return [...left.slice(0, -1), ...right];
}
return [lineStart, lineEnd];
}
function loopsToSkPath(loops, cols, rows, rect) {
const path = Skia.Path.Make();
for (const rawLoop of loops) {
// Two-pass simplification: first remove collinear points, then RDP to
// smooth stair-step artifacts from grid boundary tracing.
const orthogonal = simplifyOrthogonalLoop(rawLoop);
const loop = simplifyLoopRdp(orthogonal, 1.0);
if (loop.length < 2) {
continue;
}
const [first, ...rest] = loop;
path.moveTo(rect.x + (first.x / cols) * rect.w, rect.y + (first.y / rows) * rect.h);
for (const point of rest) {
path.lineTo(rect.x + (point.x / cols) * rect.w, rect.y + (point.y / rows) * rect.h);
}
path.close();
}
return path;
}
function pointInIntegerLoop(px, py, loop) {
let inside = false;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) {
const xi = loop[i].x;
const yi = loop[i].y;
const xj = loop[j].x;
const yj = loop[j].y;
const intersect = yi > py !== yj > py &&
px < ((xj - xi) * (py - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
function loopBoundingArea(loop) {
if (loop.length === 0) {
return 0;
}
let minX = loop[0].x;
let maxX = loop[0].x;
let minY = loop[0].y;
let maxY = loop[0].y;
for (const point of loop) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return (maxX - minX) * (maxY - minY);
}
function filterOutlineLoops(loops, cols, rows, seedPx) {
if (loops.length === 0) {
return loops;
}
const minLoopArea = Math.max(16, Math.floor(cols * rows * 0.00005));
const significant = loops.filter(loop => loopBoundingArea(loop) >= minLoopArea);
const candidates = significant.length > 0 ? significant : loops;
if (seedPx) {
const sampleX = seedPx.x + 0.5;
const sampleY = seedPx.y + 0.5;
const containing = candidates.filter(loop => pointInIntegerLoop(sampleX, sampleY, loop));
if (containing.length > 0) {
containing.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
return containing;
}
}
candidates.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
// Keep loops ≥ 5% of the largest area to filter isolated noise speckles
// while retaining genuine disconnected fragments of the same region.
const minKeepArea = loopBoundingArea(candidates[0]) * 0.05;
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
}
function floodFillComponent(binary, cols, rows, seedX, seedY) {
if (seedX < 0 ||
seedY < 0 ||
seedX >= cols ||
seedY >= rows ||
!binary[seedY * cols + seedX]) {
return null;
}
const out = new Uint8Array(cols * rows);
const stack = [seedY * cols + seedX];
out[stack[0]] = 255;
while (stack.length > 0) {
const index = stack.pop();
const x = index % cols;
const y = (index - x) / cols;
if (x > 0) {
const left = index - 1;
if (binary[left] && !out[left]) {
out[left] = 255;
stack.push(left);
}
}
if (x + 1 < cols) {
const right = index + 1;
if (binary[right] && !out[right]) {
out[right] = 255;
stack.push(right);
}
}
if (y > 0) {
const up = index - cols;
if (binary[up] && !out[up]) {
out[up] = 255;
stack.push(up);
}
}
if (y + 1 < rows) {
const down = index + cols;
if (binary[down] && !out[down]) {
out[down] = 255;
stack.push(down);
}
}
}
return out;
}
function findLargestComponentSeed(binary, cols, rows) {
const visited = new Uint8Array(cols * rows);
let bestArea = 0;
let bestSeed = null;
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
const start = row + x;
if (!binary[start] || visited[start]) {
continue;
}
let area = 0;
let sumX = 0;
let sumY = 0;
const stack = [start];
visited[start] = 1;
while (stack.length > 0) {
const index = stack.pop();
area += 1;
const px = index % cols;
const py = (index - px) / cols;
sumX += px;
sumY += py;
if (px > 0) {
const left = index - 1;
if (binary[left] && !visited[left]) {
visited[left] = 1;
stack.push(left);
}
}
if (px + 1 < cols) {
const right = index + 1;
if (binary[right] && !visited[right]) {
visited[right] = 1;
stack.push(right);
}
}
if (py > 0) {
const up = index - cols;
if (binary[up] && !visited[up]) {
visited[up] = 1;
stack.push(up);
}
}
if (py + 1 < rows) {
const down = index + cols;
if (binary[down] && !visited[down]) {
visited[down] = 1;
stack.push(down);
}
}
}
if (area > bestArea) {
bestArea = area;
bestSeed = {
x: Math.floor(sumX / area),
y: Math.floor(sumY / area),
};
}
}
}
return bestSeed;
}
function buildRegionOutlinePathFromBinary(binary, cols, rows, rect, seedPx) {
let working = binary;
if (seedPx) {
const component = floodFillComponent(binary, cols, rows, seedPx.x, seedPx.y);
if (!component) {
return Skia.Path.Make();
}
working = component;
}
const edges = collectBoundaryEdges(working, cols, rows);
const loops = chainBoundaryLoops(edges);
const filtered = filterOutlineLoops(loops, cols, rows, seedPx);
return loopsToSkPath(filtered, cols, rows, rect);
}
function resolveRegionOutlineSeedPx(binary, cols, rows, normSeed) {
if (normSeed) {
return {
x: Math.min(cols - 1, Math.max(0, Math.floor(normSeed.x * cols))),
y: Math.min(rows - 1, Math.max(0, Math.floor(normSeed.y * rows))),
};
}
return findLargestComponentSeed(binary, cols, rows) ?? undefined;
}
export function buildRegionOutlinePathForRegion(regionId, regions, maskData, rect, normSeed) {
const binaries = extractRegionBinaries(regions, maskData);
const binary = binaries.get(regionId);
if (!binary) {
return Skia.Path.Make();
}
const { cols, rows } = maskData;
const seedPx = resolveRegionOutlineSeedPx(binary, cols, rows, normSeed);
return buildRegionOutlinePathFromBinary(binary, cols, rows, rect, seedPx);
}
function extractRegionBinaries(regions, maskData) {
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
const size = cols * rows;
const binaries = new Map();
const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors;
const regionIdBySemantic = new Int32Array(semanticColors.length);
regionIdBySemantic.fill(-1);
const wallSubRegionIds = new Map();
let baseboardRegionId = null;
for (const reg of regions) {
binaries.set(reg.id, new Uint8Array(size));
if (reg.thinStrip) {
baseboardRegionId = reg.id;
continue;
}
const wallMatch = /^wall-(\d+)$/.exec(reg.name);
if (wallMatch && wallSubLabels) {
wallSubRegionIds.set(Number(wallMatch[1]) - 1, reg.id);
continue;
}
const semanticIndex = semanticColors.findIndex(entry => entry.name === reg.name);
if (semanticIndex >= 0) {
regionIdBySemantic[semanticIndex] = reg.id;
}
}
const semanticCount = semanticColors.length;
const wallIdx = semanticColors.findIndex(entry => entry.name === 'wall');
for (let i = 0; i < size; i++) {
if (baseboardRegionId != null && baseboardBinary[i] > 0) {
binaries.get(baseboardRegionId)[i] = 255;
continue;
}
if (wallSubLabels && wallIdx >= 0 && labels[i] === wallIdx) {
const subIdx = wallSubLabels[i];
if (subIdx !== 255) {
const regionId = wallSubRegionIds.get(subIdx);
if (regionId !== undefined) {
binaries.get(regionId)[i] = 255;
}
}
continue;
}
const semanticIndex = labels[i];
if (semanticIndex < semanticCount && regionIdBySemantic[semanticIndex] >= 0) {
binaries.get(regionIdBySemantic[semanticIndex])[i] = 255;
}
}
return binaries;
}
export function buildAllRegionOutlinePaths(regions, maskData, rect) {
const { cols, rows } = maskData;
const binaries = extractRegionBinaries(regions, maskData);
const map = new Map();
for (const reg of regions) {
const binary = binaries.get(reg.id);
if (!binary) {
map.set(reg.id, Skia.Path.Make());
continue;
}
// No seed — build outlines from the full binary so all disconnected
// fragments (common after mask downsampling) get dashed outlines during
// the init flash loop. Per-region hold highlights still use a touch-seed
// via buildRegionOutlinePathForRegion for precise fragment isolation.
map.set(reg.id, buildRegionOutlinePathFromBinary(binary, cols, rows, rect));
}
return map;
}
//# sourceMappingURL=maskOutlinePaths.js.map

1
dist/utils/maskOutlinePaths.js.map vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
{"version":3,"file":"maskSegmentRuntime.d.ts","sourceRoot":"","sources":["../../src/utils/maskSegmentRuntime.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,iBAAiB,EACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EACV,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,cAAc,EAEf,MAAM,uCAAuC,CAAC;AAC/C,WAAW;AACX,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,cAAc,CAQlD,CAAC;AAEF,aAAa;AACb,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,cAAc,CAQpD,CAAC;AAEF,UAAU;AACV,eAAO,MAAM,YAAY,EAAE,QAAQ,CAAC,cAAc,CAQjD,CAAC;AACF,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,cAAc,EAAE,QAAQ,CAAC,cAAc,CAAC,CAI7E,CAAC;AAEF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,cAAc,CAAiB,CAAC;AAE/E,wBAAgB,qBAAqB,CACnC,MAAM,CAAC,EAAE,cAAc,EACvB,SAAS,CAAC,EAAE,cAAc,GACzB,QAAQ,CAAC,cAAc,CAAC,CAI1B;AAWD,eAAO,MAAM,oBAAoB,EAAE,QAAQ,CAAC,WAAW,CAkBtD,CAAC;AAEF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,iBAAiB,CAOlE,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CACxC,IAAI,CACF,iBAAiB,EACf,yBAAyB,GACzB,eAAe,GACf,kBAAkB,GAClB,wBAAwB,CAC3B,CACF,GAAG;IACF,uBAAuB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACrC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,sBAAsB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpC,cAAc,EAAE,iBAAiB,EAAE,CAAC;CAwBrC,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IACnC,IAAI,EAAE,OAAO,mBAAmB,CAAC;IACjC,KAAK,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC7B,WAAW,EAAE,QAAQ,CAAC,iBAAiB,CAAC,CAAC;CAC1C,CAAC;AAMF,wBAAgB,eAAe,CAC7B,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,mBAAmB,CA0D5B;AAED,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE;IAC1C,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC,GAAG,0BAA0B,CAiB7B;AAKD,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE;IAClD,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC,GAAG,0BAA0B,CAiB7B;AAED,wBAAgB,2BAA2B,IAAI,0BAA0B,CAExE;AAED,wBAAgB,6BAA6B,IAAI,0BAA0B,CAI1E"} {"version":3,"file":"maskSegmentRuntime.d.ts","sourceRoot":"","sources":["../../src/utils/maskSegmentRuntime.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,iBAAiB,EACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EACV,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,cAAc,EAEf,MAAM,uCAAuC,CAAC;AAC/C,WAAW;AACX,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,cAAc,CAQlD,CAAC;AAEF,aAAa;AACb,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,cAAc,CAQpD,CAAC;AAEF,UAAU;AACV,eAAO,MAAM,YAAY,EAAE,QAAQ,CAAC,cAAc,CAQjD,CAAC;AACF,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,cAAc,EAAE,QAAQ,CAAC,cAAc,CAAC,CAI7E,CAAC;AAEF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,cAAc,CAAmB,CAAC;AAEjF,wBAAgB,qBAAqB,CACnC,MAAM,CAAC,EAAE,cAAc,EACvB,SAAS,CAAC,EAAE,cAAc,GACzB,QAAQ,CAAC,cAAc,CAAC,CAI1B;AAWD,eAAO,MAAM,oBAAoB,EAAE,QAAQ,CAAC,WAAW,CAkBtD,CAAC;AAEF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,iBAAiB,CAOlE,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CACxC,IAAI,CACF,iBAAiB,EACf,yBAAyB,GACzB,eAAe,GACf,kBAAkB,GAClB,wBAAwB,CAC3B,CACF,GAAG;IACF,uBAAuB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACrC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,sBAAsB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpC,cAAc,EAAE,iBAAiB,EAAE,CAAC;CAwBrC,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IACnC,IAAI,EAAE,OAAO,mBAAmB,CAAC;IACjC,KAAK,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC7B,WAAW,EAAE,QAAQ,CAAC,iBAAiB,CAAC,CAAC;CAC1C,CAAC;AAMF,wBAAgB,eAAe,CAC7B,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,mBAAmB,CA0D5B;AAED,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE;IAC1C,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC,GAAG,0BAA0B,CAiB7B;AAKD,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE;IAClD,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC,GAAG,0BAA0B,CAiB7B;AAED,wBAAgB,2BAA2B,IAAI,0BAA0B,CAExE;AAED,wBAAgB,6BAA6B,IAAI,0BAA0B,CAI1E"}

View File

@ -34,7 +34,7 @@ export const PIPELINE_PRESETS = {
medium: PIPELINE_MEDIUM, medium: PIPELINE_MEDIUM,
low: PIPELINE_LOW, low: PIPELINE_LOW,
}; };
export const DEFAULT_PIPELINE_CONFIG = PIPELINE_HIGH; export const DEFAULT_PIPELINE_CONFIG = PIPELINE_MEDIUM;
export function resolvePipelineConfig(preset, overrides) { export function resolvePipelineConfig(preset, overrides) {
const base = preset != null ? PIPELINE_PRESETS[preset] : DEFAULT_PIPELINE_CONFIG; const base = preset != null ? PIPELINE_PRESETS[preset] : DEFAULT_PIPELINE_CONFIG;
return { ...base, ...overrides }; return { ...base, ...overrides };

File diff suppressed because one or more lines are too long

View File

@ -55,21 +55,8 @@ export type SegmentRegion = {
thinStrip?: boolean; thinStrip?: boolean;
}; };
export declare function buildRegionOutlinePolygons(reg: SegmentRegion): NormPoint[][]; export declare function buildRegionOutlinePolygons(reg: SegmentRegion): NormPoint[][];
export declare function buildRegionOutlinePathForRegion(regionId: number, regions: SegmentRegion[], maskData: RegionMaskData, rect: { import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion } from './maskOutlinePaths';
x: number; export { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion };
y: number;
w: number;
h: number;
}, normSeed?: {
x: number;
y: number;
}): SkPath;
export declare function buildAllRegionOutlinePaths(regions: SegmentRegion[], maskData: RegionMaskData, rect: {
x: number;
y: number;
w: number;
h: number;
}): Map<number, SkPath>;
/** 从二值图逐行条带构建蒙版(供 Skia PathBuilder 使用) */ /** 从二值图逐行条带构建蒙版(供 Skia PathBuilder 使用) */
export declare function appendMaskBinaryToPathBuilder(binary: Uint8Array, cols: number, rows: number, rect: { export declare function appendMaskBinaryToPathBuilder(binary: Uint8Array, cols: number, rows: number, rect: {
x: number; x: number;

View File

@ -1 +1 @@
{"version":3,"file":"maskSegmentation.d.ts","sourceRoot":"","sources":["../../src/utils/maskSegmentation.ts"],"names":[],"mappings":"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"} {"version":3,"file":"maskSegmentation.d.ts","sourceRoot":"","sources":["../../src/utils/maskSegmentation.ts"],"names":[],"mappings":"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"}

View File

@ -24,422 +24,9 @@ export function buildRegionOutlinePolygons(reg) {
} }
return [bboxToPolygon(reg.bbox)]; return [bboxToPolygon(reg.bbox)];
} }
function isMaskPixelOn(binary, cols, rows, x, y) { import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion } from './maskOutlinePaths';
return (x >= 0 && x < cols && y >= 0 && y < rows && binary[y * cols + x] > 0); // Re-export for backward compatibility
} export { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion };
function collectBoundaryEdges(binary, cols, rows) {
const edges = [];
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
if (!binary[row + x]) {
continue;
}
if (!isMaskPixelOn(binary, cols, rows, x, y - 1)) {
edges.push({ x0: x, y0: y, x1: x + 1, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x, y + 1)) {
edges.push({ x0: x + 1, y0: y + 1, x1: x, y1: y + 1 });
}
if (!isMaskPixelOn(binary, cols, rows, x - 1, y)) {
edges.push({ x0: x, y0: y + 1, x1: x, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x + 1, y)) {
edges.push({ x0: x + 1, y0: y, x1: x + 1, y1: y + 1 });
}
}
}
return edges;
}
function chainBoundaryLoops(edges) {
const outgoing = new Map();
const edgeKey = (edge) => `${edge.x0},${edge.y0}->${edge.x1},${edge.y1}`;
for (const edge of edges) {
const key = `${edge.x0},${edge.y0}`;
const list = outgoing.get(key);
if (list) {
list.push(edge);
}
else {
outgoing.set(key, [edge]);
}
}
const used = new Set();
const loops = [];
for (const edge of edges) {
const startEdgeKey = edgeKey(edge);
if (used.has(startEdgeKey)) {
continue;
}
const loop = [{ x: edge.x0, y: edge.y0 }];
let current = edge;
used.add(startEdgeKey);
loop.push({ x: current.x1, y: current.y1 });
while (true) {
const endKey = `${current.x1},${current.y1}`;
const startKey = `${loop[0].x},${loop[0].y}`;
if (endKey === startKey && loop.length > 2) {
break;
}
const candidates = outgoing.get(endKey);
const next = candidates?.find(candidate => !used.has(edgeKey(candidate)));
if (!next) {
break;
}
current = next;
used.add(edgeKey(current));
loop.push({ x: current.x1, y: current.y1 });
if (loop.length > edges.length + 1) {
break;
}
}
if (loop.length >= 4) {
loops.push(loop);
}
}
return loops;
}
function simplifyOrthogonalLoop(points) {
if (points.length <= 3) {
return points;
}
const out = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
const prev = out[out.length - 1];
const curr = points[i];
const next = points[i + 1];
const collinearX = prev.x === curr.x && curr.x === next.x;
const collinearY = prev.y === curr.y && curr.y === next.y;
if (!collinearX && !collinearY) {
out.push(curr);
}
}
out.push(points[points.length - 1]);
return out;
}
function perpendicularDistance2(point, lineStart, lineEnd) {
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - lineStart.x, point.y - lineStart.y);
}
const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) /
(dx * dx + dy * dy);
const projX = lineStart.x + t * dx;
const projY = lineStart.y + t * dy;
return Math.hypot(point.x - projX, point.y - projY);
}
/**
* Ramer-Douglas-Peucker simplification for outline loops.
* Reduces stair-step jagginess from grid boundary tracing so that dashed
* outlines render smoothly instead of zigzagging across every pixel edge.
* Epsilon is in grid-pixel units (1.0 = one mask pixel).
*/
function simplifyLoopRdp(points, epsilon) {
if (points.length <= 2) {
return points;
}
let maxDist = 0;
let index = 0;
const end = points.length - 1;
const lineStart = points[0];
const lineEnd = points[end];
for (let i = 1; i < end; i++) {
const p = points[i];
const dist = perpendicularDistance2(p, lineStart, lineEnd);
if (dist > maxDist) {
maxDist = dist;
index = i;
}
}
if (maxDist > epsilon) {
const left = simplifyLoopRdp(points.slice(0, index + 1), epsilon);
const right = simplifyLoopRdp(points.slice(index), epsilon);
return [...left.slice(0, -1), ...right];
}
return [lineStart, lineEnd];
}
function loopsToSkPath(loops, cols, rows, rect) {
const path = Skia.Path.Make();
for (const rawLoop of loops) {
// Two-pass simplification: first remove collinear points, then RDP to
// smooth stair-step artifacts from grid boundary tracing.
const orthogonal = simplifyOrthogonalLoop(rawLoop);
const loop = simplifyLoopRdp(orthogonal, 1.0);
if (loop.length < 2) {
continue;
}
const [first, ...rest] = loop;
path.moveTo(rect.x + (first.x / cols) * rect.w, rect.y + (first.y / rows) * rect.h);
for (const point of rest) {
path.lineTo(rect.x + (point.x / cols) * rect.w, rect.y + (point.y / rows) * rect.h);
}
path.close();
}
return path;
}
function pointInIntegerLoop(px, py, loop) {
let inside = false;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) {
const xi = loop[i].x;
const yi = loop[i].y;
const xj = loop[j].x;
const yj = loop[j].y;
const intersect = yi > py !== yj > py &&
px < ((xj - xi) * (py - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
function loopBoundingArea(loop) {
if (loop.length === 0) {
return 0;
}
let minX = loop[0].x;
let maxX = loop[0].x;
let minY = loop[0].y;
let maxY = loop[0].y;
for (const point of loop) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return (maxX - minX) * (maxY - minY);
}
function filterOutlineLoops(loops, cols, rows, seedPx) {
if (loops.length === 0) {
return loops;
}
const minLoopArea = Math.max(16, Math.floor(cols * rows * 0.00005));
const significant = loops.filter(loop => loopBoundingArea(loop) >= minLoopArea);
const candidates = significant.length > 0 ? significant : loops;
if (seedPx) {
const sampleX = seedPx.x + 0.5;
const sampleY = seedPx.y + 0.5;
const containing = candidates.filter(loop => pointInIntegerLoop(sampleX, sampleY, loop));
if (containing.length > 0) {
containing.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
return containing;
}
}
candidates.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
// Keep loops ≥ 5% of the largest area to filter isolated noise speckles
// while retaining genuine disconnected fragments of the same region.
const minKeepArea = loopBoundingArea(candidates[0]) * 0.05;
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
}
function floodFillComponent(binary, cols, rows, seedX, seedY) {
if (seedX < 0 ||
seedY < 0 ||
seedX >= cols ||
seedY >= rows ||
!binary[seedY * cols + seedX]) {
return null;
}
const out = new Uint8Array(cols * rows);
const stack = [seedY * cols + seedX];
out[stack[0]] = 255;
while (stack.length > 0) {
const index = stack.pop();
const x = index % cols;
const y = (index - x) / cols;
if (x > 0) {
const left = index - 1;
if (binary[left] && !out[left]) {
out[left] = 255;
stack.push(left);
}
}
if (x + 1 < cols) {
const right = index + 1;
if (binary[right] && !out[right]) {
out[right] = 255;
stack.push(right);
}
}
if (y > 0) {
const up = index - cols;
if (binary[up] && !out[up]) {
out[up] = 255;
stack.push(up);
}
}
if (y + 1 < rows) {
const down = index + cols;
if (binary[down] && !out[down]) {
out[down] = 255;
stack.push(down);
}
}
}
return out;
}
function findLargestComponentSeed(binary, cols, rows) {
const visited = new Uint8Array(cols * rows);
let bestArea = 0;
let bestSeed = null;
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
const start = row + x;
if (!binary[start] || visited[start]) {
continue;
}
let area = 0;
let sumX = 0;
let sumY = 0;
const stack = [start];
visited[start] = 1;
while (stack.length > 0) {
const index = stack.pop();
area += 1;
const px = index % cols;
const py = (index - px) / cols;
sumX += px;
sumY += py;
if (px > 0) {
const left = index - 1;
if (binary[left] && !visited[left]) {
visited[left] = 1;
stack.push(left);
}
}
if (px + 1 < cols) {
const right = index + 1;
if (binary[right] && !visited[right]) {
visited[right] = 1;
stack.push(right);
}
}
if (py > 0) {
const up = index - cols;
if (binary[up] && !visited[up]) {
visited[up] = 1;
stack.push(up);
}
}
if (py + 1 < rows) {
const down = index + cols;
if (binary[down] && !visited[down]) {
visited[down] = 1;
stack.push(down);
}
}
}
if (area > bestArea) {
bestArea = area;
bestSeed = {
x: Math.floor(sumX / area),
y: Math.floor(sumY / area),
};
}
}
}
return bestSeed;
}
function buildRegionOutlinePathFromBinary(binary, cols, rows, rect, seedPx) {
let working = binary;
if (seedPx) {
const component = floodFillComponent(binary, cols, rows, seedPx.x, seedPx.y);
if (!component) {
return Skia.Path.Make();
}
working = component;
}
const edges = collectBoundaryEdges(working, cols, rows);
const loops = chainBoundaryLoops(edges);
const filtered = filterOutlineLoops(loops, cols, rows, seedPx);
return loopsToSkPath(filtered, cols, rows, rect);
}
function resolveRegionOutlineSeedPx(binary, cols, rows, normSeed) {
if (normSeed) {
return {
x: Math.min(cols - 1, Math.max(0, Math.floor(normSeed.x * cols))),
y: Math.min(rows - 1, Math.max(0, Math.floor(normSeed.y * rows))),
};
}
return findLargestComponentSeed(binary, cols, rows) ?? undefined;
}
export function buildRegionOutlinePathForRegion(regionId, regions, maskData, rect, normSeed) {
const binaries = extractRegionBinaries(regions, maskData);
const binary = binaries.get(regionId);
if (!binary) {
return Skia.Path.Make();
}
const { cols, rows } = maskData;
const seedPx = resolveRegionOutlineSeedPx(binary, cols, rows, normSeed);
return buildRegionOutlinePathFromBinary(binary, cols, rows, rect, seedPx);
}
function extractRegionBinaries(regions, maskData) {
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
const size = cols * rows;
const binaries = new Map();
const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors;
const regionIdBySemantic = new Int32Array(semanticColors.length);
regionIdBySemantic.fill(-1);
const wallSubRegionIds = new Map();
let baseboardRegionId = null;
for (const reg of regions) {
binaries.set(reg.id, new Uint8Array(size));
if (reg.thinStrip) {
baseboardRegionId = reg.id;
continue;
}
const wallMatch = /^wall-(\d+)$/.exec(reg.name);
if (wallMatch && wallSubLabels) {
wallSubRegionIds.set(Number(wallMatch[1]) - 1, reg.id);
continue;
}
const semanticIndex = semanticColors.findIndex(entry => entry.name === reg.name);
if (semanticIndex >= 0) {
regionIdBySemantic[semanticIndex] = reg.id;
}
}
const semanticCount = semanticColors.length;
const wallIdx = semanticColors.findIndex(entry => entry.name === 'wall');
for (let i = 0; i < size; i++) {
if (baseboardRegionId != null && baseboardBinary[i] > 0) {
binaries.get(baseboardRegionId)[i] = 255;
continue;
}
if (wallSubLabels && wallIdx >= 0 && labels[i] === wallIdx) {
const subIdx = wallSubLabels[i];
if (subIdx !== 255) {
const regionId = wallSubRegionIds.get(subIdx);
if (regionId !== undefined) {
binaries.get(regionId)[i] = 255;
}
}
continue;
}
const semanticIndex = labels[i];
if (semanticIndex < semanticCount && regionIdBySemantic[semanticIndex] >= 0) {
binaries.get(regionIdBySemantic[semanticIndex])[i] = 255;
}
}
return binaries;
}
export function buildAllRegionOutlinePaths(regions, maskData, rect) {
const { cols, rows } = maskData;
const binaries = extractRegionBinaries(regions, maskData);
const map = new Map();
for (const reg of regions) {
const binary = binaries.get(reg.id);
if (!binary) {
map.set(reg.id, Skia.Path.Make());
continue;
}
// No seed — build outlines from the full binary so all disconnected
// fragments (common after mask downsampling) get dashed outlines during
// the init flash loop. Per-region hold highlights still use a touch-seed
// via buildRegionOutlinePathForRegion for precise fragment isolation.
map.set(reg.id, buildRegionOutlinePathFromBinary(binary, cols, rows, rect));
}
return map;
}
function isBaseboardEntry(entry) { function isBaseboardEntry(entry) {
return entry.name === BASEBOARD_SEMANTIC_NAME; return entry.name === BASEBOARD_SEMANTIC_NAME;
} }

File diff suppressed because one or more lines are too long

View File

@ -19,7 +19,6 @@ import {
buildRegionOutlinePathForRegion, buildRegionOutlinePathForRegion,
downsampleMaskDataForPaths, downsampleMaskDataForPaths,
extractRegionsFromMaskBufferSync, extractRegionsFromMaskBufferSync,
isBaseboardMaskPixel,
upscaleBinaryMask, upscaleBinaryMask,
type RegionMaskData, type RegionMaskData,
type SegmentRegion, type SegmentRegion,
@ -85,458 +84,31 @@ export type {
MaskSemanticColor, MaskSemanticColor,
} from './MaskSegmentCanvas.types'; } from './MaskSegmentCanvas.types';
/* ========================================================================== import {
* type PaintResourceLayers,
* ========================================================================== */ type ContainRect,
type PaintResourceLayers = { type WorkScaledBgr,
lowFreqImage: SkImage; bgrColorEquals,
highFreqImage: SkImage; rectsEqual,
}; getContainRect,
canvasToNormalized,
type ContainRect = { buildZoomPanMatrix,
x: number; clampPanOffset,
y: number; screenToCanvasCoords,
w: number; pointInPolygon,
h: number; pointInPolygonWithPadding,
}; getRegionHitPolygons,
pointHitsRegion,
function bgrColorEquals(a: BgrColor, b: BgrColor): boolean { pointStrictlyHitsRegion,
return a.b === b.b && a.g === b.g && a.r === b.r; resolveRegionHit,
} pickKickRegionFromMask,
pickKickNearStrip,
/* ========================================================================== lookupRegionFromPickMap,
* releasePaintResourceLayers,
* ========================================================================== */ releaseOriginSkImage,
function rectsEqual(a: ContainRect, b: ContainRect): boolean { prepareWorkScaledBgrBuffer,
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; timeLog,
} } from '../utils/canvasGeometry';
function getContainRect(
canvasW: number,
canvasH: number,
imgW: number,
imgH: number,
): ContainRect {
const imgAspect = imgW / imgH;
const canvasAspect = canvasW / canvasH;
if (imgAspect > canvasAspect) {
const w = canvasW;
const h = canvasW / imgAspect;
return { x: 0, y: (canvasH - h) / 2, w, h };
}
const h = canvasH;
const w = canvasH * imgAspect;
return { x: (canvasW - w) / 2, y: 0, w, h };
}
function canvasToNormalized(
cx: number,
cy: number,
canvasW: number,
canvasH: number,
imgW: number,
imgH: number,
): { x: number; y: number } | null {
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
if (
cx < rect.x ||
cx > rect.x + rect.w ||
cy < rect.y ||
cy > rect.y + rect.h
) {
return null;
}
return {
x: (cx - rect.x) / rect.w,
y: (cy - rect.y) / rect.h,
};
}
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
function buildZoomPanMatrix(
panX: number,
panY: number,
scale: number,
canvasW: number,
canvasH: number,
) {
const cx = canvasW / 2;
const cy = canvasH / 2;
const m = Skia.Matrix();
m.translate(panX, panY);
m.translate(cx, cy);
m.scale(scale, scale);
m.translate(-cx, -cy);
return m;
}
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
function clampPanOffset(
pan: { x: number; y: number },
scale: number,
canvasW: number,
canvasH: number,
containRect: ContainRect | null,
): { x: number; y: number } {
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
return { x: 0, y: 0 };
}
const cx = canvasW / 2;
const cy = canvasH / 2;
const r = containRect;
const scaledMinX = cx + scale * (r.x - cx);
const scaledMaxX = cx + scale * (r.x + r.w - cx);
const scaledMinY = cy + scale * (r.y - cy);
const scaledMaxY = cy + scale * (r.y + r.h - cy);
let x = pan.x;
let y = pan.y;
if (scaledMaxX - scaledMinX > canvasW) {
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
} else {
x = 0;
}
if (scaledMaxY - scaledMinY > canvasH) {
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
} else {
y = 0;
}
return { x, y };
}
/**
* Inverse of the Skia Group transform applied during pinch-zoom.
* Converts a raw touch point (screen pixels) back to the canvas coordinate
* space where the image and regions are positioned before any scale/pan.
* When zoomScale 1 (no zoom), returns the input unchanged.
*/
function screenToCanvasCoords(
screenX: number,
screenY: number,
canvasW: number,
canvasH: number,
zoomScale: number,
panOffset: { x: number; y: number },
): { x: number; y: number } {
if (zoomScale <= 1) return { x: screenX, y: screenY };
return {
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
};
}
function pointInPolygon(
x: number,
y: number,
points: { x: number; y: number }[],
): boolean {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x;
const yi = points[i].y;
const xj = points[j].x;
const yj = points[j].y;
const intersect =
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
function pointInPolygonWithPadding(
x: number,
y: number,
points: { x: number; y: number }[],
padding: number,
): boolean {
if (points.length < 3) {
return false;
}
let minX = points[0].x;
let maxX = points[0].x;
let minY = points[0].y;
let maxY = points[0].y;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
if (
x >= minX - padding &&
x <= maxX + padding &&
y >= minY - padding &&
y <= maxY + padding
) {
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
return true;
}
}
return pointInPolygon(x, y, points);
}
function getRegionHitPolygons(reg: SegmentRegion): { x: number; y: number }[][] {
return reg.hitPolygons && reg.hitPolygons.length > 0
? reg.hitPolygons
: reg.polygons;
}
function pointHitsRegion(
x: number,
y: number,
reg: SegmentRegion,
options?: { thinPadding?: number },
): boolean {
const interaction = getMaskSegmentRuntimeConfig().interaction;
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
return getRegionHitPolygons(reg).some(
poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding),
);
}
function pointStrictlyHitsRegion(x: number, y: number, reg: SegmentRegion): boolean {
return getRegionHitPolygons(reg).some(
poly => poly.length >= 3 && pointInPolygon(x, y, poly),
);
}
function resolveRegionHit(
regions: SegmentRegion[],
x: number,
y: number,
): number | null {
const hits: SegmentRegion[] = [];
for (const reg of regions) {
const bboxPad = reg.thinStrip ? 0.005 : 0;
const b = reg.bbox;
if (
x < b.x - bboxPad ||
x > b.x + b.w + bboxPad ||
y < b.y - bboxPad ||
y > b.y + b.h + bboxPad
) {
continue;
}
if (pointHitsRegion(x, y, reg)) {
hits.push(reg);
}
}
if (hits.length === 0) {
return null;
}
if (hits.length === 1) {
return hits[0].id;
}
const strictNonThin = hits.filter(
reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
);
if (strictNonThin.length > 0) {
strictNonThin.sort((a, b) => a.area - b.area);
return strictNonThin[0].id;
}
const strictThin = hits.filter(
reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
);
if (strictThin.length > 0) {
strictThin.sort((a, b) => a.area - b.area);
return strictThin[0].id;
}
const nonThin = hits.filter(reg => !reg.thinStrip);
if (nonThin.length > 0) {
nonThin.sort((a, b) => a.area - b.area);
return nonThin[0].id;
}
hits.sort((a, b) => a.area - b.area);
return hits[0].id;
}
function pickKickRegionFromMask(
normX: number,
normY: number,
pick: { buffer: Uint8Array; cols: number; rows: number },
kickRegionId: number,
baseboardPickMask?: Uint8Array | null,
strict = false,
): number | null {
const cx = Math.floor(normX * pick.cols);
const cy = Math.floor(normY * pick.rows);
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
return null;
}
if (strict) {
if (baseboardPickMask) {
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
}
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
? kickRegionId
: null;
}
const interaction = getMaskSegmentRuntimeConfig().interaction;
const radius = Math.max(
interaction.kickMaskPickRadiusPx,
Math.floor(pick.cols * 0.022),
);
const radiusSq = radius * radius;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radiusSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
if (baseboardPickMask) {
if (baseboardPickMask[y * pick.cols + x]) {
return kickRegionId;
}
continue;
}
if (
isBaseboardMaskPixel(
pick.buffer,
pick.cols,
pick.rows,
x,
y,
)
) {
return kickRegionId;
}
}
}
return null;
}
function pickKickNearStrip(
normX: number,
normY: number,
kickReg: SegmentRegion,
): boolean {
const polys = kickReg.hitPolygons ?? kickReg.polygons;
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
return polys.some(
poly =>
poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad),
);
}
function lookupRegionFromPickMap(
normX: number,
normY: number,
pick: { buffer: Uint8Array; cols: number; rows: number },
radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx,
): number | null {
const cx = Math.min(
pick.cols - 1,
Math.max(0, Math.floor(normX * pick.cols)),
);
const cy = Math.min(
pick.rows - 1,
Math.max(0, Math.floor(normY * pick.rows)),
);
const readCode = (x: number, y: number) => pick.buffer[y * pick.cols + x];
const center = readCode(cx, cy);
if (center > 0) {
return center - 1;
}
if (radiusPx <= 0) {
return null;
}
const r = Math.max(4, radiusPx);
const rSq = r * r;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > rSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
const code = readCode(x, y);
if (code > 0) {
return code - 1;
}
}
}
return null;
}
function releasePaintResourceLayers(layers: PaintResourceLayers | null) {
if (!layers) {
return;
}
layers.lowFreqImage.dispose();
layers.highFreqImage.dispose();
}
function releaseOriginSkImage(image: SkImage | null) {
if (image) {
image.dispose();
}
}
type WorkScaledBgr = {
buffer: Uint8Array;
cols: number;
rows: number;
};
async function prepareWorkScaledBgrBuffer(
bgrBuffer: Uint8Array,
cols: number,
rows: number,
workScale: number,
): Promise<WorkScaledBgr> {
if (workScale >= 1) {
return { buffer: bgrBuffer, cols, rows };
}
const workCols = Math.floor(cols * workScale);
const workRows = Math.floor(rows * workScale);
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
return { buffer, cols: workCols, rows: workRows };
}
/* ==========================================================================
*
* ========================================================================== */
let _timeLogTs = 0;
function timeLog(tag: string) {
if (!__DEV__) return;
const now = performance.now();
const dt = _timeLogTs ? now - _timeLogTs : 0;
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
_timeLogTs = now;
}
/* ========================================================================== /* ==========================================================================
* *

474
src/utils/canvasGeometry.ts Normal file
View File

@ -0,0 +1,474 @@
import { Skia, type SkImage } from '@shopify/react-native-skia';
import type { SegmentRegion } from './maskSegmentation';
import { isBaseboardMaskPixel } from './maskSegmentation';
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
import { resizeBgrBuffer } from './pngImage';
import type { BgrColor } from '../components/MaskSegmentCanvas.types';
/* ==========================================================================
*
* ========================================================================== */
export type PaintResourceLayers = {
lowFreqImage: SkImage;
highFreqImage: SkImage;
};
export type ContainRect = {
x: number;
y: number;
w: number;
h: number;
};
export type WorkScaledBgr = {
buffer: Uint8Array;
cols: number;
rows: number;
};
/* ==========================================================================
*
* ========================================================================== */
export function bgrColorEquals(a: BgrColor, b: BgrColor): boolean {
return a.b === b.b && a.g === b.g && a.r === b.r;
}
/* ==========================================================================
*
* ========================================================================== */
export function rectsEqual(a: ContainRect, b: ContainRect): boolean {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
export function getContainRect(
canvasW: number,
canvasH: number,
imgW: number,
imgH: number,
): ContainRect {
const imgAspect = imgW / imgH;
const canvasAspect = canvasW / canvasH;
if (imgAspect > canvasAspect) {
const w = canvasW;
const h = canvasW / imgAspect;
return { x: 0, y: (canvasH - h) / 2, w, h };
}
const h = canvasH;
const w = canvasH * imgAspect;
return { x: (canvasW - w) / 2, y: 0, w, h };
}
export function canvasToNormalized(
cx: number,
cy: number,
canvasW: number,
canvasH: number,
imgW: number,
imgH: number,
): { x: number; y: number } | null {
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
if (
cx < rect.x ||
cx > rect.x + rect.w ||
cy < rect.y ||
cy > rect.y + rect.h
) {
return null;
}
return {
x: (cx - rect.x) / rect.w,
y: (cy - rect.y) / rect.h,
};
}
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
export function buildZoomPanMatrix(
panX: number,
panY: number,
scale: number,
canvasW: number,
canvasH: number,
) {
const cx = canvasW / 2;
const cy = canvasH / 2;
const m = Skia.Matrix();
m.translate(panX, panY);
m.translate(cx, cy);
m.scale(scale, scale);
m.translate(-cx, -cy);
return m;
}
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
export function clampPanOffset(
pan: { x: number; y: number },
scale: number,
canvasW: number,
canvasH: number,
containRect: ContainRect | null,
): { x: number; y: number } {
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
return { x: 0, y: 0 };
}
const cx = canvasW / 2;
const cy = canvasH / 2;
const r = containRect;
const scaledMinX = cx + scale * (r.x - cx);
const scaledMaxX = cx + scale * (r.x + r.w - cx);
const scaledMinY = cy + scale * (r.y - cy);
const scaledMaxY = cy + scale * (r.y + r.h - cy);
let x = pan.x;
let y = pan.y;
if (scaledMaxX - scaledMinX > canvasW) {
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
} else {
x = 0;
}
if (scaledMaxY - scaledMinY > canvasH) {
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
} else {
y = 0;
}
return { x, y };
}
/**
* Inverse of the Skia Group transform applied during pinch-zoom.
* Converts a raw touch point (screen pixels) back to the canvas coordinate
* space where the image and regions are positioned before any scale/pan.
* When zoomScale 1 (no zoom), returns the input unchanged.
*/
export function screenToCanvasCoords(
screenX: number,
screenY: number,
canvasW: number,
canvasH: number,
zoomScale: number,
panOffset: { x: number; y: number },
): { x: number; y: number } {
if (zoomScale <= 1) return { x: screenX, y: screenY };
return {
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
};
}
/* ==========================================================================
*
* ========================================================================== */
export function pointInPolygon(
x: number,
y: number,
points: { x: number; y: number }[],
): boolean {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i].x;
const yi = points[i].y;
const xj = points[j].x;
const yj = points[j].y;
const intersect =
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
export function pointInPolygonWithPadding(
x: number,
y: number,
points: { x: number; y: number }[],
padding: number,
): boolean {
if (points.length < 3) {
return false;
}
let minX = points[0].x;
let maxX = points[0].x;
let minY = points[0].y;
let maxY = points[0].y;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
if (
x >= minX - padding &&
x <= maxX + padding &&
y >= minY - padding &&
y <= maxY + padding
) {
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
return true;
}
}
return pointInPolygon(x, y, points);
}
export function getRegionHitPolygons(reg: SegmentRegion): { x: number; y: number }[][] {
return reg.hitPolygons && reg.hitPolygons.length > 0
? reg.hitPolygons
: reg.polygons;
}
export function pointHitsRegion(
x: number,
y: number,
reg: SegmentRegion,
options?: { thinPadding?: number },
): boolean {
const interaction = getMaskSegmentRuntimeConfig().interaction;
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
return getRegionHitPolygons(reg).some(
poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding),
);
}
export function pointStrictlyHitsRegion(x: number, y: number, reg: SegmentRegion): boolean {
return getRegionHitPolygons(reg).some(
poly => poly.length >= 3 && pointInPolygon(x, y, poly),
);
}
export function resolveRegionHit(
regions: SegmentRegion[],
x: number,
y: number,
): number | null {
const hits: SegmentRegion[] = [];
for (const reg of regions) {
const bboxPad = reg.thinStrip ? 0.005 : 0;
const b = reg.bbox;
if (
x < b.x - bboxPad ||
x > b.x + b.w + bboxPad ||
y < b.y - bboxPad ||
y > b.y + b.h + bboxPad
) {
continue;
}
if (pointHitsRegion(x, y, reg)) {
hits.push(reg);
}
}
if (hits.length === 0) {
return null;
}
if (hits.length === 1) {
return hits[0].id;
}
const strictNonThin = hits.filter(
reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
);
if (strictNonThin.length > 0) {
strictNonThin.sort((a, b) => a.area - b.area);
return strictNonThin[0].id;
}
const strictThin = hits.filter(
reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
);
if (strictThin.length > 0) {
strictThin.sort((a, b) => a.area - b.area);
return strictThin[0].id;
}
const nonThin = hits.filter(reg => !reg.thinStrip);
if (nonThin.length > 0) {
nonThin.sort((a, b) => a.area - b.area);
return nonThin[0].id;
}
hits.sort((a, b) => a.area - b.area);
return hits[0].id;
}
/* ==========================================================================
* 线/
* ========================================================================== */
export function pickKickRegionFromMask(
normX: number,
normY: number,
pick: { buffer: Uint8Array; cols: number; rows: number },
kickRegionId: number,
baseboardPickMask?: Uint8Array | null,
strict = false,
): number | null {
const cx = Math.floor(normX * pick.cols);
const cy = Math.floor(normY * pick.rows);
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
return null;
}
if (strict) {
if (baseboardPickMask) {
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
}
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
? kickRegionId
: null;
}
const interaction = getMaskSegmentRuntimeConfig().interaction;
const radius = Math.max(
interaction.kickMaskPickRadiusPx,
Math.floor(pick.cols * 0.022),
);
const radiusSq = radius * radius;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radiusSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
if (baseboardPickMask) {
if (baseboardPickMask[y * pick.cols + x]) {
return kickRegionId;
}
continue;
}
if (
isBaseboardMaskPixel(
pick.buffer,
pick.cols,
pick.rows,
x,
y,
)
) {
return kickRegionId;
}
}
}
return null;
}
export function pickKickNearStrip(
normX: number,
normY: number,
kickReg: SegmentRegion,
): boolean {
const polys = kickReg.hitPolygons ?? kickReg.polygons;
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
return polys.some(
poly =>
poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad),
);
}
export function lookupRegionFromPickMap(
normX: number,
normY: number,
pick: { buffer: Uint8Array; cols: number; rows: number },
radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx,
): number | null {
const cx = Math.min(
pick.cols - 1,
Math.max(0, Math.floor(normX * pick.cols)),
);
const cy = Math.min(
pick.rows - 1,
Math.max(0, Math.floor(normY * pick.rows)),
);
const readCode = (x: number, y: number) => pick.buffer[y * pick.cols + x];
const center = readCode(cx, cy);
if (center > 0) {
return center - 1;
}
if (radiusPx <= 0) {
return null;
}
const r = Math.max(4, radiusPx);
const rSq = r * r;
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (dx * dx + dy * dy > rSq) {
continue;
}
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
continue;
}
const code = readCode(x, y);
if (code > 0) {
return code - 1;
}
}
}
return null;
}
/* ==========================================================================
*
* ========================================================================== */
export function releasePaintResourceLayers(layers: PaintResourceLayers | null) {
if (!layers) {
return;
}
layers.lowFreqImage.dispose();
layers.highFreqImage.dispose();
}
export function releaseOriginSkImage(image: SkImage | null) {
if (image) {
image.dispose();
}
}
/* ==========================================================================
*
* ========================================================================== */
export async function prepareWorkScaledBgrBuffer(
bgrBuffer: Uint8Array,
cols: number,
rows: number,
workScale: number,
): Promise<WorkScaledBgr> {
if (workScale >= 1) {
return { buffer: bgrBuffer, cols, rows };
}
const workCols = Math.floor(cols * workScale);
const workRows = Math.floor(rows * workScale);
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
return { buffer, cols: workCols, rows: workRows };
}
/* ==========================================================================
*
* ========================================================================== */
let _timeLogTs = 0;
export function timeLog(tag: string) {
if (!__DEV__) return;
const now = performance.now();
const dt = _timeLogTs ? now - _timeLogTs : 0;
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
_timeLogTs = now;
}

View File

@ -0,0 +1,556 @@
import { Skia, type SkPath } from '@shopify/react-native-skia';
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
import type { SegmentRegion, RegionMaskData } from './maskSegmentation';
/** 分区虚线高亮:从与填充蒙版相同的像素网格提取外轮廓 */
type GridPoint = { x: number; y: number };
type GridEdge = {
x0: number;
y0: number;
x1: number;
y1: number;
};
function isMaskPixelOn(
binary: Uint8Array,
cols: number,
rows: number,
x: number,
y: number,
): boolean {
return (
x >= 0 && x < cols && y >= 0 && y < rows && binary[y * cols + x] > 0
);
}
function collectBoundaryEdges(
binary: Uint8Array,
cols: number,
rows: number,
): GridEdge[] {
const edges: GridEdge[] = [];
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
if (!binary[row + x]) {
continue;
}
if (!isMaskPixelOn(binary, cols, rows, x, y - 1)) {
edges.push({ x0: x, y0: y, x1: x + 1, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x, y + 1)) {
edges.push({ x0: x + 1, y0: y + 1, x1: x, y1: y + 1 });
}
if (!isMaskPixelOn(binary, cols, rows, x - 1, y)) {
edges.push({ x0: x, y0: y + 1, x1: x, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x + 1, y)) {
edges.push({ x0: x + 1, y0: y, x1: x + 1, y1: y + 1 });
}
}
}
return edges;
}
function chainBoundaryLoops(edges: GridEdge[]): GridPoint[][] {
const outgoing = new Map<string, GridEdge[]>();
const edgeKey = (edge: GridEdge) =>
`${edge.x0},${edge.y0}->${edge.x1},${edge.y1}`;
for (const edge of edges) {
const key = `${edge.x0},${edge.y0}`;
const list = outgoing.get(key);
if (list) {
list.push(edge);
} else {
outgoing.set(key, [edge]);
}
}
const used = new Set<string>();
const loops: GridPoint[][] = [];
for (const edge of edges) {
const startEdgeKey = edgeKey(edge);
if (used.has(startEdgeKey)) {
continue;
}
const loop: GridPoint[] = [{ x: edge.x0, y: edge.y0 }];
let current = edge;
used.add(startEdgeKey);
loop.push({ x: current.x1, y: current.y1 });
while (true) {
const endKey = `${current.x1},${current.y1}`;
const startKey = `${loop[0].x},${loop[0].y}`;
if (endKey === startKey && loop.length > 2) {
break;
}
const candidates = outgoing.get(endKey);
const next = candidates?.find(candidate => !used.has(edgeKey(candidate)));
if (!next) {
break;
}
current = next;
used.add(edgeKey(current));
loop.push({ x: current.x1, y: current.y1 });
if (loop.length > edges.length + 1) {
break;
}
}
if (loop.length >= 4) {
loops.push(loop);
}
}
return loops;
}
function simplifyOrthogonalLoop(points: GridPoint[]): GridPoint[] {
if (points.length <= 3) {
return points;
}
const out: GridPoint[] = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
const prev = out[out.length - 1];
const curr = points[i];
const next = points[i + 1];
const collinearX = prev.x === curr.x && curr.x === next.x;
const collinearY = prev.y === curr.y && curr.y === next.y;
if (!collinearX && !collinearY) {
out.push(curr);
}
}
out.push(points[points.length - 1]);
return out;
}
function perpendicularDistance2(
point: GridPoint,
lineStart: GridPoint,
lineEnd: GridPoint,
): number {
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - lineStart.x, point.y - lineStart.y);
}
const t =
((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) /
(dx * dx + dy * dy);
const projX = lineStart.x + t * dx;
const projY = lineStart.y + t * dy;
return Math.hypot(point.x - projX, point.y - projY);
}
/**
* Ramer-Douglas-Peucker simplification for outline loops.
* Reduces stair-step jagginess from grid boundary tracing so that dashed
* outlines render smoothly instead of zigzagging across every pixel edge.
* Epsilon is in grid-pixel units (1.0 = one mask pixel).
*/
function simplifyLoopRdp(points: GridPoint[], epsilon: number): GridPoint[] {
if (points.length <= 2) {
return points;
}
let maxDist = 0;
let index = 0;
const end = points.length - 1;
const lineStart = points[0]!;
const lineEnd = points[end]!;
for (let i = 1; i < end; i++) {
const p = points[i]!;
const dist = perpendicularDistance2(p, lineStart, lineEnd);
if (dist > maxDist) {
maxDist = dist;
index = i;
}
}
if (maxDist > epsilon) {
const left = simplifyLoopRdp(points.slice(0, index + 1), epsilon);
const right = simplifyLoopRdp(points.slice(index), epsilon);
return [...left.slice(0, -1), ...right];
}
return [lineStart, lineEnd];
}
function loopsToSkPath(
loops: GridPoint[][],
cols: number,
rows: number,
rect: { x: number; y: number; w: number; h: number },
): SkPath {
const path = Skia.Path.Make();
for (const rawLoop of loops) {
// Two-pass simplification: first remove collinear points, then RDP to
// smooth stair-step artifacts from grid boundary tracing.
const orthogonal = simplifyOrthogonalLoop(rawLoop);
const loop = simplifyLoopRdp(orthogonal, 1.0);
if (loop.length < 2) {
continue;
}
const [first, ...rest] = loop;
path.moveTo(
rect.x + (first.x / cols) * rect.w,
rect.y + (first.y / rows) * rect.h,
);
for (const point of rest) {
path.lineTo(
rect.x + (point.x / cols) * rect.w,
rect.y + (point.y / rows) * rect.h,
);
}
path.close();
}
return path;
}
function pointInIntegerLoop(px: number, py: number, loop: GridPoint[]): boolean {
let inside = false;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) {
const xi = loop[i].x;
const yi = loop[i].y;
const xj = loop[j].x;
const yj = loop[j].y;
const intersect =
yi > py !== yj > py &&
px < ((xj - xi) * (py - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
function loopBoundingArea(loop: GridPoint[]): number {
if (loop.length === 0) {
return 0;
}
let minX = loop[0].x;
let maxX = loop[0].x;
let minY = loop[0].y;
let maxY = loop[0].y;
for (const point of loop) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return (maxX - minX) * (maxY - minY);
}
function filterOutlineLoops(
loops: GridPoint[][],
cols: number,
rows: number,
seedPx?: { x: number; y: number },
): GridPoint[][] {
if (loops.length === 0) {
return loops;
}
const minLoopArea = Math.max(16, Math.floor(cols * rows * 0.00005));
const significant = loops.filter(
loop => loopBoundingArea(loop) >= minLoopArea,
);
const candidates = significant.length > 0 ? significant : loops;
if (seedPx) {
const sampleX = seedPx.x + 0.5;
const sampleY = seedPx.y + 0.5;
const containing = candidates.filter(loop =>
pointInIntegerLoop(sampleX, sampleY, loop),
);
if (containing.length > 0) {
containing.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
return containing;
}
}
candidates.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
// Keep loops ≥ 5% of the largest area to filter isolated noise speckles
// while retaining genuine disconnected fragments of the same region.
const minKeepArea = loopBoundingArea(candidates[0]!) * 0.05;
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
}
function floodFillComponent(
binary: Uint8Array,
cols: number,
rows: number,
seedX: number,
seedY: number,
): Uint8Array | null {
if (
seedX < 0 ||
seedY < 0 ||
seedX >= cols ||
seedY >= rows ||
!binary[seedY * cols + seedX]
) {
return null;
}
const out = new Uint8Array(cols * rows);
const stack = [seedY * cols + seedX];
out[stack[0]] = 255;
while (stack.length > 0) {
const index = stack.pop()!;
const x = index % cols;
const y = (index - x) / cols;
if (x > 0) {
const left = index - 1;
if (binary[left] && !out[left]) {
out[left] = 255;
stack.push(left);
}
}
if (x + 1 < cols) {
const right = index + 1;
if (binary[right] && !out[right]) {
out[right] = 255;
stack.push(right);
}
}
if (y > 0) {
const up = index - cols;
if (binary[up] && !out[up]) {
out[up] = 255;
stack.push(up);
}
}
if (y + 1 < rows) {
const down = index + cols;
if (binary[down] && !out[down]) {
out[down] = 255;
stack.push(down);
}
}
}
return out;
}
function findLargestComponentSeed(
binary: Uint8Array,
cols: number,
rows: number,
): { x: number; y: number } | null {
const visited = new Uint8Array(cols * rows);
let bestArea = 0;
let bestSeed: { x: number; y: number } | null = null;
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
const start = row + x;
if (!binary[start] || visited[start]) {
continue;
}
let area = 0;
let sumX = 0;
let sumY = 0;
const stack = [start];
visited[start] = 1;
while (stack.length > 0) {
const index = stack.pop()!;
area += 1;
const px = index % cols;
const py = (index - px) / cols;
sumX += px;
sumY += py;
if (px > 0) {
const left = index - 1;
if (binary[left] && !visited[left]) {
visited[left] = 1;
stack.push(left);
}
}
if (px + 1 < cols) {
const right = index + 1;
if (binary[right] && !visited[right]) {
visited[right] = 1;
stack.push(right);
}
}
if (py > 0) {
const up = index - cols;
if (binary[up] && !visited[up]) {
visited[up] = 1;
stack.push(up);
}
}
if (py + 1 < rows) {
const down = index + cols;
if (binary[down] && !visited[down]) {
visited[down] = 1;
stack.push(down);
}
}
}
if (area > bestArea) {
bestArea = area;
bestSeed = {
x: Math.floor(sumX / area),
y: Math.floor(sumY / area),
};
}
}
}
return bestSeed;
}
function buildRegionOutlinePathFromBinary(
binary: Uint8Array,
cols: number,
rows: number,
rect: { x: number; y: number; w: number; h: number },
seedPx?: { x: number; y: number },
): SkPath {
let working = binary;
if (seedPx) {
const component = floodFillComponent(
binary,
cols,
rows,
seedPx.x,
seedPx.y,
);
if (!component) {
return Skia.Path.Make();
}
working = component;
}
const edges = collectBoundaryEdges(working, cols, rows);
const loops = chainBoundaryLoops(edges);
const filtered = filterOutlineLoops(loops, cols, rows, seedPx);
return loopsToSkPath(filtered, cols, rows, rect);
}
function resolveRegionOutlineSeedPx(
binary: Uint8Array,
cols: number,
rows: number,
normSeed?: { x: number; y: number },
): { x: number; y: number } | undefined {
if (normSeed) {
return {
x: Math.min(cols - 1, Math.max(0, Math.floor(normSeed.x * cols))),
y: Math.min(rows - 1, Math.max(0, Math.floor(normSeed.y * rows))),
};
}
return findLargestComponentSeed(binary, cols, rows) ?? undefined;
}
export function buildRegionOutlinePathForRegion(
regionId: number,
regions: SegmentRegion[],
maskData: RegionMaskData,
rect: { x: number; y: number; w: number; h: number },
normSeed?: { x: number; y: number },
): SkPath {
const binaries = extractRegionBinaries(regions, maskData);
const binary = binaries.get(regionId);
if (!binary) {
return Skia.Path.Make();
}
const { cols, rows } = maskData;
const seedPx = resolveRegionOutlineSeedPx(binary, cols, rows, normSeed);
return buildRegionOutlinePathFromBinary(binary, cols, rows, rect, seedPx);
}
function extractRegionBinaries(
regions: SegmentRegion[],
maskData: RegionMaskData,
): Map<number, Uint8Array> {
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
const size = cols * rows;
const binaries = new Map<number, Uint8Array>();
const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors;
const regionIdBySemantic = new Int32Array(semanticColors.length);
regionIdBySemantic.fill(-1);
const wallSubRegionIds = new Map<number, number>();
let baseboardRegionId: number | null = null;
for (const reg of regions) {
binaries.set(reg.id, new Uint8Array(size));
if (reg.thinStrip) {
baseboardRegionId = reg.id;
continue;
}
const wallMatch = /^wall-(\d+)$/.exec(reg.name);
if (wallMatch && wallSubLabels) {
wallSubRegionIds.set(Number(wallMatch[1]) - 1, reg.id);
continue;
}
const semanticIndex = semanticColors.findIndex(
entry => entry.name === reg.name,
);
if (semanticIndex >= 0) {
regionIdBySemantic[semanticIndex] = reg.id;
}
}
const semanticCount = semanticColors.length;
const wallIdx = semanticColors.findIndex(entry => entry.name === 'wall');
for (let i = 0; i < size; i++) {
if (baseboardRegionId != null && baseboardBinary[i] > 0) {
binaries.get(baseboardRegionId)![i] = 255;
continue;
}
if (wallSubLabels && wallIdx >= 0 && labels[i] === wallIdx) {
const subIdx = wallSubLabels[i];
if (subIdx !== 255) {
const regionId = wallSubRegionIds.get(subIdx);
if (regionId !== undefined) {
binaries.get(regionId)![i] = 255;
}
}
continue;
}
const semanticIndex = labels[i];
if (semanticIndex < semanticCount && regionIdBySemantic[semanticIndex] >= 0) {
binaries.get(regionIdBySemantic[semanticIndex])![i] = 255;
}
}
return binaries;
}
export function buildAllRegionOutlinePaths(
regions: SegmentRegion[],
maskData: RegionMaskData,
rect: { x: number; y: number; w: number; h: number },
): Map<number, SkPath> {
const { cols, rows } = maskData;
const binaries = extractRegionBinaries(regions, maskData);
const map = new Map<number, SkPath>();
for (const reg of regions) {
const binary = binaries.get(reg.id);
if (!binary) {
map.set(reg.id, Skia.Path.Make());
continue;
}
// No seed — build outlines from the full binary so all disconnected
// fragments (common after mask downsampling) get dashed outlines during
// the init flash loop. Per-region hold highlights still use a touch-seed
// via buildRegionOutlinePathForRegion for precise fragment isolation.
map.set(
reg.id,
buildRegionOutlinePathFromBinary(binary, cols, rows, rect),
);
}
return map;
}

View File

@ -51,7 +51,7 @@ export const PIPELINE_PRESETS: Record<PipelinePreset, Required<PipelineConfig>>
low: PIPELINE_LOW, low: PIPELINE_LOW,
}; };
export const DEFAULT_PIPELINE_CONFIG: Required<PipelineConfig> = PIPELINE_HIGH; export const DEFAULT_PIPELINE_CONFIG: Required<PipelineConfig> = PIPELINE_MEDIUM;
export function resolvePipelineConfig( export function resolvePipelineConfig(
preset?: PipelinePreset, preset?: PipelinePreset,

View File

@ -77,558 +77,10 @@ export function buildRegionOutlinePolygons(reg: SegmentRegion): NormPoint[][] {
return [bboxToPolygon(reg.bbox)]; return [bboxToPolygon(reg.bbox)];
} }
/** 分区虚线高亮:从与填充蒙版相同的像素网格提取外轮廓 */ import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion } from './maskOutlinePaths';
type GridPoint = { x: number; y: number };
type GridEdge = { // Re-export for backward compatibility
x0: number; export { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion };
y0: number;
x1: number;
y1: number;
};
function isMaskPixelOn(
binary: Uint8Array,
cols: number,
rows: number,
x: number,
y: number,
): boolean {
return (
x >= 0 && x < cols && y >= 0 && y < rows && binary[y * cols + x] > 0
);
}
function collectBoundaryEdges(
binary: Uint8Array,
cols: number,
rows: number,
): GridEdge[] {
const edges: GridEdge[] = [];
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
if (!binary[row + x]) {
continue;
}
if (!isMaskPixelOn(binary, cols, rows, x, y - 1)) {
edges.push({ x0: x, y0: y, x1: x + 1, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x, y + 1)) {
edges.push({ x0: x + 1, y0: y + 1, x1: x, y1: y + 1 });
}
if (!isMaskPixelOn(binary, cols, rows, x - 1, y)) {
edges.push({ x0: x, y0: y + 1, x1: x, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x + 1, y)) {
edges.push({ x0: x + 1, y0: y, x1: x + 1, y1: y + 1 });
}
}
}
return edges;
}
function chainBoundaryLoops(edges: GridEdge[]): GridPoint[][] {
const outgoing = new Map<string, GridEdge[]>();
const edgeKey = (edge: GridEdge) =>
`${edge.x0},${edge.y0}->${edge.x1},${edge.y1}`;
for (const edge of edges) {
const key = `${edge.x0},${edge.y0}`;
const list = outgoing.get(key);
if (list) {
list.push(edge);
} else {
outgoing.set(key, [edge]);
}
}
const used = new Set<string>();
const loops: GridPoint[][] = [];
for (const edge of edges) {
const startEdgeKey = edgeKey(edge);
if (used.has(startEdgeKey)) {
continue;
}
const loop: GridPoint[] = [{ x: edge.x0, y: edge.y0 }];
let current = edge;
used.add(startEdgeKey);
loop.push({ x: current.x1, y: current.y1 });
while (true) {
const endKey = `${current.x1},${current.y1}`;
const startKey = `${loop[0].x},${loop[0].y}`;
if (endKey === startKey && loop.length > 2) {
break;
}
const candidates = outgoing.get(endKey);
const next = candidates?.find(candidate => !used.has(edgeKey(candidate)));
if (!next) {
break;
}
current = next;
used.add(edgeKey(current));
loop.push({ x: current.x1, y: current.y1 });
if (loop.length > edges.length + 1) {
break;
}
}
if (loop.length >= 4) {
loops.push(loop);
}
}
return loops;
}
function simplifyOrthogonalLoop(points: GridPoint[]): GridPoint[] {
if (points.length <= 3) {
return points;
}
const out: GridPoint[] = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
const prev = out[out.length - 1];
const curr = points[i];
const next = points[i + 1];
const collinearX = prev.x === curr.x && curr.x === next.x;
const collinearY = prev.y === curr.y && curr.y === next.y;
if (!collinearX && !collinearY) {
out.push(curr);
}
}
out.push(points[points.length - 1]);
return out;
}
function perpendicularDistance2(
point: GridPoint,
lineStart: GridPoint,
lineEnd: GridPoint,
): number {
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - lineStart.x, point.y - lineStart.y);
}
const t =
((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) /
(dx * dx + dy * dy);
const projX = lineStart.x + t * dx;
const projY = lineStart.y + t * dy;
return Math.hypot(point.x - projX, point.y - projY);
}
/**
* Ramer-Douglas-Peucker simplification for outline loops.
* Reduces stair-step jagginess from grid boundary tracing so that dashed
* outlines render smoothly instead of zigzagging across every pixel edge.
* Epsilon is in grid-pixel units (1.0 = one mask pixel).
*/
function simplifyLoopRdp(points: GridPoint[], epsilon: number): GridPoint[] {
if (points.length <= 2) {
return points;
}
let maxDist = 0;
let index = 0;
const end = points.length - 1;
const lineStart = points[0]!;
const lineEnd = points[end]!;
for (let i = 1; i < end; i++) {
const p = points[i]!;
const dist = perpendicularDistance2(p, lineStart, lineEnd);
if (dist > maxDist) {
maxDist = dist;
index = i;
}
}
if (maxDist > epsilon) {
const left = simplifyLoopRdp(points.slice(0, index + 1), epsilon);
const right = simplifyLoopRdp(points.slice(index), epsilon);
return [...left.slice(0, -1), ...right];
}
return [lineStart, lineEnd];
}
function loopsToSkPath(
loops: GridPoint[][],
cols: number,
rows: number,
rect: { x: number; y: number; w: number; h: number },
): SkPath {
const path = Skia.Path.Make();
for (const rawLoop of loops) {
// Two-pass simplification: first remove collinear points, then RDP to
// smooth stair-step artifacts from grid boundary tracing.
const orthogonal = simplifyOrthogonalLoop(rawLoop);
const loop = simplifyLoopRdp(orthogonal, 1.0);
if (loop.length < 2) {
continue;
}
const [first, ...rest] = loop;
path.moveTo(
rect.x + (first.x / cols) * rect.w,
rect.y + (first.y / rows) * rect.h,
);
for (const point of rest) {
path.lineTo(
rect.x + (point.x / cols) * rect.w,
rect.y + (point.y / rows) * rect.h,
);
}
path.close();
}
return path;
}
function pointInIntegerLoop(px: number, py: number, loop: GridPoint[]): boolean {
let inside = false;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) {
const xi = loop[i].x;
const yi = loop[i].y;
const xj = loop[j].x;
const yj = loop[j].y;
const intersect =
yi > py !== yj > py &&
px < ((xj - xi) * (py - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
function loopBoundingArea(loop: GridPoint[]): number {
if (loop.length === 0) {
return 0;
}
let minX = loop[0].x;
let maxX = loop[0].x;
let minY = loop[0].y;
let maxY = loop[0].y;
for (const point of loop) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return (maxX - minX) * (maxY - minY);
}
function filterOutlineLoops(
loops: GridPoint[][],
cols: number,
rows: number,
seedPx?: { x: number; y: number },
): GridPoint[][] {
if (loops.length === 0) {
return loops;
}
const minLoopArea = Math.max(16, Math.floor(cols * rows * 0.00005));
const significant = loops.filter(
loop => loopBoundingArea(loop) >= minLoopArea,
);
const candidates = significant.length > 0 ? significant : loops;
if (seedPx) {
const sampleX = seedPx.x + 0.5;
const sampleY = seedPx.y + 0.5;
const containing = candidates.filter(loop =>
pointInIntegerLoop(sampleX, sampleY, loop),
);
if (containing.length > 0) {
containing.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
return containing;
}
}
candidates.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
// Keep loops ≥ 5% of the largest area to filter isolated noise speckles
// while retaining genuine disconnected fragments of the same region.
const minKeepArea = loopBoundingArea(candidates[0]!) * 0.05;
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
}
function floodFillComponent(
binary: Uint8Array,
cols: number,
rows: number,
seedX: number,
seedY: number,
): Uint8Array | null {
if (
seedX < 0 ||
seedY < 0 ||
seedX >= cols ||
seedY >= rows ||
!binary[seedY * cols + seedX]
) {
return null;
}
const out = new Uint8Array(cols * rows);
const stack = [seedY * cols + seedX];
out[stack[0]] = 255;
while (stack.length > 0) {
const index = stack.pop()!;
const x = index % cols;
const y = (index - x) / cols;
if (x > 0) {
const left = index - 1;
if (binary[left] && !out[left]) {
out[left] = 255;
stack.push(left);
}
}
if (x + 1 < cols) {
const right = index + 1;
if (binary[right] && !out[right]) {
out[right] = 255;
stack.push(right);
}
}
if (y > 0) {
const up = index - cols;
if (binary[up] && !out[up]) {
out[up] = 255;
stack.push(up);
}
}
if (y + 1 < rows) {
const down = index + cols;
if (binary[down] && !out[down]) {
out[down] = 255;
stack.push(down);
}
}
}
return out;
}
function findLargestComponentSeed(
binary: Uint8Array,
cols: number,
rows: number,
): { x: number; y: number } | null {
const visited = new Uint8Array(cols * rows);
let bestArea = 0;
let bestSeed: { x: number; y: number } | null = null;
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
const start = row + x;
if (!binary[start] || visited[start]) {
continue;
}
let area = 0;
let sumX = 0;
let sumY = 0;
const stack = [start];
visited[start] = 1;
while (stack.length > 0) {
const index = stack.pop()!;
area += 1;
const px = index % cols;
const py = (index - px) / cols;
sumX += px;
sumY += py;
if (px > 0) {
const left = index - 1;
if (binary[left] && !visited[left]) {
visited[left] = 1;
stack.push(left);
}
}
if (px + 1 < cols) {
const right = index + 1;
if (binary[right] && !visited[right]) {
visited[right] = 1;
stack.push(right);
}
}
if (py > 0) {
const up = index - cols;
if (binary[up] && !visited[up]) {
visited[up] = 1;
stack.push(up);
}
}
if (py + 1 < rows) {
const down = index + cols;
if (binary[down] && !visited[down]) {
visited[down] = 1;
stack.push(down);
}
}
}
if (area > bestArea) {
bestArea = area;
bestSeed = {
x: Math.floor(sumX / area),
y: Math.floor(sumY / area),
};
}
}
}
return bestSeed;
}
function buildRegionOutlinePathFromBinary(
binary: Uint8Array,
cols: number,
rows: number,
rect: { x: number; y: number; w: number; h: number },
seedPx?: { x: number; y: number },
): SkPath {
let working = binary;
if (seedPx) {
const component = floodFillComponent(
binary,
cols,
rows,
seedPx.x,
seedPx.y,
);
if (!component) {
return Skia.Path.Make();
}
working = component;
}
const edges = collectBoundaryEdges(working, cols, rows);
const loops = chainBoundaryLoops(edges);
const filtered = filterOutlineLoops(loops, cols, rows, seedPx);
return loopsToSkPath(filtered, cols, rows, rect);
}
function resolveRegionOutlineSeedPx(
binary: Uint8Array,
cols: number,
rows: number,
normSeed?: { x: number; y: number },
): { x: number; y: number } | undefined {
if (normSeed) {
return {
x: Math.min(cols - 1, Math.max(0, Math.floor(normSeed.x * cols))),
y: Math.min(rows - 1, Math.max(0, Math.floor(normSeed.y * rows))),
};
}
return findLargestComponentSeed(binary, cols, rows) ?? undefined;
}
export function buildRegionOutlinePathForRegion(
regionId: number,
regions: SegmentRegion[],
maskData: RegionMaskData,
rect: { x: number; y: number; w: number; h: number },
normSeed?: { x: number; y: number },
): SkPath {
const binaries = extractRegionBinaries(regions, maskData);
const binary = binaries.get(regionId);
if (!binary) {
return Skia.Path.Make();
}
const { cols, rows } = maskData;
const seedPx = resolveRegionOutlineSeedPx(binary, cols, rows, normSeed);
return buildRegionOutlinePathFromBinary(binary, cols, rows, rect, seedPx);
}
function extractRegionBinaries(
regions: SegmentRegion[],
maskData: RegionMaskData,
): Map<number, Uint8Array> {
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
const size = cols * rows;
const binaries = new Map<number, Uint8Array>();
const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors;
const regionIdBySemantic = new Int32Array(semanticColors.length);
regionIdBySemantic.fill(-1);
const wallSubRegionIds = new Map<number, number>();
let baseboardRegionId: number | null = null;
for (const reg of regions) {
binaries.set(reg.id, new Uint8Array(size));
if (reg.thinStrip) {
baseboardRegionId = reg.id;
continue;
}
const wallMatch = /^wall-(\d+)$/.exec(reg.name);
if (wallMatch && wallSubLabels) {
wallSubRegionIds.set(Number(wallMatch[1]) - 1, reg.id);
continue;
}
const semanticIndex = semanticColors.findIndex(
entry => entry.name === reg.name,
);
if (semanticIndex >= 0) {
regionIdBySemantic[semanticIndex] = reg.id;
}
}
const semanticCount = semanticColors.length;
const wallIdx = semanticColors.findIndex(entry => entry.name === 'wall');
for (let i = 0; i < size; i++) {
if (baseboardRegionId != null && baseboardBinary[i] > 0) {
binaries.get(baseboardRegionId)![i] = 255;
continue;
}
if (wallSubLabels && wallIdx >= 0 && labels[i] === wallIdx) {
const subIdx = wallSubLabels[i];
if (subIdx !== 255) {
const regionId = wallSubRegionIds.get(subIdx);
if (regionId !== undefined) {
binaries.get(regionId)![i] = 255;
}
}
continue;
}
const semanticIndex = labels[i];
if (semanticIndex < semanticCount && regionIdBySemantic[semanticIndex] >= 0) {
binaries.get(regionIdBySemantic[semanticIndex])![i] = 255;
}
}
return binaries;
}
export function buildAllRegionOutlinePaths(
regions: SegmentRegion[],
maskData: RegionMaskData,
rect: { x: number; y: number; w: number; h: number },
): Map<number, SkPath> {
const { cols, rows } = maskData;
const binaries = extractRegionBinaries(regions, maskData);
const map = new Map<number, SkPath>();
for (const reg of regions) {
const binary = binaries.get(reg.id);
if (!binary) {
map.set(reg.id, Skia.Path.Make());
continue;
}
// No seed — build outlines from the full binary so all disconnected
// fragments (common after mask downsampling) get dashed outlines during
// the init flash loop. Per-region hold highlights still use a touch-seed
// via buildRegionOutlinePathForRegion for precise fragment isolation.
map.set(
reg.id,
buildRegionOutlinePathFromBinary(binary, cols, rows, rect),
);
}
return map;
}
function isBaseboardEntry(entry: PaletteEntry): boolean { function isBaseboardEntry(entry: PaletteEntry): boolean {
return entry.name === BASEBOARD_SEMANTIC_NAME; return entry.name === BASEBOARD_SEMANTIC_NAME;