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

|

|
||||||
|
|
|
||||||
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"
|
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 |
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
2838
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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");
|
||||||
|
|
@ -21,7 +27,7 @@ import("../../image-processor/pkg").then((module) => {
|
||||||
ctx.drawImage(sourceImage, 0, 0);
|
ctx.drawImage(sourceImage, 0, 0);
|
||||||
|
|
||||||
let rust_image = module.open_image(canvas, ctx);
|
let rust_image = module.open_image(canvas, ctx);
|
||||||
const out= module.spiegel(rust_image, strokeSize, preview);
|
const out = module.spiegel(rust_image, strokeSize, preview);
|
||||||
|
|
||||||
module.putImageData(canvas, ctx, out);
|
module.putImageData(canvas, ctx, out);
|
||||||
canvas.setAttribute(
|
canvas.setAttribute(
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue