鱗目界域-龍論壇

游態龍的錫安山。龍的力量、智慧、野性、與優雅

您尚未登录。 (登录 | 注册)

公告

mb 爪機版     |    論壇指南     |    Discord     |    QQ群

《龙魂志》第一期
《龙魂志》第二期

Tips:这是专为龙族建设的网站,历史的、现代的、心灵的、神话的、现实的。

#1 2026-04-13 18:36:34  |  只看该作者

羽落
虬龍
Registered: 2024-03-30
Posts: 175
网站

AI工具樓

看有一個ai遊戲樓了,想着平時自己也有一些小工具的需求,但有時候跨設備不好找,就建個樓好了。
一樓做目錄:
------------
1. BMR計算器
2. 肉類能量計算器
3. 多餘換行清除工具
4. 像素畫量化工具
5. 像素畫轉h5 canvas網頁

最后修改: 羽落 (2026-05-20 11:30:20)


若有謬誤,懇請告知。

离线

#2 2026-04-13 18:41:33  |  只看该作者

羽落
虬龍
Registered: 2024-03-30
Posts: 175
网站

回应: AI工具樓

BMR(Basal Metabolic Rate 基礎代謝率)計算器,通常乘一個常數後就能近似表示爲動物的每日消耗能量。


<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>跨物种 BMR 计算器</title>
    <style>
        :root {
            --primary: #4a90e2;
            --secondary: #f5f7fa;
            --text: #333;
            --accent: #50c878;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background-color: var(--secondary);
            color: var(--text);
            margin: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
        }

        .container {
            display: flex;
            gap: 20px;
            max-width: 900px;
            width: 95%;
            flex-wrap: wrap;
        }

        .card {
            background: white;
            padding: 2rem;
            border-radius: 15px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.1);
            flex: 1;
            min-width: 300px;
        }

        .sidebar {
            background: white;
            padding: 1.5rem;
            border-radius: 15px;
            width: 250px;
            box-shadow: 0 5px 15px rgba(0,0,0,0.05);
        }

        h2 { color: var(--primary); margin-top: 0; }
        
        .input-group { margin-bottom: 1.5rem; }
        
        label { display: block; margin-bottom: 0.5rem; font-weight: bold; }
        
        input, select {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 8px;
            box-sizing: border-box;
        }

        .preset-btns {
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 10px;
            margin-bottom: 1.5rem;
        }

        button {
            padding: 10px;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.3s;
            background: #eee;
        }

        button.active {
            background: var(--primary);
            color: white;
        }

        .calc-btn {
            background: var(--accent);
            color: white;
            width: 100%;
            font-size: 1.1rem;
            font-weight: bold;
        }

        .result {
            margin-top: 20px;
            padding: 15px;
            background: #eef9f1;
            border-left: 5px solid var(--accent);
            display: none;
        }

        .info-table {
            width: 100%;
            font-size: 0.9rem;
            border-collapse: collapse;
        }

        .info-table th, .info-table td {
            text-align: left;
            padding: 8px;
            border-bottom: 1px solid #eee;
        }
    </style>
</head>
<body>

<div class="container">
    <div class="card">
        <h2>BMR 计算器</h2>
        <p style="font-size: 0.8rem; color: #666;">公式: $BMR = k \times \text{体重}^{0.75}$</p>
        
        <label>选择物种预设</label>
        <div class="preset-btns" id="presets">
            <button onclick="setPreset('human', 70)">人类</button>
            <button onclick="setPreset('cat', 60)">猫科</button>
            <button onclick="setPreset('dog', 70)">犬类</button>
            <button onclick="setPreset('bird', 129)">鸟类 (雀形目)</button>
            <button onclick="setPreset('custom', 0)">自定义系数</button>
        </div>

        <div class="input-group">
            <label for="weight">体重 (kg)</label>
            <input type="number" id="weight" placeholder="请输入体重" step="0.1">
        </div>

        <div class="input-group" id="custom-k-group" style="display: none;">
            <label for="k-value">自定义代谢系数 (k)</label>
            <input type="number" id="k-value" value="70">
        </div>

        <button class="calc-btn" onclick="calculateBMR()">立即计算</button>

        <div id="result" class="result">
            <strong>计算结果:</strong>
            <p id="bmr-output"></p>
        </div>
    </div>

    <div class="sidebar">
        <h3>常见动物 BMR 参考</h3>
        <p style="font-size: 0.8rem; color: #777; margin-bottom: 15px;">(基于典型成年个体估算)</p>
        <table class="info-table">
            <thead>
                <tr><th>物种</th><th>体重</th><th>约计 BMR</th></tr>
            </thead>
            <tbody>
                <tr><td>麻雀</td><td>25g</td><td>4 kcal/d</td></tr>
                <tr><td>家猫</td><td>4kg</td><td>170 kcal/d</td></tr>
                <tr><td>中型犬</td><td>15kg</td><td>530 kcal/d</td></tr>
                <tr><td>成年男</td><td>70kg</td><td>1690 kcal/d</td></tr>
                <tr><td>大象</td><td>3000kg</td><td>28500 kcal/d</td></tr>
            </tbody>
        </table>
    </div>
</div>

<script>
    let currentK = 70;

    function setPreset(type, k) {
        // 更新按钮状态
        const btns = document.querySelectorAll('#presets button');
        btns.forEach(btn => btn.classList.remove('active'));
        event.target.classList.add('active');

        // 处理自定义逻辑
        const kGroup = document.getElementById('custom-k-group');
        if (type === 'custom') {
            kGroup.style.display = 'block';
        } else {
            kGroup.style.display = 'none';
            currentK = k;
            document.getElementById('k-value').value = k;
        }
    }

    function calculateBMR() {
        const weight = parseFloat(document.getElementById('weight').value);
        const k = parseFloat(document.getElementById('k-value').value);
        
        if (!weight || weight <= 0) {
            alert("请输入有效的体重!");
            return;
        }

        // 使用克莱伯定律公式: BMR = k * W^0.75
        const bmr = k * Math.pow(weight, 0.75);
        
        const resultDiv = document.getElementById('result');
        const output = document.getElementById('bmr-output');
        
        resultDiv.style.display = 'block';
        output.innerHTML = `该个体的基础代谢率 (BMR) 约为:<br><b style="font-size: 1.4rem; color: #50c878;">${bmr.toFixed(2)}</b> kcal/日`;
    }

    // 默认选中人类
    window.onload = () => {
        document.querySelector('#presets button').click();
    };
</script>

</body>
</html>

若有謬誤,懇請告知。

离线

#3 2026-04-14 20:31:09  |  只看该作者

羽落
虬龍
Registered: 2024-03-30
Posts: 175
网站

回应: AI工具樓

簡單小巧的肉類能量計算器



<!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>
        :root {
            --primary-color: #e63946;
            --bg-color: #f8f9fa;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background-color: var(--bg-color);
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
        }
        .container {
            background: white;
            padding: 2rem;
            border-radius: 12px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.1);
            width: 100%;
            max-width: 400px;
        }
        h2 {
            text-align: center;
            color: var(--primary-color);
            margin-bottom: 1.5rem;
        }
        .input-group {
            margin-bottom: 1.2rem;
        }
        label {
            display: block;
            margin-bottom: 0.5rem;
            font-weight: bold;
            color: #333;
        }
        select, input {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 6px;
            box-sizing: border-box;
            font-size: 1rem;
        }
        /* 单位切换样式 */
        .unit-select {
            margin-top: 8px;
        }
        button {
            width: 100%;
            padding: 12px;
            background-color: var(--primary-color);
            color: white;
            border: none;
            border-radius: 6px;
            font-size: 1.1rem;
            cursor: pointer;
            transition: background 0.3s;
        }
        button:hover {
            background-color: #c1121f;
        }
        #result {
            margin-top: 1.5rem;
            padding: 1rem;
            background-color: #fff3f3;
            border-left: 5px solid var(--primary-color);
            display: none;
        }
        .calories-val {
            font-size: 1.5rem;
            font-weight: bold;
            color: var(--primary-color);
        }
    </style>
</head>
<body>

<div class="container">
    <h2>肉类能量计算器</h2>
    
    <div class="input-group">
        <label for="meatType">选择肉类品种</label>
        <select id="meatType">
            <option value="143">猪肉 (瘦)</option>
            <option value="395">猪肉 (肥瘦)</option>
            <option value="106">牛肉 (瘦)</option>
            <option value="125">羊肉 (瘦)</option>
            <option value="167">鸡胸肉</option>
            <option value="240">鸡腿肉 (带皮)</option>
            <option value="240">鸭肉</option>
            <option value="115">鱼肉 (草鱼平均)</option>
            <option value="91">虾肉</option>
        </select>
    </div>

    <div class="input-group">
        <label for="weight">输入质量</label>
        <input type="number" id="weight" placeholder="例如: 100" min="0">
        <!-- 新增单位切换下拉框 -->
        <select id="unit" class="unit-select" onchange="updateUnitLabel()">
            <option value="g">克 (g)</option>
            <option value="kg">千克 (kg)</option>
        </select>
    </div>

    <button onclick="calculate()">计算能量</button>

    <div id="result">
        <span id="output-text">预计摄入热量:</span><br>
        <span class="calories-val" id="caloriesDisplay">0</span> 千卡 (kcal)
    </div>
</div>

<script>
    // 初始化时更新单位提示文字
    window.onload = updateUnitLabel;

    // 更新输入框的单位提示
    function updateUnitLabel() {
        const unit = document.getElementById('unit').value;
        const weightInput = document.getElementById('weight');
        if(unit === 'g'){
            weightInput.placeholder = "例如: 100";
        }else{
            weightInput.placeholder = "例如: 0.5";
        }
    }

    function calculate() {
        const kcalPer100g = parseFloat(document.getElementById('meatType').value);
        const weight = parseFloat(document.getElementById('weight').value);
        const unit = document.getElementById('unit').value;
        const resultDiv = document.getElementById('result');
        const display = document.getElementById('caloriesDisplay');

        // 验证输入
        if (isNaN(weight) || weight <= 0) {
            alert("请输入有效的质量!");
            return;
        }

        let totalCalories;
        // 根据单位自动换算
        if(unit === 'g'){
            // 克计算:(每100g热量 / 100) * 克数
            totalCalories = (kcalPer100g / 100 * weight).toFixed(1);
        }else{
            // 千克计算:每100g热量 * 10 * 千克数
            totalCalories = (kcalPer100g * 10 * weight).toFixed(1);
        }

        display.innerText = totalCalories;
        resultDiv.style.display = 'block';
    }
</script>

</body>
</html>

若有謬誤,懇請告知。

离线

#4 2026-05-09 23:17:07  |  只看该作者

羽落
虬龍
Registered: 2024-03-30
Posts: 175
网站

回应: AI工具樓

多餘換行清除工具,用於清除文本間多於一個的換行。


<!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;
            font-family: "Microsoft YaHei", Arial, sans-serif;
        }

        body {
            background-color: #f5f7fa;
            padding: 20px;
        }

        .container {
            max-width: 1000px;
            margin: 0 auto;
            background-color: #ffffff;
            border-radius: 12px;
            box-shadow: 0 2px 16px rgba(0, 0, 0, 0.08);
            padding: 30px;
        }

        h1 {
            text-align: center;
            color: #333333;
            margin-bottom: 30px;
            font-size: 24px;
        }

        .text-area-group {
            margin-bottom: 25px;
        }

        label {
            display: block;
            margin-bottom: 8px;
            color: #555555;
            font-weight: 500;
            font-size: 16px;
        }

        textarea {
            width: 100%;
            height: 200px;
            padding: 15px;
            border: 1px solid #e0e0e0;
            border-radius: 8px;
            resize: vertical;
            font-size: 14px;
            line-height: 1.6;
            color: #333;
            outline: none;
            transition: border-color 0.3s ease;
        }

        textarea:focus {
            border-color: #409eff;
            box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
        }

        .btn-group {
            display: flex;
            gap: 12px;
            margin-bottom: 25px;
            flex-wrap: wrap;
        }

        button {
            padding: 12px 24px;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            transition: all 0.3s ease;
        }

        .btn-primary {
            background-color: #409eff;
            color: #ffffff;
        }

        .btn-primary:hover {
            background-color: #337ecc;
        }

        .btn-secondary {
            background-color: #f0f2f5;
            color: #555555;
        }

        .btn-secondary:hover {
            background-color: #e4e6eb;
        }

        .tip {
            text-align: center;
            color: #999999;
            font-size: 12px;
            margin-top: 10px;
        }

        .copy-success {
            position: fixed;
            top: 20px;
            right: 20px;
            background-color: #67c23a;
            color: #ffffff;
            padding: 10px 20px;
            border-radius: 8px;
            font-size: 14px;
            opacity: 0;
            transition: opacity 0.3s ease;
            pointer-events: none;
        }

        .copy-success.show {
            opacity: 1;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>多余换行去除工具</h1>

        <!-- 输入文本区域 -->
        <div class="text-area-group">
            <label for="input-text">待处理文本(可粘贴含多余换行的内容)</label>
            <textarea id="input-text" placeholder="请输入或粘贴需要去除多余换行的文本..."></textarea>
        </div>

        <!-- 操作按钮组 -->
        <div class="btn-group">
            <button class="btn-primary" id="process-btn">去除多余换行</button>
            <button class="btn-secondary" id="clear-input-btn">清空输入</button>
            <button class="btn-secondary" id="clear-output-btn">清空输出</button>
            <button class="btn-secondary" id="copy-output-btn">复制输出结果</button>
        </div>

        <!-- 输出文本区域 -->
        <div class="text-area-group">
            <label for="output-text">处理后文本(已去除多余非正常换行)</label>
            <textarea id="output-text" placeholder="处理后的结果将显示在这里..." readonly></textarea>
        </div>

        <div class="tip">提示:工具会保留单个有效换行,去除连续多个换行(空行)</div>
    </div>

    <!-- 复制成功提示 -->
    <div class="copy-success" id="copy-tip">复制成功!</div>

    <script>
        // 获取页面元素
        const inputText = document.getElementById('input-text');
        const outputText = document.getElementById('output-text');
        const processBtn = document.getElementById('process-btn');
        const clearInputBtn = document.getElementById('clear-input-btn');
        const clearOutputBtn = document.getElementById('clear-output-btn');
        const copyOutputBtn = document.getElementById('copy-output-btn');
        const copyTip = document.getElementById('copy-tip');

        /**
         * 核心功能:去除多余换行
         * 逻辑:
         * 1. 匹配连续多个换行(\n\n+ 匹配连续2个及以上换行,兼容Windows(\r\n)和Linux(\n)格式)
         * 2. 替换为单个换行,保留有效换行结构
         * 3. 去除文本首尾的多余换行和空格
         */
        function removeExtraLineBreaks(text) {
            if (!text) return '';
            // 第一步:将Windows格式换行(\r\n)统一转为Linux格式(\n),避免格式混乱
            const unifiedText = text.replace(/\r\n/g, '\n');
            // 第二步:将连续多个换行(2个及以上)替换为单个换行
            const processedText = unifiedText.replace(/\n{2,}/g, '\n');
            // 第三步:去除文本首尾的换行和空白字符,返回最终结果
            return processedText.trim();
        }

        // 绑定「去除多余换行」按钮事件
        processBtn.addEventListener('click', function() {
            const originalText = inputText.value;
            const resultText = removeExtraLineBreaks(originalText);
            outputText.value = resultText;
        });

        // 绑定「清空输入」按钮事件
        clearInputBtn.addEventListener('click', function() {
            inputText.value = '';
            // 清空输入时可选择是否清空输出(按需调整,这里保留输出)
            // outputText.value = '';
        });

        // 绑定「清空输出」按钮事件
        clearOutputBtn.addEventListener('click', function() {
            outputText.value = '';
        });

        // 绑定「复制输出结果」按钮事件
        copyOutputBtn.addEventListener('click', async function() {
            const outputValue = outputText.value;
            if (!outputValue) {
                alert('输出区域无内容可复制!');
                return;
            }

            try {
                // 调用浏览器剪贴板API复制文本
                await navigator.clipboard.writeText(outputValue);
                // 显示复制成功提示
                copyTip.classList.add('show');
                // 3秒后隐藏提示
                setTimeout(() => {
                    copyTip.classList.remove('show');
                }, 3000);
            } catch (err) {
                console.error('复制失败:', err);
                alert('复制失败,请手动选中复制!');
            }
        });

        // 可选:绑定回车快捷键(仅输入区聚焦时,按Ctrl+Enter触发处理)
        inputText.addEventListener('keydown', function(e) {
            if (e.ctrlKey && e.key === 'Enter') {
                e.preventDefault();
                processBtn.click();
            }
        });
    </script>
</body>
</html>

若有謬誤,懇請告知。

离线

#5 2026-05-19 22:21:52  |  只看该作者

羽落
虬龍
Registered: 2024-03-30
Posts: 175
网站

回应: AI工具樓

像素畫量化工具,可以用於將ai生成海量顏色的像素畫減少顏色種類,cluade出品,搭配https://yinglong.org/forum/viewtopic.php?id=5052食用更佳

已更新v2,添加雙邊濾波預處理和SLIC超像素選項以及現在指向調色盤時處理後的圖像會高亮對應顏色。

<!DOCTYPE html>
<html lang="zh">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>像素畫調色盤精簡工具</title>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Noto+Sans+SC:wght@400;500;700&display=swap');

        :root {
            --bg: #0e0e12;
            --surface: #16161e;
            --surface2: #1e1e2a;
            --border: #2a2a38;
            --accent: #c8ff57;
            --accent2: #57c8ff;
            --text: #e8e8f0;
            --muted: #7878a0;
            --pixel-size: 2px;
        }

        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            background: var(--bg);
            color: var(--text);
            font-family: 'Noto Sans SC', sans-serif;
            min-height: 100vh;
            overflow-x: hidden;
        }

        .scanlines {
            position: fixed;
            inset: 0;
            pointer-events: none;
            z-index: 100;
            background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.03) 2px, rgba(0, 0, 0, 0.03) 4px);
        }

        header {
            border-bottom: 1px solid var(--border);
            padding: 1.5rem 2rem;
            display: flex;
            align-items: baseline;
            gap: 1.5rem;
        }

        .logo {
            font-family: 'Space Mono', monospace;
            font-size: 1.1rem;
            font-weight: 700;
            color: var(--accent);
            letter-spacing: 0.1em;
            text-transform: uppercase;
        }

        .tagline {
            font-size: 0.8rem;
            color: var(--muted);
            letter-spacing: 0.05em;
        }

        .main {
            display: grid;
            grid-template-columns: 340px 1fr;
            height: calc(100vh - 65px);
        }

        .sidebar {
            border-right: 1px solid var(--border);
            padding: 1.5rem;
            overflow-y: auto;
            display: flex;
            flex-direction: column;
            gap: 1.2rem;
        }

        .sidebar>* {
            flex-shrink: 0;
            /* 強制所有側邊欄子元素(面板、按鈕、統計)不壓縮自身高度 */
        }

        .panel {
            background: var(--surface);
            border: 1px solid var(--border);
            border-radius: 8px;
            overflow: visible;
            /* 改爲 visible,防止部分小組件陰影或邊緣被切掉 */
        }

        .panel {
            background: var(--surface);
            border: 1px solid var(--border);
            border-radius: 8px;
            overflow: hidden;
        }

        .panel-header {
            padding: 0.75rem 1rem;
            font-family: 'Space Mono', monospace;
            font-size: 0.7rem;
            text-transform: uppercase;
            letter-spacing: 0.12em;
            color: var(--muted);
            border-bottom: 1px solid var(--border);
            background: var(--surface2);
            display: flex;
            align-items: center;
            justify-content: space-between;
        }

        .panel-header span.badge {
            background: var(--accent);
            color: #000;
            font-size: 0.6rem;
            padding: 2px 6px;
            border-radius: 3px;
            font-weight: 700;
        }

        .panel-body {
            padding: 1rem;
        }

        .drop-zone {
            border: 2px dashed var(--border);
            border-radius: 6px;
            padding: 2rem 1rem;
            text-align: center;
            cursor: pointer;
            transition: all 0.2s;
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 0.5rem;
        }

        .drop-zone:hover,
        .drop-zone.drag-over {
            border-color: var(--accent);
            background: rgba(200, 255, 87, 0.04);
        }

        .drop-icon {
            font-size: 2rem;
            opacity: 0.4;
            display: block;
        }

        .drop-zone p {
            font-size: 0.85rem;
            color: var(--muted);
        }

        .drop-zone strong {
            color: var(--accent);
            font-weight: 500;
        }

        input[type=file] {
            display: none;
        }

        .control-row {
            display: flex;
            flex-direction: column;
            gap: 0.4rem;
            margin-bottom: 1rem;
        }

        .control-row:last-child {
            margin-bottom: 0;
        }

        .control-label {
            display: flex;
            justify-content: space-between;
            font-size: 0.78rem;
            color: var(--muted);
        }

        .control-label span {
            font-family: 'Space Mono', monospace;
            color: var(--accent);
            font-size: 0.72rem;
        }

        input[type=range] {
            width: 100%;
            appearance: none;
            height: 4px;
            background: var(--surface2);
            border-radius: 2px;
            outline: none;
            cursor: pointer;
        }

        input[type=range]::-webkit-slider-thumb {
            appearance: none;
            width: 14px;
            height: 14px;
            border-radius: 50%;
            background: var(--accent);
            cursor: pointer;
            border: 2px solid var(--bg);
        }

        input[type=range]::-webkit-slider-runnable-track {
            height: 4px;
            border-radius: 2px;
        }

        .method-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 0.5rem;
        }

        .method-btn {
            padding: 0.6rem 0.5rem;
            border: 1px solid var(--border);
            background: transparent;
            color: var(--muted);
            border-radius: 5px;
            font-family: 'Space Mono', monospace;
            font-size: 0.65rem;
            text-transform: uppercase;
            letter-spacing: 0.08em;
            cursor: pointer;
            transition: all 0.2s;
            text-align: center;
        }

        .method-btn:hover {
            border-color: var(--accent);
            color: var(--text);
        }

        .method-btn.active {
            background: rgba(200, 255, 87, 0.1);
            border-color: var(--accent);
            color: var(--accent);
        }

        .run-btn {
            width: 100%;
            padding: 0.85rem;
            background: var(--accent);
            color: #000;
            border: none;
            border-radius: 6px;
            font-family: 'Space Mono', monospace;
            font-size: 0.8rem;
            font-weight: 700;
            text-transform: uppercase;
            letter-spacing: 0.1em;
            cursor: pointer;
            transition: all 0.15s;
        }

        .run-btn:hover {
            background: #d8ff77;
            transform: translateY(-1px);
        }

        .run-btn:active {
            transform: translateY(0);
        }

        .run-btn:disabled {
            opacity: 0.4;
            cursor: not-allowed;
            transform: none;
        }

        .stats-grid {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 0.5rem;
        }

        .stat-box {
            background: var(--surface2);
            border-radius: 5px;
            padding: 0.6rem 0.75rem;
        }

        .stat-label {
            font-size: 0.65rem;
            color: var(--muted);
            text-transform: uppercase;
            letter-spacing: 0.08em;
            margin-bottom: 0.25rem;
        }

        .stat-value {
            font-family: 'Space Mono', monospace;
            font-size: 1rem;
            font-weight: 700;
            color: var(--accent);
        }

        .stat-value.muted {
            color: var(--muted);
        }

        .palette-strip {
            display: flex;
            flex-wrap: wrap;
            gap: 3px;
            max-height: 80px;
            overflow-y: auto;
        }

        .swatch {
            width: 16px;
            height: 16px;
            border-radius: 3px;
            border: 1px solid rgba(255, 255, 255, 0.1);
            cursor: pointer;
            transition: transform 0.1s;
        }

        .swatch:hover {
            transform: scale(1.3);
            z-index: 1;
            position: relative;
        }

        .canvas-area {
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }

        .canvas-toolbar {
            border-bottom: 1px solid var(--border);
            padding: 0.75rem 1.5rem;
            display: flex;
            align-items: center;
            gap: 1rem;
            background: var(--surface);
        }

        .view-tabs {
            display: flex;
            gap: 0.25rem;
            background: var(--surface2);
            border-radius: 5px;
            padding: 3px;
        }

        .view-tab {
            padding: 0.35rem 0.9rem;
            border: none;
            background: transparent;
            color: var(--muted);
            font-family: 'Space Mono', monospace;
            font-size: 0.65rem;
            text-transform: uppercase;
            letter-spacing: 0.08em;
            border-radius: 3px;
            cursor: pointer;
            transition: all 0.15s;
        }

        .view-tab.active {
            background: var(--surface);
            color: var(--text);
            border: 1px solid var(--border);
        }

        .zoom-controls {
            display: flex;
            align-items: center;
            gap: 0.5rem;
            margin-left: auto;
        }

        .zoom-btn {
            width: 28px;
            height: 28px;
            border: 1px solid var(--border);
            background: transparent;
            color: var(--text);
            border-radius: 4px;
            font-size: 1rem;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: border-color 0.15s;
        }

        .zoom-btn:hover {
            border-color: var(--accent2);
        }

        .zoom-label {
            font-family: 'Space Mono', monospace;
            font-size: 0.7rem;
            color: var(--muted);
            min-width: 40px;
            text-align: center;
        }

        .download-btn {
            padding: 0.4rem 0.9rem;
            border: 1px solid var(--accent2);
            background: transparent;
            color: var(--accent2);
            border-radius: 4px;
            font-family: 'Space Mono', monospace;
            font-size: 0.65rem;
            text-transform: uppercase;
            letter-spacing: 0.08em;
            cursor: pointer;
            transition: all 0.15s;
        }

        .download-btn:hover {
            background: rgba(87, 200, 255, 0.1);
        }

        .download-btn:disabled {
            opacity: 0.3;
            cursor: not-allowed;
        }

        .canvas-viewport {
            flex: 1;
            overflow: auto;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 2rem;
            background:
                radial-gradient(ellipse at 20% 50%, rgba(200, 255, 87, 0.03) 0%, transparent 50%),
                radial-gradient(ellipse at 80% 20%, rgba(87, 200, 255, 0.03) 0%, transparent 50%),
                repeating-conic-gradient(#16161e 0% 25%, #17171f 0% 50%) 0 0 / 24px 24px;
        }

        .canvases-wrap {
            display: flex;
            gap: 2rem;
            align-items: flex-start;
        }

        .canvas-group {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 0.5rem;
        }

        .canvas-caption {
            font-family: 'Space Mono', monospace;
            font-size: 0.65rem;
            text-transform: uppercase;
            letter-spacing: 0.1em;
            color: var(--muted);
        }

        canvas {
            image-rendering: pixelated;
            image-rendering: crisp-edges;
            display: block;
            border: 1px solid var(--border);
            border-radius: 4px;
        }

        .empty-state {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 1rem;
            color: var(--muted);
            text-align: center;
        }

        .empty-grid {
            display: grid;
            grid-template-columns: repeat(8, 24px);
            gap: 2px;
            opacity: 0.2;
        }

        .empty-pixel {
            width: 24px;
            height: 24px;
            border-radius: 2px;
        }

        .progress-bar {
            height: 2px;
            background: var(--surface2);
            border-radius: 1px;
            overflow: hidden;
            margin-top: 0.5rem;
        }

        .progress-fill {
            height: 100%;
            background: var(--accent);
            border-radius: 1px;
            width: 0%;
            transition: width 0.1s linear;
        }

        .toggle-row {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: 0.75rem;
        }

        .toggle-label {
            font-size: 0.78rem;
            color: var(--muted);
        }

        .toggle {
            width: 36px;
            height: 20px;
            background: var(--surface2);
            border: 1px solid var(--border);
            border-radius: 10px;
            cursor: pointer;
            position: relative;
            transition: background 0.2s;
        }

        .toggle.on {
            background: rgba(200, 255, 87, 0.3);
            border-color: var(--accent);
        }

        .toggle::after {
            content: '';
            position: absolute;
            width: 14px;
            height: 14px;
            background: var(--muted);
            border-radius: 50%;
            top: 2px;
            left: 2px;
            transition: all 0.2s;
        }

        .toggle.on::after {
            left: 18px;
            background: var(--accent);
        }

        ::-webkit-scrollbar {
            width: 6px;
            height: 6px;
        }

        ::-webkit-scrollbar-track {
            background: var(--bg);
        }

        ::-webkit-scrollbar-thumb {
            background: var(--border);
            border-radius: 3px;
        }

        ::-webkit-scrollbar-thumb:hover {
            background: var(--muted);
        }

        @keyframes pulse {

            0%,
            100% {
                opacity: 1
            }

            50% {
                opacity: 0.5
            }
        }

        .processing {
            animation: pulse 1.5s infinite;
        }
    </style>
</head>

<body>
    <div class="scanlines"></div>

    <header>
        <div class="logo">PixelPalette</div>
        <div class="tagline">AI像素畫 → 人工風格調色盤精簡</div>
    </header>

    <div class="main">
        <div class="sidebar">

            <div class="panel">
                <div class="panel-header">輸入圖像</div>
                <div class="panel-body">
                    <div class="drop-zone" id="dropZone">
                        <span class="drop-icon">⊞</span>
                        <p><strong>拖拽圖片</strong>到此處</p>
                        <p>或點擊選擇文件</p>
                        <p style="font-size:0.72rem; margin-top:0.25rem;">支持 PNG / GIF / BMP</p>
                    </div>
                    <input type="file" id="fileInput" accept="image/png,image/gif,image/bmp,image/jpeg,image/webp">
                    <div class="progress-bar" id="progressBar" style="display:none">
                        <div class="progress-fill" id="progressFill"></div>
                    </div>
                </div>
            </div>

            <div class="panel">
                <div class="panel-header">
                    顏色數量
                    <span class="badge" id="colorCountBadge">—</span>
                </div>
                <div class="panel-body">
                    <div class="control-row">
                        <div class="control-label">
                            目標顏色數
                            <span id="paletteSizeVal">32</span>
                        </div>
                        <input type="range" id="paletteSize" min="2" max="256" value="32" step="1">
                    </div>
                    <div class="control-row">
                        <div class="control-label">
                            抖動強度
                            <span id="ditherVal">關</span>
                        </div>
                        <input type="range" id="ditherLevel" min="0" max="4" value="0" step="1">
                    </div>
                </div>
            </div>

            <div class="panel">
                <div class="panel-header">量化算法</div>
                <div class="panel-body">
                    <div class="method-grid" style="grid-template-columns:1fr 1fr 1fr;">
                        <button class="method-btn active" data-method="median-cut">Median Cut</button>
                        <button class="method-btn" data-method="kmeans">K-Means</button>
                        <button class="method-btn" data-method="octree">Octree</button>
                        <button class="method-btn" data-method="popularity">頻率優先</button>
                        <button class="method-btn" data-method="slic" style="grid-column:span 2;" title="超像素聚類量化:將圖像分割爲緊湊超像素區域後聚類,保留邊緣清晰度">SLIC 超像素</button>
                    </div>
                </div>
            </div>

            <div class="panel">
                <div class="panel-header">預處理 / 後處理</div>
                <div class="panel-body">
                    <div class="toggle-row">
                        <span class="toggle-label" title="雙邊濾波:在量化前平滑噪點,同時保留邊緣銳利度,適合高噪聲圖像">🔬 雙邊濾波預處理</span>
                        <div class="toggle" id="toggleBilateral"></div>
                    </div>
                    <div id="bilateralParams" style="display:none; margin-bottom:0.75rem; padding:0.5rem; background:var(--surface2); border-radius:5px; border:1px solid var(--border);">
                        <div class="control-row" style="margin-bottom:0.5rem;">
                            <div class="control-label">空間半徑 σ_s <span id="bilateralSigmaSVal">3</span></div>
                            <input type="range" id="bilateralSigmaS" min="1" max="8" value="3" step="1">
                        </div>
                        <div class="control-row" style="margin-bottom:0;">
                            <div class="control-label">色彩強度 σ_r <span id="bilateralSigmaRVal">40</span></div>
                            <input type="range" id="bilateralSigmaR" min="5" max="100" value="40" step="5">
                        </div>
                    </div>
                    <div class="toggle-row">
                        <span class="toggle-label">亮度矯正</span>
                        <div class="toggle" id="toggleBrightness"></div>
                    </div>
                    <div class="toggle-row">
                        <span class="toggle-label">飽和度增強</span>
                        <div class="toggle" id="toggleSaturation"></div>
                    </div>
                    <div class="toggle-row">
                        <span class="toggle-label">邊緣銳化</span>
                        <div class="toggle" id="toggleSharpen"></div>
                    </div>
                    <div class="control-row" style="margin-bottom:0">
                        <div class="control-label">
                            色彩容差(合併相近色)
                            <span id="toleranceVal">12</span>
                        </div>
                        <input type="range" id="tolerance" min="0" max="64" value="12" step="1">
                    </div>
                </div>
            </div>

            <button class="run-btn" id="runBtn" disabled>▶ 開始精簡</button>

            <div class="panel" id="palettePanel" style="display:none">
                <div class="panel-header">
                    輸出調色盤
                    <span class="badge" id="outColorCount">0色</span>
                </div>
                <div class="panel-body">
                    <div class="palette-strip" id="paletteStrip"></div>
                </div>
            </div>

            <div class="stats-grid" id="statsGrid" style="display:none">
                <div class="stat-box">
                    <div class="stat-label">原始顏色</div>
                    <div class="stat-value" id="statOriginal">—</div>
                </div>
                <div class="stat-box">
                    <div class="stat-label">精簡後</div>
                    <div class="stat-value" id="statOutput">—</div>
                </div>
                <div class="stat-box">
                    <div class="stat-label">壓縮比</div>
                    <div class="stat-value" id="statRatio">—</div>
                </div>
                <div class="stat-box">
                    <div class="stat-label">處理用時</div>
                    <div class="stat-value muted" id="statTime">—</div>
                </div>
            </div>

        </div>

        <div class="canvas-area">
            <div class="canvas-toolbar">
                <div class="view-tabs">
                    <button class="view-tab active" data-view="both">對比</button>
                    <button class="view-tab" data-view="original">原圖</button>
                    <button class="view-tab" data-view="result">結果</button>
                </div>
                <div class="zoom-controls">
                    <button class="zoom-btn" id="zoomOut">−</button>
                    <span class="zoom-label" id="zoomLabel">1×</span>
                    <button class="zoom-btn" id="zoomIn">+</button>
                </div>
                <button class="download-btn" id="downloadBtn" disabled>↓ 導出 PNG</button>
            </div>

            <div class="canvas-viewport" id="viewport">
                <div class="empty-state" id="emptyState">
                    <div class="empty-grid" id="emptyGrid"></div>
                    <p style="font-size:0.85rem;">請先上傳像素畫圖像</p>
                    <p style="font-size:0.75rem; opacity:0.6;">支持AI生成的像素圖、精靈圖、圖標等</p>
                </div>

                <div class="canvases-wrap" id="canvasesWrap" style="display:none">
                    <div class="canvas-group" id="groupOrig">
                        <div class="canvas-caption">原圖</div>
                        <canvas id="canvasOrig"></canvas>
                    </div>
                    <div class="canvas-group" id="groupResult">
                        <div class="canvas-caption">精簡後</div>
                        <canvas id="canvasResult"></canvas>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        const $ = id => document.getElementById(id);

        // Build decorative empty grid
        const colors = ['#c8ff57', '#57c8ff', '#ff5787', '#ffb857', '#8857ff', '#57ffb8'];
        const emptyGrid = $('emptyGrid');
        for (let i = 0; i < 32; i++) {
            const d = document.createElement('div');
            d.className = 'empty-pixel';
            d.style.background = Math.random() < 0.6 ? colors[Math.floor(Math.random() * colors.length)] : '#1e1e2a';
            emptyGrid.appendChild(d);
        }

        // State
        let srcImageData = null;
        let resultImageData = null;
        let zoom = 1;
        let currentView = 'both';
        let toggleStates = { toggleBrightness: false, toggleSaturation: false, toggleSharpen: false, toggleBilateral: false };

        // Toggles
        ['toggleBrightness', 'toggleSaturation', 'toggleSharpen', 'toggleBilateral'].forEach(id => {
            $(id).addEventListener('click', () => {
                toggleStates[id] = !toggleStates[id];
                $(id).classList.toggle('on', toggleStates[id]);
                if (id === 'toggleBilateral') {
                    $('bilateralParams').style.display = toggleStates[id] ? 'block' : 'none';
                }
            });
        });

        // Sliders
        [['paletteSize', 'paletteSizeVal'], ['ditherLevel', 'ditherVal'], ['tolerance', 'toleranceVal']].forEach(([id, out]) => {
            $(id).addEventListener('input', () => {
                let v = $(id).value;
                if (id === 'ditherLevel') v = ['關', '低', '中', '高', '最高'][v];
                $(out).textContent = v;
            });
        });
        $('bilateralSigmaS').addEventListener('input', () => { $('bilateralSigmaSVal').textContent = $('bilateralSigmaS').value; });
        $('bilateralSigmaR').addEventListener('input', () => { $('bilateralSigmaRVal').textContent = $('bilateralSigmaR').value; });

        // File input
        const dropZone = $('dropZone');
        const fileInput = $('fileInput');

        dropZone.addEventListener('click', () => fileInput.click());
        dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
        dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
        dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]); });
        fileInput.addEventListener('change', () => { if (fileInput.files[0]) loadFile(fileInput.files[0]); });

        function loadFile(file) {
            const reader = new FileReader();
            reader.onload = e => {
                const img = new Image();
                img.onload = () => {
                    const c = document.createElement('canvas');
                    c.width = img.width; c.height = img.height;
                    const ctx = c.getContext('2d');
                    ctx.drawImage(img, 0, 0);
                    srcImageData = ctx.getImageData(0, 0, img.width, img.height);

                    const origCanvas = $('canvasOrig');
                    origCanvas.width = img.width; origCanvas.height = img.height;
                    origCanvas.getContext('2d').putImageData(srcImageData, 0, 0);

                    const colorCount = countColors(srcImageData);
                    $('colorCountBadge').textContent = colorCount.toLocaleString() + '色';
                    $('statOriginal').textContent = colorCount.toLocaleString();
                    $('runBtn').disabled = false;

                    dropZone.querySelector('p:first-of-type').innerHTML = `<strong>${file.name}</strong>`;
                    dropZone.querySelector('p:nth-of-type(2)').textContent = `${img.width}×${img.height}`;

                    showCanvases();
                    updateZoom();
                };
                img.src = e.target.result;
            };
            reader.readAsDataURL(file);
        }

        function countColors(imgData) {
            const set = new Set();
            for (let i = 0; i < imgData.data.length; i += 4) {
                if (imgData.data[i + 3] < 128) continue;
                set.add((imgData.data[i] << 16) | (imgData.data[i + 1] << 8) | imgData.data[i + 2]);
            }
            return set.size;
        }

        // Method selection
        let selectedMethod = 'median-cut';
        document.querySelectorAll('.method-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                document.querySelectorAll('.method-btn').forEach(b => b.classList.remove('active'));
                btn.classList.add('active');
                selectedMethod = btn.dataset.method;
            });
        });

        // View tabs
        document.querySelectorAll('.view-tab').forEach(tab => {
            tab.addEventListener('click', () => {
                document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
                tab.classList.add('active');
                currentView = tab.dataset.view;
                updateView();
            });
        });

        function updateView() {
            const go = $('groupOrig'), gr = $('groupResult');
            if (!go || !gr) return;
            if (currentView === 'both') { go.style.display = 'flex'; gr.style.display = 'flex'; }
            else if (currentView === 'original') { go.style.display = 'flex'; gr.style.display = 'none'; }
            else { go.style.display = 'none'; gr.style.display = 'flex'; }
        }

        // Zoom
        let zoomLevels = [0.5, 1, 2, 3, 4, 6, 8, 12, 16];
        let zoomIdx = 1;
        $('zoomIn').addEventListener('click', () => { if (zoomIdx < zoomLevels.length - 1) { zoomIdx++; updateZoom(); } });
        $('zoomOut').addEventListener('click', () => { if (zoomIdx > 0) { zoomIdx--; updateZoom(); } });

        function updateZoom() {
            zoom = zoomLevels[zoomIdx];
            $('zoomLabel').textContent = zoom + '×';
            const canvases = document.querySelectorAll('.canvas-area canvas');
            canvases.forEach(c => {
                c.style.width = c.width * zoom + 'px';
                c.style.height = c.height * zoom + 'px';
            });
        }

        function showCanvases() {
            $('emptyState').style.display = 'none';
            $('canvasesWrap').style.display = 'flex';
        }

        // ===== QUANTIZATION ALGORITHMS =====

        // Median Cut
        function medianCut(pixels, maxColors) {
            let buckets = [pixels.slice()];
            while (buckets.length < maxColors) {
                // Find bucket with largest range
                let maxRange = -1, splitIdx = 0;
                buckets.forEach((bucket, i) => {
                    if (bucket.length === 0) return;
                    const ranges = [0, 1, 2].map(ch => {
                        let mn = 255, mx = 0;
                        bucket.forEach(p => { mn = Math.min(mn, p[ch]); mx = Math.max(mx, p[ch]); });
                        return mx - mn;
                    });
                    const r = Math.max(...ranges);
                    if (r > maxRange) { maxRange = r; splitIdx = i; }
                });
                if (maxRange === 0) break;

                const bucket = buckets.splice(splitIdx, 1)[0];
                const ranges = [0, 1, 2].map(ch => {
                    let mn = 255, mx = 0;
                    bucket.forEach(p => { mn = Math.min(mn, p[ch]); mx = Math.max(mx, p[ch]); });
                    return mx - mn;
                });
                const sortCh = ranges.indexOf(Math.max(...ranges));
                bucket.sort((a, b) => a[sortCh] - b[sortCh]);
                const mid = Math.floor(bucket.length / 2);
                buckets.push(bucket.slice(0, mid));
                buckets.push(bucket.slice(mid));
            }
            return buckets.filter(b => b.length > 0).map(bucket => {
                let r = 0, g = 0, b = 0;
                bucket.forEach(p => { r += p[0]; g += p[1]; b += p[2]; });
                const n = bucket.length;
                return [Math.round(r / n), Math.round(g / n), Math.round(b / n)];
            });
        }

        // K-Means
        function kmeans(pixels, k, iterations = 10) {
            let centroids = [];
            const step = Math.floor(pixels.length / k);
            for (let i = 0; i < k; i++) centroids.push([...pixels[i * step] || pixels[0]]);

            for (let iter = 0; iter < iterations; iter++) {
                const clusters = Array.from({ length: k }, () => []);
                pixels.forEach(p => {
                    let minD = Infinity, minI = 0;
                    centroids.forEach((c, i) => {
                        const d = (p[0] - c[0]) ** 2 + (p[1] - c[1]) ** 2 + (p[2] - c[2]) ** 2;
                        if (d < minD) { minD = d; minI = i; }
                    });
                    clusters[minI].push(p);
                });
                let changed = false;
                clusters.forEach((cluster, i) => {
                    if (cluster.length === 0) return;
                    let r = 0, g = 0, b = 0;
                    cluster.forEach(p => { r += p[0]; g += p[1]; b += p[2]; });
                    const n = cluster.length;
                    const nr = Math.round(r / n), ng = Math.round(g / n), nb = Math.round(b / n);
                    if (nr !== centroids[i][0] || ng !== centroids[i][1] || nb !== centroids[i][2]) {
                        centroids[i] = [nr, ng, nb]; changed = true;
                    }
                });
                if (!changed) break;
            }
            return centroids;
        }

        // Octree (simplified)
        function octreeQuant(pixels, maxColors) {
            // Use a simplified version: histogram + merge small buckets
            const hist = {};
            pixels.forEach(p => {
                const key = `${p[0] >> 2},${p[1] >> 2},${p[2] >> 2}`;
                if (!hist[key]) hist[key] = { r: 0, g: 0, b: 0, count: 0, rr: p[0] >> 2, gg: p[1] >> 2, bb: p[2] >> 2 };
                hist[key].r += p[0]; hist[key].g += p[1]; hist[key].b += p[2]; hist[key].count++;
            });
            let buckets = Object.values(hist).sort((a, b) => b.count - a.count);
            buckets = buckets.slice(0, maxColors);
            return buckets.map(b => [Math.round(b.r / b.count), Math.round(b.g / b.count), Math.round(b.b / b.count)]);
        }

        // Popularity
        function popularityQuant(pixels, maxColors) {
            const hist = {};
            pixels.forEach(p => {
                const key = (p[0] << 16) | (p[1] << 8) | p[2];
                hist[key] = (hist[key] || 0) + 1;
            });
            return Object.entries(hist)
                .sort((a, b) => b[1] - a[1])
                .slice(0, maxColors)
                .map(([k]) => [(k >> 16) & 255, (k >> 8) & 255, k & 255]);
        }

        // Nearest color in palette
        function nearestColor(r, g, b, palette) {
            let minD = Infinity, best = 0;
            palette.forEach((c, i) => {
                const d = (r - c[0]) ** 2 + (g - c[1]) ** 2 + (b - c[2]) ** 2;
                if (d < minD) { minD = d; best = i; }
            });
            return best;
        }

        // Merge similar colors by tolerance
        function mergeSimilar(palette, tolerance) {
            const tol2 = tolerance * tolerance;
            const result = [];
            const merged = new Array(palette.length).fill(false);
            for (let i = 0; i < palette.length; i++) {
                if (merged[i]) continue;
                let group = [palette[i]];
                for (let j = i + 1; j < palette.length; j++) {
                    if (!merged[j]) {
                        const d = (palette[i][0] - palette[j][0]) ** 2 + (palette[i][1] - palette[j][1]) ** 2 + (palette[i][2] - palette[j][2]) ** 2;
                        if (d <= tol2) { group.push(palette[j]); merged[j] = true; }
                    }
                }
                const avg = group.reduce((a, c) => [a[0] + c[0], a[1] + c[1], a[2] + c[2]], [0, 0, 0]);
                result.push([Math.round(avg[0] / group.length), Math.round(avg[1] / group.length), Math.round(avg[2] / group.length)]);
            }
            return result;
        }

        // Floyd-Steinberg dither
        function floydSteinberg(imgData, palette, strength) {
            const w = imgData.width, h = imgData.height;
            const data = new Float32Array(imgData.data);
            const result = new Uint8ClampedArray(imgData.data);
            const s = strength / 4;

            for (let y = 0; y < h; y++) {
                for (let x = 0; x < w; x++) {
                    const idx = (y * w + x) * 4;
                    const or = data[idx], og = data[idx + 1], ob = data[idx + 2];
                    const ci = nearestColor(Math.max(0, Math.min(255, or)), Math.max(0, Math.min(255, og)), Math.max(0, Math.min(255, ob)), palette);
                    result[idx] = palette[ci][0]; result[idx + 1] = palette[ci][1]; result[idx + 2] = palette[ci][2]; result[idx + 3] = imgData.data[idx + 3];
                    const er = or - palette[ci][0], eg = og - palette[ci][1], eb = ob - palette[ci][2];
                    const spread = [[1, 0, 7 / 16], [- 1, 1, 3 / 16], [0, 1, 5 / 16], [1, 1, 1 / 16]];
                    spread.forEach(([dx, dy, w2]) => {
                        const nx = x + dx, ny = y + dy;
                        if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
                            const ni = (ny * w + nx) * 4;
                            data[ni] += er * w2 * s * 4;
                            data[ni + 1] += eg * w2 * s * 4;
                            data[ni + 2] += eb * w2 * s * 4;
                        }
                    });
                }
            }
            return result;
        }

        // Post process
        function adjustSaturation(data, factor) {
            for (let i = 0; i < data.length; i += 4) {
                const r = data[i], g = data[i + 1], b = data[i + 2];
                const gray = 0.299 * r + 0.587 * g + 0.114 * b;
                data[i] = Math.max(0, Math.min(255, gray + (r - gray) * factor));
                data[i + 1] = Math.max(0, Math.min(255, gray + (g - gray) * factor));
                data[i + 2] = Math.max(0, Math.min(255, gray + (b - gray) * factor));
            }
        }

        function adjustBrightness(data) {
            let sum = 0, cnt = 0;
            for (let i = 0; i < data.length; i += 4) { sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; cnt++; }
            const avg = sum / cnt;
            const factor = avg < 100 ? 1.15 : avg > 160 ? 0.9 : 1.0;
            for (let i = 0; i < data.length; i += 4) {
                data[i] = Math.max(0, Math.min(255, data[i] * factor));
                data[i + 1] = Math.max(0, Math.min(255, data[i + 1] * factor));
                data[i + 2] = Math.max(0, Math.min(255, data[i + 2] * factor));
            }
        }

        // Sharpen (simple 3x3 unsharp)
        function sharpen(data, w, h) {
            const orig = new Uint8ClampedArray(data);
            const kernel = [0, -1, 0, -1, 5, -1, 0, -1, 0];
            for (let y = 1; y < h - 1; y++) {
                for (let x = 1; x < w - 1; x++) {
                    for (let ch = 0; ch < 3; ch++) {
                        let sum = 0;
                        for (let ky = -1; ky <= 1; ky++) {
                            for (let kx = -1; kx <= 1; kx++) {
                                const ki = (ky + 1) * 3 + (kx + 1);
                                const pi = ((y + ky) * w + (x + kx)) * 4 + ch;
                                sum += orig[pi] * kernel[ki];
                            }
                        }
                        data[(y * w + x) * 4 + ch] = Math.max(0, Math.min(255, sum));
                    }
                }
            }
        }

        // ===== BILATERAL FILTER (Pre-processing) =====
        // Preserves edges while smoothing color noise — ideal before quantization.
        // Uses a spatial Gaussian (σ_s) and a color-range Gaussian (σ_r).
        function bilateralFilter(imgData, sigmaS, sigmaR) {
            const w = imgData.width, h = imgData.height;
            const src = imgData.data;
            const dst = new Uint8ClampedArray(src);
            const radius = Math.ceil(sigmaS * 2);
            const sigmaS2 = 2 * sigmaS * sigmaS;
            const sigmaR2 = 2 * sigmaR * sigmaR;

            // Precompute spatial Gaussian kernel weights
            const kernelSize = radius * 2 + 1;
            const spatialW = new Float32Array(kernelSize * kernelSize);
            for (let dy = -radius; dy <= radius; dy++) {
                for (let dx = -radius; dx <= radius; dx++) {
                    spatialW[(dy + radius) * kernelSize + (dx + radius)] =
                        Math.exp(-(dx * dx + dy * dy) / sigmaS2);
                }
            }

            for (let y = 0; y < h; y++) {
                for (let x = 0; x < w; x++) {
                    const ci = (y * w + x) * 4;
                    if (src[ci + 3] < 128) continue;
                    const cr = src[ci], cg = src[ci + 1], cb = src[ci + 2];
                    let sumR = 0, sumG = 0, sumB = 0, sumW = 0;

                    for (let dy = -radius; dy <= radius; dy++) {
                        const ny = Math.max(0, Math.min(h - 1, y + dy));
                        for (let dx = -radius; dx <= radius; dx++) {
                            const nx = Math.max(0, Math.min(w - 1, x + dx));
                            const ni = (ny * w + nx) * 4;
                            if (src[ni + 3] < 128) continue;

                            const dr = src[ni] - cr, dg = src[ni + 1] - cg, db = src[ni + 2] - cb;
                            const colorDist2 = dr * dr + dg * dg + db * db;
                            const sw = spatialW[(dy + radius) * kernelSize + (dx + radius)];
                            const rangeW = Math.exp(-colorDist2 / sigmaR2);
                            const weight = sw * rangeW;

                            sumR += src[ni] * weight;
                            sumG += src[ni + 1] * weight;
                            sumB += src[ni + 2] * weight;
                            sumW += weight;
                        }
                    }

                    if (sumW > 0) {
                        dst[ci] = Math.round(sumR / sumW);
                        dst[ci + 1] = Math.round(sumG / sumW);
                        dst[ci + 2] = Math.round(sumB / sumW);
                    }
                    dst[ci + 3] = src[ci + 3];
                }
            }
            return new ImageData(dst, w, h);
        }

        // ===== SLIC SUPERPIXEL QUANTIZATION =====
        // Simple Linear Iterative Clustering: partitions the image into compact,
        // perceptually uniform superpixels in [CIELAB + XY] space, then
        // applies k-means on their mean colors to derive the palette.
        function slicQuant(imgData, maxColors) {
            const w = imgData.width, h = imgData.height;
            const data = imgData.data;
            const N = w * h;

            // --- Convert RGB→Lab for perceptual clustering ---
            function rgbToLab(r, g, b) {
                // sRGB → linear
                let rl = r / 255, gl = g / 255, bl = b / 255;
                rl = rl > 0.04045 ? Math.pow((rl + 0.055) / 1.055, 2.4) : rl / 12.92;
                gl = gl > 0.04045 ? Math.pow((gl + 0.055) / 1.055, 2.4) : gl / 12.92;
                bl = bl > 0.04045 ? Math.pow((bl + 0.055) / 1.055, 2.4) : bl / 12.92;
                // linear → XYZ (D65)
                let X = rl * 0.4124564 + gl * 0.3575761 + bl * 0.1804375;
                let Y = rl * 0.2126729 + gl * 0.7151522 + bl * 0.0721750;
                let Z = rl * 0.0193339 + gl * 0.1191920 + bl * 0.9503041;
                X /= 0.95047; Z /= 1.08883;
                function f(t) { return t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116; }
                const fx = f(X), fy = f(Y), fz = f(Z);
                return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
            }

            // Build pixel Lab + alpha arrays
            const L = new Float32Array(N), A = new Float32Array(N), B_ = new Float32Array(N);
            const alpha = new Uint8Array(N);
            for (let i = 0; i < N; i++) {
                const pi = i * 4;
                alpha[i] = data[pi + 3];
                if (data[pi + 3] >= 128) {
                    const lab = rgbToLab(data[pi], data[pi + 1], data[pi + 2]);
                    L[i] = lab[0]; A[i] = lab[1]; B_[i] = lab[2];
                }
            }

            // --- Initialize superpixel centers on a grid ---
            // Target superpixel count: use enough to capture structure but manageable
            const S = Math.max(2, Math.round(Math.sqrt(N / (maxColors * 4))));
            const gridCols = Math.ceil(w / S), gridRows = Math.ceil(h / S);
            const K = gridCols * gridRows; // actual superpixel count

            const cL = new Float32Array(K), cA = new Float32Array(K), cB = new Float32Array(K);
            const cX = new Float32Array(K), cY = new Float32Array(K);
            let ki = 0;
            for (let gy = 0; gy < gridRows; gy++) {
                for (let gx = 0; gx < gridCols; gx++) {
                    const cx = Math.min(Math.round((gx + 0.5) * S), w - 1);
                    const cy = Math.min(Math.round((gy + 0.5) * S), h - 1);
                    const idx = cy * w + cx;
                    cL[ki] = L[idx]; cA[ki] = A[idx]; cB[ki] = B_[idx];
                    cX[ki] = cx; cY[ki] = cy;
                    ki++;
                }
            }

            const labels = new Int32Array(N).fill(-1);
            const distances = new Float32Array(N).fill(Infinity);
            const m = 10; // compactness factor (spatial vs color weight)
            const mOverS = m / S;

            // --- SLIC iterations ---
            const ITERS = 6;
            for (let iter = 0; iter < ITERS; iter++) {
                distances.fill(Infinity);

                for (let k = 0; k < K; k++) {
                    const x0 = Math.max(0, Math.round(cX[k]) - S);
                    const x1 = Math.min(w - 1, Math.round(cX[k]) + S);
                    const y0 = Math.max(0, Math.round(cY[k]) - S);
                    const y1 = Math.min(h - 1, Math.round(cY[k]) + S);

                    for (let py = y0; py <= y1; py++) {
                        for (let px = x0; px <= x1; px++) {
                            const pidx = py * w + px;
                            if (alpha[pidx] < 128) continue;

                            const dL = L[pidx] - cL[k], dA = A[pidx] - cA[k], dB = B_[pidx] - cB[k];
                            const dc = Math.sqrt(dL * dL + dA * dA + dB * dB);
                            const dx = px - cX[k], dy = py - cY[k];
                            const ds = Math.sqrt(dx * dx + dy * dy);
                            const D = dc + mOverS * ds;

                            if (D < distances[pidx]) {
                                distances[pidx] = D;
                                labels[pidx] = k;
                            }
                        }
                    }
                }

                // Recompute cluster centers
                const sumL = new Float64Array(K), sumA = new Float64Array(K), sumB = new Float64Array(K);
                const sumX = new Float64Array(K), sumY = new Float64Array(K), cnt = new Int32Array(K);
                for (let i = 0; i < N; i++) {
                    const k = labels[i];
                    if (k < 0) continue;
                    sumL[k] += L[i]; sumA[k] += A[i]; sumB[k] += B_[i];
                    sumX[k] += i % w; sumY[k] += Math.floor(i / w);
                    cnt[k]++;
                }
                for (let k = 0; k < K; k++) {
                    if (cnt[k] > 0) {
                        cL[k] = sumL[k] / cnt[k]; cA[k] = sumA[k] / cnt[k]; cB[k] = sumB[k] / cnt[k];
                        cX[k] = sumX[k] / cnt[k]; cY[k] = sumY[k] / cnt[k];
                    }
                }
            }

            // --- Collect mean RGB per superpixel ---
            const spR = new Float64Array(K), spG = new Float64Array(K), spBc = new Float64Array(K);
            const spCnt = new Int32Array(K);
            for (let i = 0; i < N; i++) {
                const k = labels[i];
                if (k < 0) continue;
                const pi = i * 4;
                spR[k] += data[pi]; spG[k] += data[pi + 1]; spBc[k] += data[pi + 2];
                spCnt[k]++;
            }
            const spColors = [];
            for (let k = 0; k < K; k++) {
                if (spCnt[k] > 0) {
                    spColors.push([
                        Math.round(spR[k] / spCnt[k]),
                        Math.round(spG[k] / spCnt[k]),
                        Math.round(spBc[k] / spCnt[k])
                    ]);
                }
            }

            // --- Final k-means on superpixel colors to get palette ---
            return kmeans(spColors, Math.min(maxColors, spColors.length), 12);
        }


        // 新增:用於顏色高亮功能的全局狀態
        let currentPalette = []; 
        let isPostProcessed = false;
        let originalResultPixels = null; // 備份未高亮前的結果圖數據
        $('runBtn').addEventListener('click', async () => {
            if (!srcImageData) return;
            const t0 = performance.now();
            $('runBtn').disabled = true;
            $('runBtn').textContent = '⟳ 處理中...';
            $('runBtn').classList.add('processing');
            $('progressBar').style.display = 'block';
            $('progressFill').style.width = '5%';

            await new Promise(r => setTimeout(r, 20));

            const maxColors = parseInt($('paletteSize').value);
            const tolerance = parseInt($('tolerance').value);
            const ditherLevel = parseInt($('ditherLevel').value);
            const w = srcImageData.width, h = srcImageData.height;

            // --- PRE-PROCESSING: Bilateral Filter ---
            let workingData = srcImageData;
            if (toggleStates.toggleBilateral) {
                $('runBtn').textContent = '⟳ 雙邊濾波...';
                await new Promise(r => setTimeout(r, 10));
                const sigmaS = parseFloat($('bilateralSigmaS').value);
                const sigmaR = parseFloat($('bilateralSigmaR').value);
                workingData = bilateralFilter(srcImageData, sigmaS, sigmaR);
            }

            $('progressFill').style.width = '20%';
            await new Promise(r => setTimeout(r, 10));

            // Sample pixels (skip transparent)
            const pixels = [];
            for (let i = 0; i < workingData.data.length; i += 4) {
                if (workingData.data[i + 3] >= 128)
                    pixels.push([workingData.data[i], workingData.data[i + 1], workingData.data[i + 2]]);
            }

            // Subsample for speed
            const maxSample = 50000;
            const sampled = pixels.length > maxSample
                ? pixels.filter((_, i) => i % (Math.ceil(pixels.length / maxSample)) === 0)
                : pixels;

            $('progressFill').style.width = '35%';
            await new Promise(r => setTimeout(r, 10));

            let palette;
            if (selectedMethod === 'median-cut') palette = medianCut(sampled, maxColors);
            else if (selectedMethod === 'kmeans') palette = kmeans(sampled, Math.min(maxColors, sampled.length));
            else if (selectedMethod === 'octree') palette = octreeQuant(sampled, maxColors);
            else if (selectedMethod === 'slic') {
                $('runBtn').textContent = '⟳ SLIC 分割...';
                await new Promise(r => setTimeout(r, 10));
                palette = slicQuant(workingData, maxColors);
            }
            else palette = popularityQuant(sampled, maxColors);

            $('progressFill').style.width = '55%';
            await new Promise(r => setTimeout(r, 10));

            if (tolerance > 0) palette = mergeSimilar(palette, tolerance);

            // Apply palette to image (map from workingData for bilateral pre-processed result)
            let resultData;
            if (ditherLevel > 0) {
                resultData = floydSteinberg(workingData, palette, ditherLevel);
            } else {
                resultData = new Uint8ClampedArray(workingData.data);
                for (let i = 0; i < resultData.length; i += 4) {
                    if (resultData[i + 3] < 128) continue;
                    const ci = nearestColor(resultData[i], resultData[i + 1], resultData[i + 2], palette);
                    resultData[i] = palette[ci][0]; resultData[i + 1] = palette[ci][1]; resultData[i + 2] = palette[ci][2];
                }
            }

            $('progressFill').style.width = '75%';
            await new Promise(r => setTimeout(r, 10));

            // Post-process
            if (toggleStates.toggleBrightness) adjustBrightness(resultData);
            if (toggleStates.toggleSaturation) adjustSaturation(resultData, 1.3);
            if (toggleStates.toggleSharpen) sharpen(resultData, w, h);

            $('progressFill').style.width = '90%';
            await new Promise(r => setTimeout(r, 10));

            // Draw result
            const rc = $('canvasResult');
            rc.width = w; rc.height = h;
            const rctx = rc.getContext('2d');
            resultImageData = new ImageData(resultData, w, h);
            rctx.putImageData(resultImageData, 0, 0);
            updateZoom();
            showCanvases();
            updateView();

            // Actual color count in result
            const actualCount = countColors(resultImageData);

            // Update palette display
            $('palettePanel').style.display = 'block';
            const strip = $('paletteStrip');
            strip.innerHTML = '';
            palette.slice(0, 128).forEach(c => {
                const sw = document.createElement('div');
                sw.className = 'swatch';
                sw.style.background = `rgb(${c[0]},${c[1]},${c[2]})`;
                sw.title = `#${c[0].toString(16).padStart(2, '0')}${c[1].toString(16).padStart(2, '0')}${c[2].toString(16).padStart(2, '0')}`;
                strip.appendChild(sw);
            });
            $('outColorCount').textContent = actualCount + '色';
            // 保存當前調色盤與後處理狀態
            currentPalette = palette;
            isPostProcessed = toggleStates.toggleBrightness || toggleStates.toggleSaturation || toggleStates.toggleSharpen;
            // 備份一份乾淨的、沒有高亮白色像素的結果圖數據
            originalResultPixels = new Uint8ClampedArray(resultData);

            // Stats
            const origCount = parseInt($('statOriginal').textContent.replace(/,/g, '')) || countColors(srcImageData);
            $('statsGrid').style.display = 'grid';
            $('statOriginal').textContent = origCount.toLocaleString();
            $('statOutput').textContent = actualCount.toLocaleString();
            const ratio = origCount > 0 ? (origCount / actualCount).toFixed(1) + '×' : '—';
            $('statRatio').textContent = ratio;
            $('statTime').textContent = ((performance.now() - t0) / 1000).toFixed(2) + 's';

            $('progressFill').style.width = '100%';
            setTimeout(() => { $('progressBar').style.display = 'none'; $('progressFill').style.width = '0%'; }, 500);

            $('downloadBtn').disabled = false;
            $('runBtn').disabled = false;
            $('runBtn').textContent = '▶ 重新精簡';
            $('runBtn').classList.remove('processing');
        });

        // ===== 新增:調色盤顏色像素高亮功能 =====
        const strip = $('paletteStrip');
        
        // 鼠標移入色塊:將對應顏色在畫布中變成白色
        strip.addEventListener('mouseover', (e) => {
            // 確保移入的是色塊,且未開啓後處理,且有備份的數據
            if (!e.target.classList.contains('swatch') || isPostProcessed || !originalResultPixels) return;
            
            const rc = $('canvasResult');
            const rctx = rc.getContext('2d');
            const w = resultImageData.width;
            const h = resultImageData.height;
            
            // 獲取當前懸停色塊的 RGB 顏色值
            const rgbStr = e.target.style.background; // 格式通常爲 "rgb(r, g, b)"
            const match = rgbStr.match(/\d+/g);
            if (!match) return;
            const [sr, sg, sb] = match.map(Number);
            
            // 基於備份的像素數據進行修改,避免多次懸停導致色彩污染
            const tempPixels = new Uint8ClampedArray(originalResultPixels);
            
            // 遍歷像素,尋找相同的顏色並替換爲白色 (255, 255, 255)
            for (let i = 0; i < tempPixels.length; i += 4) {
                if (tempPixels[i + 3] < 128) continue; // 跳過透明像素
                
                if (tempPixels[i] === sr && tempPixels[i + 1] === sg && tempPixels[i + 2] === sb) {
                    tempPixels[i] = 255;     // R
                    tempPixels[i + 1] = 255; // G
                    tempPixels[i + 2] = 255; // B
                }
            }
            
            // 渲染高亮後的圖像
            const highlightedData = new ImageData(tempPixels, w, h);
            rctx.putImageData(highlightedData, 0, 0);
        });

        // 鼠標移出色塊:恢復原狀
        strip.addEventListener('mouseout', (e) => {
            if (!e.target.classList.contains('swatch') || isPostProcessed || !originalResultPixels) return;
            
            const rc = $('canvasResult');
            const rctx = rc.getContext('2d');
            
            // 直接用備份的乾淨數據重新覆蓋畫布
            const restoredData = new ImageData(new Uint8ClampedArray(originalResultPixels), resultImageData.width, resultImageData.height);
            rctx.putImageData(restoredData, 0, 0);
        });

        // Download
        $('downloadBtn').addEventListener('click', () => {
            if (!resultImageData) return;
            const c = document.createElement('canvas');
            c.width = resultImageData.width; c.height = resultImageData.height;
            c.getContext('2d').putImageData(resultImageData, 0, 0);
            const a = document.createElement('a');
            a.download = 'pixelpalette_output.png';
            a.href = c.toDataURL('image/png');
            a.click();
        });
    </script>
</body>

</html>

最后修改: 羽落 (2026-05-20 14:16:49)


若有謬誤,懇請告知。

离线

#6 2026-05-19 22:28:15  |  只看该作者

羽落
虬龍
Registered: 2024-03-30
Posts: 175
网站

回应: AI工具樓

像素畫轉h5 canvas網頁,最近在摸索怎麼讓ai給我把像素畫變成gif,順便就vibe code了一個這個。注意:超過128*128像素轉換可能會卡頓,對於海量顏色的ai像素畫原圖轉換的網頁大小會很大(300kb左右),46種顏色、大概256*170的像素畫也要70kb,還在摸索更加好的方式。



<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>像素画 → Canvas 代码转换器</title>
  <style>
    @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Noto+Sans+SC:wght@300;500;700&display=swap');

    :root {
      --bg: #0e0e12;
      --surface: #16161e;
      --border: #2a2a38;
      --accent: #e8ff5a;
      --accent2: #ff5a8a;
      --accent3: #5af0ff;
      --text: #e8e8f0;
      --muted: #6a6a88;
      --pixel: 4px;
    }

    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    body {
      background: var(--bg);
      color: var(--text);
      font-family: 'Noto Sans SC', sans-serif;
      font-weight: 300;
      min-height: 100vh;
      overflow-x: hidden;
    }

    /* Scanline overlay */
    body::before {
      content: '';
      position: fixed;
      inset: 0;
      background: repeating-linear-gradient(0deg,
          transparent,
          transparent 2px,
          rgba(0, 0, 0, 0.08) 2px,
          rgba(0, 0, 0, 0.08) 4px);
      pointer-events: none;
      z-index: 999;
    }

    header {
      padding: 2rem 2.5rem 1.5rem;
      border-bottom: 1px solid var(--border);
      display: flex;
      align-items: baseline;
      gap: 1.2rem;
      position: relative;
    }

    header::after {
      content: '';
      position: absolute;
      bottom: -1px;
      left: 0;
      width: 120px;
      height: 2px;
      background: var(--accent);
    }

    .logo {
      font-family: 'Space Mono', monospace;
      font-size: 1.4rem;
      font-weight: 700;
      color: var(--accent);
      letter-spacing: -1px;
    }

    .logo span {
      color: var(--accent2);
    }

    .subtitle {
      font-size: 0.78rem;
      color: var(--muted);
      letter-spacing: 2px;
      text-transform: uppercase;
    }

    main {
      display: grid;
      grid-template-columns: 1fr 1fr;
      grid-template-rows: auto 1fr;
      gap: 0;
      height: calc(100vh - 73px);
      min-height: 0;
      overflow: hidden;
    }

    /* ---- Drop zone ---- */
    .drop-zone {
      grid-column: 1;
      grid-row: 1 / 3;
      border-right: 1px solid var(--border);
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 1.2rem;
      padding: 2rem;
      cursor: pointer;
      transition: background 0.2s;
      position: relative;
      overflow: hidden;
    }

    .drop-zone:hover {
      background: rgba(232, 255, 90, 0.03);
    }

    .drop-zone.drag-over {
      background: rgba(232, 255, 90, 0.07);
    }

    .drop-zone.has-image {
      justify-content: flex-start;
      padding-top: 1.5rem;
    }

    .drop-icon {
      width: 64px;
      height: 64px;
      border: 2px solid var(--border);
      display: grid;
      place-items: center;
      color: var(--muted);
      position: relative;
    }

    .drop-icon::before,
    .drop-icon::after {
      content: '';
      position: absolute;
      background: var(--accent);
    }

    .drop-icon::before {
      width: 2px;
      height: 28px;
    }

    .drop-icon::after {
      width: 28px;
      height: 2px;
    }

    .drop-text {
      text-align: center;
      font-size: 0.82rem;
      color: var(--muted);
      line-height: 1.7;
    }

    .drop-text strong {
      color: var(--text);
      font-weight: 500;
      display: block;
      margin-bottom: 4px;
    }

    #file-input {
      display: none;
    }

    .pixel-preview {
      image-rendering: pixelated;
      image-rendering: crisp-edges;
      max-width: 100%;
      max-height: calc(100% - 120px);
      border: 1px solid var(--border);
      display: none;
    }

    .preview-info {
      font-family: 'Space Mono', monospace;
      font-size: 0.72rem;
      color: var(--accent3);
      text-align: center;
      display: none;
    }

    .btn-convert {
      display: none;
      background: var(--accent);
      color: #000;
      border: none;
      font-family: 'Space Mono', monospace;
      font-size: 0.78rem;
      font-weight: 700;
      letter-spacing: 1px;
      padding: 0.6rem 1.8rem;
      cursor: pointer;
      text-transform: uppercase;
      transition: all 0.15s;
      position: relative;
    }

    .btn-convert::after {
      content: '';
      position: absolute;
      bottom: -3px;
      right: -3px;
      width: 100%;
      height: 100%;
      border: 2px solid var(--accent);
      transition: all 0.15s;
    }

    .btn-convert:hover {
      transform: translate(-2px, -2px);
    }

    .btn-convert:hover::after {
      bottom: -5px;
      right: -5px;
    }

    .btn-convert:active {
      transform: translate(0, 0);
    }

    .error-msg {
      color: var(--accent2);
      font-size: 0.78rem;
      font-family: 'Space Mono', monospace;
      text-align: center;
      display: none;
    }

    /* ---- Output panel ---- */
    .output-panel {
      grid-column: 2;
      grid-row: 1 / 3;
      display: flex;
      flex-direction: column;
      min-width: 0;
      /* 关键修复:防止超长单行文本把面板无限撑宽 */
      min-height: 0;
      /* 关键修复:防止纵向无限撑高 */
      height: 100%;
      /* 填满父容器的高度 */
    }

    .panel-header {
      padding: 1rem 1.5rem;
      border-bottom: 1px solid var(--border);
      display: flex;
      align-items: center;
      justify-content: space-between;
    }

    .panel-title {
      font-family: 'Space Mono', monospace;
      font-size: 0.72rem;
      color: var(--muted);
      letter-spacing: 2px;
      text-transform: uppercase;
    }

    .panel-actions {
      display: flex;
      gap: 0.5rem;
    }

    .btn-sm {
      background: transparent;
      border: 1px solid var(--border);
      color: var(--muted);
      font-family: 'Space Mono', monospace;
      font-size: 0.65rem;
      padding: 0.3rem 0.8rem;
      cursor: pointer;
      letter-spacing: 1px;
      text-transform: uppercase;
      transition: all 0.15s;
    }

    .btn-sm:hover {
      border-color: var(--accent3);
      color: var(--accent3);
    }

    .btn-sm.active {
      border-color: var(--accent);
      color: var(--accent);
    }

    .code-area {
      flex: 1;
      overflow: auto;
      /* 此时生效,会规规矩矩在内部出现滚动条 */
      padding: 1.2rem 1.5rem;
      font-family: 'Space Mono', monospace;
      font-size: 0.68rem;
      line-height: 1.8;
      color: #c8c8e0;
      background: var(--surface);
      white-space: pre-wrap;
      /* 替换原来的 pre:允许代码在超长时自动换行,防止横向无限溢出 */
      word-break: break-all;
      /* 强制长字符串/数组在边界断行,彻底杜绝撑开容器 */
      tab-size: 2;
    }

    .code-area:empty::before {
      content: '// 上传像素画后点击"转换"按钮\n// 这里将生成 canvas 绘制代码';
      color: var(--muted);
      font-style: italic;
    }

    /* syntax highlight colours */
    .kw {
      color: #ff5a8a;
    }

    .fn {
      color: #e8ff5a;
    }

    .str {
      color: #5af0ff;
    }

    .num {
      color: #ffb85a;
    }

    .cm {
      color: #6a6a88;
      font-style: italic;
    }

    .status-bar {
      padding: 0.4rem 1.5rem;
      border-top: 1px solid var(--border);
      background: var(--bg);
      font-family: 'Space Mono', monospace;
      font-size: 0.62rem;
      color: var(--muted);
      display: flex;
      gap: 2rem;
    }

    .status-bar span {
      color: var(--accent3);
    }

    /* copy toast */
    .toast {
      position: fixed;
      bottom: 2rem;
      right: 2rem;
      background: var(--accent);
      color: #000;
      font-family: 'Space Mono', monospace;
      font-size: 0.72rem;
      font-weight: 700;
      padding: 0.5rem 1.2rem;
      transform: translateY(80px);
      opacity: 0;
      transition: all 0.3s cubic-bezier(.34, 1.56, .64, 1);
      pointer-events: none;
      z-index: 1000;
    }

    .toast.show {
      transform: translateY(0);
      opacity: 1;
    }

    /* pixel grid bg decoration */
    .pixel-grid-bg {
      position: absolute;
      inset: 0;
      background-image:
        linear-gradient(var(--border) 1px, transparent 1px),
        linear-gradient(90deg, var(--border) 1px, transparent 1px);
      background-size: 24px 24px;
      opacity: 0.3;
      pointer-events: none;
    }
  </style>
</head>

<body>

  <header>
    <div class="logo">PIXEL<span>→</span>CVS</div>
    <div class="subtitle">像素画 · Canvas 代码生成器</div>
  </header>

  <main>
    <!-- Left: upload -->
    <div class="drop-zone" id="dropZone">
      <div class="pixel-grid-bg"></div>
      <div class="drop-icon" id="dropIcon"></div>
      <div class="drop-text" id="dropText">
        <strong>拖拽或点击上传像素画</strong>
        支持 PNG / GIF / BMP · 8×8 ~ 512×512 像素
      </div>
      <img class="pixel-preview" id="preview" alt="preview">
      <div class="preview-info" id="previewInfo"></div>
      <div class="error-msg" id="errorMsg"></div>
      <button class="btn-convert" id="btnConvert" onclick="convert()">生成 Canvas 代码</button>
      <input type="file" id="file-input" accept="image/png,image/gif,image/bmp,image/jpeg,image/webp">
    </div>

    <!-- Right: output -->
    <div class="output-panel">
      <div class="panel-header">
        <div class="panel-title">生成代码</div>
        <div class="panel-actions">
          <button class="btn-sm active" id="btnModeInline" onclick="setMode('inline')">内联颜色</button>
          <button class="btn-sm" id="btnModePalette" onclick="setMode('palette')">调色板</button>
          <button class="btn-sm" id="btnCopy" onclick="copyCode()">复制</button>
          <button class="btn-sm" id="btnDownload" onclick="downloadCode()">下载</button>
        </div>
      </div>
      <div class="code-area" id="codeArea"></div>
      <div class="status-bar" id="statusBar">
        <span id="statSize">—</span> 像素
        &nbsp;·&nbsp; 代码 <span id="statLines">—</span> 行
        &nbsp;·&nbsp; <span id="statColors">—</span> 种颜色
      </div>
    </div>
  </main>

  <div class="toast" id="toast">已复制到剪贴板</div>

  <script>
    const dropZone = document.getElementById('dropZone');
    const fileInput = document.getElementById('file-input');
    const preview = document.getElementById('preview');
    const previewInfo = document.getElementById('previewInfo');
    const btnConvert = document.getElementById('btnConvert');
    const errorMsg = document.getElementById('errorMsg');
    const codeArea = document.getElementById('codeArea');
    const dropIcon = document.getElementById('dropIcon');
    const dropText = document.getElementById('dropText');

    let currentMode = 'inline';
    let currentCode = '';
    let imgData = null;  // {width, height, data: Uint8ClampedArray}

    // ---- Drag & Drop ----
    dropZone.addEventListener('click', e => {
      if (e.target === btnConvert) return;
      fileInput.click();
    });
    dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
    dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
    dropZone.addEventListener('drop', e => {
      e.preventDefault();
      dropZone.classList.remove('drag-over');
      const f = e.dataTransfer.files[0];
      if (f) loadFile(f);
    });
    fileInput.addEventListener('change', () => {
      if (fileInput.files[0]) loadFile(fileInput.files[0]);
    });

    function loadFile(file) {
      showError('');
      const url = URL.createObjectURL(file);
      const img = new Image();
      img.onload = () => {
        const w = img.naturalWidth, h = img.naturalHeight;
        if (w < 8 || h < 8 || w > 512 || h > 512) {
          showError(`尺寸 ${w}×${h} 不在支持范围(8×8 ~ 512×512)内`);
          URL.revokeObjectURL(url);
          return;
        }
        // Read pixel data
        const canvas = document.createElement('canvas');
        canvas.width = w; canvas.height = h;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0);
        imgData = { width: w, height: h, data: ctx.getImageData(0, 0, w, h).data };
        URL.revokeObjectURL(url);

        // Show preview
        preview.src = canvas.toDataURL();
        preview.style.display = 'block';
        previewInfo.textContent = `${w} × ${h} px`;
        previewInfo.style.display = 'block';
        btnConvert.style.display = 'inline-block';
        dropIcon.style.display = 'none';
        dropText.style.display = 'none';
        dropZone.classList.add('has-image');

        // Auto-convert
        convert();
      };
      img.onerror = () => showError('图片加载失败,请重试');
      img.src = url;
    }

    function showError(msg) {
      errorMsg.textContent = msg;
      errorMsg.style.display = msg ? 'block' : 'none';
    }

    function setMode(m) {
      currentMode = m;
      document.getElementById('btnModeInline').classList.toggle('active', m === 'inline');
      document.getElementById('btnModePalette').classList.toggle('active', m === 'palette');
      if (imgData) convert();
    }

    // ---- Core conversion ----
    function convert() {
      if (!imgData) return;
      const { width: W, height: H, data } = imgData;

      // Build pixel map: index -> rgba string
      const pixels = [];
      for (let i = 0; i < W * H; i++) {
        const r = data[i * 4], g = data[i * 4 + 1], b = data[i * 4 + 2], a = data[i * 4 + 3];
        pixels.push(a < 16 ? null : rgbaStr(r, g, b, a));
      }

      const uniqueColors = [...new Set(pixels.filter(Boolean))];

      if (currentMode === 'palette') {
        currentCode = generatePaletteCode(W, H, pixels, uniqueColors);
      } else {
        currentCode = generateInlineCode(W, H, pixels);
      }

      renderCode(currentCode);
      updateStats(W, H, uniqueColors.length);
    }

    function rgbaStr(r, g, b, a) {
      if (a === 255) {
        return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
      }
      return `rgba(${r},${g},${b},${(a / 255).toFixed(2)})`;
    }

    // --- Mode 1: inline (run-length compressed rows) ---
    function generateInlineCode(W, H, pixels) {
      const scale = computeScale(W, H);

      const rows = [];
      for (let y = 0; y < H; y++) {
        // RLE per row
        const row = [];
        let i = y * W;
        while (i < y * W + W) {
          const col = pixels[i];
          let run = 1;
          while (i + run < y * W + W && pixels[i + run] === col) run++;
          row.push([col, run]);
          i += run;
        }
        rows.push(row);
      }

      // Serialise compactly
      // Format each row as array of [color, count] — but skip fully-null rows
      const rowStrs = rows.map((row, y) => {
        if (row.every(([c]) => c === null)) return null;
        const segs = row.map(([c, n]) => c === null ? `[0,${n}]` : (n === 1 ? `"${c}"` : `["${c}",${n}]`));
        return `  /* y=${y} */ [${segs.join(',')}]`;
      });

      const rowsCode = rowStrs
        .map((r, y) => r === null ? `  null /* y=${y} transparent */` : r)
        .join(',\n');

      return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>pixel art</title>
<style>body{margin:0;background:#111;display:flex;align-items:center;justify-content:center;min-height:100vh}</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// ${W}x${H} pixel art  scale:${scale}x
const W=${W}, H=${H}, S=${scale};
const rows=[
${rowsCode}
];
const c=document.getElementById('c');
c.width=W*S; c.height=H*S;
const ctx=c.getContext('2d');
rows.forEach((row,y)=>{
  if(!row)return;
  let x=0;
  row.forEach(seg=>{
    if(Array.isArray(seg)){
      const[col,n]=seg;
      if(typeof col==='string'){ctx.fillStyle=col;ctx.fillRect(x*S,y*S,n*S,S);}
      x+=n;
    } else {
      ctx.fillStyle=seg;ctx.fillRect(x*S,y*S,S,S);x++;
    }
  });
});
<\/script>
</body>
</html>`;
    }

    // --- Mode 2: palette + index grid ---
    function generatePaletteCode(W, H, pixels, palette) {
      const idx = {};
      palette.forEach((c, i) => idx[c] = i);

      // Build index rows, compressing transparent as -1
      const grid = [];
      for (let y = 0; y < H; y++) {
        const row = [];
        for (let x = 0; x < W; x++) {
          const c = pixels[y * W + x];
          row.push(c === null ? -1 : idx[c]);
        }
        // RLE
        const rle = [];
        let i = 0;
        while (i < row.length) {
          let run = 1;
          while (i + run < row.length && row[i + run] === row[i]) run++;
          rle.push(run === 1 ? row[i] : [row[i], run]);
          i += run;
        }
        grid.push(rle);
      }

      const scale = computeScale(W, H);

      const paletteStr = palette.map(c => `"${c}"`).join(',');
      const gridStr = grid.map((row, y) =>
        `  /* y=${y} */ [${row.map(s => Array.isArray(s) ? `[${s[0]},${s[1]}]` : s).join(',')}]`
      ).join(',\n');

      return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>pixel art</title>
<style>body{margin:0;background:#111;display:flex;align-items:center;justify-content:center;min-height:100vh}</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// ${W}x${H} pixel art  ${palette.length} colors  scale:${scale}x
const W=${W},H=${H},S=${scale};
const P=[${paletteStr}]; // palette
const G=[
${gridStr}
];
const c=document.getElementById('c');
c.width=W*S;c.height=H*S;
const ctx=c.getContext('2d');
G.forEach((row,y)=>{
  let x=0;
  row.forEach(seg=>{
    const[ci,n]=Array.isArray(seg)?seg:[seg,1];
    if(ci>=0){ctx.fillStyle=P[ci];ctx.fillRect(x*S,y*S,n*S,S);}
    x+=n;
  });
});
<\/script>
</body>
</html>`;
    }

    function computeScale(w, h) {
      // Target canvas around 400–600px, never go below 1
      const target = 480;
      const s = Math.max(1, Math.min(32, Math.floor(target / Math.max(w, h))));
      return s;
    }

    // ---- Render with naive syntax highlight ----
    function renderCode(code) {
      const escaped = code
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');

      // Very lightweight highlight
      const hl = escaped
        .replace(/(\/\/.+)/g, '<span class="cm">$1</span>')
        .replace(/\b(const|let|var|function|return|if|else|forEach|new|document|null|true|false)\b/g, '<span class="kw">$1</span>')
        .replace(/\b(getElementById|getContext|fillRect|fillStyle|width|height)\b/g, '<span class="fn">$1</span>')
        .replace(/(&quot;[^&]*&quot;|&#39;[^&#]*&#39;)/g, '<span class="str">$1</span>')
        .replace(/\b(\d+)\b/g, '<span class="num">$1</span>');

      codeArea.innerHTML = hl;
    }

    function updateStats(w, h, colors) {
      document.getElementById('statSize').textContent = `${w}×${h}`;
      const lines = currentCode.split('\n').length;
      document.getElementById('statLines').textContent = lines;
      document.getElementById('statColors').textContent = colors;
    }

    function copyCode() {
      if (!currentCode) return;
      navigator.clipboard.writeText(currentCode).then(() => {
        const t = document.getElementById('toast');
        t.classList.add('show');
        setTimeout(() => t.classList.remove('show'), 2000);
      });
    }

    function downloadCode() {
      if (!currentCode) return;
      const a = document.createElement('a');
      a.href = 'data:text/html;charset=utf-8,' + encodeURIComponent(currentCode);
      a.download = 'pixel-art.html';
      a.click();
    }
  </script>
</body>
</html>

有 2 位朋友喜欢这篇文章:龍爪翻書, NancalaStarry


若有謬誤,懇請告知。

离线

论坛页尾