<!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设置打包下载,方便您保存或进一步使用。
标签: 中间调调整
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播