improved performance
This commit is contained in:
parent
16d4306763
commit
8799c606d9
14 changed files with 2013 additions and 1745 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
image-processor/target/
|
||||
image-processor/pkg/
|
||||
.DS_Store
|
||||
.idea/
|
||||
*.iml
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal 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
|
||||
14
README.md
14
README.md
|
|
@ -6,12 +6,12 @@
|
|||
|
||||
Live demo at https://shautvast.github.io/spiegel-demo/
|
||||
|
||||
* 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.
|
||||
(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,
|
||||
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.
|
||||
- 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.
|
||||
(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,
|
||||
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.
|
||||
|
||||
sample output
|
||||

|
||||

|
||||
|
|
|
|||
590
image-processor/Cargo.lock
generated
590
image-processor/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,11 +11,11 @@ image = "0.25"
|
|||
imageproc = "0.24"
|
||||
hex = "0.4"
|
||||
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 }
|
||||
wasm-bindgen = "0.2.78"
|
||||
wasm-bindgen = "0.2.100"
|
||||
include_dir = "0.7.3"
|
||||
web-sys = "0.3.55"
|
||||
web-sys = "0.3.77"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 MiB |
|
|
@ -1,11 +1,9 @@
|
|||
use image::{GenericImage, GenericImageView, Pixel, Rgba, RgbaImage};
|
||||
use photon_rs::PhotonImage;
|
||||
use std::collections::LinkedList;
|
||||
use image::imageops::FilterType;
|
||||
|
||||
mod samples;
|
||||
|
||||
use samples::log;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
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());
|
||||
unsafe {
|
||||
for y in 0..src.height() {
|
||||
log(&format!("{}", y));
|
||||
for x in 0..src.width() {
|
||||
if out.unsafe_get_pixel(x, y) == BLACK {
|
||||
let pixel = src.unsafe_get_pixel(x, y);
|
||||
|
|
@ -68,45 +65,34 @@ fn fill(
|
|||
unsafe {
|
||||
let height = sample.height();
|
||||
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) {
|
||||
points.push_back(Coord(px, py));
|
||||
points.push(Coord(px, py));
|
||||
}
|
||||
|
||||
while !points.is_empty() {
|
||||
if let Some(coord) = points.pop_back() {
|
||||
let orig_pixel = src.unsafe_get_pixel(coord.0, coord.1);
|
||||
let x = coord.0;
|
||||
let y = coord.1;
|
||||
if src.unsafe_get_pixel(x, y) != BLACK {
|
||||
if is_same(orig_pixel, color) {
|
||||
let mut xx = x;
|
||||
let mut yy = y;
|
||||
while xx >= width {
|
||||
xx -= width;
|
||||
}
|
||||
while yy >= height {
|
||||
yy -= height;
|
||||
}
|
||||
dest.unsafe_put_pixel(x, y, sample.unsafe_get_pixel(xx, yy));
|
||||
src.unsafe_put_pixel(x, y, BLACK);
|
||||
if x > 1 {
|
||||
points.push_front(Coord(x - 1, y));
|
||||
}
|
||||
if y > 1 {
|
||||
points.push_front(Coord(x, y - 1));
|
||||
}
|
||||
if x < src.width() - 1 {
|
||||
points.push_front(Coord(x + 1, y));
|
||||
}
|
||||
if y < src.height() - 1 {
|
||||
points.push_front(Coord(x, y + 1));
|
||||
}
|
||||
while let Some(coord) = points.pop() {
|
||||
let orig_pixel = src.unsafe_get_pixel(coord.0, coord.1);
|
||||
let x = coord.0;
|
||||
let y = coord.1;
|
||||
if src.unsafe_get_pixel(x, y) != BLACK {
|
||||
if is_same(orig_pixel, color) {
|
||||
let xx = x % width;
|
||||
let yy = y % height;
|
||||
dest.unsafe_put_pixel(x, y, sample.unsafe_get_pixel(xx, yy));
|
||||
src.unsafe_put_pixel(x, y, BLACK);
|
||||
if x > 1 {
|
||||
points.push(Coord(x - 1, y));
|
||||
}
|
||||
if y > 1 {
|
||||
points.push(Coord(x, y - 1));
|
||||
}
|
||||
if x < src.width() - 1 {
|
||||
points.push(Coord(x + 1, y));
|
||||
}
|
||||
if y < src.height() - 1 {
|
||||
points.push(Coord(x, y + 1));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("break");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use wasm_bindgen::prelude::*;
|
|||
use include_dir::{include_dir, Dir, DirEntry};
|
||||
|
||||
static mut SAMPLES: OnceLock<Vec<ColorSample>> = OnceLock::new();
|
||||
static LUT: OnceLock<Vec<usize>> = OnceLock::new();
|
||||
static SAMPLES_DIR: Dir = include_dir!("src/samples");
|
||||
|
||||
pub fn read_jpeg_bytes() {
|
||||
|
|
@ -14,31 +15,50 @@ pub fn read_jpeg_bytes() {
|
|||
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 {
|
||||
unsafe {
|
||||
let color_samples = SAMPLES.get_mut().unwrap();
|
||||
|
||||
let mut closest = None;
|
||||
let mut min_diff: f32 = 4294967295.0; //0xFFFFFFFF
|
||||
for sample in color_samples {
|
||||
let diff = get_distance(sample.r, sample.g, sample.b, r, g, b);
|
||||
if diff < min_diff {
|
||||
closest = Some(sample);
|
||||
min_diff = diff;
|
||||
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
|
||||
}
|
||||
|
||||
let closest = closest.unwrap();
|
||||
if closest.image.is_none() {
|
||||
let sample_image =
|
||||
load_from_memory_with_format(closest.raw_bytes, image::ImageFormat::Jpeg)
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
closest.image = Some(sample_image);
|
||||
pub fn get_closest_color(r: u8, g: u8, b: u8) -> &'static ColorSample {
|
||||
unsafe {
|
||||
let lut = LUT.get().unwrap();
|
||||
let idx = lut[((r >> 3) as usize * 32 + (g >> 3) as usize) * 32 + (b >> 3) as usize];
|
||||
let samples = SAMPLES.get_mut().unwrap();
|
||||
let sample = &mut samples[idx];
|
||||
if sample.image.is_none() {
|
||||
let img = load_from_memory_with_format(sample.raw_bytes, image::ImageFormat::Jpeg)
|
||||
.unwrap()
|
||||
.to_rgba8();
|
||||
sample.image = Some(img);
|
||||
}
|
||||
closest
|
||||
&*sample
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,10 +36,6 @@
|
|||
<canvas id="canvas" style="visibility: hidden;"></canvas>
|
||||
</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>
|
||||
<script>
|
||||
function allowDrop(event) {
|
||||
|
|
@ -50,8 +46,6 @@
|
|||
event.preventDefault();
|
||||
let dt = event.dataTransfer;
|
||||
|
||||
console.log(dt.files);
|
||||
|
||||
document.querySelector("#source-image").src = URL.createObjectURL(dt.files[0]);
|
||||
document.querySelector('#image-container').setAttribute("class", "no-border");
|
||||
document.querySelector("#slidecontainer").setAttribute("class", "slidecontainer");
|
||||
|
|
|
|||
2838
package-lock.json
generated
2838
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,8 +18,8 @@
|
|||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"style-loader": "^3.3.4",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.0.4"
|
||||
"webpack": "^5.105.4",
|
||||
"webpack-cli": "^7.0.2",
|
||||
"webpack-dev-server": "^5.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ canvas {
|
|||
border: none;
|
||||
}
|
||||
|
||||
|
||||
body h4, body h1 {
|
||||
body h4,
|
||||
body h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +54,7 @@ h4 {
|
|||
|
||||
.show {
|
||||
display: block;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.link:hover + .hide {
|
||||
|
|
@ -61,6 +62,7 @@ h4 {
|
|||
position: absolute;
|
||||
top: 10vh;
|
||||
left: 20vw;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 9.9 KiB |
131
src/js/index.js
131
src/js/index.js
|
|
@ -1,95 +1,104 @@
|
|||
import "../css/styles.css";
|
||||
import spiegel from '../img/spieghel.jpg';
|
||||
import browserwarning from '../img/browserwarning.png';
|
||||
import spiegel from "../img/spieghel.jpg";
|
||||
|
||||
let canvas, ctx, originalWidth, originalHeight, canvasTop, strokeSize=1;
|
||||
let canvas,
|
||||
ctx,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
canvasTop,
|
||||
strokeSize = 1;
|
||||
|
||||
import("../../image-processor/pkg").then((module) => {
|
||||
const slider = document.querySelector("#slider");
|
||||
slider.onchange = (event) => {
|
||||
strokeSize = parseInt(event.target.value) / 5;
|
||||
filterImage(event, true);
|
||||
};
|
||||
slider.value = 0;
|
||||
const applyButton = document.querySelector("#apply");
|
||||
const slider = document.querySelector("#slider");
|
||||
let debounceTimer;
|
||||
slider.oninput = (event) => {
|
||||
strokeSize = parseInt(event.target.value) / 5;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => filterImage(event, true), 200);
|
||||
};
|
||||
slider.value = 0;
|
||||
const applyButton = document.querySelector("#apply");
|
||||
|
||||
applyButton.onclick = (event) => {
|
||||
filterImage(event, false);
|
||||
};
|
||||
applyButton.onclick = (event) => {
|
||||
filterImage(event, false);
|
||||
};
|
||||
|
||||
function filterImage(event, preview) {
|
||||
ctx.drawImage(sourceImage, 0, 0);
|
||||
|
||||
let rust_image = module.open_image(canvas, ctx);
|
||||
const out= module.spiegel(rust_image, strokeSize, preview);
|
||||
|
||||
module.putImageData(canvas, ctx, out);
|
||||
canvas.setAttribute(
|
||||
"style",
|
||||
`visibility:visible;position:absolute;top:${canvasTop}px`,
|
||||
);
|
||||
}
|
||||
function filterImage(event, preview) {
|
||||
ctx.drawImage(sourceImage, 0, 0);
|
||||
|
||||
let rust_image = module.open_image(canvas, ctx);
|
||||
const out = module.spiegel(rust_image, strokeSize, preview);
|
||||
|
||||
module.putImageData(canvas, ctx, out);
|
||||
canvas.setAttribute(
|
||||
"style",
|
||||
`visibility:visible;position:absolute;top:${canvasTop}px`,
|
||||
);
|
||||
}
|
||||
});
|
||||
document.querySelector("#spieghel").src = spiegel;
|
||||
document.querySelector("#browserwarning").src = browserwarning;
|
||||
|
||||
const sourceImage = new Image();
|
||||
sourceImage.id = "source-image";
|
||||
let element = document.querySelector("#image-container");
|
||||
element.appendChild(sourceImage);
|
||||
sourceImage.onload = () => {
|
||||
setUpCanvas()
|
||||
setUpCanvas();
|
||||
};
|
||||
|
||||
function setUpCanvas() {
|
||||
canvas = document.querySelector("#canvas");
|
||||
originalWidth = sourceImage.width;
|
||||
originalHeight = sourceImage.height;
|
||||
canvas = document.querySelector("#canvas");
|
||||
originalWidth = sourceImage.width;
|
||||
originalHeight = sourceImage.height;
|
||||
|
||||
canvas.width = originalWidth;
|
||||
canvas.height = originalHeight;
|
||||
sourceImage.setAttribute("style", "width:50vw");
|
||||
const imageContainer = document.querySelector("#image-container");
|
||||
const rect = imageContainer.getBoundingClientRect();
|
||||
canvasTop = rect.top;
|
||||
ctx = canvas.getContext("2d");
|
||||
canvas.width = originalWidth;
|
||||
canvas.height = originalHeight;
|
||||
sourceImage.setAttribute("style", "width:50vw");
|
||||
const imageContainer = document.querySelector("#image-container");
|
||||
const rect = imageContainer.getBoundingClientRect();
|
||||
canvasTop = rect.top;
|
||||
ctx = canvas.getContext("2d");
|
||||
}
|
||||
|
||||
document.querySelector('#upload').addEventListener('change', function (e) {
|
||||
let file = e.target.files[0];
|
||||
if (file.type.match('image.*')) {
|
||||
let reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = function (e) {
|
||||
const image = document.querySelector('#source-image');
|
||||
image.src = reader.result;
|
||||
document.querySelector("#image-container").setAttribute("class", "no-border");
|
||||
document.querySelector("#upload").setAttribute("class", "hide");
|
||||
document.querySelector("#slidecontainer").setAttribute("class", "slidecontainer");
|
||||
}
|
||||
} else {
|
||||
alert("Uploaded file is not an image. Please upload an image file.");
|
||||
}
|
||||
document.querySelector("#upload").addEventListener("change", function (e) {
|
||||
let file = e.target.files[0];
|
||||
if (file.type.match("image.*")) {
|
||||
let reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = function (e) {
|
||||
const image = document.querySelector("#source-image");
|
||||
image.src = reader.result;
|
||||
document
|
||||
.querySelector("#image-container")
|
||||
.setAttribute("class", "no-border");
|
||||
document.querySelector("#upload").setAttribute("class", "hide");
|
||||
document
|
||||
.querySelector("#slidecontainer")
|
||||
.setAttribute("class", "slidecontainer");
|
||||
};
|
||||
} else {
|
||||
alert("Uploaded file is not an image. Please upload an image file.");
|
||||
}
|
||||
});
|
||||
|
||||
function allowDrop(ev) {
|
||||
ev.preventDefault();
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
function drag(ev) {
|
||||
ev.dataTransfer.setData("text", ev.target.id);
|
||||
ev.dataTransfer.setData("text", ev.target.id);
|
||||
}
|
||||
|
||||
function drop(event) {
|
||||
event.preventDefault();
|
||||
let dt = event.dataTransfer;
|
||||
event.preventDefault();
|
||||
let dt = event.dataTransfer;
|
||||
|
||||
console.log(dt.files);
|
||||
console.log(dt.files);
|
||||
|
||||
document.querySelector("#source-image").src = URL.createObjectURL(dt.files[0]);
|
||||
document.querySelector('#image-container').setAttribute("class", "no-border");
|
||||
document.querySelector("#upload").setAttribute("class", "hide");
|
||||
document.querySelector("#slidecontainer").setAttribute("class", "show");
|
||||
document.querySelector("#source-image").src = URL.createObjectURL(
|
||||
dt.files[0],
|
||||
);
|
||||
document.querySelector("#image-container").setAttribute("class", "no-border");
|
||||
document.querySelector("#upload").setAttribute("class", "hide");
|
||||
document.querySelector("#slidecontainer").setAttribute("class", "show");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue