Skip to content

Commit aaf0118

Browse files
chore: added pre-process screen
1 parent 2644b53 commit aaf0118

8 files changed

Lines changed: 279 additions & 5 deletions

File tree

string-art-website/package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

string-art-website/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@emotion/styled": "^11.14.1",
1616
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
1717
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
18+
"@mediapipe/tasks-vision": "^0.10.22-rc.20250304",
1819
"@mui/material": "^7.3.1",
1920
"@reduxjs/toolkit": "^2.8.2",
2021
"i18next-browser-languagedetector": "^8.2.0",

string-art-website/public/locales/i18n/en/translation.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
"Lines ": "Lines ",
2828
"Score ": "Score ",
2929
"Upload Image": "Upload Image",
30+
"Preprocess Image": "Preprocess Image",
31+
"Run Preprocess": "Run Preprocess",
32+
"Processing...": "Processing...",
33+
"Source Image": "Source Image",
34+
"Preprocessed Preview": "Preprocessed Preview",
35+
"Use Preprocessed Image": "Use Preprocessed Image",
36+
"Clear": "Clear",
37+
"No preview yet": "No preview yet",
3038
"Render String Art": "Render String Art",
3139
"All steps completed - you're finished": "All steps completed - you're finished",
3240
"Download Image": "Download Image",
@@ -36,5 +44,6 @@
3644
"Line length diameter Label": "Line length diameter (m): ",
3745
"Line length output label": "Estimated line length: ",
3846
"Line length calculating label": "Calculating line length...",
39-
"Line length title": "Estimated Line Length"
47+
"Line length title": "Estimated Line Length",
48+
"PreProcessBlurb": "This is your processed image, the background is removed."
4049
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useTranslation } from "react-i18next";
2+
import { usePreProcess } from "./usePreProcess";
3+
4+
/**
5+
* Minimal PreProcess screen that:
6+
* - loads MediaPipe FaceDetector (tasks-vision) and SelfieSegmentation (cdn)
7+
* - detects best face bbox, expands & squares it
8+
* - crops + resizes to TARGET
9+
* - runs selfie segmentation on the crop and composites on white
10+
* - produces a PNG base64 and allows "Use for generation" which dispatches to store
11+
*
12+
* NOTE: This file keeps helpers inline for simplicity. Future refactor should move heavy code
13+
* to a service + WebWorker/OffscreenCanvas.
14+
*/
15+
16+
17+
export default function PreProcessScreen() {
18+
const i18next = useTranslation();
19+
const preview = usePreProcess();
20+
21+
return (
22+
<main className="upload-section controls-section">
23+
<div style={{ marginTop: 12, marginLeft: "auto", marginRight: "auto" }}>
24+
{preview ? (
25+
<div>
26+
<p>{i18next.t("PreProcessBlurb")}</p>
27+
<img src={preview} alt="preview" style={{ maxWidth: 512}} />
28+
</div>
29+
) : (
30+
<div>
31+
<p>{i18next.t("Processing...")}</p>
32+
</div>
33+
)}
34+
</div>
35+
</main>
36+
);
37+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './PreProcessScreen';
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { useState, useRef, useEffect } from "react";
2+
import { useDispatch, useSelector } from "react-redux";
3+
import type { AppDispatch } from "../shared/redux/store";
4+
import { setImageUrl, setPreprocessedImageUrl } from "../shared/redux/stringArtSlice";
5+
6+
7+
const TARGET_SIZE = 1080;
8+
9+
declare global {
10+
interface Window {
11+
SelfieSegmentation?: any;
12+
}
13+
}
14+
15+
async function loadSelfieSegmentation(): Promise<any> {
16+
if ((window as any).SelfieSegmentation) return (window as any).SelfieSegmentation;
17+
return new Promise((resolve, reject) => {
18+
const script = document.createElement("script");
19+
script.src = "https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation/selfie_segmentation.js";
20+
script.async = true;
21+
script.onload = () => {
22+
resolve((window as any).SelfieSegmentation);
23+
};
24+
script.onerror = reject;
25+
document.head.appendChild(script);
26+
});
27+
}
28+
29+
30+
export const usePreProcess = () => {
31+
const dispatch = useDispatch<AppDispatch>();
32+
const imageUrl = useSelector((state: any) => state.stringArt.imageUrl || state.stringArt.preprocessedImageUrl);
33+
const [loading, setLoading] = useState(false);
34+
const [preview, setPreview] = useState<string | null>(null);
35+
const selfieRef = useRef<any>(null);
36+
37+
useEffect(() => {
38+
// Lazy load SelfieSegmentation (cdn) — we'll use it after we have a crop
39+
loadSelfieSegmentation()
40+
.then((SelfieSegmentation) => {
41+
selfieRef.current = new SelfieSegmentation({
42+
locateFile: (file: string) =>
43+
`https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation/${file}`,
44+
});
45+
selfieRef.current.setOptions({ modelSelection: 1 });
46+
})
47+
.catch((err) => {
48+
console.warn("Failed to load SelfieSegmentation", err);
49+
});
50+
// FaceDetector (tasks-vision) is relatively heavy and may already be used elsewhere;
51+
// for simplicity we'll dynamically import it from the same CDN used in the demo if needed.
52+
// If project has a central copy, refactor to reuse it.
53+
// We do not eagerly instantiate tasks-vision FaceDetector here to keep initial load small.
54+
}, []);
55+
56+
async function processImage() {
57+
if (!imageUrl) return;
58+
setLoading(true);
59+
setPreview(null);
60+
61+
// Load image element
62+
const img = new Image();
63+
img.crossOrigin = "anonymous";
64+
img.src = imageUrl;
65+
await new Promise((res, rej) => {
66+
img.onload = res;
67+
img.onerror = rej;
68+
});
69+
70+
const cropCanvas = document.createElement("canvas");
71+
cropCanvas.width = TARGET_SIZE;
72+
cropCanvas.height = TARGET_SIZE;
73+
const ctx = cropCanvas.getContext("2d");
74+
if (!ctx) {
75+
setLoading(false);
76+
return;
77+
}
78+
// draw full image to canvas preserving aspect ratio (letterbox)
79+
const scale = Math.min(TARGET_SIZE / img.width, TARGET_SIZE / img.height);
80+
const drawWidth = Math.round(img.width * scale);
81+
const drawHeight = Math.round(img.height * scale);
82+
const dx = Math.round((TARGET_SIZE - drawWidth) / 2);
83+
const dy = Math.round((TARGET_SIZE - drawHeight) / 2);
84+
// fill background white to avoid transparent borders
85+
ctx.fillStyle = "#ffffff";
86+
ctx.fillRect(0, 0, TARGET_SIZE, TARGET_SIZE);
87+
ctx.drawImage(img, 0, 0, img.width, img.height, dx, dy, drawWidth, drawHeight);
88+
89+
// Run selfie segmentation on the cropped canvas
90+
if (!selfieRef.current) {
91+
console.warn("SelfieSegmentation not loaded; returning plain crop on white");
92+
// Composite crop on white and return
93+
const whiteCanvas = document.createElement("canvas");
94+
whiteCanvas.width = TARGET_SIZE;
95+
whiteCanvas.height = TARGET_SIZE;
96+
const wctx = whiteCanvas.getContext("2d");
97+
if (!wctx) {
98+
setLoading(false);
99+
return;
100+
}
101+
wctx.fillStyle = "#ffffff";
102+
wctx.fillRect(0, 0, TARGET_SIZE, TARGET_SIZE);
103+
wctx.drawImage(cropCanvas, 0, 0);
104+
const dataUrl = whiteCanvas.toDataURL("image/png");
105+
setPreview(dataUrl);
106+
setLoading(false);
107+
return;
108+
}
109+
110+
// SelfieSegmentation in browser: use onResults pattern; we will send the crop canvas as an ImageBitmap
111+
const segmentationResult = await new Promise<any>((resolve) => {
112+
(async () => {
113+
selfieRef.current.onResults((results: any) => {
114+
resolve(results);
115+
});
116+
// selfieRef expects an HTMLVideoElement/Image/Canvas; pass the cropCanvas
117+
await selfieRef.current.send({ image: cropCanvas });
118+
})();
119+
});
120+
121+
// segmentationResult.segmentationMask is an HTMLCanvas or ImageData depending on implementation;
122+
// To be robust, draw the mask onto a temp canvas.
123+
const maskCanvas = document.createElement("canvas");
124+
maskCanvas.width = TARGET_SIZE;
125+
maskCanvas.height = TARGET_SIZE;
126+
const mctx = maskCanvas.getContext("2d");
127+
if (!mctx) {
128+
setLoading(false);
129+
return;
130+
}
131+
132+
if (segmentationResult.segmentationMask) {
133+
// draw mask to canvas
134+
mctx.drawImage(segmentationResult.segmentationMask, 0, 0, TARGET_SIZE, TARGET_SIZE);
135+
} else if (segmentationResult.multiSegmentationMask) {
136+
mctx.drawImage(segmentationResult.multiSegmentationMask[0], 0, 0, TARGET_SIZE, TARGET_SIZE);
137+
} else if (segmentationResult.mask) {
138+
// mask as float array: convert to ImageData
139+
const imgData = mctx.createImageData(TARGET_SIZE, TARGET_SIZE);
140+
const mask = segmentationResult.mask; // assume Float32Array or array of [0..1]
141+
for (let i = 0; i < mask.length && i * 4 < imgData.data.length; i++) {
142+
const a = Math.round(Math.min(1, Math.max(0, mask[i])) * 255);
143+
imgData.data[i * 4 + 0] = 255;
144+
imgData.data[i * 4 + 1] = 255;
145+
imgData.data[i * 4 + 2] = 255;
146+
imgData.data[i * 4 + 3] = a;
147+
}
148+
mctx.putImageData(imgData, 0, 0);
149+
} else {
150+
// unknown mask format, fallback
151+
mctx.fillStyle = "#ffffff";
152+
mctx.fillRect(0, 0, TARGET_SIZE, TARGET_SIZE);
153+
}
154+
155+
// Optional: feather edges by applying a small blur filter to mask
156+
// create final canvas: draw white background, then draw image masked by alpha
157+
const finalCanvas = document.createElement("canvas");
158+
finalCanvas.width = TARGET_SIZE;
159+
finalCanvas.height = TARGET_SIZE;
160+
const fctx = finalCanvas.getContext("2d");
161+
if (!fctx) {
162+
setLoading(false);
163+
return;
164+
}
165+
166+
// Composite: draw white background and keep only foreground using the mask
167+
// Paint white background first
168+
fctx.fillStyle = "#ffffff";
169+
fctx.fillRect(0, 0, TARGET_SIZE, TARGET_SIZE);
170+
// Draw the crop image on top
171+
fctx.drawImage(cropCanvas, 0, 0, TARGET_SIZE, TARGET_SIZE);
172+
// Use mask to keep only foreground pixels (mask white = foreground)
173+
fctx.globalCompositeOperation = 'destination-in';
174+
fctx.drawImage(maskCanvas, 0, 0, TARGET_SIZE, TARGET_SIZE);
175+
// Restore default composite mode
176+
fctx.globalCompositeOperation = 'source-over';
177+
178+
// Convert final canvas to data URL
179+
const finalDataUrl = finalCanvas.toDataURL("image/png");
180+
setPreview(finalDataUrl);
181+
// Automatically set the preprocessed image as the image to process
182+
dispatch(setImageUrl(finalDataUrl));
183+
dispatch(setPreprocessedImageUrl(finalDataUrl));
184+
setLoading(false);
185+
}
186+
187+
useEffect(() => {
188+
if (imageUrl && !preview && !loading) {
189+
processImage();
190+
}
191+
// eslint-disable-next-line
192+
}, [imageUrl]);
193+
194+
195+
return preview;
196+
}

string-art-website/src/features/Stepper/StepperScreen.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import StepLabel from '@mui/material/StepLabel';
66
import Button from '@mui/material/Button';
77
import Typography from '@mui/material/Typography';
88
import UploadScreen from '../1Upload/UploadScreen';
9+
import PreProcessScreen from '../2PreProcess/PreProcessScreen';
910
import RenderImageScreen from '../3RenderImage/RenderImageScreen';
1011
import { useSelector } from 'react-redux';
1112
import { type StringArtState } from '../shared/redux/stringArtSlice';
@@ -14,7 +15,11 @@ import { useTranslation } from 'react-i18next';
1415

1516
export default function StepperScreen() {
1617
const i18next = useTranslation();
17-
const steps = [i18next.t('Upload Image'), i18next.t('Render String Art')];
18+
const steps = [
19+
i18next.t('Upload Image'),
20+
i18next.t('Preprocess Image'),
21+
i18next.t('Render String Art'),
22+
];
1823
const [activeStep, setActiveStep] = React.useState(0);
1924
const { imageData, currentPath } = useSelector((state: { stringArt: StringArtState }) => state.stringArt);
2025
const renderImageScreenRef = React.useRef<HTMLCanvasElement>(null);
@@ -102,7 +107,8 @@ export default function StepperScreen() {
102107
) : (
103108
<React.Fragment>
104109
{activeStep === 0 && <UploadScreen onImageSelected={handleNext} />}
105-
{activeStep === 1 && <RenderImageScreen ref={renderImageScreenRef} />}
110+
{activeStep === 1 && <PreProcessScreen />}
111+
{activeStep === 2 && <RenderImageScreen ref={renderImageScreenRef} />}
106112
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
107113
<Button
108114
color="inherit"

string-art-website/src/features/shared/redux/stringArtSlice.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { ProgressInfo } from '../../../wasm/string_art_rust_impl';
77
export interface StringArtState {
88
imageData: Uint8Array | null;
99
imageUrl: string;
10+
preprocessedImageUrl: string;
11+
preprocessedParams: any;
1012
isGenerating: boolean;
1113
currentPath: number[];
1214
nailCoords: Array<[number, number]>;
@@ -21,6 +23,8 @@ interface StrinArtThunkProperties { imageData: Uint8Array; settings: StringArtCo
2123
const initialState: StringArtState = {
2224
imageData: null,
2325
imageUrl: '',
26+
preprocessedImageUrl: '',
27+
preprocessedParams: null,
2428
isGenerating: false,
2529
currentPath: [],
2630
nailCoords: [],
@@ -46,7 +50,7 @@ export const generateStringArtThunk = createAsyncThunk(
4650
'stringArt/generate',
4751
async (
4852
{ imageData, settings }: StrinArtThunkProperties,
49-
{dispatch}
53+
{ dispatch }
5054
) => {
5155
return await generateStringArt(
5256
settings,
@@ -62,7 +66,7 @@ export const generateStringArtThunk = createAsyncThunk(
6266
}
6367
);
6468

65-
// Actions for async generation will be added later via thunk
69+
// Redux slice
6670
const stringArtSlice = createSlice({
6771
name: 'stringArt',
6872
initialState,
@@ -85,9 +89,17 @@ const stringArtSlice = createSlice({
8589
setSettings(state: StringArtState, action: PayloadAction<StringArtConfig>) {
8690
state.settings = action.payload;
8791
},
92+
setPreprocessedImageUrl(state: StringArtState, action: PayloadAction<string>) {
93+
state.preprocessedImageUrl = action.payload;
94+
},
95+
setPreprocessedParams(state: StringArtState, action: PayloadAction<any>) {
96+
state.preprocessedParams = action.payload;
97+
},
8898
resetState(state: StringArtState) {
8999
state.imageData = null;
90100
state.imageUrl = '';
101+
state.preprocessedImageUrl = '';
102+
state.preprocessedParams = null;
91103
state.isGenerating = false;
92104
state.currentPath = [];
93105
state.nailCoords = [];
@@ -113,11 +125,16 @@ const stringArtSlice = createSlice({
113125
},
114126
});
115127

128+
export const selectPreprocessedImageUrl = (state: { stringArt: StringArtState }) => state.stringArt.preprocessedImageUrl;
129+
export const selectPreprocessedParams = (state: { stringArt: StringArtState }) => state.stringArt.preprocessedParams;
130+
116131
export const {
117132
setImageData,
118133
setImageUrl,
119134
setIsLoading,
120135
setSettings,
136+
setPreprocessedImageUrl,
137+
setPreprocessedParams,
121138
resetState,
122139
appendToCurrentPath,
123140
setNailCoords,

0 commit comments

Comments
 (0)