Merge branch 'zh' into 'main'
Zh See merge request prism/lable/labelimage!14
This commit is contained in:
@@ -1511,6 +1511,7 @@ const LabelPage = ({
|
|||||||
|
|
||||||
const restoreDrawShortcutSnapshot = useCallback(() => {
|
const restoreDrawShortcutSnapshot = useCallback(() => {
|
||||||
useKeyboardStore.getState().setDrawAction("none")
|
useKeyboardStore.getState().setDrawAction("none")
|
||||||
|
useKeyboardStore.getState().setSelectedObjectEditTarget(null)
|
||||||
|
|
||||||
const snapshot = drawShortcutSnapshotRef.current
|
const snapshot = drawShortcutSnapshotRef.current
|
||||||
if (!snapshot) return
|
if (!snapshot) return
|
||||||
@@ -1562,6 +1563,7 @@ const LabelPage = ({
|
|||||||
|
|
||||||
setActiveOperation(selectedContext.operationId)
|
setActiveOperation(selectedContext.operationId)
|
||||||
usePaperStore.getState().setContinuePolygonTarget(null)
|
usePaperStore.getState().setContinuePolygonTarget(null)
|
||||||
|
useKeyboardStore.getState().setSelectedObjectEditTarget(selectedContext)
|
||||||
setEditMode(true)
|
setEditMode(true)
|
||||||
setDrawOption("default")
|
setDrawOption("default")
|
||||||
useKeyboardStore.getState().setDrawAction(mode)
|
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 handleContinueSelectedPolygon = useCallback(() => {
|
||||||
const selectedContext = resolveSelectedObjectContext()
|
const selectedContext = resolveSelectedObjectContext()
|
||||||
if (!selectedContext) {
|
if (!selectedContext) {
|
||||||
@@ -1738,6 +1717,7 @@ const LabelPage = ({
|
|||||||
setPressA(false)
|
setPressA(false)
|
||||||
setActiveOperation(selectedContext.operationId)
|
setActiveOperation(selectedContext.operationId)
|
||||||
useKeyboardStore.getState().setDrawAction("none")
|
useKeyboardStore.getState().setDrawAction("none")
|
||||||
|
useKeyboardStore.getState().setSelectedObjectEditTarget(null)
|
||||||
usePaperStore.getState().setContinuePolygonTarget({
|
usePaperStore.getState().setContinuePolygonTarget({
|
||||||
imageId: selectedContext.imageId,
|
imageId: selectedContext.imageId,
|
||||||
operationId: selectedContext.operationId,
|
operationId: selectedContext.operationId,
|
||||||
@@ -1758,70 +1738,6 @@ const LabelPage = ({
|
|||||||
setPressA,
|
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(
|
const applySelectedPolygonOverlap = useCallback(
|
||||||
(mode: "subtract" | "unite") => {
|
(mode: "subtract" | "unite") => {
|
||||||
const selectedContext = resolveSelectedObjectContext()
|
const selectedContext = resolveSelectedObjectContext()
|
||||||
@@ -1842,138 +1758,9 @@ const LabelPage = ({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
usePaperStore.getState().applySelectedPolygonBooleanOperation(mode)
|
||||||
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] : []
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[
|
[resolveSelectedObjectContext]
|
||||||
collectPolygonPoints,
|
|
||||||
createWorkingPolygonShape,
|
|
||||||
hasShapeSegments,
|
|
||||||
removeObjectFromGroupMap,
|
|
||||||
rerenderActiveImageByLabel,
|
|
||||||
resolveSelectedObjectContext,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const applySelectedBrushIntersectUnite = useCallback(() => {
|
const applySelectedBrushIntersectUnite = useCallback(() => {
|
||||||
@@ -2583,6 +2370,7 @@ const LabelPage = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useKeyboardStore.getState().setCopyDataIds([])
|
useKeyboardStore.getState().setCopyDataIds([])
|
||||||
useKeyboardStore.getState().setDrawAction("none")
|
useKeyboardStore.getState().setDrawAction("none")
|
||||||
|
useKeyboardStore.getState().setSelectedObjectEditTarget(null)
|
||||||
usePaperStore.getState().setContinuePolygonTarget(null)
|
usePaperStore.getState().setContinuePolygonTarget(null)
|
||||||
drawShortcutSnapshotRef.current = null
|
drawShortcutSnapshotRef.current = null
|
||||||
}, [activeImage])
|
}, [activeImage])
|
||||||
@@ -2638,7 +2426,7 @@ const LabelPage = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isView) {
|
if (!isView) {
|
||||||
const delay = 10 * 60000
|
const delay = 5 * 60000
|
||||||
// 编辑模式下,修改labelData时启动或更新定时器
|
// 编辑模式下,修改labelData时启动或更新定时器
|
||||||
useTimerStore.getState().startTimer(() => {
|
useTimerStore.getState().startTimer(() => {
|
||||||
useTopToolsStore.getState().setIsView(true)
|
useTopToolsStore.getState().setIsView(true)
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const getAuxiliaryAnnotation = (data: {
|
|||||||
background_points?: Array<[number, number]>
|
background_points?: Array<[number, number]>
|
||||||
boxes?: Array<[number, number, number, number]>
|
boxes?: Array<[number, number, number, number]>
|
||||||
}
|
}
|
||||||
|
contour_approximation?: number
|
||||||
}) => {
|
}) => {
|
||||||
return httpFetch<{
|
return httpFetch<{
|
||||||
contours: Array<Array<[[number, number]]>>
|
contours: Array<Array<[[number, number]]>>
|
||||||
|
|||||||
@@ -518,7 +518,7 @@ const PaperContainer = (
|
|||||||
token: number
|
token: number
|
||||||
imageName: string
|
imageName: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const renderModePanRef = useRef({
|
const middleMousePanRef = useRef({
|
||||||
active: false,
|
active: false,
|
||||||
lastX: 0,
|
lastX: 0,
|
||||||
lastY: 0,
|
lastY: 0,
|
||||||
@@ -997,6 +997,7 @@ const PaperContainer = (
|
|||||||
: {}),
|
: {}),
|
||||||
...(normalizedBoxes.length ? { boxes: normalizedBoxes } : {}),
|
...(normalizedBoxes.length ? { boxes: normalizedBoxes } : {}),
|
||||||
},
|
},
|
||||||
|
contour_approximation: 0,
|
||||||
})
|
})
|
||||||
const { contours = [], hollows = [] } = data || {}
|
const { contours = [], hollows = [] } = data || {}
|
||||||
const outerPaths: any[] = []
|
const outerPaths: any[] = []
|
||||||
@@ -1050,6 +1051,8 @@ const PaperContainer = (
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
} finally {
|
||||||
|
notifications.hide("sam2")
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -1429,6 +1432,7 @@ const PaperContainer = (
|
|||||||
})
|
})
|
||||||
.filter((path): path is paper.Path => !!path) || []
|
.filter((path): path is paper.Path => !!path) || []
|
||||||
|
|
||||||
|
if (!(outerPaths.length === 1 && !innerPaths.length)) {
|
||||||
addCompoundPath(
|
addCompoundPath(
|
||||||
renderCompoundPath,
|
renderCompoundPath,
|
||||||
[...outerPaths, ...innerPaths],
|
[...outerPaths, ...innerPaths],
|
||||||
@@ -1437,7 +1441,8 @@ const PaperContainer = (
|
|||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
// fillColor: fillColor,
|
// fillColor: fillColor,
|
||||||
fillColor: blankColor,
|
fillColor: blankColor,
|
||||||
visible: !paperPathStatus[renderImageId + item[0]]?.["HIDE"],
|
visible:
|
||||||
|
!paperPathStatus[renderImageId + item[0]]?.["HIDE"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "polygon",
|
type: "polygon",
|
||||||
@@ -1449,6 +1454,7 @@ const PaperContainer = (
|
|||||||
selected: selectedIds.includes(item[0]),
|
selected: selectedIds.includes(item[0]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// renderCompoundPath(usePaperStore.getState().group!, { children });
|
// renderCompoundPath(usePaperStore.getState().group!, { children });
|
||||||
// item[1].forEach((child) => {
|
// item[1].forEach((child) => {
|
||||||
@@ -2337,15 +2343,7 @@ const PaperContainer = (
|
|||||||
syncRenderModeVisuals,
|
syncRenderModeVisuals,
|
||||||
])
|
])
|
||||||
|
|
||||||
const stopRenderModePan = useCallback(() => {
|
const hidePaperContextMenu = useCallback(() => {
|
||||||
renderModePanRef.current.active = false
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleRenderModeOverlayMouseDown = useCallback(
|
|
||||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
useOtherToolsStore.getState().setShowContextMenu({
|
useOtherToolsStore.getState().setShowContextMenu({
|
||||||
showContextMenu: false,
|
showContextMenu: false,
|
||||||
position: { top: 0, left: 0 },
|
position: { top: 0, left: 0 },
|
||||||
@@ -2353,26 +2351,64 @@ const PaperContainer = (
|
|||||||
operationId: "",
|
operationId: "",
|
||||||
id: 0,
|
id: 0,
|
||||||
})
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (event.button !== 1) return
|
const startMiddleMousePan = useCallback(
|
||||||
|
(clientX: number, clientY: number) => {
|
||||||
renderModePanRef.current = {
|
middleMousePanRef.current = {
|
||||||
active: true,
|
active: true,
|
||||||
lastX: event.clientX,
|
lastX: clientX,
|
||||||
lastY: event.clientY,
|
lastY: clientY,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const stopMiddleMousePan = useCallback(() => {
|
||||||
|
middleMousePanRef.current.active = false
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!renderMode) {
|
const container = containerRef.current
|
||||||
stopRenderModePan()
|
if (!container) return
|
||||||
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()
|
||||||
|
|
||||||
|
hidePaperContextMenu()
|
||||||
|
|
||||||
|
if (event.button !== 1) return
|
||||||
|
|
||||||
|
startMiddleMousePan(event.clientX, event.clientY)
|
||||||
|
},
|
||||||
|
[hidePaperContextMenu, startMiddleMousePan]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const handleMouseMove = (event: MouseEvent) => {
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
const currentPan = renderModePanRef.current
|
const currentPan = middleMousePanRef.current
|
||||||
if (!currentPan.active) return
|
if (!currentPan.active) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -2384,26 +2420,29 @@ const PaperContainer = (
|
|||||||
if (!deltaX && !deltaY) return
|
if (!deltaX && !deltaY) return
|
||||||
|
|
||||||
group.position = group.position.add(new paper.Point(deltaX, deltaY))
|
group.position = group.position.add(new paper.Point(deltaX, deltaY))
|
||||||
renderModePanRef.current.lastX = event.clientX
|
middleMousePanRef.current.lastX = event.clientX
|
||||||
renderModePanRef.current.lastY = event.clientY
|
middleMousePanRef.current.lastY = event.clientY
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp = (event: MouseEvent) => {
|
const handleMouseUp = (event: MouseEvent) => {
|
||||||
if (event.button === 1) {
|
if (event.button === 1) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
stopRenderModePan()
|
|
||||||
|
stopMiddleMousePan()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("mousemove", handleMouseMove, { passive: false })
|
window.addEventListener("mousemove", handleMouseMove, { passive: false })
|
||||||
window.addEventListener("mouseup", handleMouseUp, { passive: false })
|
window.addEventListener("mouseup", handleMouseUp, { passive: false })
|
||||||
|
window.addEventListener("blur", stopMiddleMousePan)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("mousemove", handleMouseMove)
|
window.removeEventListener("mousemove", handleMouseMove)
|
||||||
window.removeEventListener("mouseup", handleMouseUp)
|
window.removeEventListener("mouseup", handleMouseUp)
|
||||||
stopRenderModePan()
|
window.removeEventListener("blur", stopMiddleMousePan)
|
||||||
|
stopMiddleMousePan()
|
||||||
}
|
}
|
||||||
}, [renderMode, stopRenderModePan])
|
}, [stopMiddleMousePan])
|
||||||
|
|
||||||
const getTargetGroupPosition = useCallback(
|
const getTargetGroupPosition = useCallback(
|
||||||
({
|
({
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
|
|
||||||
|
export interface SelectedObjectEditTarget {
|
||||||
|
imageId: string
|
||||||
|
operationId: string
|
||||||
|
objectId: number
|
||||||
|
objectType: string
|
||||||
|
}
|
||||||
|
|
||||||
interface KeyboardStore {
|
interface KeyboardStore {
|
||||||
ctrl: boolean
|
ctrl: boolean
|
||||||
setCtrl: (v: boolean) => void
|
setCtrl: (v: boolean) => void
|
||||||
@@ -7,6 +14,8 @@ interface KeyboardStore {
|
|||||||
setShift: (v: boolean) => void
|
setShift: (v: boolean) => void
|
||||||
drawAction: "none" | "add" | "subtract"
|
drawAction: "none" | "add" | "subtract"
|
||||||
setDrawAction: (mode: "none" | "add" | "subtract") => void
|
setDrawAction: (mode: "none" | "add" | "subtract") => void
|
||||||
|
selectedObjectEditTarget: SelectedObjectEditTarget | null
|
||||||
|
setSelectedObjectEditTarget: (target: SelectedObjectEditTarget | null) => void
|
||||||
copyDataIds: number[] // ctrl + c 复制数据
|
copyDataIds: number[] // ctrl + c 复制数据
|
||||||
setCopyDataIds: (ids: number[]) => void
|
setCopyDataIds: (ids: number[]) => void
|
||||||
}
|
}
|
||||||
@@ -15,9 +24,12 @@ export const useKeyboardStore = create<KeyboardStore>((set) => ({
|
|||||||
ctrl: false,
|
ctrl: false,
|
||||||
shift: false,
|
shift: false,
|
||||||
drawAction: "none",
|
drawAction: "none",
|
||||||
|
selectedObjectEditTarget: null,
|
||||||
copyDataIds: [],
|
copyDataIds: [],
|
||||||
setCtrl: (v) => set((state) => ({ ...state, ctrl: v })),
|
setCtrl: (v) => set((state) => ({ ...state, ctrl: v })),
|
||||||
setShift: (v) => set((state) => ({ ...state, shift: v })),
|
setShift: (v) => set((state) => ({ ...state, shift: v })),
|
||||||
setDrawAction: (mode) => set((state) => ({ ...state, drawAction: mode })),
|
setDrawAction: (mode) => set((state) => ({ ...state, drawAction: mode })),
|
||||||
|
setSelectedObjectEditTarget: (target) =>
|
||||||
|
set((state) => ({ ...state, selectedObjectEditTarget: target })),
|
||||||
setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })),
|
setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })),
|
||||||
}))
|
}))
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
806
components/label/utils/geometry/booleanAdapter.ts
Normal file
806
components/label/utils/geometry/booleanAdapter.ts
Normal 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/,/. 链式操作。
|
||||||
@@ -13,6 +13,13 @@ const getTagVisibilityByConfig = (tagCategory?: string) => {
|
|||||||
return true
|
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 = (
|
export const getObjectTagVisibility = (
|
||||||
imageId: string,
|
imageId: string,
|
||||||
objectId: number,
|
objectId: number,
|
||||||
@@ -30,17 +37,12 @@ const setSelectedItemFalse = (id: number) => {
|
|||||||
if (item.data.type === "mask" || item.data.type === "text") {
|
if (item.data.type === "mask" || item.data.type === "text") {
|
||||||
item.remove()
|
item.remove()
|
||||||
}
|
}
|
||||||
|
resetSelectedItemVisual(item)
|
||||||
if (item.data.selected) item.data.selected = false
|
if (item.data.selected) item.data.selected = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearHiddenObjectSelectionState = (imageId: string, objectId: number) => {
|
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()
|
const otherToolsState = useOtherToolsStore.getState()
|
||||||
if (
|
if (
|
||||||
otherToolsState.showContextMenu &&
|
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 = (
|
export const toggleObjectHideByShortcut = (
|
||||||
imageId: string,
|
imageId: string,
|
||||||
operationId: string,
|
operationId: string,
|
||||||
@@ -98,6 +163,7 @@ export const toggleObjectHideByShortcut = (
|
|||||||
syncObjectItemsVisibility(imageId, objectId, hide)
|
syncObjectItemsVisibility(imageId, objectId, hide)
|
||||||
setSelectedItemFalse(objectId)
|
setSelectedItemFalse(objectId)
|
||||||
clearHiddenObjectSelectionState(imageId, objectId)
|
clearHiddenObjectSelectionState(imageId, objectId)
|
||||||
|
syncSelectedObjectsVisualState(imageId)
|
||||||
|
|
||||||
const operationChildren = storeLabel.get(imageId)?.get(operationId) || []
|
const operationChildren = storeLabel.get(imageId)?.get(operationId) || []
|
||||||
const hasVisibleChild = operationChildren.some((child) => {
|
const hasVisibleChild = operationChildren.some((child) => {
|
||||||
@@ -115,4 +181,5 @@ export const toggleObjectHideByShortcut = (
|
|||||||
updateOperationStatus(imageId + operationId, "HIDE", hide)
|
updateOperationStatus(imageId + operationId, "HIDE", hide)
|
||||||
updatePathStatus(imageId + objectId, "HIDE", hide)
|
updatePathStatus(imageId + objectId, "HIDE", hide)
|
||||||
syncObjectItemsVisibility(imageId, objectId, hide)
|
syncObjectItemsVisibility(imageId, objectId, hide)
|
||||||
|
syncSelectedObjectsVisualState(imageId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"msgpack-lite": "^0.1.26",
|
"msgpack-lite": "^0.1.26",
|
||||||
"next": "15.5.7",
|
"next": "15.5.7",
|
||||||
"paper": "^0.12.18",
|
"paper": "^0.12.18",
|
||||||
|
"polygon-clipping": "^0.15.7",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-window": "^2.2.3",
|
"react-window": "^2.2.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user