Merge branch 'zh' into 'main'

Zh

See merge request prism/lable/labelimage!14
This commit is contained in:
刘耀勇
2026-04-17 19:30:57 +08:00
8 changed files with 2019 additions and 885 deletions

View File

@@ -1511,6 +1511,7 @@ const LabelPage = ({
const restoreDrawShortcutSnapshot = useCallback(() => {
useKeyboardStore.getState().setDrawAction("none")
useKeyboardStore.getState().setSelectedObjectEditTarget(null)
const snapshot = drawShortcutSnapshotRef.current
if (!snapshot) return
@@ -1562,6 +1563,7 @@ const LabelPage = ({
setActiveOperation(selectedContext.operationId)
usePaperStore.getState().setContinuePolygonTarget(null)
useKeyboardStore.getState().setSelectedObjectEditTarget(selectedContext)
setEditMode(true)
setDrawOption("default")
useKeyboardStore.getState().setDrawAction(mode)
@@ -1692,29 +1694,6 @@ const LabelPage = ({
[]
)
const removeObjectFromGroupMap = useCallback(
(imageId: string, id: number) => {
const nextPathGroupMap = safeClone(
useRightToolsStore.getState().pathGroupMap
)
const imageGroupMap = nextPathGroupMap.get(imageId)
if (!imageGroupMap) return
for (let [groupId, pathIds] of imageGroupMap.entries()) {
const nextPathIds = pathIds.filter((pathId) => pathId !== id)
if (nextPathIds.length > 1) {
imageGroupMap.set(groupId, nextPathIds)
} else {
imageGroupMap.delete(groupId)
}
}
if (!imageGroupMap.size) nextPathGroupMap.delete(imageId)
useRightToolsStore.getState().setPathGroupMap(nextPathGroupMap)
},
[]
)
const handleContinueSelectedPolygon = useCallback(() => {
const selectedContext = resolveSelectedObjectContext()
if (!selectedContext) {
@@ -1738,6 +1717,7 @@ const LabelPage = ({
setPressA(false)
setActiveOperation(selectedContext.operationId)
useKeyboardStore.getState().setDrawAction("none")
useKeyboardStore.getState().setSelectedObjectEditTarget(null)
usePaperStore.getState().setContinuePolygonTarget({
imageId: selectedContext.imageId,
operationId: selectedContext.operationId,
@@ -1758,70 +1738,6 @@ const LabelPage = ({
setPressA,
])
const hasShapeSegments = useCallback((shape: any) => {
if (!shape) return false
if (shape instanceof paper.CompoundPath) {
return (shape.children as paper.Path[]).some(
(child) => child.segments?.length
)
}
if (shape instanceof paper.Path) {
return !!shape.segments?.length
}
return false
}, [])
const createWorkingPolygonShape = useCallback((items: any[]) => {
const compound = items.find((item) => item instanceof paper.CompoundPath)
if (compound) {
return (compound as paper.CompoundPath).clone({ insert: false }) as
| paper.Path
| paper.CompoundPath
}
const paths = items.filter(
(item) => item instanceof paper.Path
) as paper.Path[]
if (!paths.length) return null
if (paths.length === 1) {
return paths[0].clone({ insert: false }) as
| paper.Path
| paper.CompoundPath
}
const merged = new paper.CompoundPath({ insert: false })
paths.forEach((path) => {
merged.addChild(path.clone({ insert: false }))
})
return merged as paper.Path | paper.CompoundPath
}, [])
const collectPolygonPoints = useCallback(
(shape: paper.Path | paper.CompoundPath) => {
const polygonPoints: any[] = []
const polygonHollowPoints: any[] = []
const collectPath = (path: paper.Path) => {
if (!path.segments?.length) return
const pathData = JSON.parse(path.exportJSON({ precision: 20 }))
if (path.clockwise) {
polygonPoints.push(pathData[1]?.segments)
} else {
polygonHollowPoints.push(pathData[1]?.segments)
}
}
if (shape instanceof paper.CompoundPath) {
;(shape.children as paper.Path[]).forEach((path) => collectPath(path))
} else {
collectPath(shape)
}
return { polygonPoints, polygonHollowPoints }
},
[]
)
const applySelectedPolygonOverlap = useCallback(
(mode: "subtract" | "unite") => {
const selectedContext = resolveSelectedObjectContext()
@@ -1842,138 +1758,9 @@ const LabelPage = ({
})
return
}
const group = usePaperStore.getState().group
if (!group) return
const targetItems = group
.getItems({ data: { id: selectedContext.objectId } })
.filter((item) => item.data?.type === "polygon")
if (!targetItems.length) return
let workingShape = createWorkingPolygonShape(targetItems)
if (!workingShape || !hasShapeSegments(workingShape)) return
const otherPolygonPaths: paper.Path[] = []
group.children.forEach((item: any) => {
if (
!item.visible ||
!item.data?.id ||
item.data.id === selectedContext.objectId ||
item.data.type !== "polygon"
)
return
if (item instanceof paper.Path) {
if (item.segments?.length) otherPolygonPaths.push(item)
} else if (item instanceof paper.CompoundPath) {
;(item.children as paper.Path[]).forEach((child) => {
if (child.segments?.length) otherPolygonPaths.push(child)
})
}
})
let changed = false
for (const eachPath of otherPolygonPaths) {
if (!workingShape || !hasShapeSegments(workingShape)) break
const eachPathClone = eachPath.clone({ insert: false }) as paper.Path
const intersectionPath = (workingShape as any).intersect(
eachPathClone,
{
insert: false,
}
) as paper.Path | paper.CompoundPath
const hasIntersection = hasShapeSegments(intersectionPath)
intersectionPath?.remove()
if (!hasIntersection) {
eachPathClone.remove()
continue
}
const nextShape: paper.Path | paper.CompoundPath =
mode === "subtract"
? ((workingShape as any).subtract(eachPathClone, {
insert: false,
}) as paper.Path | paper.CompoundPath)
: ((workingShape as any).unite(eachPathClone, {
insert: false,
}) as paper.Path | paper.CompoundPath)
eachPathClone.remove()
workingShape?.remove()
workingShape = nextShape
changed = true
}
if (!changed) {
workingShape?.remove()
return
}
if (!workingShape) return
const raster = usePaperStore.getState().rasterPath
if (raster && hasShapeSegments(workingShape)) {
const clippedShape = (workingShape as any).intersect(raster, {
insert: false,
trace: false,
}) as paper.Path | paper.CompoundPath
workingShape?.remove()
workingShape = clippedShape
}
if (!workingShape) return
const { polygonPoints, polygonHollowPoints } =
collectPolygonPoints(workingShape)
workingShape?.remove()
const nextLabel = safeClone(useLabelStore.getState().label)
const imageLabel = nextLabel.get(selectedContext.imageId)
if (!imageLabel) return
const operationPaths = safeClone(
imageLabel.get(selectedContext.operationId) || []
)
const selectedIndex = operationPaths.findIndex(
(item) => item[0] === selectedContext.objectId
)
if (selectedIndex === -1) return
const currentTuple = operationPaths[selectedIndex]
if (polygonPoints.length) {
operationPaths[selectedIndex] = [
selectedContext.objectId,
polygonPoints,
safeClone(currentTuple?.[2] || {}),
polygonHollowPoints,
]
imageLabel.set(selectedContext.operationId, operationPaths)
} else {
imageLabel.set(
selectedContext.operationId,
operationPaths.filter((item) => item[0] !== selectedContext.objectId)
)
removeObjectFromGroupMap(
selectedContext.imageId,
selectedContext.objectId
)
}
useLabelStore.getState().setLabel(nextLabel)
useLabelStore.getState().pushStateStack(nextLabel)
rerenderActiveImageByLabel(
selectedContext.imageId,
imageLabel,
polygonPoints.length ? [selectedContext.objectId] : []
)
usePaperStore.getState().applySelectedPolygonBooleanOperation(mode)
},
[
collectPolygonPoints,
createWorkingPolygonShape,
hasShapeSegments,
removeObjectFromGroupMap,
rerenderActiveImageByLabel,
resolveSelectedObjectContext,
]
[resolveSelectedObjectContext]
)
const applySelectedBrushIntersectUnite = useCallback(() => {
@@ -2583,6 +2370,7 @@ const LabelPage = ({
useEffect(() => {
useKeyboardStore.getState().setCopyDataIds([])
useKeyboardStore.getState().setDrawAction("none")
useKeyboardStore.getState().setSelectedObjectEditTarget(null)
usePaperStore.getState().setContinuePolygonTarget(null)
drawShortcutSnapshotRef.current = null
}, [activeImage])
@@ -2638,7 +2426,7 @@ const LabelPage = ({
useEffect(() => {
if (!isView) {
const delay = 10 * 60000
const delay = 5 * 60000
// 编辑模式下修改labelData时启动或更新定时器
useTimerStore.getState().startTimer(() => {
useTopToolsStore.getState().setIsView(true)

View File

@@ -123,6 +123,7 @@ export const getAuxiliaryAnnotation = (data: {
background_points?: Array<[number, number]>
boxes?: Array<[number, number, number, number]>
}
contour_approximation?: number
}) => {
return httpFetch<{
contours: Array<Array<[[number, number]]>>

View File

@@ -518,7 +518,7 @@ const PaperContainer = (
token: number
imageName: string
} | null>(null)
const renderModePanRef = useRef({
const middleMousePanRef = useRef({
active: false,
lastX: 0,
lastY: 0,
@@ -997,6 +997,7 @@ const PaperContainer = (
: {}),
...(normalizedBoxes.length ? { boxes: normalizedBoxes } : {}),
},
contour_approximation: 0,
})
const { contours = [], hollows = [] } = data || {}
const outerPaths: any[] = []
@@ -1050,6 +1051,8 @@ const PaperContainer = (
})
} catch (error) {
console.log(error)
} finally {
notifications.hide("sam2")
}
} catch (error: any) {
console.log(error)
@@ -1429,26 +1432,29 @@ const PaperContainer = (
})
.filter((path): path is paper.Path => !!path) || []
addCompoundPath(
renderCompoundPath,
[...outerPaths, ...innerPaths],
{
strokeColor: strokeColor,
strokeWidth: 2,
// fillColor: fillColor,
fillColor: blankColor,
visible: !paperPathStatus[renderImageId + item[0]]?.["HIDE"],
},
{
type: "polygon",
id: item[0],
imageId: renderImageId,
operationId: operationId,
fillColor: fillColor,
blankColor: blankColor,
selected: selectedIds.includes(item[0]),
}
)
if (!(outerPaths.length === 1 && !innerPaths.length)) {
addCompoundPath(
renderCompoundPath,
[...outerPaths, ...innerPaths],
{
strokeColor: strokeColor,
strokeWidth: 2,
// fillColor: fillColor,
fillColor: blankColor,
visible:
!paperPathStatus[renderImageId + item[0]]?.["HIDE"],
},
{
type: "polygon",
id: item[0],
imageId: renderImageId,
operationId: operationId,
fillColor: fillColor,
blankColor: blankColor,
selected: selectedIds.includes(item[0]),
}
)
}
// renderCompoundPath(usePaperStore.getState().group!, { children });
// item[1].forEach((child) => {
@@ -2337,42 +2343,72 @@ const PaperContainer = (
syncRenderModeVisuals,
])
const stopRenderModePan = useCallback(() => {
renderModePanRef.current.active = false
const hidePaperContextMenu = useCallback(() => {
useOtherToolsStore.getState().setShowContextMenu({
showContextMenu: false,
position: { top: 0, left: 0 },
imageId: "",
operationId: "",
id: 0,
})
}, [])
const startMiddleMousePan = useCallback(
(clientX: number, clientY: number) => {
middleMousePanRef.current = {
active: true,
lastX: clientX,
lastY: clientY,
}
},
[]
)
const stopMiddleMousePan = useCallback(() => {
middleMousePanRef.current.active = false
}, [])
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleMouseDownCapture = (event: MouseEvent) => {
if (event.button !== 1) return
const target = event.target as HTMLElement | null
if (target?.closest('[data-label-context-menu="true"]')) return
event.preventDefault()
event.stopPropagation()
hidePaperContextMenu()
startMiddleMousePan(event.clientX, event.clientY)
}
container.addEventListener("mousedown", handleMouseDownCapture, true)
return () => {
container.removeEventListener("mousedown", handleMouseDownCapture, true)
stopMiddleMousePan()
}
}, [hidePaperContextMenu, startMiddleMousePan, stopMiddleMousePan])
const handleRenderModeOverlayMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
useOtherToolsStore.getState().setShowContextMenu({
showContextMenu: false,
position: { top: 0, left: 0 },
imageId: "",
operationId: "",
id: 0,
})
hidePaperContextMenu()
if (event.button !== 1) return
renderModePanRef.current = {
active: true,
lastX: event.clientX,
lastY: event.clientY,
}
startMiddleMousePan(event.clientX, event.clientY)
},
[]
[hidePaperContextMenu, startMiddleMousePan]
)
useEffect(() => {
if (!renderMode) {
stopRenderModePan()
return
}
const handleMouseMove = (event: MouseEvent) => {
const currentPan = renderModePanRef.current
const currentPan = middleMousePanRef.current
if (!currentPan.active) return
event.preventDefault()
@@ -2384,26 +2420,29 @@ const PaperContainer = (
if (!deltaX && !deltaY) return
group.position = group.position.add(new paper.Point(deltaX, deltaY))
renderModePanRef.current.lastX = event.clientX
renderModePanRef.current.lastY = event.clientY
middleMousePanRef.current.lastX = event.clientX
middleMousePanRef.current.lastY = event.clientY
}
const handleMouseUp = (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault()
}
stopRenderModePan()
stopMiddleMousePan()
}
window.addEventListener("mousemove", handleMouseMove, { passive: false })
window.addEventListener("mouseup", handleMouseUp, { passive: false })
window.addEventListener("blur", stopMiddleMousePan)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
stopRenderModePan()
window.removeEventListener("blur", stopMiddleMousePan)
stopMiddleMousePan()
}
}, [renderMode, stopRenderModePan])
}, [stopMiddleMousePan])
const getTargetGroupPosition = useCallback(
({

View File

@@ -1,5 +1,12 @@
import { create } from "zustand"
export interface SelectedObjectEditTarget {
imageId: string
operationId: string
objectId: number
objectType: string
}
interface KeyboardStore {
ctrl: boolean
setCtrl: (v: boolean) => void
@@ -7,6 +14,8 @@ interface KeyboardStore {
setShift: (v: boolean) => void
drawAction: "none" | "add" | "subtract"
setDrawAction: (mode: "none" | "add" | "subtract") => void
selectedObjectEditTarget: SelectedObjectEditTarget | null
setSelectedObjectEditTarget: (target: SelectedObjectEditTarget | null) => void
copyDataIds: number[] // ctrl + c 复制数据
setCopyDataIds: (ids: number[]) => void
}
@@ -15,9 +24,12 @@ export const useKeyboardStore = create<KeyboardStore>((set) => ({
ctrl: false,
shift: false,
drawAction: "none",
selectedObjectEditTarget: null,
copyDataIds: [],
setCtrl: (v) => set((state) => ({ ...state, ctrl: v })),
setShift: (v) => set((state) => ({ ...state, shift: v })),
setDrawAction: (mode) => set((state) => ({ ...state, drawAction: mode })),
setSelectedObjectEditTarget: (target) =>
set((state) => ({ ...state, selectedObjectEditTarget: target })),
setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })),
}))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,806 @@
import type { MultiPolygon, Pair, Polygon, Ring } from "polygon-clipping"
import * as polygonClipping from "polygon-clipping"
export type GeometryPoint = Pair
export type NormalizedRing = Ring
export type NormalizedPolygon = Polygon
export type NormalizedMultiPolygon = MultiPolygon
export type BooleanAdapterMode = "unite" | "subtract" | "intersect" | "xor"
export interface BooleanAdapterInput {
subject: NormalizedMultiPolygon
operands?: NormalizedMultiPolygon[]
mode: BooleanAdapterMode
rasterClip?: NormalizedMultiPolygon | null
}
export interface BooleanAdapterMeta {
polygonCount: number
outerRingCount: number
holeCount: number
isEmpty: boolean
}
export interface BooleanAdapterResult {
geometry: NormalizedMultiPolygon
meta: BooleanAdapterMeta
}
export type SerializableContour = Array<
| GeometryPoint
| { x: number; y: number }
| { point?: { x: number; y: number } }
>
export interface PaperLikePathShape {
segments?: SerializableContour
data?: unknown
}
export interface PaperLikeCompoundPathShape {
children?: PaperLikePathShape[]
data?: unknown
}
export type LabelTupleLike = [
number,
SerializableContour[],
unknown,
SerializableContour[],
]
export const EMPTY_MULTI_POLYGON: NormalizedMultiPolygon = []
const POINT_EPSILON = 0.000001
const MIN_RING_AREA = 0.0001
const clonePoint = ([x, y]: GeometryPoint): GeometryPoint => [x, y]
const isSameNumber = (a: number, b: number) => Math.abs(a - b) <= POINT_EPSILON
const isSamePoint = (a: GeometryPoint, b: GeometryPoint) =>
isSameNumber(a[0], b[0]) && isSameNumber(a[1], b[1])
const getGeometryPoint = (value: any): GeometryPoint | null => {
if (!value) return null
if (
Array.isArray(value) &&
value.length >= 2 &&
typeof value[0] === "number" &&
typeof value[1] === "number"
) {
return [value[0], value[1]]
}
if (typeof value.x === "number" && typeof value.y === "number") {
return [value.x, value.y]
}
if (
value.point &&
typeof value.point.x === "number" &&
typeof value.point.y === "number"
) {
return [value.point.x, value.point.y]
}
return null
}
const normalizeOpenRing = (ring: NormalizedRing | null | undefined) => {
if (!ring?.length) return null
const deduped = ring.reduce<NormalizedRing>((result, point) => {
const nextPoint = clonePoint(point)
const lastPoint = result[result.length - 1]
if (lastPoint && isSamePoint(lastPoint, nextPoint)) return result
result.push(nextPoint)
return result
}, [])
while (
deduped.length > 1 &&
isSamePoint(deduped[0], deduped[deduped.length - 1])
) {
deduped.pop()
}
return deduped.length >= 3 ? deduped : null
}
const closeRing = (ring: NormalizedRing) => [...ring, clonePoint(ring[0])]
const getRingSignedArea = (ring: NormalizedRing) => {
let area = 0
for (let index = 0; index < ring.length - 1; index++) {
const [x1, y1] = ring[index]
const [x2, y2] = ring[index + 1]
area += x1 * y2 - x2 * y1
}
return area / 2
}
const rotateRingToStableStart = (ring: NormalizedRing) => {
if (!ring.length) return ring
let startIndex = 0
for (let index = 1; index < ring.length; index += 1) {
const currentPoint = ring[index]
const startPoint = ring[startIndex]
if (
currentPoint[0] < startPoint[0] - POINT_EPSILON ||
(isSameNumber(currentPoint[0], startPoint[0]) &&
currentPoint[1] < startPoint[1] - POINT_EPSILON)
) {
startIndex = index
}
}
return [...ring.slice(startIndex), ...ring.slice(0, startIndex)]
}
const normalizeRing = (
ring: NormalizedRing | null | undefined,
options: {
clockwise?: boolean
} = {}
) => {
const openRing = normalizeOpenRing(ring)
if (!openRing) return null
let nextRing = openRing
let signedArea = getRingSignedArea(closeRing(nextRing))
if (Math.abs(signedArea) <= MIN_RING_AREA) return null
if (typeof options.clockwise === "boolean") {
const shouldReverse = options.clockwise ? signedArea < 0 : signedArea > 0
if (shouldReverse) {
nextRing = [...nextRing].reverse()
signedArea = getRingSignedArea(closeRing(nextRing))
}
}
const stableRing = rotateRingToStableStart(nextRing)
const closedRing = closeRing(stableRing)
// 闭环后至少需要 4 个点,且面积不能退化到近似 0。
return closedRing.length >= 4 &&
Math.abs(getRingSignedArea(closedRing)) > MIN_RING_AREA
? closedRing
: null
}
export const contourToRing = (
contour: SerializableContour | null | undefined
): NormalizedRing | null => {
if (!contour?.length) return null
return normalizeRing(
contour
.map((point) => getGeometryPoint(point))
.filter((point): point is GeometryPoint => !!point)
)
}
const normalizePolygon = (polygon: NormalizedPolygon | null | undefined) => {
if (!polygon?.length) return null
const [outerRing, ...holeRings] = polygon
const nextOuterRing = normalizeRing(outerRing, { clockwise: true })
if (!nextOuterRing) return null
const rings = [
nextOuterRing,
...holeRings
.map((ring) => normalizeRing(ring, { clockwise: false }))
.filter((ring): ring is NormalizedRing => !!ring),
]
if (!rings.length) return null
return rings
}
export const normalizeMultiPolygon = (
geometry: NormalizedMultiPolygon | null | undefined
): NormalizedMultiPolygon => {
if (!geometry?.length) return []
return geometry
.map((polygon) => normalizePolygon(polygon))
.filter((polygon): polygon is NormalizedPolygon => !!polygon)
}
const getRingSamplePoint = (ring: NormalizedRing): GeometryPoint => {
const withoutClosure = ring.slice(0, -1)
if (!withoutClosure.length) {
return clonePoint(ring[0])
}
const bounds = getRingBounds(ring)
const candidates: GeometryPoint[] = [
[(bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2],
]
const [sumX, sumY] = withoutClosure.reduce(
(result, [x, y]) => [result[0] + x, result[1] + y],
[0, 0]
)
candidates.push([sumX / withoutClosure.length, sumY / withoutClosure.length])
let leftMostIndex = 0
for (let index = 1; index < withoutClosure.length; index += 1) {
const currentPoint = withoutClosure[index]
const leftMostPoint = withoutClosure[leftMostIndex]
if (
currentPoint[0] < leftMostPoint[0] - POINT_EPSILON ||
(isSameNumber(currentPoint[0], leftMostPoint[0]) &&
currentPoint[1] < leftMostPoint[1] - POINT_EPSILON)
) {
leftMostIndex = index
}
}
const previousPoint =
withoutClosure[
(leftMostIndex - 1 + withoutClosure.length) % withoutClosure.length
]
const currentPoint = withoutClosure[leftMostIndex]
const nextPoint = withoutClosure[(leftMostIndex + 1) % withoutClosure.length]
candidates.push([
(previousPoint[0] + currentPoint[0] + nextPoint[0]) / 3,
(previousPoint[1] + currentPoint[1] + nextPoint[1]) / 3,
])
for (const candidate of candidates) {
if (isPointInsideRing(candidate, ring)) {
return candidate
}
}
return clonePoint(currentPoint)
}
const isPointInsideRing = (point: GeometryPoint, ring: NormalizedRing) => {
let inside = false
for (
let current = 0, previous = ring.length - 1;
current < ring.length;
previous = current++
) {
const [currentX, currentY] = ring[current]
const [previousX, previousY] = ring[previous]
const intersects =
currentY > point[1] !== previousY > point[1] &&
point[0] <
((previousX - currentX) * (point[1] - currentY)) /
(previousY - currentY || Number.EPSILON) +
currentX
if (intersects) {
inside = !inside
}
}
return inside
}
type RingEntry = {
ring: NormalizedRing
sourceIndex: number
signedArea: number
absArea: number
samplePoint: GeometryPoint
}
type RingBounds = {
minX: number
minY: number
maxX: number
maxY: number
}
const createRingEntries = (rings: Array<NormalizedRing | null | undefined>) => {
return rings
.map((ring, sourceIndex) => {
if (!ring) return null
const signedArea = getRingSignedArea(ring)
return {
ring,
sourceIndex,
signedArea,
absArea: Math.abs(signedArea),
samplePoint: getRingSamplePoint(ring),
}
})
.filter((entry): entry is RingEntry => !!entry)
}
const getRingBounds = (ring: NormalizedRing): RingBounds => {
return ring.reduce<RingBounds>(
(result, [x, y]) => ({
minX: Math.min(result.minX, x),
minY: Math.min(result.minY, y),
maxX: Math.max(result.maxX, x),
maxY: Math.max(result.maxY, y),
}),
{
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
}
)
}
const compareRingEntriesForSerialization = (
left: RingEntry,
right: RingEntry
) => {
if (right.absArea !== left.absArea) {
return right.absArea - left.absArea
}
const leftBounds = getRingBounds(left.ring)
const rightBounds = getRingBounds(right.ring)
if (leftBounds.minX !== rightBounds.minX) {
return leftBounds.minX - rightBounds.minX
}
if (leftBounds.minY !== rightBounds.minY) {
return leftBounds.minY - rightBounds.minY
}
if (leftBounds.maxX !== rightBounds.maxX) {
return leftBounds.maxX - rightBounds.maxX
}
if (leftBounds.maxY !== rightBounds.maxY) {
return leftBounds.maxY - rightBounds.maxY
}
return left.sourceIndex - right.sourceIndex
}
const getRingBoundsArea = (bounds: RingBounds) => {
const width = Math.max(0, bounds.maxX - bounds.minX)
const height = Math.max(0, bounds.maxY - bounds.minY)
return width * height
}
const getRingBoundsIntersectionArea = (
leftBounds: RingBounds,
rightBounds: RingBounds
) => {
const overlapWidth =
Math.min(leftBounds.maxX, rightBounds.maxX) -
Math.max(leftBounds.minX, rightBounds.minX)
const overlapHeight =
Math.min(leftBounds.maxY, rightBounds.maxY) -
Math.max(leftBounds.minY, rightBounds.minY)
if (overlapWidth <= 0 || overlapHeight <= 0) {
return 0
}
return overlapWidth * overlapHeight
}
const getBoundsCenterPoint = (bounds: RingBounds): GeometryPoint => [
(bounds.minX + bounds.maxX) / 2,
(bounds.minY + bounds.maxY) / 2,
]
const getPointDistance = (left: GeometryPoint, right: GeometryPoint) =>
Math.hypot(left[0] - right[0], left[1] - right[1])
const isSameRingOrder = (
left: GeometryPoint[],
right: GeometryPoint[]
): boolean => {
if (left.length !== right.length || !left.length) return false
for (let offset = 0; offset < right.length; offset += 1) {
let matched = true
for (let index = 0; index < left.length; index += 1) {
if (!isSamePoint(left[index], right[(index + offset) % right.length])) {
matched = false
break
}
}
if (matched) return true
}
return false
}
const isSameRing = (left: NormalizedRing, right: NormalizedRing) => {
const leftOpenRing = normalizeOpenRing(left)
const rightOpenRing = normalizeOpenRing(right)
if (!leftOpenRing || !rightOpenRing) return false
return (
isSameRingOrder(leftOpenRing, rightOpenRing) ||
isSameRingOrder(leftOpenRing, [...rightOpenRing].reverse())
)
}
const getRingMatchScore = (reference: RingEntry, candidate: RingEntry) => {
if (isSameRing(reference.ring, candidate.ring)) {
return Number.MAX_SAFE_INTEGER
}
const referenceBounds = getRingBounds(reference.ring)
const candidateBounds = getRingBounds(candidate.ring)
const overlapArea = getRingBoundsIntersectionArea(
referenceBounds,
candidateBounds
)
const smallerBoundsArea = Math.max(
Math.min(
getRingBoundsArea(referenceBounds),
getRingBoundsArea(candidateBounds)
),
POINT_EPSILON
)
const overlapRatio = overlapArea / smallerBoundsArea
const areaRatio =
Math.min(reference.absArea, candidate.absArea) /
Math.max(reference.absArea, candidate.absArea, POINT_EPSILON)
const centerDistance = getPointDistance(
getBoundsCenterPoint(referenceBounds),
getBoundsCenterPoint(candidateBounds)
)
const referenceInsideCandidate = isPointInsideRing(
reference.samplePoint,
candidate.ring
)
const candidateInsideReference = isPointInsideRing(
candidate.samplePoint,
reference.ring
)
return (
(referenceInsideCandidate ? 1000000 : 0) +
(candidateInsideReference ? 500000 : 0) +
overlapRatio * 1000 +
areaRatio * 100 -
centerDistance
)
}
const attachHolesToOuters = (
outerEntries: RingEntry[],
holeEntries: RingEntry[]
): NormalizedMultiPolygon => {
const polygons = outerEntries.map((outerEntry) => ({
outer: outerEntry,
holes: [] as RingEntry[],
}))
holeEntries.forEach((holeEntry) => {
const owner = polygons
.filter((polygon) =>
isPointInsideRing(holeEntry.samplePoint, polygon.outer.ring)
)
.sort((left, right) => left.outer.absArea - right.outer.absArea)[0]
if (owner) {
owner.holes.push(holeEntry)
}
})
return polygons.map(({ outer, holes }) => [
outer.ring,
...holes
.sort((left, right) => left.sourceIndex - right.sourceIndex)
.map((hole) => hole.ring),
])
}
export const contoursToMultiPolygon = ({
outerContours = [],
holeContours = [],
}: {
outerContours?: Array<SerializableContour | null | undefined>
holeContours?: Array<SerializableContour | null | undefined>
}): NormalizedMultiPolygon => {
const outerEntries = createRingEntries(
outerContours.map((contour) => contourToRing(contour))
).sort((left, right) => left.sourceIndex - right.sourceIndex)
const holeEntries = createRingEntries(
holeContours.map((contour) => contourToRing(contour))
)
return normalizeMultiPolygon(attachHolesToOuters(outerEntries, holeEntries))
}
export const labelTupleToMultiPolygon = (
tuple:
| LabelTupleLike
| {
1: SerializableContour[]
3: SerializableContour[]
}
| null
| undefined
) => {
if (!tuple) return EMPTY_MULTI_POLYGON
return contoursToMultiPolygon({
outerContours: tuple[1] as SerializableContour[],
holeContours: tuple[3] as SerializableContour[],
})
}
export const paperShapeToMultiPolygon = (
shape: PaperLikePathShape | PaperLikeCompoundPathShape | null | undefined
) => {
if (!shape) return EMPTY_MULTI_POLYGON
const rings = Array.isArray((shape as PaperLikeCompoundPathShape).children)
? ((shape as PaperLikeCompoundPathShape).children || [])
.map((child) => contourToRing(child.segments))
.filter((ring): ring is NormalizedRing => !!ring)
: [contourToRing((shape as PaperLikePathShape).segments)].filter(
(ring): ring is NormalizedRing => !!ring
)
if (!rings.length) return EMPTY_MULTI_POLYGON
const ringEntries = createRingEntries(rings)
const classified = ringEntries.map((entry, entryIndex) => {
const depth = ringEntries.reduce((count, otherEntry, otherIndex) => {
if (entryIndex === otherIndex) return count
return isPointInsideRing(entry.samplePoint, otherEntry.ring)
? count + 1
: count
}, 0)
return {
...entry,
isHole: depth % 2 === 1,
}
})
if (classified.length && !classified.some((entry) => !entry.isHole)) {
const largestOuter = classified.reduce((result, entry) => {
if (!result) return entry
return entry.absArea > result.absArea ? entry : result
}, classified[0])
if (largestOuter) {
largestOuter.isHole = false
}
}
return normalizeMultiPolygon(
attachHolesToOuters(
classified.filter((entry) => !entry.isHole),
classified.filter((entry) => entry.isHole)
)
)
}
export const multiPolygonToStoredContours = (
geometry: NormalizedMultiPolygon | null | undefined
) => {
const normalizedGeometry = normalizeMultiPolygon(geometry)
const sortedGeometry = normalizedGeometry
.map((polygon) => {
const [outerRing, ...holeRings] = polygon
if (!outerRing) return null
const outerEntry = createRingEntries([outerRing])[0]
const holeEntries = createRingEntries(holeRings).sort(
compareRingEntriesForSerialization
)
return {
outer: outerEntry,
holes: holeEntries,
}
})
.filter(
(
polygon
): polygon is {
outer: RingEntry
holes: RingEntry[]
} => !!polygon
)
.sort((left, right) =>
compareRingEntriesForSerialization(left.outer, right.outer)
)
const points = sortedGeometry.map(({ outer }) =>
outer.ring.map((point) => clonePoint(point))
)
const hollowPoints = sortedGeometry.flatMap(({ holes }) =>
holes.map((hole) => hole.ring.map((point) => clonePoint(point)))
)
return {
points,
hollowPoints,
}
}
export const multiPolygonToLabelTuple = (
objectId: number,
detail: unknown,
geometry: NormalizedMultiPolygon | null | undefined
): LabelTupleLike => {
const { points, hollowPoints } = multiPolygonToStoredContours(geometry)
return [objectId, points, detail, hollowPoints]
}
const getLastValidContourIndex = (
contours: Array<SerializableContour | null | undefined>
) => {
for (let index = contours.length - 1; index >= 0; index -= 1) {
if (contourToRing(contours[index])) {
return index
}
}
return -1
}
export const findBestMatchingOuterContourIndex = ({
previousContours = [],
nextContours = [],
preferredIndex = null,
}: {
previousContours?: Array<SerializableContour | null | undefined>
nextContours?: Array<SerializableContour | null | undefined>
preferredIndex?: number | null
}) => {
const nextEntries = createRingEntries(
nextContours.map((contour) => contourToRing(contour))
)
if (!nextEntries.length) return -1
const fallbackIndex = (() => {
if (
preferredIndex !== null &&
preferredIndex >= 0 &&
preferredIndex < nextContours.length &&
contourToRing(nextContours[preferredIndex])
) {
return preferredIndex
}
const lastValidContourIndex = getLastValidContourIndex(nextContours)
return lastValidContourIndex >= 0
? lastValidContourIndex
: nextEntries[0].sourceIndex
})()
const previousRings = previousContours.map((contour) =>
contourToRing(contour)
)
const preferredReferenceRing =
preferredIndex !== null &&
preferredIndex >= 0 &&
preferredIndex < previousRings.length
? previousRings[preferredIndex]
: null
const fallbackReferenceRing =
preferredReferenceRing ||
[...previousRings]
.reverse()
.find((ring): ring is NormalizedRing => !!ring) ||
null
if (!fallbackReferenceRing) {
return fallbackIndex
}
const referenceEntry = createRingEntries([fallbackReferenceRing])[0]
const bestMatch = nextEntries.reduce<{
entry: RingEntry
score: number
} | null>((result, entry) => {
const score = getRingMatchScore(referenceEntry, entry)
if (!result) {
return { entry, score }
}
if (score > result.score + POINT_EPSILON) {
return { entry, score }
}
if (
isSameNumber(score, result.score) &&
compareRingEntriesForSerialization(entry, result.entry) < 0
) {
return { entry, score }
}
return result
}, null)
return bestMatch?.entry.sourceIndex ?? fallbackIndex
}
const buildMeta = (geometry: NormalizedMultiPolygon): BooleanAdapterMeta => {
const outerRingCount = geometry.length
const holeCount = geometry.reduce((count, polygon) => {
return count + Math.max(0, polygon.length - 1)
}, 0)
return {
polygonCount: geometry.length,
outerRingCount,
holeCount,
isEmpty: geometry.length === 0,
}
}
const unionAll = (geometries: NormalizedMultiPolygon[]) => {
const [first, ...rest] = geometries
if (!first?.length) return []
return polygonClipping.union(first, ...rest)
}
const applyMode = (
mode: BooleanAdapterMode,
subject: NormalizedMultiPolygon,
operands: NormalizedMultiPolygon[]
) => {
if (!subject.length) {
return mode === "unite" ? unionAll(operands) : []
}
if (!operands.length) {
return subject
}
switch (mode) {
case "unite":
return polygonClipping.union(subject, ...operands)
case "subtract":
return polygonClipping.difference(subject, ...operands)
case "intersect":
return polygonClipping.intersection(subject, ...operands)
case "xor":
return polygonClipping.xor(subject, ...operands)
default:
return subject
}
}
export const applyBooleanAdapter = ({
subject,
operands = [],
mode,
rasterClip = null,
}: BooleanAdapterInput): BooleanAdapterResult => {
const normalizedSubject = normalizeMultiPolygon(subject)
const normalizedOperands = operands
.map((geometry) => normalizeMultiPolygon(geometry))
.filter((geometry) => geometry.length)
let nextGeometry = normalizeMultiPolygon(
applyMode(mode, normalizedSubject, normalizedOperands)
)
if (rasterClip?.length && nextGeometry.length) {
nextGeometry = normalizeMultiPolygon(
polygonClipping.intersection(
nextGeometry,
normalizeMultiPolygon(rasterClip)
)
)
}
return {
geometry: nextGeometry,
meta: buildMeta(nextGeometry),
}
}
// TODO phase 2:
// 1. 为 concave ring 补更强的 interior point / overlap 计算,减少极端轮廓下的误判。
// 2. 为几何层追加最小可重复验证入口,覆盖多次 a/d/,/. 链式操作。

View File

@@ -13,6 +13,13 @@ const getTagVisibilityByConfig = (tagCategory?: string) => {
return true
}
const resetSelectedItemVisual = (item: paper.Item) => {
if (!(item instanceof paper.Path || item instanceof paper.CompoundPath))
return
if (!["rectangle", "polygon"].includes(item.data?.type || "")) return
item.fillColor = item.data?.blankColor || item.fillColor || null
}
export const getObjectTagVisibility = (
imageId: string,
objectId: number,
@@ -30,17 +37,12 @@ const setSelectedItemFalse = (id: number) => {
if (item.data.type === "mask" || item.data.type === "text") {
item.remove()
}
resetSelectedItemVisual(item)
if (item.data.selected) item.data.selected = false
})
}
const clearHiddenObjectSelectionState = (imageId: string, objectId: number) => {
const { selectedPath, updateSelectedPath } = useObjectStore.getState()
if (selectedPath[imageId]?.includes(objectId)) {
updateSelectedPath(imageId, "DELETE", objectId)
usePaperSupportStore.getState().clearAll()
}
const otherToolsState = useOtherToolsStore.getState()
if (
otherToolsState.showContextMenu &&
@@ -84,6 +86,69 @@ const syncObjectItemsVisibility = (
})
}
const applySelectedItemVisual = (item: paper.Item) => {
if (!(item instanceof paper.Path || item instanceof paper.CompoundPath))
return
const itemType = item.data?.type
if (!["rectangle", "polygon", "brush", "point"].includes(itemType || ""))
return
if (itemType === "rectangle" && item instanceof paper.Path) {
item.data.selected = true
usePaperSupportStore.getState().handleBufferPaths(item)
usePaperSupportStore.getState().handleCircles(item)
item.fillColor = item.data.fillColor || item.fillColor || null
} else if (itemType === "brush") {
item.data.selected = true
if (item instanceof paper.Path) {
usePaperSupportStore.getState().handleMask(item)
usePaperSupportStore.getState().handleBufferPaths(item)
usePaperSupportStore.getState().handleCircles(item)
}
} else if (itemType === "point" && item instanceof paper.Path) {
item.data.selected = true
usePaperSupportStore.getState().handleMask(item)
usePaperSupportStore.getState().handleText(item)
usePaperSupportStore.getState().handleBufferPaths(item)
usePaperSupportStore.getState().handleCircles(item)
} else if (itemType === "polygon") {
item.data.selected = true
if (!(item instanceof paper.Path) || !item.data?.isHollowPolygon) {
item.fillColor = item.data.fillColor || item.fillColor || null
}
}
item.bringToFront()
}
export const syncSelectedObjectsVisualState = (imageId: string) => {
const group = usePaperStore.getState().group
if (!group) return
group
.getItems({
match: (item: paper.Item) => !!item.data?.selected,
})
.forEach((item) => {
resetSelectedItemVisual(item)
item.data.selected = false
})
usePaperSupportStore.getState().clearAll()
const { selectedPath, pathStatus } = useObjectStore.getState()
;(selectedPath[imageId] || []).forEach((selectedId) => {
if (pathStatus[imageId + selectedId]?.HIDE) return
usePaperStore
.getState()
.getItemsById(selectedId)
.forEach((item) => {
if (item.data?.type === "tag") return
applySelectedItemVisual(item)
})
})
}
export const toggleObjectHideByShortcut = (
imageId: string,
operationId: string,
@@ -98,6 +163,7 @@ export const toggleObjectHideByShortcut = (
syncObjectItemsVisibility(imageId, objectId, hide)
setSelectedItemFalse(objectId)
clearHiddenObjectSelectionState(imageId, objectId)
syncSelectedObjectsVisualState(imageId)
const operationChildren = storeLabel.get(imageId)?.get(operationId) || []
const hasVisibleChild = operationChildren.some((child) => {
@@ -115,4 +181,5 @@ export const toggleObjectHideByShortcut = (
updateOperationStatus(imageId + operationId, "HIDE", hide)
updatePathStatus(imageId + objectId, "HIDE", hide)
syncObjectItemsVisibility(imageId, objectId, hide)
syncSelectedObjectsVisualState(imageId)
}

View File

@@ -59,6 +59,7 @@
"msgpack-lite": "^0.1.26",
"next": "15.5.7",
"paper": "^0.12.18",
"polygon-clipping": "^0.15.7",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-window": "^2.2.3",