Merge commit '04d530d692e91c917e9b2626333619aa8cc374e2' into label
This commit is contained in:
52
auxiliary_area_threshold_plan.md
Normal file
52
auxiliary_area_threshold_plan.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 辅助标注尺寸阈值过滤实施记录
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
在 `F1 -> 点选/框选 -> 请求 getAuxiliaryAnnotation -> 生成辅助轮廓 -> F1 确认生成对象` 的流程里,接口可能返回大量很小的区域或小镂空,影响最终对象质量与后续编辑效率。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
- 在工具栏提供尺寸阈值输入框。
|
||||||
|
- 提供按钮与快捷键,对小于阈值的区域/镂空进行过滤。
|
||||||
|
- 该能力既能用于 `F1` 生成后的辅助预览,也能用于已确认生成并被选中的 `polygon` 对象。
|
||||||
|
- 不影响正常标注流程。
|
||||||
|
|
||||||
|
## 链路分析
|
||||||
|
1. `LabelNossr.tsx`
|
||||||
|
- `F1` 进入 `support` 模式。
|
||||||
|
- 再次 `F1` 时调用 `PaperContainer.saveSupportAnnotationData()` 完成确认。
|
||||||
|
2. `usePaperStore.ts`
|
||||||
|
- `supportTool` 收集点/框提示,调用 `PaperContainer.renderSupportAnnotation()`。
|
||||||
|
3. `PaperContainer.tsx`
|
||||||
|
- `renderSupportAnnotation()` 请求 `getAuxiliaryAnnotation()`。
|
||||||
|
- 接口返回 `contours/hollows` 后,在画布上生成 `support` 预览。
|
||||||
|
- `saveSupportAnnotationData()` 将 `support` 预览转成正式标注对象。
|
||||||
|
|
||||||
|
## 本次实现
|
||||||
|
### 已完成
|
||||||
|
- 在 `components/label/useTopToolsStore.ts` 增加 `auxiliarySizeThreshold` 状态。
|
||||||
|
- 新增 `components/label/utils/geometry/areaFilter.ts`,用于按尺寸过滤 multipolygon 外轮廓与洞。
|
||||||
|
- 在 `components/label/components/TopTools.tsx` 增加:
|
||||||
|
- 尺寸阈值输入框
|
||||||
|
- `过滤` 按钮
|
||||||
|
- 在 `components/label/LabelNossr.tsx` 增加 `F2` 快捷键,触发尺寸过滤。
|
||||||
|
- 在 `components/label/components/PaperContainer.tsx` 增加:
|
||||||
|
- 辅助预览 geometry 重绘能力
|
||||||
|
- 对 `support` 预览执行尺寸过滤
|
||||||
|
- 对已选中 `polygon` 对象执行尺寸过滤
|
||||||
|
- 若过滤后对象为空,则直接删除对象
|
||||||
|
- 快捷键说明中补充 `F2` 文案。
|
||||||
|
|
||||||
|
### 当前行为
|
||||||
|
- 若当前存在 `support` 预览,`F2` / `过滤` 优先作用于辅助预览。
|
||||||
|
- 若当前没有 `support` 预览,则对当前选中的一个或多个 `polygon` 对象执行批量过滤。
|
||||||
|
- 输入值含义为“尺寸阈值”,实际面积阈值为“尺寸阈值²”。
|
||||||
|
- 小外轮廓会被删除;小镂空会被填平。
|
||||||
|
- 阈值需大于 `0` 才会生效。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
- 已执行:`pnpm exec tsc --noEmit`
|
||||||
|
- 结果:通过
|
||||||
|
|
||||||
|
## 后续可选项
|
||||||
|
- 支持一次过滤多个已选中 polygon 对象。
|
||||||
|
- 在工具栏中增加更明确的阈值单位说明。
|
||||||
|
- 如果需要,可再补一轮手动回归用例清单。
|
||||||
@@ -2192,6 +2192,12 @@ const LabelPage = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.key === "F2") {
|
||||||
|
e.preventDefault()
|
||||||
|
paperContainerRef.current?.filterBySizeThreshold?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 追踪
|
// 追踪
|
||||||
if (e.key === "i") {
|
if (e.key === "i") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -2585,6 +2591,9 @@ const LabelPage = ({
|
|||||||
updateOldWorkLoad={updateOldWorkLoad}
|
updateOldWorkLoad={updateOldWorkLoad}
|
||||||
isLabelTask={isLabelTask}
|
isLabelTask={isLabelTask}
|
||||||
onOperationChange={handleSelectOperation}
|
onOperationChange={handleSelectOperation}
|
||||||
|
onFilterBySizeThreshold={() => {
|
||||||
|
paperContainerRef.current?.filterBySizeThreshold?.()
|
||||||
|
}}
|
||||||
renderPolygons={
|
renderPolygons={
|
||||||
paperContainerRef.current
|
paperContainerRef.current
|
||||||
? paperContainerRef.current.renderPolygons
|
? paperContainerRef.current.renderPolygons
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ import { usePermissionStore } from "../store/auth"
|
|||||||
import { useBottomToolsStore } from "../useBottomToolsStore"
|
import { useBottomToolsStore } from "../useBottomToolsStore"
|
||||||
import { useKeyboardStore } from "../useKeyBoardStore"
|
import { useKeyboardStore } from "../useKeyBoardStore"
|
||||||
import { useOtherToolsStore } from "../useOtherToolsStore"
|
import { useOtherToolsStore } from "../useOtherToolsStore"
|
||||||
import { clearSupportAnnotationItem, usePaperStore } from "../usePaperStore"
|
import {
|
||||||
|
clearSupportAnnotationItem,
|
||||||
|
syncDeletedPathGroupState,
|
||||||
|
usePaperStore,
|
||||||
|
} from "../usePaperStore"
|
||||||
import { usePaperSupportStore } from "../usePaperSupportStore"
|
import { usePaperSupportStore } from "../usePaperSupportStore"
|
||||||
import { renderGroupPath } from "../useRenderGroupPath"
|
import { renderGroupPath } from "../useRenderGroupPath"
|
||||||
import useRenderTag from "../useRenderTag"
|
import useRenderTag from "../useRenderTag"
|
||||||
@@ -55,6 +59,12 @@ import { useTopToolsStore } from "../useTopToolsStore"
|
|||||||
import { buildSubAttributeFormValues, checkCommentsIsSame } from "../util"
|
import { buildSubAttributeFormValues, checkCommentsIsSame } from "../util"
|
||||||
import { safeClone } from "../utils/clone"
|
import { safeClone } from "../utils/clone"
|
||||||
import { labelTypeMap } from "../utils/constants"
|
import { labelTypeMap } from "../utils/constants"
|
||||||
|
import {
|
||||||
|
contoursToMultiPolygon,
|
||||||
|
multiPolygonToStoredContours,
|
||||||
|
paperShapeToMultiPolygon,
|
||||||
|
} from "../utils/geometry/booleanAdapter"
|
||||||
|
import { filterMultiPolygonByAreaThreshold } from "../utils/geometry/areaFilter"
|
||||||
import { adjustPoints } from "../utils/paperjs"
|
import { adjustPoints } from "../utils/paperjs"
|
||||||
import AssistShapeComponent from "./AssistShapeComponent"
|
import AssistShapeComponent from "./AssistShapeComponent"
|
||||||
import CrosshairComponent from "./CrosshairComponent"
|
import CrosshairComponent from "./CrosshairComponent"
|
||||||
@@ -158,6 +168,7 @@ const SHORTCUT_GUIDE_SECTIONS = [
|
|||||||
title: "专项工具",
|
title: "专项工具",
|
||||||
items: [
|
items: [
|
||||||
["F1", "进入辅助标注 / 在辅助模式下确认"],
|
["F1", "进入辅助标注 / 在辅助模式下确认"],
|
||||||
|
["F2", "按尺寸阈值过滤小区域 / 小镂空"],
|
||||||
["I", "执行追踪"],
|
["I", "执行追踪"],
|
||||||
["P", "QA 文本场景检查错词"],
|
["P", "QA 文本场景检查错词"],
|
||||||
],
|
],
|
||||||
@@ -1040,6 +1051,347 @@ const PaperContainer = (
|
|||||||
}
|
}
|
||||||
}, [clearPendingFrameSwitch, loadingData])
|
}, [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
|
||||||
|
)
|
||||||
|
const currentScale = usePaperStore.getState().rasterScale[activeImage] ?? 1
|
||||||
|
return (
|
||||||
|
normalizedSizeThreshold *
|
||||||
|
normalizedSizeThreshold *
|
||||||
|
currentScale *
|
||||||
|
currentScale
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[activeImage]
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
const renderSupportAnnotation = useCallback(
|
||||||
async (
|
async (
|
||||||
points: Array<[number, number]>,
|
points: Array<[number, number]>,
|
||||||
@@ -1055,20 +1407,8 @@ const PaperContainer = (
|
|||||||
loading: true,
|
loading: true,
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
})
|
})
|
||||||
const operationSchema =
|
const { strokeColor, fillColor, blankColor } =
|
||||||
projectDetail?.label_schema_list?.find(
|
getSupportPreviewColors()
|
||||||
(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 currentScale =
|
const currentScale =
|
||||||
usePaperStore.getState().rasterScale[activeImage] ?? 1
|
usePaperStore.getState().rasterScale[activeImage] ?? 1
|
||||||
|
|
||||||
@@ -1141,13 +1481,11 @@ const PaperContainer = (
|
|||||||
if (!promptPoints.length && !normalizedBoxes.length) {
|
if (!promptPoints.length && !normalizedBoxes.length) {
|
||||||
throw new Error("缺少有效提示,无法调用模型接口")
|
throw new Error("缺少有效提示,无法调用模型接口")
|
||||||
}
|
}
|
||||||
// const picSize = usePaperStore.getState().rasterSize[activeImage]
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getAuxiliaryAnnotation({
|
const data = await getAuxiliaryAnnotation({
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
image_name: activeImage,
|
image_name: activeImage,
|
||||||
// project_id: "image_test_bk",
|
|
||||||
prompt: {
|
prompt: {
|
||||||
...(promptPoints.length
|
...(promptPoints.length
|
||||||
? { foreground_points: promptPoints }
|
? { foreground_points: promptPoints }
|
||||||
@@ -1160,50 +1498,29 @@ const PaperContainer = (
|
|||||||
contour_approximation: 0,
|
contour_approximation: 0,
|
||||||
})
|
})
|
||||||
const { contours = [], hollows = [] } = data || {}
|
const { contours = [], hollows = [] } = data || {}
|
||||||
const outerPaths: any[] = []
|
const geometry = contoursToMultiPolygon({
|
||||||
const innerPaths: any[] = []
|
outerContours: contours
|
||||||
contours.forEach((contour: any, index: number) => {
|
.map((contour: any, index: number) => {
|
||||||
const normalizedContour = normalizeContour(contour)
|
if (hollows[index]) return null
|
||||||
if (normalizedContour.length > 3) {
|
return normalizeContour(contour).map(
|
||||||
const path = new paper.Path({
|
([x, y]) => [x * currentScale, y * currentScale] as [number, number]
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
if (hollows[index]) {
|
.filter(
|
||||||
path.data.isHollowPolygon = true
|
(contour): contour is Array<[number, number]> => !!contour
|
||||||
innerPaths.push(path)
|
),
|
||||||
} else {
|
holeContours: contours
|
||||||
path.fillColor = new paper.Color(fillColor)
|
.map((contour: any, index: number) => {
|
||||||
outerPaths.push(path)
|
if (!hollows[index]) return null
|
||||||
}
|
return normalizeContour(contour).map(
|
||||||
}
|
([x, y]) => [x * currentScale, y * currentScale] as [number, number]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(contour): contour is Array<[number, number]> => !!contour
|
||||||
|
),
|
||||||
})
|
})
|
||||||
const compoundPath = new paper.CompoundPath({
|
renderSupportPreviewGeometry(geometry)
|
||||||
children: [...outerPaths, ...innerPaths],
|
|
||||||
data: {
|
|
||||||
id: "support",
|
|
||||||
type: "parent",
|
|
||||||
},
|
|
||||||
strokeColor: strokeColor,
|
|
||||||
strokeWidth: 2,
|
|
||||||
fillColor: fillColor,
|
|
||||||
})
|
|
||||||
compoundPath.parent = usePaperStore.getState().group!
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
id: "sam2",
|
id: "sam2",
|
||||||
message: "模型生成成功",
|
message: "模型生成成功",
|
||||||
@@ -1228,7 +1545,7 @@ const PaperContainer = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeImage, projectDetail]
|
[activeImage, getSupportPreviewColors, projectDetail, renderSupportPreviewGeometry]
|
||||||
)
|
)
|
||||||
|
|
||||||
// const renderTrackingAnnotation = useCallback(async () => {
|
// const renderTrackingAnnotation = useCallback(async () => {
|
||||||
@@ -4153,6 +4470,7 @@ const PaperContainer = (
|
|||||||
renderTrackingAnnotation,
|
renderTrackingAnnotation,
|
||||||
renderSupportAnnotation,
|
renderSupportAnnotation,
|
||||||
saveSupportAnnotationData,
|
saveSupportAnnotationData,
|
||||||
|
filterBySizeThreshold,
|
||||||
copyAnnotationDataToMultiFrame,
|
copyAnnotationDataToMultiFrame,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ interface TopToolsComponentProps {
|
|||||||
updateOldWorkLoad: (data: WorkLoad) => void
|
updateOldWorkLoad: (data: WorkLoad) => void
|
||||||
isLabelTask: boolean
|
isLabelTask: boolean
|
||||||
onOperationChange: (operationId: string) => void
|
onOperationChange: (operationId: string) => void
|
||||||
|
onFilterBySizeThreshold?: () => void
|
||||||
renderPolygons: (
|
renderPolygons: (
|
||||||
operationId: string,
|
operationId: string,
|
||||||
imageData: Map<string, [number, any[], any, any[]][]> | undefined
|
imageData: Map<string, [number, any[], any, any[]][]> | undefined
|
||||||
@@ -164,6 +165,7 @@ const TopTools = (
|
|||||||
updateOldWorkLoad,
|
updateOldWorkLoad,
|
||||||
isLabelTask,
|
isLabelTask,
|
||||||
onOperationChange,
|
onOperationChange,
|
||||||
|
onFilterBySizeThreshold,
|
||||||
renderPolygons,
|
renderPolygons,
|
||||||
} = props
|
} = props
|
||||||
const { backUrl, basePath } = useBackUrlStore()
|
const { backUrl, basePath } = useBackUrlStore()
|
||||||
@@ -230,6 +232,8 @@ const TopTools = (
|
|||||||
setAssistToolEnabled,
|
setAssistToolEnabled,
|
||||||
assistToolSize,
|
assistToolSize,
|
||||||
setAssistToolSize,
|
setAssistToolSize,
|
||||||
|
auxiliarySizeThreshold,
|
||||||
|
setAuxiliarySizeThreshold,
|
||||||
nodeSize,
|
nodeSize,
|
||||||
setNodeSize,
|
setNodeSize,
|
||||||
needBackup,
|
needBackup,
|
||||||
@@ -238,6 +242,9 @@ const TopTools = (
|
|||||||
setMagnetFlag,
|
setMagnetFlag,
|
||||||
} = useTopToolsStore()
|
} = useTopToolsStore()
|
||||||
const [nodeSizeInput, setNodeSizeInput] = useState<number | string>(nodeSize)
|
const [nodeSizeInput, setNodeSizeInput] = useState<number | string>(nodeSize)
|
||||||
|
const [auxiliarySizeThresholdInput, setAuxiliarySizeThresholdInput] = useState<
|
||||||
|
number | string
|
||||||
|
>(auxiliarySizeThreshold)
|
||||||
|
|
||||||
const { pathGroupMap } = useRightToolsStore()
|
const { pathGroupMap } = useRightToolsStore()
|
||||||
const isAutoSave = useIntervalStore((state) => state.isAutoSave)
|
const isAutoSave = useIntervalStore((state) => state.isAutoSave)
|
||||||
@@ -261,6 +268,10 @@ const TopTools = (
|
|||||||
usePaperStore.getState().refreshNodeVisualSize()
|
usePaperStore.getState().refreshNodeVisualSize()
|
||||||
}, [nodeSize])
|
}, [nodeSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAuxiliarySizeThresholdInput(auxiliarySizeThreshold)
|
||||||
|
}, [auxiliarySizeThreshold])
|
||||||
|
|
||||||
const toggleRightPanel = useCallback(
|
const toggleRightPanel = useCallback(
|
||||||
(visible: boolean, setter: (val: boolean) => void) => {
|
(visible: boolean, setter: (val: boolean) => void) => {
|
||||||
const nextVisible = !visible
|
const nextVisible = !visible
|
||||||
@@ -2186,39 +2197,84 @@ const TopTools = (
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex justify="space-between" align="center" px="xs" gap="xs">
|
<Flex justify="space-between" align="center" px="xs" gap="xs">
|
||||||
<Tooltip label={`节点直径范围 ${NODE_SIZE_MIN}-${NODE_SIZE_MAX}`}>
|
<Flex align="center" gap="xs" wrap="nowrap">
|
||||||
<Flex align="center" gap={4}>
|
<Tooltip label={`节点直径范围 ${NODE_SIZE_MIN}-${NODE_SIZE_MAX}`}>
|
||||||
<Text span size="xs" style={{ minWidth: "fit-content" }}>
|
<Flex align="center" gap={4}>
|
||||||
节点
|
<Text span size="xs" style={{ minWidth: "fit-content" }}>
|
||||||
</Text>
|
节点
|
||||||
<NumberInput
|
</Text>
|
||||||
value={nodeSizeInput}
|
<NumberInput
|
||||||
w={50}
|
value={nodeSizeInput}
|
||||||
min={NODE_SIZE_MIN}
|
w={50}
|
||||||
max={NODE_SIZE_MAX}
|
min={NODE_SIZE_MIN}
|
||||||
step={1}
|
max={NODE_SIZE_MAX}
|
||||||
allowDecimal={false}
|
step={1}
|
||||||
size="xs"
|
allowDecimal={false}
|
||||||
radius="sm"
|
size="xs"
|
||||||
hideControls
|
radius="sm"
|
||||||
onChange={(value) => {
|
hideControls
|
||||||
setNodeSizeInput(value)
|
onChange={(value) => {
|
||||||
}}
|
setNodeSizeInput(value)
|
||||||
onFocus={() => {
|
}}
|
||||||
useKeyEventStore.getState().setFocusInput(true)
|
onFocus={() => {
|
||||||
}}
|
useKeyEventStore.getState().setFocusInput(true)
|
||||||
onBlur={(event) => {
|
}}
|
||||||
useKeyEventStore.getState().setFocusInput(false)
|
onBlur={(event) => {
|
||||||
const rawValue = event.currentTarget.value.trim()
|
useKeyEventStore.getState().setFocusInput(false)
|
||||||
const nextValue =
|
const rawValue = event.currentTarget.value.trim()
|
||||||
rawValue === "" ? DEFAULT_NODE_SIZE : Number(rawValue)
|
const nextValue =
|
||||||
setNodeSize(
|
rawValue === "" ? DEFAULT_NODE_SIZE : Number(rawValue)
|
||||||
Number.isFinite(nextValue) ? nextValue : DEFAULT_NODE_SIZE
|
setNodeSize(
|
||||||
)
|
Number.isFinite(nextValue) ? nextValue : DEFAULT_NODE_SIZE
|
||||||
}}
|
)
|
||||||
/>
|
}}
|
||||||
</Flex>
|
/>
|
||||||
</Tooltip>
|
</Flex>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
label={`按尺寸阈值过滤辅助结果或选中 polygon(F2),实际面积阈值为 ${(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}
|
{renderEditIcon}
|
||||||
<Button
|
<Button
|
||||||
w="fit-content"
|
w="fit-content"
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ const clearPathParentGroupMetadata = (
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncDeletedPathGroupState = ({
|
export const syncDeletedPathGroupState = ({
|
||||||
imageId,
|
imageId,
|
||||||
removedPathIds,
|
removedPathIds,
|
||||||
removedParentGroupIds,
|
removedParentGroupIds,
|
||||||
@@ -866,6 +866,17 @@ const cleanupDeletedObjectItems = (
|
|||||||
syncSelectedObjectsVisualState(imageId)
|
syncSelectedObjectsVisualState(imageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const removePaperObjectById = (
|
||||||
|
imageId: string,
|
||||||
|
pathId?: number,
|
||||||
|
parentGroupId?: number,
|
||||||
|
options: {
|
||||||
|
pushState?: boolean
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
cleanupDeletedObjectItems(imageId, pathId, parentGroupId, options)
|
||||||
|
}
|
||||||
|
|
||||||
const handleShift = (flag: boolean) => {
|
const handleShift = (flag: boolean) => {
|
||||||
useKeyEventStore.getState().setShift(flag)
|
useKeyEventStore.getState().setShift(flag)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ interface TopToolsState {
|
|||||||
setAssistToolEnabled: (val: boolean) => void
|
setAssistToolEnabled: (val: boolean) => void
|
||||||
assistToolSize: number
|
assistToolSize: number
|
||||||
setAssistToolSize: (val: number) => void
|
setAssistToolSize: (val: number) => void
|
||||||
|
auxiliarySizeThreshold: number
|
||||||
|
setAuxiliarySizeThreshold: (val: number) => void
|
||||||
nodeSize: number
|
nodeSize: number
|
||||||
setNodeSize: (val: number) => void
|
setNodeSize: (val: number) => void
|
||||||
showTags: boolean
|
showTags: boolean
|
||||||
@@ -107,6 +109,7 @@ const initialTopToolsState = {
|
|||||||
crosshairStatus: "hidden" as "hidden" | "line" | "carve",
|
crosshairStatus: "hidden" as "hidden" | "line" | "carve",
|
||||||
assistToolEnabled: false,
|
assistToolEnabled: false,
|
||||||
assistToolSize: 10,
|
assistToolSize: 10,
|
||||||
|
auxiliarySizeThreshold: 0,
|
||||||
nodeSize: DEFAULT_NODE_SIZE,
|
nodeSize: DEFAULT_NODE_SIZE,
|
||||||
drawOption: "default" as "default" | "intersect" | "unite",
|
drawOption: "default" as "default" | "intersect" | "unite",
|
||||||
saveCurrentScale: false,
|
saveCurrentScale: false,
|
||||||
@@ -197,6 +200,12 @@ export const useTopToolsStore = create<TopToolsState>()(
|
|||||||
...state,
|
...state,
|
||||||
assistToolSize: Math.max(1, val),
|
assistToolSize: Math.max(1, val),
|
||||||
})),
|
})),
|
||||||
|
auxiliarySizeThreshold: initialTopToolsState.auxiliarySizeThreshold,
|
||||||
|
setAuxiliarySizeThreshold: (val) =>
|
||||||
|
set((state: TopToolsState) => ({
|
||||||
|
...state,
|
||||||
|
auxiliarySizeThreshold: Math.max(0, Number(val) || 0),
|
||||||
|
})),
|
||||||
nodeSize: initialTopToolsState.nodeSize,
|
nodeSize: initialTopToolsState.nodeSize,
|
||||||
setNodeSize: (val) =>
|
setNodeSize: (val) =>
|
||||||
set((state: TopToolsState) => ({
|
set((state: TopToolsState) => ({
|
||||||
|
|||||||
69
components/label/utils/geometry/areaFilter.ts
Normal file
69
components/label/utils/geometry/areaFilter.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
197
size_threshold_presets_plan_a.md
Normal file
197
size_threshold_presets_plan_a.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# 尺寸阈值预设档位方案 A 说明
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
当前项目中,辅助结果 / 选中 `polygon` 的过滤能力已经支持“尺寸阈值”输入:
|
||||||
|
|
||||||
|
- 用户输入一个尺寸值 `n`
|
||||||
|
- 实际参与过滤的面积阈值为 `n²`
|
||||||
|
- 在画布坐标中再结合当前图片缩放比例换算后执行过滤
|
||||||
|
|
||||||
|
这套方式已经可以满足精细调参,但在高频使用时,仍然存在一个体验问题:
|
||||||
|
|
||||||
|
- 用户每次都需要手动输入数字
|
||||||
|
- 常用值虽然固定,但容易忘记
|
||||||
|
- 即使理解了 `尺寸² = 面积阈值`,操作上仍然偏“参数化”而不是“工具化”
|
||||||
|
|
||||||
|
因此可以考虑引入“方案 A:尺寸阈值预设档位”。
|
||||||
|
|
||||||
|
## 方案 A 是什么
|
||||||
|
在现有“尺寸阈值”输入框旁,增加一组常用快捷档位按钮,例如:
|
||||||
|
|
||||||
|
- `8`
|
||||||
|
- `16`
|
||||||
|
- `32`
|
||||||
|
- `64`
|
||||||
|
|
||||||
|
点击任意一个档位时:
|
||||||
|
|
||||||
|
- 自动将尺寸阈值设置为该值
|
||||||
|
- 不改变现有过滤逻辑
|
||||||
|
- 用户仍然可以继续手动输入其他值
|
||||||
|
|
||||||
|
这不是替代手输,而是在手输之外增加“常用值一键设置”能力。
|
||||||
|
|
||||||
|
## 为什么考虑做
|
||||||
|
### 1. 降低操作成本
|
||||||
|
很多场景下,用户反复尝试的其实是几个相对稳定的阈值。
|
||||||
|
|
||||||
|
如果每次都手输:
|
||||||
|
- 需要点击输入框
|
||||||
|
- 输入数字
|
||||||
|
- 再触发过滤
|
||||||
|
|
||||||
|
如果有预设档位:
|
||||||
|
- 一次点击即可完成阈值设置
|
||||||
|
- 再配合 `F2` 或直接点击过滤按钮,路径更短
|
||||||
|
|
||||||
|
### 2. 降低记忆负担
|
||||||
|
当前用户需要自己记住:
|
||||||
|
- `16² = 256`
|
||||||
|
- `32² = 1024`
|
||||||
|
- `64² = 4096`
|
||||||
|
|
||||||
|
有了固定档位后,用户不需要每次重新心算或回忆常用数值,久而久之会形成稳定操作习惯。
|
||||||
|
|
||||||
|
### 3. 更符合工具型操作习惯
|
||||||
|
标注工具里高频操作通常更适合:
|
||||||
|
- 固定档位
|
||||||
|
- 一键切换
|
||||||
|
- 快速试错
|
||||||
|
|
||||||
|
而不是每次都进入“参数输入”模式。
|
||||||
|
|
||||||
|
预设档位能让这个功能更像一个成熟工具,而不是一个单纯的数值配置项。
|
||||||
|
|
||||||
|
### 4. 与当前心智模型兼容
|
||||||
|
当前已经将输入语义改成“尺寸阈值”,用户理解的是“边长感 / 尺寸感”。
|
||||||
|
|
||||||
|
在这个基础上,预设 `8 / 16 / 32 / 64` 是自然延伸:
|
||||||
|
- 数字仍然是“尺寸”
|
||||||
|
- 实际面积阈值仍然是 `尺寸²`
|
||||||
|
- 不会引入新的概念
|
||||||
|
|
||||||
|
## 为什么建议优先采用数字档位
|
||||||
|
相较于“极小 / 小 / 中 / 大”这类文字档位,数字档位更适合当前项目:
|
||||||
|
|
||||||
|
### 1. 语义更精确
|
||||||
|
`32` 就是 `32`,不会有解释歧义。
|
||||||
|
|
||||||
|
而“中”在不同用户理解里可能对应:
|
||||||
|
- 16
|
||||||
|
- 24
|
||||||
|
- 32
|
||||||
|
- 48
|
||||||
|
|
||||||
|
### 2. 与现有输入框保持一致
|
||||||
|
当前输入框本身就是数值输入,预设按钮如果也是数值,用户的认知路径最短。
|
||||||
|
|
||||||
|
### 3. 便于后续沟通与复现
|
||||||
|
当团队成员交流时,可以直接说:
|
||||||
|
- “试一下 16”
|
||||||
|
- “32 过滤得更合适”
|
||||||
|
- “这批图用 64 比较稳”
|
||||||
|
|
||||||
|
这比说“用中档”更容易复现与共享经验。
|
||||||
|
|
||||||
|
## 建议的预设档位
|
||||||
|
建议初始只放 4 个:
|
||||||
|
|
||||||
|
- `8` → 实际面积阈值 `64`
|
||||||
|
- `16` → 实际面积阈值 `256`
|
||||||
|
- `32` → 实际面积阈值 `1024`
|
||||||
|
- `64` → 实际面积阈值 `4096`
|
||||||
|
|
||||||
|
原因:
|
||||||
|
- 数量少,不会挤占工具栏空间
|
||||||
|
- 都是 2 的幂,容易记忆
|
||||||
|
- 能覆盖从较小噪声到较大碎片区域的大多数场景
|
||||||
|
|
||||||
|
如果后续真实使用中发现跨度不够,再考虑补充:
|
||||||
|
- `24`
|
||||||
|
- `48`
|
||||||
|
- `96`
|
||||||
|
|
||||||
|
但第一版不建议一开始就放太多。
|
||||||
|
|
||||||
|
## 如果要做,准备怎么做
|
||||||
|
### UI 位置
|
||||||
|
建议放在现有 `TopTools` 中“尺寸阈值”输入框旁边,保持同一区域:
|
||||||
|
|
||||||
|
- 输入框仍然保留
|
||||||
|
- 预设档位按钮放在输入框右侧或下方
|
||||||
|
- 当前选中的档位可以有高亮态
|
||||||
|
|
||||||
|
### 交互行为
|
||||||
|
建议行为如下:
|
||||||
|
|
||||||
|
1. 点击某个预设档位
|
||||||
|
- 直接把尺寸阈值设置为该值
|
||||||
|
- 同步更新输入框显示
|
||||||
|
- 不自动执行过滤
|
||||||
|
|
||||||
|
2. 用户随后:
|
||||||
|
- 点击“过滤”按钮,或
|
||||||
|
- 按 `F2`
|
||||||
|
|
||||||
|
这样可以和当前交互习惯保持一致,避免点击预设后立刻修改结果,减少误操作。
|
||||||
|
|
||||||
|
### 状态同步
|
||||||
|
预设档位只是“设置输入值”的快捷方式,因此状态仍然复用现有尺寸阈值:
|
||||||
|
|
||||||
|
- 不需要新增一套独立过滤逻辑
|
||||||
|
- 不需要新增第二套 store 字段
|
||||||
|
- 只需要在 UI 层加上档位按钮和“当前档位是否命中”的判断
|
||||||
|
|
||||||
|
### 提示文案
|
||||||
|
建议鼠标悬浮时展示:
|
||||||
|
- 当前尺寸值
|
||||||
|
- 对应面积阈值
|
||||||
|
|
||||||
|
例如:
|
||||||
|
- `32(面积阈值 1024)`
|
||||||
|
- `64(面积阈值 4096)`
|
||||||
|
|
||||||
|
## 做了之后的好处
|
||||||
|
### 1. 高频场景效率更高
|
||||||
|
对于常用阈值,用户几乎不再需要键盘输入。
|
||||||
|
|
||||||
|
### 2. 功能更容易被理解和使用
|
||||||
|
一些用户即使理解了“尺寸阈值”的概念,也未必愿意频繁输入参数。
|
||||||
|
|
||||||
|
预设档位会明显降低上手门槛。
|
||||||
|
|
||||||
|
### 3. 更适合形成团队共识
|
||||||
|
如果多人协作标注,预设档位更容易沉淀成经验值:
|
||||||
|
- 某类图片常用 `16`
|
||||||
|
- 某类图片常用 `32`
|
||||||
|
- 某类图片常用 `64`
|
||||||
|
|
||||||
|
### 4. 不破坏现有能力
|
||||||
|
因为手动输入仍然保留,所以:
|
||||||
|
- 精细调参能力不丢
|
||||||
|
- 只是新增一个更顺手的入口
|
||||||
|
|
||||||
|
## 风险与注意点
|
||||||
|
### 1. 工具栏空间会更紧张
|
||||||
|
如果按钮做得太多,会挤压现有工具布局。
|
||||||
|
|
||||||
|
因此第一版建议:
|
||||||
|
- 只放少量档位
|
||||||
|
- 使用较紧凑的按钮样式
|
||||||
|
|
||||||
|
### 2. 不宜默认自动过滤
|
||||||
|
点击档位后如果立刻执行过滤,会让用户难以控制操作时机。
|
||||||
|
|
||||||
|
建议保留“设置值”和“执行过滤”两个动作分离。
|
||||||
|
|
||||||
|
### 3. 预设值不一定覆盖所有场景
|
||||||
|
所以必须保留手动输入作为兜底方式。
|
||||||
|
|
||||||
|
## 当前结论
|
||||||
|
方案 A 值得考虑,原因是:
|
||||||
|
- 实现成本不高
|
||||||
|
- 不会改动底层过滤逻辑
|
||||||
|
- 对高频操作的收益明确
|
||||||
|
- 与当前“尺寸阈值”心智模型完全兼容
|
||||||
|
|
||||||
|
但当前阶段不急于实现,先记录方案,后续根据你的使用体验再决定是否上线。
|
||||||
Reference in New Issue
Block a user