Merge branch 'zh' into 'main'

Zh

See merge request prism/lable/labelimage!17
This commit is contained in:
刘耀勇
2026-04-20 20:26:54 +08:00
11 changed files with 888 additions and 242 deletions

View File

@@ -20,6 +20,7 @@ import {
Button,
Collapse,
Group,
MultiSelect,
Select,
SimpleGrid,
Stack,
@@ -63,10 +64,17 @@ const EMPTY_TASK_SIZE: Task.SizeProps = Object.freeze({
comment_question_size: 0,
})
function createInitialFilters() {
interface FilterFormRecord {
search_id: string
search_status: string
current_uid: string[]
}
function createInitialFilters(): FilterFormRecord {
return {
search_id: "",
search_status: "0",
current_uid: [],
}
}
@@ -342,6 +350,20 @@ export default function OwnTaskTableContainer(props: {
{searchExpanded ? "收起筛选" : "展开筛选"}
</Button>
</Group>
<SettingFilterActions>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={handleReset}>
</Button>
<Button
leftSection={<IconSearch size={16} />}
onClick={handleSearch}
loading={loading}>
</Button>
</SettingFilterActions>
</Group>
<Collapse
in={searchExpanded}
@@ -363,7 +385,6 @@ export default function OwnTaskTableContainer(props: {
search_id: e.target.value,
}))
}
style={{ width: 160 }}
/>
<Select
label="状态"
@@ -379,23 +400,20 @@ export default function OwnTaskTableContainer(props: {
}
allowDeselect={false}
/>
<MultiSelect
label="当前负责人"
data={Array.from(userEnums.allEnum.entries()).map(([x, y]) => ({
label: y,
value: x.toString(),
}))}
searchable
clearable
value={filters.current_uid}
onChange={(v) => setFilters((s) => ({ ...s, current_uid: v }))}
/>
</SimpleGrid>
</Stack>
</Collapse>
<SettingFilterActions>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={handleReset}>
</Button>
<Button
leftSection={<IconSearch size={16} />}
onClick={handleSearch}
loading={loading}>
</Button>
</SettingFilterActions>
</SettingFilterPanel>
<SettingContentPanel

View File

@@ -865,6 +865,20 @@ export default function TaskTableContainer(props: {
{searchExpanded ? "收起筛选" : "展开筛选"}
</Button>
</Group>
<SettingFilterActions>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={handleReset}>
</Button>
<Button
leftSection={<IconSearch size={16} />}
onClick={handleSearch}
loading={loading}>
</Button>
</SettingFilterActions>
</Group>
<Collapse
in={searchExpanded}
@@ -993,20 +1007,6 @@ export default function TaskTableContainer(props: {
</SimpleGrid>
</Stack>
</Collapse>
<SettingFilterActions>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={handleReset}>
</Button>
<Button
leftSection={<IconSearch size={16} />}
onClick={handleSearch}
loading={loading}>
</Button>
</SettingFilterActions>
</SettingFilterPanel>
<SettingContentPanel
style={{

View File

@@ -2156,7 +2156,8 @@ const LabelPage = ({
if (lowerKey === "s" && commandKey) {
e.preventDefault()
setIsConfirmSave(true)
topRef.current?.handleSave()
// setIsConfirmSave(true)
}
if (lowerKey === "g" && commandKey) {
@@ -2192,6 +2193,12 @@ const LabelPage = ({
}
}
if (e.key === "F2") {
e.preventDefault()
paperContainerRef.current?.filterBySizeThreshold?.()
return
}
// 追踪
if (e.key === "i") {
e.preventDefault()
@@ -2585,6 +2592,9 @@ const LabelPage = ({
updateOldWorkLoad={updateOldWorkLoad}
isLabelTask={isLabelTask}
onOperationChange={handleSelectOperation}
onFilterBySizeThreshold={() => {
paperContainerRef.current?.filterBySizeThreshold?.()
}}
renderPolygons={
paperContainerRef.current
? paperContainerRef.current.renderPolygons

View File

@@ -6,6 +6,8 @@ import React, {
useImperativeHandle,
useRef,
} from "react"
import { useBottomToolsStore } from "../useBottomToolsStore"
import { usePaperStore } from "../usePaperStore"
import { useTopToolsStore } from "../useTopToolsStore"
interface AssistShapeComponentProps {
@@ -18,6 +20,10 @@ const AssistShapeComponent = (
) => {
const { size } = props
const scale = useTopToolsStore((state) => state.scale)
const activeImage = useBottomToolsStore((state) => state.activeImage)
const rasterScale = usePaperStore(
(state) => state.rasterScale[activeImage] ?? 1
)
const canvasRef = useRef<HTMLCanvasElement>(null)
const lastPointerRef = useRef<{ x: number; y: number } | null>(null)
@@ -27,7 +33,7 @@ const AssistShapeComponent = (
const ctx = canvas?.getContext("2d")
if (!canvas || !ctx) return
const radius = Math.max(1, size * scale)
const radius = Math.max(1, size * scale * rasterScale)
const side = radius * 2
const left = centerX - radius
const top = centerY - radius
@@ -41,7 +47,7 @@ const AssistShapeComponent = (
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
ctx.stroke()
},
[scale, size]
[rasterScale, scale, size]
)
useEffect(() => {

View File

@@ -46,7 +46,11 @@ import { usePermissionStore } from "../store/auth"
import { useBottomToolsStore } from "../useBottomToolsStore"
import { useKeyboardStore } from "../useKeyBoardStore"
import { useOtherToolsStore } from "../useOtherToolsStore"
import { clearSupportAnnotationItem, usePaperStore } from "../usePaperStore"
import {
clearSupportAnnotationItem,
syncDeletedPathGroupState,
usePaperStore,
} from "../usePaperStore"
import { usePaperSupportStore } from "../usePaperSupportStore"
import { renderGroupPath } from "../useRenderGroupPath"
import useRenderTag from "../useRenderTag"
@@ -55,6 +59,12 @@ import { useTopToolsStore } from "../useTopToolsStore"
import { buildSubAttributeFormValues, checkCommentsIsSame } from "../util"
import { safeClone } from "../utils/clone"
import { labelTypeMap } from "../utils/constants"
import { filterMultiPolygonByAreaThreshold } from "../utils/geometry/areaFilter"
import {
contoursToMultiPolygon,
multiPolygonToStoredContours,
paperShapeToMultiPolygon,
} from "../utils/geometry/booleanAdapter"
import { adjustPoints } from "../utils/paperjs"
import AssistShapeComponent from "./AssistShapeComponent"
import CrosshairComponent from "./CrosshairComponent"
@@ -158,6 +168,7 @@ const SHORTCUT_GUIDE_SECTIONS = [
title: "专项工具",
items: [
["F1", "进入辅助标注 / 在辅助模式下确认"],
["F2", "按尺寸阈值过滤小区域 / 小镂空"],
["I", "执行追踪"],
["P", "QA 文本场景检查错词"],
],
@@ -1040,6 +1051,343 @@ const PaperContainer = (
}
}, [clearPendingFrameSwitch, loadingData])
const getSupportPreviewColors = useCallback(() => {
const operationSchema =
projectDetail?.label_schema_list?.find(
(item) =>
item.category_id === +useTopToolsStore.getState().activeOperation
) || null
return {
strokeColor: operationSchema
? `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})`
: "rgb(0,0,0)",
fillColor: operationSchema
? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)`
: "rgba(0,0,0,0.3)",
blankColor: operationSchema
? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)`
: "rgba(0,0,0,0.01)",
}
}, [projectDetail?.label_schema_list])
const clearSupportPreviewShapeItems = useCallback(() => {
usePaperStore
.getState()
.group?.getItems({
match: (item: paper.Item) =>
item.data?.id === "support" &&
["polygon", "parent"].includes(item.data?.type || ""),
})
.forEach((item) => {
item.remove()
})
}, [])
const renderSupportPreviewGeometry = useCallback(
(geometry: ReturnType<typeof paperShapeToMultiPolygon>) => {
const paperGroup = usePaperStore.getState().group
if (!paperGroup) return
clearSupportPreviewShapeItems()
const { points, hollowPoints } = multiPolygonToStoredContours(geometry)
if (!points.length) return
const { strokeColor, fillColor, blankColor } = getSupportPreviewColors()
const outerPaths = points
.map((contour) => {
if (contour.length <= 3) return null
const path = new paper.Path({
segments: contour.slice(0, -1),
fillColor: blankColor,
strokeColor,
closed: true,
data: {
id: "support",
type: "polygon",
isHollowPolygon: false,
fillColor,
strokeColor,
blankColor,
},
parent: paperGroup,
strokeScaling: false,
})
path.fillColor = new paper.Color(fillColor)
return path
})
.filter((path): path is paper.Path => !!path)
const innerPaths = hollowPoints
.map((contour) => {
if (contour.length <= 3) return null
return new paper.Path({
segments: contour.slice(0, -1),
fillColor: blankColor,
strokeColor,
closed: true,
data: {
id: "support",
type: "polygon",
isHollowPolygon: true,
fillColor,
strokeColor,
blankColor,
},
parent: paperGroup,
strokeScaling: false,
})
})
.filter((path): path is paper.Path => !!path)
const compoundPath = new paper.CompoundPath({
children: [...outerPaths, ...innerPaths],
data: {
id: "support",
type: "parent",
},
strokeColor,
strokeWidth: 2,
fillColor,
strokeScaling: false,
})
compoundPath.parent = paperGroup
},
[clearSupportPreviewShapeItems, getSupportPreviewColors]
)
function rerenderActiveImageObjects() {
const group = usePaperStore.getState().group
if (!group) return
group
.getItems({})
.filter((item) => item.data.id)
.forEach((item) => {
item.remove()
})
const imageData = useLabelStore.getState().label.get(activeImage)
if (!imageData) return
for (const key of imageData.keys()) {
renderPolygons(key, imageData)
}
}
const getMinAreaBySizeThreshold = useCallback((sizeThreshold: number) => {
const normalizedSizeThreshold = Math.max(0, Number(sizeThreshold) || 0)
// Threshold input is already expressed in the visible canvas coordinate
// system, so the area threshold should be size^2 without reapplying
// rasterScale.
return normalizedSizeThreshold * normalizedSizeThreshold
}, [])
function filterSupportPreviewBySizeThreshold(sizeThreshold: number) {
const group = usePaperStore.getState().group
if (!group) return false
const compoundPath = group.getItems({
data: { id: "support", type: "parent" },
})?.[0] as paper.CompoundPath | undefined
if (!compoundPath) return false
const { geometry, removedOuterCount, removedHoleCount } =
filterMultiPolygonByAreaThreshold({
geometry: paperShapeToMultiPolygon(compoundPath),
minArea: getMinAreaBySizeThreshold(sizeThreshold),
})
if (!removedOuterCount && !removedHoleCount) {
notifications.show({
color: "yellow",
message: "辅助结果中未发现小于尺寸阈值的区域或镂空",
})
return true
}
renderSupportPreviewGeometry(geometry)
notifications.show({
color: "green",
message: `已按尺寸阈值过滤 ${removedOuterCount} 个小区域、${removedHoleCount} 个小镂空`,
})
return true
}
function filterSelectedPolygonBySizeThreshold(sizeThreshold: number) {
const selectedIds =
useObjectStore.getState().selectedPath[activeImage] || []
if (!selectedIds.length) {
notifications.show({
color: "yellow",
message: "请先选中一个或多个 polygon 对象",
})
return false
}
const minArea = getMinAreaBySizeThreshold(sizeThreshold)
const nextLabel = safeClone(useLabelStore.getState().label)
let removedOuterCount = 0
let removedHoleCount = 0
let updatedObjectCount = 0
let removedObjectCount = 0
let skippedNonPolygonCount = 0
let skippedMissingCount = 0
const removedPathIds: number[] = []
const removedParentGroupIds: Array<number | undefined> = []
selectedIds.forEach((selectedId) => {
const selectedItems = usePaperStore.getState().getItemsById(selectedId)
const selectedShape =
selectedItems.find((item) => item instanceof paper.CompoundPath) ||
selectedItems.find((item) => item.data?.type === "polygon") ||
null
if (!selectedShape || selectedShape.data?.type !== "polygon") {
skippedNonPolygonCount += 1
return
}
const operationId = String(selectedShape.data?.operationId || "")
if (!operationId) {
skippedMissingCount += 1
return
}
const operationData = nextLabel.get(activeImage)?.get(operationId)
const pathIndex =
operationData?.findIndex((item) => item[0] === selectedId) ?? -1
const oldData = pathIndex >= 0 ? operationData?.[pathIndex] : null
if (!operationData || !oldData) {
skippedMissingCount += 1
return
}
const {
geometry,
removedOuterCount: currentRemovedOuterCount,
removedHoleCount: currentRemovedHoleCount,
} = filterMultiPolygonByAreaThreshold({
geometry: contoursToMultiPolygon({
outerContours: oldData[1],
holeContours: oldData[3],
}),
minArea,
})
if (!currentRemovedOuterCount && !currentRemovedHoleCount) {
return
}
removedOuterCount += currentRemovedOuterCount
removedHoleCount += currentRemovedHoleCount
if (!geometry.length) {
nextLabel.get(activeImage)?.set(
operationId,
operationData.filter((item) => item[0] !== selectedId)
)
removedObjectCount += 1
removedPathIds.push(selectedId)
removedParentGroupIds.push(oldData[2]?.parentGroupId)
return
}
const { points, hollowPoints } = multiPolygonToStoredContours(geometry)
const currentDetail = oldData[2]
const nextDetail = currentDetail
? {
...currentDetail,
first_modified_timestamp:
currentDetail.first_modified_timestamp || dayjs().unix(),
first_modified_uid:
currentDetail.first_modified_uid ||
usePermissionStore.getState().user_id,
last_modified_timestamp: dayjs().unix(),
last_modified_uid: usePermissionStore.getState().user_id,
}
: {
...initialDetail,
create_timestamp: dayjs().unix(),
}
operationData[pathIndex] = [selectedId, points, nextDetail, hollowPoints]
updatedObjectCount += 1
})
if (!updatedObjectCount && !removedObjectCount) {
notifications.show({
color: skippedNonPolygonCount ? "yellow" : "yellow",
message:
skippedNonPolygonCount === selectedIds.length
? "当前选中对象仅 polygon 支持尺寸过滤"
: "当前选中对象中未发现小于尺寸阈值的区域或镂空",
})
return true
}
if (removedPathIds.length) {
syncDeletedPathGroupState({
imageId: activeImage,
removedPathIds,
removedParentGroupIds,
labelData: nextLabel,
})
removedPathIds.forEach((pathId) => {
useObjectStore
.getState()
.updateSelectedPath(activeImage, "DELETE", pathId)
})
}
useLabelStore.getState().setLabel(nextLabel)
useLabelStore.getState().pushStateStack(nextLabel)
rerenderActiveImageObjects()
const summaryParts = [
`已按尺寸阈值过滤 ${updatedObjectCount + removedObjectCount} 个 polygon`,
`删除 ${removedOuterCount} 个小区域`,
`删除 ${removedHoleCount} 个小镂空`,
]
if (removedObjectCount) {
summaryParts.push(`移除 ${removedObjectCount} 个空对象`)
}
if (skippedNonPolygonCount) {
summaryParts.push(`跳过 ${skippedNonPolygonCount} 个非 polygon 对象`)
}
if (skippedMissingCount) {
summaryParts.push(`忽略 ${skippedMissingCount} 个异常对象`)
}
notifications.show({
color: "green",
message: summaryParts.join(""),
})
return true
}
function filterBySizeThreshold() {
const sizeThreshold = Number(
useTopToolsStore.getState().auxiliarySizeThreshold
)
if (!Number.isFinite(sizeThreshold) || sizeThreshold <= 0) {
notifications.show({
color: "yellow",
message: "请先设置大于 0 的尺寸阈值",
})
return
}
if (filterSupportPreviewBySizeThreshold(sizeThreshold)) {
return
}
filterSelectedPolygonBySizeThreshold(sizeThreshold)
}
const renderSupportAnnotation = useCallback(
async (
points: Array<[number, number]>,
@@ -1055,20 +1403,7 @@ const PaperContainer = (
loading: true,
autoClose: false,
})
const operationSchema =
projectDetail?.label_schema_list?.find(
(item) =>
item.category_id === +useTopToolsStore.getState().activeOperation
) || null
const strokeColor = operationSchema
? `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})`
: "rgb(0,0,0)"
const fillColor = operationSchema
? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)`
: "rgba(0,0,0,0.3)"
const blankColor = operationSchema
? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)`
: "rgba(0,0,0,0.01)"
// const { strokeColor, fillColor, blankColor } = getSupportPreviewColors()
const currentScale =
usePaperStore.getState().rasterScale[activeImage] ?? 1
@@ -1141,13 +1476,11 @@ const PaperContainer = (
if (!promptPoints.length && !normalizedBoxes.length) {
throw new Error("缺少有效提示,无法调用模型接口")
}
// const picSize = usePaperStore.getState().rasterSize[activeImage]
try {
const data = await getAuxiliaryAnnotation({
project_id: projectId,
image_name: activeImage,
// project_id: "image_test_bk",
prompt: {
...(promptPoints.length
? { foreground_points: promptPoints }
@@ -1160,50 +1493,31 @@ const PaperContainer = (
contour_approximation: 0,
})
const { contours = [], hollows = [] } = data || {}
const outerPaths: any[] = []
const innerPaths: any[] = []
contours.forEach((contour: any, index: number) => {
const normalizedContour = normalizeContour(contour)
if (normalizedContour.length > 3) {
const path = new paper.Path({
segments: normalizedContour.map(([x, y]) => [
x * currentScale,
y * currentScale,
]),
fillColor: blankColor,
strokeColor: strokeColor,
closed: true,
data: {
id: "support",
type: "polygon",
isHollowPolygon: false,
fillColor,
strokeColor,
blankColor,
},
parent: usePaperStore.getState().group,
strokeScaling: false,
const geometry = contoursToMultiPolygon({
outerContours: contours
.map((contour: any, index: number) => {
if (hollows[index]) return null
return normalizeContour(contour).map(
([x, y]) =>
[x * currentScale, y * currentScale] as [number, number]
)
})
if (hollows[index]) {
path.data.isHollowPolygon = true
innerPaths.push(path)
} else {
path.fillColor = new paper.Color(fillColor)
outerPaths.push(path)
}
}
.filter(
(contour): contour is Array<[number, number]> => !!contour
),
holeContours: contours
.map((contour: any, index: number) => {
if (!hollows[index]) return null
return normalizeContour(contour).map(
([x, y]) =>
[x * currentScale, y * currentScale] as [number, number]
)
})
const compoundPath = new paper.CompoundPath({
children: [...outerPaths, ...innerPaths],
data: {
id: "support",
type: "parent",
},
strokeColor: strokeColor,
strokeWidth: 2,
fillColor: fillColor,
.filter(
(contour): contour is Array<[number, number]> => !!contour
),
})
compoundPath.parent = usePaperStore.getState().group!
renderSupportPreviewGeometry(geometry)
notifications.show({
id: "sam2",
message: "模型生成成功",
@@ -1228,7 +1542,12 @@ const PaperContainer = (
}
}
},
[activeImage, projectDetail]
[
activeImage,
// getSupportPreviewColors,
projectDetail,
renderSupportPreviewGeometry,
]
)
// const renderTrackingAnnotation = useCallback(async () => {
@@ -4153,6 +4472,7 @@ const PaperContainer = (
renderTrackingAnnotation,
renderSupportAnnotation,
saveSupportAnnotationData,
filterBySizeThreshold,
copyAnnotationDataToMultiFrame,
}))

View File

@@ -8,8 +8,8 @@ import {
Collapse,
Flex,
Group,
HoverCard,
Paper,
Popover,
ScrollArea,
Stack,
Text,
@@ -31,6 +31,7 @@ import {
} from "lucide-react"
import paper from "paper"
import { useCallback, useMemo, useState } from "react"
import { Comment } from "../api/label/typing"
import { Project } from "../api/project/typing"
import { Task } from "../api/task/typing"
import { LabelState } from "../LabelNossr"
@@ -141,6 +142,65 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
}
}, [])
const getCommentTextColor = useCallback((type: number) => {
switch (type) {
case 3:
return "yellow.9"
case 2:
return "red.7"
case 1:
return "green.7"
case 0:
return "gray.7"
default:
return "blue.7"
}
}, [])
const getUserLabel = useCallback(
(uid?: number) => {
if (uid === undefined || uid === null) return "-"
return (
userOpts.find((item) => item.value === uid)?.label || uid.toString()
)
},
[userOpts]
)
const getCommentCount = useCallback((commentItem: Comment) => {
const checkBoxComments = (commentItem.check_box_comments || []).filter(
(item) => item?.trim()
)
const textComments = (commentItem.text_comments || []).filter((item) =>
item?.trim()
)
return checkBoxComments.length + textComments.length
}, [])
const getCommentContent = useCallback((commentItem: Comment) => {
const mergedComments = [
...(commentItem.check_box_comments || []),
...(commentItem.text_comments || []),
].filter((item) => item?.trim())
return mergedComments.length ? mergedComments.join(",") : "无批注"
}, [])
const getCommentDisplayType = useCallback(
(commentItem: Comment, lastModifiedTimestamp?: number) => {
const type = Math.max(0, Math.min(3, commentItem.comment_type ?? 0))
if (
type === 2 &&
lastModifiedTimestamp &&
lastModifiedTimestamp > commentItem.date &&
getCommentCount(commentItem) > 0
) {
return 3
}
return type
},
[getCommentCount]
)
const { shift: pressShift } = useKeyEventStore()
const continuePolygonTarget = usePaperStore(
@@ -696,15 +756,10 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
comment.length === 1
? comment[comment.length - 1]
: comment[comment.length - 2]
let type = latest_comment.comment_type
if (type === 2) {
const { last_modified_timestamp } = child[2]
if (
last_modified_timestamp &&
last_modified_timestamp > latest_comment.date
const type = getCommentDisplayType(
latest_comment,
child[2].last_modified_timestamp
)
type = 3
}
visibleFlag = checkBoxsChecked[3 - type]
}
if (checkBoxsChecked.every((value) => !value))
@@ -954,12 +1009,7 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
<Group gap={4}>
<Text size="xs"></Text>
<Text size="xs">
{
userOpts.find(
(item) =>
item.value === child[2]?.uid
)?.label
}
{getUserLabel(child[2]?.uid)}
</Text>
<Text size="xs">
{dayjs(
@@ -972,14 +1022,9 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
<Group gap={4}>
<Text size="xs"></Text>
<Text size="xs">
{
userOpts.find(
(item) =>
item.value ===
child[2]
?.first_modified_uid
)?.label
}
{getUserLabel(
child[2]?.first_modified_uid
)}
</Text>
<Text size="xs">
{dayjs(
@@ -994,14 +1039,9 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
<Group gap={4}>
<Text size="xs"></Text>
<Text size="xs">
{
userOpts.find(
(item) =>
item.value ===
child[2]
?.last_modified_uid
)?.label
}
{getUserLabel(
child[2]?.last_modified_uid
)}
</Text>
<Text size="xs">
{dayjs(
@@ -1027,33 +1067,28 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
</Group>
{comment ? (
<Group gap={4}>
{comment.map((c: any) => {
const count =
c.check_box_comments.length +
c.text_comments.length
const { last_modified_timestamp } =
child[2]
let flag = false
if (
count > 0 &&
last_modified_timestamp &&
last_modified_timestamp > c.date
{comment.map((c: Comment) => {
const count = getCommentCount(c)
const commentType =
getCommentDisplayType(
c,
child[2]?.last_modified_timestamp
)
flag = true
let badgeColor = "gray"
if (c.comment_type === 1)
badgeColor = "green"
if (c.comment_type === 2)
badgeColor = "red"
if (flag) badgeColor = "yellow"
const badgeColor =
getCheckBoxColor(commentType)
const commentTextColor =
getCommentTextColor(commentType)
return (
<Popover
<HoverCard
key={`${child[0]}${c.turn}${c.times}`}
position="left"
withArrow>
<Popover.Target>
withArrow
shadow="md"
openDelay={100}
closeDelay={100}
withinPortal>
<HoverCard.Target>
<Badge
size="xs"
circle
@@ -1063,16 +1098,14 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
}}>
{count}
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Text size="xs" c={badgeColor}>
{`P${child[2].image_id}${
child[2].uid
}${c.check_box_comments.join(
","
)},${c.text_comments.join(
","
)} ${
</HoverCard.Target>
<HoverCard.Dropdown>
<Text
size="xs"
c={commentTextColor}>
{`P${child[2].image_id}${getUserLabel(
c.uid
)}${getCommentContent(c)} ${
c.date
? dayjs(
c.date * 1000
@@ -1082,8 +1115,8 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
: ""
}`}
</Text>
</Popover.Dropdown>
</Popover>
</HoverCard.Dropdown>
</HoverCard>
)
})}
</Group>

View File

@@ -25,6 +25,7 @@ import {
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Project } from "../api/project/typing"
import { Task } from "../api/task/typing"
import { getTaskList } from "../api/task"
import { useObjectStore } from "../store"
import type { Option } from "../store/auth"
@@ -38,11 +39,54 @@ interface RightTaskToolsComponentProps {
task_id: number
}
interface FilterOption {
label: string
value: string
}
interface FilterOptions {
all_user: Option[]
label_user: Option[]
review_user1: Option[]
review_user2: Option[]
all_user: FilterOption[]
label_user: FilterOption[]
review_user1: FilterOption[]
review_user2: FilterOption[]
}
interface FilterFormValues {
label_status: string[]
current_uid: string[]
label_user: string[]
review_user1: string[]
review_user2: string[]
rejected: string | null
id: string
}
type TaskListFilterParams = Partial<
Pick<
Task.ListRequest,
| "label_status"
| "current_uid"
| "label_user"
| "review_user1"
| "review_user2"
| "rejected"
| "id"
>
>
const normalizeFilterOption = (option: Option): FilterOption | null => {
if (option.value === null || option.value === undefined) return null
return {
label: option.label,
value: String(option.value),
}
}
const pushUniqueOption = (options: FilterOption[], option: FilterOption) => {
if (!options.some((item) => item.value === option.value)) {
options.push(option)
}
}
const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
@@ -57,11 +101,12 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
const { userOpts } = useAllUserStore()
const [taskList, setTaskList] = useState<any[]>([])
const [taskList, setTaskList] = useState<Task.DataProps[]>([])
const [activePage, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filterOpened, setFilterOpened] = useState(false)
const filterForm = useForm({
const filterForm = useForm<FilterFormValues>({
initialValues: {
label_status: [],
current_uid: [],
@@ -72,32 +117,25 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
id: "",
},
})
const [filterParams, setFilterParams] = useState<any>({})
const isParamsEmpty: boolean = JSON.stringify(filterParams) === "{}"
const [filterParams, setFilterParams] = useState<TaskListFilterParams>({})
const isParamsEmpty = Object.keys(filterParams).length === 0
const getListData = useCallback(async () => {
let pg_num = 0,
pg_size = 1000
let total = 0
let tasksCount = 1000000
let finalData: any[] = []
let tasksCount = Number.POSITIVE_INFINITY
let finalData: Task.DataProps[] = []
while (total < tasksCount) {
pg_num += 1
const params = filterParams
? {
const params: Task.ListRequest = {
project_id: [projectId],
get_data: true,
page_number: pg_num,
page_size: pg_size,
...filterParams,
}
: {
project_id: [projectId],
get_data: true,
page_number: pg_num,
page_size: pg_size,
}
const res = await getTaskList(params)
const { total_items, task_list } = res
finalData = [...finalData, ...task_list]
@@ -112,6 +150,18 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
queueMicrotask(() => getListData())
}, [getListData])
const normalizedUserOpts = useMemo(() => {
return userOpts.reduce<FilterOption[]>((result, item) => {
const normalizedOption = normalizeFilterOption(item)
if (normalizedOption) {
result.push(normalizedOption)
}
return result
}, [])
}, [userOpts])
const filterUserOpts = useMemo(() => {
const opts: FilterOptions = {
all_user: [],
@@ -121,29 +171,33 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
}
if (!projectDetail) return opts
const steps = projectDetail.label_process.step_detail
const normalizedUserMap = new Map(
normalizedUserOpts.map((item) => [item.value, item])
)
const steps = projectDetail.label_process.step_detail ?? []
steps.forEach(({ action, user_list }) => {
let users: any[] = []
user_list.forEach((v) => {
userOpts.forEach((item) => {
if (item.value === v) {
users.push(item)
const flag = opts.all_user.find((u) => u.value === v)
if (!flag) opts.all_user.push(item)
}
})
const users: FilterOption[] = []
user_list.forEach((userId) => {
const matchedUser = normalizedUserMap.get(String(userId))
if (!matchedUser) return
pushUniqueOption(users, matchedUser)
pushUniqueOption(opts.all_user, matchedUser)
})
if (action === 1) {
opts.label_user.push(...users)
users.forEach((user) => pushUniqueOption(opts.label_user, user))
} else if (action === 2) {
opts.review_user1.push(...users)
users.forEach((user) => pushUniqueOption(opts.review_user1, user))
} else if (action === 3) {
opts.review_user2.push(...users)
users.forEach((user) => pushUniqueOption(opts.review_user2, user))
}
})
return opts
}, [projectDetail, userOpts])
}, [normalizedUserOpts, projectDetail])
const handleTurnPrevious = () => {
const index = taskList.findIndex((task) => task.id === taskId)
@@ -185,12 +239,14 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
label: value,
value: key,
}))}
comboboxProps={{ withinPortal: false }}
{...filterForm.getInputProps("label_status")}
clearable
/>
<MultiSelect
label="当前负责人"
data={filterUserOpts.all_user}
comboboxProps={{ withinPortal: false }}
{...filterForm.getInputProps("current_uid")}
searchable
clearable
@@ -198,6 +254,7 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
<MultiSelect
label="标注员"
data={filterUserOpts.label_user}
comboboxProps={{ withinPortal: false }}
{...filterForm.getInputProps("label_user")}
searchable
clearable
@@ -205,6 +262,7 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
<MultiSelect
label="审核员"
data={filterUserOpts.review_user1}
comboboxProps={{ withinPortal: false }}
{...filterForm.getInputProps("review_user1")}
searchable
clearable
@@ -212,6 +270,7 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
<MultiSelect
label="复审员"
data={filterUserOpts.review_user2}
comboboxProps={{ withinPortal: false }}
{...filterForm.getInputProps("review_user2")}
searchable
clearable
@@ -222,6 +281,7 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
{ label: "是", value: "1" },
{ label: "否", value: "0" },
]}
comboboxProps={{ withinPortal: false }}
{...filterForm.getInputProps("rejected")}
clearable
/>
@@ -237,23 +297,36 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
filterForm.reset()
setFilterParams({})
setPage(1)
setFilterOpened(false)
}}>
</Button>
<Button
onClick={() => {
const obj = filterForm.values
const params: any = {}
const params: TaskListFilterParams = {}
if (obj.label_status?.length)
params.label_status = obj.label_status.map(Number)
if (obj.current_uid?.length) params.current_uid = obj.current_uid
if (obj.label_user?.length) params.label_user = obj.label_user
if (obj.review_user1?.length) params.review_user1 = obj.review_user1
if (obj.review_user2?.length) params.review_user2 = obj.review_user2
if (obj.rejected) params.rejected = Number(obj.rejected)
if (obj.id) params.id = obj.id.trim()
if (obj.current_uid?.length)
params.current_uid = obj.current_uid.map(Number)
if (obj.label_user?.length)
params.label_user = obj.label_user.map(Number)
if (obj.review_user1?.length)
params.review_user1 = obj.review_user1.map(Number)
if (obj.review_user2?.length)
params.review_user2 = obj.review_user2.map(Number)
if (obj.rejected !== null)
params.rejected = Number(obj.rejected) === 1
if (obj.id.trim()) {
const taskId = Number(obj.id.trim())
if (Number.isFinite(taskId)) {
params.id = [taskId]
}
}
setFilterParams(params)
setPage(1)
setFilterOpened(false)
}}>
</Button>
@@ -276,12 +349,18 @@ const RightTaskTools: React.FC<RightTaskToolsComponentProps> = (props) => {
<IconChevronRight size={16} />
</ActionIcon>
</Group>
<Popover position="bottom-end" withArrow shadow="md">
<Popover
position="bottom-end"
withArrow
shadow="md"
opened={filterOpened}
onChange={setFilterOpened}>
<Popover.Target>
<ActionIcon
variant="subtle"
size="sm"
color={!isParamsEmpty ? "blue" : "gray"}>
color={!isParamsEmpty ? "blue" : "gray"}
onClick={() => setFilterOpened((opened) => !opened)}>
<IconFilter size={16} />
</ActionIcon>
</Popover.Target>

View File

@@ -120,6 +120,7 @@ interface TopToolsComponentProps {
updateOldWorkLoad: (data: WorkLoad) => void
isLabelTask: boolean
onOperationChange: (operationId: string) => void
onFilterBySizeThreshold?: () => void
renderPolygons: (
operationId: string,
imageData: Map<string, [number, any[], any, any[]][]> | undefined
@@ -164,6 +165,7 @@ const TopTools = (
updateOldWorkLoad,
isLabelTask,
onOperationChange,
onFilterBySizeThreshold,
renderPolygons,
} = props
const { backUrl, basePath } = useBackUrlStore()
@@ -182,6 +184,9 @@ const TopTools = (
const [operationPickerOpened, setOperationPickerOpened] = useState(false)
const [operationKeyword, setOperationKeyword] = useState("")
const labelData = useLabelStore((state) => state.label)
const labelDefaultComments = useLabelStore(
(state) => state.labelDefaultComments
)
const setLabel = useLabelStore((state) => state.setLabel)
const pushStateStack = useLabelStore((state) => state.pushStateStack)
const setLabelTime = useLabelTimeStore((state) => state.setLabelTime)
@@ -230,6 +235,8 @@ const TopTools = (
setAssistToolEnabled,
assistToolSize,
setAssistToolSize,
auxiliarySizeThreshold,
setAuxiliarySizeThreshold,
nodeSize,
setNodeSize,
needBackup,
@@ -238,6 +245,8 @@ const TopTools = (
setMagnetFlag,
} = useTopToolsStore()
const [nodeSizeInput, setNodeSizeInput] = useState<number | string>(nodeSize)
const [auxiliarySizeThresholdInput, setAuxiliarySizeThresholdInput] =
useState<number | string>(auxiliarySizeThreshold)
const { pathGroupMap } = useRightToolsStore()
const isAutoSave = useIntervalStore((state) => state.isAutoSave)
@@ -261,6 +270,10 @@ const TopTools = (
usePaperStore.getState().refreshNodeVisualSize()
}, [nodeSize])
useEffect(() => {
setAuxiliarySizeThresholdInput(auxiliarySizeThreshold)
}, [auxiliarySizeThreshold])
const toggleRightPanel = useCallback(
(visible: boolean, setter: (val: boolean) => void) => {
const nextVisible = !visible
@@ -1025,10 +1038,41 @@ const TopTools = (
})
return
}
const finalData = adjustAllPoints(
const recoveredData = adjustAllPoints(
backupData,
usePaperStore.getState().rasterScale
)
const finalData = safeClone(recoveredData)
for (const [imageId, categoryMap] of finalData.entries()) {
const currentDetailById = new Map<number, any>()
labelData.get(imageId)?.forEach((objects) => {
objects.forEach(([id, _points, detail]) => {
currentDetailById.set(id, detail)
})
})
for (const [operationId, objects] of categoryMap.entries()) {
categoryMap.set(
operationId,
objects.map(([id, points, detail, hollowPoints]) => {
const currentDetail = currentDetailById.get(id)
return [
id,
points,
{
...detail,
comment: currentDetail?.comment
? safeClone(currentDetail.comment)
: safeClone(labelDefaultComments),
last_modified_timestamp:
currentDetail?.last_modified_timestamp ??
detail.last_modified_timestamp,
},
hollowPoints,
]
}) as any
)
}
}
for (const entry of finalData.entries()) {
// 赋值用过的id
for (const category of entry[1].values()) {
@@ -1089,6 +1133,8 @@ const TopTools = (
},
[
activeImage,
labelData,
labelDefaultComments,
projectDetail?.label_type,
pushStateStack,
renderPolygons,
@@ -2186,6 +2232,7 @@ const TopTools = (
</Flex>
</Flex>
<Flex justify="space-between" align="center" px="xs" gap="xs">
<Flex align="center" gap="xs" wrap="nowrap">
<Tooltip label={`节点直径范围 ${NODE_SIZE_MIN}-${NODE_SIZE_MAX}`}>
<Flex align="center" gap={4}>
<Text span size="xs" style={{ minWidth: "fit-content" }}>
@@ -2219,6 +2266,50 @@ const TopTools = (
/>
</Flex>
</Tooltip>
<Tooltip
label={`按当前可见画布尺寸阈值过滤辅助结果或选中 polygonF2面积阈值 = size² = ${(Number(auxiliarySizeThresholdInput) || 0) ** 2}`}>
<Flex align="center" gap={4} wrap="nowrap">
<Text span size="xs" style={{ minWidth: "fit-content" }}>
</Text>
<NumberInput
value={auxiliarySizeThresholdInput}
w={68}
min={0}
step={1}
allowDecimal={false}
size="xs"
radius="sm"
hideControls
onChange={(value) => {
setAuxiliarySizeThresholdInput(value)
}}
onFocus={() => {
useKeyEventStore.getState().setFocusInput(true)
}}
onBlur={(event) => {
useKeyEventStore.getState().setFocusInput(false)
const rawValue = event.currentTarget.value.trim()
const nextValue = rawValue === "" ? 0 : Number(rawValue)
setAuxiliarySizeThreshold(
Number.isFinite(nextValue) ? nextValue : 0
)
}}
/>
<Button
size="xs"
variant="light"
px={8}
onClick={() => {
const nextValue = Number(auxiliarySizeThresholdInput) || 0
setAuxiliarySizeThreshold(nextValue)
onFilterBySizeThreshold?.()
}}>
</Button>
</Flex>
</Tooltip>
</Flex>
{renderEditIcon}
<Button
w="fit-content"

View File

@@ -688,7 +688,7 @@ const clearPathParentGroupMetadata = (
return changed
}
const syncDeletedPathGroupState = ({
export const syncDeletedPathGroupState = ({
imageId,
removedPathIds,
removedParentGroupIds,
@@ -866,6 +866,17 @@ const cleanupDeletedObjectItems = (
syncSelectedObjectsVisualState(imageId)
}
export const removePaperObjectById = (
imageId: string,
pathId?: number,
parentGroupId?: number,
options: {
pushState?: boolean
} = {}
) => {
cleanupDeletedObjectItems(imageId, pathId, parentGroupId, options)
}
const handleShift = (flag: boolean) => {
useKeyEventStore.getState().setShift(flag)
}

View File

@@ -45,6 +45,8 @@ interface TopToolsState {
setAssistToolEnabled: (val: boolean) => void
assistToolSize: number
setAssistToolSize: (val: number) => void
auxiliarySizeThreshold: number
setAuxiliarySizeThreshold: (val: number) => void
nodeSize: number
setNodeSize: (val: number) => void
showTags: boolean
@@ -107,6 +109,7 @@ const initialTopToolsState = {
crosshairStatus: "hidden" as "hidden" | "line" | "carve",
assistToolEnabled: false,
assistToolSize: 10,
auxiliarySizeThreshold: 0,
nodeSize: DEFAULT_NODE_SIZE,
drawOption: "default" as "default" | "intersect" | "unite",
saveCurrentScale: false,
@@ -197,6 +200,12 @@ export const useTopToolsStore = create<TopToolsState>()(
...state,
assistToolSize: Math.max(1, val),
})),
auxiliarySizeThreshold: initialTopToolsState.auxiliarySizeThreshold,
setAuxiliarySizeThreshold: (val) =>
set((state: TopToolsState) => ({
...state,
auxiliarySizeThreshold: Math.max(0, Number(val) || 0),
})),
nodeSize: initialTopToolsState.nodeSize,
setNodeSize: (val) =>
set((state: TopToolsState) => ({

View File

@@ -0,0 +1,69 @@
import {
normalizeMultiPolygon,
type NormalizedMultiPolygon,
type NormalizedRing,
} from "./booleanAdapter"
export interface AreaFilterResult {
geometry: NormalizedMultiPolygon
removedOuterCount: number
removedHoleCount: number
}
const getRingArea = (ring: NormalizedRing) => {
let area = 0
for (let index = 0; index < ring.length - 1; index += 1) {
const [x1, y1] = ring[index]
const [x2, y2] = ring[index + 1]
area += x1 * y2 - x2 * y1
}
return Math.abs(area / 2)
}
export const filterMultiPolygonByAreaThreshold = ({
geometry,
minArea,
}: {
geometry: NormalizedMultiPolygon | null | undefined
minArea: number
}): AreaFilterResult => {
const normalizedGeometry = normalizeMultiPolygon(geometry)
const normalizedMinArea = Math.max(0, Number(minArea) || 0)
if (!normalizedMinArea) {
return {
geometry: normalizedGeometry,
removedOuterCount: 0,
removedHoleCount: 0,
}
}
let removedOuterCount = 0
let removedHoleCount = 0
const nextGeometry = normalizedGeometry.flatMap((polygon) => {
const [outerRing, ...holeRings] = polygon
if (!outerRing) return []
if (getRingArea(outerRing) < normalizedMinArea) {
removedOuterCount += 1
return []
}
const nextHoleRings = holeRings.filter((ring) => {
const keep = getRingArea(ring) >= normalizedMinArea
if (!keep) {
removedHoleCount += 1
}
return keep
})
return [[outerRing, ...nextHoleRings]]
})
return {
geometry: nextGeometry,
removedOuterCount,
removedHoleCount,
}
}