开源一个手搓证件照二件套工具

开源一个手搓证件照二件套工具
开源一个手搓证件照二件套工具

证件照换底(支持自定义颜色代码)

<!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 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文