From 018e91dcb3754164a72636c339754437dc69cb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Thu, 5 Jun 2025 20:18:28 +0200 Subject: [PATCH 01/15] feat: add option to remove hot pixels with specified percentile --- api/src/lib.rs | 45 +++++++++++++++++++- client/src/images/ImageConverter.jsx | 63 ++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index ab4956b..66a928f 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,4 +1,4 @@ -use image::{DynamicImage, ImageError, ImageOutputFormat}; +use image::{DynamicImage, ImageBuffer, ImageError, ImageOutputFormat, Rgb}; use std::io::Cursor; use wasm_bindgen::prelude::*; @@ -22,6 +22,49 @@ pub fn to_png(image: &[u8]) -> Result, JsValue> { read_and_encode(image, |img| img) } +#[wasm_bindgen] +pub fn remove_hot_pixels_with_percentile( + image_data: &[u8], + percentile: f32, +) -> Result, JsValue> { + let img = image::load_from_memory(image_data) + .map_err(|e| JsValue::from_str(&format!("Image decode error: {}", e)))?; + + let rgb = img.to_rgb8(); + let (width, height) = rgb.dimensions(); + let mut pixels = rgb.into_raw(); + + let total = pixels.len(); + if total == 0 { + return Err(JsValue::from_str("Empty image buffer")); + } + if !(0.0..=100.0).contains(&percentile) { + return Err(JsValue::from_str("Percentile must be between 0 and 100")); + } + let mut flat = pixels.clone(); + let idx = ((percentile / 100.0) * (total as f32)).floor() as usize; + let idx = if idx >= total { total - 1 } else { idx }; + flat.select_nth_unstable(idx); + let cutoff = flat[idx]; + + for v in pixels.iter_mut() { + if *v > cutoff { + *v = cutoff; + } + } + + let clamped_buf: ImageBuffer, Vec> = + ImageBuffer::from_raw(width, height, pixels) + .ok_or_else(|| JsValue::from_str("Buffer length mismatch"))?; + let dyn_img = DynamicImage::ImageRgb8(clamped_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 read_and_encode( diff --git a/client/src/images/ImageConverter.jsx b/client/src/images/ImageConverter.jsx index 4b1453f..b69e08e 100644 --- a/client/src/images/ImageConverter.jsx +++ b/client/src/images/ImageConverter.jsx @@ -2,7 +2,7 @@ import "./ImageConverter.css"; import { useState } from "preact/hooks"; import Select from "react-select"; import { useWasm } from "../useWasm.js"; -import { to_grayscale, invert_colors, to_png } from "../wasm/wasm_api.js"; +import { to_grayscale, invert_colors, to_png, remove_hot_pixels_with_percentile } from "../wasm/wasm_api.js"; import ImagePreview from "./ImagePreview.jsx"; const options = [ @@ -43,6 +43,8 @@ const ImageConverter = () => { const [previesAspectRatios, setPreviesAspectRatios] = useState(16 / 10); const [conversionType, setConversionType] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const [percentile, setPercentile] = useState(95); + const [removeHotPixels, setRemoveHotPixels] = useState(false); const handleUpload = async (e) => { const file = e.target.files?.[0]; @@ -69,9 +71,29 @@ const ImageConverter = () => { const handleConvert = async () => { if (!rawBytes || !wasmReady) return; - const output = convert(rawBytes, conversionType.value); - const blob = new Blob([output], { type: "image/png" }); - setImgResult(URL.createObjectURL(blob)); + try { + const convertedBytes = convert(rawBytes, conversionType.value); + + let finalBytes = convertedBytes; + if (removeHotPixels) { + try { + finalBytes = remove_hot_pixels_with_percentile( + convertedBytes, + percentile + ); + } catch (hpErr) { + console.error(`Hot-pixel removal error: ${hpErr}`); + setErrorMessage(`Hot-pixel removal error: ${hpErr}`); + finalBytes = convertedBytes; + } + } + + const blob = new Blob([finalBytes], { type: "image/png" }); + setImgResult(URL.createObjectURL(blob)); + } catch (convErr) { + console.error(`Conversion error: ${convErr}`); + setErrorMessage(`Conversion error: ${convErr}`); + } }; return ( @@ -102,6 +124,39 @@ const ImageConverter = () => { }), }} /> + + + { + const val = e.currentTarget.valueAsNumber; + if (!Number.isNaN(val) && val >= 0 && val <= 100) { + setPercentile(val); + } + }} + style={{ + width: '80px', + borderRadius: '6px', + borderColor: '#ccc', + padding: '4px', + }} + /> + + + setRemoveHotPixels(e.currentTarget.checked)} + className='form-checkbox h-4 w-4 text-blue-600 rounded mr-2 focus:ring-blue-500' + /> + Remove hot-pixels + +
+ + { + const val = e.currentTarget.valueAsNumber; + if (!Number.isNaN(val) && val >= 0 && val <= 100) { + setPercentile(val); + } + }} + className='w-24 px-3 py-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm' + /> +
+ + + + ); +}; + +export default ImageConverter; diff --git a/client/src/components/images/ImagePreview.css b/client/src/components/images/ImagePreview.css new file mode 100644 index 0000000..45108e4 --- /dev/null +++ b/client/src/components/images/ImagePreview.css @@ -0,0 +1,34 @@ +/*.image-preview {*/ +/* width: 100%;*/ +/* display: flex;*/ +/* flex-direction: column;*/ +/* align-items: center;*/ +/*}*/ + +/*.image-preview h2 {*/ +/* margin-bottom: 1rem;*/ +/* font-size: 1rem;*/ +/* text-align: center;*/ +/*}*/ + +/*.image-preview img,*/ +/*.empty-preview {*/ +/* width: 100%;*/ +/* object-fit: contain;*/ +/* border-radius: 0.5rem;*/ +/* background-color: #f9f9f9;*/ +/* box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);*/ +/*}*/ + +/*.empty-preview {*/ +/* display: flex;*/ +/* justify-content: center;*/ +/* align-items: center;*/ +/* border: 2px dashed #ddd;*/ +/* background-color: #f9f9f9;*/ +/*}*/ + +/*.error-message {*/ +/* margin-top: 20px;*/ +/* color: red;*/ +/*}*/ diff --git a/client/src/images/ImagePreview.jsx b/client/src/components/images/ImagePreview.jsx similarity index 61% rename from client/src/images/ImagePreview.jsx rename to client/src/components/images/ImagePreview.jsx index 5f14e19..3e8fbc0 100644 --- a/client/src/images/ImagePreview.jsx +++ b/client/src/components/images/ImagePreview.jsx @@ -34,16 +34,16 @@ const ImagePreview = ({ }; return ( -
- {header &&

{header}

} +
+ {header &&
{header}
} {imageUrl ? ( - Preview + Preview ) : ( -
-

{emptyText || "No image available"}

+
+

{emptyText || "No image available"}

)} - {error && {error}} + {error && {error}}
); }; diff --git a/client/src/useWasm.js b/client/src/hooks/useWasm.js similarity index 91% rename from client/src/useWasm.js rename to client/src/hooks/useWasm.js index 6c457de..0aa21a4 100644 --- a/client/src/useWasm.js +++ b/client/src/hooks/useWasm.js @@ -1,5 +1,5 @@ import { useEffect, useState } from "preact/hooks"; -import init from "./wasm/wasm_api.js"; +import init from "../wasm/wasm_api.js"; export function useWasm() { const [wasmReady, setWasmReady] = useState(false); diff --git a/client/src/images/ImageConverter.css b/client/src/images/ImageConverter.css deleted file mode 100644 index a162e7c..0000000 --- a/client/src/images/ImageConverter.css +++ /dev/null @@ -1,99 +0,0 @@ -.image-converter { - max-width: 100%; - margin: 0 auto; - padding: 2rem; - background: white; - border-radius: 1rem; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); -} - -.image-converter-header { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - gap: 1rem; - margin-bottom: 2rem; -} - -.image-converter-header h1 { - font-size: 2rem; - flex: 1 1 auto; - margin: 0; - color: #333; -} - -.image-converter-controls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75rem; -} - -.image-converter-controls input[type="file"] { - display: none; -} - -.image-converter-controls button { - padding: 0.5rem 1.25rem; - font-size: 0.95rem; - background-color: #007bff; - border: none; - border-radius: 6px; - color: white; - cursor: pointer; - transition: background-color 0.3s ease; -} - -.image-converter-controls button:hover:not(:disabled) { - background-color: #0056b3; -} - -.image-converter-controls button:disabled { - background-color: #ccc; - cursor: not-allowed; -} - -.conversion-select { - padding: 0.5rem 1.25rem; - font-size: 0.95rem; - border-radius: 6px; - border: 1px solid #ccc; - background-color: white; - color: #333; - cursor: pointer; - transition: border-color 0.3s ease, box-shadow 0.3s ease; -} - -.conversion-select:focus { - outline: none; - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); -} - -.conversion-select:hover { - border-color: #888; -} - -.image-preview-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1.5rem; - margin-top: 2rem; - margin-bottom: 2rem; -} - -.upload-label { - display: inline-block; - padding: 0.5rem 1.25rem; - background-color: #28a745; - color: white; - border-radius: 6px; - font-size: 0.95rem; - cursor: pointer; - transition: background-color 0.3s ease; -} - -.upload-label:hover { - background-color: #218838; -} diff --git a/client/src/images/ImageConverter.jsx b/client/src/images/ImageConverter.jsx deleted file mode 100644 index c197bc1..0000000 --- a/client/src/images/ImageConverter.jsx +++ /dev/null @@ -1,199 +0,0 @@ -import "./ImageConverter.css"; -import { useState } from "preact/hooks"; -import Select from "react-select"; -import { useWasm } from "../useWasm.js"; -import { to_grayscale, invert_colors, to_png, remove_hot_pixels_with_percentile } from "../wasm/wasm_api.js"; -import ImagePreview from "./ImagePreview.jsx"; - -const options = [ - { value: "grayscale", label: "Grayscale" }, - { value: "invert", label: "Invert" }, -]; - -const convert = (rawBytes, conversionType) => { - switch (conversionType) { - case "grayscale": - return to_grayscale(rawBytes); - case "invert": - return invert_colors(rawBytes); - default: - throw new Error("Unsupported conversion type"); - } -} - -const processBytes = (fileType, bytes) => { - switch (fileType) { - case "image/png": - return bytes; - case "image/jpg": - return bytes; - case "image/tiff": - return to_png(bytes); - default: - throw new Error("Unsupported image type. Supported: [png, jpg, tiff]"); - } -} - -const ImageConverter = () => { - const { wasmReady } = useWasm(); - - const [imgSrc, setImgSrc] = useState(null); - const [imgResult, setImgResult] = useState(null); - const [rawBytes, setRawBytes] = useState(null); - const [previesAspectRatios, setPreviesAspectRatios] = useState(16 / 10); - const [conversionType, setConversionType] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const [percentile, setPercentile] = useState(95); - const [removeHotPixels, setRemoveHotPixels] = useState(false); - - const handleUpload = async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - - const arrayBuffer = await file.arrayBuffer(); - const bytes = new Uint8Array(arrayBuffer); - - setErrorMessage(null); - - try { - const processedBytes = processBytes(file.type, bytes); - setRawBytes(processedBytes); - const blob = new Blob([processedBytes]); - setImgSrc(URL.createObjectURL(blob)); - } catch (err) { - console.error(`Upload error: ${err}`) - setErrorMessage(`Upload error: ${err}`) - setImgSrc(null); - setRawBytes(null); - } - }; - - const handleConvert = async () => { - if (!rawBytes || !wasmReady) return; - - try { - const convertedBytes = convert(rawBytes, conversionType.value); - - let finalBytes = convertedBytes; - if (removeHotPixels) { - try { - finalBytes = remove_hot_pixels_with_percentile( - convertedBytes, - percentile - ); - } catch (hpErr) { - console.error(`Hot-pixel removal error: ${hpErr}`); - setErrorMessage(`Hot-pixel removal error: ${hpErr}`); - finalBytes = convertedBytes; - } - } - - const blob = new Blob([finalBytes], { type: "image/png" }); - setImgResult(URL.createObjectURL(blob)); - } catch (convErr) { - console.error(`Conversion error: ${convErr}`); - setErrorMessage(`Conversion error: ${convErr}`); - } - }; - - return ( -
-
-

Image Converter

-
- - setRemoveHotPixels(e.currentTarget.checked)} - style={{ marginRight: '4px' }} - /> - Remove hot-pixels - - - { - const val = e.currentTarget.valueAsNumber; - if (!Number.isNaN(val) && val >= 0 && val <= 100) { - setPercentile(val); - } - }} - style={{ - width: '80px', - borderRadius: '6px', - borderColor: '#ccc', - padding: '4px', - }} - /> - - -
-
-
- - -
-
- ); -}; - -export default ImageConverter; diff --git a/client/src/images/ImagePreview.css b/client/src/images/ImagePreview.css deleted file mode 100644 index 90f9ccd..0000000 --- a/client/src/images/ImagePreview.css +++ /dev/null @@ -1,34 +0,0 @@ -.image-preview { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; -} - -.image-preview h2 { - margin-bottom: 1rem; - font-size: 1rem; - text-align: center; -} - -.image-preview img, -.empty-preview { - width: 100%; - object-fit: contain; - border-radius: 0.5rem; - background-color: #f9f9f9; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); -} - -.empty-preview { - display: flex; - justify-content: center; - align-items: center; - border: 2px dashed #ddd; - background-color: #f9f9f9; -} - -.error-message { - margin-top: 20px; - color: red; -} diff --git a/client/src/index.css b/client/src/index.css index 0870c42..bd6213e 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,4 +1,3 @@ @tailwind base; @tailwind components; -@tailwind utilities; -/*@import "tailwindcss";*/ \ No newline at end of file +@tailwind utilities; \ No newline at end of file diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 85cc330..6fb6968 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -2,7 +2,7 @@ export default { content: [ "./index.html", - "./src/**/*.{html,js,jsx}" + "./src/**/*.{html,js,jsx,ts,tsx}" ], theme: { extend: {}, diff --git a/client/vite.config.js b/client/vite.config.ts similarity index 100% rename from client/vite.config.js rename to client/vite.config.ts From 1875aff1e42164fdea744b78144b377222d11e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Thu, 5 Jun 2025 23:22:52 +0200 Subject: [PATCH 05/15] feat: convert useWasm hook to ts --- client/src/components/images/ImageConverter.jsx | 2 +- client/src/hooks/{useWasm.js => useWasm.ts} | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename client/src/hooks/{useWasm.js => useWasm.ts} (68%) diff --git a/client/src/components/images/ImageConverter.jsx b/client/src/components/images/ImageConverter.jsx index 58a5d80..6ffe9d5 100644 --- a/client/src/components/images/ImageConverter.jsx +++ b/client/src/components/images/ImageConverter.jsx @@ -1,7 +1,7 @@ import "./ImageConverter.css"; import { useState } from "preact/hooks"; import Select from "react-select"; -import { useWasm } from "../../hooks/useWasm.js"; +import { useWasm } from "../../hooks/useWasm.ts"; import { to_grayscale, invert_colors, to_png, remove_hot_pixels_with_percentile } from "../../wasm/wasm_api.js"; import ImagePreview from "./ImagePreview.jsx"; diff --git a/client/src/hooks/useWasm.js b/client/src/hooks/useWasm.ts similarity index 68% rename from client/src/hooks/useWasm.js rename to client/src/hooks/useWasm.ts index 0aa21a4..47c6a18 100644 --- a/client/src/hooks/useWasm.js +++ b/client/src/hooks/useWasm.ts @@ -2,15 +2,15 @@ import { useEffect, useState } from "preact/hooks"; import init from "../wasm/wasm_api.js"; export function useWasm() { - const [wasmReady, setWasmReady] = useState(false); - const [wasmError, setWasmError] = useState(null); + const [wasmReady, setWasmReady] = useState(false); + const [wasmError, setWasmError] = useState(null); useEffect(() => { const loadWasm = async () => { try { await init(); setWasmReady(true); - } catch (error) { + } catch (error: any) { setWasmError(error); } }; From e764895497dccceb0f793283c15ca4f23dc332fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 6 Jun 2025 10:08:14 +0200 Subject: [PATCH 06/15] refactor: remove unused css files after adding tailwind --- client/public/output.css | 131 ------------------ .../src/components/images/ImageConverter.css | 99 ------------- client/src/components/images/ImagePreview.css | 34 ----- 3 files changed, 264 deletions(-) delete mode 100644 client/src/components/images/ImageConverter.css delete mode 100644 client/src/components/images/ImagePreview.css diff --git a/client/public/output.css b/client/public/output.css index ac862ad..6da6dda 100644 --- a/client/public/output.css +++ b/client/public/output.css @@ -9,42 +9,18 @@ .hidden { display: none; } -.inline { - display: inline; -} -.h-fit { - height: fit-content; -} .h-full { height: 100%; } .h-screen { height: 100vh; } -.min-h-screen { - min-height: 100vh; -} -.w-1\/3 { - width: calc(1/3 * 100%); -} -.w-1\/4 { - width: calc(1/4 * 100%); -} .w-1\/5 { width: calc(1/5 * 100%); } .w-2\/5 { width: calc(2/5 * 100%); } -.w-\[10px\] { - width: 10px; -} -.w-\[100px\] { - width: 100px; -} -.w-\[125px\] { - width: 125px; -} .w-\[150px\] { width: 150px; } @@ -54,9 +30,6 @@ .flex-grow { flex-grow: 1; } -.transform { - transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); -} .cursor-not-allowed { cursor: not-allowed; } @@ -69,18 +42,12 @@ .flex-row { flex-direction: row; } -.flex-wrap { - flex-wrap: wrap; -} .items-center { align-items: center; } .items-start { align-items: flex-start; } -.justify-between { - justify-content: space-between; -} .justify-center { justify-content: center; } @@ -95,18 +62,10 @@ border-style: var(--tw-border-style); border-width: 2px; } -.border-r-4 { - border-right-style: var(--tw-border-style); - border-right-width: 4px; -} .border-dashed { --tw-border-style: dashed; border-style: dashed; } -.bg-gradient-to-r { - --tw-gradient-position: to right in oklab; - background-image: linear-gradient(var(--tw-gradient-stops)); -} .object-contain { object-fit: contain; } @@ -116,10 +75,6 @@ .opacity-50 { opacity: 50%; } -.outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; -} .grayscale { --tw-grayscale: grayscale(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); @@ -128,74 +83,17 @@ --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } -.transition { - transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; - transition-timing-function: var(--tw-ease, ease); - transition-duration: var(--tw-duration, 0s); -} -.transition-all { - transition-property: all; - transition-timing-function: var(--tw-ease, ease); - transition-duration: var(--tw-duration, 0s); -} -.duration-300 { - --tw-duration: 300ms; - transition-duration: 300ms; -} -.hover\:scale-105 { - &:hover { - @media (hover: hover) { - --tw-scale-x: 105%; - --tw-scale-y: 105%; - --tw-scale-z: 105%; - scale: var(--tw-scale-x) var(--tw-scale-y); - } - } -} .focus\:outline-none { &:focus { --tw-outline-style: none; outline-style: none; } } -.active\:scale-95 { - &:active { - --tw-scale-x: 95%; - --tw-scale-y: 95%; - --tw-scale-z: 95%; - scale: var(--tw-scale-x) var(--tw-scale-y); - } -} -@property --tw-rotate-x { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-y { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-z { - syntax: "*"; - inherits: false; -} -@property --tw-skew-x { - syntax: "*"; - inherits: false; -} -@property --tw-skew-y { - syntax: "*"; - inherits: false; -} @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -249,35 +147,10 @@ syntax: "*"; inherits: false; } -@property --tw-duration { - syntax: "*"; - inherits: false; -} -@property --tw-scale-x { - syntax: "*"; - inherits: false; - initial-value: 1; -} -@property --tw-scale-y { - syntax: "*"; - inherits: false; - initial-value: 1; -} -@property --tw-scale-z { - syntax: "*"; - inherits: false; - initial-value: 1; -} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { - --tw-rotate-x: initial; - --tw-rotate-y: initial; - --tw-rotate-z: initial; - --tw-skew-x: initial; - --tw-skew-y: initial; --tw-border-style: solid; - --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; @@ -291,10 +164,6 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; - --tw-duration: initial; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-scale-z: 1; } } } diff --git a/client/src/components/images/ImageConverter.css b/client/src/components/images/ImageConverter.css deleted file mode 100644 index 0d7c9a5..0000000 --- a/client/src/components/images/ImageConverter.css +++ /dev/null @@ -1,99 +0,0 @@ -/*.image-converter {*/ -/* max-width: 100%;*/ -/* margin: 0 auto;*/ -/* padding: 2rem;*/ -/* background: white;*/ -/* border-radius: 1rem;*/ -/* box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);*/ -/*}*/ - -/*.image-converter-header {*/ -/* display: flex;*/ -/* flex-wrap: wrap;*/ -/* justify-content: space-between;*/ -/* align-items: center;*/ -/* gap: 1rem;*/ -/* margin-bottom: 2rem;*/ -/*}*/ - -/*.image-converter-header h1 {*/ -/* font-size: 2rem;*/ -/* flex: 1 1 auto;*/ -/* margin: 0;*/ -/* color: #333;*/ -/*}*/ - -/*.image-converter-controls {*/ -/* display: flex;*/ -/* flex-wrap: wrap;*/ -/* align-items: center;*/ -/* gap: 0.75rem;*/ -/*}*/ - -/*.image-converter-controls input[type="file"] {*/ -/* display: none;*/ -/*}*/ - -/*.image-converter-controls button {*/ -/* padding: 0.5rem 1.25rem;*/ -/* font-size: 0.95rem;*/ -/* background-color: #007bff;*/ -/* border: none;*/ -/* border-radius: 6px;*/ -/* color: white;*/ -/* cursor: pointer;*/ -/* transition: background-color 0.3s ease;*/ -/*}*/ - -/*.image-converter-controls button:hover:not(:disabled) {*/ -/* background-color: #0056b3;*/ -/*}*/ - -/*.image-converter-controls button:disabled {*/ -/* background-color: #ccc;*/ -/* cursor: not-allowed;*/ -/*}*/ - -/*.conversion-select {*/ -/* padding: 0.5rem 1.25rem;*/ -/* font-size: 0.95rem;*/ -/* border-radius: 6px;*/ -/* border: 1px solid #ccc;*/ -/* background-color: white;*/ -/* color: #333;*/ -/* cursor: pointer;*/ -/* transition: border-color 0.3s ease, box-shadow 0.3s ease;*/ -/*}*/ - -/*.conversion-select:focus {*/ -/* outline: none;*/ -/* border-color: #007bff;*/ -/* box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);*/ -/*}*/ - -/*.conversion-select:hover {*/ -/* border-color: #888;*/ -/*}*/ - -/*.image-preview-container {*/ -/* display: grid;*/ -/* grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));*/ -/* gap: 1.5rem;*/ -/* margin-top: 2rem;*/ -/* margin-bottom: 2rem;*/ -/*}*/ - -/*.upload-label {*/ -/* display: inline-block;*/ -/* padding: 0.5rem 1.25rem;*/ -/* background-color: #28a745;*/ -/* color: white;*/ -/* border-radius: 6px;*/ -/* font-size: 0.95rem;*/ -/* cursor: pointer;*/ -/* transition: background-color 0.3s ease;*/ -/*}*/ - -/*.upload-label:hover {*/ -/* background-color: #218838;*/ -/*}*/ diff --git a/client/src/components/images/ImagePreview.css b/client/src/components/images/ImagePreview.css deleted file mode 100644 index 45108e4..0000000 --- a/client/src/components/images/ImagePreview.css +++ /dev/null @@ -1,34 +0,0 @@ -/*.image-preview {*/ -/* width: 100%;*/ -/* display: flex;*/ -/* flex-direction: column;*/ -/* align-items: center;*/ -/*}*/ - -/*.image-preview h2 {*/ -/* margin-bottom: 1rem;*/ -/* font-size: 1rem;*/ -/* text-align: center;*/ -/*}*/ - -/*.image-preview img,*/ -/*.empty-preview {*/ -/* width: 100%;*/ -/* object-fit: contain;*/ -/* border-radius: 0.5rem;*/ -/* background-color: #f9f9f9;*/ -/* box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);*/ -/*}*/ - -/*.empty-preview {*/ -/* display: flex;*/ -/* justify-content: center;*/ -/* align-items: center;*/ -/* border: 2px dashed #ddd;*/ -/* background-color: #f9f9f9;*/ -/*}*/ - -/*.error-message {*/ -/* margin-top: 20px;*/ -/* color: red;*/ -/*}*/ From a0e2b9dc52ad04c6392239c867b898d15949ece1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 6 Jun 2025 10:11:08 +0200 Subject: [PATCH 07/15] refactor: fix import paths --- client/src/components/images/ImageConverter.jsx | 5 ++--- client/src/components/images/ImagePreview.jsx | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/components/images/ImageConverter.jsx b/client/src/components/images/ImageConverter.jsx index 6ffe9d5..bddfd9c 100644 --- a/client/src/components/images/ImageConverter.jsx +++ b/client/src/components/images/ImageConverter.jsx @@ -1,8 +1,7 @@ -import "./ImageConverter.css"; import { useState } from "preact/hooks"; import Select from "react-select"; -import { useWasm } from "../../hooks/useWasm.ts"; -import { to_grayscale, invert_colors, to_png, remove_hot_pixels_with_percentile } from "../../wasm/wasm_api.js"; +import { useWasm } from "@/hooks/useWasm.ts"; +import { to_grayscale, invert_colors, to_png, remove_hot_pixels_with_percentile } from "@/wasm/wasm_api.ts"; import ImagePreview from "./ImagePreview.jsx"; const options = [ diff --git a/client/src/components/images/ImagePreview.jsx b/client/src/components/images/ImagePreview.jsx index 3e8fbc0..7145a12 100644 --- a/client/src/components/images/ImagePreview.jsx +++ b/client/src/components/images/ImagePreview.jsx @@ -1,5 +1,4 @@ import { useEffect, useRef } from "preact/hooks"; -import "./ImagePreview.css"; const ImagePreview = ({ imageUrl, From d451a25a7ecee1204cfcb170c27522d6b49b4340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 6 Jun 2025 10:14:46 +0200 Subject: [PATCH 08/15] refactor: copilot suggestions - setErrorMessage inside convert() - remove relative path inside index.html --- client/index.html | 2 +- client/src/components/images/ImageConverter.jsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/index.html b/client/index.html index 0e148ac..e803818 100644 --- a/client/index.html +++ b/client/index.html @@ -9,6 +9,6 @@
- + diff --git a/client/src/components/images/ImageConverter.jsx b/client/src/components/images/ImageConverter.jsx index bddfd9c..0dacd01 100644 --- a/client/src/components/images/ImageConverter.jsx +++ b/client/src/components/images/ImageConverter.jsx @@ -1,7 +1,7 @@ import { useState } from "preact/hooks"; import Select from "react-select"; import { useWasm } from "@/hooks/useWasm.ts"; -import { to_grayscale, invert_colors, to_png, remove_hot_pixels_with_percentile } from "@/wasm/wasm_api.ts"; +import { to_grayscale, invert_colors, to_png, remove_hot_pixels_with_percentile } from "@/wasm/wasm_api.js"; import ImagePreview from "./ImagePreview.jsx"; const options = [ @@ -70,6 +70,8 @@ const ImageConverter = () => { const handleConvert = async () => { if (!rawBytes || !wasmReady) return; + setErrorMessage(null); + try { const convertedBytes = convert(rawBytes, conversionType.value); From daebda5a52cc3f63c8ddef637fe6b8126ea80c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 6 Jun 2025 10:32:29 +0200 Subject: [PATCH 09/15] refactor: simplify hot pixel removal function --- api/src/lib.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 66a928f..70e075d 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -27,6 +27,9 @@ pub fn remove_hot_pixels_with_percentile( image_data: &[u8], percentile: f32, ) -> Result, JsValue> { + if percentile < 0.0 || percentile > 100.0 { + return Err(JsValue::from_str("Percentile must be between 0 and 100")); + } let img = image::load_from_memory(image_data) .map_err(|e| JsValue::from_str(&format!("Image decode error: {}", e)))?; @@ -38,20 +41,14 @@ pub fn remove_hot_pixels_with_percentile( if total == 0 { return Err(JsValue::from_str("Empty image buffer")); } - if !(0.0..=100.0).contains(&percentile) { - return Err(JsValue::from_str("Percentile must be between 0 and 100")); - } + let mut flat = pixels.clone(); let idx = ((percentile / 100.0) * (total as f32)).floor() as usize; let idx = if idx >= total { total - 1 } else { idx }; flat.select_nth_unstable(idx); let cutoff = flat[idx]; - for v in pixels.iter_mut() { - if *v > cutoff { - *v = cutoff; - } - } + pixels.iter_mut().for_each(|v| *v = (*v).min(cutoff)); let clamped_buf: ImageBuffer, Vec> = ImageBuffer::from_raw(width, height, pixels) From 913f1d2373b5aedde06555ac6b247fce60d7c701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 6 Jun 2025 10:47:50 +0200 Subject: [PATCH 10/15] refactor: fix import path --- client/src/app.css | 7 ------- client/src/app.tsx | 1 - 2 files changed, 8 deletions(-) delete mode 100644 client/src/app.css diff --git a/client/src/app.css b/client/src/app.css deleted file mode 100644 index f794e1c..0000000 --- a/client/src/app.css +++ /dev/null @@ -1,7 +0,0 @@ -/*body {*/ -/* font-family: system-ui, sans-serif;*/ -/* background: #f8f9fa;*/ -/* margin: 0;*/ -/* padding: 2rem;*/ -/* color: #333;*/ -/*}*/ diff --git a/client/src/app.tsx b/client/src/app.tsx index b87107c..85c3ef5 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -1,4 +1,3 @@ -import "./app.css"; import ImageConverter from "@/components/images/ImageConverter.jsx"; import { useWasm } from "./hooks/useWasm.js"; From 1be9434c83bc74b59653616fbf6041fce94e48af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 6 Jun 2025 10:54:26 +0200 Subject: [PATCH 11/15] refactor: rename clamp to clip --- api/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 70e075d..e64d2b3 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -50,10 +50,10 @@ pub fn remove_hot_pixels_with_percentile( pixels.iter_mut().for_each(|v| *v = (*v).min(cutoff)); - let clamped_buf: ImageBuffer, Vec> = + let clip_buf: ImageBuffer, Vec> = ImageBuffer::from_raw(width, height, pixels) .ok_or_else(|| JsValue::from_str("Buffer length mismatch"))?; - let dyn_img = DynamicImage::ImageRgb8(clamped_buf); + let dyn_img = DynamicImage::ImageRgb8(clip_buf); let mut output = Cursor::new(Vec::new()); dyn_img From 43812a6416f5ae1e29c9e9764ef30b8e2cbb4961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 6 Jun 2025 11:54:50 +0200 Subject: [PATCH 12/15] refactor: add low_percentile option for clipping --- api/src/lib.rs | 85 +++++++++++++++---- .../src/components/images/ImageConverter.jsx | 38 +++++++-- 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index e64d2b3..fdf6bb5 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -23,36 +23,62 @@ pub fn to_png(image: &[u8]) -> Result, JsValue> { } #[wasm_bindgen] -pub fn remove_hot_pixels_with_percentile( +pub fn clip_pixels_with_percentiles( image_data: &[u8], - percentile: f32, + low_percentile: Option, + high_percentile: Option, ) -> Result, JsValue> { - if percentile < 0.0 || percentile > 100.0 { - return Err(JsValue::from_str("Percentile must be between 0 and 100")); + if let Some(lp) = low_percentile { + if !(0.0..=100.0).contains(&lp) { + return Err(JsValue::from_str("low_percentile must be between 0 and 100")); + } } + if let Some(hp) = high_percentile { + if !(0.0..=100.0).contains(&hp) { + return Err(JsValue::from_str("high_percentile must be between 0 and 100")); + } + } + let img = image::load_from_memory(image_data) .map_err(|e| JsValue::from_str(&format!("Image decode error: {}", e)))?; - let rgb = img.to_rgb8(); - let (width, height) = rgb.dimensions(); - let mut pixels = rgb.into_raw(); - - let total = pixels.len(); - if total == 0 { - return Err(JsValue::from_str("Empty image buffer")); + let rgb8: image::RgbImage = img.to_rgb8(); + let (width, height) = rgb8.dimensions(); + let mut pixels: Vec = rgb8.into_raw(); + + if low_percentile.is_none() && high_percentile.is_none() { + let buf: ImageBuffer, Vec> = + 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 = pixels.clone(); - let idx = ((percentile / 100.0) * (total as f32)).floor() as usize; - let idx = if idx >= total { total - 1 } else { idx }; - flat.select_nth_unstable(idx); - let cutoff = flat[idx]; + let mut flat_for_low: Vec = pixels.clone(); + let mut flat_for_high: Vec = pixels.clone(); - pixels.iter_mut().for_each(|v| *v = (*v).min(cutoff)); + // Compute low_cutoff and high_cutoff + let low_cutoff: Option = percentile_cutoff(&mut flat_for_low, low_percentile); + let high_cutoff: Option = 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, Vec> = ImageBuffer::from_raw(width, height, pixels) - .ok_or_else(|| JsValue::from_str("Buffer length mismatch"))?; + .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()); @@ -64,6 +90,29 @@ pub fn remove_hot_pixels_with_percentile( // Helpers +fn percentile_cutoff(flat_pixels: &mut [u8], pct: Option) -> Option { + 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, diff --git a/client/src/components/images/ImageConverter.jsx b/client/src/components/images/ImageConverter.jsx index 0dacd01..feafc05 100644 --- a/client/src/components/images/ImageConverter.jsx +++ b/client/src/components/images/ImageConverter.jsx @@ -1,7 +1,7 @@ import { useState } from "preact/hooks"; import Select from "react-select"; import { useWasm } from "@/hooks/useWasm.ts"; -import { to_grayscale, invert_colors, to_png, remove_hot_pixels_with_percentile } from "@/wasm/wasm_api.js"; +import { to_grayscale, invert_colors, to_png, clip_pixels_with_percentiles } from "@/wasm/wasm_api.js"; import ImagePreview from "./ImagePreview.jsx"; const options = [ @@ -42,7 +42,8 @@ const ImageConverter = () => { const [previesAspectRatios, setPreviesAspectRatios] = useState(16 / 10); const [conversionType, setConversionType] = useState(null); const [errorMessage, setErrorMessage] = useState(null); - const [percentile, setPercentile] = useState(95); + const [lowPercentile, setLowPercentile] = useState(5); + const [highPercentile, setHighPercentile] = useState(95); const [removeHotPixels, setRemoveHotPixels] = useState(false); const handleUpload = async (e) => { @@ -78,9 +79,10 @@ const ImageConverter = () => { let finalBytes = convertedBytes; if (removeHotPixels) { try { - finalBytes = remove_hot_pixels_with_percentile( - convertedBytes, - percentile + finalBytes = clip_pixels_with_percentiles( + convertedBytes, + lowPercentile, + highPercentile ); } catch (hpErr) { console.error(`Hot-pixel removal error: ${hpErr}`); @@ -171,7 +173,7 @@ const ImageConverter = () => {
{ min="0" max="100" step="1" - value={percentile} + value={lowPercentile} onInput={(e) => { const val = e.currentTarget.valueAsNumber; if (!Number.isNaN(val) && val >= 0 && val <= 100) { - setPercentile(val); + setLowPercentile(val); + } + }} + className='w-24 px-3 py-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm' + /> +
+
+ + { + const val = e.currentTarget.valueAsNumber; + if (!Number.isNaN(val) && val >= 0 && val <= 100) { + setHighPercentile(val); } }} className='w-24 px-3 py-1 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-sm' From bfa42809391dd531080953a6e7efd777c3a062ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= Date: Fri, 6 Jun 2025 12:59:07 +0200 Subject: [PATCH 13/15] refactor: add check for low > hp percentile --- api/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/src/lib.rs b/api/src/lib.rs index fdf6bb5..926f828 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -38,6 +38,11 @@ pub fn clip_pixels_with_percentiles( return Err(JsValue::from_str("high_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)))?; From bc674ac807a2ebf9c03425bc411744d836e15900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20St=C4=99pie=C5=84?= <43409196+jkbstepien@users.noreply.github.com> Date: Fri, 6 Jun 2025 13:01:12 +0200 Subject: [PATCH 14/15] Update client/src/components/images/ImageConverter.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/src/components/images/ImageConverter.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/images/ImageConverter.jsx b/client/src/components/images/ImageConverter.jsx index feafc05..fd3feb7 100644 --- a/client/src/components/images/ImageConverter.jsx +++ b/client/src/components/images/ImageConverter.jsx @@ -172,11 +172,11 @@ const ImageConverter = () => { Remove hot-pixels
-