kotones-auto-assistant/kotonebot-devtool/src/pages/ImageAnnotation/ImageAnnotation.tsx

584 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { SideToolBar, Tool } from '../../components/SideToolBar';
import PropertyGrid, { Property, PropertyCategory } from '../../components/PropertyGrid';
import ImageEditor, { AnnotationChangedEvent } from '../../components/ImageEditor/ImageEditor';
import { Annotation, Tool as EditorTool } from '../../components/ImageEditor/types';
import { BsCursor, BsSquare, BsFolder2Open, BsUpload, BsFloppy, BsDownload } from 'react-icons/bs';
import { useImmer } from 'use-immer';
import { useImageViewerModal } from '../../components/ImageViewerModal';
import { useMessageBox } from '../../hooks/useMessageBox';
import { useToast } from '../../components/ToastMessage';
import DragArea from './DragArea';
import { Definitions, ImageMetaData, TemplateDefinition, DefinitionType } from './types';
import { cropImage, openFileWFS, openFileInput, downloadJSONToFile, readFileAsJSON, readFileAsDataURL, FileResult, saveFileWFS } from '../../utils/fileUtils';
import NativeDiv from '../../components/NativeDiv';
const PageContainer = styled.div`
display: flex;
width: 100%;
height: 100vh;
gap: 16px;
padding: 16px;
background-color: #f8f9fa;
`;
const EditorContainer = styled(NativeDiv)`
flex: 1;
min-width: 0;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
`;
const PropertyContainer = styled.div`
width: 300px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 16px;
`;
const Tip = styled.span`
font-weight: bold;
color: white;
text-shadow:
-1px -1px 0 black,
1px -1px 0 black,
-1px 1px 0 black,
1px 1px 0 black;
`;
// 工具栏配置
const tools: Array<Tool | 'separator'> = [
{
id: 'open',
icon: <BsFolder2Open size={24} />,
title: '打开 (WebFileSystem)',
selectable: false,
},
{
id: 'save',
icon: <BsFloppy size={24} />,
title: '保存 (WebFileSystem)',
selectable: false,
},
{
id: 'upload',
icon: <BsUpload size={24} />,
title: '上传 (Input)',
selectable: false,
},
{
id: 'download',
icon: <BsDownload size={24} />,
title: '下载 (Input)',
selectable: false,
},
'separator',
{
id: 'drag',
icon: <BsCursor size={24} />,
title: '拖动工具 (V)',
selectable: true,
},
{
id: 'rect',
icon: <BsSquare size={24} />,
title: '矩形工具 (R)',
selectable: true,
},
];
const toolsMap: Record<string, EditorTool> = {
drag: EditorTool.Drag,
rect: EditorTool.Rect,
};
// 示例图片URL
const SAMPLE_IMAGE_URL = 'https://picsum.photos/seed/123/800/600';
// 计算最大公约数
const gcd = (a: number, b: number): number => {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
const t = b;
b = a % b;
a = t;
}
return a;
};
// 计算最简比例
const getSimplestRatio = (width: number, height: number): string => {
const divisor = gcd(width, height);
const simpleWidth = width / divisor;
const simpleHeight = height / divisor;
return `${simpleWidth}:${simpleHeight}`;
};
// 属性栏数据 Hook
const usePropertyGridData = (
selectedAnnotation: Annotation | null,
definitions: Definitions,
image: HTMLImageElement | null,
onImageClick: (imageUrl: string) => void,
onDefinitionChange?: (id: string, changes: Partial<TemplateDefinition>) => void,
imageFileName?: string,
annotations?: Annotation[],
currentFileResult?: FileResult | null
) => {
const [croppedImageUrl, setCroppedImageUrl] = React.useState<string>('');
React.useEffect(() => {
if (selectedAnnotation && image) {
const url = cropImage(image, selectedAnnotation.data);
setCroppedImageUrl(url);
} else {
setCroppedImageUrl('');
}
}, [selectedAnnotation, image]);
if (!selectedAnnotation) {
if (!image) return [];
return [
{
title: '文件名',
render: () => imageFileName || '未命名',
},
{
title: '打开方式',
render: () => currentFileResult?.fileSystem === 'wfs' ? 'WebFileSystem' : 'Input',
},
{
title: '宽高',
render: () => `${image.width} × ${image.height}`,
},
{
title: '宽高比',
render: () => getSimplestRatio(image.width, image.height),
},
{
title: '标注数量',
render: () => annotations?.length || 0,
}
];
}
const definition = definitions[selectedAnnotation.id];
const { x1, y1, x2, y2 } = selectedAnnotation.data;
const generalProperties: Array<PropertyCategory | Property> = [
{
render: () => {
if (!image) return <span>...</span>;
if (!croppedImageUrl) return <span>...</span>;
return (
<div style={{
height: '100px',
margin: '0 auto'
}}>
<img
src={croppedImageUrl}
style={{
maxWidth: '100%',
maxHeight: '100px',
objectFit: 'contain',
cursor: 'pointer',
}}
onClick={() => onImageClick(croppedImageUrl)}
/>
</div>
);
},
},
{
title: '通用',
properties: [
{
title: '名称',
render: {
type: 'text',
required: true,
value: definition.name,
onChange: (value: string) => onDefinitionChange?.(selectedAnnotation.id, { name: value }),
}
},
{
title: '显示名称',
render: {
type: 'text',
required: true,
value: definition.displayName,
onChange: (value: string) => onDefinitionChange?.(selectedAnnotation.id, { displayName: value }),
}
},
{
title: '类型',
render: () => definition.type,
}
],
foldable: true
},
];
const annotationProperties: Array<PropertyCategory | Property> = [
{
title: '标注',
properties: [
{
title: 'ID',
render: () => selectedAnnotation.id,
},
{
title: '类型',
render: () => '矩形',
},
{
title: '范围',
render: () => `(${x1}, ${y1}, ${x2}, ${y2})`,
},
{
title: '宽高',
render: () => `${x2 - x1} × ${y2 - y1}`,
}
],
foldable: true
}
];
let specificProperties: Array<PropertyCategory | Property> = [];
if (definition.type === 'template') {
const rectDef = definition as TemplateDefinition;
specificProperties = [
{
title: '模板',
properties: [
{
title: '提示矩形',
render: {
type: 'checkbox',
required: false,
value: rectDef.useHintRect,
onChange: (value: boolean) => onDefinitionChange?.(selectedAnnotation.id, { useHintRect: value }),
}
}
],
foldable: true,
}
];
}
return [
...generalProperties,
...specificProperties,
...annotationProperties,
];
};
const ImageAnnotation: React.FC = () => {
const [currentTool, setCurrentTool] = useState<EditorTool>(EditorTool.Drag);
const [imageMetaData, updateImageMetaData] = useImmer<ImageMetaData>({
definitions: {},
annotations: [],
});
const [selectedAnnotation, setSelectedAnnotation] = useState<Annotation | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const imageFileNameRef = useRef<string>('');
const { modal, openModal } = useImageViewerModal('裁剪预览');
const [imageUrl, setImageUrl] = useState<string>(SAMPLE_IMAGE_URL);
const { yesNo, MessageBoxComponent } = useMessageBox();
const { showToast, ToastComponent } = useToast();
const currentFileResult = useRef<FileResult | null>(null);
// 预加载图片
useEffect(() => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
setImage(img);
};
img.src = imageUrl;
}, [imageUrl]);
const handleImageLoad = useCallback((newImageUrl: string, shouldClearMetaData: boolean = true) => {
setImageUrl(newImageUrl);
if (shouldClearMetaData) {
// 只有在不是同时加载 meta 数据时才清空标注
updateImageMetaData(draft => {
draft.annotations = [];
draft.definitions = {};
});
setSelectedAnnotation(null);
setIsDirty(false);
}
}, []);
const handleAnnotationChange = (e: AnnotationChangedEvent) => {
if (e.type === 'add') {
let type: DefinitionType | undefined = undefined;
if (currentTool === EditorTool.Rect) {
type = 'template';
}
if (!type) {
showToast('danger', '错误', '无法识别的标注类型');
return;
}
updateImageMetaData(draft => {
draft.annotations.push(e.annotation);
draft.definitions[e.annotation.id] = {
name: '',
displayName: '',
type: type,
annotationId: e.annotation.id,
path: '',
useHintRect: false,
} as TemplateDefinition;
});
setIsDirty(true);
} else if (e.type === 'update') {
updateImageMetaData(draft => {
const oldAnnotation = draft.annotations.find(a => a.id === e.annotation.id);
if (!oldAnnotation) return;
Object.assign(oldAnnotation, e.annotation);
});
if (selectedAnnotation?.id === e.annotation.id) {
setSelectedAnnotation(e.annotation);
}
setIsDirty(true);
} else if (e.type === 'remove') {
updateImageMetaData(draft => {
const index = draft.annotations.findIndex(a => a.id === e.annotation.id);
if (index !== -1) {
draft.annotations.splice(index, 1);
}
});
if (selectedAnnotation?.id === e.annotation.id) {
setSelectedAnnotation(null);
updateImageMetaData(draft => {
delete draft.definitions[e.annotation.id];
});
}
setIsDirty(true);
}
};
const handleOpen = useCallback(async (useFileSystem: boolean = false) => {
// 如果有未保存的修改,显示确认对话框
if (isDirty) {
const result = await yesNo({
title: '未保存的修改',
text: '当前有未保存的修改,是否继续打开新图片?未保存的修改将会丢失。'
});
if (result === 'no') {
return;
}
}
try {
const openFunc = useFileSystem ? openFileWFS : openFileInput;
const result = await openFunc({
accept: 'image/*,.json',
multiple: true,
});
const imageFile = result.files.find((f: FileResult) => f.file.type.startsWith('image/'));
const jsonFile = result.files.find((f: FileResult) => f.file.name.endsWith('.json'));
if (imageFile) {
imageFileNameRef.current = imageFile.name;
const dataUrl = await readFileAsDataURL(imageFile.file);
handleImageLoad(dataUrl, !jsonFile);
}
// 保存文件句柄
if (jsonFile) {
currentFileResult.current = jsonFile;
try {
const metaData = await readFileAsJSON(jsonFile.file);
updateImageMetaData(draft => {
draft.annotations = metaData?.annotations || [];
draft.definitions = metaData?.definitions || {};
});
} catch (error) {
console.error('Failed to parse JSON file:', error);
throw new Error('JSON文件格式错误无法加载。');
}
}
if (imageFile && jsonFile) {
showToast('success', '加载成功', '已载入图片与 meta 数据');
} else if (imageFile) {
showToast('success', '加载成功', '已载入新图片');
} else if (jsonFile) {
showToast('success', '加载成功', '已载入 meta 数据');
}
} catch (error) {
await yesNo({
title: '错误',
text: error instanceof Error ? error.message : '加载文件时发生错误'
});
showToast('danger', '加载失败', '无法加载文件');
}
}, [handleImageLoad, isDirty, yesNo, showToast, updateImageMetaData]);
const handleUpload = useCallback(async () => {
await handleOpen(false);
}, [handleOpen]);
const handleDownload = useCallback(() => {
const data = imageMetaData;
const filename = imageFileNameRef.current ? `${imageFileNameRef.current}.json` : 'metadata.json';
downloadJSONToFile(data, filename);
setIsDirty(false);
}, [imageMetaData]);
const handleSave = useCallback(async () => {
if (currentFileResult.current?.fileSystem !== 'wfs') {
showToast('warning', '无法保存', '当前文件不是通过文件系统打开的');
return;
}
try {
const handle = await saveFileWFS(
currentFileResult.current?.handle,
JSON.stringify(imageMetaData),
imageFileNameRef.current ? `${imageFileNameRef.current}.json` : 'metadata.json'
);
// 更新文件句柄
if (handle !== currentFileResult.current?.handle) {
currentFileResult.current = {
file: await handle.getFile(),
name: (await handle.getFile()).name,
handle,
fileSystem: 'wfs'
};
}
setIsDirty(false);
showToast('success', '保存成功', '文件已保存');
} catch (error) {
console.error('Failed to save file:', error);
showToast('danger', '保存失败', '无法保存文件');
}
}, [currentFileResult, imageMetaData, showToast]);
const handleToolSelect = useCallback((id: string) => {
setCurrentTool(toolsMap[id]);
}, [toolsMap]);
const handleToolClick = useCallback((id: string) => {
if (id === 'upload') {
handleUpload();
} else if (id === 'open') {
handleOpen(true);
} else if (id === 'download') {
handleDownload();
} else if (id === 'save') {
handleSave();
}
}, [handleUpload, handleOpen, handleDownload, handleSave]);
const handleAnnotationSelect = (annotation: Annotation | null) => {
setSelectedAnnotation(annotation);
};
const handleDefinitionChange = (id: string, changes: Partial<TemplateDefinition>) => {
updateImageMetaData(draft => {
Object.assign(draft.definitions[id], changes);
const oldDef = draft.definitions[id];
const annotation = draft.annotations.find(a => a.id === id);
const displayName = changes.displayName || oldDef.displayName;
const name = changes.name || oldDef.name;
if (oldDef && annotation) {
annotation.tip = <Tip>{displayName} ({name})</Tip>;
}
});
};
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// 如果正在输入文本,不处理快捷键
console.log(e.target);
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
// 处理 Ctrl + S 保存快捷键
if (e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault(); // 阻止浏览器默认的保存行为
handleSave();
return;
}
const key = e.key.toLowerCase();
switch (key) {
case 'v':
setCurrentTool(EditorTool.Drag);
break;
case 'r':
setCurrentTool(EditorTool.Rect);
break;
case 'delete':
if (selectedAnnotation) {
handleAnnotationChange({
currentTool: EditorTool.Drag,
type: 'remove',
annotationType: 'rect',
annotation: selectedAnnotation
});
setSelectedAnnotation(null);
}
break;
}
}, [selectedAnnotation, handleAnnotationChange, handleSave]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
const properties = usePropertyGridData(
selectedAnnotation,
imageMetaData.definitions,
image,
(imageUrl) => openModal(imageUrl, { imageRendering: 'pixelated' }),
handleDefinitionChange,
imageFileNameRef.current,
imageMetaData.annotations,
currentFileResult.current
);
return (
<PageContainer>
<SideToolBar
tools={tools}
selectedToolId={currentTool}
onSelectTool={handleToolSelect}
onClickTool={handleToolClick}
/>
<EditorContainer>
<DragArea onImageLoad={handleImageLoad}>
<ImageEditor
image={imageUrl}
tool={currentTool}
annotations={imageMetaData.annotations}
onAnnotationChanged={handleAnnotationChange}
onAnnotationSelected={handleAnnotationSelect}
enableMask
showCrosshair
/>
</DragArea>
</EditorContainer>
<PropertyContainer>
<PropertyGrid properties={properties} />
</PropertyContainer>
{modal}
{MessageBoxComponent}
{ToastComponent}
</PageContainer>
);
};
export default ImageAnnotation;