Как выполнить заливку на холсте HTML в JavaScript?

По ссылке ниже приведен снимок экрана моего холста (внешняя рамка - это холст). Внутреннее поле - это серая рамка, а линия - это линия, нарисованная на холсте. Как создать функцию заливки, которая заполняет весь холст (кроме внутреннего серого поля и линии) определенным цветом?

Функция должна принимать только три переменные: x y и цвет, как показано ниже, но я не знаю, как продолжить:

floodFill(x, y, color) {
    this.canvasColor[x][y] = color;

    this.floodFill(x-1, y, color);
    this.floodFill(x+1, y, color);
    this.floodFill(x, y-1, color);
    this.floodFill(x, y+1, color);
}

Изображение холста


person user3345791    schedule 31.10.2018    source источник
comment
Просто установите цвет всего холста, а затем заполните внутреннее поле белым цветом.   -  person smnbbrv    schedule 31.10.2018
comment
Как нарисован исходный рисунок? drawImage ()? rect ()? lineTo ()?   -  person Sven Liivak    schedule 31.10.2018


Ответы (1)


Чтобы создать заливку, вам нужно иметь возможность посмотреть на пиксели, которые уже есть, и убедиться, что они не того цвета, с которого вы начали, так что что-то вроде этого.

const ctx = document.querySelector("canvas").getContext("2d");

ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();

floodFill(ctx, 40, 50, [255, 0, 0, 255]);

function getPixel(imageData, x, y) {
  if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) {
    return [-1, -1, -1, -1];  // impossible color
  } else {
    const offset = (y * imageData.width + x) * 4;
    return imageData.data.slice(offset, offset + 4);
  }
}

function setPixel(imageData, x, y, color) {
  const offset = (y * imageData.width + x) * 4;
  imageData.data[offset + 0] = color[0];
  imageData.data[offset + 1] = color[1];
  imageData.data[offset + 2] = color[2];
  imageData.data[offset + 3] = color[0];
}

function colorsMatch(a, b) {
  return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
}

function floodFill(ctx, x, y, fillColor) {
  // read the pixels in the canvas
  const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  // get the color we're filling
  const targetColor = getPixel(imageData, x, y);
  
  // check we are actually filling a different color
  if (!colorsMatch(targetColor, fillColor)) {
  
    fillPixel(imageData, x, y, targetColor, fillColor);
    
    // put the data back
    ctx.putImageData(imageData, 0, 0);
  }
}

function fillPixel(imageData, x, y, targetColor, fillColor) {
  const currentColor = getPixel(imageData, x, y);
  if (colorsMatch(currentColor, targetColor)) {
    setPixel(imageData, x, y, fillColor);
    fillPixel(imageData, x + 1, y, targetColor, fillColor);
    fillPixel(imageData, x - 1, y, targetColor, fillColor);
    fillPixel(imageData, x, y + 1, targetColor, fillColor);
    fillPixel(imageData, x, y - 1, targetColor, fillColor);
  }
}
<canvas></canvas>

Однако с этим кодом есть как минимум 2 проблемы.

  1. Это глубоко рекурсивно.

    Таким образом, у вас может закончиться место в стеке

  2. Это медленно.

    Понятия не имею, слишком ли он медленный, но JavaScript в браузере в основном однопоточный, поэтому, пока выполняется этот код, браузер зависает. Для большого холста это замороженное время может сделать страницу очень медленной, и если она заморожена слишком долго, браузер спросит, хочет ли пользователь убить страницу.

Решением проблемы нехватки места в стеке является реализация нашего собственного стека. Например, вместо рекурсивного вызова fillPixel мы могли бы сохранить массив позиций, на которые мы хотим смотреть. Мы добавляли 4 позиции в этот массив, а затем извлекали элементы из массива, пока он не стал пустым.

const ctx = document.querySelector("canvas").getContext("2d");

ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();

floodFill(ctx, 40, 50, [255, 0, 0, 255]);

function getPixel(imageData, x, y) {
  if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) {
    return [-1, -1, -1, -1];  // impossible color
  } else {
    const offset = (y * imageData.width + x) * 4;
    return imageData.data.slice(offset, offset + 4);
  }
}

function setPixel(imageData, x, y, color) {
  const offset = (y * imageData.width + x) * 4;
  imageData.data[offset + 0] = color[0];
  imageData.data[offset + 1] = color[1];
  imageData.data[offset + 2] = color[2];
  imageData.data[offset + 3] = color[0];
}

function colorsMatch(a, b) {
  return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
}

function floodFill(ctx, x, y, fillColor) {
  // read the pixels in the canvas
  const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  // get the color we're filling
  const targetColor = getPixel(imageData, x, y);
  
  // check we are actually filling a different color
  if (!colorsMatch(targetColor, fillColor)) {
  
    const pixelsToCheck = [x, y];
    while (pixelsToCheck.length > 0) {
      const y = pixelsToCheck.pop();
      const x = pixelsToCheck.pop();
      
      const currentColor = getPixel(imageData, x, y);
      if (colorsMatch(currentColor, targetColor)) {
        setPixel(imageData, x, y, fillColor);
        pixelsToCheck.push(x + 1, y);
        pixelsToCheck.push(x - 1, y);
        pixelsToCheck.push(x, y + 1);
        pixelsToCheck.push(x, y - 1);
      }
    }
    
    // put the data back
    ctx.putImageData(imageData, 0, 0);
  }
}
<canvas></canvas>

Решение проблемы слишком медленной работы - либо заставить его работать понемногу ИЛИ переместить это к работнику. Я думаю, что это слишком много, чтобы показать в том же ответе, хотя вот пример. Я протестировал приведенный выше код на холсте 4096x4096, и мне потребовалось 16 секунд, чтобы заполнить пустой холст на моей машине, так что да, он, возможно, слишком медленный, но размещение его в рабочем создает новые проблемы, которые заключаются в том, что результат будет асинхронным, поэтому даже если браузер не зависнет, вы, вероятно, захотите запретить пользователю что-то делать, пока оно не завершится.

Другая проблема заключается в том, что вы увидите, что линии сглажены, и поэтому заливка сплошным цветом закрывает линию, но не полностью до нее. Чтобы исправить это, вы можете изменить colorsMatch на проверку достаточно близко, но тогда у вас возникнет новая проблема: если targetColor и fillColor также достаточно близко, он будет продолжать попытки заполниться. Вы можете решить эту проблему, создав другой массив, один байт или один бит на пиксель, для отслеживания мест, которые вы уже проверили.

const ctx = document.querySelector("canvas").getContext("2d");

ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);
ctx.stroke();

floodFill(ctx, 40, 50, [255, 0, 0, 255], 128);

function getPixel(imageData, x, y) {
  if (x < 0 || y < 0 || x >= imageData.width || y >= imageData.height) {
    return [-1, -1, -1, -1];  // impossible color
  } else {
    const offset = (y * imageData.width + x) * 4;
    return imageData.data.slice(offset, offset + 4);
  }
}

function setPixel(imageData, x, y, color) {
  const offset = (y * imageData.width + x) * 4;
  imageData.data[offset + 0] = color[0];
  imageData.data[offset + 1] = color[1];
  imageData.data[offset + 2] = color[2];
  imageData.data[offset + 3] = color[0];
}

function colorsMatch(a, b, rangeSq) {
  const dr = a[0] - b[0];
  const dg = a[1] - b[1];
  const db = a[2] - b[2];
  const da = a[3] - b[3];
  return dr * dr + dg * dg + db * db + da * da < rangeSq;
}

function floodFill(ctx, x, y, fillColor, range = 1) {
  // read the pixels in the canvas
  const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  // flags for if we visited a pixel already
  const visited = new Uint8Array(imageData.width, imageData.height);
  
  // get the color we're filling
  const targetColor = getPixel(imageData, x, y);
  
  // check we are actually filling a different color
  if (!colorsMatch(targetColor, fillColor)) {

    const rangeSq = range * range;
    const pixelsToCheck = [x, y];
    while (pixelsToCheck.length > 0) {
      const y = pixelsToCheck.pop();
      const x = pixelsToCheck.pop();
      
      const currentColor = getPixel(imageData, x, y);
      if (!visited[y * imageData.width + x] &&
           colorsMatch(currentColor, targetColor, rangeSq)) {
        setPixel(imageData, x, y, fillColor);
        visited[y * imageData.width + x] = 1;  // mark we were here already
        pixelsToCheck.push(x + 1, y);
        pixelsToCheck.push(x - 1, y);
        pixelsToCheck.push(x, y + 1);
        pixelsToCheck.push(x, y - 1);
      }
    }
    
    // put the data back
    ctx.putImageData(imageData, 0, 0);
  }
}
<canvas></canvas>

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

Обновлять

Другой способ ускорить процесс - это, конечно, просто оптимизировать код. Кайидо указал на очевидное ускорение, которое заключается в использовании Uint32Array просмотра пикселей. Таким образом, ища пиксель и устанавливая пиксель, остается только одно 32-битное значение для чтения или записи. Одно это изменение делает его примерно в 4 раза быстрее. Однако для заполнения холста размером 4096x4096 пикселей по-прежнему требуется 4 секунды. Может быть другая оптимизация, например, вместо вызова getPixels сделать это встроенным, но не вставляйте новый пиксель в наш список пикселей, чтобы проверить, находятся ли они вне диапазона. Это может быть 10% ускорение (без понятия), но не сделает его достаточно быстрым, чтобы быть интерактивной.

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

Это усложнит алгоритмы, поэтому лучше оставить их на ваше усмотрение.

person gman    schedule 31.10.2018
comment
Для заливки лучше работать с Uint32Array. getPixel становится простым поиском, а setPixel - единственной операцией. - person Kaiido; 31.10.2018
comment
Хороший момент, хотя проверка близости становится на 0,1% сложнее: P - person gman; 31.10.2018
comment
мой старый компьютер покончил жизнь самоубийством, когда я установил точку заполнения вне многоугольника (floodFill (ctx, 0, 0, [255, 0, 0, 255], 128);) - person Sven Liivak; 01.11.2018
comment
есть ли какой-нибудь пример кода, который делает это с помощью Uint32Array? - person Chanwoo Park; 20.05.2019
comment
@ChanwooPark есть ссылка на версию с Uint32Array в ответе внизу - person gman; 20.05.2019
comment
@gman есть ли какие-либо возможные способы преобразовать шестнадцатеричный цвет (например, # 2268d8) или значение rgba в форму 0xFF000000? - person Chanwoo Park; 22.05.2019
comment
В сети есть много кода для этого, но творческим решением было бы создать холст размером 1x1 пиксель, установить fillStyle на нужный цвет, заполнить пиксель 1x1, вызвать getImageData, чтобы получить пиксель, получить представление Uint32Array для этих пиксель - person gman; 22.05.2019
comment
Можете ли вы подробнее рассказать об алгоритме ColorMatching, который может заполнять пиксели, не пропуская пиксели? function colorsMatch(a, b, rangeSq) { const dr = a[0] - b[0]; const dg = a[1] - b[1]; const db = a[2] - b[2]; const da = a[3] - b[3]; return dr * dr + dg * dg + db * db + da * da < rangeSq; //especially this line } (почему этот блок кода сформирован так некрасиво в комментарии) что означает квадрат каждого оцененного цвета в сумме меньше квадрата значения диапазона? - person Chanwoo Park; 29.05.2019
comment
Это просто расстояние от точки. Расстояние между двумя точками - это квадратный корень из суммы квадратов разницы между каждым компонентом. length = sqrt(a^2 + b^2 + c^2 + d^2) то же самое, что и distance = sqrt((ax - bx)^2 + (ay - by)^2 + (az - bz)^2 + (aw - bw)^2), но sqrt работает медленно, поэтому, если все, что вы хотите сделать, это проверить distance < maxDistance, вы можете так же легко сравнить distanceSq < maxDistanceSq и избежать извлечения квадратного корня. Однако сравнение цветов - это проблема восприятия. Вам следует использовать алгоритмы сравнения перцептивного цвета в Google или что-то в этом роде, если вам нужны другие решения. - person gman; 29.05.2019
comment
@gman снова привет :) версия bucket fill Uint32Array исчерпала стек вызовов при попытке bucketfill незакрытых чертежей. Я предполагаю, что нет защищающей логики, продолжающей помещать пиксели в стек. какая-нибудь хорошая идея для этой ситуации? - person Chanwoo Park; 27.06.2019
comment
Нет стека, поэтому я не понимаю, что вы имеете в виду, говоря о том, что стек закончился. Вышеупомянутый алгоритм - это простейшая версия, не рассчитанная на то, чтобы быть эффективной. Если вам нужна более эффективная версия, выполняйте больше работы на каждой итерации. Например, нарисуйте влево, вправо, вверх и вниз, пока не установите флажок перпендикулярно, чтобы увидеть, нужно ли добавлять новые места для проверки, добавляйте новые места только в том случае, если они еще не были посещены, и если вы еще не добавили соседний пиксель. Я уверен, что есть тысячи способов ускорить его и использовать меньше памяти. - person gman; 27.06.2019
comment
Простое изменение, вероятно, состоит в том, чтобы изменить pixelsToCheck.push(x + 1, y) на pushPixelToCheckOnlyIfNotAMatch(x + 1, y) и написать функцию, которая проверяет, действительно ли этот пиксель нужно добавить в массив вещей для проверки. - person gman; 27.06.2019
comment
Множество других алгоритмов здесь и здесь - person gman; 27.06.2019