Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use image::{DynamicImage, ImageError, ImageOutputFormat};
use image::{DynamicImage, ImageBuffer, ImageError, ImageOutputFormat, Rgb};
use std::io::Cursor;
use wasm_bindgen::prelude::*;

Expand All @@ -22,8 +22,103 @@ pub fn to_png(image: &[u8]) -> Result<Vec<u8>, JsValue> {
read_and_encode(image, |img| img)
}

#[wasm_bindgen]
pub fn clip_pixels_with_percentiles(
image_data: &[u8],
low_percentile: Option<f32>,
high_percentile: Option<f32>,
) -> Result<Vec<u8>, JsValue> {
if let Some(lp) = low_percentile {
if lp < 0.0 || lp > 100.0 {
return Err(JsValue::from_str("Percentile must be between 0 and 100"));
}
}
if let Some(hp) = high_percentile {
if hp < 0.0 || hp > 100.0 {
return Err(JsValue::from_str("Percentile must be between 0 and 100"));
}
}

if let (Some(lp), Some(hp)) = (low_percentile, high_percentile) {
if lp > hp {
return Err(JsValue::from_str("low_percentile must be <= high_percentile"));
}
}

let img = image::load_from_memory(image_data)
.map_err(|e| JsValue::from_str(&format!("Image decode error: {}", e)))?;

let rgb8: image::RgbImage = img.to_rgb8();
let (width, height) = rgb8.dimensions();
let mut pixels: Vec<u8> = rgb8.into_raw();

if low_percentile.is_none() && high_percentile.is_none() {
let buf: ImageBuffer<Rgb<u8>, Vec<u8>> =
ImageBuffer::from_raw(width, height, pixels.clone())
.ok_or_else(|| JsValue::from_str("Buffer length mismatch"))?;
let dyn_img = DynamicImage::ImageRgb8(buf);
let mut out = Cursor::new(Vec::new());
dyn_img
.write_to(&mut out, ImageOutputFormat::Png)
.map_err(|e| JsValue::from_str(&format!("PNG encode error: {}", e)))?;
return Ok(out.into_inner());
}

let mut flat_for_low: Vec<u8> = pixels.clone();
let mut flat_for_high: Vec<u8> = pixels.clone();

// Compute low_cutoff and high_cutoff
let low_cutoff: Option<u8> = percentile_cutoff(&mut flat_for_low, low_percentile);
let high_cutoff: Option<u8> = percentile_cutoff(&mut flat_for_high, high_percentile);

let low_val: u8 = low_cutoff.unwrap_or(0);
let high_val: u8 = high_cutoff.unwrap_or(255);

pixels.iter_mut().for_each(|channel_byte| {
if *channel_byte < low_val {
*channel_byte = low_val;
} else if *channel_byte > high_val {
*channel_byte = high_val;
}
});

let clip_buf: ImageBuffer<Rgb<u8>, Vec<u8>> =
ImageBuffer::from_raw(width, height, pixels)
.ok_or_else(|| JsValue::from_str("Buffer length mismatch after clipping"))?;
let dyn_img = DynamicImage::ImageRgb8(clip_buf);

let mut output = Cursor::new(Vec::new());
dyn_img
.write_to(&mut output, ImageOutputFormat::Png)
.map_err(|e| JsValue::from_str(&format!("PNG encode error: {}", e)))?;
Ok(output.into_inner())
}

// Helpers

fn percentile_cutoff(flat_pixels: &mut [u8], pct: Option<f32>) -> Option<u8> {
if let Some(p) = pct {
let p = if p < 0.0 { 0.0 } else if p > 100.0 { 100.0 } else { p };

let total = flat_pixels.len();
if total == 0 {
return None;
}
let idx_f = (p / 100.0) * (total as f32);
let mut idx = idx_f.floor() as usize;
if idx >= total {
idx = total - 1;
}

flat_pixels.select_nth_unstable(idx);

return Some(flat_pixels[idx]);
}

// If pct == None, no cutoff on that side:
None
}

fn read_and_encode(
image_bytes: &[u8],
mut transform: impl FnMut(DynamicImage) -> DynamicImage,
Expand Down
3 changes: 2 additions & 1 deletion client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="stylesheet" href="/output.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rustoscope</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
14 changes: 13 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,27 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"deploy": "vite build && gh-pages -d dist"
"typecheck": "tsc --noEmit",
"deploy": "vite build && gh-pages -d dist",
"build-css": "npx @tailwindcss/cli -i ./src/index.css -o ./public/output.css",
"build-css-watch": "npx @tailwindcss/cli -i ./src/index.css -o ./public/output.css --watch"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.8",
"preact": "^10.26.5",
"react-select": "^5.10.1"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.1",
"@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"autoprefixer": "^10.4.21",
"gh-pages": "^6.3.0",
"postcss": "^8.5.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"wasm-pack": "^0.13.1"
}
Expand Down
Loading
Loading