<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">图片批量伽马工具 · 中间调调整</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #1e1e2f;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 1.5rem;
}
.app {
max-width: 1200px;
width: 100%;
background: #2a2a3d;
border-radius: 24px;
padding: 2rem;
box-shadow: 0 15px 40px rgba(0,0,0,0.5);
}
h1 {
font-weight: 500;
font-size: 1.8rem;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 0.25rem;
}
h1 small {
font-size: 0.9rem;
opacity: 0.7;
font-weight: 300;
}
.subhead {
margin-bottom: 2rem;
opacity: 0.65;
border-left: 3px solid #ffb347;
padding-left: 15px;
font-size: 0.95rem;
}
.main-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.5rem;
background: #1f1f2e;
padding: 1.2rem 1.8rem;
border-radius: 60px;
margin-bottom: 2rem;
justify-content: space-between;
}
.gamma-slider-area {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.gamma-slider-area label {
font-weight: 400;
color: #bbb;
}
input[type="range"] {
width: 200px;
height: 6px;
-webkit-appearance: none;
background: #444;
border-radius: 10px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #ffb347;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 12px rgba(255, 180, 70, 0.4);
transition: 0.1s;
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.1); }
.gamma-value {
min-width: 42px;
font-weight: 600;
color: #ffb347;
background: #2a2a3d;
padding: 0.2rem 0.7rem;
border-radius: 30px;
text-align: center;
}
.file-actions {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.btn {
background: #3d3d5c;
border: none;
color: #eee;
padding: 0.55rem 1.4rem;
border-radius: 40px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
border: 1px solid #4f4f6b;
}
.btn-primary {
background: #ffb347;
color: #1e1e2f;
border: none;
font-weight: 600;
}
.btn-primary:hover { background: #ffa726; box-shadow: 0 4px 12px rgba(255, 180, 70, 0.3); }
.btn-outline {
background: transparent;
border: 1px solid #5f5f7a;
}
.btn-outline:hover { background: #3a3a52; }
.btn:active { transform: scale(0.96); }
.stats {
font-size: 0.85rem;
opacity: 0.7;
padding: 0 0.2rem;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.2rem;
margin-top: 1.8rem;
min-height: 120px;
}
.card {
background: #23233a;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
transition: transform 0.1s ease;
display: flex;
flex-direction: column;
position: relative;
}
.card:hover { transform: translateY(-3px); }
.card-img-wrapper {
width: 100%;
aspect-ratio: 1 / 1;
background: #18182a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-img-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: 0.15s;
}
.card-body {
padding: 0.5rem 0.7rem 0.7rem;
display: flex;
justify-content: space-between;
align-items: center;
background: #1f1f32;
}
.card-body .filename {
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 110px;
opacity: 0.7;
}
.card-body .remove-btn {
background: none;
border: none;
color: #ff6b6b;
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0.6;
transition: 0.15s;
}
.card-body .remove-btn:hover { opacity: 1; transform: scale(1.1); }
.empty-message {
grid-column: 1 / -1;
text-align: center;
padding: 3rem 1rem;
opacity: 0.5;
font-style: italic;
background: #1f1f30;
border-radius: 40px;
border: 1px dashed #444;
}
.batch-download-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-top: 1.8rem;
padding-top: 1.2rem;
border-top: 1px solid #373752;
}
.progress-indicator {
font-size: 0.9rem;
opacity: 0.8;
}
@media (max-width: 640px) {
.app { padding: 1rem; }
.main-controls { border-radius: 30px; flex-direction: column; align-items: stretch; gap: 1rem; }
.gamma-slider-area { justify-content: center; }
input[type="range"] { width: 140px; }
.grid-container { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
}
</style>
</head>
<body>
<div class="app">
<h1>⚡ 伽马工具 <small>中间调 · 批量</small></h1>
<div class="subhead">调整 Gamma 值(中间调对比度),实时预览,批量导出</div>
<!-- 控制栏 -->
<div class="main-controls">
<div class="gamma-slider-area">
<label for="gammaRange">Gamma</label>
<input type="range" id="gammaRange" min="0.10" max="3.00" step="0.01" value="1.00">
<span class="gamma-value" id="gammaDisplay">1.00</span>
</div>
<div class="file-actions">
<input type="file" id="fileInput" multiple accept="image/png,image/jpeg,image/webp,image/bmp,image/gif,image/tiff" hidden>
<button class="btn btn-primary" id="addBtn">📁 添加图片</button>
<button class="btn btn-outline" id="clearBtn">🗑️ 清空</button>
<span class="stats" id="imageCount">0 张</span>
</div>
</div>
<!-- 图片网格 -->
<div class="grid-container" id="gridContainer">
<div class="empty-message">点击「添加图片」上传,中间调实时调整</div>
</div>
<!-- 批量导出 -->
<div class="batch-download-bar">
<div class="progress-indicator" id="progressInfo">就绪</div>
<button class="btn btn-primary" id="downloadAllBtn">📥 批量导出 (ZIP)</button>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script>
(function() {
'use strict';
// DOM
const fileInput = document.getElementById('fileInput');
const addBtn = document.getElementById('addBtn');
const clearBtn = document.getElementById('clearBtn');
const gammaRange = document.getElementById('gammaRange');
const gammaDisplay = document.getElementById('gammaDisplay');
const grid = document.getElementById('gridContainer');
const imageCountSpan = document.getElementById('imageCount');
const progressInfo = document.getElementById('progressInfo');
const downloadAllBtn = document.getElementById('downloadAllBtn');
// 状态:存储图片数据
let imageRecords = []; // { id, fileName, originalDataUrl, adjustedDataUrl? (canvas生成) }
let nextId = 0;
// 当前 gamma (浮点数)
let currentGamma = 1.0;
// 标志:正在处理 (用于批量导出)
let isProcessing = false;
// ========== 工具函数 ==========
function updateGammaDisplay() {
gammaDisplay.textContent = currentGamma.toFixed(2);
}
// 更新统计信息
function updateStats() {
const len = imageRecords.length;
imageCountSpan.textContent = `${len} 张`;
}
// 显示空状态
function showEmptyIfNeeded() {
const empty = grid.querySelector('.empty-message');
if (imageRecords.length === 0) {
if (!empty) {
const msg = document.createElement('div');
msg.className = 'empty-message';
msg.textContent = '暂无图片 · 点击「添加图片」开始';
grid.appendChild(msg);
}
} else {
const emptyMsg = grid.querySelector('.empty-message');
if (emptyMsg) emptyMsg.remove();
}
}
// ========== 核心:渲染所有卡片 (基于当前 gamma 重新生成 adjusted) ==========
function renderAllCards() {
// 清除所有卡片 (保留 .empty-message 由后面逻辑处理)
const cards = grid.querySelectorAll('.card');
cards.forEach(c => c.remove());
if (imageRecords.length === 0) {
showEmptyIfNeeded();
updateStats();
return;
}
// 遍历记录,为每个创建卡片
imageRecords.forEach((record, index) => {
const card = document.createElement('div');
card.className = 'card';
card.dataset.id = record.id;
// 图片容器
const wrapper = document.createElement('div');
wrapper.className = 'card-img-wrapper';
const img = document.createElement('img');
// 先显示原始图,调整会异步更新
img.alt = record.fileName;
// 生成调整后的图像数据 (同步用canvas)
const adjustedDataUrl = generateAdjustedDataUrl(record.originalDataUrl, currentGamma);
img.src = adjustedDataUrl;
// 存下调整后的url到record中 (用于导出)
record.adjustedDataUrl = adjustedDataUrl;
wrapper.appendChild(img);
card.appendChild(wrapper);
// 底部信息
const body = document.createElement('div');
body.className = 'card-body';
const nameSpan = document.createElement('span');
nameSpan.className = 'filename';
nameSpan.textContent = record.fileName;
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.innerHTML = '✕';
removeBtn.setAttribute('aria-label', '移除图片');
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeImageById(record.id);
});
body.appendChild(nameSpan);
body.appendChild(removeBtn);
card.appendChild(body);
grid.appendChild(card);
});
// 移除空状态
showEmptyIfNeeded();
updateStats();
}
// ========== canvas 调整 gamma (中间调) ==========
function generateAdjustedDataUrl(dataUrl, gamma) {
if (gamma === 1.0) return dataUrl; // 无变化直接返回原图 (节省性能)
const img = new Image();
// 同步方式:借助 canvas 同步绘制 (但 img 需要完全加载,这里用 dataUrl 已加载)
// 但是为了通用性,实际需要用回调或者同步方式,由于我们已经有了 dataUrl,可以直接创建 Image 并立即绘制?
// 但 Image 的 src 赋值是异步的,不过如果 dataUrl 是完整的,浏览器通常会同步解码(小图),但为了健壮性,
// 使用 Promise 或同步方法?但我们在渲染循环中需要同步返回,所以采用 OffscreenCanvas 或者 2D context 的同步绘制?
// 这里用 canvas 绘制,但必须确保图片已加载,比较可靠: 构造 Image 并设置 src,但 onload 后绘制,
// 但为了函数同步返回,可以使用 canvas 的 drawImage 直接传入 Image 对象,Image 尚未解码则可能空白。
// 替代方案:使用 Image 的 decode() 但也是异步,但是我们是生成调整后的预览,可以使用一个临时 canvas 并调用 drawImage,
// 如果图片未加载,可以稍后更新?但交互中gamma滑动频繁,会导致闪烁。
// 改进:我们将原始图片数据保持为 Image 对象或直接canvas 预处理,简单且稳健:每个 record 保存原始图片的 Image 对象 (预加载)
// 但为了简洁,这里使用一个技巧:在添加图片时,我们将图片数据转为 Image 对象存入 record,并缓存原始 canvas 数据?
// 更简洁:每次调整都创建一个新 Image 并等待?但渲染会卡顿,为了演示质量和流畅,我们设计为 在 gamma 变化时重新生成所有卡片,
// 卡片中 img.src 采用同步生成的 dataUrl,这里用 canvas 绘制时如果图片未完成加载,采用上次的原始dataUrl 生成。
// 解决方案:在添加图片时,将原始图片加载到 Image 对象,并保留原始 dataUrl 和 一个 HTMLImageElement。
// 这里我们重构:每个 record 增加 originalImage (HTMLImageElement) 在添加时创建。
// 为简化,我们可以在 gamma 调整时遍历 record 并基于 record.originalImage 绘制。
// 但需要处理图片加载状态,我们选择在添加图片时等待图片加载完成,并保存 image 对象。
// 重新设计 record: { id, fileName, originalDataUrl, image: HTMLImageElement, adjustedDataUrl? }
// 由于已经实现到一半,这里采用快速修正:在 generateAdjustedDataUrl 中,record 有 image 属性则用,否则创建临时 Image。
// 但该函数静态无法访问 records,我们修改为在渲染时传入 record,将 renderAllCards 重构。
// 下面调整 renderAllCards 使用 record.image 直接绘制。
// 由于我们之前保存 record 没有存 image,现在在添加图片时补充。
// 改动较大但保证流畅,我们从头构建一个更可靠的架构:
}
// ========== 重新设计:使用 record.image 对象 ==========
// 立即重构上方代码,重新定义渲染方式。
// 新的渲染函数 (完全基于缓存 image 对象)
function renderAllCardsStable() {
// 清空卡片
const cards = grid.querySelectorAll('.card');
cards.forEach(c => c.remove());
if (imageRecords.length === 0) {
showEmptyIfNeeded();
updateStats();
return;
}
imageRecords.forEach((record) => {
const card = document.createElement('div');
card.className = 'card';
card.dataset.id = record.id;
const wrapper = document.createElement('div');
wrapper.className = 'card-img-wrapper';
const img = document.createElement('img');
img.alt = record.fileName;
// 根据当前 gamma 生成调整后的 dataUrl (同步)
const adjusted = generateAdjustedDataUrlFromImage(record.image, currentGamma);
img.src = adjusted;
record.adjustedDataUrl = adjusted; // 缓存
wrapper.appendChild(img);
card.appendChild(wrapper);
const body = document.createElement('div');
body.className = 'card-body';
const nameSpan = document.createElement('span');
nameSpan.className = 'filename';
nameSpan.textContent = record.fileName;
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.innerHTML = '✕';
removeBtn.addEventListener('click', () => removeImageById(record.id));
body.appendChild(nameSpan);
body.appendChild(removeBtn);
card.appendChild(body);
grid.appendChild(card);
});
showEmptyIfNeeded();
updateStats();
}
// 基于 Image 对象生成 gamma 调整 dataUrl (同步)
function generateAdjustedDataUrlFromImage(image, gamma) {
if (gamma === 1.0) {
// 直接返回原始 dataUrl (但记录中可能没有存原始dataUrl, 我们也可以从canvas导出)
// 为性能,直接从image绘制导出原图
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
return canvas.toDataURL('image/png');
}
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const gammaInv = 1 / gamma;
for (let i = 0; i < data.length; i += 4) {
// R, G, B
data[i] = applyGamma(data[i], gammaInv);
data[i+1] = applyGamma(data[i+1], gammaInv);
data[i+2] = applyGamma(data[i+2], gammaInv);
// alpha 不变
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
function applyGamma(value, gammaInv) {
const normalized = value / 255;
// 避免负数
const corrected = Math.pow(Math.max(0, normalized), gammaInv);
return Math.min(255, Math.max(0, Math.round(corrected * 255)));
}
// ========== 添加图片 ==========
function addFiles(files) {
if (!files || files.length === 0) return;
const validTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/bmp', 'image/gif', 'image/tiff'];
const promises = [];
for (const file of files) {
if (!validTypes.includes(file.type) && !file.type.startsWith('image/')) continue;
promises.push(loadImageFromFile(file));
}
if (promises.length === 0) return;
Promise.allSettled(promises).then((results) => {
let addedCount = 0;
for (const result of results) {
if (result.status === 'fulfilled') {
const { image, fileName, dataUrl } = result.value;
imageRecords.push({
id: nextId++,
fileName: fileName,
image: image,
originalDataUrl: dataUrl,
adjustedDataUrl: null,
});
addedCount++;
}
}
if (addedCount > 0) {
renderAllCardsStable();
progressInfo.textContent = `已添加 ${addedCount} 张`;
} else {
progressInfo.textContent = '⚠️ 无法加载图片';
}
});
}
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target.result;
const img = new Image();
img.onload = () => {
resolve({ image: img, fileName: file.name, dataUrl: dataUrl });
};
img.onerror = () => reject(new Error('图片解码失败'));
img.src = dataUrl;
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsDataURL(file);
});
}
// ========== 移除图片 ==========
function removeImageById(id) {
imageRecords = imageRecords.filter(r => r.id !== id);
renderAllCardsStable();
progressInfo.textContent = `已移除 1 张`;
}
// ========== 清空 ==========
function clearAll() {
if (imageRecords.length === 0) return;
imageRecords = [];
renderAllCardsStable();
progressInfo.textContent = '已清空';
}
// ========== gamma 滑动事件 ==========
gammaRange.addEventListener('input', function() {
currentGamma = parseFloat(this.value);
updateGammaDisplay();
renderAllCardsStable();
progressInfo.textContent = `Gamma = ${currentGamma.toFixed(2)}`;
});
// ========== 添加按钮 ==========
addBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
if (files && files.length > 0) {
addFiles(files);
}
fileInput.value = ''; // 重置
});
// ========== 清空按钮 ==========
clearBtn.addEventListener('click', clearAll);
// ========== 批量导出 (ZIP) ==========
downloadAllBtn.addEventListener('click', async function() {
if (isProcessing) return;
if (imageRecords.length === 0) {
progressInfo.textContent = '⚠️ 没有图片可导出';
return;
}
isProcessing = true;
downloadAllBtn.disabled = true;
progressInfo.textContent = '⏳ 正在生成...';
try {
const zip = new JSZip();
const folder = zip.folder('gamma_adjusted');
// 确保每个 record 有最新的 adjustedDataUrl (渲染时已缓存)
// 但为了保险,重新生成一次
const tasks = imageRecords.map((record, index) => {
// 使用当前 gamma 生成
const dataUrl = generateAdjustedDataUrlFromImage(record.image, currentGamma);
const base64Data = dataUrl.split(',')[1];
const ext = record.fileName.includes('.') ? record.fileName.split('.').pop() : 'png';
// 简单过滤非字母数字
const safeName = record.fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
folder.file(`gamma_${safeName}`, base64Data, { base64: true });
});
await Promise.all(tasks);
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `gamma_adjusted_${currentGamma.toFixed(2)}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
progressInfo.textContent = `✅ 导出成功 (${imageRecords.length} 张)`;
} catch (err) {
console.error(err);
progressInfo.textContent = '❌ 导出失败';
} finally {
isProcessing = false;
downloadAllBtn.disabled = false;
}
});
// ========== 初始化 ==========
updateGammaDisplay();
showEmptyIfNeeded();
updateStats();
// 支持拖放 (可选)
const appEl = document.querySelector('.app');
appEl.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
appEl.addEventListener('drop', (e) => {
e.preventDefault();
const files = e.dataTransfer.files;
if (files.length > 0) addFiles(files);
});
})();
</script>
</body>
</html>
工具功能与操作说明
您可以通过滑动滑块调整Gamma值,并实时观察所有图片的中间调变化,以下是主要操作模块:

- 图片管理:点击“添加图片”按钮或直接拖拽图片到工具区域,即可批量导入,每张图片右上角的“✕”按钮可单独移除。
- Gamma调整:拖动滑块(范围0.10至3.00),所有已添加图片的中间调对比度会同步更新,当前Gamma值会实时显示。
- 批量导出:调整满意后,点击“批量导出 (ZIP)”按钮,所有图片将按照当前Gamma设置打包下载,方便您保存或进一步使用。
标签: 中间调调整
版权声明:除非特别标注,否则均为本站原创文章,转载时请以链接形式注明文章出处。