这是一个图片批量转灰度的HTML工具,支持多张图片同时处理,并一键下载转换后的黑白图片。

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">图片批量灰度工具 · 黑白转换</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
body {
background: #f2f4f8;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.card {
max-width: 800px;
width: 100%;
background: white;
border-radius: 32px;
box-shadow: 0 12px 30px rgba(0,0,0,0.08);
padding: 30px 28px;
transition: 0.2s;
}
h1 {
font-size: 26px;
font-weight: 620;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.sub {
color: #5f6b7a;
font-size: 15px;
margin-bottom: 24px;
border-left: 4px solid #1e293b;
padding-left: 16px;
background: #f8fafc;
border-radius: 0 8px 8px 0;
line-height: 1.5;
}
.drop-area {
border: 2px dashed #cdd5df;
background: #fafcff;
border-radius: 22px;
padding: 42px 20px;
text-align: center;
cursor: pointer;
transition: 0.2s;
position: relative;
}
.drop-area:hover {
border-color: #3b82f6;
background: #f0f6ff;
}
.drop-area.dragging {
border-color: #2563eb;
background: #eaf1fe;
box-shadow: 0 0 0 4px rgba(59,130,246,0.2);
}
.drop-icon {
font-size: 46px;
line-height: 1;
margin-bottom: 10px;
color: #4b5a6e;
}
.drop-text {
font-size: 16px;
font-weight: 500;
color: #1e293b;
}
.drop-hint {
font-size: 14px;
color: #6c7a8e;
margin-top: 8px;
}
.file-input {
display: none;
}
.img-grid {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 28px;
justify-content: center;
}
.img-card {
background: white;
border-radius: 20px;
box-shadow: 0 4px 14px rgba(0,0,0,0.04);
border: 1px solid #e9ecf2;
overflow: hidden;
flex: 1 1 150px;
max-width: 200px;
min-width: 130px;
transition: 0.1s;
}
.img-card figure {
margin: 0;
padding: 8px 8px 0 8px;
}
.img-card img {
width: 100%;
height: 130px;
object-fit: cover;
border-radius: 12px;
background: #f1f3f7;
display: block;
}
.img-card .info {
padding: 10px 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #1e293b;
background: #ffffff;
}
.img-card .filename {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 90px;
font-weight: 480;
}
.img-card .badge {
background: #eef2f6;
padding: 4px 10px;
border-radius: 30px;
font-size: 11px;
font-weight: 500;
color: #1a2634;
}
.action-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-top: 28px;
gap: 12px;
}
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: white;
border: 1px solid #d0d7e2;
padding: 10px 22px;
border-radius: 60px;
font-size: 14px;
font-weight: 500;
color: #1a2634;
cursor: pointer;
transition: 0.15s;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.02);
}
.btn-primary {
background: #1e293b;
border: 1px solid #1e293b;
color: white;
box-shadow: 0 2px 8px rgba(30,41,59,0.15);
}
.btn-primary:hover {
background: #0f172b;
border-color: #0f172b;
box-shadow: 0 4px 12px rgba(30,41,59,0.25);
}
.btn-primary:active {
transform: scale(0.96);
}
.btn-outline {
border: 1px solid #d0d7e2;
background: white;
}
.btn-outline:hover {
background: #f4f7fc;
border-color: #b3bfcf;
}
.btn:disabled {
opacity: 0.5;
pointer-events: none;
filter: grayscale(0.3);
}
.btn-download {
background: #0f172b;
border: 1px solid #0f172b;
color: white;
}
.btn-download:hover {
background: #020617;
}
.status-msg {
margin-top: 18px;
padding: 12px 18px;
border-radius: 60px;
background: #f8fafc;
color: #334155;
font-size: 14px;
border: 1px solid #e5e9f0;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.status-msg .count {
background: #e4e9f2;
padding: 2px 12px;
border-radius: 50px;
font-weight: 500;
}
.status-msg .action-hint {
color: #3b82f6;
font-weight: 450;
}
.footer-note {
margin-top: 18px;
font-size: 13px;
color: #6b7b8f;
text-align: center;
border-top: 1px solid #eef2f6;
padding-top: 18px;
}
@media (max-width: 600px) {
.card { padding: 20px 16px; }
.img-card { flex: 1 1 120px; min-width: 100px; }
.img-card img { height: 100px; }
.btn { padding: 8px 16px; font-size: 13px; }
.action-bar { flex-direction: column; align-items: stretch; }
.btn-group { justify-content: stretch; }
.btn-group .btn { flex: 1; }
}
</style>
</head>
<body>
<div class="card">
<h1>
<span>⚫</span> 批量灰度转换
</h1>
<div class="sub">拖放或选择图片 — 转黑白 · 一键下载全部</div>
<!-- 拖放区 -->
<div class="drop-area" id="dropArea">
<div class="drop-icon">🖼️</div>
<div class="drop-text">点击选择图片 或 拖拽到此处</div>
<div class="drop-hint">支持 jpg, png, webp, bmp · 多选批量</div>
<input type="file" id="fileInput" class="file-input" multiple accept="image/png, image/jpeg, image/webp, image/bmp, image/tiff">
</div>
<!-- 缩略图网格 -->
<div class="img-grid" id="imgGrid"></div>
<!-- 操作栏 -->
<div class="action-bar">
<div class="btn-group">
<button class="btn btn-outline" id="addMoreBtn">➕ 添加更多</button>
<button class="btn btn-outline" id="clearBtn">🗑️ 清空</button>
</div>
<div class="btn-group">
<button class="btn btn-primary" id="convertBtn">⚡ 转为灰度</button>
<button class="btn btn-download" id="downloadBtn">⬇️ 下载全部</button>
</div>
</div>
<!-- 状态栏 -->
<div class="status-msg" id="statusMsg">
<span class="count" id="imgCount">0</span> 张图片
<span id="statusExtra">等待操作</span>
</div>
<div class="footer-note">
⚡ 点击「转为灰度」后图片预览会更新,下载将包含转换后的黑白版本
</div>
</div>
<script>
(function(){
// ----- 状态管理 -----
const files = []; // 存储原始 File 对象
const convertedBlobs = []; // 存储每个文件对应的灰度 blob (未转换时 null)
let currentImageUrls = []; // 用于预览的 objectURL (随时清理)
// DOM 元素
const dropArea = document.getElementById('dropArea');
const fileInput = document.getElementById('fileInput');
const imgGrid = document.getElementById('imgGrid');
const imgCountSpan = document.getElementById('imgCount');
const statusExtra = document.getElementById('statusExtra');
const addMoreBtn = document.getElementById('addMoreBtn');
const clearBtn = document.getElementById('clearBtn');
const convertBtn = document.getElementById('convertBtn');
const downloadBtn = document.getElementById('downloadBtn');
// ----- 辅助: 释放所有预览URL -----
function revokeAllUrls() {
currentImageUrls.forEach(url => {
if (url) URL.revokeObjectURL(url);
});
currentImageUrls = [];
}
// ----- 重新渲染网格 (根据files & convertedBlobs) -----
function renderGrid() {
// 清除旧预览
revokeAllUrls();
// 清空网格
imgGrid.innerHTML = '';
if (files.length === 0) {
imgCountSpan.textContent = '0';
statusExtra.textContent = '添加一些图片吧';
return;
}
// 构建新的预览
files.forEach((file, index) => {
// 判断是否有转换后的 blob
const hasConverted = convertedBlobs[index] !== null && convertedBlobs[index] !== undefined;
const blobToShow = hasConverted ? convertedBlobs[index] : file;
const url = URL.createObjectURL(blobToShow);
currentImageUrls.push(url);
const card = document.createElement('div');
card.className = 'img-card';
// 文件名截断
let displayName = file.name;
if (displayName.length > 12) {
const ext = displayName.lastIndexOf('.');
if (ext > 0) {
displayName = displayName.slice(0, 8) + '...' + displayName.slice(ext);
} else {
displayName = displayName.slice(0, 10) + '…';
}
}
const badgeText = hasConverted ? '黑白' : '原图';
card.innerHTML = `
<figure>
<img src="${url}" alt="${file.name}" loading="lazy">
</figure>
<div class="info">
<span class="filename" title="${file.name}">${displayName}</span>
<span class="badge">${badgeText}</span>
</div>
`;
imgGrid.appendChild(card);
});
// 更新计数 & 状态
imgCountSpan.textContent = files.length;
const convertedCount = convertedBlobs.filter(b => b !== null && b !== undefined).length;
if (convertedCount === 0) {
statusExtra.textContent = '未转换 · 点击「转为灰度」';
} else if (convertedCount === files.length) {
statusExtra.textContent = '全部已转为黑白 ✓';
} else {
statusExtra.textContent = `${convertedCount}/${files.length} 已转换`;
}
}
// ----- 添加文件 (过滤非图片, 去重可选) -----
function addFiles(newFiles) {
let added = 0;
for (const f of newFiles) {
// 简单类型过滤
if (!f.type.startsWith('image/')) continue;
// 可选去重 (可以根据 name+size 简单去重)
const duplicate = files.some(ex => ex.name === f.name && ex.size === f.size && ex.lastModified === f.lastModified);
if (duplicate) continue;
files.push(f);
convertedBlobs.push(null); // 未转换
added++;
}
if (added > 0) {
renderGrid();
statusExtra.textContent = `已添加 ${added} 张图片`;
} else {
statusExtra.textContent = '没有新图片被添加 (可能重复或格式不支持)';
}
}
// ----- 清空所有 -----
function clearAll() {
if (files.length === 0) return;
// 释放blob
convertedBlobs.forEach((blob, idx) => {
if (blob && blob !== files[idx]) {
// 如果是独立blob释放? 但都会被gc,不需要手动; 但为了安全不操作
}
});
// 清空数组
files.length = 0;
convertedBlobs.length = 0;
renderGrid();
statusExtra.textContent = '已清空';
}
// ----- 核心: 将单张图片转为灰度 Blob (canvas) -----
function convertToGrayscale(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
try {
// 创建 canvas 绘制
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
// 绘制原图
ctx.drawImage(img, 0, 0);
// 获取图像数据
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// 灰度化 ( luminosity 加权 )
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 更自然的灰度加权
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
data[i] = gray; // R
data[i + 1] = gray; // G
data[i + 2] = gray; // B
// alpha 不变
}
ctx.putImageData(imageData, 0, 0);
// 输出为 blob (保持原格式 png/jpg/webp, 默认png)
let mimeType = file.type;
// 对不支持的类型降级
if (!['image/jpeg', 'image/png', 'image/webp', 'image/bmp'].includes(mimeType)) {
mimeType = 'image/png';
}
// 如果原图是 jpeg 可以用较高压缩
const quality = (mimeType === 'image/jpeg') ? 0.92 : 0.95;
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('canvas toBlob 失败'));
}
}, mimeType, quality);
} catch (err) {
reject(err);
} finally {
URL.revokeObjectURL(url);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('图片加载失败: ' + file.name));
};
img.src = url;
});
}
// ----- 批量转换 -----
async function batchConvert() {
if (files.length === 0) {
statusExtra.textContent = '没有图片需要转换';
return;
}
// 过滤已经转换的
const unprocessedIndices = [];
files.forEach((f, idx) => {
if (convertedBlobs[idx] === null || convertedBlobs[idx] === undefined) {
unprocessedIndices.push(idx);
}
});
if (unprocessedIndices.length === 0) {
statusExtra.textContent = '所有图片已经转为灰度';
return;
}
// 禁用按钮防止重复点击
convertBtn.disabled = true;
downloadBtn.disabled = true;
statusExtra.textContent = `正在转换 ${unprocessedIndices.length} 张...`;
let success = 0;
let fail = 0;
for (let i = 0; i < unprocessedIndices.length; i++) {
const idx = unprocessedIndices[i];
const file = files[idx];
try {
const grayBlob = await convertToGrayscale(file);
convertedBlobs[idx] = grayBlob;
success++;
} catch (err) {
console.warn('转换失败:', file.name, err);
fail++;
// 保留原始 (或者可选标记为失败? 保留null, 显示原图)
convertedBlobs[idx] = null; // 保持原图
}
// 更新状态 (每张)
statusExtra.textContent = `转换中 ${success+fail}/${unprocessedIndices.length} ...`;
// 每转换一张刷新一次网格? 为了流畅,最后统一刷新
}
// 重新渲染
renderGrid();
convertBtn.disabled = false;
downloadBtn.disabled = false;
if (fail === 0) {
statusExtra.textContent = `🎯 全部转换成功 (${success} 张)`;
} else {
statusExtra.textContent = `转换完成: ${success} 成功, ${fail} 失败 (保留原图)`;
}
}
// ----- 下载全部 (优先使用灰度版本) -----
function downloadAll() {
if (files.length === 0) {
statusExtra.textContent = '没有图片可下载';
return;
}
let downloaded = 0;
files.forEach((file, index) => {
// 决定使用哪个 blob: 如果有转换且不为 null 则用转换版
const blobToDownload = (convertedBlobs[index] !== null && convertedBlobs[index] !== undefined)
? convertedBlobs[index] : file;
// 生成文件名: 原文件名 + _gray 后缀 (如果已转换)
const isConverted = convertedBlobs[index] !== null && convertedBlobs[index] !== undefined;
const originalName = file.name;
const dotIndex = originalName.lastIndexOf('.');
const baseName = dotIndex > 0 ? originalName.slice(0, dotIndex) : originalName;
const ext = dotIndex > 0 ? originalName.slice(dotIndex) : '.png';
const suffix = isConverted ? '_bw' : '_original';
const newFileName = `${baseName}${suffix}${ext}`;
// 创建下载链接
const link = document.createElement('a');
link.href = URL.createObjectURL(blobToDownload);
link.download = newFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 释放临时url (稍后批量释放)
setTimeout(() => URL.revokeObjectURL(link.href), 100);
downloaded++;
});
statusExtra.textContent = `⬇️ 已触发 ${downloaded} 个文件下载 (浏览器限制可能会询问)`;
}
// ----- 事件绑定 -----
// 1. 拖放
dropArea.addEventListener('dragover', (e) => {
e.preventDefault();
dropArea.classList.add('dragging');
});
dropArea.addEventListener('dragleave', () => {
dropArea.classList.remove('dragging');
});
dropArea.addEventListener('drop', (e) => {
e.preventDefault();
dropArea.classList.remove('dragging');
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
}
});
// 2. 点击选择
dropArea.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
addFiles(e.target.files);
fileInput.value = ''; // 允许重复选择同一文件
}
});
// 3. 添加更多按钮
addMoreBtn.addEventListener('click', () => {
fileInput.click();
});
// 4. 清空按钮
clearBtn.addEventListener('click', clearAll);
// 5. 转换按钮
convertBtn.addEventListener('click', batchConvert);
// 6. 下载按钮
downloadBtn.addEventListener('click', downloadAll);
// 7. 额外: 防止浏览器默认拖拽打开链接
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', (e) => e.preventDefault());
// 初始化渲染
renderGrid();
statusExtra.textContent = '添加图片后可进行灰度转换';
})();
</script>
</body>
</html>
核心功能操作说明
您可以通过拖放或点击添加图片,然后一键完成批量灰度转换和下载,其核心流程如下:
- 添加图片:您可以通过拖拽图片到指定区域,或点击“添加更多”按钮,从本地选择多张图片。
- 执行转换:点击“转为灰度”按钮,工具会逐张将图片转换为黑白效果,并在缩略图上标记为“黑白”。
- 下载结果:所有图片转换完成后,点击“下载全部”按钮,即可将灰度版本(文件名带
_bw后缀)打包下载到本地。
优化建议: 您可以根据需要调整 convertToGrayscale 函数中的灰度加权系数(299 * r + 0.587 * g + 0.114 * b),以改变黑白转换的视觉效果。
标签: 黑白转换
版权声明:除非特别标注,否则均为本站原创文章,转载时请以链接形式注明文章出处。