|
|
<!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> |