<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">怀旧双色调 · 批量图片工具</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: #1e1a17;
font-family: 'Segoe UI', Roboto, system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
color: #e8ddd0;
}
.app-container {
max-width: 1200px;
width: 100%;
background: #2c2622;
border-radius: 32px;
padding: 28px 30px 35px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 215, 180, 0.08);
border: 1px solid #4d3d32;
}
h1 {
display: flex;
align-items: center;
gap: 12px;
font-weight: 400;
font-size: 1.9rem;
letter-spacing: 1px;
color: #dac5b0;
border-bottom: 1px solid #4f3f34;
padding-bottom: 16px;
margin-bottom: 24px;
}
h1 span {
background: #7a5f4c;
color: #efe1d2;
font-size: 0.9rem;
font-weight: 500;
padding: 4px 14px;
border-radius: 40px;
letter-spacing: 0.3px;
margin-left: 8px;
}
.control-panel {
background: #362e28;
border-radius: 24px;
padding: 20px 24px;
margin-bottom: 28px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 18px 30px;
border: 1px solid #5a463a;
box-shadow: inset 0 2px 3px rgba(0,0,0,0.3);
}
.file-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.file-upload label {
background: #6b4f3d;
padding: 10px 20px;
border-radius: 40px;
color: #f7efe9;
font-weight: 500;
cursor: pointer;
transition: 0.2s;
border: 1px solid #9a7b66;
letter-spacing: 0.3px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.file-upload label:hover {
background: #8a6b55;
border-color: #be9b83;
transform: scale(1.01);
}
.file-upload input[type="file"] {
display: none;
}
.file-upload .file-hint {
color: #bba693;
font-size: 0.9rem;
}
.color-inputs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 24px;
background: #271f1b;
padding: 8px 18px 8px 14px;
border-radius: 48px;
border: 1px solid #5d4a3d;
}
.color-pair {
display: flex;
align-items: center;
gap: 8px;
}
.color-pair label {
font-size: 0.85rem;
color: #d0bcab;
}
.color-pair input[type="color"] {
width: 36px;
height: 36px;
border: 2px solid #7d6555;
border-radius: 50%;
background: transparent;
cursor: pointer;
padding: 2px;
}
.color-pair input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-pair input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 50%;
}
.preset-zone {
display: flex;
align-items: center;
gap: 6px;
}
.preset-btn {
background: #4d3a30;
border: none;
color: #e5d4c4;
padding: 5px 14px;
border-radius: 30px;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.4px;
cursor: pointer;
border: 1px solid #6f594a;
transition: 0.15s;
}
.preset-btn:hover {
background: #6e5543;
border-color: #a6836c;
color: #fff6ed;
}
.preset-btn.active-preset {
background: #9e7a62;
border-color: #d6b49b;
color: #fffaf4;
box-shadow: 0 0 0 1px #c8a68d;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-left: auto;
}
.action-btn {
background: #5b473b;
border: 1px solid #826b5a;
color: #ede0d4;
padding: 9px 22px;
border-radius: 40px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.action-btn:hover {
background: #7b6453;
border-color: #b6967e;
color: #fffcf7;
}
.action-btn:active {
transform: scale(0.96);
}
.action-btn.download-btn {
background: #6f543f;
border-color: #b18568;
}
.action-btn.download-btn:hover {
background: #9a7a61;
}
.action-btn.clear-btn {
background: #3f3129;
border-color: #5f4b3d;
}
.action-btn.clear-btn:hover {
background: #5d4a3c;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 18px;
margin: 28px 0 12px;
min-height: 120px;
}
.card {
background: #1f1a16;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 8px 14px rgba(0,0,0,0.6);
border: 1px solid #4f3f34;
transition: 0.2s;
display: flex;
flex-direction: column;
}
.card:hover {
transform: translateY(-3px);
border-color: #8d735f;
}
.card canvas {
width: 100%;
aspect-ratio: 1/1;
display: block;
background: #2e2722;
object-fit: cover;
}
.card .card-footer {
padding: 10px 12px;
background: #2b231f;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
color: #b8a392;
border-top: 1px solid #3f332b;
}
.card .card-footer .filename {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100px;
}
.card .card-footer .remove-btn {
background: none;
border: none;
color: #b28d78;
font-size: 1.2rem;
cursor: pointer;
padding: 0 6px;
line-height: 1;
transition: 0.1s;
}
.card .card-footer .remove-btn:hover {
color: #f3b59a;
transform: scale(1.2);
}
.empty-gallery {
grid-column: 1 / -1;
text-align: center;
color: #786559;
padding: 40px 0;
font-size: 1.1rem;
letter-spacing: 0.3px;
background: #23201c;
border-radius: 30px;
border: 1px dashed #5c4c40;
}
.footer-info {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
margin-top: 20px;
color: #8d7b6d;
font-size: 0.85rem;
border-top: 1px solid #3f352e;
padding-top: 18px;
}
.badge {
background: #3a2e27;
padding: 2px 14px;
border-radius: 30px;
color: #c7b3a2;
}
@media (max-width: 700px) {
.app-container { padding: 16px; }
.control-panel { flex-direction: column; align-items: stretch; gap: 14px; }
.action-buttons { margin-left: 0; justify-content: flex-end; }
.color-inputs { justify-content: center; }
}
</style>
</head>
<body>
<div class="app-container">
<h1>
🎞️ 双色调·怀旧
<span>批量</span>
</h1>
<div class="control-panel">
<div class="file-upload">
<label for="imageInput">📁 选择图片</label>
<input type="file" id="imageInput" accept="image/*" multiple>
<span class="file-hint">支持多选</span>
</div>
<div class="color-inputs">
<div class="color-pair">
<label for="color1">深色</label>
<input type="color" id="color1" value="#3b2b23">
</div>
<div class="color-pair">
<label for="color2">浅色</label>
<input type="color" id="color2" value="#e9d6c0">
</div>
</div>
<div class="preset-zone">
<span style="color:#a08b7b; font-size:0.8rem; margin-right:4px;">🎨</span>
<button class="preset-btn" data-h="sepia" data-c1="#3b2b23" data-c2="#e9d6c0">复古棕</button>
<button class="preset-btn" data-h="vintage" data-c1="#2c2a3b" data-c2="#cbba9a">旧报纸</button>
<button class="preset-btn" data-h="cyan" data-c1="#153b42" data-c2="#d9cdb3">青调</button>
<button class="preset-btn" data-h="warm" data-c1="#5a3828" data-c2="#f2dbb8">暖阳</button>
</div>
<div class="action-buttons">
<button class="action-btn download-btn" id="downloadAllBtn">⬇️ 全部下载</button>
<button class="action-btn clear-btn" id="clearBtn">✕ 清空</button>
</div>
</div>
<div class="gallery" id="galleryContainer">
<div class="empty-gallery">⬆ 选择图片,开始怀旧双色调</div>
</div>
<div class="footer-info">
<span>🕯️ 双色调 · 怀旧感</span>
<span class="badge" id="imageCount">0 张图片</span>
</div>
</div>
<script>
(function() {
// DOM 元素
const input = document.getElementById('imageInput');
const gallery = document.getElementById('galleryContainer');
const color1Input = document.getElementById('color1');
const color2Input = document.getElementById('color2');
const downloadAllBtn = document.getElementById('downloadAllBtn');
const clearBtn = document.getElementById('clearBtn');
const imageCountSpan = document.getElementById('imageCount');
// 存储图片数据 { id, file, dataUrl, canvas }
let imageItems = [];
let idCounter = 0;
// 预设按钮
const presetBtns = document.querySelectorAll('.preset-btn');
presetBtns.forEach(btn => {
btn.addEventListener('click', function(e) {
const c1 = this.dataset.c1;
const c2 = this.dataset.c2;
if (c1) color1Input.value = c1;
if (c2) color2Input.value = c2;
// 高亮当前预设
presetBtns.forEach(b => b.classList.remove('active-preset'));
this.classList.add('active-preset');
// 重新渲染所有图片
rerenderAll();
});
});
// 颜色变化时重新渲染
color1Input.addEventListener('input', rerenderAll);
color2Input.addEventListener('input', rerenderAll);
// 移除预设高亮(手动改颜色)
color1Input.addEventListener('change', () => presetBtns.forEach(b => b.classList.remove('active-preset')));
color2Input.addEventListener('change', () => presetBtns.forEach(b => b.classList.remove('active-preset')));
// 清空
clearBtn.addEventListener('click', function() {
imageItems = [];
renderGallery();
});
// 下载全部
downloadAllBtn.addEventListener('click', function() {
if (imageItems.length === 0) {
alert('没有图片可以下载');
return;
}
imageItems.forEach((item, index) => {
const canvas = item.canvas;
if (!canvas) return;
const link = document.createElement('a');
link.download = `duotone_${item.filename || `image_${index+1}`}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
});
});
// 文件选择
input.addEventListener('change', function(e) {
const files = e.target.files;
if (!files || files.length === 0) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith('image/')) continue;
const reader = new FileReader();
reader.onload = (function(f) {
return function(ev) {
const dataUrl = ev.target.result;
addImageItem(dataUrl, f.name);
};
})(file);
reader.readAsDataURL(file);
}
// 清空 input 以便再次选择相同文件
input.value = '';
});
// 添加图片项
function addImageItem(dataUrl, filename) {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
// 保持比例,最大边长 640 (保持性能)
let w = img.naturalWidth;
let h = img.naturalHeight;
const maxDim = 640;
if (w > maxDim || h > maxDim) {
const ratio = Math.min(maxDim / w, maxDim / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
// 应用双色调
applyDuotoneToCanvas(canvas, color1Input.value, color2Input.value);
const item = {
id: idCounter++,
filename: filename || 'image',
dataUrl: dataUrl,
canvas: canvas,
};
imageItems.push(item);
renderGallery();
};
img.src = dataUrl;
}
// 对canvas应用双色调 (保留透明区域?默认透明背景处理为白色)
function applyDuotoneToCanvas(canvas, hex1, hex2) {
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const c1 = hexToRgb(hex1);
const c2 = hexToRgb(hex2);
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const alpha = data[i + 3];
// 灰度 ( luminosity )
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
// 归一化 0-1
const t = Math.min(1, Math.max(0, gray / 255));
// 双色调插值
const newR = Math.round(c1.r + (c2.r - c1.r) * t);
const newG = Math.round(c1.g + (c2.g - c1.g) * t);
const newB = Math.round(c1.b + (c2.b - c1.b) * t);
data[i] = newR;
data[i + 1] = newG;
data[i + 2] = newB;
// 保留原始alpha (但白色背景更好)
if (alpha === 0) {
// 完全透明 -> 设置为白色 (或浅色) 避免全透明
data[i] = c2.r;
data[i+1] = c2.g;
data[i+2] = c2.b;
data[i+3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
}
// 重新渲染全部图片 (从存储的原始canvas重新应用双色调)
function rerenderAll() {
const c1 = color1Input.value;
const c2 = color2Input.value;
imageItems.forEach(item => {
if (!item.canvas) return;
// 重新从原始图片绘制? 直接基于当前canvas再次应用双色调会叠加,需要从原始dataUrl重绘
// 但是canvas已经被修改,所以我们用存储的dataUrl重新生成canvas
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
let w = img.naturalWidth;
let h = img.naturalHeight;
const maxDim = 640;
if (w > maxDim || h > maxDim) {
const ratio = Math.min(maxDim / w, maxDim / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
// 双色调
applyDuotoneToCanvas(canvas, c1, c2);
item.canvas = canvas;
// 更新DOM中对应的canvas
const card = document.querySelector(`.card[data-id="${item.id}"]`);
if (card) {
const canvasEl = card.querySelector('canvas');
if (canvasEl) {
const ctx2 = canvasEl.getContext('2d');
canvasEl.width = canvas.width;
canvasEl.height = canvas.height;
ctx2.drawImage(canvas, 0, 0);
}
}
};
img.src = item.dataUrl;
});
// gallery 已经渲染,等待图片加载完成后更新 但简单处理:直接重新渲染整个gallery
// 但为了流畅,延迟一点点重新渲染
setTimeout(() => {
renderGallery();
}, 50);
}
// 渲染画廊
function renderGallery() {
if (imageItems.length === 0) {
gallery.innerHTML = `<div class="empty-gallery">⬆ 选择图片,开始怀旧双色调</div>`;
imageCountSpan.textContent = '0 张图片';
return;
}
let html = '';
imageItems.forEach(item => {
const canvas = item.canvas;
let dataUrl = '';
if (canvas) {
dataUrl = canvas.toDataURL('image/png');
}
html += `
<div class="card" data-id="${item.id}">
<canvas width="${canvas?.width || 200}" height="${canvas?.height || 200}" style="background: #2e2722;"></canvas>
<div class="card-footer">
<span class="filename" title="${item.filename}">${item.filename}</span>
<button class="remove-btn" data-id="${item.id}" title="移除">✕</button>
</div>
</div>
`;
});
gallery.innerHTML = html;
// 渲染canvas图像 (因innerHTML canvas不会自动绘制)
imageItems.forEach(item => {
const card = gallery.querySelector(`.card[data-id="${item.id}"]`);
if (!card) return;
const canvasEl = card.querySelector('canvas');
if (canvasEl && item.canvas) {
const ctx = canvasEl.getContext('2d');
canvasEl.width = item.canvas.width;
canvasEl.height = item.canvas.height;
ctx.drawImage(item.canvas, 0, 0);
}
});
// 绑定移除按钮
gallery.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
const id = parseInt(this.dataset.id);
imageItems = imageItems.filter(item => item.id !== id);
renderGallery();
});
});
imageCountSpan.textContent = `${imageItems.length} 张图片`;
}
// 工具: hex to rgb
function hexToRgb(hex) {
let h = hex.replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const num = parseInt(h, 16);
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
}
// 初始化:若有预设高亮 (默认复古棕)
document.querySelector('.preset-btn[data-h="sepia"]')?.classList.add('active-preset');
// 默认渲染空状态
renderGallery();
})();
</script>
</body>
</html>
工具功能与操作流程
您可以通过简单的几步,为图片添加上世纪风格的复古色调,其核心是让您自由控制双色调的深浅颜色,并支持批量处理。

- 图片导入与批量处理:点击“选择图片”按钮,您可以从本地一次性选取多张图片,所有图片会以网格形式展示在界面中,方便您预览和管理。
- 双色调颜色自定义:您可以通过“深色”和“浅色”颜色选择器,自由搭配双色调的两种主色,工具还预设了“复古棕”、“旧报纸”等几种怀旧风格,点击即可快速应用。
- 效果预览与输出:每张图片的缩略图会实时显示应用双色调后的效果,您可以通过每张图片右下角的“✕”按钮移除单独图片,或使用“全部下载”按钮将所有处理后的图片保存为PNG格式。
标签: 双色调
版权声明:除非特别标注,否则均为本站原创文章,转载时请以链接形式注明文章出处。