/**
|
* 统一的图片处理服务
|
* 整合所有图片处理功能,提供统一的API接口
|
*/
|
|
import { hexToRgba } from './imageHelper';
|
import { log } from './logger';
|
|
// 路径常量定义
|
const PATH_CONFIG = {
|
THUMBNAILS: {
|
IMAGE_LEVEL: 'images/thumbnails/',
|
ANNOTATION: 'annotations/',
|
EXPORT: 'exports/'
|
}
|
};
|
|
class ImageProcessor {
|
constructor() {
|
this.cache = new Map();
|
}
|
|
/**
|
* 生成标注缩略图(统一入口)
|
* @param {string} imagePath - 图像路径
|
* @param {Object} annotation - 标注对象
|
* @param {string} projectPath - 项目路径
|
* @param {string} imageId - 图像ID
|
* @param {Object} options - 配置选项
|
* @returns {Promise<string|null>} 缩略图路径
|
*/
|
async generateAnnotationThumbnail(imagePath, annotation, projectPath, imageId, options = {}) {
|
const defaultOptions = {
|
thumbnailSize: 150,
|
paddingRatio: 0.15,
|
quality: 0.6
|
};
|
|
const config = { ...defaultOptions, ...options };
|
|
try {
|
if (annotation.type === 'image-level') {
|
return await this.generateImageLevelThumbnail(imagePath, projectPath, imageId, annotation.id);
|
} else {
|
return await this.generateRegionThumbnail(
|
imagePath,
|
annotation,
|
projectPath,
|
imageId,
|
config
|
);
|
}
|
} catch (error) {
|
console.error('[ImageProcessor] 生成缩略图失败:', error);
|
return null;
|
}
|
}
|
|
/**
|
* 生成图像级标注缩略图
|
* @private
|
*/
|
async generateImageLevelThumbnail(imagePath, projectPath, imageId, annotationId) {
|
const fileName = this.generateThumbnailFileName(annotationId, imageId, 'image-level');
|
|
try {
|
// 方案1:使用已有的主进程 API
|
if (window.electronAPI?.generateThumbnail) {
|
const fullPath = `${projectPath}/${fileName}`;
|
const result = await window.electronAPI.generateThumbnail(imagePath, fullPath, {
|
width: 400,
|
height: 400,
|
quality: 60
|
});
|
|
if (result && result.success) {
|
console.log('[ImageProcessor] 图像级缩略图创建成功(主进程):', fileName);
|
return fileName;
|
}
|
}
|
|
// 方案2:降级到 Canvas 处理(类似区域标注的处理方式)
|
console.log('[ImageProcessor] 使用 Canvas 生成图像级缩略图');
|
const imageDataUrl = await this.loadImageData(imagePath);
|
if (!imageDataUrl) {
|
console.error('[ImageProcessor] 无法加载图像数据');
|
return null;
|
}
|
|
// 创建一个虚拟的全图标注对象
|
const fullImageAnnotation = {
|
id: annotationId,
|
type: 'rectangle',
|
x: 0,
|
y: 0,
|
width: 999999, // 使用大值确保包含整张图
|
height: 999999,
|
color: '#409EFF'
|
};
|
|
// 复用区域缩略图生成逻辑,但调整参数以生成更大的缩略图
|
const largeConfig = {
|
thumbnailSize: 400, // 图像级使用更大的缩略图
|
paddingRatio: 0, // 不需要额外边距
|
quality: 0.6
|
};
|
|
return await this.generateRegionThumbnail(
|
imagePath,
|
fullImageAnnotation,
|
projectPath,
|
imageId,
|
largeConfig
|
);
|
|
} catch (error) {
|
console.error('[ImageProcessor] 创建图像级缩略图异常:', error);
|
return null;
|
}
|
}
|
|
/**
|
* 生成区域标注缩略图
|
* @private
|
*/
|
async generateRegionThumbnail(imagePath, annotation, projectPath, imageId, config) {
|
// 获取图像数据
|
const imageDataUrl = await this.loadImageData(imagePath);
|
if (!imageDataUrl) {
|
return null;
|
}
|
|
return new Promise((resolve, reject) => {
|
const img = new Image();
|
img.crossOrigin = 'anonymous';
|
|
img.onload = async () => {
|
try {
|
const canvas = document.createElement('canvas');
|
const ctx = canvas.getContext('2d');
|
|
// 计算裁剪区域
|
const cropRect = this.calculateCropRect(
|
annotation,
|
img.width,
|
img.height,
|
config.paddingRatio
|
);
|
|
// 计算缩略图尺寸(正方形)
|
const thumbSize = this.calculateThumbnailSize(cropRect, config.thumbnailSize);
|
|
// 设置画布尺寸为正方形
|
canvas.width = thumbSize.width;
|
canvas.height = thumbSize.height;
|
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingQuality = 'high';
|
|
// 直接绘制裁剪的正方形区域到画布
|
// 因为 cropRect 已经是正方形了,直接缩放即可
|
ctx.drawImage(
|
img,
|
cropRect.x, cropRect.y, cropRect.width, cropRect.height,
|
0, 0, thumbSize.width, thumbSize.height
|
);
|
|
// 绘制标注覆盖层
|
this.drawAnnotationOverlay(ctx, annotation, cropRect, thumbSize, config);
|
|
// 生成base64数据
|
const dataURL = canvas.toDataURL('image/jpeg', config.quality);
|
|
// 保存缩略图文件
|
const fileName = this.generateThumbnailFileName(annotation.id, imageId, 'region');
|
const saved = await this.saveThumbnail(projectPath, fileName, dataURL);
|
|
resolve(saved ? fileName : null);
|
} catch (error) {
|
console.error('[ImageProcessor] 处理区域缩略图失败:', error);
|
reject(error);
|
}
|
};
|
|
img.onerror = (error) => {
|
console.error('[ImageProcessor] 图像加载失败:', error);
|
reject(new Error('图像加载失败'));
|
};
|
|
img.src = imageDataUrl;
|
});
|
}
|
|
/**
|
* 加载图像数据
|
* @private
|
*/
|
async loadImageData(imagePath) {
|
if (window.electronAPI?.getLocalImageData) {
|
const result = await window.electronAPI.getLocalImageData(imagePath);
|
if (result.success) {
|
return result.data;
|
} else {
|
console.error('[ImageProcessor] 加载图像失败:', result.error);
|
return null;
|
}
|
} else {
|
console.error('[ImageProcessor] getLocalImageData API不可用');
|
return null;
|
}
|
}
|
|
/**
|
* 计算裁剪区域(生成正方形区域)
|
* @private
|
*/
|
calculateCropRect(annotation, imgWidth, imgHeight, paddingRatio) {
|
let cropX = 0, cropY = 0, cropWidth = 0, cropHeight = 0;
|
|
if (annotation.type === 'rectangle') {
|
cropX = annotation.x || annotation.startX || 0;
|
cropY = annotation.y || annotation.startY || 0;
|
cropWidth = annotation.width || 0;
|
cropHeight = annotation.height || 0;
|
|
// 尝试从startX/endX计算宽高
|
if (cropWidth === 0 && annotation.endX !== undefined && annotation.startX !== undefined) {
|
cropWidth = Math.abs(annotation.endX - annotation.startX);
|
}
|
if (cropHeight === 0 && annotation.endY !== undefined && annotation.startY !== undefined) {
|
cropHeight = Math.abs(annotation.endY - annotation.startY);
|
}
|
|
console.log('[ImageProcessor] 矩形裁剪区域:', { cropX, cropY, cropWidth, cropHeight });
|
} else if (annotation.type === 'line-polygon' && annotation.points?.length > 0) {
|
const xs = annotation.points.map(p => p.x);
|
const ys = annotation.points.map(p => p.y);
|
cropX = Math.min(...xs);
|
cropY = Math.min(...ys);
|
cropWidth = Math.max(...xs) - cropX;
|
cropHeight = Math.max(...ys) - cropY;
|
console.log('[ImageProcessor] 多边形裁剪区域:', { cropX, cropY, cropWidth, cropHeight });
|
} else if (annotation.type === 'point') {
|
const baseSize = 20;
|
cropX = (annotation.x || 0) - baseSize / 2;
|
cropY = (annotation.y || 0) - baseSize / 2;
|
cropWidth = baseSize;
|
cropHeight = baseSize;
|
}
|
|
// 安全检查:如果裁剪区域太小,使用整张图片
|
if (cropWidth < 5 || cropHeight < 5) {
|
console.warn('[ImageProcessor] 裁剪区域太小,使用整张图片');
|
const size = Math.min(imgWidth, imgHeight);
|
return { x: 0, y: 0, width: size, height: size };
|
}
|
|
// 添加内边距
|
const minPadding = 10;
|
const maxPadding = 50;
|
const horizontalPadding = Math.max(minPadding, Math.min(maxPadding, cropWidth * paddingRatio));
|
const verticalPadding = Math.max(minPadding, Math.min(maxPadding, cropHeight * paddingRatio));
|
|
// 扩展裁剪区域(含padding)
|
let expandedX = cropX - horizontalPadding;
|
let expandedY = cropY - verticalPadding;
|
let expandedWidth = cropWidth + (horizontalPadding * 2);
|
let expandedHeight = cropHeight + (verticalPadding * 2);
|
|
// 转换为正方形:选择最大边作为正方形边长
|
const squareSize = Math.max(expandedWidth, expandedHeight);
|
|
// 调整位置使标注居中在正方形中
|
if (expandedWidth > expandedHeight) {
|
// 宽度较大,垂直居中
|
expandedY = expandedY - (squareSize - expandedHeight) / 2;
|
} else {
|
// 高度较大,水平居中
|
expandedX = expandedX - (squareSize - expandedWidth) / 2;
|
}
|
|
// 确保不超出图像边界
|
let finalX = Math.max(0, expandedX);
|
let finalY = Math.max(0, expandedY);
|
let finalSize = squareSize;
|
|
// 如果超出右边或下边,需要调整
|
if (finalX + finalSize > imgWidth) {
|
finalX = Math.max(0, imgWidth - finalSize);
|
}
|
if (finalY + finalSize > imgHeight) {
|
finalY = Math.max(0, imgHeight - finalSize);
|
}
|
|
// 如果调整后仍然超出,缩小正方形
|
if (finalX + finalSize > imgWidth || finalY + finalSize > imgHeight) {
|
finalSize = Math.min(imgWidth - finalX, imgHeight - finalY);
|
}
|
|
return { x: finalX, y: finalY, width: finalSize, height: finalSize };
|
}
|
|
/**
|
* 计算缩略图尺寸(生成正方形)
|
* @private
|
*/
|
calculateThumbnailSize(cropRect, maxSize) {
|
// 始终返回正方形尺寸
|
return { width: maxSize, height: maxSize };
|
}
|
|
/**
|
* 绘制标注覆盖层
|
* @private
|
*/
|
drawAnnotationOverlay(ctx, annotation, cropRect, thumbSize, config = {}) {
|
if (!annotation || !ctx) return;
|
|
// 因为裁剪区域和缩略图都是正方形,缩放比例相同
|
const scale = thumbSize.width / cropRect.width;
|
|
// 根据categoryId从项目类别定义中查找颜色,否则使用默认色
|
let color = '#409EFF';
|
if (annotation.categoryId && config.projectTypes) {
|
const category = config.projectTypes.find(type => type.key === annotation.categoryId);
|
if (category && category.color) {
|
color = category.color;
|
}
|
}
|
// 如果标注本身有颜色,优先使用
|
if (annotation.color) {
|
color = annotation.color;
|
}
|
|
const lineWidth = 2;
|
|
ctx.save();
|
|
try {
|
if (annotation.type === 'rectangle') {
|
this.drawRectangleOverlay(ctx, annotation, cropRect, scale, color, lineWidth);
|
} else if (annotation.type === 'line-polygon') {
|
this.drawPolygonOverlay(ctx, annotation, cropRect, scale, color, lineWidth);
|
} else if (annotation.type === 'point') {
|
this.drawPointOverlay(ctx, annotation, cropRect, scale, color, lineWidth);
|
}
|
} catch (error) {
|
console.error('[ImageProcessor] 绘制覆盖层失败:', error);
|
}
|
|
ctx.restore();
|
}
|
|
/**
|
* 绘制矩形覆盖层
|
* @private
|
*/
|
drawRectangleOverlay(ctx, annotation, cropRect, scale, color, lineWidth) {
|
const x = (annotation.x - cropRect.x) * scale;
|
const y = (annotation.y - cropRect.y) * scale;
|
const width = annotation.width * scale;
|
const height = annotation.height * scale;
|
|
ctx.fillStyle = hexToRgba(color, 0.2);
|
ctx.fillRect(x, y, width, height);
|
ctx.strokeStyle = color;
|
ctx.lineWidth = lineWidth;
|
ctx.strokeRect(x, y, width, height);
|
}
|
|
/**
|
* 绘制多边形覆盖层
|
* @private
|
*/
|
drawPolygonOverlay(ctx, annotation, cropRect, scale, color, lineWidth) {
|
if (!annotation.points || annotation.points.length < 3) return;
|
|
ctx.beginPath();
|
const firstPoint = annotation.points[0];
|
ctx.moveTo(
|
(firstPoint.x - cropRect.x) * scale,
|
(firstPoint.y - cropRect.y) * scale
|
);
|
|
for (let i = 1; i < annotation.points.length; i++) {
|
const point = annotation.points[i];
|
ctx.lineTo(
|
(point.x - cropRect.x) * scale,
|
(point.y - cropRect.y) * scale
|
);
|
}
|
ctx.closePath();
|
|
ctx.fillStyle = hexToRgba(color, 0.2);
|
ctx.fill();
|
ctx.strokeStyle = color;
|
ctx.lineWidth = lineWidth;
|
ctx.stroke();
|
}
|
|
/**
|
* 绘制点覆盖层
|
* @private
|
*/
|
drawPointOverlay(ctx, annotation, cropRect, scale, color, lineWidth) {
|
const x = (annotation.x - cropRect.x) * scale;
|
const y = (annotation.y - cropRect.y) * scale;
|
const radius = 5;
|
|
ctx.beginPath();
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
ctx.fillStyle = color;
|
ctx.fill();
|
ctx.strokeStyle = '#ffffff';
|
ctx.lineWidth = lineWidth;
|
ctx.stroke();
|
}
|
|
/**
|
* 保存缩略图文件
|
* @private
|
*/
|
async saveThumbnail(projectPath, fileName, dataURL) {
|
if (window.electronAPI?.saveThumbnail) {
|
const result = await window.electronAPI.saveThumbnail(projectPath, fileName, dataURL);
|
if (result.success) {
|
console.log('[ImageProcessor] 缩略图保存成功:', fileName);
|
return true;
|
} else {
|
console.error('[ImageProcessor] 保存失败:', result.error);
|
return false;
|
}
|
} else {
|
console.warn('[ImageProcessor] saveThumbnail API不可用');
|
return false;
|
}
|
}
|
|
/**
|
* 生成缩略图文件名
|
* @private
|
*/
|
generateThumbnailFileName(annotationId, imageId, type = 'region') {
|
if (type === 'image-level') {
|
// 图像级标注使用原有的命名规则
|
return `${PATH_CONFIG.THUMBNAILS.ANNOTATION}thumb_${imageId}_${annotationId}.jpg`;
|
} else {
|
// 区域标注
|
return `${PATH_CONFIG.THUMBNAILS.ANNOTATION}thumb_${imageId}_${annotationId}.jpg`;
|
}
|
}
|
|
/**
|
* 创建图像级标注(整合自annotationUtils)
|
*/
|
async createImageLevelAnnotation(image, categoryId, projectPath = '') {
|
const annotationId = `ann_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
const imageId = image.id || `img_${Date.now()}`;
|
|
console.log('[ImageProcessor] ========== 创建图像级标注 ==========');
|
console.log('[ImageProcessor] 图片ID:', imageId);
|
console.log('[ImageProcessor] 类别ID:', categoryId);
|
console.log('[ImageProcessor] 图片路径:', image.path);
|
console.log('[ImageProcessor] 项目路径:', projectPath);
|
console.log('[ImageProcessor] 图片尺寸:', image.width, 'x', image.height);
|
|
// 生成缩略图
|
let thumbnailPath = null;
|
if (projectPath && image.path) {
|
// 处理图片路径
|
let imagePath;
|
if (image.path.startsWith('/') || image.path.startsWith('\\')) {
|
// 绝对路径
|
imagePath = image.path;
|
} else if (image.path.startsWith('images/')) {
|
// 相对于项目的路径(压缩后的图片通常在 images/compressed/ 或 images/raw/)
|
imagePath = `${projectPath}/${image.path}`;
|
} else {
|
// 其他相对路径
|
imagePath = `${projectPath}/${image.path}`;
|
}
|
|
console.log('[ImageProcessor] 处理后的图片路径:', imagePath);
|
console.log('[ImageProcessor] 开始生成缩略图...');
|
|
try {
|
thumbnailPath = await this.generateImageLevelThumbnail(imagePath, projectPath, imageId, annotationId);
|
console.log('[ImageProcessor] 缩略图生成成功,路径:', thumbnailPath);
|
} catch (error) {
|
console.error('[ImageProcessor] 缩略图生成失败:', error);
|
console.error('[ImageProcessor] 错误堆栈:', error.stack);
|
// 缩略图生成失败不影响标注创建
|
}
|
}
|
|
const annotation = {
|
id: annotationId,
|
type: 'image-level',
|
categoryId: categoryId, // 确保使用传入的 categoryId
|
x: 0,
|
y: 0,
|
width: image.width || 0,
|
height: image.height || 0,
|
points: null,
|
zIndex: 0,
|
thumbnailData: thumbnailPath
|
};
|
|
console.log('[ImageProcessor] 图像级标注创建完成:', annotation);
|
console.log('[ImageProcessor] ========== 图像级标注创建结束 ==========');
|
|
return annotation;
|
}
|
|
/**
|
* 批量生成缩略图
|
*/
|
async batchGenerateThumbnails(imagePath, annotations, projectPath, imageId, options = {}) {
|
const results = [];
|
|
for (const annotation of annotations) {
|
try {
|
const thumbnailPath = await this.generateAnnotationThumbnail(
|
imagePath,
|
annotation,
|
projectPath,
|
imageId,
|
options
|
);
|
results.push({ ...annotation, thumbnailData: thumbnailPath });
|
} catch (error) {
|
console.error(`[ImageProcessor] 批量处理失败 - ${annotation.id}:`, error);
|
results.push({ ...annotation, thumbnailData: null });
|
}
|
}
|
|
return results;
|
}
|
|
/**
|
* 获取缩略图URL
|
*/
|
getThumbnailUrl(annotation, projectPath) {
|
if (!annotation.thumbnailData) {
|
return '';
|
}
|
|
// base64数据直接返回
|
if (annotation.thumbnailData.startsWith('data:')) {
|
return annotation.thumbnailData;
|
}
|
|
// 绝对路径
|
if (annotation.thumbnailData.startsWith('/')) {
|
return `file://${annotation.thumbnailData}`;
|
}
|
|
// 相对路径
|
return `file://${projectPath}/${annotation.thumbnailData}`;
|
}
|
|
/**
|
* 清理缓存
|
*/
|
clearCache() {
|
this.cache.clear();
|
console.log('[ImageProcessor] 缓存已清理');
|
}
|
}
|
|
// 导出单例实例
|
const imageProcessor = new ImageProcessor();
|
|
export default imageProcessor;
|
|
// 导出便捷方法
|
export const {
|
generateAnnotationThumbnail,
|
createImageLevelAnnotation,
|
batchGenerateThumbnails,
|
getThumbnailUrl
|
} = {
|
generateAnnotationThumbnail: imageProcessor.generateAnnotationThumbnail.bind(imageProcessor),
|
createImageLevelAnnotation: imageProcessor.createImageLevelAnnotation.bind(imageProcessor),
|
batchGenerateThumbnails: imageProcessor.batchGenerateThumbnails.bind(imageProcessor),
|
getThumbnailUrl: imageProcessor.getThumbnailUrl.bind(imageProcessor)
|
};
|