295 lines
8.8 KiB
HTML
295 lines
8.8 KiB
HTML
<!doctype html>
|
||
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>图片黑白处理</title>
|
||
<style>
|
||
:root{
|
||
--bg-1:#E9F9E7; /* 淡绿色 */
|
||
--bg-2:#F5FBE7; /* 淡黄绿色 */
|
||
--fg:#0f5132; /* 深一点的绿色文字 */
|
||
--muted:#5c7a66;
|
||
--card:#ffffffcc;
|
||
--accent:#6cc870;
|
||
--accent-2:#90d47f;
|
||
--shadow: 0 8px 24px rgba(39, 115, 72, .15);
|
||
--radius: 18px;
|
||
}
|
||
html,body{
|
||
height:100%;
|
||
}
|
||
body{
|
||
margin:0;
|
||
font-family: -apple-system,BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft Yahei", sans-serif;
|
||
color:var(--fg);
|
||
background: linear-gradient(160deg,var(--bg-1) 0%, var(--bg-2) 100%);
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
display:flex; align-items:stretch; justify-content:center;
|
||
}
|
||
.wrap{
|
||
width:min(100%, 760px);
|
||
padding:16px;
|
||
}
|
||
header{
|
||
text-align:center;
|
||
margin: 8px 0 14px;
|
||
}
|
||
h1{
|
||
margin:0 0 6px;
|
||
font-weight:800;
|
||
letter-spacing:.3px;
|
||
font-size: clamp(20px, 4.5vw, 28px);
|
||
}
|
||
.sub{
|
||
margin:0 auto;
|
||
max-width: 32em;
|
||
color:var(--muted);
|
||
font-size: clamp(12px, 3.4vw, 14px);
|
||
}.card{
|
||
background: var(--card);
|
||
backdrop-filter: blur(6px) saturate(120%);
|
||
border: 1px solid rgba(108,200,112,.18);
|
||
border-radius: var(--radius);
|
||
padding: 14px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.controls{
|
||
display:grid;
|
||
grid-template-columns: 1fr;
|
||
gap:12px;
|
||
}
|
||
.row{
|
||
display:flex; align-items:center; gap:10px; flex-wrap:wrap;
|
||
}
|
||
label{
|
||
font-weight:600; font-size:14px;
|
||
}
|
||
input[type="file"]{
|
||
width:100%;
|
||
padding:12px;
|
||
border: 1px dashed #a8d7a3;
|
||
border-radius: 14px;
|
||
background: #ffffffb0;
|
||
}
|
||
input[type="range"]{
|
||
width:100%;
|
||
accent-color: var(--accent);
|
||
height: 28px;
|
||
}
|
||
output{ font-variant-numeric: tabular-nums; min-width:3ch; text-align:right; display:inline-block; }
|
||
|
||
.buttons{
|
||
display:grid; grid-template-columns: 1fr 1fr; gap:10px;
|
||
}
|
||
button{
|
||
appearance:none; border:0; cursor:pointer;
|
||
padding:12px 14px; border-radius: 14px; font-weight:700;
|
||
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
||
color:white; box-shadow: 0 6px 16px rgba(16,123,62,.25);
|
||
}
|
||
button.secondary{
|
||
background:#eaf8ea; color:#245b35; box-shadow:none; border:1px solid #cfe9ce;
|
||
}
|
||
button:disabled{ opacity:.5; cursor:not-allowed; }
|
||
|
||
.preview{
|
||
margin-top: 12px;
|
||
display:grid; gap:10px;
|
||
}
|
||
.canvas-wrap{
|
||
background: repeating-linear-gradient( 45deg, #f3fbf0, #f3fbf0 14px, #eef8ec 14px, #eef8ec 28px);
|
||
border:1px solid #dcefd7; border-radius: 14px; overflow:hidden;
|
||
display:flex; align-items:center; justify-content:center;
|
||
min-height: 240px;
|
||
}
|
||
canvas{ max-width:100%; height:auto; display:block; }
|
||
.placeholder{ color:#7da287; padding:20px; text-align:center; }
|
||
|
||
footer{
|
||
text-align:center; color:var(--muted); font-size:12px; margin:14px auto 6px;
|
||
}
|
||
|
||
/* 更偏向手机竖屏的合理布局 */
|
||
@media (min-width: 720px){
|
||
.controls{ grid-template-columns: 1.2fr 1fr; align-items:end; }
|
||
.buttons{ grid-template-columns: 1fr 1fr; }
|
||
}
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header>
|
||
<h1>图片黑白处理 · 轻柔绿意版</h1>
|
||
<p class="sub">上传一张图片,拖动滑块设置<span style="font-weight:600">黑白程度</span>(0% 原图 → 100% 全黑白),一键下载处理结果。完全本地处理,无需联网。</p>
|
||
</header><section class="card">
|
||
<div class="controls">
|
||
<div>
|
||
<label for="file">选择图片</label>
|
||
<input id="file" type="file" accept="image/*" />
|
||
</div>
|
||
<div>
|
||
<div class="row" style="justify-content:space-between">
|
||
<label for="amount">黑白程度</label>
|
||
<div><output id="val">100</output>%</div>
|
||
</div>
|
||
<input id="amount" type="range" min="0" max="100" step="1" value="100" />
|
||
<div class="buttons">
|
||
<button id="download" disabled>下载处理后的图片</button>
|
||
<button id="reset" class="secondary" disabled>重置</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview">
|
||
<div class="canvas-wrap">
|
||
<canvas id="canvas" aria-label="预览画布"></canvas>
|
||
<div id="ph" class="placeholder">⬆️ 请选择一张图片开始…</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<footer>© 黑白处理在您的设备本地完成 · 支持手机竖屏友好显示</footer>
|
||
|
||
</div><script>
|
||
(() => {
|
||
const fileInput = document.getElementById('file');
|
||
const amount = document.getElementById('amount');
|
||
const valOut = document.getElementById('val');
|
||
const canvas = document.getElementById('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const downloadBtn = document.getElementById('download');
|
||
const resetBtn = document.getElementById('reset');
|
||
const placeholder = document.getElementById('ph');
|
||
|
||
let originalPixels = null; // Uint8ClampedArray:当前画布的原始像素(用于反复处理)
|
||
let imgNatural = { w: 0, h: 0 };
|
||
let currentFileName = 'processed.png';
|
||
let rafId = null;
|
||
|
||
// 将图像绘制到画布,并根据屏幕宽度进行适度缩放,避免超大图导致内存压力
|
||
const MAX_DIM = 4096; // 上限,兼顾清晰度与移动端内存
|
||
function drawImageToCanvas(img) {
|
||
// 处理超大图片:在不改变比例的前提下收敛到 MAX_DIM 以内
|
||
let w = img.naturalWidth;
|
||
let h = img.naturalHeight;
|
||
const scale = Math.min(1, MAX_DIM / Math.max(w, h));
|
||
w = Math.round(w * scale);
|
||
h = Math.round(h * scale);
|
||
|
||
// 为了保证下载清晰度,canvas 使用实际像素尺寸;显示层用 CSS 自适应
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
|
||
ctx.clearRect(0,0,w,h);
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
|
||
// 保存原始像素用于反复应用不同程度
|
||
originalPixels = ctx.getImageData(0,0,w,h);
|
||
imgNatural = { w, h };
|
||
}
|
||
|
||
function lerp(a,b,t){ return a + (b-a) * t; }
|
||
|
||
// 将原图按给定强度转为黑白(灰度)
|
||
function applyGrayscale(strength01){
|
||
if(!originalPixels) return;
|
||
const { data: src } = originalPixels;
|
||
const copy = new ImageData(new Uint8ClampedArray(src), imgNatural.w, imgNatural.h);
|
||
const out = copy.data;
|
||
|
||
// 加权灰度(符合 sRGB 感知权重)
|
||
for(let i=0; i<out.length; i+=4){
|
||
const r = src[i], g = src[i+1], b = src[i+2];
|
||
const y = 0.2126*r + 0.7152*g + 0.0722*b;
|
||
out[i] = lerp(r, y, strength01);
|
||
out[i+1] = lerp(g, y, strength01);
|
||
out[i+2] = lerp(b, y, strength01);
|
||
// 保留 alpha
|
||
}
|
||
ctx.putImageData(copy, 0, 0);
|
||
}
|
||
|
||
// 防抖 + rAF,流畅响应滑杆
|
||
function scheduleRender(){
|
||
if(rafId) cancelAnimationFrame(rafId);
|
||
rafId = requestAnimationFrame(() => {
|
||
const t = Number(amount.value) / 100; // 0~1
|
||
valOut.textContent = amount.value;
|
||
applyGrayscale(t);
|
||
});
|
||
}
|
||
|
||
// 处理文件加载
|
||
fileInput.addEventListener('change', async (e) => {
|
||
const file = e.target.files && e.target.files[0];
|
||
if(!file) return;
|
||
|
||
currentFileName = (file.name ? file.name.replace(/\.[^.]+$/, '') : 'processed') + '.png';
|
||
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
placeholder.style.display = 'none';
|
||
drawImageToCanvas(img);
|
||
// 初始按当前滑杆值处理
|
||
scheduleRender();
|
||
downloadBtn.disabled = false;
|
||
resetBtn.disabled = false;
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
img.onerror = () => {
|
||
alert('无法加载该图片,请更换文件试试。');
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
img.src = url;
|
||
});
|
||
|
||
amount.addEventListener('input', scheduleRender);
|
||
|
||
resetBtn.addEventListener('click', () => {
|
||
if(!originalPixels) return;
|
||
amount.value = 100;
|
||
scheduleRender();
|
||
});
|
||
|
||
// 下载当前画布
|
||
function downloadCanvas(filename){
|
||
// toBlob 在少数旧版浏览器可能不可用,做个兼容
|
||
if(canvas.toBlob){
|
||
canvas.toBlob((blob) => {
|
||
if(!blob){ fallback(); return; }
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(link.href), 5000);
|
||
}, 'image/png');
|
||
} else {
|
||
fallback();
|
||
}
|
||
|
||
function fallback(){
|
||
const dataURL = canvas.toDataURL('image/png');
|
||
const link = document.createElement('a');
|
||
link.href = dataURL;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
}
|
||
}
|
||
|
||
downloadBtn.addEventListener('click', () => {
|
||
if(!originalPixels) return;
|
||
downloadCanvas(currentFileName);
|
||
});
|
||
})();
|
||
</script></body>
|
||
</html> |