You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
duoji-frontend/image-cropper.html

1342 lines
51 KiB
HTML

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片裁剪工具</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
width: 100%;
margin: 0;
padding: 20px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.images-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 30px;
width: 100%;
}
.image-item {
background: white;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.image-title {
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
.image-container {
position: relative;
display: inline-block;
margin-bottom: 10px;
}
.image {
max-width: 100%;
height: 500px;
object-fit: contain;
display: block;
}
.canvas {
position: absolute;
top: 0;
left: 0;
cursor: crosshair;
border: 1px solid #ccc;
}
.crop-info {
font-size: 12px;
color: #666;
margin-top: 5px;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.crop-btn {
background-color: #007bff;
color: white;
}
.crop-btn:hover {
background-color: #0056b3;
}
.reset-btn {
background-color: #6c757d;
color: white;
}
.reset-btn:hover {
background-color: #545b62;
}
.export-btn {
background-color: #28a745;
color: white;
padding: 12px 24px;
font-size: 16px;
margin: 20px auto;
display: block;
}
.export-btn:hover {
background-color: #218838;
}
.upload-section {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.file-input {
display: none;
}
.upload-label {
display: inline-block;
padding: 10px 20px;
background-color: #17a2b8;
color: white;
border-radius: 4px;
cursor: pointer;
margin: 0 10px;
}
.upload-label:hover {
background-color: #138496;
}
.cropped-preview {
margin-top: 10px;
border: 1px solid #ddd;
padding: 5px;
background-color: #f9f9f9;
}
.cropped-img {
max-width: 100%;
height: 150px;
object-fit: contain;
}
.delete-btn {
background-color: #dc3545;
color: white;
margin-top: 10px;
}
.delete-btn:hover {
background-color: #c82333;
}
.code-separator {
grid-column: 1 / -1;
border: 2px solid #007bff;
margin: 20px 0;
text-align: center;
font-weight: bold;
color: #007bff;
background-color: #f0f8ff;
padding: 10px;
border-radius: 8px;
}
.template-section {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.template-info {
font-size: 12px;
color: #666;
margin-top: 5px;
}
.template-btn {
background-color: #ffc107;
color: #333;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-left: 10px;
}
.template-btn:hover {
background-color: #e0a800;
}
.match-result {
margin-top: 10px;
padding: 10px;
border: 1px solid #007bff;
background-color: #e6f2ff;
border-radius: 4px;
}
.loading {
color: #666;
font-style: italic;
}
.error {
color: #dc3545;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>图片裁剪工具</h1>
<p>选择图片区域进行裁剪,然后导出所有裁剪结果</p>
</div>
<div id="imagesGrid" class="images-grid">
<!-- 动态生成的图片项目 -->
</div>
<button class="export-btn" onclick="exportAllCroppedImages()">导出所有裁剪图片</button>
</div>
<script>
// 存储裁剪后的图片数据
const croppedImages = {};
// 裁剪状态
const cropStates = {};
// 存储每个code的裁剪计数
const cropCounters = {};
// 模板匹配相关
let templateData = null; // 存储模板的像素数据
let templateCode = null; // 模板所属的code
let templateStreet = null; // 模板所属的street
// 初始化页面
function initializePage() {
const grid = document.getElementById('imagesGrid');
grid.innerHTML = '';
// 先按street分组,再按code分组
const groupedByStreet = {};
imagesData.forEach((item, index) => {
const street = item.street || 'unknown';
const code = item.code || `img_${index}`;
if (!groupedByStreet[street]) {
groupedByStreet[street] = {};
}
if (!groupedByStreet[street][code]) {
groupedByStreet[street][code] = [];
}
groupedByStreet[street][code].push({ ...item, index });
});
// 为每个street添加分隔符和图片
Object.keys(groupedByStreet).forEach((street, streetIndex) => {
// 添加street分隔符(如果不是第一个)
if (streetIndex > 0) {
const separator = document.createElement('div');
separator.className = 'code-separator';
separator.textContent = `Street: ${street}`;
separator.style.borderColor = '#28a745';
separator.style.color = '#28a745';
separator.style.backgroundColor = '#f0fff0';
grid.appendChild(separator);
}
// 为该street下的每个code添加分隔符和图片
const codes = Object.keys(groupedByStreet[street]);
codes.forEach((code, codeIndex) => {
// 添加code分隔符
if (codeIndex > 0 || streetIndex > 0) {
const separator = document.createElement('div');
separator.className = 'code-separator';
separator.textContent = `Code: ${code}`;
grid.appendChild(separator);
}
// 添加该code的所有图片
groupedByStreet[street][code].forEach((item) => {
const key = `${item.code}-1${item.index + 1}`;
const url = item.url;
const imageItem = createImageItem(key, url, code, item.street);
grid.appendChild(imageItem);
// 初始化裁剪计数器
if (!cropCounters[code]) {
cropCounters[code] = 0;
}
// 在元素添加到DOM后,添加鼠标悬停事件
const container = document.getElementById(`container-${key}`);
const img = document.getElementById(`img-${key}`);
if (container) {
container.addEventListener('mouseenter', function() {
if (!cropStates[key].cropping) {
startCrop(key);
}
});
}
});
});
});
}
// 创建单个图片项目
function createImageItem(key, url, code, street) {
const div = document.createElement('div');
div.className = 'image-item';
div.innerHTML = `
<div class="image-title">${key}</div>
<div class="image-container" id="container-${key}">
<img class="image" id="img-${key}" src="${url}" alt="图片 ${key}" crossorigin="anonymous" onerror="handleImageError('${key}')">
<canvas class="canvas" id="canvas-${key}" style="display: none;"></canvas>
</div>
<div class="crop-info" id="info-${key}">鼠标悬停开始裁剪</div>
<div class="button-group">
<button class="reset-btn" onclick="resetCrop('${key}')">重置裁剪</button>
<button class="template-btn" onclick="matchTemplate('${key}', '${code}', '${street || ''}')">匹配并裁剪</button>
<button class="delete-btn" onclick="deleteImage('${key}')">删除图片</button>
</div>
<div id="match-result-${key}"></div>
<div id="cropped-preview-${key}"></div>
`;
// 初始化裁剪状态
cropStates[key] = {
isSelecting: false,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
cropping: false
};
return div;
}
// 处理图片加载错误
function handleImageError(key) {
const url = "未知URL";
const container = document.getElementById(`container-${key}`);
container.innerHTML = `<div class="error">图片加载失败: ${url}</div>`;
}
// 开始裁剪
function startCrop(key) {
const img = document.getElementById(`img-${key}`);
const canvas = document.getElementById(`canvas-${key}`);
const info = document.getElementById(`info-${key}`);
if (!img.complete || img.naturalWidth === 0) {
info.textContent = '图片尚未加载完成';
return;
}
// 设置canvas大小和位置
canvas.width = img.clientWidth;
canvas.height = img.clientHeight;
canvas.style.display = 'block';
canvas.style.width = img.clientWidth + 'px';
canvas.style.height = img.clientHeight + 'px';
// 清除之前的裁剪
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 添加事件监听器
canvas.onmousedown = function(e) { startSelection(e, key); };
canvas.onmousemove = function(e) { updateSelection(e, key); };
canvas.onmouseup = function(e) { endSelection(e, key); };
canvas.onmouseleave = function(e) { endSelection(e, key); };
// 触摸事件支持
canvas.ontouchstart = function(e) { startSelection(e.touches[0], key); };
canvas.ontouchmove = function(e) { updateSelection(e.touches[0], key); };
canvas.ontouchend = function(e) { endSelection(e.changedTouches[0], key); };
cropStates[key].cropping = true;
info.textContent = '拖动鼠标选择裁剪区域';
}
// 开始选择
function startSelection(e, key) {
const canvas = document.getElementById(`canvas-${key}`);
const rect = canvas.getBoundingClientRect();
cropStates[key].isSelecting = true;
cropStates[key].startX = e.clientX - rect.left;
cropStates[key].startY = e.clientY - rect.top;
}
// 更新选择
function updateSelection(e, key) {
if (!cropStates[key].isSelecting) return;
const canvas = document.getElementById(`canvas-${key}`);
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
cropStates[key].endX = e.clientX - rect.left;
cropStates[key].endY = e.clientY - rect.top;
// 清除画布并绘制选择框
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#007bff';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
const x = Math.min(cropStates[key].startX, cropStates[key].endX);
const y = Math.min(cropStates[key].startY, cropStates[key].endY);
const width = Math.abs(cropStates[key].endX - cropStates[key].startX);
const height = Math.abs(cropStates[key].endY - cropStates[key].startY);
ctx.strokeRect(x, y, width, height);
// 更新信息
document.getElementById(`info-${key}`).textContent =
`选择区域: ${Math.round(x)}, ${Math.round(y)}, ${Math.round(width)}x${Math.round(height)}`;
}
// 结束选择
function endSelection(e, key) {
if (!cropStates[key].isSelecting) return;
cropStates[key].isSelecting = false;
const canvas = document.getElementById(`canvas-${key}`);
const ctx = canvas.getContext('2d');
const x = Math.min(cropStates[key].startX, cropStates[key].endX);
const y = Math.min(cropStates[key].startY, cropStates[key].endY);
const width = Math.abs(cropStates[key].endX - cropStates[key].startX);
const height = Math.abs(cropStates[key].endY - cropStates[key].startY);
if (width > 5 && height > 5) {
// 执行裁剪
performCrop(key, x, y, width, height);
} else {
document.getElementById(`info-${key}`).textContent = '选择区域太小,请重新选择';
}
}
// 执行裁剪
function performCrop(key, x, y, width, height) {
const img = document.getElementById(`img-${key}`);
const previewContainer = document.getElementById(`cropped-preview-${key}`);
// 提取code
const code = key.split('-')[0];
// 增加该code的裁剪计数
cropCounters[code] = (cropCounters[code] || 0) + 1;
// 创建canvas进行裁剪
const cropCanvas = document.createElement('canvas');
const cropCtx = cropCanvas.getContext('2d');
// 计算实际图片坐标
const scaleX = img.naturalWidth / img.clientWidth;
const scaleY = img.naturalHeight / img.clientHeight;
const actualX = x * scaleX;
const actualY = y * scaleY;
const actualWidth = width * scaleX;
const actualHeight = height * scaleY;
cropCanvas.width = actualWidth;
cropCanvas.height = actualHeight;
// 裁剪图片
cropCtx.drawImage(img, actualX, actualY, actualWidth, actualHeight,
0, 0, actualWidth, actualHeight);
// 转换为blob并存储
cropCanvas.toBlob(function(blob) {
if (!blob) {
console.error('Failed to create blob from canvas');
document.getElementById(`info-${key}`).textContent = '裁剪失败:无法生成图片数据';
return;
}
// 使用code-1 + 序号的格式,如130665-11, 130665-12
const cropKey = `${code}-1${cropCounters[code]}`;
croppedImages[cropKey] = blob;
// 显示预览
previewContainer.innerHTML = `
<div class="cropped-preview">
<div>裁剪预览 (${cropKey}):</div>
<img class="cropped-img" src="${URL.createObjectURL(blob)}" alt="裁剪结果">
</div>
`;
document.getElementById(`info-${key}`).textContent =
`裁剪完成: ${Math.round(actualWidth)}x${Math.round(actualHeight)} 像素 (${cropKey})`;
}, 'image/png');
}
// 重置裁剪
function resetCrop(key) {
const canvas = document.getElementById(`canvas-${key}`);
const previewContainer = document.getElementById(`cropped-preview-${key}`);
const info = document.getElementById(`info-${key}`);
// 隐藏canvas
canvas.style.display = 'none';
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 清除预览
previewContainer.innerHTML = '';
// 删除存储的裁剪图片
delete croppedImages[key];
// 重置状态
cropStates[key] = {
isSelecting: false,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
cropping: false
};
info.textContent = '点击图片开始裁剪';
}
// 在裁剪完成后,使用当前裁剪作为模板,识别相同巷道、相同code的其他图片
function matchTemplate(key, code, street) {
// 检查是否有裁剪的图片作为模板
const croppedPreview = document.getElementById(`cropped-preview-${key}`);
const croppedImg = croppedPreview?.querySelector('.cropped-img');
if (!croppedImg) {
alert('请先在当前图片上裁剪出一个区域作为模板!');
return;
}
// 获取模板图片
const templateImg = new Image();
templateImg.onload = function() {
// 提取模板像素数据
const templateCanvas = document.createElement('canvas');
templateCanvas.width = templateImg.naturalWidth;
templateCanvas.height = templateImg.naturalHeight;
const templateCtx = templateCanvas.getContext('2d');
templateCtx.drawImage(templateImg, 0, 0);
const templatePixels = templateCtx.getImageData(0, 0, templateCanvas.width, templateCanvas.height);
// 保存模板数据
templateData = templatePixels;
templateCode = code;
templateStreet = street;
// 在相同巷道、相同code的其他图片上进行匹配
autoMatchAllImages(code, street);
};
templateImg.src = croppedImg.src;
}
// 自动在所有匹配的图片上进行模板匹配和裁剪
function autoMatchAllImages(targetCode, targetStreet) {
if (!templateData) {
return;
}
const results = [];
const matchedKeys = [];
// 遍历所有图片
imagesData.forEach((item, index) => {
const currentCode = item.code;
const currentStreet = item.street;
const key = `${item.code}-1${item.index + 1}`;
// 只处理相同巷道和相同code的图片
if (currentCode === targetCode && currentStreet === targetStreet) {
const img = document.getElementById(`img-${key}`);
if (!img || !img.complete) return;
// 执行模板匹配
const matchResult = findBestMatch(img, templateData);
if (matchResult && matchResult.score > 0.7) {
results.push({
key: key,
score: matchResult.score,
x: matchResult.x,
y: matchResult.y,
width: matchResult.width,
height: matchResult.height
});
matchedKeys.push(key);
}
}
});
// 根据匹配结果自动裁剪
results.forEach(result => {
if (result.key !== `${templateCode}-11`) { // 不裁剪模板图片本身
autoCropByMatch(result);
}
});
// 提示结果
if (results.length > 0) {
alert(`在 ${results.length} 张图片中找到匹配并自动裁剪!`);
} else {
alert('未找到匹配的图片!');
}
}
// 使用像素对比进行模板匹配(不使用OpenCV)
function findBestMatch(img, templateData) {
const targetCanvas = document.createElement('canvas');
targetCanvas.width = img.naturalWidth;
targetCanvas.height = img.naturalHeight;
const targetCtx = targetCanvas.getContext('2d');
targetCtx.drawImage(img, 0, 0);
const targetPixels = targetCtx.getImageData(0, 0, targetCanvas.width, targetCanvas.height);
const templateWidth = templateData.width;
const templateHeight = templateData.height;
const targetWidth = targetPixels.width;
const targetHeight = targetPixels.height;
// 模板不能比目标图片大
if (templateWidth > targetWidth || templateHeight > targetHeight) {
return null;
}
let bestMatch = { score: 0, x: 0, y: 0, width: templateWidth, height: templateHeight };
const step = 5; // 步长,提高性能
// 滑动窗口匹配
for (let y = 0; y <= targetHeight - templateHeight; y += step) {
for (let x = 0; x <= targetWidth - templateWidth; x += step) {
const score = comparePixelRegion(targetPixels, templateData, x, y, templateWidth, templateHeight);
if (score > bestMatch.score) {
bestMatch = { score, x, y, width: templateWidth, height: templateHeight };
}
}
}
return bestMatch.score > 0.7 ? bestMatch : null;
}
// 比较两个像素区域的相似度
function comparePixelRegion(targetPixels, templatePixels, startX, startY, width, height) {
let totalDiff = 0;
let totalPixels = 0;
for (let y = 0; y < height; y += 2) { // 每2个像素采样一次,提高性能
for (let x = 0; x < width; x += 2) {
const targetIndex = ((startY + y) * targetPixels.width + (startX + x)) * 4;
const templateIndex = (y * templatePixels.width + x) * 4;
// 计算RGB差异
const diffR = Math.abs(targetPixels.data[targetIndex] - templatePixels.data[templateIndex]);
const diffG = Math.abs(targetPixels.data[targetIndex + 1] - templatePixels.data[templateIndex + 1]);
const diffB = Math.abs(targetPixels.data[targetIndex + 2] - templatePixels.data[templateIndex + 2]);
totalDiff += (diffR + diffG + diffB) / 3;
totalPixels++;
}
}
const avgDiff = totalDiff / totalPixels;
// 转换为相似度(0-1)
const similarity = Math.max(0, 1 - (avgDiff / 255));
return similarity;
}
// 根据匹配结果自动裁剪
function autoCropByMatch(result) {
const { key, x, y, width, height } = result;
const img = document.getElementById(`img-${key}`);
if (!img) return;
// 计算缩放比例
const scaleX = img.clientWidth / img.naturalWidth;
const scaleY = img.clientHeight / img.naturalHeight;
// 转换到显示坐标
const displayX = x * scaleX;
const displayY = y * scaleY;
const displayWidth = width * scaleX;
const displayHeight = height * scaleY;
// 执行裁剪
performCrop(key, displayX, displayY, displayWidth, displayHeight);
}
// 删除图片
function deleteImage(key) {
// 从imagesData中移除
const index = imagesData.findIndex(item => {
const itemKey = item.code ? `${item.code}-1${imagesData.indexOf(item) + 1}` : `img_${imagesData.indexOf(item) + 1}`;
return itemKey === key;
});
if (index !== -1) {
imagesData.splice(index, 1);
}
// 清除相关数据
delete croppedImages[key];
delete cropStates[key];
// 重新初始化页面
initializePage();
}
// 按巷道导出
function exportByStreet(street) {
const croppedKeys = Object.keys(croppedImages);
const streetImages = [];
// 筛选出该巷道的图片
croppedKeys.forEach(key => {
const code = key.split('-')[0];
const item = imagesData.find(img => img.code === code && img.street === street);
if (item) {
streetImages.push(key);
}
});
if (streetImages.length === 0) {
alert(`巷道 ${street} 没有裁剪的图片可以导出!`);
return;
}
// 创建ZIP文件
createZipFileByStreet(street, streetImages);
}
// 为特定巷道创建ZIP文件
async function createZipFileByStreet(street, imageKeys) {
try {
const files = [];
// 收集该巷道的文件数据
for (const key of imageKeys) {
const blob = croppedImages[key];
const arrayBuffer = await blobToArrayBuffer(blob);
const fileName = `${key}.png`;
files.push({
name: fileName,
data: new Uint8Array(arrayBuffer)
});
}
// 生成ZIP文件
const zipBlob = createSimpleZip(files);
// 下载ZIP文件
const fileName = `${street}.zip`;
downloadFile(zipBlob, fileName);
alert(`已导出巷道 ${street} 的 ${imageKeys.length} 个裁剪图片`);
} catch (error) {
console.error('导出ZIP失败:', error);
alert('导出ZIP文件失败,请重试!');
}
}
// 为每个巷道添加导出按钮
function addStreetExportButtons() {
const grid = document.getElementById('imagesGrid');
const streets = new Set(imagesData.map(item => item.street));
streets.forEach(street => {
const exportBtn = document.createElement('button');
exportBtn.className = 'export-btn';
exportBtn.style.margin = '10px auto';
exportBtn.style.display = 'block';
exportBtn.textContent = `导出巷道 ${street}`;
exportBtn.onclick = () => exportByStreet(street);
grid.appendChild(exportBtn);
});
}
// 修改初始化页面函数,添加导出按钮
const originalInitializePage = initializePage;
initializePage = function() {
originalInitializePage();
addStreetExportButtons();
};
// 创建并下载所有裁剪图片为ZIP
async function exportAllCroppedImages() {
const croppedKeys = Object.keys(croppedImages);
if (croppedKeys.length === 0) {
alert('没有裁剪的图片可以导出!');
return;
}
try {
// 创建ZIP文件
const zipBlob = await createZipFile();
// 下载ZIP文件
const fileName = `cropped_images_${new Date().getTime()}.zip`;
downloadFile(zipBlob, fileName);
alert(`已导出 ${croppedKeys.length} 个裁剪图片到ZIP文件\n文件格式:PNG\n文件名格式:130665-11.png, 130665-12.png`);
} catch (error) {
console.error('导出ZIP失败:', error);
alert('导出ZIP文件失败,请重试!');
}
}
// 创建ZIP文件
async function createZipFile() {
const files = [];
// 收集所有文件数据
for (const key of Object.keys(croppedImages)) {
const blob = croppedImages[key];
const arrayBuffer = await blobToArrayBuffer(blob);
const fileName = `${key}.png`;
files.push({
name: fileName,
data: new Uint8Array(arrayBuffer)
});
}
// 生成ZIP文件
return createSimpleZip(files);
}
// Blob转ArrayBuffer
function blobToArrayBuffer(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = function() {
resolve(reader.result);
};
reader.readAsArrayBuffer(blob);
});
}
// 创建BMP文件数据
async function createBMPData(pngBlob) {
try {
// 创建一个canvas来转换图片格式
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 创建一个Promise来等待图片加载
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = URL.createObjectURL(pngBlob);
});
// 设置canvas尺寸
canvas.width = img.width;
canvas.height = img.height;
// 绘制图片到canvas
ctx.drawImage(img, 0, 0);
// 获取图片数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
const width = canvas.width;
const height = canvas.height;
const bytesPerPixel = 3; // RGB
const rowSize = Math.ceil((bytesPerPixel * width) / 4) * 4; // 行必须是4字节对齐
const pixelDataSize = rowSize * height;
const fileSize = 54 + pixelDataSize; // 54字节头部 + 像素数据
const bmpData = new Uint8Array(fileSize);
// BMP文件头 (14字节)
bmpData[0] = 0x42; bmpData[1] = 0x4D; // 'BM'
bmpData[2] = fileSize & 0xFF;
bmpData[3] = (fileSize >> 8) & 0xFF;
bmpData[4] = (fileSize >> 16) & 0xFF;
bmpData[5] = (fileSize >> 24) & 0xFF;
// 保留字段 (6-9) = 0
// 数据偏移 (10-13) = 54
bmpData[10] = 54; bmpData[11] = 0; bmpData[12] = 0; bmpData[13] = 0;
// BMP信息头 (40字节)
// 信息头大小 (14-17) = 40
bmpData[14] = 40; bmpData[15] = 0; bmpData[16] = 0; bmpData[17] = 0;
// 宽度 (18-21)
bmpData[18] = width & 0xFF;
bmpData[19] = (width >> 8) & 0xFF;
bmpData[20] = (width >> 16) & 0xFF;
bmpData[21] = (width >> 24) & 0xFF;
// 高度 (22-25)
bmpData[22] = height & 0xFF;
bmpData[23] = (height >> 8) & 0xFF;
bmpData[24] = (height >> 16) & 0xFF;
bmpData[25] = (height >> 24) & 0xFF;
// 平面数 (26-27) = 1
bmpData[26] = 1; bmpData[27] = 0;
// 位深度 (28-29) = 24
bmpData[28] = 24; bmpData[29] = 0;
// 压缩方式 (30-33) = 0 (无压缩)
// 图像大小 (34-37) = 0 (无压缩时可以为0)
// X分辨率 (38-41) = 0
// Y分辨率 (42-45) = 0
// 颜色数 (46-49) = 0
// 重要颜色数 (50-53) = 0
// 像素数据 (从54开始)
// BMP是从下到上存储的,每行4字节对齐
let pixelIndex = 54;
for (let y = height - 1; y >= 0; y--) {
for (let x = 0; x < width; x++) {
const srcIndex = (y * width + x) * 4;
// BMP是BGR格式
bmpData[pixelIndex++] = pixels[srcIndex + 2]; // B
bmpData[pixelIndex++] = pixels[srcIndex + 1]; // G
bmpData[pixelIndex++] = pixels[srcIndex + 0]; // R
}
// 行填充到4字节对齐
const padding = rowSize - (width * bytesPerPixel);
for (let p = 0; p < padding; p++) {
bmpData[pixelIndex++] = 0;
}
}
// 清理URL
URL.revokeObjectURL(img.src);
return bmpData;
} catch (error) {
console.error('BMP转换失败:', error);
// 如果转换失败,返回PNG数据(虽然扩展名是.bmp,但至少有数据)
return new Uint8Array(await pngBlob.arrayBuffer());
}
}
// 创建完整的ZIP文件
function createSimpleZip(files) {
const encoder = new TextEncoder();
let zipData = new Uint8Array(0);
const centralDirectory = [];
let offset = 0;
// 1. 添加所有文件的本地文件头和数据
for (const file of files) {
const fileNameBytes = encoder.encode(file.name);
const fileData = file.data;
// ZIP本地文件头 (30字节 + 文件名长度 + 文件数据)
const localFileHeader = new Uint8Array(30 + fileNameBytes.length);
// 本地文件头签名
localFileHeader[0] = 0x50; localFileHeader[1] = 0x4B; localFileHeader[2] = 0x03; localFileHeader[3] = 0x04;
// 版本 (20)
localFileHeader[4] = 20; localFileHeader[5] = 0;
// 标志
localFileHeader[6] = 0; localFileHeader[7] = 0;
// 压缩方法 (0 = 无压缩)
localFileHeader[8] = 0; localFileHeader[9] = 0;
// 修改时间和日期
const now = new Date();
const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() / 2)) & 0xFFFF;
const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xFFFF;
localFileHeader[10] = dosTime & 0xFF;
localFileHeader[11] = (dosTime >> 8) & 0xFF;
localFileHeader[12] = dosDate & 0xFF;
localFileHeader[13] = (dosDate >> 8) & 0xFF;
// CRC32 (简化为0)
localFileHeader[14] = 0; localFileHeader[15] = 0; localFileHeader[16] = 0; localFileHeader[17] = 0;
// 压缩大小
const compressedSize = fileData.length;
localFileHeader[18] = compressedSize & 0xFF;
localFileHeader[19] = (compressedSize >> 8) & 0xFF;
localFileHeader[20] = (compressedSize >> 16) & 0xFF;
localFileHeader[21] = (compressedSize >> 24) & 0xFF;
// 未压缩大小
localFileHeader[22] = compressedSize & 0xFF;
localFileHeader[23] = (compressedSize >> 8) & 0xFF;
localFileHeader[24] = (compressedSize >> 16) & 0xFF;
localFileHeader[25] = (compressedSize >> 24) & 0xFF;
// 文件名长度
const fileNameLength = fileNameBytes.length;
localFileHeader[26] = fileNameLength & 0xFF;
localFileHeader[27] = (fileNameLength >> 8) & 0xFF;
// 额外字段长度
localFileHeader[28] = 0; localFileHeader[29] = 0;
// 文件名
localFileHeader.set(fileNameBytes, 30);
// 记录中央目录信息
centralDirectory.push({
fileName: file.name,
fileNameBytes: fileNameBytes,
fileData: fileData,
offset: offset,
compressedSize: compressedSize,
uncompressedSize: compressedSize,
crc32: 0,
modTime: dosTime,
modDate: dosDate
});
// 合并本地文件头和数据
const fileContent = new Uint8Array(localFileHeader.length + fileData.length);
fileContent.set(localFileHeader);
fileContent.set(fileData, localFileHeader.length);
// 添加到ZIP数据
const newZipData = new Uint8Array(zipData.length + fileContent.length);
newZipData.set(zipData);
newZipData.set(fileContent, zipData.length);
zipData = newZipData;
offset += fileContent.length;
}
// 2. 添加中央目录
const centralDirStart = offset;
let centralDirData = new Uint8Array(0);
for (const entry of centralDirectory) {
// 中央目录文件头 (46字节 + 文件名长度)
const centralHeader = new Uint8Array(46 + entry.fileNameBytes.length);
// 中央目录签名
centralHeader[0] = 0x50; centralHeader[1] = 0x4B; centralHeader[2] = 0x01; centralHeader[3] = 0x02;
// 版本 made by (20)
centralHeader[4] = 20; centralHeader[5] = 0;
// 版本 needed to extract (20)
centralHeader[6] = 20; centralHeader[7] = 0;
// 标志
centralHeader[8] = 0; centralHeader[9] = 0;
// 压缩方法
centralHeader[10] = 0; centralHeader[11] = 0;
// 修改时间
centralHeader[12] = entry.modTime & 0xFF;
centralHeader[13] = (entry.modTime >> 8) & 0xFF;
centralHeader[14] = entry.modDate & 0xFF;
centralHeader[15] = (entry.modDate >> 8) & 0xFF;
// CRC32
centralHeader[16] = entry.crc32 & 0xFF;
centralHeader[17] = (entry.crc32 >> 8) & 0xFF;
centralHeader[18] = (entry.crc32 >> 16) & 0xFF;
centralHeader[19] = (entry.crc32 >> 24) & 0xFF;
// 压缩大小
centralHeader[20] = entry.compressedSize & 0xFF;
centralHeader[21] = (entry.compressedSize >> 8) & 0xFF;
centralHeader[22] = (entry.compressedSize >> 16) & 0xFF;
centralHeader[23] = (entry.compressedSize >> 24) & 0xFF;
// 未压缩大小
centralHeader[24] = entry.uncompressedSize & 0xFF;
centralHeader[25] = (entry.uncompressedSize >> 8) & 0xFF;
centralHeader[26] = (entry.uncompressedSize >> 16) & 0xFF;
centralHeader[27] = (entry.uncompressedSize >> 24) & 0xFF;
// 文件名长度
const fileNameLength = entry.fileNameBytes.length;
centralHeader[28] = fileNameLength & 0xFF;
centralHeader[29] = (fileNameLength >> 8) & 0xFF;
// 额外字段长度
centralHeader[30] = 0; centralHeader[31] = 0;
// 文件注释长度
centralHeader[32] = 0; centralHeader[33] = 0;
// 磁盘号
centralHeader[34] = 0; centralHeader[35] = 0;
// 内部文件属性
centralHeader[36] = 0; centralHeader[37] = 0;
// 外部文件属性
centralHeader[38] = 0; centralHeader[39] = 0; centralHeader[40] = 0; centralHeader[41] = 0;
// 本地文件头偏移
centralHeader[42] = entry.offset & 0xFF;
centralHeader[43] = (entry.offset >> 8) & 0xFF;
centralHeader[44] = (entry.offset >> 16) & 0xFF;
centralHeader[45] = (entry.offset >> 24) & 0xFF;
// 文件名
centralHeader.set(entry.fileNameBytes, 46);
// 添加到中央目录数据
const newCentralDirData = new Uint8Array(centralDirData.length + centralHeader.length);
newCentralDirData.set(centralDirData);
newCentralDirData.set(centralHeader, centralDirData.length);
centralDirData = newCentralDirData;
}
// 3. 添加中央目录结束记录
const centralDirSize = centralDirData.length;
const totalEntries = centralDirectory.length;
// ZIP结束记录 (22字节)
const endRecord = new Uint8Array(22);
// 结束记录签名
endRecord[0] = 0x50; endRecord[1] = 0x4B; endRecord[2] = 0x05; endRecord[3] = 0x06;
// 磁盘号
endRecord[4] = 0; endRecord[5] = 0;
// 中央目录磁盘号
endRecord[6] = 0; endRecord[7] = 0;
// 本磁盘记录数
endRecord[8] = totalEntries & 0xFF;
endRecord[9] = (totalEntries >> 8) & 0xFF;
// 总记录数
endRecord[10] = totalEntries & 0xFF;
endRecord[11] = (totalEntries >> 8) & 0xFF;
// 中央目录大小
endRecord[12] = centralDirSize & 0xFF;
endRecord[13] = (centralDirSize >> 8) & 0xFF;
endRecord[14] = (centralDirSize >> 16) & 0xFF;
endRecord[15] = (centralDirSize >> 24) & 0xFF;
// 中央目录偏移
endRecord[16] = centralDirStart & 0xFF;
endRecord[17] = (centralDirStart >> 8) & 0xFF;
endRecord[18] = (centralDirStart >> 16) & 0xFF;
endRecord[19] = (centralDirStart >> 24) & 0xFF;
// ZIP文件注释长度
endRecord[20] = 0; endRecord[21] = 0;
// 合并所有数据
const finalZipData = new Uint8Array(zipData.length + centralDirData.length + endRecord.length);
finalZipData.set(zipData);
finalZipData.set(centralDirData, zipData.length);
finalZipData.set(endRecord, zipData.length + centralDirData.length);
return new Blob([finalZipData], { type: 'application/zip' });
}
// 下载文件
function downloadFile(blob, fileName) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// 处理文件上传
function handleFileUpload(event) {
const files = event.target.files;
const grid = document.getElementById('imagesGrid');
grid.innerHTML = '';
// 清空之前的数据
Object.keys(croppedImages).forEach(key => delete croppedImages[key]);
Object.keys(cropStates).forEach(key => delete cropStates[key]);
Array.from(files).forEach((file, index) => {
const key = String(index + 1);
const reader = new FileReader();
reader.onload = function(e) {
imagesData[key] = e.target.result;
const imageItem = createImageItem(key, e.target.result);
grid.appendChild(imageItem);
};
reader.readAsDataURL(file);
});
}
// 加载默认示例图片
function loadDefaultImages() {
// 使用一些在线的示例图片
const sampleImages = {
"1": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+CiAgPHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxOCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPuWbvueJh+iAheWbvTwvdGV4dD4KICA8Y2lyY2xlIGN4PSI3NSIgY3k9IjUwIiByPSIyMCIgZmlsbD0iIzY2NiIvPgogIDxjaXJjbGUgY3g9IjIyNSIgY3k9IjUwIiByPSIyMCIgZmlsbD0iIzk5OSIvPgogIDxyZWN0IHg9IjUwIiB5PSIxMDAiIHdpZHRoPSIyMDAiIGhlaWdodD0iNTAiIGZpbGw9IiNjY2MiLz4KPC9zdmc+",
"2": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRmIi8+CiAgPHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxOCIgZmlsbD0iIzY2NiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPuS4lueVjOWbvueJhzwvdGV4dD4KICA8cG9seWdvbiBwb2ludHM9IjE1MCwzMCAxODAsMTAwIDEyMCwxMDAiIGZpbGw9IiM0NGQiLz4KICA8cmVjdCB4PSI1MCIgeT0iMTIwIiB3aWR0aD0iNjAiIGhlaWdodD0iNDAiIGZpbGw9IiNlZWUiLz4KICA8cmVjdCB4PSIxOTAiIHk9IjEyMCIgd2lkdGg9IjYwIiBoZWlnaHQ9IjQwIiBmaWxsPSIjZWVlIi8+Cjwvc3ZnPg=="
};
// 清空之前的数据
Object.keys(croppedImages).forEach(key => delete croppedImages[key]);
Object.keys(cropStates).forEach(key => delete cropStates[key]);
// 更新imagesData
Object.assign(imagesData, sampleImages);
// 重新初始化页面
initializePage();
}
// 按巷道导出
function exportByStreet(street) {
const croppedKeys = Object.keys(croppedImages);
const streetImages = [];
// 筛选出该巷道的图片
croppedKeys.forEach(key => {
const code = key.split('-')[0];
const item = imagesData.find(img => img.code === code && img.street === street);
if (item) {
streetImages.push(key);
}
});
if (streetImages.length === 0) {
alert(`巷道 ${street} 没有裁剪的图片可以导出!`);
return;
}
// 创建ZIP文件
createZipFileByStreet(street, streetImages);
}
// 为特定巷道创建ZIP文件
async function createZipFileByStreet(street, imageKeys) {
try {
const files = [];
// 收集该巷道的文件数据
for (const key of imageKeys) {
const blob = croppedImages[key];
const arrayBuffer = await blobToArrayBuffer(blob);
const fileName = `${key}.png`;
files.push({
name: fileName,
data: new Uint8Array(arrayBuffer)
});
}
// 生成ZIP文件
const zipBlob = createSimpleZip(files);
// 下载ZIP文件
const fileName = `${street}.zip`;
downloadFile(zipBlob, fileName);
alert(`已导出巷道 ${street} 的 ${imageKeys.length} 个裁剪图片`);
} catch (error) {
console.error('导出ZIP失败:', error);
alert('导出ZIP文件失败,请重试!');
}
}
// 为每个巷道添加导出按钮
function addStreetExportButtons() {
const grid = document.getElementById('imagesGrid');
const streets = new Set(imagesData.map(item => item.street));
streets.forEach(street => {
const exportBtn = document.createElement('button');
exportBtn.className = 'export-btn';
exportBtn.style.margin = '10px auto';
exportBtn.style.display = 'block';
exportBtn.textContent = `导出巷道 ${street}`;
exportBtn.onclick = () => exportByStreet(street);
grid.appendChild(exportBtn);
});
}
// 修改初始化页面函数,添加导出按钮
const originalInitializePage = initializePage;
initializePage = function() {
originalInitializePage();
addStreetExportButtons();
};
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initializePage);
// 图片数据对象
const imagesData = [
{"code":"130665", "street":"20", "url":"http://10.17.152.53:8007/fileData/20251229206649/40858/130665_error.jpg"}
];
</script>
</body>
</html>