证件照换底(支持自定义颜色代码)
<!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>
*{margin:0;padding:0;box-sizing:border-box;}
body{
font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;
background:#f0f2f5;min-height:100vh;color:#1a1a2e;line-height:1.6;
}
.header{
background:#fff;border-bottom:1px solid #e0e0e0;padding:14px 0;
position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,0.06);
}
.header-inner{max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:space-between;}
.logo{display:flex;align-items:center;gap:10px;font-size:1.25rem;font-weight:700;}
.logo-icon{width:38px;height:38px;border-radius:11px;background:linear-gradient(135deg,#4f6ef7,#7b8ff7);display:flex;align-items:center;justify-content:center;font-size:1.3rem;color:#fff;}
.privacy-badge{display:flex;align-items:center;gap:6px;font-size:0.8rem;color:#27ae60;background:#eafaf1;padding:5px 12px;border-radius:20px;font-weight:600;}
.privacy-dot{width:7px;height:7px;border-radius:50%;background:#27ae60;animation:pulse 2s infinite;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
.main-container{max-width:1200px;margin:0 auto;padding:24px;display:grid;grid-template-columns:1fr 1fr;gap:24px;}
@media(max-width:900px){.main-container{grid-template-columns:1fr;max-width:520px;}}
.card{background:#fff;border-radius:16px;padding:22px;box-shadow:0 4px 24px rgba(0,0,0,0.08);border:1px solid #e0e0e0;}
.card-title{font-size:1rem;font-weight:700;margin-bottom:16px;}
.upload-zone{border:2px dashed #d0d5e0;border-radius:10px;padding:36px 20px;text-align:center;cursor:pointer;background:#fafbfc;position:relative;min-height:180px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;transition:0.25s;}
.upload-zone:hover,.upload-zone.drag-over{border-color:#4f6ef7;background:#eef1ff;}
.upload-zone.has-image{border-style:solid;border-color:#e0e0e0;padding:6px;min-height:auto;}
.upload-icon{font-size:2.8rem;opacity:0.5;}
.upload-text{font-size:0.9rem;color:#555;}
.upload-hint{font-size:0.75rem;color:#999;}
.upload-zone input[type="file"]{position:absolute;inset:0;opacity:0;cursor:pointer;}
.preview-image{width:100%;max-height:350px;object-fit:contain;display:block;border-radius:5px;}
.image-container{position:relative;display:block;width:100%;}
.image-actions-overlay{position:absolute;top:8px;right:8px;z-index:5;}
.btn-icon-sm{width:32px;height:32px;border-radius:50%;border:none;background:rgba(0,0,0,0.5);color:#fff;cursor:pointer;font-size:0.85rem;display:flex;align-items:center;justify-content:center;}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:9px 18px;border-radius:25px;font-size:0.88rem;font-weight:600;cursor:pointer;border:none;transition:0.25s;white-space:nowrap;}
.btn:active{transform:scale(0.96);}
.btn-primary{background:#4f6ef7;color:#fff;}
.btn-primary:hover{background:#3b54db;}
.btn-outline{background:#fff;color:#4f6ef7;border:2px solid #4f6ef7;}
.btn-outline:hover{background:#eef1ff;}
.btn-success{background:#27ae60;color:#fff;}
.btn-success:hover{opacity:0.9;}
.btn-lg{padding:11px 26px;font-size:0.95rem;border-radius:28px;}
.btn:disabled{opacity:0.5;cursor:not-allowed;pointer-events:none;}
.btn-block{width:100%;}
.section-label{font-size:0.82rem;font-weight:600;margin:14px 0 8px;}
.color-presets{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:8px;}
.color-preset{width:40px;height:40px;border-radius:50%;cursor:pointer;border:3px solid transparent;transition:0.25s;box-shadow:0 1px 3px rgba(0,0,0,0.1);position:relative;}
.color-preset:hover{transform:scale(1.12);}
.color-preset.active{border-color:#1a1a2e;box-shadow:0 0 0 4px rgba(0,0,0,0.08);}
.color-preset.active::after{content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:bold;font-size:0.9rem;text-shadow:0 1px 2px rgba(0,0,0,0.5);}
.custom-color-section{background:#f8f9fb;border-radius:12px;padding:12px 14px;margin-bottom:4px;border:1px solid #e8eaef;}
.custom-color-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.custom-color-row input[type="color"]{width:40px;height:40px;border-radius:50%;border:2px solid #e0e0e0;cursor:pointer;padding:2px;flex-shrink:0;}
.custom-color-row input[type="color"]::-webkit-color-swatch-wrapper{padding:0;}
.custom-color-row input[type="color"]::-webkit-color-swatch{border-radius:50%;border:none;}
.hex-input-wrapper{position:relative;flex:1;min-width:110px;}
.hex-input-wrapper .hash{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:#999;font-weight:600;font-size:0.85rem;pointer-events:none;}
.hex-input{width:100%;padding:8px 10px 8px 24px;border:2px solid #e0e5ec;border-radius:10px;font-size:0.85rem;font-family:monospace;font-weight:600;color:#1a1a2e;background:#fff;outline:none;transition:0.25s;}
.hex-input:focus{border-color:#4f6ef7;box-shadow:0 0 0 3px rgba(79,110,247,0.1);}
.hex-input.valid{border-color:#27ae60;background:#f8fdf8;}
.hex-input.invalid{border-color:#e74c3c;background:#fef8f8;}
.color-preview-dot{width:30px;height:30px;border-radius:50%;border:2px solid #ddd;flex-shrink:0;box-shadow:inset 0 1px 2px rgba(0,0,0,0.1);}
.control-group{margin-bottom:12px;}
.control-label{display:flex;justify-content:space-between;align-items:center;font-size:0.82rem;font-weight:600;margin-bottom:5px;}
.control-value{font-size:0.75rem;color:#555;background:#f5f6f8;padding:2px 10px;border-radius:12px;}
input[type="range"]{-webkit-appearance:none;width:100%;height:5px;border-radius:3px;background:#e0e5ec;outline:none;cursor:pointer;}
input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:20px;border-radius:50%;background:#4f6ef7;cursor:pointer;}
.size-presets{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;}
.size-preset{padding:10px 12px;border-radius:8px;border:2px solid #e0e0e0;cursor:pointer;text-align:center;font-size:0.82rem;font-weight:600;transition:0.25s;background:#fafbfc;}
.size-preset:hover{border-color:#4f6ef7;background:#eef1ff;}
.size-preset.active{border-color:#4f6ef7;background:#eef1ff;color:#4f6ef7;}
.size-preset .size-dims{font-size:0.7rem;color:#555;font-weight:400;}
.result-container{text-align:center;}
.result-image{max-width:100%;max-height:350px;border-radius:10px;box-shadow:0 4px 24px rgba(0,0,0,0.08);border:1px solid #e0e0e0;}
.result-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:220px;color:#bbb;gap:10px;}
.result-placeholder .icon{font-size:3.5rem;}
.action-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px;}
.toast{position:fixed;bottom:30px;left:50%;transform:translateX(-50%) translateY(100px);background:#1a1a2e;color:#fff;padding:10px 22px;border-radius:25px;font-size:0.85rem;font-weight:600;z-index:999;opacity:0;transition:all 0.35s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,0.25);}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
.toast.success{background:#27ae60;}
.toast.error{background:#e74c3c;}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,0.3);border-top-color:#fff;border-radius:50%;animation:spin 0.7s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
.footer{text-align:center;padding:18px;font-size:0.75rem;color:#aaa;}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<div class="logo"><div class="logo-icon">📸</div><span>证件照换底</span></div>
<div class="privacy-badge"><span class="privacy-dot"></span>离线处理 · 隐私安全</div>
</div>
</header>
<div class="main-container">
<!-- ========== 左侧面板 ========== -->
<div class="card">
<div class="card-title">⚙️ 上传与设置</div>
<div class="upload-zone" id="uploadZone">
<span class="upload-icon">📷</span>
<span class="upload-text">点击或拖拽上传证件照</span>
<span class="upload-hint">支持 JPG / PNG / WebP</span>
<input type="file" id="fileInput" accept="image/*">
</div>
<div class="section-label">🎨 背景颜色(实时预览)</div>
<div class="color-presets" id="colorPresets">
<div class="color-preset active" data-color="#FF0000" style="background:#FF0000;" title="红色"></div>
<div class="color-preset" data-color="#FFFFFF" style="background:#FFFFFF;" title="白色"></div>
<div class="color-preset" data-color="#438EDB" style="background:#438EDB;" title="蓝色"></div>
<div class="color-preset" data-color="#1AAD19" style="background:#1AAD19;" title="绿色"></div>
<div class="color-preset" data-color="#808080" style="background:#808080;" title="灰色"></div>
<div class="color-preset" data-color="#003399" style="background:#003399;" title="深蓝"></div>
</div>
<div class="custom-color-section">
<div style="font-size:0.78rem;font-weight:600;margin-bottom:8px;color:#555;">🎯 自定义颜色</div>
<div class="custom-color-row">
<input type="color" id="customColorPicker" value="#FF0000" title="取色器">
<div class="hex-input-wrapper">
<span class="hash">#</span>
<input type="text" class="hex-input valid" id="hexInput" value="FF0000" maxlength="6" placeholder="输入6位HEX色值">
</div>
<div class="color-preview-dot" id="colorPreviewDot" style="background:#FF0000;" title="当前颜色"></div>
</div>
</div>
<div class="control-group" style="margin-top:14px;">
<div class="control-label"><span>🔍 容差范围</span><span class="control-value" id="toleranceVal">50</span></div>
<input type="range" id="toleranceSlider" min="10" max="120" value="50" step="1">
</div>
<div class="control-group">
<div class="control-label"><span>✨ 边缘羽化</span><span class="control-value" id="featherVal">1</span></div>
<input type="range" id="featherSlider" min="0" max="5" value="1" step="0.5">
</div>
<div class="control-group">
<div class="control-label"><span>☀️ 亮度</span><span class="control-value" id="brightnessVal">0</span></div>
<input type="range" id="brightnessSlider" min="-30" max="30" value="0" step="1">
</div>
<div class="section-label">📐 裁剪尺寸</div>
<div class="size-presets" id="sizePresets">
<div class="size-preset active" data-width="295" data-height="413" data-name="一寸"><div>一寸</div><div class="size-dims">25×35mm</div></div>
<div class="size-preset" data-width="413" data-height="579" data-name="二寸"><div>二寸</div><div class="size-dims">35×49mm</div></div>
<div class="size-preset" data-width="260" data-height="378" data-name="小一寸"><div>小一寸</div><div class="size-dims">22×32mm</div></div>
<div class="size-preset" data-width="390" data-height="567" data-name="小二寸"><div>小二寸</div><div class="size-dims">33×48mm</div></div>
</div>
<div class="action-row">
<button class="btn btn-primary btn-lg" id="processBtn" disabled>🎯 开始换底</button>
<button class="btn btn-outline" id="resetBtn">🔄 重置</button>
</div>
</div>
<!-- ========== 右侧面板 ========== -->
<div class="card">
<div class="card-title">🖼️ 结果预览</div>
<div class="result-container">
<div class="result-placeholder" id="resultPlaceholder">
<span class="icon">🖼️</span>
<span>处理后的照片将显示在这里</span>
</div>
<img class="result-image" id="resultImage" style="display:none;" alt="处理结果">
</div>
<div class="action-row">
<button class="btn btn-success btn-block" id="downloadBtn" disabled>💾 下载照片</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="footer">🔒 所有图片处理均在浏览器本地完成,不会上传到任何服务器</div>
<script>
(function() {
// ==================== DOM引用 ====================
var uploadZone = document.getElementById('uploadZone');
var fileInput = document.getElementById('fileInput');
var processBtn = document.getElementById('processBtn');
var resetBtn = document.getElementById('resetBtn');
var downloadBtn = document.getElementById('downloadBtn');
var resultImage = document.getElementById('resultImage');
var resultPH = document.getElementById('resultPlaceholder');
var toastEl = document.getElementById('toast');
var colorPicker = document.getElementById('customColorPicker');
var hexInput = document.getElementById('hexInput');
var colorDot = document.getElementById('colorPreviewDot');
var tolSlider = document.getElementById('toleranceSlider');
var tolVal = document.getElementById('toleranceVal');
var feaSlider = document.getElementById('featherSlider');
var feaVal = document.getElementById('featherVal');
var briSlider = document.getElementById('brightnessSlider');
var briVal = document.getElementById('brightnessVal');
// ==================== 状态 ====================
var originalImage = null;
var resultDataURL = null;
var currentColor = '#FF0000';
var currentSize = { w: 295, h: 413, name: '一寸' };
var toastTimer = null;
var autoTimer = null;
var cachedMask = null;
// ==================== Toast ====================
function toast(msg, type) {
if (toastTimer) clearTimeout(toastTimer);
toastEl.textContent = msg;
toastEl.className = 'toast ' + (type || '') + ' show';
toastTimer = setTimeout(function() {
toastEl.classList.remove('show');
}, 2000);
}
// ==================== 颜色工具 ====================
function hex3to6(s) {
s = s.toUpperCase();
if (s.length === 3) {
return s[0]+s[0]+s[1]+s[1]+s[2]+s[2];
}
return s;
}
function isValidHex(s) {
return /^[0-9a-fA-F]{3}$/.test(s) || /^[0-9a-fA-F]{6}$/.test(s);
}
function hexToRgb(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex3to6(hex);
return {
r: parseInt(hex.substr(0,2), 16),
g: parseInt(hex.substr(2,2), 16),
b: parseInt(hex.substr(4,2), 16)
};
}
// 同步所有颜色UI
function applyColor(hex6, source) {
hex6 = hex6.toUpperCase();
if (!/^[0-9a-fA-F]{6}$/.test(hex6)) return;
currentColor = '#' + hex6;
// 取色器
if (source !== 'picker') {
colorPicker.value = currentColor;
}
// 输入框
if (source !== 'input') {
hexInput.value = hex6;
hexInput.className = 'hex-input valid';
}
// 预设圆点
if (source !== 'preset') {
var all = document.querySelectorAll('.color-preset');
for (var i = 0; i < all.length; i++) {
all[i].classList.remove('active');
}
var m = document.querySelector('.color-preset[data-color="#' + hex6 + '"]');
if (m) m.classList.add('active');
}
// 预览圆点
colorDot.style.background = currentColor;
// 实时更新结果
scheduleAuto();
}
// ==================== 颜色事件 ====================
document.getElementById('colorPresets').addEventListener('click', function(e) {
var p = e.target.closest('.color-preset');
if (!p) return;
var hex6 = p.getAttribute('data-color').replace('#', '');
var all = document.querySelectorAll('.color-preset');
for (var i = 0; i < all.length; i++) all[i].classList.remove('active');
p.classList.add('active');
applyColor(hex6, 'preset');
});
colorPicker.addEventListener('input', function() {
var hex = colorPicker.value.replace('#', '');
if (isValidHex(hex)) {
applyColor(hex3to6(hex), 'picker');
}
});
hexInput.addEventListener('input', function() {
var raw = hexInput.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
hexInput.value = raw;
if (raw.length === 6 && /^[0-9a-fA-F]{6}$/.test(raw)) {
hexInput.className = 'hex-input valid';
applyColor(raw, 'input');
} else if (raw.length === 3 && /^[0-9a-fA-F]{3}$/.test(raw)) {
hexInput.className = 'hex-input valid';
applyColor(hex3to6(raw), 'input');
} else if (raw.length === 0) {
hexInput.className = 'hex-input';
} else if (/^[0-9a-fA-F]+$/.test(raw)) {
hexInput.className = 'hex-input';
} else {
hexInput.className = 'hex-input invalid';
}
});
hexInput.addEventListener('blur', function() {
var raw = hexInput.value;
if (isValidHex(raw)) {
var h6 = hex3to6(raw);
hexInput.value = h6;
hexInput.className = 'hex-input valid';
applyColor(h6, 'input');
}
});
hexInput.addEventListener('paste', function(e) {
e.preventDefault();
var t = (e.clipboardData || window.clipboardData).getData('text');
t = t.replace('#', '').replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
hexInput.value = t;
if (isValidHex(t)) {
hexInput.className = 'hex-input valid';
applyColor(hex3to6(t), 'input');
}
});
// ==================== 尺寸 ====================
document.getElementById('sizePresets').addEventListener('click', function(e) {
var p = e.target.closest('.size-preset');
if (!p) return;
var all = document.querySelectorAll('.size-preset');
for (var i = 0; i < all.length; i++) all[i].classList.remove('active');
p.classList.add('active');
currentSize = {
w: parseInt(p.getAttribute('data-width')),
h: parseInt(p.getAttribute('data-height')),
name: p.getAttribute('data-name')
};
scheduleAuto();
});
// ==================== 滑块 ====================
tolSlider.addEventListener('input', function() {
tolVal.textContent = tolSlider.value;
cachedMask = null;
scheduleAuto();
});
feaSlider.addEventListener('input', function() {
feaVal.textContent = feaSlider.value;
scheduleAuto();
});
briSlider.addEventListener('input', function() {
briVal.textContent = briSlider.value;
scheduleAuto();
});
// ==================== 防抖 ====================
function scheduleAuto() {
if (!originalImage) return;
if (autoTimer) clearTimeout(autoTimer);
autoTimer = setTimeout(function() {
doProcess(true);
}, 200);
}
// ==================== 上传 ====================
uploadZone.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') return;
fileInput.click();
});
fileInput.addEventListener('change', function(e) {
if (e.target.files && e.target.files[0]) {
loadFile(e.target.files[0]);
}
});
uploadZone.addEventListener('dragover', function(e) {
e.preventDefault();
uploadZone.classList.add('drag-over');
});
uploadZone.addEventListener('dragleave', function() {
uploadZone.classList.remove('drag-over');
});
uploadZone.addEventListener('drop', function(e) {
e.preventDefault();
uploadZone.classList.remove('drag-over');
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
loadFile(e.dataTransfer.files[0]);
}
});
function loadFile(file) {
if (!file.type.match(/image\//)) {
toast('请上传图片文件', 'error');
return;
}
var reader = new FileReader();
reader.onload = function(ev) {
var img = new Image();
img.onload = function() {
originalImage = img;
cachedMask = null;
showPreview(img);
processBtn.disabled = false;
toast('图片加载成功', 'success');
doProcess(false);
};
img.onerror = function() {
toast('图片加载失败', 'error');
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
}
function showPreview(img) {
// 清除旧预览
var old = uploadZone.querySelector('.image-container');
if (old) old.remove();
var iconEl = uploadZone.querySelector('.upload-icon');
var textEl = uploadZone.querySelector('.upload-text');
var hintEl = uploadZone.querySelector('.upload-hint');
uploadZone.classList.add('has-image');
if (iconEl) iconEl.style.display = 'none';
if (textEl) textEl.style.display = 'none';
if (hintEl) hintEl.style.display = 'none';
var container = document.createElement('div');
container.className = 'image-container';
var pImg = document.createElement('img');
pImg.className = 'preview-image';
pImg.src = img.src;
pImg.alt = '原始照片';
var overlay = document.createElement('div');
overlay.className = 'image-actions-overlay';
var delBtn = document.createElement('button');
delBtn.className = 'btn-icon-sm';
delBtn.innerHTML = '✕';
delBtn.title = '移除图片';
delBtn.addEventListener('click', function(ev) {
ev.stopPropagation();
resetAll();
});
overlay.appendChild(delBtn);
container.appendChild(pImg);
container.appendChild(overlay);
uploadZone.appendChild(container);
}
// ==================== 核心算法 ====================
function doProcess(silent) {
if (!originalImage) return;
var tolerance = parseInt(tolSlider.value);
var featherR = parseFloat(feaSlider.value);
var brightness = parseInt(briSlider.value);
var target = hexToRgb(currentColor);
var srcW = originalImage.naturalWidth;
var srcH = originalImage.naturalHeight;
// 画到canvas
var canvas = document.createElement('canvas');
canvas.width = srcW;
canvas.height = srcH;
var ctx = canvas.getContext('2d');
ctx.drawImage(originalImage, 0, 0);
var imgData = ctx.getImageData(0, 0, srcW, srcH);
var data = imgData.data;
var w = srcW;
var h = srcH;
// 步骤1:建立遮罩(仅首次或容差改变时)
if (cachedMask === null) {
// 采样边缘
var samples = [];
var step = Math.max(1, Math.floor(Math.min(w, h) / 30));
var x, y;
for (x = 0; x < w; x += step) {
samples.push(getPixel(data, w, x, 0));
samples.push(getPixel(data, w, x, h - 1));
}
for (y = 0; y < h; y += step) {
samples.push(getPixel(data, w, 0, y));
samples.push(getPixel(data, w, w - 1, y));
}
// 中位数
samples.sort(function(a, b) {
return (a.r + a.g + a.b) - (b.r + b.g + b.b);
});
var mid = samples[Math.floor(samples.length / 2)];
var bgR = mid.r;
var bgG = mid.g;
var bgB = mid.b;
// 颜色距离
var mask = new Uint8Array(w * h);
var tolSq = tolerance * tolerance;
var i;
for (i = 0; i < w * h; i++) {
var pi = i * 4;
var dr = data[pi] - bgR;
var dg = data[pi + 1] - bgG;
var db = data[pi + 2] - bgB;
if (dr * dr + dg * dg + db * db <= tolSq) {
mask[i] = 1;
}
}
// Flood fill 从边缘连通
var visited = new Uint8Array(w * h);
var queue = [];
var head = 0;
var x2, y2;
for (x2 = 0; x2 < w; x2++) {
if (mask[x2] === 1 && !visited[x2]) {
visited[x2] = 1;
queue.push(x2);
}
var bIdx = (h - 1) * w + x2;
if (mask[bIdx] === 1 && !visited[bIdx]) {
visited[bIdx] = 1;
queue.push(bIdx);
}
}
for (y2 = 0; y2 < h; y2++) {
var lIdx = y2 * w;
if (mask[lIdx] === 1 && !visited[lIdx]) {
visited[lIdx] = 1;
queue.push(lIdx);
}
var rIdx = y2 * w + (w - 1);
if (mask[rIdx] === 1 && !visited[rIdx]) {
visited[rIdx] = 1;
queue.push(rIdx);
}
}
while (head < queue.length) {
var qIdx = queue[head++];
var qx = qIdx % w;
var qy = Math.floor(qIdx / w);
var nb = [];
if (qx > 0) nb.push(qIdx - 1);
if (qx < w - 1) nb.push(qIdx + 1);
if (qy > 0) nb.push(qIdx - w);
if (qy < h - 1) nb.push(qIdx + w);
for (var ni = 0; ni < nb.length; ni++) {
var nIdx = nb[ni];
if (mask[nIdx] === 1 && !visited[nIdx]) {
visited[nIdx] = 1;
queue.push(nIdx);
}
}
}
cachedMask = visited;
}
// 步骤2:从原始图重新着色
var origCanvas = document.createElement('canvas');
origCanvas.width = w;
origCanvas.height = h;
var origCtx = origCanvas.getContext('2d');
origCtx.drawImage(originalImage, 0, 0);
var origData = origCtx.getImageData(0, 0, w, h).data;
var work = new Uint8ClampedArray(origData.length);
var k;
for (k = 0; k < origData.length; k++) {
work[k] = origData[k];
}
var featherPx = Math.round(featherR);
var j;
for (j = 0; j < work.length; j += 4) {
var idx = j / 4;
if (cachedMask[idx] === 1) {
var alpha = 1;
if (featherPx > 0) {
alpha = getFeather(cachedMask, idx, w, h, featherPx);
}
work[j] = Math.round(work[j] * (1 - alpha) + target.r * alpha);
work[j + 1] = Math.round(work[j + 1] * (1 - alpha) + target.g * alpha);
work[j + 2] = Math.round(work[j + 2] * (1 - alpha) + target.b * alpha);
}
}
// 亮度
if (brightness !== 0) {
var bj;
for (bj = 0; bj < work.length; bj += 4) {
work[bj] = clamp(work[bj] + brightness, 0, 255);
work[bj + 1] = clamp(work[bj + 1] + brightness, 0, 255);
work[bj + 2] = clamp(work[bj + 2] + brightness, 0, 255);
}
}
var resultID = new ImageData(work, w, h);
var workCanvas = document.createElement('canvas');
workCanvas.width = w;
workCanvas.height = h;
var workCtx = workCanvas.getContext('2d');
workCtx.putImageData(resultID, 0, 0);
// 裁剪
var finalCanvas = cropCanvas(workCanvas, currentSize.w, currentSize.h);
resultDataURL = finalCanvas.toDataURL('image/png');
resultImage.src = resultDataURL;
resultImage.style.display = 'block';
resultPH.style.display = 'none';
downloadBtn.disabled = false;
if (!silent) {
toast('换底完成!', 'success');
}
}
function getPixel(data, w, x, y) {
var i = (y * w + x) * 4;
return { r: data[i], g: data[i + 1], b: data[i + 2] };
}
function getFeather(mask, idx, w, h, radius) {
var y = Math.floor(idx / w);
var x = idx % w;
var fg = 0;
var total = 0;
var dy, dx;
for (dy = -radius; dy <= radius; dy++) {
for (dx = -radius; dx <= radius; dx++) {
var nx = x + dx;
var ny = y + dy;
if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
total++;
if (mask[ny * w + nx] === 0) fg++;
}
}
}
if (total === 0) return 1;
var ratio = fg / total;
if (ratio > 0.6) return 0;
if (ratio > 0.3) return 0.5;
return 1;
}
function cropCanvas(srcCanvas, tw, th) {
var sw = srcCanvas.width;
var sh = srcCanvas.height;
var sa = sw / sh;
var ta = tw / th;
var cw, ch, ox, oy;
if (sa > ta) {
ch = sh;
cw = Math.round(sh * ta);
ox = Math.round((sw - cw) / 2);
oy = 0;
} else {
cw = sw;
ch = Math.round(sw / ta);
ox = 0;
oy = Math.round((sh - ch) / 2);
}
var fc = document.createElement('canvas');
fc.width = tw;
fc.height = th;
var fctx = fc.getContext('2d');
fctx.imageSmoothingEnabled = true;
fctx.imageSmoothingQuality = 'high';
fctx.drawImage(srcCanvas, ox, oy, cw, ch, 0, 0, tw, th);
return fc;
}
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
// ==================== 按钮 ====================
processBtn.addEventListener('click', function() {
processBtn.disabled = true;
processBtn.innerHTML = '<span class="spinner"></span> 处理中...';
cachedMask = null;
setTimeout(function() {
try {
doProcess(false);
} catch(err) {
console.error(err);
toast('处理出错: ' + err.message, 'error');
}
processBtn.disabled = false;
processBtn.innerHTML = '🎯 开始换底';
}, 30);
});
resetBtn.addEventListener('click', resetAll);
downloadBtn.addEventListener('click', function() {
if (!resultDataURL) return;
var a = document.createElement('a');
a.download = '证件照_' + currentSize.name + '_' + Date.now() + '.png';
a.href = resultDataURL;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast('下载中', 'success');
});
function resetAll() {
originalImage = null;
resultDataURL = null;
cachedMask = null;
if (autoTimer) clearTimeout(autoTimer);
resultImage.style.display = 'none';
resultImage.src = '';
resultPH.style.display = '';
downloadBtn.disabled = true;
processBtn.disabled = true;
processBtn.innerHTML = '🎯 开始换底';
uploadZone.classList.remove('has-image');
var old = uploadZone.querySelector('.image-container');
if (old) old.remove();
var iconEl = uploadZone.querySelector('.upload-icon');
var textEl = uploadZone.querySelector('.upload-text');
var hintEl = uploadZone.querySelector('.upload-hint');
if (iconEl) iconEl.style.display = '';
if (textEl) textEl.style.display = '';
if (hintEl) hintEl.style.display = '';
fileInput.value = '';
tolSlider.value = 50;
tolVal.textContent = '50';
feaSlider.value = 1;
feaVal.textContent = '1';
briSlider.value = 0;
briVal.textContent = '0';
currentColor = '#FF0000';
colorPicker.value = '#FF0000';
hexInput.value = 'FF0000';
hexInput.className = 'hex-input valid';
colorDot.style.background = '#FF0000';
var all = document.querySelectorAll('.color-preset');
for (var i = 0; i < all.length; i++) all[i].classList.remove('active');
var red = document.querySelector('.color-preset[data-color="#FF0000"]');
if (red) red.classList.add('active');
var allS = document.querySelectorAll('.size-preset');
for (var j = 0; j < allS.length; j++) allS[j].classList.remove('active');
var yc = document.querySelector('.size-preset[data-name="一寸"]');
if (yc) yc.classList.add('active');
currentSize = { w: 295, h: 413, name: '一寸' };
}
// ==================== 快捷键 ====================
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
if (!processBtn.disabled) processBtn.click();
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (!downloadBtn.disabled) downloadBtn.click();
}
});
console.log('证件照换底工具已就绪 - 离线处理 - 实时预览');
})();
</script>
</body>
</html>
2 个帖子 - 1 位参与者