|
1 | 1 | <html> |
2 | 2 | <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 | | - |
5 | 3 | <style> |
| 4 | +body { |
| 5 | + font-family: "Roboto", sans-serif; |
| 6 | +} |
6 | 7 | .flex-grid { |
7 | 8 | display: flex; |
8 | 9 | } |
|
17 | 18 | } |
18 | 19 | .flex-grid-thirds .col { |
19 | 20 | width: 32%; |
| 21 | +} |
| 22 | +.col img, .col canvas { |
| 23 | + max-width: 100%; |
20 | 24 | } |
21 | 25 | </style> |
22 | 26 | </head> |
23 | 27 | <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> |
25 | 34 | <div class="flex-grid-thirds"> |
26 | 35 | <div class="col"> SOURCE <input type="file" id="source-input" accept="image/png, image/jpeg" /> </div> |
27 | 36 | <!-- onChange="onChangeInput(this)" --> |
|
40 | 49 | // Relies on numpy bincount, cumsum, interp, reimplemented below with some assumptions |
41 | 50 |
|
42 | 51 | 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'; |
45 | 54 |
|
46 | 55 |
|
47 | 56 | // Compute histogram bins for an array, with optional min/max for faster compute, and optional normalization parameter |
|
51 | 60 |
|
52 | 61 | let bins = new Uint32Array(binCount); |
53 | 62 | 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)]++; |
59 | 66 | } |
60 | | - |
61 | | - // Float32Array or Float64Array ? |
62 | 67 | if (normalize) |
63 | 68 | bins = Float64Array.from(bins, b => b / arr.length); |
64 | 69 |
|
|
111 | 116 |
|
112 | 117 | // helper for uint8 histograms |
113 | 118 | 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)); |
122 | 120 | } |
123 | 121 |
|
124 | 122 | // compute mapping between source and target |
125 | 123 | export function matchCumulativeCdf(source, target) { |
126 | 124 | const srcCdf = getNormalizedCdfUint8(source); |
127 | 125 | const tgtCdf = getNormalizedCdfUint8(target); |
128 | | - console.log('srcCdf', srcCdf, tgtCdf) |
129 | | - |
130 | 126 | const values = Array.from({ length: 256 }, (_, i) => i); |
131 | 127 |
|
132 | | - // const mapping = interp(values, srcCdf, tgtCdf) |
133 | 128 | // 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); |
140 | 130 | } |
141 | 131 |
|
142 | 132 | // ------------------- high-level API ------------------- |
143 | 133 |
|
144 | 134 | /** |
145 | 135 | * Match histograms of two raw JS images. |
146 | 136 | * 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 |
147 | 138 | * |
148 | 139 | * @param {Uint8Array} source - Source image array (flat 1D, 1/3/4 channels interleaved). |
149 | 140 | * @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. |
150 | 142 | * @param {'MAPPING'|'MATCHED_DATA'} returnType - Whether to return mappings or matched data. |
151 | 143 | * @returns {{mappings: Float64Array[], matched?: Uint8Array}} |
152 | 144 | */ |
153 | 145 | export function matchHistograms(source, target, channels = 3, returnType = 'MATCHED_DATA') { |
154 | 146 | // const channels = source.length % 4 === 0 ? 4 : |
155 | 147 | // 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); |
168 | 156 | } |
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) |
181 | 158 |
|
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]]; |
194 | 164 | } |
195 | | - return {mappings, matched: out}; |
| 165 | + if (channels === 4) out[i + 3] = source[i + 3]; // keep alpha |
196 | 166 | } |
| 167 | + return {mappings, matched: out}; |
197 | 168 | } |
198 | 169 |
|
199 | 170 |
|
|
217 | 188 | const target_arr = target.ctx.getImageData(0, 0, target.canvas.width, target.canvas.height); |
218 | 189 | console.log('source_arr', source_arr, target_arr) |
219 | 190 |
|
| 191 | + const start = Date.now(); |
220 | 192 | const returnType = 'MATCHED_DATA' |
221 | 193 | const channels = 3 // should be computed from image, or getImageData is always same type? |
222 | 194 | const matched = matchHistograms(source_arr.data, target_arr.data, channels, returnType); |
223 | 195 | console.log('matched', matched, 'mappings slice') |
224 | 196 | console.log(matched.mappings[0].slice(0, 30)) |
| 197 | + const elapsed_ms = Date.now() - start |
225 | 198 |
|
226 | 199 | // Render matched image |
227 | 200 | const canvas_out = document.createElement("canvas"); |
|
233 | 206 | ctx_out.putImageData(imageData, 0, 0); |
234 | 207 |
|
235 | 208 | 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"); |
237 | 237 | } |
238 | 238 | } |
239 | 239 | img.src = filepath; |
|
286 | 286 | document.getElementById('source-input').onchange = onChangeInput |
287 | 287 | document.getElementById('target-input').onchange = onChangeInput |
288 | 288 |
|
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 | | - |
296 | 289 | </script> |
297 | 290 |
|
298 | 291 | </body> |
|
0 commit comments