feat(ui): implement fill tool
This commit is contained in:
parent
b96f820971
commit
312d15a296
@ -19,6 +19,10 @@
|
||||
}
|
||||
|
||||
let layerOpacity = 50;
|
||||
let _layerOpacity = localStorage.getItem("layerOpacity");
|
||||
if (_layerOpacity) {
|
||||
layerOpacity = _layerOpacity;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`wrapper tool-${activeTool}`}>
|
||||
|
@ -1,5 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { AI, bufToImageUrl } from "helpers";
|
||||
import {
|
||||
AI,
|
||||
bufToImageUrl,
|
||||
createFloodMap,
|
||||
hexToRGB,
|
||||
imageToArray,
|
||||
} from "helpers";
|
||||
import { images as imageStore } from "stores";
|
||||
import OrbView from "./OrbView";
|
||||
import { onMount } from "svelte";
|
||||
@ -53,6 +59,7 @@
|
||||
let topLeftY = 0;
|
||||
let wrapperHeightRatio = 1;
|
||||
let wrapperWidth = 0;
|
||||
const pixelAmount = image.width * image.height;
|
||||
|
||||
let xOffset = 0;
|
||||
|
||||
@ -160,6 +167,56 @@
|
||||
prePolygonImage.src = canvas.toDataURL();
|
||||
}
|
||||
|
||||
let preFloodMx, floodMap, preFloodImage: ArrayBuffer, floodImage: ImageData;
|
||||
function handleFloodFill() {
|
||||
if (!floodMap) return;
|
||||
|
||||
const amount = 255 - Math.floor(Math.abs(mx - preFloodMx) / 4);
|
||||
|
||||
const [r, g, b] = hexToRGB(activeColor);
|
||||
|
||||
for (let i = 0; i < pixelAmount; i++) {
|
||||
if (floodMap[i] > amount) {
|
||||
floodImage.data[i * 4 + 0] = r;
|
||||
floodImage.data[i * 4 + 1] = g;
|
||||
floodImage.data[i * 4 + 2] = b;
|
||||
floodImage.data[i * 4 + 3] = 255;
|
||||
} else {
|
||||
floodImage.data[i * 4 + 0] = preFloodImage[i * 4 + 0];
|
||||
floodImage.data[i * 4 + 1] = preFloodImage[i * 4 + 1];
|
||||
floodImage.data[i * 4 + 2] = preFloodImage[i * 4 + 2];
|
||||
floodImage.data[i * 4 + 3] = preFloodImage[i * 4 + 3];
|
||||
}
|
||||
}
|
||||
|
||||
cx1.putImageData(floodImage, 0, 0);
|
||||
}
|
||||
async function savePreFlood() {
|
||||
preFloodMx = mx;
|
||||
prePolygonImage.src = canvas.toDataURL();
|
||||
floodImage = cx1.getImageData(0, 0, image.width, image.height);
|
||||
preFloodImage = cx1
|
||||
.getImageData(0, 0, image.width, image.height)
|
||||
.data.slice(0);
|
||||
|
||||
const x = Math.floor(
|
||||
mx * wrapperHeightRatio - xOffset * wrapperHeightRatio
|
||||
);
|
||||
const y = Math.floor(my * wrapperHeightRatio);
|
||||
|
||||
floodMap = await createFloodMap(image, x, y);
|
||||
|
||||
// const pixelAmount = image.width * image.height;
|
||||
// const withAlpha = new Uint8ClampedArray(pixelAmount * 4);
|
||||
// for (let i = 0; i < pixelAmount; i++) {
|
||||
// withAlpha[i * 4 + 0] = floodMap[i];
|
||||
// withAlpha[i * 4 + 1] = floodMap[i];
|
||||
// withAlpha[i * 4 + 2] = floodMap[i];
|
||||
// withAlpha[i * 4 + 3] = 255;
|
||||
// }
|
||||
// cx1.putImageData(new ImageData(withAlpha, image.width, image.height), 0, 0);
|
||||
}
|
||||
|
||||
function handlePolygonMouseDown() {
|
||||
if (polygonPoints.length < 2) savePrePolygon();
|
||||
|
||||
@ -242,6 +299,9 @@
|
||||
if (activeTool === "smooth_polygon") {
|
||||
savePrePolygon();
|
||||
}
|
||||
if (activeTool === "flood_fill") {
|
||||
savePreFlood();
|
||||
}
|
||||
if (activeTool === "polygon") {
|
||||
if (isDbClick) {
|
||||
handleFinishPolygon();
|
||||
@ -274,6 +334,10 @@
|
||||
(downOffset + e.clientX - downX) % (image.width / wrapperHeightRatio);
|
||||
}
|
||||
|
||||
if (activeTool === "flood_fill") {
|
||||
handleFloodFill();
|
||||
}
|
||||
|
||||
if (activeTool === "erasor") {
|
||||
cx1.globalCompositeOperation = "destination-out";
|
||||
cx1.fillStyle = "#" + activeColor;
|
||||
|
@ -6,7 +6,14 @@
|
||||
|
||||
export let activeTool = "pan";
|
||||
|
||||
const tools = ["pan", "brush", "erasor", "smooth_polygon", "polygon"];
|
||||
const tools = [
|
||||
"pan",
|
||||
"brush",
|
||||
"erasor",
|
||||
"smooth_polygon",
|
||||
"polygon",
|
||||
"flood_fill",
|
||||
];
|
||||
|
||||
$: if (activeTool) {
|
||||
if (!tools.includes(activeTool)) activeTool = "brush";
|
||||
@ -41,11 +48,13 @@
|
||||
<style>
|
||||
button.active {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
color: black;
|
||||
background-color: transparent;
|
||||
margin-left: 5px;
|
||||
margin-bottom: 5px;
|
||||
|
@ -5,7 +5,7 @@
|
||||
const minRadius = 1;
|
||||
const maxRadius = 100;
|
||||
export let brushRadius = 10;
|
||||
export const layerOpacity = 50;
|
||||
export let layerOpacity = 50;
|
||||
|
||||
const colors = ["ff0000", "00ff00", "0000ff", "ffff00", "00ffff", "ff00ff"];
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
localStorage.setItem("activeColor", activeColor);
|
||||
}
|
||||
|
||||
$: if (layerOpacity) {
|
||||
localStorage.setItem("layerOpacity", layerOpacity);
|
||||
}
|
||||
|
||||
function handleMouseWheel(e) {
|
||||
brushRadius = Math.min(
|
||||
Math.max(brushRadius - e.deltaY / 10, minRadius),
|
||||
@ -47,15 +51,15 @@
|
||||
bind:value={brushRadius}
|
||||
/>
|
||||
{/if}
|
||||
<!-- <label for="brush-radius">LayerOpacity</label>
|
||||
<label for="layer-opacity">LayerOpacity</label>
|
||||
<input
|
||||
id="brush-radius"
|
||||
id="layer-opacity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
steps="1"
|
||||
bind:value={layerOpacity}
|
||||
/> -->
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { bufToImageUrl } from ".";
|
||||
import { bufToImageUrl, imageToArray } from ".";
|
||||
|
||||
const worker = new Worker("build/workers/ai-worker.js");
|
||||
|
||||
@ -14,26 +14,13 @@ worker.addEventListener("message", ev => {
|
||||
}
|
||||
})
|
||||
|
||||
const analyze = (img: Image) => new Promise((res, rej) => {
|
||||
const analyze = (img: Image) => new Promise(async (res, rej) => {
|
||||
i++;
|
||||
const _i = i;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
const cx = canvas.getContext("2d");
|
||||
const image = document.createElement("img");
|
||||
image.onload = () => {
|
||||
|
||||
cx.drawImage(image, 0, 0);
|
||||
const pixels = cx.getImageData(0, 0, image.width, image.height).data
|
||||
|
||||
worker.postMessage({ i: _i, pixels, width: img.width, height: img.height });
|
||||
}
|
||||
|
||||
image.src = bufToImageUrl(img.data, img.type);
|
||||
const pixels = await imageToArray(img);
|
||||
|
||||
worker.postMessage({ i: _i, pixels, width: img.width, height: img.height });
|
||||
|
||||
cb[_i] = res;
|
||||
});
|
||||
|
@ -1,16 +1,4 @@
|
||||
import { images } from "../stores";
|
||||
|
||||
const worker = new Worker("build/workers/pixel-worker.js");
|
||||
|
||||
let i = 0;
|
||||
|
||||
let cb = {};
|
||||
|
||||
worker.addEventListener("message", ev => {
|
||||
if (ev.data.i in cb) {
|
||||
cb[ev.data.i](ev.data.result);
|
||||
}
|
||||
})
|
||||
import _pixelWorker from "./_pixelWorker";
|
||||
|
||||
interface res {
|
||||
color: string
|
||||
@ -19,9 +7,6 @@ interface res {
|
||||
value: number
|
||||
}
|
||||
|
||||
export default (img: Image, correctDistortion: boolean): Promise<res[]> => new Promise((res, rej) => {
|
||||
i++;
|
||||
const _i = i;
|
||||
worker.postMessage({ i: _i, pixels: img.overlayData, width: img.width, height: img.height, correctDistortion });
|
||||
cb[_i] = res;
|
||||
});
|
||||
export default async (img: Image, correctDistortion: boolean): Promise<res[]> => {
|
||||
return await _pixelWorker({ pixels: img.overlayData, width: img.width, height: img.height, correctDistortion, type: "count" })
|
||||
};
|
6
view/src/helpers/FloodFill.ts
Normal file
6
view/src/helpers/FloodFill.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import hexToRgb from "./hexToRGB"
|
||||
import _pixelWorker from "./_pixelWorker";
|
||||
|
||||
export default async (floodMap: Uint8ClampedArray, image: Uint8ClampedArray, color: string, amount: number) => {
|
||||
return await _pixelWorker({ type: "flood_fill", floodMap, image, color, amount });
|
||||
}
|
6
view/src/helpers/FloodFillMap.ts
Normal file
6
view/src/helpers/FloodFillMap.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { imageToArray } from "helpers";
|
||||
import _pixelWorker from "./_pixelWorker"
|
||||
|
||||
export default async (image: Image, x: number, y: number): Promise<Uint8ClampedArray> => {
|
||||
return await _pixelWorker({ type: "flood_map", pixels: await imageToArray(image, false), x, y, width: image.width, height: image.height });
|
||||
}
|
34
view/src/helpers/ImageToArray.ts
Normal file
34
view/src/helpers/ImageToArray.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import BuffToImg from "./BuffToImg";
|
||||
|
||||
export default (img: Image, withAlpha = true) => new Promise((resolve) => {
|
||||
|
||||
const c = document.createElement("canvas");
|
||||
c.width = img.width;
|
||||
c.height = img.height;
|
||||
|
||||
const ctx = c.getContext("2d");
|
||||
|
||||
const imgEl = new Image(img.width, img.height);
|
||||
imgEl.src = BuffToImg(img.data, img.type)
|
||||
|
||||
|
||||
|
||||
imgEl.onload = () => {
|
||||
ctx.drawImage(imgEl, 0, 0);
|
||||
const alphaPixels = ctx.getImageData(0, 0, img.width, img.height).data;
|
||||
|
||||
if (withAlpha) return resolve(alphaPixels);
|
||||
|
||||
const pixelAmount = img.width * img.height;
|
||||
const pixels = new Uint8ClampedArray(pixelAmount * 3);
|
||||
|
||||
for (let i = 0; i < pixelAmount; i++) {
|
||||
pixels[i * 3 + 0] = alphaPixels[i * 4 + 0];
|
||||
pixels[i * 3 + 1] = alphaPixels[i * 4 + 1];
|
||||
pixels[i * 3 + 2] = alphaPixels[i * 4 + 2];
|
||||
}
|
||||
|
||||
resolve(pixels);
|
||||
}
|
||||
|
||||
});
|
18
view/src/helpers/_pixelWorker.ts
Normal file
18
view/src/helpers/_pixelWorker.ts
Normal file
@ -0,0 +1,18 @@
|
||||
const worker = new Worker("build/workers/pixel-worker.js");
|
||||
|
||||
let i = 0;
|
||||
|
||||
let cb = {};
|
||||
|
||||
worker.addEventListener("message", ev => {
|
||||
if (ev.data.i in cb) {
|
||||
cb[ev.data.i](ev.data.result);
|
||||
}
|
||||
})
|
||||
|
||||
export default (msg: any): Promise<any> => new Promise((res, rej) => {
|
||||
i++;
|
||||
const _i = i;
|
||||
worker.postMessage({ i: _i, ...msg });
|
||||
cb[_i] = res;
|
||||
});
|
8
view/src/helpers/hexToRGB.ts
Normal file
8
view/src/helpers/hexToRGB.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export default function hexToRgb(hex) {
|
||||
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? [
|
||||
parseInt(result[1], 16),
|
||||
parseInt(result[2], 16),
|
||||
parseInt(result[3], 16)
|
||||
] : null;
|
||||
}
|
@ -2,4 +2,7 @@ export { default as bufToImageUrl } from "./BuffToImg";
|
||||
export { default as fileToImage } from "./FileToImage";
|
||||
export { default as countPixels } from "./CountPixels";
|
||||
export { default as downloadImage } from "./DownloadImage";
|
||||
export { default as imageToArray } from "./ImageToArray";
|
||||
export { default as createFloodMap } from "./FloodFillMap";
|
||||
export { default as hexToRGB } from "./hexToRGB";
|
||||
export { default as AI } from "./AI";
|
37
view/src/icons/Fill.svelte
Normal file
37
view/src/icons/Fill.svelte
Normal file
@ -0,0 +1,37 @@
|
||||
<script>
|
||||
export let color = "black";
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M88.0216 51.8241L45.4864 94.3593L11.9785 60.8514L54.5137 18.3162L88.0216 51.8241Z"
|
||||
stroke={color}
|
||||
stroke-width="5"
|
||||
/>
|
||||
<path
|
||||
d="M68.5713 64.4131L22.767 64.4129L45.7679 87.2165L68.5713 64.4131Z"
|
||||
fill={color}
|
||||
/>
|
||||
<path d="M62.069 45.4335L66.4193 5.31409" stroke={color} stroke-width="5" />
|
||||
<circle
|
||||
cx="62.069"
|
||||
cy="44.7038"
|
||||
r="6.92501"
|
||||
transform="rotate(45 62.069 44.7038)"
|
||||
stroke={color}
|
||||
stroke-width="5"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
@ -9,6 +9,7 @@ export { default as brush } from "./Brush.svelte"
|
||||
export { default as clear } from "./Clear.svelte"
|
||||
export { default as cross } from "./Cross.svelte"
|
||||
export { default as erasor } from "./Erasor.svelte"
|
||||
export { default as flood_fill } from "./Fill.svelte"
|
||||
export { default as pan } from "./Pan.svelte"
|
||||
export { default as polygon } from "./Polygon.svelte"
|
||||
export { default as smooth_polygon } from "./SmoothPolygon.svelte"
|
@ -1,10 +1,11 @@
|
||||
import hexToRGB from "helpers/hexToRGB";
|
||||
|
||||
|
||||
export { };
|
||||
|
||||
self.addEventListener('message', function (e) {
|
||||
const countColors = ({ pixels, width, height }) => {
|
||||
|
||||
|
||||
const { data: { i, pixels, correctDistortion = true, width, height } } = e;
|
||||
|
||||
// This is the old method just by counting pixels
|
||||
const oldStore = {};
|
||||
|
||||
@ -80,6 +81,114 @@ self.addEventListener('message', function (e) {
|
||||
}
|
||||
}).sort((a, b) => a.value > b.value ? -1 : 1)
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
const distance2D = (x1, y1, x2, y2) => {
|
||||
const dx = x1 - x2;
|
||||
const dy = y1 - y2;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
const distance3D = (x1, y1, z1, x2, y2, z2) => {
|
||||
const dx = x1 - x2;
|
||||
const dy = y1 - y2;
|
||||
const dz = z1 - z2;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
const createFloodFillMap = ({ pixels, x, y, width, height }: { pixels: Uint8ClampedArray, x: number, y: number, width: number, height: number }) => {
|
||||
|
||||
const pixelAmount = width * height;
|
||||
const index = (y * width + x) * 3;
|
||||
|
||||
const _R = pixels[index + 0];
|
||||
const _G = pixels[index + 1];
|
||||
const _B = pixels[index + 2];
|
||||
|
||||
let distanceWeight = 0.7;
|
||||
|
||||
// This is the distance to the pixel that is furthest away from xy
|
||||
const maxDistance = distance2D((x < (width / 2)) ? width : 0, (y < (height / 2)) ? height : 0, x, y);
|
||||
|
||||
console.log(maxDistance)
|
||||
|
||||
// This the distance to the color that is furthest away from _R_G_B;
|
||||
let maxColorDistance = 0;
|
||||
|
||||
for (let i = 0; i < pixelAmount; i++) {
|
||||
const r = pixels[i * 3 + 0];
|
||||
const g = pixels[i * 3 + 1];
|
||||
const b = pixels[i * 3 + 2];
|
||||
const dist = distance3D(r, g, b, _R, _G, _B);
|
||||
if (dist > maxColorDistance) maxColorDistance = dist;
|
||||
}
|
||||
|
||||
|
||||
const floodFillMap = new Uint8ClampedArray(pixelAmount);
|
||||
|
||||
|
||||
const maxWeight = (maxDistance * distanceWeight) + maxColorDistance * (1 - distanceWeight);
|
||||
|
||||
for (let i = 0; i < pixelAmount; i++) {
|
||||
|
||||
const _x = i % width;
|
||||
const _y = Math.floor(i / width);
|
||||
|
||||
const r = pixels[i * 3 + 0];
|
||||
const g = pixels[i * 3 + 1];
|
||||
const b = pixels[i * 3 + 2];
|
||||
|
||||
let dist = distance2D(_x, _y, x, y);
|
||||
let colDist = distance3D(r, g, b, _R, _G, _B);
|
||||
|
||||
let weight = 1 - ((dist * distanceWeight) + (colDist * (1 - distanceWeight))) / maxWeight;
|
||||
|
||||
floodFillMap[i] = Math.floor(weight * 255);
|
||||
|
||||
}
|
||||
|
||||
|
||||
return floodFillMap;
|
||||
|
||||
}
|
||||
|
||||
const drawFloodFill = ({ floodMap, image, color, amount }: { floodMap: Uint8ClampedArray, image: Uint8ClampedArray, color: string, amount: number }) => {
|
||||
|
||||
const [r, g, b] = hexToRGB(color);
|
||||
|
||||
|
||||
|
||||
|
||||
return image;
|
||||
|
||||
}
|
||||
|
||||
const send = (result, i) => {
|
||||
//@ts-ignore
|
||||
self.postMessage({ result, i });
|
||||
}
|
||||
|
||||
self.addEventListener('message', function (e) {
|
||||
|
||||
const data = e.data;
|
||||
|
||||
const { i, type = "count" } = data;
|
||||
|
||||
switch (type) {
|
||||
case "count":
|
||||
send(countColors(data), i);
|
||||
break;
|
||||
case "flood_map":
|
||||
send(createFloodFillMap(data), i);
|
||||
break;
|
||||
case "flood_fill":
|
||||
send(drawFloodFill(data), i);
|
||||
break;
|
||||
default:
|
||||
console.error("ERROR IN POXEL WORKER")
|
||||
}
|
||||
|
||||
|
||||
|
||||
}, false);
|
Loading…
x
Reference in New Issue
Block a user