Как убрать дефекты выпуклости на площади Судоку?


193

Я занимался веселым проектом: Решением судоку из входного изображения с использованием OpenCV (как в Google Goggles и т. Д.). И я выполнил задание, но в конце обнаружил небольшую проблему, ради которой я пришел сюда.

Я занимался программированием с использованием Python API OpenCV 2.3.1.

Ниже то, что я сделал:

  1. Прочитайте изображение
  2. Найти контуры
  3. Выберите тот, который имеет максимальную площадь (и также несколько эквивалентен квадрату).
  4. Найдите угловые точки.

    например, дано ниже:

    введите описание изображения здесь

    ( Обратите внимание, что зеленая линия правильно совпадает с истинной границей судоку, поэтому судоку можно правильно деформировать . Проверьте следующее изображение)

  5. деформировать изображение в идеальный квадрат

    например, изображение:

    введите описание изображения здесь

  6. Выполнить OCR (для которого я использовал метод, который я дал в Простом распознавании цифр OCR в OpenCV-Python )

И метод работал хорошо.

Проблема:

Проверьте это изображение.

Выполнение шага 4 на этом изображении дает следующий результат:

введите описание изображения здесь

Красная линия - это оригинальный контур, который является истинным контуром границы судоку.

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

Что, конечно, есть разница между зеленой линией и красной линией на верхнем краю судоку. Таким образом, пока я не искажаю исходную границу судоку.

Мой вопрос :

Как можно деформировать изображение на правильной границе судоку, то есть на красной линии ИЛИ как убрать разницу между красной линией и зеленой линией? Есть ли способ для этого в OpenCV?


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

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

@ Дугал: Я думаю, что нарисованная зеленая линия является приблизительной прямой линией красной линии. так что это линия между этими угловыми точками. Когда я искажаюсь по зеленой линии, я получаю изогнутую красную линию вверху искаженного изображения. (я надеюсь, вы понимаете, мое объяснение кажется немного плохим)
Абид Рахман К

@ EMS: я думаю, что красная линия нарисована точно на границе судоку. Но проблема в том, как деформировать изображение именно на границе судоку. (я имею в виду, проблема заключается в деформации, то есть преобразовании изогнутой границы в точный квадрат, как я показал на втором изображении)
Абид Рахман K

Ответы:


252

У меня есть решение, которое работает, но вам придется самостоятельно перевести его на OpenCV. Это написано в Mathematica.

Первый шаг - настроить яркость изображения, разделив каждый пиксель в результате операции закрытия:

src = ColorConvert[Import["http://davemark.com/images/sudoku.jpg"], "Grayscale"];
white = Closing[src, DiskMatrix[5]];
srcAdjusted = Image[ImageData[src]/ImageData[white]]

введите описание изображения здесь

Следующий шаг - найти область судоку, чтобы я мог игнорировать (маскировать) фон. Для этого я использую анализ связанных компонентов и выбираю компонент с наибольшей выпуклой областью:

components = 
  ComponentMeasurements[
    ColorNegate@Binarize[srcAdjusted], {"ConvexArea", "Mask"}][[All, 
    2]];
largestComponent = Image[SortBy[components, First][[-1, 2]]]

введите описание изображения здесь

Заполняя это изображение, я получаю маску для сетки судоку:

mask = FillingTransform[largestComponent]

введите описание изображения здесь

Теперь я могу использовать производный фильтр 2-го порядка, чтобы найти вертикальные и горизонтальные линии в двух отдельных изображениях:

lY = ImageMultiply[MorphologicalBinarize[GaussianFilter[srcAdjusted, 3, {2, 0}], {0.02, 0.05}], mask];
lX = ImageMultiply[MorphologicalBinarize[GaussianFilter[srcAdjusted, 3, {0, 2}], {0.02, 0.05}], mask];

введите описание изображения здесь

Я снова использую анализ связанных компонентов, чтобы извлечь линии сетки из этих изображений. Линии сетки намного длиннее цифр, поэтому я могу использовать длину штангенциркуля для выбора только компонентов, соединенных линиями сетки. Сортируя их по положению, я получаю маски 2х10 для каждой вертикальной / горизонтальной линии сетки на изображении:

verticalGridLineMasks = 
  SortBy[ComponentMeasurements[
      lX, {"CaliperLength", "Centroid", "Mask"}, # > 100 &][[All, 
      2]], #[[2, 1]] &][[All, 3]];
horizontalGridLineMasks = 
  SortBy[ComponentMeasurements[
      lY, {"CaliperLength", "Centroid", "Mask"}, # > 100 &][[All, 
      2]], #[[2, 2]] &][[All, 3]];

введите описание изображения здесь

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

centerOfGravity[l_] := 
 ComponentMeasurements[Image[l], "Centroid"][[1, 2]]
gridCenters = 
  Table[centerOfGravity[
    ImageData[Dilation[Image[h], DiskMatrix[2]]]*
     ImageData[Dilation[Image[v], DiskMatrix[2]]]], {h, 
    horizontalGridLineMasks}, {v, verticalGridLineMasks}];

введите описание изображения здесь

Последний шаг - определить две функции интерполяции для отображения X / Y через эти точки и преобразовать изображение с помощью этих функций:

fnX = ListInterpolation[gridCenters[[All, All, 1]]];
fnY = ListInterpolation[gridCenters[[All, All, 2]]];
transformed = 
 ImageTransformation[
  srcAdjusted, {fnX @@ Reverse[#], fnY @@ Reverse[#]} &, {9*50, 9*50},
   PlotRange -> {{1, 10}, {1, 10}}, DataRange -> Full]

введите описание изображения здесь

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


3
О Боже !!!!!!!!! Это было чудесно. Это действительно здорово. Я постараюсь сделать это в OpenCV. Надеюсь, вы поможете мне с подробностями об определенных функциях и терминологии ... Спасибо.
Абид Рахман К

@arkiaz: Я не эксперт OpenCV, но я помогу, если смогу, конечно.
Ники,

Не могли бы вы объяснить, для чего используется функция «закрытие»? Что я имею в виду, что происходит в фоновом режиме? В документации сказано, что закрытие удаляет соль и перец? Является ли закрытие фильтра низких частот?
Абид Рахман К

2
Удивительный ответ! Откуда у вас появилась идея деления на закрытие для нормализации яркости изображения? Я пытаюсь повысить скорость этого метода, поскольку деление с плавающей запятой на мобильных телефонах мучительно медленное. Есть ли у вас какие-либо предложения? @AbidRahmanK
1 ''

1
@ 1 *: я думаю, что это называется "настройка белого изображения". Не спрашивайте меня, где я читал об этом, это стандартный инструмент для обработки изображений. Идея, лежащая в основе этой идеи, проста: количество света, отраженного от (ламбертовской) поверхности, является просто поверхностной яркостью, умноженной на количество света, которое белое тело будет отражать в том же положении. Оцените видимую яркость белого тела в той же позиции, разделите фактическую яркость на это, и вы получите яркость поверхности.
Ники

209

Ответ Ники решил мою проблему, но он был в Mathematica. Поэтому я подумал, что мне следует дать здесь адаптацию OpenCV. Но после реализации я увидел, что код OpenCV намного больше, чем код mathematica nikie. Кроме того, я не смог найти метод интерполяции, сделанный nikie в OpenCV (хотя это можно сделать с помощью scipy, я скажу это, когда придет время).

1. Предварительная обработка изображения (операция закрытия)

import cv2
import numpy as np

img = cv2.imread('dave.jpg')
img = cv2.GaussianBlur(img,(5,5),0)
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
mask = np.zeros((gray.shape),np.uint8)
kernel1 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(11,11))

close = cv2.morphologyEx(gray,cv2.MORPH_CLOSE,kernel1)
div = np.float32(gray)/(close)
res = np.uint8(cv2.normalize(div,div,0,255,cv2.NORM_MINMAX))
res2 = cv2.cvtColor(res,cv2.COLOR_GRAY2BGR)

Результат:

Результат закрытия

2. Найти площадь Судоку и создать изображение маски

thresh = cv2.adaptiveThreshold(res,255,0,1,19,2)
contour,hier = cv2.findContours(thresh,cv2.RETR_TREE,cv2.CHAIN_APPROX_SIMPLE)

max_area = 0
best_cnt = None
for cnt in contour:
    area = cv2.contourArea(cnt)
    if area > 1000:
        if area > max_area:
            max_area = area
            best_cnt = cnt

cv2.drawContours(mask,[best_cnt],0,255,-1)
cv2.drawContours(mask,[best_cnt],0,0,2)

res = cv2.bitwise_and(res,mask)

Результат:

введите описание изображения здесь

3. Нахождение вертикальных линий

kernelx = cv2.getStructuringElement(cv2.MORPH_RECT,(2,10))

dx = cv2.Sobel(res,cv2.CV_16S,1,0)
dx = cv2.convertScaleAbs(dx)
cv2.normalize(dx,dx,0,255,cv2.NORM_MINMAX)
ret,close = cv2.threshold(dx,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)
close = cv2.morphologyEx(close,cv2.MORPH_DILATE,kernelx,iterations = 1)

contour, hier = cv2.findContours(close,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
for cnt in contour:
    x,y,w,h = cv2.boundingRect(cnt)
    if h/w > 5:
        cv2.drawContours(close,[cnt],0,255,-1)
    else:
        cv2.drawContours(close,[cnt],0,0,-1)
close = cv2.morphologyEx(close,cv2.MORPH_CLOSE,None,iterations = 2)
closex = close.copy()

Результат:

введите описание изображения здесь

4. Нахождение горизонтальных линий

kernely = cv2.getStructuringElement(cv2.MORPH_RECT,(10,2))
dy = cv2.Sobel(res,cv2.CV_16S,0,2)
dy = cv2.convertScaleAbs(dy)
cv2.normalize(dy,dy,0,255,cv2.NORM_MINMAX)
ret,close = cv2.threshold(dy,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)
close = cv2.morphologyEx(close,cv2.MORPH_DILATE,kernely)

contour, hier = cv2.findContours(close,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
for cnt in contour:
    x,y,w,h = cv2.boundingRect(cnt)
    if w/h > 5:
        cv2.drawContours(close,[cnt],0,255,-1)
    else:
        cv2.drawContours(close,[cnt],0,0,-1)

close = cv2.morphologyEx(close,cv2.MORPH_DILATE,None,iterations = 2)
closey = close.copy()

Результат:

введите описание изображения здесь

Конечно, этот не так хорош.

5. Нахождение точек сетки

res = cv2.bitwise_and(closex,closey)

Результат:

введите описание изображения здесь

6. Исправление дефектов

Здесь Ники делает какую-то интерполяцию, о которой я мало что знаю. И я не мог найти соответствующую функцию для этого OpenCV. (может быть, это там, я не знаю).

Проверьте этот SOF, который объясняет, как сделать это с помощью SciPy, который я не хочу использовать: Преобразование изображений в OpenCV

Итак, здесь я взял 4 угла каждого квадрата и применил перспективу деформации к каждому.

Для этого сначала найдем центроиды.

contour, hier = cv2.findContours(res,cv2.RETR_LIST,cv2.CHAIN_APPROX_SIMPLE)
centroids = []
for cnt in contour:
    mom = cv2.moments(cnt)
    (x,y) = int(mom['m10']/mom['m00']), int(mom['m01']/mom['m00'])
    cv2.circle(img,(x,y),4,(0,255,0),-1)
    centroids.append((x,y))

Но получившиеся центроиды не будут отсортированы. Проверьте ниже изображение, чтобы увидеть их порядок:

введите описание изображения здесь

Таким образом, мы сортируем их слева направо, сверху вниз.

centroids = np.array(centroids,dtype = np.float32)
c = centroids.reshape((100,2))
c2 = c[np.argsort(c[:,1])]

b = np.vstack([c2[i*10:(i+1)*10][np.argsort(c2[i*10:(i+1)*10,0])] for i in xrange(10)])
bm = b.reshape((10,10,2))

Теперь смотрите ниже их порядок:

введите описание изображения здесь

Наконец мы применяем преобразование и создаем новое изображение размером 450x450.

output = np.zeros((450,450,3),np.uint8)
for i,j in enumerate(b):
    ri = i/10
    ci = i%10
    if ci != 9 and ri!=9:
        src = bm[ri:ri+2, ci:ci+2 , :].reshape((4,2))
        dst = np.array( [ [ci*50,ri*50],[(ci+1)*50-1,ri*50],[ci*50,(ri+1)*50-1],[(ci+1)*50-1,(ri+1)*50-1] ], np.float32)
        retval = cv2.getPerspectiveTransform(src,dst)
        warp = cv2.warpPerspective(res2,retval,(450,450))
        output[ri*50:(ri+1)*50-1 , ci*50:(ci+1)*50-1] = warp[ri*50:(ri+1)*50-1 , ci*50:(ci+1)*50-1].copy()

Результат:

введите описание изображения здесь

Результат почти такой же, как у nikie, но длина кода велика. Может быть, есть лучшие методы, но до тех пор это работает хорошо.

С уважением ARK.


4
«Я предпочитаю сбой моего приложения, чем получение неправильных ответов». <- Я тоже согласен на это 100%
Виктор Шер

Спасибо, его реальный ответ дает Ники. Но это было в Mathematica, поэтому я просто преобразовал его в OpenCV. Таким образом, реальный ответ получил достаточно голосов, я думаю
Абид Рахман K

Ах, не видел, вы также отправили вопрос :)
Виктор Sehr

Да. Вопрос тоже мой. Мое и Ники ответят только в конце. У него есть какая-то функция интерполяции в mathematica, которая не в numpy или opencv (но она есть в Scipy, но я не хотел использовать Scipy здесь)
Абид Рахман K

Я получаю сообщение об ошибке: вывод [ri * 50: (ri + 1) * 50-1, ci * 50: (ci + 1) * 50-1] = деформация [ri * 50: (ri + 1) * 50- 1, ci * 50: (ci + 1) * 50-1] .copy TypeError: long () аргумент должен быть строкой или числом, а не 'builtin_function_or_method'
user898678

6

Вы можете попытаться использовать какое-то сеточное моделирование произвольной деформации. А поскольку судоку уже является сеткой, это не должно быть слишком сложно.

Таким образом, вы можете попытаться определить границы каждого субрегиона 3х3, а затем деформировать каждый регион в отдельности. Если обнаружение пройдет успешно, это даст вам лучшее приближение.


1

Я хочу добавить, что описанный выше метод работает только тогда, когда доска судоку стоит прямо, в противном случае проверка соотношения высоты / ширины (или наоборот), скорее всего, провалится, и вы не сможете обнаружить края судоку. (Я также хочу добавить, что если линии, которые не перпендикулярны границам изображения, операции sobel (dx и dy) будут по-прежнему работать, так как линии по-прежнему будут иметь края относительно обеих осей.)

Чтобы иметь возможность обнаруживать прямые линии, вы должны работать с контурным или пиксельным анализом, таким как contourArea / boundingRectArea, верхняя левая и нижняя правая точки ...

Изменить: мне удалось проверить, образует ли набор контуров линию или нет, применив линейную регрессию и проверив ошибку. Однако линейная регрессия выполняется плохо, когда наклон линии слишком велик (т. Е.> 1000) или очень близок к 0. Поэтому применение вышеуказанного теста отношения (в ответе с наибольшим количеством голосов) перед линейной регрессией логично и действительно работает для меня.


1

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

До гамма-коррекции

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

После гамма коррекции

Код является:

gamma = 0.8
invGamma = 1/gamma
table = np.array([((i / 255.0) ** invGamma) * 255
                  for i in np.arange(0, 256)]).astype("uint8")
cv2.LUT(img, table, img)

Это в дополнение к ответу Абида Рахмана, если отсутствуют некоторые угловые точки.


0

Я думал, что это отличный пост и отличное решение от ARK; очень хорошо продуман и объяснен.

Я работал над аналогичной проблемой, и построил все это. Были некоторые изменения (например, xrange to range, аргументы в cv2.findContours), но это должно работать из коробки (Python 3.5, Anaconda).

Это компиляция вышеперечисленных элементов с добавлением некоторого недостающего кода (т. Е. Маркировка точек).

'''

/programming/10196198/how-to-remove-convexity-defects-in-a-sudoku-square

'''

import cv2
import numpy as np

img = cv2.imread('test.png')

winname="raw image"
cv2.namedWindow(winname)
cv2.imshow(winname, img)
cv2.moveWindow(winname, 100,100)


img = cv2.GaussianBlur(img,(5,5),0)

winname="blurred"
cv2.namedWindow(winname)
cv2.imshow(winname, img)
cv2.moveWindow(winname, 100,150)

gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
mask = np.zeros((gray.shape),np.uint8)
kernel1 = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(11,11))

winname="gray"
cv2.namedWindow(winname)
cv2.imshow(winname, gray)
cv2.moveWindow(winname, 100,200)

close = cv2.morphologyEx(gray,cv2.MORPH_CLOSE,kernel1)
div = np.float32(gray)/(close)
res = np.uint8(cv2.normalize(div,div,0,255,cv2.NORM_MINMAX))
res2 = cv2.cvtColor(res,cv2.COLOR_GRAY2BGR)

winname="res2"
cv2.namedWindow(winname)
cv2.imshow(winname, res2)
cv2.moveWindow(winname, 100,250)

 #find elements
thresh = cv2.adaptiveThreshold(res,255,0,1,19,2)
img_c, contour,hier = cv2.findContours(thresh,cv2.RETR_TREE,cv2.CHAIN_APPROX_SIMPLE)

max_area = 0
best_cnt = None
for cnt in contour:
    area = cv2.contourArea(cnt)
    if area > 1000:
        if area > max_area:
            max_area = area
            best_cnt = cnt

cv2.drawContours(mask,[best_cnt],0,255,-1)
cv2.drawContours(mask,[best_cnt],0,0,2)

res = cv2.bitwise_and(res,mask)

winname="puzzle only"
cv2.namedWindow(winname)
cv2.imshow(winname, res)
cv2.moveWindow(winname, 100,300)

# vertical lines
kernelx = cv2.getStructuringElement(cv2.MORPH_RECT,(2,10))

dx = cv2.Sobel(res,cv2.CV_16S,1,0)
dx = cv2.convertScaleAbs(dx)
cv2.normalize(dx,dx,0,255,cv2.NORM_MINMAX)
ret,close = cv2.threshold(dx,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)
close = cv2.morphologyEx(close,cv2.MORPH_DILATE,kernelx,iterations = 1)

img_d, contour, hier = cv2.findContours(close,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
for cnt in contour:
    x,y,w,h = cv2.boundingRect(cnt)
    if h/w > 5:
        cv2.drawContours(close,[cnt],0,255,-1)
    else:
        cv2.drawContours(close,[cnt],0,0,-1)
close = cv2.morphologyEx(close,cv2.MORPH_CLOSE,None,iterations = 2)
closex = close.copy()

winname="vertical lines"
cv2.namedWindow(winname)
cv2.imshow(winname, img_d)
cv2.moveWindow(winname, 100,350)

# find horizontal lines
kernely = cv2.getStructuringElement(cv2.MORPH_RECT,(10,2))
dy = cv2.Sobel(res,cv2.CV_16S,0,2)
dy = cv2.convertScaleAbs(dy)
cv2.normalize(dy,dy,0,255,cv2.NORM_MINMAX)
ret,close = cv2.threshold(dy,0,255,cv2.THRESH_BINARY+cv2.THRESH_OTSU)
close = cv2.morphologyEx(close,cv2.MORPH_DILATE,kernely)

img_e, contour, hier = cv2.findContours(close,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)

for cnt in contour:
    x,y,w,h = cv2.boundingRect(cnt)
    if w/h > 5:
        cv2.drawContours(close,[cnt],0,255,-1)
    else:
        cv2.drawContours(close,[cnt],0,0,-1)

close = cv2.morphologyEx(close,cv2.MORPH_DILATE,None,iterations = 2)
closey = close.copy()

winname="horizontal lines"
cv2.namedWindow(winname)
cv2.imshow(winname, img_e)
cv2.moveWindow(winname, 100,400)


# intersection of these two gives dots
res = cv2.bitwise_and(closex,closey)

winname="intersections"
cv2.namedWindow(winname)
cv2.imshow(winname, res)
cv2.moveWindow(winname, 100,450)

# text blue
textcolor=(0,255,0)
# points green
pointcolor=(255,0,0)

# find centroids and sort
img_f, contour, hier = cv2.findContours(res,cv2.RETR_LIST,cv2.CHAIN_APPROX_SIMPLE)
centroids = []
for cnt in contour:
    mom = cv2.moments(cnt)
    (x,y) = int(mom['m10']/mom['m00']), int(mom['m01']/mom['m00'])
    cv2.circle(img,(x,y),4,(0,255,0),-1)
    centroids.append((x,y))

# sorting
centroids = np.array(centroids,dtype = np.float32)
c = centroids.reshape((100,2))
c2 = c[np.argsort(c[:,1])]

b = np.vstack([c2[i*10:(i+1)*10][np.argsort(c2[i*10:(i+1)*10,0])] for i in range(10)])
bm = b.reshape((10,10,2))

# make copy
labeled_in_order=res2.copy()

for index, pt in enumerate(b):
    cv2.putText(labeled_in_order,str(index),tuple(pt),cv2.FONT_HERSHEY_DUPLEX, 0.75, textcolor)
    cv2.circle(labeled_in_order, tuple(pt), 5, pointcolor)

winname="labeled in order"
cv2.namedWindow(winname)
cv2.imshow(winname, labeled_in_order)
cv2.moveWindow(winname, 100,500)

# create final

output = np.zeros((450,450,3),np.uint8)
for i,j in enumerate(b):
    ri = int(i/10) # row index
    ci = i%10 # column index
    if ci != 9 and ri!=9:
        src = bm[ri:ri+2, ci:ci+2 , :].reshape((4,2))
        dst = np.array( [ [ci*50,ri*50],[(ci+1)*50-1,ri*50],[ci*50,(ri+1)*50-1],[(ci+1)*50-1,(ri+1)*50-1] ], np.float32)
        retval = cv2.getPerspectiveTransform(src,dst)
        warp = cv2.warpPerspective(res2,retval,(450,450))
        output[ri*50:(ri+1)*50-1 , ci*50:(ci+1)*50-1] = warp[ri*50:(ri+1)*50-1 , ci*50:(ci+1)*50-1].copy()

winname="final"
cv2.namedWindow(winname)
cv2.imshow(winname, output)
cv2.moveWindow(winname, 600,100)

cv2.waitKey(0)
cv2.destroyAllWindows()
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.