Быстрый способ заливки контуров с возможностью экспорта в полигональный формат - PullRequest
2 голосов
/ 08 мая 2020

У меня есть несколько медицинских картинок, представленных на холсте, как пример ниже.

knee

Я пытаюсь сделать инструмент, который позволяет выделить любую область изображения с помощью инструмента в виде расширяемого круг и заполните только ту его часть, которая не выходит за пределы контура go, в котором находился исходный пиксель щелчка. Контур с заливкой рисуется на отдельном слое холста.

Сейчас я использую наиболее распространенную итеративную реализацию стека заливки заливкой с переменным допуском (функция сравнения). Ознакомиться с ним можно здесь . Все работает не очень хорошо, особенно на фотографиях, где нет сильных контрастов, а на изображениях с высоким разрешением все остальное работает довольно медленно.

flood fill result

У меня возникла идея создать контейнер состояния и посмотреть, существует ли там желаемый заполненный контур, и если да, затем просто замените массив пикселей холста (хотя, опять же, мне придется прибегнуть к некоторой дополнительной обработке, массив пикселей холста содержит 4 канала, а на выходе алгоритма получается только 1, и просто замена содержимого не работает , вам нужно каждый раз заменять каждый пиксель на пиксель, разделенный на 4 канала) вместо медленной заливки. Но у этого подхода есть одна существенная проблема: потребление памяти . Как нетрудно догадаться, заполненный контур, особенно одного лишь приличного разрешения, может занимать довольно много места, и их набор становится настоящей проблемой потребления памяти.

Готовые контуры решено было сохранить. в виде многоугольников и извлекая их из контейнера, просто заполните их более быстрой контекстной заливкой . Используемый алгоритм позволяет мне вывести набор границ, но из-за особенностей алгоритма этот массив неупорядочен и, соединяя вершины в этом порядке, мы получаем только частично заполненный контур (правый рисунок). Есть ли способ отсортировать их таким образом, чтобы я мог только соединить их и получить замкнутый путь (дыры, которые находятся в закрашенном контуре на левом рисунке, не должны быть априори, поэтому нам не о чем беспокоиться о них)?

flood fillresulted path based on boundaries

Подводя итог, я думаю, из-за не самой лучшей работы по заполнению использовать другой алгоритм / реализацию, но я не знаю какой. Вот некоторые из моих идей:

  1. Используйте другую реализацию, например, метод строчной развертки. Насколько мне известно, вот одна из самых быстрых и эффективных реализаций алгоритма среди открытых источников . Плюсы: возможная эффективность и скорость. Минусы: нужно как-то преобразовать результат в многоугольник, переписать алгоритм на javascript (наверное emscripten , может хорошо, но в любом случае придется переписывать значительную часть кода ).

  2. Используйте совершенно другой подход.

    a) Я не знаю, но, возможно, детектор Canny может быть полезен для извлечения многоугольника. Но поскольку использование программы подразумевается на стороне клиента, извлекать все границы будет невыгодно, необходимо разобраться, как обрабатывать только нужный участок, а не всю картинку.

    б) Затем, зная границу, используйте любой достаточно быстрый алгоритм заполнения, который просто go не выйдет за найденные границы.

Я буду рад узнать о некоторых другими способами, а еще лучше увидеть готовые реализации в javascript

UPD:

Для лучшего понимания курсора инструмента и ожидаемого результата алгоритмы представлены ниже.

tool cursorexpected result

1 Ответ

2 голосов
/ 09 мая 2020

Вот пример с opencv

Ниже должно работать или, в конечном итоге, использовать ссылку на скрипт , предоставленную внутри фрагмента кода

Интересно: прибл. ваши потребности (проверьте алгоритм Рамера-Дугласа-Пекера )

// USE FIDDLE
// https://jsfiddle.net/c7xrq1uy/

async function loadSomeImage() {
  const ctx = document.querySelector('#imageSrc').getContext('2d')
  ctx.fillStyle = 'black'
  const img = new Image()
  img.crossOrigin = ''
  img.src = 'https://cors-anywhere.herokuapp.com/https://i.stack.imgur.com/aiZ7z.png'

  img.onload = () => {
    const imgwidth = img.offsetWidth
    const imgheight = img.offsetHeight
    ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, 400, 400) 
  }
}

function plotPoints(canvas, points, color = 'green', hold = false){
  const ctx = canvas.getContext('2d')
  !hold && ctx.clearRect(0, 0, 400, 400)
  ctx.strokeStyle = color

  Object.values(points).forEach(ps => {
    ctx.beginPath()
    ctx.moveTo(ps[0].x, ps[0].y)
    ps.slice(1).forEach(({ x, y }) => ctx.lineTo(x,y))
    ctx.closePath()
    ctx.stroke()
  })
}
const binarize = (src, threshold) => {
  cv.cvtColor(src, src, cv.COLOR_RGB2GRAY, 0)
  const dst = new cv.Mat()
  src.convertTo(dst, cv.CV_8U)
  cv.threshold(src, dst, threshold, 255, cv.THRESH_BINARY_INV)
  cv.imshow('binary', dst)
  return dst
}
const flip = src => {
  const dst = new cv.Mat()
  cv.threshold(src, dst, 128, 255, cv.THRESH_BINARY_INV)
  cv.imshow('flip', dst)
  return dst
}
const dilate = (src) => {
  const dst = new cv.Mat()
  let M = cv.Mat.ones(3, 3, cv.CV_8U)
  let anchor = new cv.Point(-1, -1)
  cv.dilate(src, dst, M, anchor, 1, cv.BORDER_CONSTANT, cv.morphologyDefaultBorderValue())
  M.delete()
  cv.imshow('dilate', dst)
  return dst
}
const PARAMS = {
  threshold: 102,
  anchor: { x: 180, y: 180 },
  eps: 1e-2
}
const dumpParams = ({ threshold, anchor, eps }) => {
  document.querySelector('#params').innerHTML = `thres=${threshold} (x,y)=(${anchor.x}, ${anchor.y}) eps:${eps}`
}
document.querySelector('input[type=range]').onmouseup = e => {
  PARAMS.threshold = Math.round(parseInt(e.target.value, 10) / 100 * 255)
  dumpParams(PARAMS)
  runCv(PARAMS)
}
document.querySelector('input[type=value]').onchange = e => {
  PARAMS.eps = parseFloat(e.target.value)
  dumpParams(PARAMS)
  runCv(PARAMS)
}
document.querySelector('#imageSrc').onclick = e => {
  const rect = e.target.getBoundingClientRect()
  PARAMS.anchor = {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  }
  dumpParams(PARAMS)
  runCv(PARAMS)
}
const contourToPoints = cnt => {
  const arr = []
  for (let j = 0; j < cnt.data32S.length; j += 2){
    let p = {}
    p.x = cnt.data32S[j]
    p.y = cnt.data32S[j+1]
    arr.push(p)
  }
  return arr
}
loadSomeImage()
dumpParams(PARAMS)
let CVREADY
const cvReady = new Promise((resolve, reject) => CVREADY = resolve)

const runCv = async ({ threshold, anchor, eps }) => {
  await cvReady
  const canvasFinal = document.querySelector('#final')
  const mat = cv.imread(document.querySelector('#imageSrc'))
  const binaryImg = binarize(mat, threshold, 'binary')
  const blurredImg = dilate(binaryImg)
  const flipImg = flip(blurredImg)
  var contours = new cv.MatVector()
  const hierarchy = new cv.Mat
  cv.findContours(flipImg, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)

  const points = {}
  let matchingPoints = null
  let matchingContour = null
  for (let i = 0; i < contours.size(); ++i) {
  let minArea = 1e40
    const ci = contours.get(i)
    points[i] = contourToPoints(ci)
    if (anchor) {
      const point = new cv.Point(anchor.x, anchor.y)
      const inside = cv.pointPolygonTest(ci, point, false) >= 1
      const area = cv.contourArea(ci)
      if (inside && area < minArea) {
        matchingPoints = points[i]
        matchingContour = ci
        minArea = area
      }
    }
  }
  plotPoints(canvasFinal, points)

  if (anchor) {
    if (matchingPoints) {
      plotPoints(canvasFinal, [matchingPoints], 'red', true)
      if (eps) {
        const epsilon = eps * cv.arcLength(matchingContour, true)
        const approx = new cv.Mat()
        cv.approxPolyDP(matchingContour, approx, epsilon, true)
        const arr = contourToPoints(approx)
        console.log('polygon', arr)
        plotPoints(canvasFinal, [arr], 'blue', true)
      }
    }
  }
  mat.delete()
  contours.delete()
  hierarchy.delete()
  binaryImg.delete()
  blurredImg.delete()
  flipImg.delete()
}
function onOpenCvReady() {
  cv['onRuntimeInitialized'] = () => {console.log('cvready'); CVREADY(); runCv(PARAMS)}
}
// just so we can load async script
var script = document.createElement('script');
script.onload = onOpenCvReady
script.src = 'https://docs.opencv.org/master/opencv.js';
document.head.appendChild(script)
canvas{border: 1px solid black;}
  .debug{width: 200px; height: 200px;}
binarization threeshold<input type="range" min="0" max="100"/><br/>
eps(approxPolyDp) <input type="value" placeholder="0.01"/><br/>
params: <span id="params"></span><br/>
<br/>
<canvas id="imageSrc" height="400" width="400"/></canvas>
<canvas id="final" height="400" width="400"></canvas>
<br/>
<canvas class="debug" id="binary" height="400" width="400" title="binary"></canvas>
<canvas class="debug" id="dilate" height="400" width="400" title="dilate"></canvas>
<canvas class="debug" id="flip" height="400" width="400" title="flip"></canvas>

ps: в консоль выводится многоугольник


приложение с маской

редактировать: в Ниже фрагмента я повеселился и применил маску. Мы можем сделать фрагмент [полная страница], а затем навести курсор на первый холст.

// USE FIDDLE
// https://jsfiddle.net/c7xrq1uy/

async function loadSomeImage() {
  const ctx = document.querySelector('#imageSrc').getContext('2d')
  ctx.fillStyle = 'black'
  const img = new Image()
  img.crossOrigin = ''
  img.src = 'https://cors-anywhere.herokuapp.com/https://i.stack.imgur.com/aiZ7z.png'

  img.onload = () => {
    const imgwidth = img.offsetWidth
    const imgheight = img.offsetHeight
    ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, 400, 400) 
  }
}

function plotPoints(canvas, points, color = 'green', hold = false){
  const ctx = canvas.getContext('2d')
  !hold && ctx.clearRect(0, 0, 400, 400)
  ctx.strokeStyle = color

  Object.values(points).forEach(ps => {
    ctx.beginPath()
    ctx.moveTo(ps[0].x, ps[0].y)
    ps.slice(1).forEach(({ x, y }) => ctx.lineTo(x,y))
    ctx.closePath()
    ctx.stroke()
  })
}
const binarize = (src, threshold) => {
  cv.cvtColor(src, src, cv.COLOR_RGB2GRAY, 0)
  const dst = new cv.Mat()
  src.convertTo(dst, cv.CV_8U)
  cv.threshold(src, dst, threshold, 255, cv.THRESH_BINARY_INV)
  cv.imshow('binary', dst)
  return dst
}
const flip = src => {
  const dst = new cv.Mat()
  cv.threshold(src, dst, 128, 255, cv.THRESH_BINARY_INV)
  cv.imshow('flip', dst)
  return dst
}
const dilate = (src) => {
  const dst = new cv.Mat()
  let M = cv.Mat.ones(3, 3, cv.CV_8U)
  let anchor = new cv.Point(-1, -1)
  cv.dilate(src, dst, M, anchor, 1, cv.BORDER_CONSTANT, cv.morphologyDefaultBorderValue())
  M.delete()
  cv.imshow('dilate', dst)
  return dst
}
const PARAMS = {
  threshold: 102,
  anchor: { x: 180, y: 180 },
  eps: 1e-2,
  radius: 50
}
const dumpParams = ({ threshold, anchor, eps }) => {
  document.querySelector('#params').innerHTML = `thres=${threshold} (x,y)=(${anchor.x}, ${anchor.y}) eps:${eps}`
}
document.querySelector('input[type=range]').onmouseup = e => {
  PARAMS.threshold = Math.round(parseInt(e.target.value, 10) / 100 * 255)
  dumpParams(PARAMS)
  runCv(PARAMS)
}
document.querySelector('input[type=value]').onchange = e => {
  PARAMS.eps = parseFloat(e.target.value)
  dumpParams(PARAMS)
  runCv(PARAMS)
}
document.querySelector('#imageSrc').onclick = e => {
  const rect = e.target.getBoundingClientRect()
  PARAMS.anchor = {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  }
  dumpParams(PARAMS)
  runCv(PARAMS)
}
// sorry for the globals, keep code simple
let DST = null
let MATCHING_CONTOUR = null
let DEBOUNCE = 0
document.querySelector('#imageSrc').onmousemove = e => {
  if (Date.now() - DEBOUNCE < 100) return
  if (!MATCHING_CONTOUR || !DST) { return }
  const rect = e.target.getBoundingClientRect()
  DEBOUNCE = Date.now()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top
  const dst = DST.clone()
  plotIntersectingMask(dst, MATCHING_CONTOUR, { anchor: { x, y }, radius: PARAMS.radius })
  dst.delete()
}
const contourToPoints = cnt => {
  const arr = []
  for (let j = 0; j < cnt.data32S.length; j += 2){
    let p = {}
    p.x = cnt.data32S[j]
    p.y = cnt.data32S[j+1]
    arr.push(p)
  }
  return arr
}
const plotIntersectingMask = (dst, cnt, { anchor, radius }) => {
  const { width, height } = dst.size()
  
  const contourMask = new cv.Mat.zeros(height, width, dst.type())
  const matVec = new cv.MatVector()
  matVec.push_back(cnt)
  cv.fillPoly(contourMask, matVec, [255, 255, 255, 255])

  const userCircle = new cv.Mat.zeros(height, width, dst.type())
  cv.circle(userCircle, new cv.Point(anchor.x, anchor.y), radius, [255, 128, 68, 255], -2)

  const commonMask = new cv.Mat.zeros(height, width, dst.type())
  cv.bitwise_and(contourMask, userCircle, commonMask)
  
  userCircle.copyTo(dst, commonMask)
  cv.imshow('final', dst)

  commonMask.delete()
  matVec.delete()
  contourMask.delete()
  userCircle.delete()
}
loadSomeImage()
dumpParams(PARAMS)
let CVREADY
const cvReady = new Promise((resolve, reject) => CVREADY = resolve)

const runCv = async ({ threshold, anchor, eps, radius }) => {
  await cvReady
  const canvasFinal = document.querySelector('#final')
  const mat = cv.imread(document.querySelector('#imageSrc'))
  const binaryImg = binarize(mat, threshold, 'binary')
  const blurredImg = dilate(binaryImg)
  const flipImg = flip(blurredImg)
  var contours = new cv.MatVector()
  const hierarchy = new cv.Mat
  cv.findContours(flipImg, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)

  const points = {}
  let matchingPoints = null
  let matchingContour = null
  for (let i = 0; i < contours.size(); ++i) {
  let minArea = 1e40
    const ci = contours.get(i)
    points[i] = contourToPoints(ci)
    if (anchor) {
      const point = new cv.Point(anchor.x, anchor.y)
      const inside = cv.pointPolygonTest(ci, point, false) >= 1
      const area = cv.contourArea(ci)
      if (inside && area < minArea) {
        matchingPoints = points[i]
        matchingContour = ci
        minArea = area
      }
    }
  }
  plotPoints(canvasFinal, points)

  if (anchor) {
    if (matchingPoints) {
      MATCHING_CONTOUR = matchingContour
      plotPoints(canvasFinal, [matchingPoints], 'red', true)
      if (eps) {
        const epsilon = eps * cv.arcLength(matchingContour, true)
        const approx = new cv.Mat()
        cv.approxPolyDP(matchingContour, approx, epsilon, true)
        const arr = contourToPoints(approx)
        //console.log('polygon', arr)
        plotPoints(canvasFinal, [arr], 'blue', true)

        if (DST) DST.delete()
        DST = cv.imread(document.querySelector('#final'))
      }
    }
  }
  mat.delete()
  contours.delete()
  hierarchy.delete()
  binaryImg.delete()
  blurredImg.delete()
  flipImg.delete()
}
function onOpenCvReady() {
  cv['onRuntimeInitialized'] = () => {console.log('cvready'); CVREADY(); runCv(PARAMS)}
}
// just so we can load async script
var script = document.createElement('script');
script.onload = onOpenCvReady
script.src = 'https://docs.opencv.org/master/opencv.js';
document.head.appendChild(script)
  canvas{border: 1px solid black;}
  .debug{width: 200px; height: 200px;}
  #imageSrc{cursor: pointer;}
binarization threeshold<input type="range" min="0" max="100"/><br/>
eps(approxPolyDp) <input type="value" placeholder="0.01"/><br/>
params: <span id="params"></span><br/>
<br/>
<canvas id="imageSrc" height="400" width="400"/></canvas>
<canvas id="final" height="400" width="400"></canvas>
<br/>
<canvas class="debug" id="binary" height="400" width="400" title="binary"></canvas>
<canvas class="debug" id="dilate" height="400" width="400" title="dilate"></canvas>
<canvas class="debug" id="flip" height="400" width="400" title="flip"></canvas>
...