Published on

人脸识别增强的 LowPoly 图像风格化(其一)

Authors

背景和前置工作

LowPoly 图像风格化是个很小众的领域。大约从 14 年开始,一个叫 Breno Bitencourt 的巴西艺术家创作了不少 LowPoly 风格的作品,同年,AppStore 里出现了通过图片自动制作 LowPoly 风格图像的 APP。

image

之后 2015 年有两篇挺出名的国人的论文,基本已经把这个方向的探索推到比较完善的程度了。这个方向核心的目标是模仿艺术家绘制 LowPoly 风格图像的效果,自动根据底图制作高质量的 LowPoly 风格抽象画。

  1. 《Low-poly style image and video processing-zhang2015》

核心算法流程:

zhang2015-main-framework
  1. 《Artistic LowPoly rendering for images-gai2015》

核心算法流程:

gai2015-overall-pipeline

都是国内大佬写的论文,一篇硕士论文 zhang2015,一篇博士论文 gai2015。其中博士论文描述的效果非常细腻,在各种场景下表现优异,基本已经到了可以商用的程度。并且博士论文把算法生成结果和真实艺术家创作的作品拿去做用户调查问卷发现,大部分非艺术家的普通用户,已经无法区分计算结果和艺术家创作结果。但硕士论文是 web-based 的实现,充分利用 WebGL 做到了交互级别性能,可以说各有所长。

我很喜欢 LowPoly 风格化,但对这个领域目前“折中”的性能表现不甚满意。2018 年的时候,我有过一个想法,针对图像中有人物的场景强化一下,结合 opencv.js 和 tf.js,并且优化了 zhang2015 论文算法的步骤和实现,在人像的场景下相比 zhang2015 有了不错的改善,并且也能保证在 web 场景下保持交互级别性能。另外,我的算法实现保持使用 SVG 格式输出,后续的处理和应用会有更大的可能性:

CV 领域著名的“lena”图

lena

2018年 LowPoly 风格化渲染结果

lena low poly liang2018

2018 年第一次做 LowPoly 风格化的结果已经比 zhang2015 的结果好不少,但距离 gai2015 的效果还有挺大的差距。三四年过去了,今天我尝试用更新版本的 opencv.js 以及 tf.js 再做一次实验,看看在保证交互性能的前提下,效果有没有比较好的提升。

算法流程描述

human
body segmentation
face landmarks detection
image
unified bitmap
edge detection
polygons
constrained points
distance map
feature flow map
saliency detection
non-uniform sampling
vertex optimization
delaunay triangulation
color post-processing
SVG

这个算法流程基本是基于 gai2015 论文的思路,在此基础上做了人脸识别、身体轮廓识别补充关键点,并且在 Web 的技术栈下实现。同时为了处理性能,所有输入图像会先做归一化(统一缩小到一定尺寸以内)。最后的三角化算法会选择 zhang2015 中的 delaunay traiangulation 而不是 gai2015 里的 voronoi-based 的方法。中间过程中的处理节点,也会根据前端实际的情况综合考虑性能和现有资源做精简或者变更。标红的节点就是整个算法过程中对 gai2015 论文有所变更的节点。

工程实现

Image to Unified Bitmap

第一步对输入图像进行归一化处理,这一步比较简单,只需要把选定文件绘制到宽高限定的 Canvas 画布上即可。这一步暂时不涉及 CV 或者 AI 方面的处理。具体做法就是:

  1. input 选定文件
  2. filereader 读取为 base64 url
  3. 设置 img 节点的 src 并预览
  4. 根据 img 节点的宽高设置 canvas 节点的宽高,并绘制图像

这一步得到的图像将作为后续图像处理的基础,也需要为最后一步上色提供访问支持,所以需要存储起来。

Edge Detection and Constrain Points Sampling

这一步可以说是整个算法最关键的一步。因为要在风格化后的图像中保留图像最关键的轮廓信息,边缘检测是必不可少的。对这一步来说,核心动作主要有两个:其一是找到准确好用的轮廓线条,其二是从密集的轮廓线条中抽样出关键边缘点,作为后续三角化的限定点。

这一步花费了比预定更长的时间,因为 OpenCV 依然没有提供 Edge Drawing 相关 feature 的 js 导出版本。之前 2018 年在做这一步的时候,做了一定的妥协,采用了简单的 Canny Edge 边缘检测,并基于检测结果做等间隔抽样的点提取。当时的做法会导致人物边缘存在很大的冗余点信息,这一方面会使得生成结果与艺术家手绘作品之间有很大的差异,另一方面也导致了整体的性能耗损。Edge Drawing 算法能在图像的边缘处生成单像素的干净的线条,从这样的线条上抽样取点可以大大提升最终绘制效果。要做到这一点,有两条路径:

  1. 把 OpenCV 完整版本加上 opencv_contrib 里图像处理的三方包也编译成 JS 版本,这样可以得到完整功能版本的高性能 Edge Drawing 算法
  2. 基于 JavaScript 手写 Edge Drawing 算法

第一条路主要涉及到 Emscripten 的配置使用和解决各种环境问题(譬如依赖库、动态库 linking、各种 make 参数处理等等)。这一步尝试了很久,直到确认因 Cuda 库依赖无法在不做内部改造的情况下用 Emscripten 打包为止。opencv_contrib 社区打包成功的先例暂时还没有,只有一个声称在 Node.js 环境下可用的 opencv4nodejs,但这个版本也无法生成可独立在浏览器上运行的版本,它是通过直接使用某些 Native 库的方式绕过 Emscripten 打包限制的。并且这个库也很久没有维护了,新版的 4.6.0 使用它提供的脚本一直无法打包成功。

但第一条路的探索也不是毫无收获,跑通 Native to JS 构建之后,我可以按需构建,把官方提供的 10MB 左右的 js 文件,成功降到 1MB 左右。并且很多官方版本不提供的兼容 Emscripten 的特性也可以自行打包使用。未来有其他 Native 库使用的需求也可以通过 Emscripten 完成 js 输出。

要达成进一步的性能表现,只剩下第二条路了。手写 Edge Drawing 算法不算太难,社区也有 Python、C++ 版本的实现可供参考。问题在于 OpenCV.js 从数据结构、API 风格各方面和 Python 版本、原生的 C++ 版本相差太大。途中踩了挺多坑才实现了这个最终结果,相比 zhang2015 论文中使用的 Canny Edge detection 算法,这个结果优异很多:

原图

mickey

Edge Drawing 算法结果(单像素连续细线)

mickey-edge-drawing

Canny Edge 算法结果(不连续的边缘像素区域,无法简化抽样取点)

mickey-canny-edge

这个算法的论文出处:

《Edge drawing- a combined real-time edge and segment detector-topal2012》

算法一共四步(输入要求是灰度图):

  1. 进行高斯滤波处理(平滑图像,去除噪音点)
  2. 获取图像的梯度幅度图和边缘方向图(gradient magnitude、edge direction)
  3. 抽取关键锚点
  4. 使用智能路由算法连接各个锚点,形成单像素连线集合

具体 JS 版本算法实现如下:

cvEdgeDrawing.js

吐槽一下:2022 年了,JS 社区连一个可用的 LSD 算法、EDLine 算法都没有。。。。。。

const GAUSS_SIZE = 5
const GAUSS_SIGMA = 1
const SOBEL_ORDER = 1
const SOBEL_SIZE = 3
const EDGE_HOR = 0
const EDGE_VER = 1
const STATUS_UNKNOWN = 0
const STATUS_BACKGROUND = 1
const STATUS_EDGE = 255
const TRACE_LEFT = 0
const TRACE_RIGHT = 1
const TRACE_UP = 2
const TRACE_DOWN = 3

function getGradient(gray, M, O) {
  const Gx = new cv.Mat()
  const Gy = new cv.Mat()
  cv.Sobel(gray, Gx, cv.CV_16SC1, SOBEL_ORDER, 0, SOBEL_SIZE)
  cv.Sobel(gray, Gy, cv.CV_16SC1, 0, SOBEL_ORDER, SOBEL_SIZE)
  for (let r = 0; r < gray.rows; ++r) {
    for (let c = 0; c < gray.cols; ++c) {
      const dx = Math.abs(Gx.shortPtr(r, c)[0])
      const dy = Math.abs(Gy.shortPtr(r, c)[0])
      M.ushortPtr(r, c)[0] = dx + dy // simplify distance for performance
      // M.ushortPtr(r, c)[0] = Math.sqrt((dx * dx) + (dy * dy)); // actual distance
      O.ucharPtr(r, c)[0] = dx > dy ? EDGE_VER : EDGE_HOR
    }
  }
}
function getAnchors(M, O, proposalThresh, anchorInterval, anchorThresh, anchors) {
  for (let r = 1; r < M.rows - 1; r += anchorInterval) {
    for (let c = 1; c < M.cols - 1; c += anchorInterval) {
      // ignore non-proposal pixels
      // eslint-disable-next-line no-continue
      if (M.ushortPtr(r, c)[0] < proposalThresh) continue
      if (O.ucharPtr(r, c)[0] === EDGE_HOR) {
        // horizontal edge
        if (
          M.ushortPtr(r, c)[0] - M.ushortPtr(r - 1, c)[0] >= anchorThresh &&
          M.ushortPtr(r, c)[0] - M.ushortPtr(r + 1, c)[0] >= anchorThresh
        ) {
          anchors.push(new cv.Point(c, r))
        }
      } else if (
        M.ushortPtr(r, c)[0] - M.ushortPtr(r, c - 1)[0] >= anchorThresh &&
        M.ushortPtr(r, c)[0] - M.ushortPtr(r, c + 1)[0] >= anchorThresh
      ) {
        // vertical edge
        anchors.push(new cv.Point(c, r))
      }
    }
  }
}
function trace(M, O, proposalThresh, ptLast, ptCur, dirLast, pushBack, status, edge) {
  // current direction
  let dirCur
  // repeat until reaches the visited pixel or non-proposal
  // eslint-disable-next-line no-constant-condition
  while (true) {
    // terminate trace if that point has already been visited
    if (status.ucharPtr(ptCur.y, ptCur.x)[0] !== STATUS_UNKNOWN) break
    // set it to background and terminate trace if that point is not a proposal edge
    if (M.ushortPtr(ptCur.y, ptCur.x)[0] < proposalThresh) {
      status.ucharPtr(ptCur.y, ptCur.x)[0] = STATUS_BACKGROUND
      break
    }
    // set point ptCur as edge
    status.ucharPtr(ptCur.y, ptCur.x)[0] = STATUS_EDGE
    if (pushBack) {
      edge.push(new cv.Point(ptCur.x, ptCur.y))
    } else {
      edge.unshift(new cv.Point(ptCur.x, ptCur.y))
    }
    // if its direction is EDGE_HOR, trace left or right
    if (O.ucharPtr(ptCur.y, ptCur.x)[0] === EDGE_HOR) {
      // calculate trace direction
      if (dirLast === TRACE_UP || dirLast === TRACE_DOWN) {
        if (ptCur.x < ptLast.x) {
          dirCur = TRACE_LEFT
        } else {
          dirCur = TRACE_RIGHT
        }
      } else {
        dirCur = dirLast
      }
      // update last state
      ptLast = ptCur
      dirLast = dirCur
      // go left
      if (dirCur === TRACE_LEFT) {
        const leftTop = M.ushortPtr(ptCur.y - 1, ptCur.x - 1)[0]
        const left = M.ushortPtr(ptCur.y, ptCur.x - 1)[0]
        const leftBottom = M.ushortPtr(ptCur.y + 1, ptCur.x - 1)[0]
        // console.log(leftTop, left, leftBottom, leftTop[0], left[0], leftBottom[0]);
        if (leftTop >= left && leftTop >= leftBottom) {
          ptCur = new cv.Point(ptCur.x - 1, ptCur.y - 1)
        } else if (leftBottom >= left && leftBottom >= leftTop) {
          ptCur = new cv.Point(ptCur.x - 1, ptCur.y + 1)
        } else {
          ptCur.x -= 1
        }
        // break if reaches the border of image, the same below
        if (ptCur.x === 0 || ptCur.y === 0 || ptCur.y === M.rows - 1) break
      } else {
        // go right
        const rightTop = M.ushortPtr(ptCur.y - 1, ptCur.x + 1)[0]
        const right = M.ushortPtr(ptCur.y, ptCur.x + 1)[0]
        const rightBottom = M.ushortPtr(ptCur.y + 1, ptCur.x + 1)[0]
        if (rightTop >= right && rightTop >= rightBottom) {
          ptCur = new cv.Point(ptCur.x + 1, ptCur.y - 1)
        } else if (rightBottom >= right && rightBottom >= rightTop) {
          ptCur = new cv.Point(ptCur.x + 1, ptCur.y + 1)
        } else {
          ptCur.x += 1
        }
        if (ptCur.x === M.cols - 1 || ptCur.y === 0 || ptCur.y === M.rows - 1) break
      }
    } else {
      // its direction is EDGE_VER, trace up or down
      // calculate trace direction
      if (dirLast === TRACE_LEFT || dirLast === TRACE_RIGHT) {
        if (ptCur.y < ptLast.y) {
          dirCur = TRACE_UP
        } else {
          dirCur = TRACE_DOWN
        }
      } else {
        dirCur = dirLast
      }
      // update last state
      ptLast = ptCur
      dirLast = dirCur
      // go up
      if (dirCur === TRACE_UP) {
        const leftTop = M.ushortPtr(ptCur.y - 1, ptCur.x - 1)[0]
        const top = M.ushortPtr(ptCur.y - 1, ptCur.x)[0]
        const rightTop = M.ushortPtr(ptCur.y - 1, ptCur.x + 1)[0]
        if (leftTop >= top && leftTop >= rightTop) {
          ptCur = new cv.Point(ptCur.x - 1, ptCur.y - 1)
        } else if (rightTop >= top && rightTop >= leftTop) {
          ptCur = new cv.Point(ptCur.x + 1, ptCur.y - 1)
        } else {
          ptCur.y -= 1
        }
        if (ptCur.y === 0 || ptCur.x === 0 || ptCur.x === M.cols - 1) break
      } else {
        // go down
        const leftBottom = M.ushortPtr(ptCur.y + 1, ptCur.x - 1)[0]
        const bottom = M.ushortPtr(ptCur.y + 1, ptCur.x)[0]
        const rightBottom = M.ushortPtr(ptCur.y + 1, ptCur.x + 1)[0]
        if (leftBottom >= bottom && leftBottom >= rightBottom) {
          ptCur = new cv.Point(ptCur.x - 1, ptCur.y + 1)
        } else if (rightBottom >= bottom && rightBottom >= leftBottom) {
          ptCur = new cv.Point(ptCur.x + 1, ptCur.y + 1)
        } else {
          ptCur.y += 1
        }
        if (ptCur.y === M.rows - 1 || ptCur.x === 0 || ptCur.x === M.cols - 1) break
      }
    }
  }
}
function cloneP(p) {
  return new cv.Point(p.x, p.y)
}
function traceFromAnchor(M, O, proposalThresh, anchor, status, edges) {
  if (status.ucharPtr(anchor.y, anchor.x)[0] !== STATUS_UNKNOWN) return
  const edge = []
  let dirLast

  if (O.ucharPtr(anchor.y, anchor.x)[0] === EDGE_HOR) {
    // if horizontal edge, go left and right
    // go left first
    // assume the last visited point is the right hand side point and TRACE_LEFT to current point, the same below
    dirLast = TRACE_LEFT
    trace(
      M,
      O,
      proposalThresh,
      new cv.Point(anchor.x + 1, anchor.y),
      cloneP(anchor),
      dirLast,
      false,
      status,
      edge
    )
    // reset anchor point
    // it has already been set in the previous traceEdge(),
    // reset it to satisfy the initial while condition, the same below
    status.ucharPtr(anchor.y, anchor.x)[0] = STATUS_UNKNOWN
    // go right then
    dirLast = TRACE_RIGHT
    trace(
      M,
      O,
      proposalThresh,
      new cv.Point(anchor.x - 1, anchor.y),
      cloneP(anchor),
      dirLast,
      true,
      status,
      edge
    )
  } else {
    // vertical edge, go up and down
    // go up first
    dirLast = TRACE_UP
    trace(
      M,
      O,
      proposalThresh,
      new cv.Point(anchor.x, anchor.y + 1),
      cloneP(anchor),
      dirLast,
      false,
      status,
      edge
    )
    // reset anchor point
    status.ucharPtr(anchor.y, anchor.x)[0] = STATUS_UNKNOWN
    // go down then
    dirLast = TRACE_DOWN
    trace(
      M,
      O,
      proposalThresh,
      new cv.Point(anchor.x, anchor.y - 1),
      cloneP(anchor),
      dirLast,
      true,
      status,
      edge
    )
  }
  edges.push(edge)
}
function cvEdgeDrawing(src, proposalThresh = 36, anchorInterval = 2, anchorThresh = 8) {
  // Step1. gauss blur
  const gray = new cv.Mat()
  cv.GaussianBlur(src, gray, new cv.Size(GAUSS_SIZE, GAUSS_SIZE), GAUSS_SIGMA, 0, cv.BORDER_DEFAULT)
  dumpResult(gray)
  // Step2. gradient magnitude and orientation
  const M = cv.Mat.zeros(gray.rows, gray.cols, cv.CV_16UC1)
  const O = cv.Mat.zeros(gray.rows, gray.cols, cv.CV_8UC1)
  getGradient(gray, M, O)
  // Step3. get anchors
  const anchors = []
  getAnchors(M, O, proposalThresh, anchorInterval, anchorThresh, anchors)
  // Step4. trace edges from anchors
  const status = new cv.Mat(gray.rows, gray.cols, cv.CV_8UC1, new cv.Scalar(STATUS_UNKNOWN)) // init
  const edges = []
  anchors.forEach((anchor) => {
    traceFromAnchor(M, O, proposalThresh, anchor, status, edges)
  })
  return edges
}
// module.exports = detectEdges;

基于 OpenCV.js 写图像处理算法有几点需要注意的:

  1. 关注数据类型,尤其是核心的 Mat 类(数字是正整数还是无符号数,是 8 位、16 位还是 32 位)
  2. 注意坐标系,Mat 存储像素点的顺序是先 Rows 后 Cols,但我们理解的 x、y、各种涉及到点的结构是反过来的
  3. js 版本对 Mat 等数据的获取和更改和别的语言完全不一样

进一步的抽样取点就比较简单了,使用经典的 Ramer-Douglas-Peucker line simplify 算法做关键点提取。这里需要注意一点,就是如果是连续、方向比较固定的线,使用 line simplify 算法会导致中间点缺失。所以在边简化之前,必须先把长边拆分成一定间隔的短边,以保留短边端点。另外,为了最终结果中能包含全图比较均匀的切分效果,我们需要人为地把图像四条边框线也加入到边线中来。

lineSimplify.js (Ramer-Douglas-Peucker algorithm)

function getSqDist(p1, p2) {
  const dx = p1.x - p2.x
  const dy = p1.y - p2.y
  return dx * dx + dy * dy
}

function getSqSegDist(p, p1, p2) {
  let { x, y } = p1
  let dx = p2.x - x
  let dy = p2.y - y

  if (dx !== 0 || dy !== 0) {
    const t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy)
    if (t > 1) {
      x = p2.x
      y = p2.y
    } else if (t > 0) {
      x += dx * t
      y += dy * t
    }
  }
  dx = p.x - x
  dy = p.y - y
  return dx * dx + dy * dy
}

// basic distance-based simplification
function simplifyRadialDist(points, sqTolerance) {
  let prevPoint = points[0]
  const newPoints = [prevPoint]
  let point

  for (let i = 1, len = points.length; i < len; i += 1) {
    point = points[i]
    if (getSqDist(point, prevPoint) > sqTolerance) {
      newPoints.push(point)
      prevPoint = point
    }
  }
  if (prevPoint !== point) newPoints.push(point)
  return newPoints
}

function simplifyDPStep(points, first, last, sqTolerance, simplified) {
  let maxSqDist = sqTolerance
  let index

  for (let i = first + 1; i < last; i += 1) {
    const sqDist = getSqSegDist(points[i], points[first], points[last])
    if (sqDist > maxSqDist) {
      index = i
      maxSqDist = sqDist
    }
  }
  if (maxSqDist > sqTolerance) {
    if (index - first > 1) simplifyDPStep(points, first, index, sqTolerance, simplified)
    simplified.push(points[index])
    if (last - index > 1) simplifyDPStep(points, index, last, sqTolerance, simplified)
  }
}

// simplification using Ramer-Douglas-Peucker algorithm
function simplifyDouglasPeucker(points, sqTolerance) {
  const last = points.length - 1
  const simplified = [points[0]]
  simplifyDPStep(points, 0, last, sqTolerance, simplified)
  simplified.push(points[last])
  return simplified
}

// both algorithms combined for awesome performance
function lineSimplify(points, tolerance, highestQuality) {
  if (points.length <= 2) return points
  const sqTolerance = tolerance !== undefined ? tolerance * tolerance : 1
  points = highestQuality ? points : simplifyRadialDist(points, sqTolerance)
  points = simplifyDouglasPeucker(points, sqTolerance)
  return points
}
// module.exports = lineSimplify;

最后,做好点简化之后得到的受控边缘关键点如下:

mickey-edge-drawing-sampling-points

关键的轮廓点信息有了,下一步就是从图像边缘点以外的地方合理取点,补全图像里的其他三角化所需的取样点,以形成视觉效果更好的结果,避免过多的锐角三角形。

Saliency Object Detection (TODO/FIXME)

这一步主要是区分显著区域(Saliency Region)和背景区域,目的是为了模仿艺术家对作品所做的“背景虚化”处理。显著区域检测(Saliency Object Detection)是一个很成熟的领域,甚至有专门的综述论文是研究如何对比不同 SOD 算法效果的:DengPingFan/SODBenchmark。但这些研究以 C++、Matlab、Python 的实现为主,并且现代的研究方向从单纯的图像信息提取,扩展到了用 AI 辅助识别,基本都要结合 AI 框架进行,JavaScript 社区在纯 Web 环境下相关的工具链是一片空白。Node.JS 大约有两个基于 Native binding 的 OpenCV.js 协作的 SOD 算法可以使用,但接口用法各方面和 Web 版本差异很大。这个步骤要手写算法实现的话,涉及的数学公式很复杂,我打算和 gai2015 论文的作者联系讨论一下,选择一个合适的 SOD 算法再行实现。也去看看最近基于 AI 的论文的思路,后面和大家再分享一次。

PS:这个步骤跳过仍然可以生成质量显著高于 zhang2015 论文的结果,并且 tf.js 处理的部分对于 gai2015 论文中提到的“缺乏高层次图像信息”的缺点也有明显改善。

替代区分显著区域 + 背景区域取点的方式,在图像处理领域也有一个很小的方向,就是 Simulating Mosaics。其实这一步也可以用一些固定的 pattern 在固定轮廓点的基础上补充其他点,可以产生不错的“背景花纹装饰”的效果。但为了更贴近原图,这里采用一个 js 库产生随机三角纹理点:qrohlf/trianglify。这个库也是一个小众,但是热度非常高的一个 js 库。补充好随机点后的 triangle mesh:

mickey-random-sampling-points

trianglify 有个“cellSize”配置项,刚好和我们 corner 边最长取样长度、anchor 点的取样间隔一致,这样可以保证生成的图像大多数三角形接近等边,使人视觉觉得比较平衡和谐的结果。不过 trianglify 的取点和图像的特征无关,并且属于随机取点,后续补充 saliency object detection 相关的算法可以让这个抽样结果更精细更接近真实的艺术创作作品。

::: !【CAUTION】实验结果证明,边缘以外的空白区域取点,其实很影响最终结果。。。Vertex Optimization 这一步事实证明是非常关键的,但 gai2015 对此语焉不详,无从窥探论文的原始实现思路。这方面的探索放到下一篇补充。 :::

Face Landmarks Detection

这个步骤就比较简单了,直接应用 tf.js,使用 face-landmarks-detection 模型,可以直接获取描绘人脸特征的 468 个面部三维位置点。但这些点放置到三角化的绘图点列表之前,还有一些可以优化的空间,譬如根据脸部占图像大小的比例(模型会返回面部的 Rectangle Box)去对标记面部的 468 个点进行再次抽样,这样可以避免人脸附近点过于密集而损失了成图的艺术性(理论上描绘人脸轮廓的点越多,最终 LowPoly 风格化后的人脸图像和原图越接近,但这种“精确性”或许不是风格化想要的)TODO。

使用 tf.js 比较麻烦的事情是需要载入比较大的预训练模型,并且适合 Web 端使用的模型很有限。不过 tf.js 毕竟是 Google 官方开发和构建发布的,长期使用应该没有问题。

woman-face-landmarks

Body Segmentation (TODO)

这个模型对图像输出有一定的优化效果,毕竟是抽象层次比较高的信息,对单纯的图像信道信息而言有“降维”的优势。但躯体轮廓信息的优先级并不高,并且这一步在做完 Edge Detection 以及 Saliency Object Detection 之后会显得不那么重要,所以本次也暂且按下不表。

Delaunay Triangulation

这个领域有一个 js 原生的非常强大的库,甚至“反哺”了其他语言的社区:mapbox/delaunator 这是 Mapbox 官方出品的开源库,是这个领域性能最强的实现没有之一。其原理也可以单表一篇,最主要参考了这篇论文的成果:

《A Simple Sweep-line Delaunay Triangulation Algorithm.pdf》

主要做的事情就是把一堆二维平面的点连接起来,变成一个一个三角形,并且保证给定平面不存在三角形没有覆盖的区域,并且保证尽可能是非“长锐角”或者“大钝角”三角形。使用这个库需要注意一件事情,就是它为了追求性能,设计的数据结构非常奇葩。

lena-delaunay-triangulation

Coloring & Post-Processing (TODO/FIXME)

颜色选择这一步我也回退到了 zhang2015 论文的实现,不出意外地,最终颜色表现并没有很好,尤其轮廓边缘和图像边缘部分,还有些突兀。时间问题,优化还是留待后续。

这一步的思路很简单粗暴,创建一个 SVG 节点,把所有三角形绘制出来,然后根据三角形的重心去确认着色:

const svg = `<svg class="${DUMPING_CANVAS_CLASS}" viewBox="0 0 ${unifiedSize[0]} ${
  unifiedSize[1]
}" xmlns="http://www.w3.org/2000/svg">
  <g>
  ${triangles.map((t) => {
    const c = t.gravityCenter
    const data = ctx.getImageData(c[0], c[1], 1, 1).data
    const fillColor = `rgb(${data[0]}, ${data[1]}, ${data[2]})`
    // eslint-disable-next-line max-len
    return `<polygon fill="${fillColor}" class="triangle" points="${t.points
      .map((p) => `${p[0]},${p[1]}`)
      .join(' ')}"/>`
  })}
  </g>
</svg>`

gai2015 论文在这一步也做了不少向前一步的探索,它的选色可以有效避免轮廓边缘的突兀着色。但具体算法实现在原文仍然没有体现,还需要后续实验探索。

lena-lowpoly-v1

以上就是目前为止做到的效果,可以看到人脸轮廓确实有所优化,但有一个最大的败笔就是对前景和背景不分青红皂白统一随机选点,以补充轮廓点以外的填充点。这使得图像中多出很多比较突兀的选点和尖锐的、破坏了轮廓的、不均衡三角形。

总结反思以及下一步

  1. 从短期来看,快比正确更重要;从长期来看,正确比快更重要
  2. 做好充分的事前调研和准备,站在巨人的肩膀上。思路上大胆假设,实践上小心求证
  3. 介绍一下 Emscripten,native2js 的神器。已经有很多官方、半官方支持的编译库:
  • opencv.js (opencv)
  • tf.js (tensorflow)
  • sql.js (sqlite)
  • viz.js (graphviz)
  • ffmpeg.js (ffmpeg)
  • skia-js (skia)
  • ocrad.js (ocrad)
  • ammo.js (bullet physics engine)
  • instascan (ZXing QR code scanning)
  • miniaudio
  • quiet-js (libquiet)
  • box2d.js (box2d)
  • imGUI-js (imGUI)
  1. 上周五到周三,除正事外大部分时间都搭在这里面了,对比原论文,完成度不足一半。。。博士论文含金量确实都很高。另外,无论从我目前探索的结果还是 gai2015 论文探索的结果来看,虽然是 LowPoly 风格化这么垂直、这么小众的一个领域,短短几年时间的研究,远远不足以真正“计算替代设计”。也许“计算辅助设计”仍然会是未来很长时间内的主流
  2. 隐约觉得避免随机选点是提升 LowPoly 风格化效果比较核心的点,尤其应该对前景背景进行分割,并采取不同的采样策略

相关链接