| <!doctype html> |
| <html> |
| <head> |
| <script src="https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.js"></script> |
| <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"> |
| <style> |
| body { |
| grid-template-columns: none !important; |
| } |
| |
| .editor { |
| display: flex; |
| width: 100vw; |
| height: 100vh; |
| } |
| |
| .toolbar { |
| width: 500px; |
| } |
| |
| .toolbar-item { |
| padding: 15px; |
| display: inline-block; |
| } |
| |
| .tool-label { |
| display: block; |
| margin-bottom: 5px; |
| font-size: 14px; |
| font-weight: bold; |
| } |
| |
| .tool-input { |
| width: 100%; |
| } |
| |
| .image-area { |
| flex-grow: 1; |
| padding: 20px; |
| } |
| |
| #canvas { |
| max-width: 100%; |
| max-height: 100%; |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <div class="editor"> |
| <div class="toolbar"> |
| <div class="toolbar-item"> |
| <input type="file" id="imageFileInput"> |
| </div> |
|
|
| <div class="toolbar-item"> |
| <label class="tool-label" for="scale">Scale (4)</label> |
| <input class="tool-input" type="range" id="scale" min="2" max="16" value="4" step="2" oninput="this.previousElementSibling.innerHTML = 'Scale (' + this.value + ')'"> |
| </div> |
| <div class="toolbar-item"> |
| <label class="tool-label" for="colors">Colors (16)</label> |
| <input class="tool-input" type="range" id="colors" min="4" max="256" value="16" step="2" oninput="this.previousElementSibling.innerHTML = 'Colors (' + this.value + ')'"> |
| </div> |
| <br /> |
| <div class="toolbar-item"> |
| <label for="quant">Quantization method:</label> |
| <select name="quant" id="quant"> |
| <option value="3">LibImageQuant</option> |
| <option value="0">MedianCut</option> |
| <option value="2">FastOctree</option> |
| <option value="1">MaxCoverage</option> |
| </select> |
| </div> |
| <div class="toolbar-item"> |
| <input type="checkbox" id="dither" name="dither" checked /> |
| <label for="dither">Enable Dithering</label> |
| </div> |
| <div class="toolbar-item"> |
| <input type="checkbox" id="kmeans" name="kmeans" /> |
| <label for="kmeans">Enable K-Means</label> |
| </div> |
| <div class="toolbar-item"> |
| <input type="checkbox" id="rgb555" name="rgb555" checked /> |
| <label for="rgb555">Apply RGB555</label> |
| </div> |
| <div class="toolbar-item"> |
| <input type="checkbox" id="snescrop" name="snescrop" /> |
| <label for="snescrop">Crop to SNES size</label> |
| </div> |
| <div class="toolbar-item"> |
| <input type="checkbox" id="rescale" name="rescale" checked /> |
| <label for="rescale">Rescale post-processing</label> |
| </div> |
| <div class="toolbar-item"> |
| <button onclick="pixelize()">Run</button> |
| </div> |
| <div class="toolbar-item"> |
| <button id="reloadBtn" onclick="loadImage()" disabled>Reload original</button> |
| </div> |
| <div class="toolbar-item"> |
| <textarea id="consoleOutput" style="width: 465px;" rows="20" disabled></textarea> |
| </div> |
| </div> |
| <div class="image-area"> |
| <canvas id="canvas" height="500" width="500"></canvas> |
| </div> |
| </div> |
| </body> |
|
|
| <script> |
| const fileInput = document.querySelector("#imageFileInput"); |
| const canvas = document.querySelector("#canvas"); |
| const canvasCtx = canvas.getContext("2d"); |
| const consoleOutput = document.getElementById("consoleOutput"); |
| |
| let image = null; |
| |
| function loadImage() { |
| canvas.width = image.width; |
| canvas.height = image.height; |
| canvasCtx.drawImage(image, 0, 0); |
| } |
| |
| fileInput.addEventListener("change", () => { |
| document.getElementById("reloadBtn").disabled = false; |
| image = new Image(); |
| |
| image.addEventListener("load", () => { |
| loadImage(); |
| }); |
| |
| image.src = URL.createObjectURL(fileInput.files[0]); |
| }); |
| |
| function addToOutput(s) { |
| consoleOutput.value += s + "\n"; |
| } |
| |
| async function main() { |
| let pyodide = await loadPyodide(); |
| await pyodide.loadPackage("numpy"); |
| await pyodide.loadPackage("./pillow-10.2.0-cp312-cp312-pyodide_2024_0_wasm32.whl"); |
| return pyodide; |
| } |
| |
| let pyodideReadyPromise = main(); |
| |
| async function pixelize() { |
| if (!image) return; |
| consoleOutput.value = ""; |
| |
| let pyodide = await pyodideReadyPromise; |
| |
| try { |
| pyodide.runPython(` |
| import numpy as np |
| from PIL import Image |
| from js import canvas, scale, colors, quant, dither, kmeans, rgb555, snescrop, rescale, addToOutput, ImageData |
| from pyodide.ffi import create_proxy |
| |
| # Access to canvas content |
| canvasCtx = canvas.getContext("2d") |
| |
| # SNES helpers |
| toRGB555 = lambda v: v >> 3 << 3 |
| def apply(arr, fn): |
| vfn = np.vectorize(fn,otypes=[arr.dtype]) |
| return vfn(arr.flatten()).reshape(arr.shape) |
| def snes_crop(image): |
| width, height = image.size |
| new_width, new_height = 256, 224 |
| left = max([0, (width - new_width)/2]) |
| top = max([0, (height - new_height)/2]) |
| right = min([width, (width + new_width)/2]) |
| bottom = min([height, (height + new_height)/2]) |
| return image.crop((left, top, right, bottom)) |
| |
| addToOutput("Starting...") |
| |
| # Read canvas ImageData as PIL.Image |
| im = Image.fromarray(np.array(canvasCtx.getImageData(0,0,canvas.width,canvas.height,{"pixelFormat":"rgba-unorm8"}).data.to_py()).reshape(canvas.height,canvas.width,4),mode='RGBA') |
| w, h = im.width, im.height |
| addToOutput(f"Got image of size ({w},{h})") |
| |
| # Stupid pixel by resize |
| s = int(scale.value) |
| w, h = int(w/s), int(h/s) |
| im = im.resize((w,h)) |
| addToOutput(f"Resized by scale {s} to ({w},{h})") |
| |
| # Reduce or quantize colors |
| dither_method = Image.Dither.FLOYDSTEINBERG if dither.checked else Image.Dither.NONE |
| kmeans_colors = int(colors.value) if kmeans.checked else 0 |
| im = im.convert('RGB').quantize(colors=int(colors.value), method=int(quant.value), dither=dither_method, kmeans=kmeans_colors).convert('RGBA') |
| addToOutput(f"Quantized to {colors.value} colors") |
| |
| # Apply RGB555 |
| if rgb555.checked: |
| im = Image.fromarray(apply(np.asarray(im.convert('RGB'),dtype='uint8'),toRGB555),'RGB').convert('RGBA') |
| addToOutput(f"Applied RGB555") |
| |
| # Apply SNES crop |
| if snescrop.checked: |
| im = snes_crop(im) |
| w, h = im.width, im.height |
| addToOutput(f"Cropped to ({w},{h})") |
| |
| # Rescale post-processing |
| if rescale.checked: |
| im = im.resize((w*s,h*s)) |
| w, h = im.width, im.height |
| addToOutput(f"Rescaled to ({w},{h})") |
| |
| # Convert back to ImageData |
| im = np.asarray(im,dtype='uint8').tobytes() |
| pixels_proxy = create_proxy(im) |
| pixels_buf = pixels_proxy.getBuffer("u8clamped") |
| img_data = ImageData.new(pixels_buf.data, w, h) |
| canvas.width = w |
| canvas.height = h |
| canvasCtx.putImageData(img_data, 0, 0) |
| pixels_proxy.destroy() |
| pixels_buf.release() |
| |
| addToOutput("Done!") |
| `); |
| } catch (err) { |
| addToOutput(err); |
| } |
| } |
| </script> |
| </html> |