improved performance

This commit is contained in:
Shautvast 2026-03-31 20:42:13 +02:00
parent 16d4306763
commit 8799c606d9
14 changed files with 2013 additions and 1745 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
image-processor/target/
image-processor/pkg/
.DS_Store
.idea/
*.iml

34
Dockerfile Normal file
View file

@ -0,0 +1,34 @@
# Stage 1: Build
FROM rust:1.86-bookworm AS builder
# Install Node.js
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs
# Install wasm-pack
RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# Add wasm target
RUN rustup target add wasm32-unknown-unknown
WORKDIR /app
# Cache npm dependencies
COPY package*.json ./
RUN npm ci
# Cache Rust dependencies
COPY image-processor/Cargo.toml image-processor/Cargo.lock* ./image-processor/
RUN mkdir -p image-processor/src && echo "pub fn main(){}" > image-processor/src/lib.rs \
&& cd image-processor && cargo fetch
# Copy full source and build
COPY . .
RUN npm run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

View file

@ -6,12 +6,12 @@
Live demo at https://shautvast.github.io/spiegel-demo/ Live demo at https://shautvast.github.io/spiegel-demo/
* Sorry for the poor performance, especially on larger images. - Sorry for the poor performance, especially on larger images.
* It uses the median image filter from image.rs. That in itself can be pretty slow. - It uses the median image filter from image.rs. That in itself can be pretty slow.
(Although the implementation uses a _sliding window histogram_, which I think is pretty nifty). (Although the implementation uses a _sliding window histogram_, which I think is pretty nifty).
* And on top of that, I created this custom flood fill algorithm, - And on top of that, I created this custom flood fill algorithm,
that instead of filling it with with a single color, looks up a sample from the that instead of filling it with with a single color, looks up a sample from the
Spiegel book (that has a corresponding color) and takes the pixels from that. Spiegel book (that has a corresponding color) and takes the pixels from that.
sample output sample output
![sample](https://github.com/shautvast/spiegel-web/blob/main/unsplash.png) ![sample](unsplash.png)

File diff suppressed because it is too large Load diff

View file

@ -11,11 +11,11 @@ image = "0.25"
imageproc = "0.24" imageproc = "0.24"
hex = "0.4" hex = "0.4"
anyhow = "1.0" anyhow = "1.0"
photon-rs = { version = "0.3.2", default-features = false } photon-rs = { version = "0.3.2" }
console_error_panic_hook = { version = "0.1.7", optional = true } console_error_panic_hook = { version = "0.1.7", optional = true }
wasm-bindgen = "0.2.78" wasm-bindgen = "0.2.100"
include_dir = "0.7.3" include_dir = "0.7.3"
web-sys = "0.3.55" web-sys = "0.3.77"
[lib] [lib]
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 MiB

View file

@ -1,11 +1,9 @@
use image::{GenericImage, GenericImageView, Pixel, Rgba, RgbaImage}; use image::{GenericImage, GenericImageView, Pixel, Rgba, RgbaImage};
use photon_rs::PhotonImage; use photon_rs::PhotonImage;
use std::collections::LinkedList;
use image::imageops::FilterType; use image::imageops::FilterType;
mod samples; mod samples;
use samples::log;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
static BLACK: Rgba<u8> = Rgba([0, 0, 0, 0]); static BLACK: Rgba<u8> = Rgba([0, 0, 0, 0]);
@ -39,7 +37,6 @@ fn apply_samples_to_image(src: &mut RgbaImage) -> RgbaImage {
let mut out = RgbaImage::new(src.width(), src.height()); let mut out = RgbaImage::new(src.width(), src.height());
unsafe { unsafe {
for y in 0..src.height() { for y in 0..src.height() {
log(&format!("{}", y));
for x in 0..src.width() { for x in 0..src.width() {
if out.unsafe_get_pixel(x, y) == BLACK { if out.unsafe_get_pixel(x, y) == BLACK {
let pixel = src.unsafe_get_pixel(x, y); let pixel = src.unsafe_get_pixel(x, y);
@ -68,46 +65,35 @@ fn fill(
unsafe { unsafe {
let height = sample.height(); let height = sample.height();
let width = sample.width(); let width = sample.width();
let mut points = LinkedList::new(); let mut points: Vec<Coord> = Vec::new();
if is_same(src.unsafe_get_pixel(px, py), color) { if is_same(src.unsafe_get_pixel(px, py), color) {
points.push_back(Coord(px, py)); points.push(Coord(px, py));
} }
while !points.is_empty() { while let Some(coord) = points.pop() {
if let Some(coord) = points.pop_back() {
let orig_pixel = src.unsafe_get_pixel(coord.0, coord.1); let orig_pixel = src.unsafe_get_pixel(coord.0, coord.1);
let x = coord.0; let x = coord.0;
let y = coord.1; let y = coord.1;
if src.unsafe_get_pixel(x, y) != BLACK { if src.unsafe_get_pixel(x, y) != BLACK {
if is_same(orig_pixel, color) { if is_same(orig_pixel, color) {
let mut xx = x; let xx = x % width;
let mut yy = y; let yy = y % height;
while xx >= width {
xx -= width;
}
while yy >= height {
yy -= height;
}
dest.unsafe_put_pixel(x, y, sample.unsafe_get_pixel(xx, yy)); dest.unsafe_put_pixel(x, y, sample.unsafe_get_pixel(xx, yy));
src.unsafe_put_pixel(x, y, BLACK); src.unsafe_put_pixel(x, y, BLACK);
if x > 1 { if x > 1 {
points.push_front(Coord(x - 1, y)); points.push(Coord(x - 1, y));
} }
if y > 1 { if y > 1 {
points.push_front(Coord(x, y - 1)); points.push(Coord(x, y - 1));
} }
if x < src.width() - 1 { if x < src.width() - 1 {
points.push_front(Coord(x + 1, y)); points.push(Coord(x + 1, y));
} }
if y < src.height() - 1 { if y < src.height() - 1 {
points.push_front(Coord(x, y + 1)); points.push(Coord(x, y + 1));
} }
} }
} }
} else {
println!("break");
break;
}
} }
} }
} }

View file

@ -5,6 +5,7 @@ use wasm_bindgen::prelude::*;
use include_dir::{include_dir, Dir, DirEntry}; use include_dir::{include_dir, Dir, DirEntry};
static mut SAMPLES: OnceLock<Vec<ColorSample>> = OnceLock::new(); static mut SAMPLES: OnceLock<Vec<ColorSample>> = OnceLock::new();
static LUT: OnceLock<Vec<usize>> = OnceLock::new();
static SAMPLES_DIR: Dir = include_dir!("src/samples"); static SAMPLES_DIR: Dir = include_dir!("src/samples");
pub fn read_jpeg_bytes() { pub fn read_jpeg_bytes() {
@ -14,31 +15,50 @@ pub fn read_jpeg_bytes() {
read_color_samples().unwrap() read_color_samples().unwrap()
}); });
} }
let samples = unsafe { SAMPLES.get().unwrap() };
LUT.get_or_init(|| {
log("building color LUT");
init_lut(samples)
});
} }
pub fn get_closest_color<'a>(r: u8, g: u8, b: u8) -> &'static ColorSample { fn init_lut(samples: &[ColorSample]) -> Vec<usize> {
let mut lut = vec![0usize; 32 * 32 * 32];
for ri in 0u8..32 {
for gi in 0u8..32 {
for bi in 0u8..32 {
let r = (ri << 3) | 4;
let g = (gi << 3) | 4;
let b = (bi << 3) | 4;
let mut best_idx = 0;
let mut best_dist = f32::MAX;
for (i, s) in samples.iter().enumerate() {
let d = get_distance(s.r, s.g, s.b, r, g, b);
if d < best_dist {
best_dist = d;
best_idx = i;
}
}
lut[(ri as usize * 32 + gi as usize) * 32 + bi as usize] = best_idx;
}
}
}
lut
}
pub fn get_closest_color(r: u8, g: u8, b: u8) -> &'static ColorSample {
unsafe { unsafe {
let color_samples = SAMPLES.get_mut().unwrap(); let lut = LUT.get().unwrap();
let idx = lut[((r >> 3) as usize * 32 + (g >> 3) as usize) * 32 + (b >> 3) as usize];
let mut closest = None; let samples = SAMPLES.get_mut().unwrap();
let mut min_diff: f32 = 4294967295.0; //0xFFFFFFFF let sample = &mut samples[idx];
for sample in color_samples { if sample.image.is_none() {
let diff = get_distance(sample.r, sample.g, sample.b, r, g, b); let img = load_from_memory_with_format(sample.raw_bytes, image::ImageFormat::Jpeg)
if diff < min_diff {
closest = Some(sample);
min_diff = diff;
}
}
let closest = closest.unwrap();
if closest.image.is_none() {
let sample_image =
load_from_memory_with_format(closest.raw_bytes, image::ImageFormat::Jpeg)
.unwrap() .unwrap()
.to_rgba8(); .to_rgba8();
closest.image = Some(sample_image); sample.image = Some(img);
} }
closest &*sample
} }
} }

View file

@ -36,10 +36,6 @@
<canvas id="canvas" style="visibility: hidden;"></canvas> <canvas id="canvas" style="visibility: hidden;"></canvas>
</div> </div>
</div> </div>
<h4>(Painting can take long, be patient</h4>
<h4>all image processing is done in your browser).</h4>
<h5>And ignore all the warnings! It will be all right.</h5>
<img id="browserwarning" src="">
</div> </div>
<script> <script>
function allowDrop(event) { function allowDrop(event) {
@ -50,8 +46,6 @@
event.preventDefault(); event.preventDefault();
let dt = event.dataTransfer; let dt = event.dataTransfer;
console.log(dt.files);
document.querySelector("#source-image").src = URL.createObjectURL(dt.files[0]); document.querySelector("#source-image").src = URL.createObjectURL(dt.files[0]);
document.querySelector('#image-container').setAttribute("class", "no-border"); document.querySelector('#image-container').setAttribute("class", "no-border");
document.querySelector("#slidecontainer").setAttribute("class", "slidecontainer"); document.querySelector("#slidecontainer").setAttribute("class", "slidecontainer");

2838
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -18,8 +18,8 @@
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.0", "html-webpack-plugin": "^5.6.0",
"style-loader": "^3.3.4", "style-loader": "^3.3.4",
"webpack": "^5.91.0", "webpack": "^5.105.4",
"webpack-cli": "^5.1.4", "webpack-cli": "^7.0.2",
"webpack-dev-server": "^5.0.4" "webpack-dev-server": "^5.2.3"
} }
} }

View file

@ -22,8 +22,8 @@ canvas {
border: none; border: none;
} }
body h4,
body h4, body h1 { body h1 {
text-align: center; text-align: center;
} }
@ -54,6 +54,7 @@ h4 {
.show { .show {
display: block; display: block;
z-index: 10;
} }
.link:hover + .hide { .link:hover + .hide {
@ -61,6 +62,7 @@ h4 {
position: absolute; position: absolute;
top: 10vh; top: 10vh;
left: 20vw; left: 20vw;
z-index: 10;
} }
li:hover { li:hover {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

View file

@ -1,14 +1,20 @@
import "../css/styles.css"; import "../css/styles.css";
import spiegel from '../img/spieghel.jpg'; import spiegel from "../img/spieghel.jpg";
import browserwarning from '../img/browserwarning.png';
let canvas, ctx, originalWidth, originalHeight, canvasTop, strokeSize=1; let canvas,
ctx,
originalWidth,
originalHeight,
canvasTop,
strokeSize = 1;
import("../../image-processor/pkg").then((module) => { import("../../image-processor/pkg").then((module) => {
const slider = document.querySelector("#slider"); const slider = document.querySelector("#slider");
slider.onchange = (event) => { let debounceTimer;
slider.oninput = (event) => {
strokeSize = parseInt(event.target.value) / 5; strokeSize = parseInt(event.target.value) / 5;
filterImage(event, true); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => filterImage(event, true), 200);
}; };
slider.value = 0; slider.value = 0;
const applyButton = document.querySelector("#apply"); const applyButton = document.querySelector("#apply");
@ -29,18 +35,15 @@ import("../../image-processor/pkg").then((module) => {
`visibility:visible;position:absolute;top:${canvasTop}px`, `visibility:visible;position:absolute;top:${canvasTop}px`,
); );
} }
}); });
document.querySelector("#spieghel").src = spiegel; document.querySelector("#spieghel").src = spiegel;
document.querySelector("#browserwarning").src = browserwarning;
const sourceImage = new Image(); const sourceImage = new Image();
sourceImage.id = "source-image"; sourceImage.id = "source-image";
let element = document.querySelector("#image-container"); let element = document.querySelector("#image-container");
element.appendChild(sourceImage); element.appendChild(sourceImage);
sourceImage.onload = () => { sourceImage.onload = () => {
setUpCanvas() setUpCanvas();
}; };
function setUpCanvas() { function setUpCanvas() {
@ -57,18 +60,22 @@ function setUpCanvas() {
ctx = canvas.getContext("2d"); ctx = canvas.getContext("2d");
} }
document.querySelector('#upload').addEventListener('change', function (e) { document.querySelector("#upload").addEventListener("change", function (e) {
let file = e.target.files[0]; let file = e.target.files[0];
if (file.type.match('image.*')) { if (file.type.match("image.*")) {
let reader = new FileReader(); let reader = new FileReader();
reader.readAsDataURL(file); reader.readAsDataURL(file);
reader.onload = function (e) { reader.onload = function (e) {
const image = document.querySelector('#source-image'); const image = document.querySelector("#source-image");
image.src = reader.result; image.src = reader.result;
document.querySelector("#image-container").setAttribute("class", "no-border"); document
.querySelector("#image-container")
.setAttribute("class", "no-border");
document.querySelector("#upload").setAttribute("class", "hide"); document.querySelector("#upload").setAttribute("class", "hide");
document.querySelector("#slidecontainer").setAttribute("class", "slidecontainer"); document
} .querySelector("#slidecontainer")
.setAttribute("class", "slidecontainer");
};
} else { } else {
alert("Uploaded file is not an image. Please upload an image file."); alert("Uploaded file is not an image. Please upload an image file.");
} }
@ -88,8 +95,10 @@ function drop(event) {
console.log(dt.files); console.log(dt.files);
document.querySelector("#source-image").src = URL.createObjectURL(dt.files[0]); document.querySelector("#source-image").src = URL.createObjectURL(
document.querySelector('#image-container').setAttribute("class", "no-border"); dt.files[0],
);
document.querySelector("#image-container").setAttribute("class", "no-border");
document.querySelector("#upload").setAttribute("class", "hide"); document.querySelector("#upload").setAttribute("class", "hide");
document.querySelector("#slidecontainer").setAttribute("class", "show"); document.querySelector("#slidecontainer").setAttribute("class", "show");
} }