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:
parent
2dd744de0f
commit
843544b9e8
2
dist/components/MaskSegmentCanvas.d.ts.map
vendored
2
dist/components/MaskSegmentCanvas.d.ts.map
vendored
@ -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"}
|
||||||
289
dist/components/MaskSegmentCanvas.js
vendored
289
dist/components/MaskSegmentCanvas.js
vendored
@ -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;
|
|
||||||
}
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
* 组件主体
|
* 组件主体
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|||||||
2
dist/components/MaskSegmentCanvas.js.map
vendored
2
dist/components/MaskSegmentCanvas.js.map
vendored
File diff suppressed because one or more lines are too long
81
dist/utils/canvasGeometry.d.ts
vendored
Normal file
81
dist/utils/canvasGeometry.d.ts
vendored
Normal 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
1
dist/utils/canvasGeometry.d.ts.map
vendored
Normal 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
306
dist/utils/canvasGeometry.js
vendored
Normal 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
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
18
dist/utils/maskOutlinePaths.d.ts
vendored
Normal 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
1
dist/utils/maskOutlinePaths.d.ts.map
vendored
Normal 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
419
dist/utils/maskOutlinePaths.js
vendored
Normal 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
1
dist/utils/maskOutlinePaths.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
dist/utils/maskSegmentRuntime.d.ts.map
vendored
2
dist/utils/maskSegmentRuntime.d.ts.map
vendored
@ -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"}
|
||||||
2
dist/utils/maskSegmentRuntime.js
vendored
2
dist/utils/maskSegmentRuntime.js
vendored
@ -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 };
|
||||||
|
|||||||
2
dist/utils/maskSegmentRuntime.js.map
vendored
2
dist/utils/maskSegmentRuntime.js.map
vendored
File diff suppressed because one or more lines are too long
17
dist/utils/maskSegmentation.d.ts
vendored
17
dist/utils/maskSegmentation.d.ts
vendored
@ -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;
|
||||||
|
|||||||
2
dist/utils/maskSegmentation.d.ts.map
vendored
2
dist/utils/maskSegmentation.d.ts.map
vendored
@ -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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"}
|
||||||
419
dist/utils/maskSegmentation.js
vendored
419
dist/utils/maskSegmentation.js
vendored
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
2
dist/utils/maskSegmentation.js.map
vendored
2
dist/utils/maskSegmentation.js.map
vendored
File diff suppressed because one or more lines are too long
@ -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
474
src/utils/canvasGeometry.ts
Normal 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;
|
||||||
|
}
|
||||||
556
src/utils/maskOutlinePaths.ts
Normal file
556
src/utils/maskOutlinePaths.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user