Midjourney 局部重绘
什么是局部重绘
Midjourney 在生成图片时会给我们生成一组来让我们选择哪个更符合要求,当选择一个作为底图后,Midjourney 还支持继续优化,而局部重绘就是优化操作中的一个,操作名称为Vary(Region)。
为什么需要局部重绘
不难理解,比如某个女星不满意自己的鼻子比较塌,就会选择去做鼻部整形。同样的道理,当我们对底图的某部分细节不是很满意的时候,就可以框选住这部分区域,让 Midjourney 基于底图对框选区域进行重新调整生成。这样进行几轮选择或者局部重绘后,我们才会得到满意的新图片。
局部重绘的框选区域的操作演示如下:
此处为语雀视频卡片,点击链接查看:iShot_2024-04-19_12.09.35.mp4
相比 Midjourney 在 Discord 中的使用,这里增加了拖拽功能,同时在撤销时也支持撤销拖拽的动作。
实现局部重绘
分析需求我们会得到“它是基于 Canvas 实现的”的结论,但是如果我们需要快速上手的话,可以找个 Canvas 库去实现,这样高效一点。
这里选择以 fabric.js 为例,流程如下:****(本文代码仅为逻辑实现,不保证复制可运行!!!)
画布框选区域
1、初始化画布
var canvas = new fabric.Canvas('.fabric-canvas', {
// 是否开启互动
interactive: true,
});
// 是否显示框选区域 默认 true
canvas.selection = true;
// 框选区域背景色
canvas.selectionColor = 'rgba(255, 255, 255, 0.7)';
// 只选择完全包含在框选区域中的形状
canvas.selectionFullyContained = true;
2、设置画布宽高及宽高比
👉要求:底图宽高及宽高比与画布相同
为什么?把我们的画布当做一个容器,底图作为一个物体要放到容器当中去,如果底图大了放不进去,小了会有空隙,影响我们框选底图区域。宽高比相同也是这个道理,但是我们这里主要还是为了方便后续生成 base64 图。
fabric.Image.fromURL('图片地址 url', (img) => {
// 以高作为基准设置画布宽高
canvas.setHeight(400);
canvas.setWidth(400 * img.width / img.height);
// 添加底图
canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), {
left: 0,
top: 0,
originX: 'left',
originY: 'top',
scaleX: canvas.width / img.width, // 调整缩放比例以展示完全
scaleY: canvas.height / img.height, // 调整缩放比例以展示完全
crossOrigin: 'anonymous',
});
}, {
crossOrigin: 'anonymous',
});
3、框选区域
这里我们要同步鼠标选择区域,不过因为除了矩形框选区域之外,我们还有多边形框选,这样就会有两种框选区域交互和展现内容不一致的冲突,这时我们需要考虑以下问题:
❓ 矩形框选时框选区域是矩形,而多边形框选时框选区域还是矩形(后续使用 #1 表示)
❓ 矩形框选时当鼠标移出画布时不绘制,回来后继续绘制,而多边形框选时移出画布依然会绘制(后续使用 #3 表示)
❓ 拖拽绘制区域时还会继续绘制(后续使用 #3 表示)
var startPoint = null;
var endPoint = null;
var tempPolygon = null;
var isDraw = true;
var mode = 'square';
var point = [];
// #1 清除多边形框选绘制区域
clearTempPolygon() {
tempPolygon && canvas.remove(tempPolygon);
}
canvas.on('mouse:out', () => {
// #2 鼠标移出画布时起始点置为null
startPoint = null;
});
canvas.on('mouse:down', (point) => {
// #3 开始绘制
isDraw = true;
// #2 起始点
startPoint = new fabric.Point(point.pointer.x, point.pointer.y);
});
canvas.on('mouse:up', (point) => {
clearTempPolygon();
// 绘制矩形所需点
endPoint = new fabric.Point(point.pointer.x, point.pointer.y);
// 禁止只绘制一个点
if (startPoint && endPoint.eq(startPoint)) return;
if (mode === 'square' && startPoint && isDraw) {
// 绘制矩形区域
const rect = new fabric.Rect({
left: startPoint.x,
top: startPoint.y,
width: endPoint.x - startPoint.x,
height: endPoint.y - startPoint.y,
fill: 'rgba(255, 255, 255, 0.7)',
// 开启拖拽,可旋转、平移、缩放、倾斜
selectable: true,
});
canvas.add(rect);
} else if (mode === 'point') {
// 绘制多边形区域
// #2 排除终止点坐标不在画布区域内
if (endPoint || endPoint.x < 0 || endPoint.x > canvas.width || endPoint.y < 0 || endPoint.y > canvas.height || points.length < 3) return;
startPoint = null;
const polygon = new fabric.Polygon([ ...points ], {
fill: 'rgba(255, 255, 255, 0.7)',
// 开启拖拽,可旋转、平移、缩放、倾斜
selectable: true,
});
canvas.add(polygon);
}
// #1 清除多边形框选绘制区域的数据
points = [];
});
canvas.on('mouse:move', (point) => {
// #3 禁止绘制
if (isDraw) return;
points.push(new fabric.Point(point.pointer.x, point.pointer.y));
clearTempPolygon();
// #1 绘制多边形时鼠标边移动边绘制
if (mode === 'point' && startPoint) {
tempPolygon = new fabric.Polygon([ ...points ], {
fill: 'rgba(255, 255, 255, 0.7)',
selectable: false,
});
canvas.add(tempPolygon);
}
});
4、监听绘制对象操作
这里我们会发现,不管把绘制对象进行旋转、移动、缩放还是倾斜,最终停止操作时都会触发一次 object:modifie 事件,因此后续我们对操作数据进行更新时可以考虑放到 object:modifie 事件里。
canvas.on('object:modified', () => {
// #3 禁止绘制
isDraw = false;
});
// 旋转
canvas.on('object:rotating', () => {
// #3 禁止绘制
isDraw = false;
});
// 缩放
canvas.on('object:scaling', () => {
// #3 禁止绘制
isDraw = false;
});
// 移动
canvas.on('object:moving', () => {
// #3 禁止绘制
isDraw = false;
});
// 倾斜
canvas.on('object:skewing', () => {
// #3 禁止绘制
isDraw = false;
});
撤销操作
我们想想,当我们进行一次操作后,不管是新绘区域还是对原有区域的动作,都需要记录操作数据,因为只有这样方便我们后续一步一步的撤销操作,是不是栈的数据结构比较满足我们的要求呢?
这里我们用数组去模拟栈,当有新操作后,我们在尾部加入整个画布的绘制对象 fabric.Object 数据;当撤销时,我们删除掉尾部最后一条数据,清除画布上的绘制对象,然后把现在的最后一条数据绘制到画布上,让画布重新渲染,这样是不是就实现了呢?
export class CanvasSnapShot {
private canvasSnapShot: fabric.Object[][] = [];
get() {
return this.canvasSnapShot;
}
getLast() {
if (count() < 1) return [];
return this.canvasSnapShot[count() - 1] ?? [];
}
push(val: fabric.Object[]) {
this.canvasSnapShot.push(val);
}
pop() {
return this.canvasSnapShot.pop();
}
count() {
return this.canvasSnapShot.length;
}
}
var canvasSnapShot = new CanvasSnapShot();
// 每次有新操作就更新栈
updateObjects() {
canvasSnapShot.push(canvas.getObjects().map((obj: fabric.Object) => {
return new fabric.Object(obj);
}));
}
// 点击撤销事件
undo() {
// 删除最后一条数据
canvasSnapShot.pop();
// 清除画布上绘制对象
canvas.remove(...canvas.getObjects());
// 更新上一条数据
canvas.add(...canvasSnapShot.getLast().map((obj: fabric.Object) => new fabric.Object(obj)));
// 重新渲染
canvas.renderAll();
}
生成 base64 图
Midjourney 要求传输的 base64 图是黑底白区域,宽高比和图片相同,这样才不会导致选择区域与原图不符合。这里我们为了不影响画布,克隆出一个相似的 Canvas 对象进行操作。
var mask_base64 = '';
createBase64() {
canvas.clone((res: any) => {
// 不需要背景图
res.setBackgroundImage(undefined);
// 黑色底
res.setBackgroundColor('rgba(0, 0, 0, 1)');
// 白色区域
res._objects = res.getObjects().map((obj: any) => {
obj.set('fill', 'rgba(255, 255, 255, 1)');
return obj;
});
// 重新渲染
res.renderAll();
// 转 base64 及修改 base64 格式
mask_base64 = res.toDataURL({
format: 'jpeg',
});
}, [ 'width', 'height' ]); // 克隆要带进去的属性名称
}
清除画布
最后,在组件或者页面销毁时清除画布元素和删除所有事件监听器。
canvas.dispose();
完整代码
代码为 Angular 实现(不通用),因而这里只有思路和遇到的问题的解决方案,如有不理解之处可评论留言或私信。
Midjourney 使用教程
如有需要 Midjourney 使用教程,可评论留言。