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>
若有謬誤,懇請告知。
离线
簡單小巧的肉類能量計算器

<!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>
若有謬誤,懇請告知。
离线
多餘換行清除工具,用於清除文本間多於一個的換行。
<!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>
若有謬誤,懇請告知。
离线
像素畫量化工具,可以用於將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)
若有謬誤,懇請告知。
离线
像素畫轉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> 像素
· 代码 <span id="statLines">—</span> 行
· <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, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
// 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(/("[^&]*"|'[^&#]*')/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
若有謬誤,懇請告知。
离线