<!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格式。
标签: 双色调
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播