Canvas 实现可交互抠图,让 Midjourney 局部重绘

538 阅读6分钟

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 使用教程,可评论留言。