深度画布

This commit is contained in:
lzp
2026-03-20 13:23:00 +08:00
parent 34be403032
commit e00fe21e95
9 changed files with 378 additions and 5 deletions

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.42857 0V1.14286H12.5714V0H16V3.42857H14.8571V12.5714H16V16H12.5714V14.8571H3.42857V16H0V12.5714H1.14286V3.42857H0V0H3.42857ZM2.28571 13.7143H1.14286V14.8571H2.28571V13.7143ZM14.8571 13.7143H13.7143V14.8571H14.8571V13.7143ZM12.5714 2.28571H3.42857V3.42857H2.28571V12.5714H3.42857V13.7143H12.5714V12.5714H13.7143V3.42857H12.5714V2.28571ZM8 4.57143C8.15155 4.57143 8.2969 4.63163 8.40406 4.7388C8.51123 4.84596 8.57143 4.9913 8.57143 5.14286V7.42857H10.8571C11.0087 7.42857 11.154 7.48878 11.2612 7.59594C11.3684 7.7031 11.4286 7.84845 11.4286 8C11.4286 8.15155 11.3684 8.2969 11.2612 8.40406C11.154 8.51123 11.0087 8.57143 10.8571 8.57143H8.57143V10.8571C8.57143 11.0087 8.51123 11.154 8.40406 11.2612C8.2969 11.3684 8.15155 11.4286 8 11.4286C7.84845 11.4286 7.7031 11.3684 7.59594 11.2612C7.48878 11.154 7.42857 11.0087 7.42857 10.8571V8.57143H5.14286C4.9913 8.57143 4.84596 8.51123 4.7388 8.40406C4.63163 8.2969 4.57143 8.15155 4.57143 8C4.57143 7.84845 4.63163 7.7031 4.7388 7.59594C4.84596 7.48878 4.9913 7.42857 5.14286 7.42857H7.42857V5.14286C7.42857 4.9913 7.48878 4.84596 7.59594 4.7388C7.7031 4.63163 7.84845 4.57143 8 4.57143ZM14.8571 1.14286H13.7143V2.28571H14.8571V1.14286ZM2.28571 1.14286H1.14286V2.28571H2.28571V1.14286Z" fill="#0D0D0D"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.42857 0V1.14286H12.5714V0H16V3.42857H14.8571V12.5714H16V16H12.5714V14.8571H3.42857V16H0V12.5714H1.14286V3.42857H0V0H3.42857ZM2.28571 13.7143H1.14286V14.8571H2.28571V13.7143ZM14.8571 13.7143H13.7143V14.8571H14.8571V13.7143ZM12.5714 2.28571H3.42857V3.42857H2.28571V12.5714H3.42857V13.7143H12.5714V12.5714H13.7143V3.42857H12.5714V2.28571ZM10.8571 7.42857C11.0087 7.42857 11.154 7.48878 11.2612 7.59594C11.3684 7.7031 11.4286 7.84845 11.4286 8C11.4286 8.15155 11.3684 8.2969 11.2612 8.40406C11.154 8.51123 11.0087 8.57143 10.8571 8.57143H5.14286C4.9913 8.57143 4.84596 8.51123 4.7388 8.40406C4.63163 8.2969 4.57143 8.15155 4.57143 8C4.57143 7.84845 4.63163 7.7031 4.7388 7.59594C4.84596 7.48878 4.9913 7.42857 5.14286 7.42857H10.8571ZM14.8571 1.14286H13.7143V2.28571H14.8571V1.14286ZM2.28571 1.14286H1.14286V2.28571H2.28571V1.14286Z" fill="#0D0D0D"/>
</svg>

After

Width:  |  Height:  |  Size: 962 B

View File

@@ -0,0 +1,76 @@
<template>
<transition name="fade">
<div v-if="show" class="ai-selectbox-panel">
<div>
<span class="icon"><svg-icon name="dc-add" size="16" /></span>
<span class="label">Add</span>
</div>
<div>
<span class="icon"><svg-icon name="dc-remove" size="16" /></span>
<span class="label">Remove</span>
</div>
<button>创建</button>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, inject, computed, watch } from 'vue'
import depthSlider from './tools/depth-slider.vue'
import { OperationType } from '../tools/layerHelper'
const props = defineProps({
currentTool: { required: true, type: [String, null] }
})
const stateManager = inject('stateManager') as any
const toolManager = inject('toolManager') as any
const showTools = [OperationType.SELECTBOX]
const show = computed(() => showTools.includes(props.currentTool))
</script>
<style lang="less" scoped>
// 淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.ai-selectbox-panel {
position: absolute;
top: 8.8rem;
left: 50%;
transform: translateX(-50%);
height: 4.1rem;
padding: 0 1.7rem;
border: 0.2rem solid #ebebeb;
background: #ffffff;
border-radius: 1.1rem;
box-shadow: 0 1.66rem 2.33rem 0 rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
user-select: none;
gap: 1rem;
> div {
display: flex;
cursor: pointer;
align-items: center;
justify-content: center;
height: 2.6rem;
padding: 0 0.7rem;
border-radius: 0.3rem;
gap: 1rem;
> .label {
font-size: 1.4rem;
color: #000;
}
&.active,
&:hover {
background: rgba(235, 235, 235, 0.9);
}
}
}
</style>

View File

@@ -6,8 +6,10 @@
<template v-if="isReady">
<layer-panel />
<details-panel />
<depth-header-tools @export="exportCanvas" @workbench="(v) => emit('workbench', v)" />
<brush-control-panel :currentTool="toolManager.currentTool.value" />
<ai-selectbox-panel :currentTool="toolManager.currentTool.value" />
<depth-header-tools @export="exportCanvas" @workbench="(v) => emit('workbench', v)" />
<zoom
:zoom="canvasManager.currentZoom.value / 100"
is-home
@@ -30,6 +32,7 @@
import depthHeaderTools from './components/depth-header-tools.vue'
import zoom from '../components/zoom.vue'
import brushControlPanel from './components/brush-control-panel.vue'
import aiSelectboxPanel from './components/ai-selectbox-panel.vue'
// 管理器
import { StateManager } from './manager/StateManager'

View File

@@ -48,7 +48,7 @@
id: config.value.canvasId || null
}
const sData = await saveDepthCanvas(data)
console.log(sData)
const canvasId = sData.id
// base64 转 file 上传转换为 url
const file = base64Tofile(options.url, 'canvas.png')

View File

@@ -1,4 +1,6 @@
import { fabric } from 'fabric-with-all'
import { createId } from '../../tools/tools'
/** 智能框选工具管理器 */
export class AISelectboxToolManager {
// 管理器
@@ -14,6 +16,7 @@ export class AISelectboxToolManager {
this.canvasManager = options.canvasManager
this.stateManager = options.stateManager
this.layerManager = options.layerManager
}
mouseDownEvent(e) {
this.isDragging = true
@@ -39,11 +42,11 @@ export class AISelectboxToolManager {
var height = e.absolutePointer.y - this.startY
var left = this.startX
var top = this.startY
if(width < 0) {
if (width < 0) {
left += width
width = -width
}
if(height < 0) {
if (height < 0) {
top += height
height = -height
}
@@ -60,6 +63,249 @@ export class AISelectboxToolManager {
this.canvasManager.canvas.remove(this.demoObject)
this.canvasManager.canvas.renderAll()
this.createSelectbox()
}
loadImageToObject(url) {
return new Promise((resolve, reject) => {
fabric.Image.fromURL(url, (img) => {
resolve(img);
}, { crossOrigin: "anonymous" });// 防止污染
});
}
rgba = { r: 0, g: 255, b: 0, a: 200 };
selectionStyle = {
stroke: "rgba(255, 77, 71, 1)",
strokeWidth: 1.5,
strokeDashArray: [4, 4],
fill: "rgba(255, 186, 186, 0.5)",
strokeUniform: true, // 保持描边宽度不随缩放改变
// strokeLineCap: "round",// 折线端点样式
// strokeLineJoin: "bevel", // 折线连接样式
// selectable: false,
// evented: false,
excludeFromExport: true,
hoverCursor: "default",
moveCursor: "default",
};
async createSelectbox() {
const url = "http://118.31.39.42:3000/falls/1a48ed3a-1faa-4fcd-bf07-765dba1702c5.png"
const image = await this.loadImageToObject(url)
const canvas = getObjectAlphaToCanvas(image, null, 0, this.rgba);
const fobject = this.canvasManager.canvas.clipPath
// const top = fobject.top - fobject.height * scaleY / 2;
// const left = fobject.left - fobject.width * scaleX / 2;
const scaleY = fobject.scaleY
const scaleX = fobject.scaleX
const top = fobject.top
const left = fobject.left
const arr = traceImageContour(canvas);
let minX = fobject.width;
let minY = fobject.height;
const str = arr.map((v) => {
if (v.x < minX) minX = v.x;
if (v.y < minY) minY = v.y;
return `${v.x} ${v.y}`
}).join(" L ");
const path = new fabric.Path(`M ${str} z`);
path.set({
left: left + minX * scaleX,
top: top + minY * scaleY,
scaleX: scaleX,
scaleY: scaleY,
...this.selectionStyle,
});
const rect1 = new fabric.Rect({
left: 0,
top: 0,
width: 100,
height: 100,
fill: '#f00',
info: {
id: createId("rect"),
name: '矩形图层',
}
})
const rect2 = new fabric.Rect({
left: 200,
top: 200,
width: 100,
height: 100,
fill: '#ff0',
info: {
id: createId("rect"),
name: '矩形图层',
}
})
this.layerManager.createGroupLayer({
child: [rect1, rect2],
})
// this.canvasManager.canvas.add(path)
// this.canvasManager.canvas.renderAll()
}
dispose() { }
}
/**
* 获取对象黑白通道画布
* @param {fabric.Object} object - 要处理的 fabric 对象
* @param {ImageData} revData - 相反的ImageData白通道的相同位置是否为透明revData为白色为透明黑色为不透明
* @param {number} diff - 差值,默认 25
* @param {Object} rgba - 自定义 rgba 值,默认 { r: 255, g: 255, b: 255, a: 255 }
* @param {boolean} isMerge - 是否合并true=合并revDatafalse=反转revData
* @returns {HTMLCanvasElement|null} 包含黑白通道的画布,或 null 如果失败
*/
export function getObjectAlphaToCanvas(object, revData, diff = 30, rgba = { r: 255, g: 255, b: 255, a: 255 }, isMerge = false) {
const image = object.getElement();
if (image.nodeName !== "IMG" && image.nodeName !== "CANVAS") {
console.warn("对象不是图片");
return null;
}
const { width, height } = image;
if (!width || !height) {
console.warn("对象没有元素");
return null;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, width, height);
const data = ctx.getImageData(0, 0, width, height);
for (let i = 0; i < data.data.length; i += 4) {
const r = data.data[i + 0];
const g = data.data[i + 1];
const b = data.data[i + 2];
const a = data.data[i + 3];
const revR = revData?.data[i + 0] || 0;
const revG = revData?.data[i + 1] || 0;
const revB = revData?.data[i + 2] || 0;
const revA = revData?.data[i + 3] || 0;
let isHave = false;
if (r || g || b || a) {
if (revR > diff || revG > diff || revB > diff || revA > diff) {
isHave = false;
} else {
isHave = true;
}
}
if (isMerge && (revR || revG || revB || revA)) isHave = true;
if (isHave) {
data.data[i + 0] = rgba.r;
data.data[i + 1] = rgba.g;
data.data[i + 2] = rgba.b;
data.data[i + 3] = rgba.a;
} else {
data.data[i + 0] = 0;
data.data[i + 1] = 0;
data.data[i + 2] = 0;
data.data[i + 3] = 0;
}
}
ctx.clearRect(0, 0, width, height);
ctx.putImageData(data, 0, 0);
return canvas;
}
/**
* 图片边界跟踪算法(透明底)
* @param {HTMLCanvasElement} canvas - canvas元素
* @param {Number} scale - 缩放比例
* @returns {Array} 边界点数组 [{x, y}, ...]
*/
export function traceImageContour(canvas) {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
// 查找起始点(第一个不透明像素)
let startX = -1;
let startY = -1;
outer: for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = (y * width + x) * 4;
if (data[index + 3] > 0) {
startX = x;
startY = y;
break outer;
}
}
}
if (startX === -1) return []; // 没有不透明像素
// Moore-Neighbor边界跟踪算法
const contour = [];
const visited = new Set();
const directions = [
[-1, 0],
[-1, -1],
[0, -1],
[1, -1],
[1, 0],
[1, 1],
[0, 1],
[-1, 1],
];
let currentX = startX;
let currentY = startY;
let backtrackDir = 4; // 起始方向:右
do {
const pointKey = `${currentX},${currentY}`;
if (!visited.has(pointKey)) {
contour.push({ x: currentX, y: currentY });
visited.add(pointKey);
}
// 从右方向开始顺时针查找
let found = false;
for (let i = 0; i < 8; i++) {
const dir = (backtrackDir + i) % 8;
const dx = directions[dir][0];
const dy = directions[dir][1];
const checkX = currentX + dx;
const checkY = currentY + dy;
if (
checkX >= 0 &&
checkX < width &&
checkY >= 0 &&
checkY < height
) {
const index = (checkY * width + checkX) * 4;
if (data[index + 3] > 0) {
currentX = checkX;
currentY = checkY;
backtrackDir = (dir + 5) % 8; // 下一个开始查找的方向
found = true;
break;
}
}
}
if (!found) break;
} while (
!(currentX === startX && currentY === startY) &&
visited.size < width * height
);
return contour;
}

View File

@@ -29,6 +29,17 @@ fabric.Object.prototype.toObject = function () {
return object
}
fabric.Image.fromURL = (function (originalFromURL) {
return function (url, callback, imgOptions) {
// 为所有图片请求添加 crossOrigin
const options = {
crossOrigin: 'anonymous', // 关键设置
...imgOptions
};
return originalFromURL.call(this, url, callback, options);
};
})(fabric.Image.fromURL);
interface CanvasInitOptions {
canvasRef: any
canvasViewWidth?: number

View File

@@ -151,6 +151,37 @@ export class LayerManager {
if (isActive) this.setActiveID(emptyObject.info.id, false)
return emptyObject
}
/** 创建组图层 */
createGroupLayer(options?: any, isRecord = true, isActive = false) {
const child = options?.child || []
delete options.child
const groupObject = new fabric.Group(child, {
subTargetCheck: true, // 关键:检测子对象
interactive: true, // 启用交互
hasControls: true,
hasBorders: true,
// // 子对象样式
// cornerColor: 'blue',
// cornerSize: 8,
// borderColor: 'green',
// // 允许子对象独立变换
// lockScalingX: false,
// lockScalingY: false,
// lockRotation: false,
info: {
id: createId("group"),
name: '组图层',
...(options?.info || {}),
}
})
// this.setLayerPosition(groupObject)
this.canvasManager.add(groupObject, isRecord)
if (isActive) this.setActiveID(groupObject.info.id, false)
return groupObject
}
/** 创建文本图层 */
async createTextLayer(text: string, options?: any) {
const textObject = new fabric.IText(text, {

View File

@@ -10,7 +10,7 @@
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const url = ''
const url = 'https://www.minio-api.aida.com.hk/fida-test/furniture/sketches/1a48ed3a-1faa-4fcd-bf07-765dba1702c5.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=admin%2F20260320%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260320T020948Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=7dc192bac887bce7b02c99d7037c08d9d684310f00add9b0e63b74b36ee63d37'
const openCanvas = () => {
myEvent.emit('openFlowCanvas', { url })
}