Skip to content

Commit 74e2e27

Browse files
committed
hist-match Make colorSpace, bands, binCount, (+ prev maxMpx) user-configurable
1 parent a3f886d commit 74e2e27

3 files changed

Lines changed: 106 additions & 34 deletions

File tree

‎histogram-matching-js.html‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@
6060
margin-bottom: 20px;
6161
/* color: #856404; */
6262
}
63+
64+
details label {
65+
min-width: 100px;
66+
display: inline-block;
67+
}
68+
input, select {
69+
font-family: monospace;
70+
}
71+
details input, details select {
72+
width: 120px;
73+
margin: 10px;
74+
margin-top: 0;
75+
}
6376
</style>
6477
</head>
6578
<body>
@@ -99,8 +112,23 @@ <h1>Pure JS, client-side, Histogram Matching</h1>
99112
<div class="col" id="target-hist-chart"></div>
100113
<div class="col">
101114
<div style="text-align: left;">
102-
<label for="max-mpx-input"><tt>Max Mpx</tt></label>
103-
<input type="number" id="max-mpx-input" value="0" min="0" step="0.1" style="width: 80px; margin: 10px; margin-top: 0;" title="Rasters Downsampling for histogram/CDF computation (0 = no limit)" >
115+
<tt>
116+
<details open>
117+
<summary>Settings</summary>
118+
<label for="max-mpx-input">Max Mpx</label>
119+
<input type="number" id="max-mpx-input" value="0" min="0" step="0.1" title="Rasters Downsampling for histogram/CDF computation (0 = no limit)" >
120+
<br/>
121+
<label for="colorSpace-input">Color Space</label>
122+
<select id="colorSpace-input" title="Color Space used to compute histogram matching" >
123+
</select>
124+
<br/>
125+
<label for="bands-input">Bands</label>
126+
<input type="text" id="bands-input" value="1,2,3" title="Apply matching LUT to these bands (default: 1,2,3)" >
127+
<br/>
128+
<label for="binCount-input">Bin Count</label>
129+
<input type="number" id="binCount-input" value="256" min="0" max="256" step="1" title="Compute histograms on N bins" >
130+
</details>
131+
</tt>
104132
</div>
105133
<pre id="output-stats" style="text-align: left; padding: 0px;"></pre>
106134
</div>

‎src/histogram-matching/histogram-main.js‎

Lines changed: 75 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,25 @@ function isGeoTIFF(fileOrUrl) {
1111
(typeof fileOrUrl === "string" && fileOrUrl.startsWith("data:image/tiff;base64,"));
1212
}
1313

14+
const DEFAULT_SETTINGS = {
15+
maxMpx: -1,
16+
colorSpace: COLOR_SPACE.RGB,
17+
bands: [1, 2, 3],
18+
binCount: 256
19+
}
1420
/**
1521
* Load image (regular or GeoTIFF)
1622
* @param {File|string} fileOrUrl - File object or URL
1723
* @param {string} slot - 'source' or 'target'
1824
* @param {number} maxMpx - Max megapixels for processing
1925
* @returns {Promise<void>}
2026
*/
21-
async function loadImage(fileOrUrl, slot, maxMpx = -1) {
27+
async function loadImage(fileOrUrl, slot, settings=DEFAULT_SETTINGS) {
2228
try {
2329
if (isGeoTIFF(fileOrUrl)) {
24-
await loadGeoTIFF(fileOrUrl, slot, maxMpx);
30+
await loadGeoTIFF(fileOrUrl, slot, settings);
2531
} else {
26-
await loadRegularImage(fileOrUrl, slot, maxMpx);
32+
await loadRegularImage(fileOrUrl, slot, settings);
2733
}
2834
} catch (error) {
2935
console.error(`Failed to load ${slot} image:`, error);
@@ -38,7 +44,7 @@ async function loadImage(fileOrUrl, slot, maxMpx = -1) {
3844
* @param {number} maxMpx - Max megapixels for processing
3945
* @returns {Promise<void>}
4046
*/
41-
async function loadRegularImage(fileOrUrl, slot, maxMpx) {
47+
async function loadRegularImage(fileOrUrl, slot, settings=DEFAULT_SETTINGS) {
4248
return new Promise((resolve, reject) => {
4349
const img = new Image();
4450
img.onload = async () => {
@@ -53,7 +59,7 @@ async function loadRegularImage(fileOrUrl, slot, maxMpx) {
5359
document.getElementById(slot + "-img-div").replaceChildren(img);
5460

5561
if (imageObjects.source && imageObjects.target) {
56-
await processMatching(maxMpx);
62+
await processMatching(settings);
5763
}
5864
resolve();
5965
} catch (error) {
@@ -72,7 +78,7 @@ async function loadRegularImage(fileOrUrl, slot, maxMpx) {
7278
* @param {number} maxMpx - Max megapixels for processing
7379
* @returns {Promise<void>}
7480
*/
75-
async function loadGeoTIFF(fileOrUrl, slot, maxMpx) {
81+
async function loadGeoTIFF(fileOrUrl, slot, settings=DEFAULT_SETTINGS) {
7682
const { fromArrayBuffer } = await import("https://esm.sh/geotiff@2.1.4-beta.0");
7783

7884
let arrayBuffer;
@@ -115,7 +121,7 @@ async function loadGeoTIFF(fileOrUrl, slot, maxMpx) {
115121
document.getElementById(slot + "-img-div").replaceChildren(canvas);
116122

117123
if (imageObjects.source && imageObjects.target) {
118-
await processMatching(maxMpx);
124+
await processMatching(settings);
119125
}
120126
}
121127

@@ -124,7 +130,7 @@ async function loadGeoTIFF(fileOrUrl, slot, maxMpx) {
124130
* @param {number} maxMpx - Max megapixels for processing
125131
* @returns {Promise<void>}
126132
*/
127-
async function processMatching(maxMpx = -1) {
133+
async function processMatching(settings=DEFAULT_SETTINGS) {
128134
const sourceCanvas = imageObjects.source.canvas;
129135
const targetCanvas = imageObjects.target.canvas;
130136
const sourceArr = imageObjects.source.ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height).data;
@@ -134,18 +140,29 @@ async function processMatching(maxMpx = -1) {
134140
const start = Date.now();
135141
// const matched = matchHistograms(sourceArr, targetArr, 4, 'MATCHED_DATA', maxMpx);
136142

137-
const matched = matchHistogramsColorspaces(
143+
let matched
144+
if (settings) {
145+
matched = matchHistogramsRGB(
146+
sourceArr,
147+
targetArr,
148+
4,
149+
'MATCHED_DATA', settings.maxMpx
150+
)
151+
} else {
152+
matched = matchHistogramsColorspaces(
138153
sourceArr,
139154
targetArr,
140155
4,
141156
{
142157
returnType: 'MATCHED_DATA',
143-
maxMpx: 0,
144-
colorSpace: COLOR_SPACE.LCH,
145-
bands: [1, 2, 3],
146-
binCount: 256,
158+
...settings
159+
// maxMpx: 0,
160+
// colorSpace: COLOR_SPACE.RGB,
161+
// bands: [1, 2, 3],
162+
// binCount: 256,
147163
}
148164
)
165+
}
149166

150167
const elapsed_ms = Date.now() - start;
151168

@@ -159,7 +176,7 @@ async function processMatching(maxMpx = -1) {
159176
ctxOut.putImageData(outData, 0, 0);
160177

161178
// Display results and handle exports
162-
await displayResults(outCanvas, matched, elapsed_ms, maxMpx);
179+
await displayResults(outCanvas, matched, elapsed_ms);
163180
}
164181

165182
/**
@@ -169,7 +186,7 @@ async function processMatching(maxMpx = -1) {
169186
* @param {number} elapsed_ms - Processing time
170187
* @returns {Promise<void>}
171188
*/
172-
async function displayResults(outCanvas, matched, elapsed_ms, maxMpx) {
189+
async function displayResults(outCanvas, matched, elapsed_ms) {
173190
const container = document.getElementById('matched-img-div');
174191

175192
// Make download Link/Button
@@ -195,7 +212,7 @@ async function displayResults(outCanvas, matched, elapsed_ms, maxMpx) {
195212
// statsDiv.style = 'text-align: left; padding: 20px;'
196213
const sourceMpix = (imageObjects.source.canvas.width * imageObjects.source.canvas.height / 1e6).toFixed(2);
197214
const targetMpix = (imageObjects.target.canvas.width * imageObjects.target.canvas.height / 1e6).toFixed(2);
198-
statsDiv.textContent = `Histogram matching took ${elapsed_ms}ms (maxMpx: ${maxMpx <= 0 ? 'uncapped' : maxMpx + 'Mpx'})\nSource: ${sourceMpix}Mpx (${imageObjects.source.canvas.width} x ${imageObjects.source.canvas.height})\nTarget: ${targetMpix}Mpx (${imageObjects.target.canvas.width} x ${imageObjects.target.canvas.height})`;
215+
statsDiv.textContent = `Histogram matching took ${elapsed_ms}ms \nSource: ${sourceMpix}Mpx (${imageObjects.source.canvas.width} x ${imageObjects.source.canvas.height})\nTarget: ${targetMpix}Mpx (${imageObjects.target.canvas.width} x ${imageObjects.target.canvas.height})`;
199216
// statsContainer.replaceChildren(statsDiv);
200217
}, "image/png");
201218

@@ -237,24 +254,37 @@ async function exportGeoTIFF(raster, geoMetadata) {
237254
* Get current maxMpx value from input
238255
* @returns {number} Current maxMpx value
239256
*/
240-
function getInputMaxMpx() {
241-
const input = document.getElementById('max-mpx-input');
242-
return input ? parseFloat(input.value) || 0 : 0;
257+
function getInputSettings() {
258+
const maxMpxInput = document.getElementById("max-mpx-input");
259+
const maxMpx = parseFloat(maxMpxInput.value) || 0;
260+
const colorSpaceInput = document.getElementById("colorSpace-input");
261+
const colorSpace = colorSpaceInput.value; // TODO MAP TO ENUM
262+
const bandsInput = document.getElementById("bands-input");
263+
const bands = bandsInput.value.trim().split(',')
264+
const binCountInput = document.getElementById("binCount-input");
265+
const binCount = parseFloat(binCountInput.value) || 256;
266+
267+
return {
268+
maxMpx,
269+
colorSpace,
270+
bands,
271+
binCount
272+
}
243273
}
244274

245275
/**
246276
* Handle file input change
247277
* @param {Event} e - Input change event
248278
*/
249279
function handleFileInput(e) {
250-
const maxMpx = getInputMaxMpx()
280+
const settings = getInputSettings()
251281
const file = e.target.files[0];
252282
if (!file) return;
253283

254284
const reader = new FileReader();
255285
reader.onload = (ev) => {
256286
const slot = e.target.id.includes('source') ? 'source' : 'target';
257-
loadImage(ev.target.result, slot, maxMpx);
287+
loadImage(ev.target.result, slot, settings);
258288
};
259289
reader.readAsDataURL(file);
260290
}
@@ -391,32 +421,46 @@ let imageObjects = {}
391421
function setupEventListeners() {
392422
const sourceInput = document.getElementById("source-input");
393423
const targetInput = document.getElementById("target-input");
394-
const maxMpxInput = document.getElementById("max-mpx-input");
395424

396425
if (sourceInput) {
397426
sourceInput.addEventListener("change", (e) => handleFileInput(e));
398427
}
399428
if (targetInput) {
400429
targetInput.addEventListener("change", (e) => handleFileInput(e));
401430
}
402-
if (maxMpxInput) {
403-
maxMpxInput.addEventListener("change", async (e) => {
404-
const maxMpx = getInputMaxMpx()
405-
await processMatching(maxMpx)
406-
});
431+
432+
// Add settings
433+
async function handleSettingsChange (e) {
434+
const settings = getInputSettings()
435+
await processMatching(settings)
436+
}
437+
document.getElementById("max-mpx-input").addEventListener("change", handleSettingsChange);
438+
document.getElementById("bands-input").addEventListener("change", handleSettingsChange);
439+
document.getElementById("binCount-input").addEventListener("change", handleSettingsChange);
440+
441+
// Add options to color-spaces select
442+
const colorSpaceInput = document.getElementById("colorSpace-input")
443+
colorSpaceInput.addEventListener("change", handleSettingsChange);
444+
if (colorSpaceInput.children.length == 0) {
445+
for (const [key, value] of Object.entries(COLOR_SPACE)) {
446+
const opt = document.createElement('option');
447+
opt.value = value;
448+
opt.innerHTML = key;
449+
colorSpaceInput.appendChild(opt);
450+
}
407451
}
408452
}
409453

410454
/**
411455
* Load default images if available
412456
*/
413457
async function loadDefaultImages() {
414-
const maxMpx = getInputMaxMpx()
415-
console.log('maxMpx', maxMpx)
458+
const settings = getInputSettings()
459+
console.log('settings', settings)
416460
try {
417461
await Promise.allSettled([
418-
loadImage('./source1.jpg', "source", maxMpx),
419-
loadImage('./reference1.jpg', "target", maxMpx)
462+
loadImage('./source1.jpg', "source", settings),
463+
loadImage('./reference1.jpg', "target", settings)
420464
]);
421465
} catch (error) {
422466
console.log('Default images not available, waiting for user input');

‎src/histogram-matching/histogram-utils.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export const COLOR_SPACE = {
168168
XYZ: 'xyz',
169169
LAB: 'lab',
170170
LCH: 'lch',
171-
};
171+
};
172172

173173
// Fixed scales per color space (not exposed by color-convert, taken from docs/specs)
174174
export const COLOR_SCALES = {

0 commit comments

Comments
 (0)