Merge branch 'zh' into 'main'

Zh

See merge request prism/lable/labelimage!18
This commit is contained in:
刘耀勇
2026-04-22 11:28:19 +08:00
9 changed files with 663 additions and 77 deletions

View File

@@ -49,16 +49,13 @@ import { useDescToolsStore } from "./useDescToolsStore"
import { useKeyboardStore } from "./useKeyBoardStore"
import { usePaperStore } from "./usePaperStore"
import { useRightToolsStore } from "./useRightToolsStore"
import {
useIntervalStore,
useLabelTimeStore,
useTimerStore,
} from "./useTimerStore"
import { useIntervalStore, useLabelTimeStore } from "./useTimerStore"
import { useTopToolsStore } from "./useTopToolsStore"
import { findGroupKey, isEditableKeyboardTarget } from "./util"
import { safeClone } from "./utils/clone"
import { labelTypeMap } from "./utils/constants"
import { toggleObjectHideByShortcut } from "./utils/objectVisibility"
import { getDisplayedImageScale } from "./utils/scale"
// Splitter component - will need custom implementation or alternative
@@ -88,6 +85,8 @@ const RIGHT_PANEL_SEPARATOR =
const RIGHT_PANEL_SOFT_MAIN_MIN_WIDTH = 420
const RIGHT_PANEL_HARD_MAIN_MIN_WIDTH = 260
const RIGHT_PANEL_TARGET_WIDTH = 350
const INACTIVITY_VIEW_DELAY_MS = 5 * 60 * 1000
const INACTIVITY_ACTIVITY_THROTTLE_MS = 1000
// utils
const getRectPoints = ([sx, sy, w, h]: number[]) => {
@@ -279,6 +278,8 @@ const LabelPage = ({
const topRef = useRef<any>(null)
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const autoSaveInProgressRef = useRef(false)
const inactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastInactivityResetAtRef = useRef(0)
const paperContainerRef = useRef<any>(null)
const qaToolContainerRef = useRef<any>(null)
const [isConfirmSave, setIsConfirmSave] = useState(false)
@@ -325,6 +326,9 @@ const LabelPage = ({
setObjectOperations,
} = useTopToolsStore()
const { activeImage } = useBottomToolsStore()
const activeRasterScale = usePaperStore(
(state) => state.rasterScale[activeImage] ?? 1
)
const {
setDescOperations,
metaOperation,
@@ -333,6 +337,12 @@ const LabelPage = ({
setQaData,
resetData,
} = useDescToolsStore()
const displayedImageScale = useMemo(() => {
return getDisplayedImageScale({
rasterScale: activeRasterScale,
viewScale: scale,
})
}, [activeRasterScale, scale])
const asyncGetProjectDetail = useCallback(async () => {
if (projectId) {
@@ -2404,10 +2414,9 @@ const LabelPage = ({
const handleBeforeUnload = (e: any) => {
e.preventDefault()
e.returnValue = ""
// 备份
topRef.current.handleBackup("auto_backup")
// 清空操作栏状态
useObjectStore.getState().resetPathAndOperationStatus()
void topRef.current?.handleBackup?.("auto_backup")
}
// 更新Splitter状态
@@ -2443,22 +2452,86 @@ const LabelPage = ({
}
}, [updateSplitterSizes])
useEffect(() => {
if (!isView) {
const delay = 5 * 60000
// 编辑模式下修改labelData时启动或更新定时器
useTimerStore.getState().startTimer(() => {
useTopToolsStore.getState().setIsView(true)
}, delay)
} else {
useTimerStore.getState().clearTimer()
const clearInactivityTimer = useCallback(() => {
if (inactivityTimerRef.current !== null) {
clearTimeout(inactivityTimerRef.current)
inactivityTimerRef.current = null
}
}, [])
const scheduleInactivityViewMode = useCallback(() => {
clearInactivityTimer()
inactivityTimerRef.current = setTimeout(() => {
const topToolsState = useTopToolsStore.getState()
if (!topToolsState.isView) {
topToolsState.setIsView(true)
}
}, INACTIVITY_VIEW_DELAY_MS)
}, [clearInactivityTimer])
const reportUserActivity = useCallback(
(force = false) => {
if (useTopToolsStore.getState().isView) return
const now = Date.now()
if (
!force &&
now - lastInactivityResetAtRef.current < INACTIVITY_ACTIVITY_THROTTLE_MS
) {
return
}
// 组件卸载时清理定时器
return () => {
useTimerStore.getState().clearTimer()
lastInactivityResetAtRef.current = now
scheduleInactivityViewMode()
},
[scheduleInactivityViewMode]
)
useEffect(() => {
if (isView) {
lastInactivityResetAtRef.current = 0
clearInactivityTimer()
return
}
}, [isView, labelData])
// 使用真实输入事件追踪活跃度,避免进行中的标注因 labelData 未变化被误判为空闲。
reportUserActivity(true)
const handlePointerDown = () => {
reportUserActivity(true)
}
const handlePointerMove = (event: PointerEvent) => {
if (event.buttons !== 0) {
reportUserActivity()
}
}
// const handleWheel = () => {
// reportUserActivity()
// }
const handleKeyDown = () => {
reportUserActivity(true)
}
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
reportUserActivity(true)
}
}
window.addEventListener("pointerdown", handlePointerDown, true)
window.addEventListener("pointermove", handlePointerMove, true)
// window.addEventListener("wheel", handleWheel, true)
window.addEventListener("keydown", handleKeyDown, true)
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
window.removeEventListener("pointerdown", handlePointerDown, true)
window.removeEventListener("pointermove", handlePointerMove, true)
// window.removeEventListener("wheel", handleWheel, true)
window.removeEventListener("keydown", handleKeyDown, true)
document.removeEventListener("visibilitychange", handleVisibilityChange)
clearInactivityTimer()
}
}, [clearInactivityTimer, isView, reportUserActivity])
// auto save
useEffect(() => {
@@ -2634,18 +2707,14 @@ const LabelPage = ({
style={{ overflow: "hidden" }}>
<ScaleComponent
options={{
offsetX: 0,
offsetY: 0,
scale: scale,
scale: displayedImageScale,
}}
mode="horizontal"
/>
<Flex h="calc(100% - 32px)">
<ScaleComponent
options={{
offsetX: 0,
offsetY: 0,
scale: scale,
scale: displayedImageScale,
}}
mode="vertical"
/>

View File

@@ -4,9 +4,13 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react"
import { useBottomToolsStore } from "../useBottomToolsStore"
import { usePaperStore } from "../usePaperStore"
import { useTopToolsStore } from "../useTopToolsStore"
import { getDisplayedImageScale } from "../utils/scale"
interface CrosshairComponentProps {
isCarved: boolean
@@ -18,7 +22,17 @@ const CrosshairComponent = (
) => {
const { isCarved } = props
const { scale } = useTopToolsStore()
const scale = useTopToolsStore((state) => state.scale)
const activeImage = useBottomToolsStore((state) => state.activeImage)
const rasterScale = usePaperStore(
(state) => state.rasterScale[activeImage] ?? 1
)
const displayedScale = useMemo(() => {
return getDisplayedImageScale({
rasterScale,
viewScale: scale,
})
}, [rasterScale, scale])
const canvasRef = useRef<HTMLCanvasElement>(null)
const lastPointerRef = useRef<{ x: number; y: number } | null>(null)
@@ -82,9 +96,9 @@ const CrosshairComponent = (
ctx.lineWidth = carveLineWidth
// 间隔
const sparsity = getSparsity(scale)
const sparsity = getSparsity(displayedScale)
const part = 10
const pixelPerUnit = scale * sparsity
const pixelPerUnit = displayedScale * sparsity
const gap = pixelPerUnit / part
const fixed = getFixed(sparsity)
@@ -136,7 +150,7 @@ const CrosshairComponent = (
}
}
},
[isCarved, scale]
[displayedScale, isCarved]
)
useEffect(() => {

View File

@@ -66,6 +66,10 @@ import {
paperShapeToMultiPolygon,
} from "../utils/geometry/booleanAdapter"
import { adjustPoints } from "../utils/paperjs"
import {
getDisplayedImageScale,
getViewScaleFromDisplayedScale,
} from "../utils/scale"
import AssistShapeComponent from "./AssistShapeComponent"
import CrosshairComponent from "./CrosshairComponent"
import { renderOperationIcon } from "./RightObjectTools"
@@ -2923,6 +2927,80 @@ const PaperContainer = (
}
}, [stopMiddleMousePan])
const getPicRasters = useCallback((group: paper.Group) => {
return group
.getItems({ data: { name: "pic" } })
.filter((item): item is paper.Raster => item instanceof paper.Raster)
}, [])
const getCachedDisplayedScale = useCallback(
(group: paper.Group | null | undefined) => {
if (!group || !saveCurrentScale) return null
const persistedDisplayedScale = useTopToolsStore.getState().displayedScale
const rasterItems = getPicRasters(group)
if (!rasterItems.length) {
return Number.isFinite(persistedDisplayedScale) &&
persistedDisplayedScale > 0
? persistedDisplayedScale
: null
}
const currentRaster = rasterItems.length
? rasterItems[rasterItems.length - 1]
: null
const imageId =
typeof currentRaster?.data?.imageId === "string"
? currentRaster.data.imageId
: useBottomToolsStore.getState().activeImage
if (!imageId) {
return Number.isFinite(persistedDisplayedScale) &&
persistedDisplayedScale > 0
? persistedDisplayedScale
: null
}
const viewScale =
Number.isFinite(group.scaling?.x) && group.scaling.x > 0
? group.scaling.x
: useTopToolsStore.getState().scale
return getDisplayedImageScale({
rasterScale: usePaperStore.getState().rasterScale[imageId],
viewScale,
})
},
[getPicRasters, saveCurrentScale]
)
const getTargetGroupScale = useCallback(
({
rasterScale,
cachedDisplayedScale,
fallbackScale,
}: {
rasterScale: number
cachedDisplayedScale?: number | null
fallbackScale?: number | null
}) => {
if (
saveCurrentScale &&
Number.isFinite(cachedDisplayedScale) &&
Number(cachedDisplayedScale) > 0
) {
return getViewScaleFromDisplayedScale({
displayedScale: cachedDisplayedScale,
rasterScale,
})
}
return Number.isFinite(fallbackScale) && Number(fallbackScale) > 0
? Number(fallbackScale)
: 1
},
[saveCurrentScale]
)
const getTargetGroupPosition = useCallback(
({
defaultPosition,
@@ -2958,6 +3036,7 @@ const PaperContainer = (
clearPendingFrameSwitch(token)
return
}
const cachedDisplayedScale = getCachedDisplayedScale(currentVisibleGroup)
const preserveGroupPosition =
saveCurrentScale &&
currentVisibleGroup.getItems({ data: { name: "pic" } }).length > 0
@@ -3034,6 +3113,11 @@ const PaperContainer = (
const scaleX = canvasWidth / raster.bounds.width
const scaleY = canvasHeight / raster.bounds.height
const scale = Math.min(scaleX, scaleY)
const targetGroupScale = getTargetGroupScale({
rasterScale: scale,
cachedDisplayedScale,
fallbackScale: currentVisibleGroup.scaling.x,
})
if (!Number.isFinite(scale) || scale <= 0) {
cleanupStagingGroup()
@@ -3048,6 +3132,10 @@ const PaperContainer = (
raster.bounds.width / 2,
raster.bounds.height / 2
)
stagingGroup.scaling = new paper.Point(
targetGroupScale,
targetGroupScale
)
const centeredGroupPosition = new paper.Point(
raster.bounds.width / 2,
raster.bounds.height / 2
@@ -3074,6 +3162,7 @@ const PaperContainer = (
const previousGroup = usePaperStore.getState().group
setGroup(stagingGroup)
useTopToolsStore.getState().setScale(targetGroupScale)
setRasterPath(new paper.Path.Rectangle(raster.bounds))
renderedImageRef.current = imageName
commitFrameSwitch(imageName, token)
@@ -3101,6 +3190,8 @@ const PaperContainer = (
[
clearPendingFrameSwitch,
commitFrameSwitch,
getCachedDisplayedScale,
getTargetGroupScale,
getTargetGroupPosition,
imgSize?.clientHeight,
imgSize?.clientWidth,
@@ -3166,12 +3257,6 @@ const PaperContainer = (
)
decodeFrameImageRef.current = decodeFrameImage
const getPicRasters = useCallback((group: paper.Group) => {
return group
.getItems({ data: { name: "pic" } })
.filter((item): item is paper.Raster => item instanceof paper.Raster)
}, [])
const cancelRasterFadeTransition = useCallback(() => {
rasterFadeTransitionIdRef.current += 1
if (
@@ -3194,6 +3279,7 @@ const PaperContainer = (
if (activeImage && projectDetail?.id) {
const requestId = ++rasterLoadRequestIdRef.current
const requestImage = activeImage
const cachedDisplayedScale = getCachedDisplayedScale(paperGroup)
const preserveGroupPosition =
saveCurrentScale && getPicRasters(paperGroup).length > 0
const preservedGroupPosition = preserveGroupPosition
@@ -3275,6 +3361,11 @@ const PaperContainer = (
])
let scale = Math.min(scaleX, scaleY) // Maintain aspect ratio
const targetGroupScale = getTargetGroupScale({
rasterScale: scale,
cachedDisplayedScale,
fallbackScale: paperGroup.scaling.x,
})
if (!Number.isFinite(scale) || scale <= 0) {
console.warn(
"[PaperContainer] skip raster scale: invalid scale",
@@ -3357,6 +3448,10 @@ const PaperContainer = (
raster.bounds.width / 2,
raster.bounds.height / 2
)
paperGroup.scaling = new paper.Point(
targetGroupScale,
targetGroupScale
)
const centeredGroupPosition = new paper.Point(
raster.bounds.width / 2,
raster.bounds.height / 2
@@ -3422,6 +3517,7 @@ const PaperContainer = (
setRasterPath(new paper.Path.Rectangle(raster.bounds))
raster.sendToBack()
renderedImageRef.current = requestImage
useTopToolsStore.getState().setScale(targetGroupScale)
setRasterInited(true)
usePaperStore.getState().group?.bringToFront()
}
@@ -3439,7 +3535,9 @@ const PaperContainer = (
activeImage,
cancelRasterFadeTransition,
decodeFrameImage,
getCachedDisplayedScale,
getPicRasters,
getTargetGroupScale,
getTargetGroupPosition,
imgSize?.clientHeight,
imgSize?.clientWidth,

View File

@@ -0,0 +1,99 @@
"use client"
import { Button, Group, NumberInput, Stack, Text } from "@mantine/core"
import { useEffect, useMemo, useState } from "react"
import CustomModal from "./CustomModal"
interface ComponentProps {
open: boolean
defaultFactor: number
selectedObjectCount: number
canRepairImage: boolean
handleCancel: () => void
handleRepairObject: (factor: number) => void
handleRepairImage: (factor: number) => void
}
const RepairScaleModal = ({
open,
defaultFactor,
selectedObjectCount,
canRepairImage,
handleCancel,
handleRepairObject,
handleRepairImage,
}: ComponentProps) => {
const [factorInput, setFactorInput] = useState<number | string>(1)
useEffect(() => {
if (!open) return
const nextFactor =
Number.isFinite(defaultFactor) && defaultFactor > 0
? Number(defaultFactor.toFixed(6))
: 1
setFactorInput(nextFactor)
}, [defaultFactor, open])
const normalizedFactor = useMemo(() => {
if (typeof factorInput === "number") return factorInput
return Number(factorInput)
}, [factorInput])
const factorError =
Number.isFinite(normalizedFactor) && normalizedFactor > 0
? null
: "请输入大于 0 的修复因子"
return (
<CustomModal
title="修复标注对象"
open={open}
onCancel={handleCancel}
centered
closeOnClickOutside={false}
footer={
<Group justify="flex-end" gap="xs" mt="md">
<Button variant="default" onClick={handleCancel}>
</Button>
<Button
variant="light"
onClick={() => handleRepairObject(normalizedFactor)}
disabled={!!factorError || selectedObjectCount !== 1}>
</Button>
<Button
onClick={() => handleRepairImage(normalizedFactor)}
disabled={!!factorError || !canRepairImage}>
</Button>
</Group>
}>
<Stack gap="sm">
<Text size="sm" c="dimmed">
`rasterScale`
</Text>
<NumberInput
label="修复因子"
placeholder="请输入大于 0 的修复因子"
value={factorInput}
onChange={setFactorInput}
allowNegative={false}
decimalScale={6}
min={0.000001}
error={factorError || undefined}
/>
<Text size="xs" c="dimmed">
{selectedObjectCount} 1
</Text>
<Text size="xs" c="dimmed">
{canRepairImage ? "已有" : "暂无"}
</Text>
</Stack>
</CustomModal>
)
}
export default RepairScaleModal

View File

@@ -4,8 +4,6 @@ import React, { useCallback, useEffect, useRef } from "react"
interface ScaleComponentProps {
options: {
offsetX: number
offsetY: number
scale: number
}
mode: "horizontal" | "vertical"
@@ -14,6 +12,7 @@ interface ScaleComponentProps {
const ScaleComponent: React.FC<ScaleComponentProps> = (props) => {
const { options, mode } = props
const canvasRef = useRef<HTMLCanvasElement>(null)
const FLOAT_EPSILON = 0.0000001
const getFixed = (sparsity: number) => {
const pointIdx = String(sparsity).indexOf(".")
@@ -22,8 +21,13 @@ const ScaleComponent: React.FC<ScaleComponentProps> = (props) => {
}
const isCloseToInteger = (num: number) => {
return Math.abs(num - Math.round(num)) < 0.0000001
return Math.abs(num - Math.round(num)) < FLOAT_EPSILON
}
const normalizeScaleValue = (num: number) => {
return Math.abs(num) < FLOAT_EPSILON ? 0 : num
}
const getSparsity = (scale: number) => {
if (scale <= 1) {
return Math.round(100 / scale)
@@ -63,20 +67,30 @@ const ScaleComponent: React.FC<ScaleComponentProps> = (props) => {
ctx.font = "12px serif"
ctx.beginPath()
const { offsetX, offsetY, scale } = options
const offset = mode === "horizontal" ? offsetX : offsetY
const { scale } = options
if (!Number.isFinite(scale) || scale <= 0) {
ctx.restore()
return
}
// 间隔
const sparsity = getSparsity(scale)
// 间隔内有多少条线
const part = 10
const pixelPerUnit = scale * sparsity
const gap = pixelPerUnit / part
const unitGap = sparsity / part
const fixed = getFixed(sparsity)
let index = offset % gap > 0 ? gap - (offset % gap) : -offset % gap
if (mode === "horizontal") {
ctx.translate(31.5, 0)
do {
const num = ((offset + index) / pixelPerUnit) * sparsity
const maxWidth = Math.max(0, w - 32)
for (
let tickIndex = 0;
tickIndex * gap <= maxWidth + FLOAT_EPSILON;
tickIndex += 1
) {
const index = tickIndex * gap
const num = normalizeScaleValue(tickIndex * unitGap)
if (isCloseToInteger(num / sparsity)) {
ctx.moveTo(index, h * 0.5)
ctx.lineTo(index, h)
@@ -87,12 +101,16 @@ const ScaleComponent: React.FC<ScaleComponentProps> = (props) => {
ctx.moveTo(index, h * 0.7)
ctx.lineTo(index, h)
}
index += gap
} while (index < w)
}
} else {
ctx.translate(0, 0.5)
do {
const num = ((offset + index) / pixelPerUnit) * sparsity
for (
let tickIndex = 0;
tickIndex * gap <= h + FLOAT_EPSILON;
tickIndex += 1
) {
const index = tickIndex * gap
const num = normalizeScaleValue(tickIndex * unitGap)
if (isCloseToInteger(num / sparsity)) {
ctx.moveTo(w * 0.5, index)
ctx.lineTo(w, index)
@@ -110,8 +128,7 @@ const ScaleComponent: React.FC<ScaleComponentProps> = (props) => {
ctx.moveTo(w * 0.7, index)
ctx.lineTo(w, index)
}
index += gap
} while (index < h)
}
}
ctx.closePath()
ctx.stroke()
@@ -129,7 +146,7 @@ const ScaleComponent: React.FC<ScaleComponentProps> = (props) => {
if (mode === "horizontal") {
return {
...baseStyles,
width: "100vw",
width: "100%",
height: "32px",
cursor: "col-resize",
}

View File

@@ -1,25 +1,75 @@
import { ActionIcon, Flex, NumberInput, Stack, Text } from "@mantine/core"
import { IconMinus, IconPlus, IconRefresh } from "@tabler/icons-react"
import paper from "paper"
import { useMemo } from "react"
import { useEffect, useMemo, useRef } from "react"
import { useKeyEventStore } from "../store"
import { useBottomToolsStore } from "../useBottomToolsStore"
import { usePaperStore } from "../usePaperStore"
import { useTopToolsStore } from "../useTopToolsStore"
import {
getDisplayedImageScale,
getViewScaleFromDisplayedScale,
} from "../utils/scale"
const ScaleToolContainer = () => {
const { scale, setScale } = useTopToolsStore()
const {
scale,
setScale,
displayedScale: savedDisplayedScale,
setDisplayedScale,
} = useTopToolsStore()
const { setFocusInput } = useKeyEventStore()
const activeImage = useBottomToolsStore((state) => state.activeImage)
const rasterScale = usePaperStore((state) => state.rasterScale[activeImage])
const rasterSize = usePaperStore((state) => state.rasterSize[activeImage])
const paperScope = usePaperStore((state) => state.paperScope)
const group = usePaperStore((state) => state.group)
const lastDisplayedScaleRef = useRef(
Number.isFinite(savedDisplayedScale) && savedDisplayedScale > 0
? savedDisplayedScale
: 1
)
const displayedScale = useMemo(() => {
const hasValidRasterScale =
Number.isFinite(rasterScale) && Number(rasterScale) > 0
if (!activeImage || !hasValidRasterScale) {
if (group) return lastDisplayedScaleRef.current
return Number.isFinite(savedDisplayedScale) && savedDisplayedScale > 0
? savedDisplayedScale
: 1
}
return getDisplayedImageScale({
rasterScale,
viewScale: scale,
})
}, [activeImage, group, rasterScale, savedDisplayedScale, scale])
useEffect(() => {
if (!activeImage) return
if (!Number.isFinite(displayedScale) || displayedScale <= 0) return
lastDisplayedScaleRef.current = displayedScale
if (Math.abs(displayedScale - savedDisplayedScale) > 0.000001) {
setDisplayedScale(displayedScale)
}
}, [activeImage, displayedScale, savedDisplayedScale, setDisplayedScale])
const displayNumber = useMemo(() => {
return Number((scale * 100).toFixed(0))
}, [scale])
return Number((displayedScale * 100).toFixed(0))
}, [displayedScale])
const setCurrentScale = (n: number) => {
const currentScale = n / 100
const currentScale = getViewScaleFromDisplayedScale({
displayedScale: n / 100,
rasterScale,
})
const group = usePaperStore.getState().group
if (!group) return
group.scaling = new paper.Point(currentScale, currentScale)
setScale(currentScale)
setDisplayedScale(n / 100)
}
const handleReset = () => {
@@ -31,9 +81,37 @@ const ScaleToolContainer = () => {
},
})
if (!rasterList || !rasterList.length) return
const currentRaster = rasterList[0]
group.position = currentRaster.position
setCurrentScale(100)
const currentRaster = rasterList.find(
(item): item is paper.Raster => item instanceof paper.Raster
)
if (!currentRaster) return
const rawWidth = rasterSize?.[0] ?? currentRaster.width
const rawHeight = rasterSize?.[1] ?? currentRaster.height
const canvasWidth = paperScope?.view.bounds.width
const canvasHeight = paperScope?.view.bounds.height
const fitDisplayedScale =
rawWidth &&
rawHeight &&
canvasWidth &&
canvasHeight &&
Number.isFinite(canvasWidth) &&
Number.isFinite(canvasHeight)
? Math.min(canvasWidth / rawWidth, canvasHeight / rawHeight)
: getDisplayedImageScale({
rasterScale,
viewScale: 1,
})
const targetViewScale = getViewScaleFromDisplayedScale({
displayedScale: fitDisplayedScale,
rasterScale,
})
group.scaling = new paper.Point(targetViewScale, targetViewScale)
group.position = new paper.Point(
(rawWidth * fitDisplayedScale) / 2,
(rawHeight * fitDisplayedScale) / 2
)
setScale(targetViewScale)
setDisplayedScale(fitDisplayedScale)
}
const handleEnter = (e: any) => {

View File

@@ -59,6 +59,7 @@ import {
SquarePen,
Tag,
Timer,
Wrench,
} from "lucide-react"
import { useRouter } from "next/navigation"
import {
@@ -102,12 +103,13 @@ import {
import { buildSubAttributeFormValues, getSubAttrStatus } from "../util"
import { safeClone } from "../utils/clone"
import { labelTypeMap, TaskStatusEnum } from "../utils/constants"
import { adjustAllPoints } from "../utils/paperjs"
import { adjustAllPoints, adjustPoints } from "../utils/paperjs"
import BackConfirmModal from "./BackConfirmModal"
import BackupModal from "./BackupModal"
import ConfirmModal from "./ConfirmModal"
import { splitWord } from "./EditorContainer"
import RecoverModal from "./RecoverModal"
import RepairScaleModal from "./RepairScaleModal"
import { renderOperationIcon } from "./RightObjectTools"
import TaskCacheModal from "./TaskCacheModal"
import TaskStatusTag from "./TaskStatusTag"
@@ -143,6 +145,8 @@ const initialWorkLoad: WorkLoad = {
question_size: {},
}
const EMPTY_SELECTED_OBJECT_IDS: number[] = []
const TaskTimerDisplay = ({
currentTimeKey,
}: {
@@ -179,6 +183,7 @@ const TopTools = (
const [duplicateConfirmOpen, setDuplicateConfirmOpen] = useState(false)
const [duplicateName, setDuplicateName] = useState("")
const [recoverOpen, setRecoverOpen] = useState(false)
const [repairOpen, setRepairOpen] = useState(false)
const [taskOpen, setTaskOpen] = useState(false)
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
const [operationPickerOpened, setOperationPickerOpened] = useState(false)
@@ -191,6 +196,12 @@ const TopTools = (
const pushStateStack = useLabelStore((state) => state.pushStateStack)
const setLabelTime = useLabelTimeStore((state) => state.setLabelTime)
const { activeImage } = useBottomToolsStore()
const selectedPathMap = useObjectStore((state) => state.selectedPath)
const currentRasterScale = usePaperStore(
(state) => state.rasterScale[activeImage] ?? 1
)
const currentSelectedObjectIds =
selectedPathMap[activeImage] ?? EMPTY_SELECTED_OBJECT_IDS
const {
isView,
setIsView,
@@ -249,6 +260,15 @@ const TopTools = (
useState<number | string>(auxiliarySizeThreshold)
const { pathGroupMap } = useRightToolsStore()
const activeImageLabelData = useMemo(
() => labelData.get(activeImage),
[activeImage, labelData]
)
const repairFactorDefault = useMemo(() => {
return Number.isFinite(currentRasterScale) && currentRasterScale > 0
? Number(currentRasterScale.toFixed(6))
: 1
}, [currentRasterScale])
const isAutoSave = useIntervalStore((state) => state.isAutoSave)
const setIsAutoSave = useIntervalStore((state) => state.setIsAutoSave)
const autoSaveGap = useIntervalStore((state) => state.autoSaveGap)
@@ -953,6 +973,36 @@ const TopTools = (
loadingData,
])
const leaveCurrentPage = useCallback((url: string) => {
// `beforeunload` may still be cancelled by the browser/user, so do not
// mutate persisted label state after requesting a full-page navigation.
window.location.href = url
}, [])
const rerenderActiveImageAnnotations = useCallback(
(
nextLabelData: Map<string, Map<string, [number, any[], any, any[]][]>>
) => {
if (!renderPolygons || !activeImage) return
usePaperStore
.getState()
.group?.getItems({})
.filter((item: any) => item.data?.id)
.forEach((item: any) => {
item.remove()
})
const currentImageMap = nextLabelData.get(activeImage)
if (!currentImageMap) return
for (const key of currentImageMap.keys()) {
renderPolygons(key, currentImageMap)
}
},
[activeImage, renderPolygons]
)
const handleBackup = useCallback(
async (name: string) => {
let newTaskLabelData = new Map()
@@ -1143,6 +1193,102 @@ const TopTools = (
]
)
const handleRepairByFactor = useCallback(
(scope: "object" | "image", factor: number) => {
if (!activeImage) {
notifications.show({
color: "yellow",
message: "当前图片未准备完成,暂时无法修复",
})
return
}
const normalizedFactor = Number(factor)
if (!Number.isFinite(normalizedFactor) || normalizedFactor <= 0) {
notifications.show({
color: "yellow",
message: "请输入大于 0 的修复因子",
})
return
}
const currentImageData = labelData.get(activeImage)
if (!currentImageData?.size) {
notifications.show({
color: "yellow",
message: "当前图片暂无可修复标注数据",
})
return
}
const scaleRatio = 1 / normalizedFactor
let nextLabelData = adjustPoints(activeImage, labelData, scaleRatio)
if (scope === "object") {
const selectedIds =
useObjectStore.getState().selectedPath[activeImage] || []
if (selectedIds.length !== 1) {
notifications.show({
color: "yellow",
message: "请先选中 1 个标注对象后再修复",
})
return
}
const scaledImageData = nextLabelData.get(activeImage)
if (!scaledImageData) {
notifications.show({
color: "yellow",
message: "当前对象修复失败,请重试",
})
return
}
const selectedSet = new Set(selectedIds)
const mergedImageData = new Map<string, [number, any[], any, any[]][]>()
currentImageData.forEach((objects, operationId) => {
const scaledObjects = scaledImageData.get(operationId) || objects
const scaledObjectById = new Map(
scaledObjects.map((item) => [item[0], item] as const)
)
mergedImageData.set(
operationId,
objects.map((item) => {
if (!selectedSet.has(item[0])) return item
return scaledObjectById.get(item[0]) || item
}) as [number, any[], any, any[]][]
)
})
nextLabelData = safeClone(labelData)
nextLabelData.set(activeImage, mergedImageData)
}
const finalData = safeClone(nextLabelData)
setLabel(finalData)
pushStateStack(finalData)
rerenderActiveImageAnnotations(finalData)
setRepairOpen(false)
notifications.show({
color: "green",
message:
scope === "object"
? "已按修复因子更新当前对象,请检查后再保存"
: "已按修复因子更新当前图片,请检查后再保存",
})
},
[
activeImage,
labelData,
pushStateStack,
rerenderActiveImageAnnotations,
setLabel,
]
)
useEffect(() => {
if (needBackup) {
handleBackup("auto_backup")
@@ -1190,10 +1336,7 @@ const TopTools = (
// 无id返回时跳转回任务列表页
const url = backUrl ? basePath + backUrl : "/"
// router.push(url)
window.location.href = url
// 重置图片比例及选中标注对象
usePaperStore.getState().resetRasterScale()
useObjectStore.getState().resetPathAndOperationStatus()
leaveCurrentPage(url)
}
}
@@ -1851,11 +1994,8 @@ const TopTools = (
if (isView) {
const url = backUrl ? basePath + backUrl : "/"
// router.push(url)
window.location.href = url
leaveCurrentPage(url)
// handleBackup("auto_backup");
// 重置图片比例及选中标注对象
usePaperStore.getState().resetRasterScale()
useObjectStore.getState().resetPathAndOperationStatus()
} else {
setConfirmOpen(true)
}
@@ -2541,6 +2681,21 @@ const TopTools = (
<CloudCog style={{ width: 20, height: 20 }} />
<Text size="xs"></Text>
</ActionIcon>
<ActionIcon
display={
process.env.NEXT_PUBLIC_ENV === "development"
? "block"
: "none"
}
variant="transparent"
c="var(--mantine-color-text)"
style={{ width: "auto", display: "flex", gap: 4 }}
onClick={() => {
setRepairOpen(true)
}}>
<Wrench style={{ width: 20, height: 20 }} />
<Text size="xs"></Text>
</ActionIcon>
</>
) : (
<>
@@ -2568,6 +2723,19 @@ const TopTools = (
<CloudCog style={{ width: 20, height: 20 }} />
<Text size="xs"></Text>
</Flex>
<Flex
display={
process.env.NEXT_PUBLIC_ENV === "development"
? "block"
: "none"
}
justify="space-between"
align="center"
gap={4}
style={{ opacity: 0.5, cursor: "not-allowed" }}>
<Wrench style={{ width: 20, height: 20 }} />
<Text size="xs"></Text>
</Flex>
</>
)}
</Flex>
@@ -2658,21 +2826,15 @@ const TopTools = (
setConfirmOpen(false)
const url = backUrl ? basePath + backUrl : "/"
// router.push(url)
window.location.href = url
// 重置图片比例及选中标注对象
usePaperStore.getState().resetRasterScale()
useObjectStore.getState().resetPathAndOperationStatus()
leaveCurrentPage(url)
}}
handleCancel={async () => {
try {
setConfirmOpen(false)
handleBackup("auto_backup")
await handleBackup("auto_backup")
const url = backUrl ? basePath + backUrl : "/"
// router.push(url)
window.location.href = url
// 重置图片比例及选中标注对象
usePaperStore.getState().resetRasterScale()
useObjectStore.getState().resetPathAndOperationStatus()
leaveCurrentPage(url)
} catch (error) {
console.log(error)
}
@@ -2775,6 +2937,23 @@ const TopTools = (
handleOk={handleRecover}
/>
)}
{repairOpen && (
<RepairScaleModal
open={repairOpen}
defaultFactor={repairFactorDefault}
selectedObjectCount={currentSelectedObjectIds.length}
canRepairImage={!!activeImageLabelData?.size}
handleCancel={() => {
setRepairOpen(false)
}}
handleRepairObject={(factor) => {
handleRepairByFactor("object", factor)
}}
handleRepairImage={(factor) => {
handleRepairByFactor("image", factor)
}}
/>
)}
{taskOpen && (
<TaskCacheModal
open={taskOpen}

View File

@@ -62,6 +62,8 @@ interface TopToolsState {
setSaveCurrentScale: (val: boolean) => void
scale: number
setScale: (val: number) => void
displayedScale: number
setDisplayedScale: (val: number) => void
activeOperation: string
setActiveOperation: (val: string) => void
showObjectList: boolean
@@ -114,6 +116,7 @@ const initialTopToolsState = {
drawOption: "default" as "default" | "intersect" | "unite",
saveCurrentScale: false,
scale: 1,
displayedScale: 1,
activeOperation: "",
showObjectList: false,
showGroupList: false,
@@ -248,6 +251,12 @@ export const useTopToolsStore = create<TopToolsState>()(
...state,
scale: val,
})),
displayedScale: initialTopToolsState.displayedScale,
setDisplayedScale: (val: number) =>
set((state: TopToolsState) => ({
...state,
displayedScale: Number.isFinite(val) && val > 0 ? val : 1,
})),
activeOperation: initialTopToolsState.activeOperation,
setActiveOperation: (val: string) =>
set((state: TopToolsState) => ({

View File

@@ -0,0 +1,23 @@
export const normalizeScale = (value: number | null | undefined) => {
return Number.isFinite(value) && Number(value) > 0 ? Number(value) : 1
}
export const getDisplayedImageScale = ({
rasterScale,
viewScale,
}: {
rasterScale?: number | null
viewScale?: number | null
}) => {
return normalizeScale(rasterScale) * normalizeScale(viewScale)
}
export const getViewScaleFromDisplayedScale = ({
displayedScale,
rasterScale,
}: {
displayedScale?: number | null
rasterScale?: number | null
}) => {
return normalizeScale(displayedScale) / normalizeScale(rasterScale)
}