|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>ChatGPT yellow tint corrector</title> |
|
<style> |
|
* { |
|
margin: 0; |
|
padding: 0; |
|
box-sizing: border-box; |
|
} |
|
|
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
|
background: #f5f5f5; |
|
padding: 20px; |
|
min-height: 100vh; |
|
display: flex; |
|
flex-direction: column; |
|
} |
|
|
|
.container { |
|
max-width: 1400px; |
|
margin: 0 auto; |
|
background: white; |
|
padding: 30px; |
|
border-radius: 8px; |
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
|
flex: 1; |
|
} |
|
|
|
h1 { |
|
font-size: 24px; |
|
margin-bottom: 20px; |
|
color: #333; |
|
} |
|
|
|
.upload-area { |
|
border: 2px dashed #ccc; |
|
border-radius: 4px; |
|
padding: 40px; |
|
text-align: center; |
|
cursor: pointer; |
|
background: #fafafa; |
|
margin-bottom: 20px; |
|
} |
|
|
|
.upload-area:hover { |
|
border-color: #999; |
|
background: #f0f0f0; |
|
} |
|
|
|
.upload-area.dragover { |
|
border-color: #4CAF50; |
|
background: #f0f8f0; |
|
} |
|
|
|
input[type="file"] { |
|
display: none; |
|
} |
|
|
|
.processing { |
|
display: none; |
|
text-align: center; |
|
padding: 20px; |
|
color: #666; |
|
} |
|
|
|
.progress-bar { |
|
width: 100%; |
|
height: 20px; |
|
background: #f0f0f0; |
|
border-radius: 10px; |
|
overflow: hidden; |
|
margin: 10px 0; |
|
} |
|
|
|
.progress-fill { |
|
height: 100%; |
|
background: #4CAF50; |
|
transition: width 0.3s ease; |
|
} |
|
|
|
.results { |
|
display: none; |
|
} |
|
|
|
.single-result { |
|
display: none; |
|
} |
|
|
|
.bulk-result { |
|
display: none; |
|
} |
|
|
|
.image-grid { |
|
display: grid; |
|
grid-template-columns: 1fr 1fr; |
|
gap: 20px; |
|
margin-bottom: 20px; |
|
} |
|
|
|
.gallery-grid { |
|
display: grid; |
|
grid-template-columns: repeat(4, 1fr); |
|
gap: 15px; |
|
margin-bottom: 20px; |
|
} |
|
|
|
.gallery-item { |
|
border: 1px solid #ddd; |
|
border-radius: 4px; |
|
overflow: hidden; |
|
cursor: pointer; |
|
position: relative; |
|
aspect-ratio: 1; |
|
} |
|
|
|
.gallery-item:hover { |
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15); |
|
} |
|
|
|
.gallery-item canvas { |
|
width: 100%; |
|
height: 100%; |
|
object-fit: cover; |
|
} |
|
|
|
.gallery-more { |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
background: #f0f0f0; |
|
color: #666; |
|
font-size: 24px; |
|
font-weight: bold; |
|
} |
|
|
|
.image-container { |
|
border: 1px solid #ddd; |
|
border-radius: 4px; |
|
padding: 10px; |
|
} |
|
|
|
.image-container h3 { |
|
font-size: 14px; |
|
margin-bottom: 10px; |
|
color: #666; |
|
} |
|
|
|
canvas { |
|
display: block; |
|
width: 100%; |
|
height: auto; |
|
} |
|
|
|
.controls { |
|
display: flex; |
|
gap: 10px; |
|
flex-wrap: wrap; |
|
} |
|
|
|
button { |
|
padding: 10px 20px; |
|
background: #4CAF50; |
|
color: white; |
|
border: none; |
|
border-radius: 4px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
} |
|
|
|
button:hover { |
|
background: #45a049; |
|
} |
|
|
|
button.secondary { |
|
background: #757575; |
|
} |
|
|
|
button.secondary:hover { |
|
background: #616161; |
|
} |
|
|
|
.info { |
|
margin-top: 20px; |
|
padding: 15px; |
|
background: #f9f9f9; |
|
border-radius: 4px; |
|
font-size: 13px; |
|
color: #666; |
|
} |
|
|
|
.modal { |
|
display: none; |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background: rgba(0,0,0,0.8); |
|
z-index: 1000; |
|
padding: 20px; |
|
} |
|
|
|
.modal-content { |
|
max-width: 90%; |
|
max-height: 90%; |
|
margin: auto; |
|
position: relative; |
|
top: 50%; |
|
transform: translateY(-50%); |
|
background: white; |
|
border-radius: 8px; |
|
padding: 20px; |
|
} |
|
|
|
.modal-close { |
|
position: absolute; |
|
top: 10px; |
|
right: 10px; |
|
font-size: 24px; |
|
cursor: pointer; |
|
background: none; |
|
border: none; |
|
color: #666; |
|
} |
|
|
|
.modal-image-container { |
|
display: grid; |
|
grid-template-columns: 1fr 1fr; |
|
gap: 20px; |
|
} |
|
|
|
.modal-image { |
|
text-align: center; |
|
} |
|
|
|
.modal-image h3 { |
|
margin-bottom: 10px; |
|
color: #666; |
|
} |
|
|
|
.modal-image canvas { |
|
max-width: 100%; |
|
height: auto; |
|
} |
|
|
|
footer { |
|
text-align: center; |
|
padding: 20px; |
|
color: #666; |
|
font-size: 12px; |
|
} |
|
|
|
footer a { |
|
color: #4CAF50; |
|
text-decoration: none; |
|
} |
|
|
|
footer a:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.image-grid { |
|
grid-template-columns: 1fr; |
|
} |
|
|
|
.gallery-grid { |
|
grid-template-columns: repeat(2, 1fr); |
|
} |
|
|
|
.modal-image-container { |
|
grid-template-columns: 1fr; |
|
} |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<h1>ChatGPT yellow tint corrector</h1> |
|
|
|
<div class="upload-area" id="uploadArea"> |
|
<p>Drop image(s) here or click to upload</p> |
|
<p style="font-size: 12px; color: #999; margin-top: 10px;">Supports JPG, PNG, WebP • Multiple files supported</p> |
|
<input type="file" id="fileInput" accept="image/*" multiple> |
|
</div> |
|
|
|
<div class="processing" id="processing"> |
|
<p>Processing <span id="currentFile">0</span> of <span id="totalFiles">0</span> images...</p> |
|
<div class="progress-bar"> |
|
<div class="progress-fill" id="progressFill"></div> |
|
</div> |
|
</div> |
|
|
|
<div class="results" id="results"> |
|
<div class="single-result" id="singleResult"> |
|
<div class="image-grid"> |
|
<div class="image-container"> |
|
<h3>Original</h3> |
|
<canvas id="originalCanvas"></canvas> |
|
</div> |
|
<div class="image-container"> |
|
<h3>Corrected</h3> |
|
<canvas id="correctedCanvas"></canvas> |
|
</div> |
|
</div> |
|
|
|
<div class="controls"> |
|
<button onclick="downloadImage()">Download Corrected</button> |
|
<button class="secondary" onclick="resetApp()">Process More Images</button> |
|
</div> |
|
</div> |
|
|
|
<div class="bulk-result" id="bulkResult"> |
|
<h3 style="margin-bottom: 15px; color: #666;">Corrected Images</h3> |
|
<div class="gallery-grid" id="galleryGrid"></div> |
|
|
|
<div class="controls"> |
|
<button onclick="downloadAll()">Download All</button> |
|
<button class="secondary" onclick="resetApp()">Process More Images</button> |
|
</div> |
|
</div> |
|
|
|
<div class="info" id="info"></div> |
|
</div> |
|
</div> |
|
|
|
<footer> |
|
Created by <a href="https://x.com/multimodalart" target="_blank">multimodalart</a><br> |
|
The images are processed on your browser and are never sent to a server |
|
</footer> |
|
|
|
<div class="modal" id="imageModal"> |
|
<div class="modal-content"> |
|
<button class="modal-close" onclick="closeModal()">×</button> |
|
<div class="modal-image-container"> |
|
<div class="modal-image"> |
|
<h3>Original</h3> |
|
<canvas id="modalOriginal"></canvas> |
|
</div> |
|
<div class="modal-image"> |
|
<h3>Corrected</h3> |
|
<canvas id="modalCorrected"></canvas> |
|
</div> |
|
</div> |
|
<div style="text-align: center; margin-top: 20px;"> |
|
<button onclick="downloadModalImage()">Download Corrected</button> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
|
|
|
|
class ImageProcessor { |
|
constructor() { |
|
this.processedImages = []; |
|
this.currentModalIndex = -1; |
|
this.setupEventListeners(); |
|
} |
|
|
|
setupEventListeners() { |
|
const uploadArea = document.getElementById('uploadArea'); |
|
const fileInput = document.getElementById('fileInput'); |
|
|
|
uploadArea.addEventListener('click', () => fileInput.click()); |
|
fileInput.addEventListener('change', (e) => this.handleFiles(e.target.files)); |
|
|
|
uploadArea.addEventListener('dragover', (e) => { |
|
e.preventDefault(); |
|
uploadArea.classList.add('dragover'); |
|
}); |
|
|
|
uploadArea.addEventListener('dragleave', () => { |
|
uploadArea.classList.remove('dragover'); |
|
}); |
|
|
|
uploadArea.addEventListener('drop', (e) => { |
|
e.preventDefault(); |
|
uploadArea.classList.remove('dragover'); |
|
if (e.dataTransfer.files.length > 0) { |
|
this.handleFiles(e.dataTransfer.files); |
|
} |
|
}); |
|
} |
|
|
|
handleFiles(files) { |
|
const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/')); |
|
if (imageFiles.length === 0) { |
|
alert('Please select image files'); |
|
return; |
|
} |
|
|
|
this.processedImages = []; |
|
this.processMultipleImages(imageFiles); |
|
} |
|
|
|
async processMultipleImages(files) { |
|
document.getElementById('uploadArea').style.display = 'none'; |
|
document.getElementById('processing').style.display = 'block'; |
|
document.getElementById('totalFiles').textContent = files.length; |
|
|
|
for (let i = 0; i < files.length; i++) { |
|
document.getElementById('currentFile').textContent = i + 1; |
|
document.getElementById('progressFill').style.width = `${((i + 1) / files.length) * 100}%`; |
|
|
|
await this.processFile(files[i]); |
|
} |
|
|
|
document.getElementById('processing').style.display = 'none'; |
|
this.displayResults(); |
|
} |
|
|
|
processFile(file) { |
|
return new Promise((resolve) => { |
|
const reader = new FileReader(); |
|
reader.onload = (e) => { |
|
const img = new Image(); |
|
img.onload = () => { |
|
const result = this.processImage(img); |
|
this.processedImages.push({ |
|
name: file.name, |
|
original: result.original, |
|
corrected: result.corrected, |
|
width: img.width, |
|
height: img.height |
|
}); |
|
resolve(); |
|
}; |
|
img.src = e.target.result; |
|
}; |
|
reader.readAsDataURL(file); |
|
}); |
|
} |
|
|
|
processImage(img) { |
|
|
|
const originalCanvas = document.createElement('canvas'); |
|
const correctedCanvas = document.createElement('canvas'); |
|
|
|
originalCanvas.width = img.width; |
|
originalCanvas.height = img.height; |
|
correctedCanvas.width = img.width; |
|
correctedCanvas.height = img.height; |
|
|
|
const originalCtx = originalCanvas.getContext('2d'); |
|
const correctedCtx = correctedCanvas.getContext('2d'); |
|
|
|
|
|
originalCtx.drawImage(img, 0, 0); |
|
|
|
|
|
const imageData = originalCtx.getImageData(0, 0, img.width, img.height); |
|
|
|
|
|
const correctedData = this.autoWhiteBalanceFinal(imageData); |
|
|
|
|
|
correctedCtx.putImageData(correctedData, 0, 0); |
|
|
|
return { |
|
original: originalCanvas, |
|
corrected: correctedCanvas |
|
}; |
|
} |
|
|
|
displayResults() { |
|
document.getElementById('results').style.display = 'block'; |
|
|
|
if (this.processedImages.length === 1) { |
|
|
|
document.getElementById('singleResult').style.display = 'block'; |
|
document.getElementById('bulkResult').style.display = 'none'; |
|
|
|
const original = document.getElementById('originalCanvas'); |
|
const corrected = document.getElementById('correctedCanvas'); |
|
|
|
original.width = this.processedImages[0].width; |
|
original.height = this.processedImages[0].height; |
|
corrected.width = this.processedImages[0].width; |
|
corrected.height = this.processedImages[0].height; |
|
|
|
original.getContext('2d').drawImage(this.processedImages[0].original, 0, 0); |
|
corrected.getContext('2d').drawImage(this.processedImages[0].corrected, 0, 0); |
|
|
|
} else { |
|
|
|
document.getElementById('singleResult').style.display = 'none'; |
|
document.getElementById('bulkResult').style.display = 'block'; |
|
|
|
const galleryGrid = document.getElementById('galleryGrid'); |
|
galleryGrid.innerHTML = ''; |
|
|
|
const maxDisplay = 16; |
|
const displayCount = Math.min(this.processedImages.length, maxDisplay); |
|
|
|
for (let i = 0; i < displayCount; i++) { |
|
if (i === 15 && this.processedImages.length > maxDisplay) { |
|
|
|
const moreDiv = document.createElement('div'); |
|
moreDiv.className = 'gallery-item gallery-more'; |
|
moreDiv.textContent = `+${this.processedImages.length - 15}`; |
|
moreDiv.onclick = () => this.showAllImages(); |
|
galleryGrid.appendChild(moreDiv); |
|
} else { |
|
const item = document.createElement('div'); |
|
item.className = 'gallery-item'; |
|
item.onclick = () => this.showModal(i); |
|
|
|
const canvas = document.createElement('canvas'); |
|
const ctx = canvas.getContext('2d'); |
|
canvas.width = 200; |
|
canvas.height = 200; |
|
|
|
|
|
const img = this.processedImages[i].corrected; |
|
const scale = Math.max(200 / img.width, 200 / img.height); |
|
const w = img.width * scale; |
|
const h = img.height * scale; |
|
ctx.drawImage(img, (200 - w) / 2, (200 - h) / 2, w, h); |
|
|
|
item.appendChild(canvas); |
|
galleryGrid.appendChild(item); |
|
} |
|
} |
|
} |
|
|
|
document.getElementById('info').innerHTML = ` |
|
Processed ${this.processedImages.length} image${this.processedImages.length > 1 ? 's' : ''} |
|
`; |
|
} |
|
|
|
showModal(index) { |
|
this.currentModalIndex = index; |
|
const modal = document.getElementById('imageModal'); |
|
const modalOriginal = document.getElementById('modalOriginal'); |
|
const modalCorrected = document.getElementById('modalCorrected'); |
|
|
|
const img = this.processedImages[index]; |
|
|
|
modalOriginal.width = img.width; |
|
modalOriginal.height = img.height; |
|
modalCorrected.width = img.width; |
|
modalCorrected.height = img.height; |
|
|
|
modalOriginal.getContext('2d').drawImage(img.original, 0, 0); |
|
modalCorrected.getContext('2d').drawImage(img.corrected, 0, 0); |
|
|
|
modal.style.display = 'block'; |
|
} |
|
|
|
showAllImages() { |
|
|
|
alert(`Showing all ${this.processedImages.length} images would be implemented here`); |
|
} |
|
|
|
autoWhiteBalanceFinal(imageData) { |
|
const data = new Float32Array(imageData.data); |
|
const width = imageData.width; |
|
const height = imageData.height; |
|
|
|
|
|
this.smartWhiteBalance(data); |
|
|
|
|
|
const meanBrightness = this.calculateMeanBrightness(data); |
|
const targetBrightness = 140; |
|
let exposureCompensation = targetBrightness / (meanBrightness + 1); |
|
exposureCompensation = Math.min(Math.max(exposureCompensation, 0.9), 1.3); |
|
|
|
for (let i = 0; i < data.length; i += 4) { |
|
data[i] *= exposureCompensation; |
|
data[i + 1] *= exposureCompensation; |
|
data[i + 2] *= exposureCompensation; |
|
} |
|
|
|
|
|
for (let i = 0; i < data.length; i += 4) { |
|
data[i] = Math.min(255, Math.max(0, data[i])); |
|
data[i + 1] = Math.min(255, Math.max(0, data[i + 1])); |
|
data[i + 2] = Math.min(255, Math.max(0, data[i + 2])); |
|
} |
|
|
|
|
|
this.applySCurve(data, 0.25); |
|
|
|
|
|
const localContrastData = this.enhanceLocalContrast(data, width, height, 15, 0.25); |
|
|
|
|
|
this.enhanceColorVibrance(localContrastData, 0.30); |
|
|
|
|
|
const finalData = this.applyMicroContrast(localContrastData, width, height); |
|
|
|
|
|
this.ensureWhites(finalData); |
|
|
|
|
|
const result = new Uint8ClampedArray(finalData.length); |
|
for (let i = 0; i < finalData.length; i++) { |
|
result[i] = Math.min(255, Math.max(0, Math.round(finalData[i]))); |
|
} |
|
|
|
return new ImageData(result, width, height); |
|
} |
|
|
|
smartWhiteBalance(data) { |
|
|
|
const whitePoint = this.findWhitePoint(data); |
|
|
|
if (!whitePoint) { |
|
|
|
this.fallbackWhiteBalance(data); |
|
return; |
|
} |
|
|
|
|
|
const targetWhite = 255; |
|
|
|
|
|
let scaleR = targetWhite / whitePoint.r; |
|
let scaleG = targetWhite / whitePoint.g; |
|
let scaleB = targetWhite / whitePoint.b; |
|
|
|
|
|
const baseScale = scaleG; |
|
scaleR = scaleR / baseScale; |
|
scaleB = scaleB / baseScale; |
|
scaleG = scaleG / baseScale; |
|
|
|
|
|
const brightnessFactor = baseScale; |
|
scaleR *= brightnessFactor; |
|
scaleG *= brightnessFactor; |
|
scaleB *= brightnessFactor; |
|
|
|
|
|
scaleR = Math.min(Math.max(scaleR, 0.5), 3.0); |
|
scaleG = Math.min(Math.max(scaleG, 0.5), 3.0); |
|
scaleB = Math.min(Math.max(scaleB, 0.5), 3.5); |
|
|
|
|
|
for (let i = 0; i < data.length; i += 4) { |
|
data[i] *= scaleR; |
|
data[i + 1] *= scaleG; |
|
data[i + 2] *= scaleB; |
|
data[i] = Math.min(255, Math.max(0, data[i])); |
|
data[i + 1] = Math.min(255, Math.max(0, data[i + 1])); |
|
data[i + 2] = Math.min(255, Math.max(0, data[i + 2])); |
|
} |
|
} |
|
|
|
findWhitePoint(data) { |
|
|
|
|
|
const candidates = []; |
|
|
|
for (let i = 0; i < data.length; i += 40) { |
|
const r = data[i]; |
|
const g = data[i + 1]; |
|
const b = data[i + 2]; |
|
|
|
const brightness = 0.299 * r + 0.587 * g + 0.114 * b; |
|
const max = Math.max(r, g, b); |
|
const min = Math.min(r, g, b); |
|
const saturation = max > 0 ? (max - min) / max : 0; |
|
|
|
|
|
if (brightness > 190 && saturation < 0.2) { |
|
candidates.push({ r, g, b, brightness }); |
|
} |
|
} |
|
|
|
if (candidates.length === 0) { |
|
return null; |
|
} |
|
|
|
|
|
candidates.sort((a, b) => b.brightness - a.brightness); |
|
const topCount = Math.max(1, Math.floor(candidates.length * 0.02)); |
|
|
|
|
|
let sumR = 0, sumG = 0, sumB = 0; |
|
for (let i = 0; i < topCount; i++) { |
|
sumR += candidates[i].r; |
|
sumG += candidates[i].g; |
|
sumB += candidates[i].b; |
|
} |
|
|
|
return { |
|
r: sumR / topCount, |
|
g: sumG / topCount, |
|
b: sumB / topCount |
|
}; |
|
} |
|
|
|
fallbackWhiteBalance(data) { |
|
|
|
const { avgR, avgG, avgB } = this.robustMean(data); |
|
|
|
|
|
const yellowFactor = ((avgR + avgG) / 2) / (avgB + 1); |
|
const yellowSeverity = Math.min(Math.max((yellowFactor - 1.0) / 0.5, 0), 1); |
|
|
|
|
|
const targetGray = 165 + yellowSeverity * 20; |
|
const blueBoost = 1.08 + yellowSeverity * 0.12; |
|
const redReduction = 0.96 - yellowSeverity * 0.04; |
|
|
|
let scaleB = (targetGray * blueBoost) / avgB; |
|
let scaleG = targetGray / avgG; |
|
let scaleR = (targetGray * redReduction) / avgR; |
|
|
|
|
|
scaleB = Math.min(Math.max(scaleB, 0.7), 3.0); |
|
scaleG = Math.min(Math.max(scaleG, 0.7), 2.5); |
|
scaleR = Math.min(Math.max(scaleR, 0.7), 2.5); |
|
|
|
|
|
for (let i = 0; i < data.length; i += 4) { |
|
data[i] *= scaleR; |
|
data[i + 1] *= scaleG; |
|
data[i + 2] *= scaleB; |
|
data[i] = Math.min(255, Math.max(0, data[i])); |
|
data[i + 1] = Math.min(255, Math.max(0, data[i + 1])); |
|
data[i + 2] = Math.min(255, Math.max(0, data[i + 2])); |
|
} |
|
} |
|
|
|
robustMean(data) { |
|
const rValues = []; |
|
const gValues = []; |
|
const bValues = []; |
|
|
|
|
|
for (let i = 0; i < data.length; i += 4) { |
|
if (data[i] > 10) rValues.push(data[i]); |
|
if (data[i + 1] > 10) gValues.push(data[i + 1]); |
|
if (data[i + 2] > 10) bValues.push(data[i + 2]); |
|
} |
|
|
|
|
|
rValues.sort((a, b) => a - b); |
|
gValues.sort((a, b) => a - b); |
|
bValues.sort((a, b) => a - b); |
|
|
|
|
|
const getPercentileMean = (arr) => { |
|
if (arr.length === 0) return 128; |
|
const start = Math.floor(arr.length * 0.2); |
|
const end = Math.floor(arr.length * 0.8); |
|
let sum = 0; |
|
for (let i = start; i < end; i++) { |
|
sum += arr[i]; |
|
} |
|
return sum / (end - start); |
|
}; |
|
|
|
return { |
|
avgR: getPercentileMean(rValues), |
|
avgG: getPercentileMean(gValues), |
|
avgB: getPercentileMean(bValues) |
|
}; |
|
} |
|
|
|
calculateMeanBrightness(data) { |
|
let sum = 0; |
|
let count = 0; |
|
for (let i = 0; i < data.length; i += 4) { |
|
|
|
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; |
|
sum += gray; |
|
count++; |
|
} |
|
return sum / count; |
|
} |
|
|
|
applySCurve(data, strength) { |
|
|
|
const k = strength * 10; |
|
const midpoint = 0.5; |
|
const curve = new Float32Array(256); |
|
|
|
for (let i = 0; i < 256; i++) { |
|
const x = i / 255; |
|
const y = 1 / (1 + Math.exp(-k * (x - midpoint))); |
|
curve[i] = y; |
|
} |
|
|
|
|
|
const minCurve = Math.min(...curve); |
|
const maxCurve = Math.max(...curve); |
|
for (let i = 0; i < 256; i++) { |
|
curve[i] = (curve[i] - minCurve) / (maxCurve - minCurve) * 255; |
|
} |
|
|
|
|
|
for (let i = 0; i < data.length; i += 4) { |
|
data[i] = curve[Math.round(data[i])]; |
|
data[i + 1] = curve[Math.round(data[i + 1])]; |
|
data[i + 2] = curve[Math.round(data[i + 2])]; |
|
} |
|
} |
|
|
|
enhanceLocalContrast(data, width, height, radius, amount) { |
|
|
|
const kernel = this.createGaussianKernel(radius); |
|
|
|
|
|
const blurred = this.applyGaussianBlur(data, width, height, kernel); |
|
|
|
|
|
const result = new Float32Array(data.length); |
|
for (let i = 0; i < data.length; i += 4) { |
|
result[i] = data[i] + (data[i] - blurred[i]) * amount; |
|
result[i + 1] = data[i + 1] + (data[i + 1] - blurred[i + 1]) * amount; |
|
result[i + 2] = data[i + 2] + (data[i + 2] - blurred[i + 2]) * amount; |
|
result[i + 3] = data[i + 3]; |
|
} |
|
|
|
return result; |
|
} |
|
|
|
createGaussianKernel(radius) { |
|
const size = radius * 2 + 1; |
|
const kernel = new Float32Array(size * size); |
|
const sigma = radius / 3; |
|
const sigma2 = sigma * sigma; |
|
let sum = 0; |
|
|
|
for (let y = 0; y < size; y++) { |
|
for (let x = 0; x < size; x++) { |
|
const dx = x - radius; |
|
const dy = y - radius; |
|
const value = Math.exp(-(dx * dx + dy * dy) / (2 * sigma2)); |
|
kernel[y * size + x] = value; |
|
sum += value; |
|
} |
|
} |
|
|
|
|
|
for (let i = 0; i < kernel.length; i++) { |
|
kernel[i] /= sum; |
|
} |
|
|
|
return { data: kernel, size: size, radius: radius }; |
|
} |
|
|
|
applyGaussianBlur(data, width, height, kernel) { |
|
const result = new Float32Array(data.length); |
|
const { data: kernelData, size, radius } = kernel; |
|
|
|
for (let y = 0; y < height; y++) { |
|
for (let x = 0; x < width; x++) { |
|
let r = 0, g = 0, b = 0; |
|
|
|
for (let ky = 0; ky < size; ky++) { |
|
for (let kx = 0; kx < size; kx++) { |
|
const px = Math.min(width - 1, Math.max(0, x + kx - radius)); |
|
const py = Math.min(height - 1, Math.max(0, y + ky - radius)); |
|
const idx = (py * width + px) * 4; |
|
const weight = kernelData[ky * size + kx]; |
|
|
|
r += data[idx] * weight; |
|
g += data[idx + 1] * weight; |
|
b += data[idx + 2] * weight; |
|
} |
|
} |
|
|
|
const idx = (y * width + x) * 4; |
|
result[idx] = r; |
|
result[idx + 1] = g; |
|
result[idx + 2] = b; |
|
result[idx + 3] = data[idx + 3]; |
|
} |
|
} |
|
|
|
return result; |
|
} |
|
|
|
enhanceColorVibrance(data, vibrance) { |
|
for (let i = 0; i < data.length; i += 4) { |
|
const r = data[i]; |
|
const g = data[i + 1]; |
|
const b = data[i + 2]; |
|
|
|
|
|
const max = Math.max(r, g, b); |
|
const min = Math.min(r, g, b); |
|
const saturation = max > 0 ? (max - min) / max : 0; |
|
|
|
|
|
const saturationBoost = 1.0 + vibrance * (1.0 - saturation); |
|
|
|
|
|
const hue = this.calculateHue(r, g, b); |
|
const isSkintone = (hue < 25 || hue > 330) && saturation > 0.1; |
|
|
|
const boost = isSkintone ? 1.0 + vibrance * 0.3 : saturationBoost; |
|
|
|
|
|
const avg = (r + g + b) / 3; |
|
data[i] = avg + (r - avg) * boost; |
|
data[i + 1] = avg + (g - avg) * boost; |
|
data[i + 2] = avg + (b - avg) * boost; |
|
} |
|
} |
|
|
|
calculateHue(r, g, b) { |
|
r /= 255; |
|
g /= 255; |
|
b /= 255; |
|
|
|
const max = Math.max(r, g, b); |
|
const min = Math.min(r, g, b); |
|
const delta = max - min; |
|
|
|
if (delta === 0) return 0; |
|
|
|
let hue; |
|
if (max === r) { |
|
hue = ((g - b) / delta) % 6; |
|
} else if (max === g) { |
|
hue = (b - r) / delta + 2; |
|
} else { |
|
hue = (r - g) / delta + 4; |
|
} |
|
|
|
hue = Math.round(hue * 60); |
|
if (hue < 0) hue += 360; |
|
|
|
return hue; |
|
} |
|
|
|
applyMicroContrast(data, width, height) { |
|
|
|
const kernel = this.createGaussianKernel(1); |
|
const blurred = this.applyGaussianBlur(data, width, height, kernel); |
|
|
|
|
|
const result = new Float32Array(data.length); |
|
for (let i = 0; i < data.length; i += 4) { |
|
result[i] = data[i] * 1.3 - blurred[i] * 0.3; |
|
result[i + 1] = data[i + 1] * 1.3 - blurred[i + 1] * 0.3; |
|
result[i + 2] = data[i + 2] * 1.3 - blurred[i + 2] * 0.3; |
|
result[i + 3] = data[i + 3]; |
|
} |
|
|
|
return result; |
|
} |
|
|
|
ensureWhites(data) { |
|
for (let i = 0; i < data.length; i += 4) { |
|
const r = data[i]; |
|
const g = data[i + 1]; |
|
const b = data[i + 2]; |
|
|
|
|
|
const brightness = 0.299 * r + 0.587 * g + 0.114 * b; |
|
const max = Math.max(r, g, b); |
|
const min = Math.min(r, g, b); |
|
const saturation = max > 0 ? (max - min) / max : 0; |
|
|
|
|
|
if (brightness > 240 && saturation < 0.06) { |
|
data[i] = 255; |
|
data[i + 1] = 255; |
|
data[i + 2] = 255; |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
const processor = new ImageProcessor(); |
|
|
|
function downloadImage() { |
|
const canvas = document.getElementById('correctedCanvas'); |
|
const link = document.createElement('a'); |
|
link.download = 'corrected_image.png'; |
|
link.href = canvas.toDataURL('image/png'); |
|
link.click(); |
|
} |
|
|
|
function downloadModalImage() { |
|
const canvas = document.getElementById('modalCorrected'); |
|
const link = document.createElement('a'); |
|
link.download = `corrected_${processor.currentModalIndex + 1}.png`; |
|
link.href = canvas.toDataURL('image/png'); |
|
link.click(); |
|
} |
|
|
|
async function downloadAll() { |
|
for (let i = 0; i < processor.processedImages.length; i++) { |
|
const link = document.createElement('a'); |
|
link.download = `corrected_${processor.processedImages[i].name}`; |
|
link.href = processor.processedImages[i].corrected.toDataURL('image/png'); |
|
link.click(); |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
} |
|
} |
|
|
|
function resetApp() { |
|
document.getElementById('results').style.display = 'none'; |
|
document.getElementById('uploadArea').style.display = 'block'; |
|
document.getElementById('fileInput').value = ''; |
|
processor.processedImages = []; |
|
} |
|
|
|
function closeModal() { |
|
document.getElementById('imageModal').style.display = 'none'; |
|
} |
|
|
|
|
|
document.getElementById('imageModal').addEventListener('click', (e) => { |
|
if (e.target.id === 'imageModal') { |
|
closeModal(); |
|
} |
|
}); |
|
</script> |
|
</body> |
|
</html> |