diff --git a/auxiliary_area_threshold_plan.md b/auxiliary_area_threshold_plan.md deleted file mode 100644 index 29d3371..0000000 --- a/auxiliary_area_threshold_plan.md +++ /dev/null @@ -1,52 +0,0 @@ -# 辅助标注尺寸阈值过滤实施记录 - -## 背景 -在 `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 对象。 -- 在工具栏中增加更明确的阈值单位说明。 -- 如果需要,可再补一轮手动回归用例清单。 diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx index 5ec2b55..9058797 100644 --- a/components/label/components/PaperContainer.tsx +++ b/components/label/components/PaperContainer.tsx @@ -59,12 +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 { filterMultiPolygonByAreaThreshold } from "../utils/geometry/areaFilter" import { adjustPoints } from "../utils/paperjs" import AssistShapeComponent from "./AssistShapeComponent" import CrosshairComponent from "./CrosshairComponent" @@ -1177,11 +1177,9 @@ const PaperContainer = ( const getMinAreaBySizeThreshold = useCallback( (sizeThreshold: number) => { - const normalizedSizeThreshold = Math.max( - 0, - Number(sizeThreshold) || 0 - ) - const currentScale = usePaperStore.getState().rasterScale[activeImage] ?? 1 + const normalizedSizeThreshold = Math.max(0, Number(sizeThreshold) || 0) + const currentScale = + usePaperStore.getState().rasterScale[activeImage] ?? 1 return ( normalizedSizeThreshold * normalizedSizeThreshold * @@ -1274,14 +1272,17 @@ const PaperContainer = ( return } - const { geometry, removedOuterCount: currentRemovedOuterCount, removedHoleCount: currentRemovedHoleCount } = - filterMultiPolygonByAreaThreshold({ - geometry: contoursToMultiPolygon({ - outerContours: oldData[1], - holeContours: oldData[3], - }), - minArea, - }) + const { + geometry, + removedOuterCount: currentRemovedOuterCount, + removedHoleCount: currentRemovedHoleCount, + } = filterMultiPolygonByAreaThreshold({ + geometry: contoursToMultiPolygon({ + outerContours: oldData[1], + holeContours: oldData[3], + }), + minArea, + }) if (!currentRemovedOuterCount && !currentRemovedHoleCount) { return @@ -1291,12 +1292,10 @@ const PaperContainer = ( removedHoleCount += currentRemovedHoleCount if (!geometry.length) { - nextLabel - .get(activeImage) - ?.set( - operationId, - operationData.filter((item) => item[0] !== selectedId) - ) + nextLabel.get(activeImage)?.set( + operationId, + operationData.filter((item) => item[0] !== selectedId) + ) removedObjectCount += 1 removedPathIds.push(selectedId) removedParentGroupIds.push(oldData[2]?.parentGroupId) @@ -1344,7 +1343,9 @@ const PaperContainer = ( labelData: nextLabel, }) removedPathIds.forEach((pathId) => { - useObjectStore.getState().updateSelectedPath(activeImage, "DELETE", pathId) + useObjectStore + .getState() + .updateSelectedPath(activeImage, "DELETE", pathId) }) } @@ -1375,7 +1376,9 @@ const PaperContainer = ( } function filterBySizeThreshold() { - const sizeThreshold = Number(useTopToolsStore.getState().auxiliarySizeThreshold) + const sizeThreshold = Number( + useTopToolsStore.getState().auxiliarySizeThreshold + ) if (!Number.isFinite(sizeThreshold) || sizeThreshold <= 0) { notifications.show({ @@ -1407,8 +1410,7 @@ const PaperContainer = ( loading: true, autoClose: false, }) - const { strokeColor, fillColor, blankColor } = - getSupportPreviewColors() + // const { strokeColor, fillColor, blankColor } = getSupportPreviewColors() const currentScale = usePaperStore.getState().rasterScale[activeImage] ?? 1 @@ -1503,7 +1505,8 @@ const PaperContainer = ( .map((contour: any, index: number) => { if (hollows[index]) return null return normalizeContour(contour).map( - ([x, y]) => [x * currentScale, y * currentScale] as [number, number] + ([x, y]) => + [x * currentScale, y * currentScale] as [number, number] ) }) .filter( @@ -1513,7 +1516,8 @@ const PaperContainer = ( .map((contour: any, index: number) => { if (!hollows[index]) return null return normalizeContour(contour).map( - ([x, y]) => [x * currentScale, y * currentScale] as [number, number] + ([x, y]) => + [x * currentScale, y * currentScale] as [number, number] ) }) .filter( @@ -1545,7 +1549,12 @@ const PaperContainer = ( } } }, - [activeImage, getSupportPreviewColors, projectDetail, renderSupportPreviewGeometry] + [ + activeImage, + // getSupportPreviewColors, + projectDetail, + renderSupportPreviewGeometry, + ] ) // const renderTrackingAnnotation = useCallback(async () => { diff --git a/components/label/components/TopTools.tsx b/components/label/components/TopTools.tsx index c4247c8..3f0aebc 100644 --- a/components/label/components/TopTools.tsx +++ b/components/label/components/TopTools.tsx @@ -242,9 +242,8 @@ const TopTools = ( setMagnetFlag, } = useTopToolsStore() const [nodeSizeInput, setNodeSizeInput] = useState(nodeSize) - const [auxiliarySizeThresholdInput, setAuxiliarySizeThresholdInput] = useState< - number | string - >(auxiliarySizeThreshold) + const [auxiliarySizeThresholdInput, setAuxiliarySizeThresholdInput] = + useState(auxiliarySizeThreshold) const { pathGroupMap } = useRightToolsStore() const isAutoSave = useIntervalStore((state) => state.isAutoSave) diff --git a/size_threshold_presets_plan_a.md b/size_threshold_presets_plan_a.md deleted file mode 100644 index 918241f..0000000 --- a/size_threshold_presets_plan_a.md +++ /dev/null @@ -1,197 +0,0 @@ -# 尺寸阈值预设档位方案 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 值得考虑,原因是: -- 实现成本不高 -- 不会改动底层过滤逻辑 -- 对高频操作的收益明确 -- 与当前“尺寸阈值”心智模型完全兼容 - -但当前阶段不急于实现,先记录方案,后续根据你的使用体验再决定是否上线。