Skip to content

Commit df218eb

Browse files
committed
Add description, stats, refacto, better style
1 parent 052a77b commit df218eb

1 file changed

Lines changed: 67 additions & 74 deletions

File tree

‎public/histogram-matching-js.html‎

Lines changed: 67 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<html>
22
<head>
3-
<script src="https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.13/html-to-image.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
4-
53
<style>
4+
body {
5+
font-family: "Roboto", sans-serif;
6+
}
67
.flex-grid {
78
display: flex;
89
}
@@ -17,11 +18,19 @@
1718
}
1819
.flex-grid-thirds .col {
1920
width: 32%;
21+
}
22+
.col img, .col canvas {
23+
max-width: 100%;
2024
}
2125
</style>
2226
</head>
2327
<body>
24-
28+
<div style="padding: 30px; ">
29+
<h3>Pure JS Histogram Matching - client-side</h3>
30+
<p>
31+
Inspired by <a href="https://github.com/mapbox/rio-hist/issues/3">mapbox/rio-hist</a> (python), this demo showcases pure-JS Histogram Matching. <br/>Implementation based on scikit-image (<a href="https://scikit-image.org/docs/stable/auto_examples/color_exposure/plot_histogram_matching.html">doc</a> and <a href="https://github.com/scikit-image/scikit-image/blob/v0.25.2/skimage/exposure/histogram_matching.py#L33-L93">code</a>) which simply relies on numpy <code>bincount, cumsum, interp</code>. <br/>scikit-image and numpy use BSD3 License.
32+
</p>
33+
</div>
2534
<div class="flex-grid-thirds">
2635
<div class="col"> SOURCE <input type="file" id="source-input" accept="image/png, image/jpeg" /> </div>
2736
<!-- onChange="onChangeInput(this)" -->
@@ -40,8 +49,8 @@
4049
// Relies on numpy bincount, cumsum, interp, reimplemented below with some assumptions
4150

4251
const img_objs = [];
43-
let source_filepath = './source2.jpg';
44-
let target_filepath = './reference2.jpg';
52+
let source_filepath = './source1.jpg';
53+
let target_filepath = './reference1.jpg';
4554

4655

4756
// Compute histogram bins for an array, with optional min/max for faster compute, and optional normalization parameter
@@ -51,14 +60,10 @@
5160

5261
let bins = new Uint32Array(binCount);
5362
for (let i = 0; i < arr.length; i++) {
54-
let bin = (arr[i] - min) * (binCount - 1) / (max - min);
55-
// clamping should not be needed given above formula
56-
// bin = Math.floor(Math.min(Math.max(bin, 0), binCount - 1));
57-
bin = Math.floor(bin);
58-
bins[bin]++;
63+
// Clamping not needed under the assumption items do not exceed min-max range, otherwise will overflow
64+
const bin = (arr[i] - min) * (binCount - 1) / (max - min);
65+
bins[Math.floor(bin)]++;
5966
}
60-
61-
// Float32Array or Float64Array ?
6267
if (normalize)
6368
bins = Float64Array.from(bins, b => b / arr.length);
6469

@@ -111,89 +116,55 @@
111116

112117
// helper for uint8 histograms
113118
function getNormalizedCdfUint8(arr) {
114-
// console.log('arr', arr)
115-
const normalize = true //
116-
const bins = bincount(arr, 256, 0, 255, normalize);
117-
// console.log('bins', bins)
118-
119-
const cumsums = cumsum(bins)
120-
console.log('cumsums', cumsums)
121-
return cumsums;
119+
return cumsum(bincount(arr, 256, 0, 255, true));
122120
}
123121

124122
// compute mapping between source and target
125123
export function matchCumulativeCdf(source, target) {
126124
const srcCdf = getNormalizedCdfUint8(source);
127125
const tgtCdf = getNormalizedCdfUint8(target);
128-
console.log('srcCdf', srcCdf, tgtCdf)
129-
130126
const values = Array.from({ length: 256 }, (_, i) => i);
131127

132-
// const mapping = interp(values, srcCdf, tgtCdf)
133128
// For each source intensity, find the CDF value, then find which target intensity has the closest CDF value.
134-
const mapping = interp(srcCdf, tgtCdf, values);
135-
136-
// TODO: check the mapping do not have to be DE-normalized by binCount ?
137-
138-
console.log('--- MAPPING RESULT', mapping)
139-
return mapping;
129+
return interp(srcCdf, tgtCdf, values);
140130
}
141131

142132
// ------------------- high-level API -------------------
143133

144134
/**
145135
* Match histograms of two raw JS images.
146136
* Works for grayscale, RGB, or RGBA.
137+
* BEWARE RETURNED MAPPINGS IS NOT A FUNCTION THAT TAKES FLOAT, BUT AN ARRAY LOOKUP TABLE, CAN ONLY TAKE INTEGER VALUES
147138
*
148139
* @param {Uint8Array} source - Source image array (flat 1D, 1/3/4 channels interleaved).
149140
* @param {Uint8Array} target - Target image array (same shape/channels as source).
141+
* @param Number channels - count of channels to do correct de-interleaving of RGB channels values.
150142
* @param {'MAPPING'|'MATCHED_DATA'} returnType - Whether to return mappings or matched data.
151143
* @returns {{mappings: Float64Array[], matched?: Uint8Array}}
152144
*/
153145
export function matchHistograms(source, target, channels = 3, returnType = 'MATCHED_DATA') {
154146
// const channels = source.length % 4 === 0 ? 4 :
155147
// source.length % 3 === 0 ? 3 : 1;
156-
console.log('channels', channels)
157-
const mappings = []
158-
159-
if (channels === 1) {
160-
mappings[0] = matchCumulativeCdf(source, target);
161-
console.log('mappings', mappings)
162-
if (returnType === 'MAPPING') return {mappings};
163-
const out = new Uint8Array(source.length);
164-
for (let i = 0; i < source.length; i++) {
165-
out[i] = mapping[source[i]];
166-
}
167-
return {mappings, matched: out};
148+
// RGB / RGBA
149+
const mappings = [];
150+
for (let c = 0; c < Math.min(3, channels); c++) {
151+
const srcChannel = [];
152+
const tgtChannel = [];
153+
for (let i = c; i < source.length; i += channels) srcChannel.push(source[i]);
154+
for (let i = c; i < target.length; i += channels) tgtChannel.push(target[i]);
155+
mappings[c] = matchCumulativeCdf(srcChannel, tgtChannel);
168156
}
169-
else if (channels === 3 || channels == 4){
170-
171-
// RGB / RGBA
172-
const mappings = [];
173-
for (let c = 0; c < 3; c++) {
174-
const srcChannel = [];
175-
const tgtChannel = [];
176-
for (let i = c; i < source.length; i += channels) srcChannel.push(source[i]);
177-
for (let i = c; i < target.length; i += channels) tgtChannel.push(target[i]);
178-
mappings[c] = matchCumulativeCdf(srcChannel, tgtChannel);
179-
}
180-
console.log('mappings', mappings)
157+
console.log('mappings', mappings)
181158

182-
if (returnType === 'MAPPING') return {mappings};
183-
184-
// BEWARE MAPPINGS IS NOT A FUNCTION THAT TAKES FLOAT, BUT AN ARRAY LOOKUP TABLE SO HAS TO TAKE INTEGER VALUES
185-
186-
const out = new Uint8Array(source.length);
187-
for (let i = 0; i < source.length; i += channels) {
188-
for (let c = 0; c < 3; c++) {
189-
out[i + c] = mappings[0 + c][source[i + c]]; // R
190-
// out[i + 1] = mappings[1][source[i + 1]]; // G
191-
// out[i + 2] = mappings[2][source[i + 2]]; // B
192-
}
193-
if (channels === 4) out[i + 3] = source[i + 3]; // keep alpha
159+
if (returnType === 'MAPPING') return {mappings};
160+
const out = new Uint8Array(source.length);
161+
for (let i = 0; i < source.length; i += channels) {
162+
for (let c = 0; c < 3; c++) {
163+
out[i + c] = mappings[0 + c][source[i + c]];
194164
}
195-
return {mappings, matched: out};
165+
if (channels === 4) out[i + 3] = source[i + 3]; // keep alpha
196166
}
167+
return {mappings, matched: out};
197168
}
198169

199170

@@ -217,11 +188,13 @@
217188
const target_arr = target.ctx.getImageData(0, 0, target.canvas.width, target.canvas.height);
218189
console.log('source_arr', source_arr, target_arr)
219190

191+
const start = Date.now();
220192
const returnType = 'MATCHED_DATA'
221193
const channels = 3 // should be computed from image, or getImageData is always same type?
222194
const matched = matchHistograms(source_arr.data, target_arr.data, channels, returnType);
223195
console.log('matched', matched, 'mappings slice')
224196
console.log(matched.mappings[0].slice(0, 30))
197+
const elapsed_ms = Date.now() - start
225198

226199
// Render matched image
227200
const canvas_out = document.createElement("canvas");
@@ -233,7 +206,34 @@
233206
ctx_out.putImageData(imageData, 0, 0);
234207

235208
container = document.getElementById('matched-img-div');
236-
container.replaceChildren(canvas_out);
209+
210+
const stats_div = document.createElement("div");
211+
const statsObj = {
212+
source: {
213+
width: source.img.width,
214+
height: source.img.height,
215+
Mpix: Math.floor(source.img.width * source.img.height / 1000000),
216+
},
217+
target: {
218+
width: target.img.width,
219+
height: target.img.height,
220+
Mpix: Math.floor(target.img.width * target.img.height / 1000000),
221+
},
222+
timings: {
223+
ms: elapsed_ms
224+
}
225+
}
226+
stats_div.innerHTML = JSON.stringify(statsObj, null, " ");
227+
228+
// container.replaceChildren(canvas_out);
229+
canvas_out.toBlob(function(blob) {
230+
const url = URL.createObjectURL(blob);
231+
const img = document.createElement("img");
232+
img.src = url;
233+
container.replaceChildren(img);
234+
235+
container.addChildren(stats_div)
236+
}, "image/png");
237237
}
238238
}
239239
img.src = filepath;
@@ -286,13 +286,6 @@
286286
document.getElementById('source-input').onchange = onChangeInput
287287
document.getElementById('target-input').onchange = onChangeInput
288288

289-
290-
291-
// import * as htmlToImage from 'https://cdnjs.cloudflare.com/ajax/libs/html-to-image/1.11.13/html-to-image.js';
292-
// import htmlToImage from 'https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.js';
293-
// console.log('htmlToImage')
294-
// console.log(htmlToImage)
295-
296289
</script>
297290

298291
</body>

0 commit comments

Comments
 (0)