Я познакомился с ним недавно, где-то в феврале, в своём телеграм-канале про питон и всякое. Если честно, я очень легко схожусь с людьми, но с друзьями у меня плоховато - то ли я такой избирательный, то ли просто характер у меня паршивый. Беспокойства я по этому поводу не испытываю - мне и так норм, в общем-то. А с этим парнем я всё-таки подружился: он оказался достаточно необычной личностью, но при этом с ним удивительно легко общаться. Легко настолько, что у нас была достаточно эпичная по масштабам беседа насчёт творчества в целом и рисования в частности, и я решил превратить её в пост, добавляя свою редактуру, но сохраняя идеи и повествование моего друга от первого лица.

Шерхан

Друг вон той гиены

Вообще как художник я бездарность.

Объясняется это принципом RPG: вы либо качаете воина, либо мага, либо бесполезное существо (полувоин-полумаг, который бесполезен и как маг, и как воин). И я вкачал всё в программирование, поэтому с рисованием у меня примерно на уровне четвёртого класса.

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

А может, всё не так однозначно?


Входные параметры (я) накладывают жёсткие ограничения на тип рисунков: никаких градиентов, полутонов, игры света и чего там ещё придумали эти художники. Ещё не хотелось бы для разовых рисунков сильно раскошеливаться и покупать кисточки из хвоста единорога шерсти волка, специальную хлопковую бумагу и другие магические предметы из биолабораторий. Тем более никаких курсов по рисованию... Ничего лишнего! Человек просто хочет порисовать на выходных.

Подумав над всем этим, я усмехнулся про себя, ведь под мои критерии подходили только разве что наскальные рисунки... Так, стоп! Наскальные рисунки! Вот оно!

207 год нашей эры, кстати
207 год нашей эры, кстати

Да не это! Вот это:

Поэтому решено: это будет трафаретное граффити. Как раз для таких великих художников, как я.

Есть только одна проблема: граффити на самом деле граффити. Уж не знаю как вам, а меня это знатно подбешивало первое время, но потом я привык.

Рисование

Но давайте вернёмся к, собсна, рисованию.

Чтобы нарисовать очень грустную девочку, нужно найти любую девочку и сделать её грустной. Очень.

Как? Изи. Ну, например, убить её отца. За нас это уже сделали, поэтому украдём Софию прямо с панихиды и попробуем нарисовать.

Gleb Garanich / Reuters / Scanpix / LETA
Gleb Garanich / Reuters / Scanpix / LETA

Как говорил Боромир, "нельзя просто взять и нарисовать". Сначала нужно понять, в каких цветах мы это будем делать. А чтобы понять это, нужно вообще узнать, какие цвета есть в наличии.

Какие цвета есть в наличии

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

Мы могли бы использовать scrapy, но я его не очень люблю, поэтому напишем простой скрипт:

import json
import re
from pathlib import Path

import requests
from bs4 import BeautifulSoup
from pprintpp import pprint

session = requests.Session()

PRODUCT_URL = 'https://leonardo.ru/ishop/group_5040700859/'
AVAILABLE_SKUS_FILE = Path('data/available_skus.json')

response = session.get(PRODUCT_URL, timeout=5)
response.raise_for_status()
html = response.content

soup = BeautifulSoup(html)
options = soup.find('select', {'id': 'colorselection'}).find_all('option', {'class': 'instock'})


def parse_sku(text: str) -> str:
    # кидней 4230 BLK -> 4230
    match = re.match(r'.+ (\w+) BLK$', text)
    assert match, text
    return match[1]


available_skus = [parse_sku(option.text) for option in options]
pprint(available_skus)  # ['9105', '6055', '4060', '5230', ...]
print(len(available_skus))  # 23 - не густо!

with AVAILABLE_SKUS_FILE.open('w') as file:
    json.dump(available_skus, file)

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

import json
from pathlib import Path

import requests
from bs4 import BeautifulSoup
from pprintpp import pprint

session = requests.Session()


CATALOG_URL = 'https://www.montana-cans.com/en/spray-cans/montana-spray-paint/black-50ml-600ml-graffiti-paint/montana-black-400ml'
SKU_TO_COLOR_FILE = Path('data/sku-to-color.json')

response = session.get(CATALOG_URL, timeout=5)
response.raise_for_status()
html = response.content

soup = BeautifulSoup(html)
options = soup.find('form', {'id': 'sAddToBasket'}).find('ul', {'class': 'color-variant-list'}).find_all('li')


def parse_sku(text: str) -> str:
    # BLK 5020 -> 5020
    return text.removeprefix('BLK').strip()


sku_to_color = {}
for option in options:
    label = option.find('label')

    title = label.find('span', {'class': 'color-code'}).text
    sku = parse_sku(title)

    sku_to_color[sku] = {
        'rgb': json.loads(label['data-rgb']),
        'cmyk': json.loads(label['data-cmyk']),
        'hex': label['data-hex'],
    }

pprint(sku_to_color)
# '8250': {
#     'cmyk': {'C': '39', 'K': '61', 'M': '81', 'Y': '93'},
#     'hex': '#5b2607',
#     'rgb': {'B': '7', 'G': '38', 'R': '91'},
# },
with SKU_TO_COLOR_FILE.open('w') as file:
    json.dump(sku_to_color, file)

Среди сотен графических редакторов, доступных на linux, я выберу gimp. Полученный на предыдущем шаге ассортимент цветов я превращу в палитру gimp, чтобы прям в редакторе видеть, что у меня потенциально есть.

import json
from logging import getLogger
from pathlib import Path

log = getLogger(__name__)

AVAILABLE_SKUS_FILE = Path('data/available_skus.json')
SKU_TO_COLOR_FILE = Path('data/sku-to-color.json')
PALETTE_FILE = Path('~/.config/GIMP/2.10/palettes/graffiti-scraped.gpl')

with AVAILABLE_SKUS_FILE.open() as file:
    available_skus = json.load(file)

with SKU_TO_COLOR_FILE.open() as file:
    sku_to_color = json.load(file)


palette_content = """
GIMP Palette
Name: Graffiti: scraped
Columns: 0
#
""".strip('\n')
for sku in available_skus:
    try:
        color = sku_to_color[sku]
    except KeyError:
        log.warning(f'{sku=} not found, skipping')
        continue

    palette_content += '\n' + ' '.join(color['rgb'].values()) + f' {sku}'


PALETTE_FILE.expanduser().write_text(palette_content)

Преобразуем картинку в палитру

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

На самом деле тут хитрость: сама стена - уже светло-серый цвет, плюс чёрный я покупаю, плюс тёмно-серый, если красить несильно. Итого 3 цвета по цене одного! Я у мамы нищеброд маркетолог.

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

Теперь нужно превратить RGB палитру картины в нашу трёхцветную. Gimp это умеет, но можно сделать на питоне и потюнить параметры вручную. Не все знают, но подход "найти ближайший цвет из палитры при помощи евклидового расстояния rgb-координат" не работает. Дело в том, что ваш глаз плевал на то, что думает мозг насчёт монотонности координат (r, g, b), и машина понимает под "разными цветами" не то же, что мы с вами.

К счастью, есть не-rgb цветовые пространства, где уже учтено восприятие цветов человеком. Поэтому можно накидать код для перевода рисунка в палитру "на глаз":

from functools import lru_cache
from itertools import product
from operator import itemgetter
from pathlib import Path
from typing import Tuple

from colormath.color_conversions import convert_color
from colormath.color_diff import delta_e_cie2000
from colormath.color_objects import LabColor, sRGBColor
from PIL import Image
from tqdm import tqdm

IMAGE_FILE_PATH = Path('data/original.jpg')
PALETTE_COLORS = [
    [135] * 3,  # light gray
    [52] * 3,  # dark gray
    [0] * 3,  # black
]
OUTPUT_FILE_PATH = Path('data/colors-reduced.png')


def get_distance(rgb1: Tuple[int, int, int], rgb2: Tuple[int, int, int]) -> float:
    color1 = sRGBColor(*(color / 255 for color in rgb1))
    color2 = sRGBColor(*(color / 255 for color in rgb2))

    return delta_e_cie2000(
        convert_color(color1, LabColor),
        convert_color(color2, LabColor),
    )


@lru_cache(maxsize=None)
def translate_color(color: Tuple[int, int, int]) -> Tuple[int, int, int]:
    diffs = (
        (get_distance(color, palette_color), palette_color)
        for palette_color in PALETTE_COLORS
    )
    translated_color = sorted(diffs, key=itemgetter(0))[0][1]
    return tuple(translated_color)


image = Image.open(str(IMAGE_FILE_PATH))
image = image.convert('RGB')

width, height = image.size

# обрабатывать вот так в цикле - достаточно тупая идея,
# обычно нужно использовать batch processing;
# уверен, в PIL это есть, но я заленился :(
# меня спасает только то, что я кэширую
# translate_color, и оно понемногу ускоряется
for xy in tqdm(
    product(range(width), range(height)),
    total=width * height,
):
    color = image.getpixel(xy)
    image.putpixel(xy, translate_color(color))

image.show()
image.save(str(OUTPUT_FILE_PATH))

Немного теории

Далее начинается самое сложное.

  1. Если вы смотрели на трафарет, вы заметили, что у букв типа О или В есть перемычки, потому что внутренние области не умеют висеть в воздухе. Поэтому первый главный вывод: никаких висящих областей быть не должно. Конечно, их можно смоделировать при помощи всяких трюков с маскированием (например, бывают маскирующая жидкость и малярный скотч ). Но это сложно и долго (в основном из-за позиционирования), поэтому будем юзать лайфхаки.

  2. Трафарет - дело небыстрое, если у вас нет Гарри Плоттера. У меня нет. Поэтому чем проще, тем проще... В смысле, чем проще рисунок, чем грубее линии, тем проще вырезать. Но при этом важно не увлекаться, иначе всё превратится в какую-то абстракцию.

  3. Важные детали грубыми делать нельзя! Наоборот, добавляйте как можно больше деталей туда, куда нужно смотреть. Например, на лица. Поможет вам в этом следующий пункт.

  4. Чем больше полотно - тем проще вырезать. Это играет на руку, когда у вас много мелких деталей на трафарете. Но такой трафарет сложнее нести. Так что палка о двух концах.

Вот, в общем-то, и всё: нужно удалить и упростить максимальное количество областей, сохранив при этом самое важное.

Ассистент, скальпель!

Режем по живому

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

Тут я женщине посередине подарил большие глаза, кстати
Тут я женщине посередине подарил большие глаза, кстати

Далее выделяем всё, кроме лица. Для выделения лиц можно использовать computer vision или human hands. В гимпе я использую инструмент "лассо", но можно заставить питона поработать:

import cv2
from pathlib import Path


IMAGE_FILE_PATH = Path('data/colors-reduced.png')
OUTPUT_PATH = Path('data/face-detected.png')

image = cv2.imread(str(IMAGE_FILE_PATH))
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
faceCascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_profileface.xml")
faces = faceCascade.detectMultiScale(
        gray,
        scaleFactor=1.3,
        minNeighbors=3,
        minSize=(30, 30)
)

for (x, y, w, h) in faces:
    cv2.rectangle(image, (x, y), (x + w, y + h), (0, 255, 0), 2)

cv2.imwrite(str(OUTPUT_PATH), image)

Ээээээ... Ладно, сегодня у программиста день рук. Наверно, классификатор не обучали на грустных девочках ¯_(ツ)_/¯

Далее "загрубляем" рисунок. Нужно избавиться от всех островков и пикселей - всё равно от них никакого толка.

Для этого юзаем комбинацию erode+dilate. Erode - это эрозия, она откусывает сколько-то пикселей от границы области. Так как мелкие кусочки имеют очень маленькую площадь, то erode откусывает их целиком, и они исчезают. Но теперь нужно вернуть границы на место, и мы юзаем dilate - это расширение границы. Всё это мы проделаем на всех областях, кроме лица.

В gimp есть специальный фильтр под эти штуки, но можно и попитонить:

import cv2
from pathlib import Path
import numpy as np


IMAGE_FILE_PATH = Path('data/colors-reduced.png')
OUTPUT_PATH = Path('data/erode-dilate.png')

image = cv2.imread(str(IMAGE_FILE_PATH))

kernel = np.ones((4, 4), np.uint8)
image = cv2.erode(image, kernel, iterations=1)
image = cv2.dilate(image, kernel, iterations=1)
cv2.imwrite(str(OUTPUT_PATH), image)

Я подрисовываю слезу, чтобы передать трагичность, но тут же стираю. Отбрасываю и банальную идею дорисовать где-то там вверху висящее распятие. Это всё только испортит, сделает каким-то не настоящим, а я хочу нарисовать так, как было, голую правду, чтоб она прям резала: и слёз нет - то ли сильная такая (я проверил - нет), то ли выплакала уже всё, - и неясно, есть ли там сверху бог или нет.

Добавляю только одну вещь: третью свечку. Почему-то мне кажется, что так нужно.

Граница важна

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

Вот такие слои получаются. Чёрный будем красить поверх тёмно-серого.

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

Гравитация, мать её

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

from pathlib import Path
from PIL import Image, ImageDraw


IMAGE_FILE_PATH = Path('data/erode-dilate-with-border.png')
OUTPUT_FILE_PATH = Path('data/dangling-detected.png')

image = Image.open(str(IMAGE_FILE_PATH))

start_coords = 0, 0
fill_color = 255, 0, 0

ImageDraw.floodfill(image, start_coords, fill_color, thresh=0)

image.show()
image.save(str(OUTPUT_FILE_PATH))

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

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

Вырезаем людей и присоединяем

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

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

Ожидание vs реальность

Одна из моих любимых фраз: "даже самый великолепный план не выдерживает встречи с реальностью". Как вы понимаете, мой план "о, серая стена, нарисую-ка я на ней" был достаточно далёк от великолепного. И я мог бы напридумывать, как здорово я всё сделал, и что сам Бэнкси вылез из кустов, чтобы пожать мне руку, но реальность, мне кажется, интереснее.

У меня нихрена не получилось. И вот почему.

Граффити должно быть видно

Ну тут как бы всё понятно: если граффити будет непонятно где, то его никто и не увидит. И даже если оно будет где надо, но слишком мелким или под неправильным углом, то его всё равно никто не увидит.

Поверхность должна быть идеальной

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

Короче, моя серая стена меня подвела. Не доверяйте серым стенам.

Шаблон должен быть крепким

Мой шаблон был "связным" - ну то есть не было висячих областей. Но я не учёл, что этого недостаточно. Если из шаблона вырезать всё больше и больше областей, то он становится всё более и более хрупким и гнущимся и перестаёт сохранять форму. Это не проблема, если шаблон из пластика, но у меня был из плотной -недостаточно плотной - бумаги, и он был похож на паутину, когда я пытался его присобачить к стене.

В общем, я, конечно, нарисовал на стене что-то, но показывать вам это мне стыдно. Но...

Дорогу осилит идущий

Жизнь научила меня одному классному правилу: если действительно хочешь чего-то добиться - страйся до последнего и никогда не опускай руки. Иди до конца.

У меня были неиспользованные листы, и я перенёс рисунок на них. Так как листы - не серая стена, и лежат они горизонтально - то все три проблемы из плана "А" были нивелированы, и, наконец-то, у меня получилось!

Да, косячно, но уже немного лучше, чем член на заборе! И главное: нарисовано это тем, кто вообще не умеет рисовать. Ну а то, что не на стене.. это только пока.

К чему всё это

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

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

Может, потому что не могут молчать?


Садитесь в мой философский пароход, билет бесплатный.

Комментарии (11)


  1. blowin
    29.06.2022 09:09
    +22

    - Как нарисовать очень грустную девочку, если вы программист?

    - Рассказать жене про SQL.


    1. PTM
      29.06.2022 09:58

      Вы ее увидите, а не нарисуете.... надо добавить сфоткайте, подложите под кальку и обведите ;)


  1. kryvichh
    29.06.2022 10:09
    +10

    Это ты называешь нарисовать: взять готовое фото с чьим-то горем, и прогнать через фильтры? Уж лучше тогда (с программистским подходом) воспользоваться ruDALL-E, подобрать правильный запрос, сгенерировать несколько картинок и выбрать лучшую.


  1. Zhbert
    29.06.2022 10:59
    +9

    Ну работа с фильтрами на питоне — норм, че, прикольно, спасибо за мануал. А вот нахрена ты все это делал я так и не понял.


  1. Demanih
    29.06.2022 12:45
    +8

    Не вижу на финальном "рисунке" грустной девочки (а уж тем более очень грустной), вижу удивлённую, восхищённую, смотрящую с надеждой, но никак не грустную.


    1. kesn Автор
      29.06.2022 17:44

      Полный провал. Спасибо


  1. gkir
    29.06.2022 13:54

    Спасибо за "девочку на питоне", но я споткнулся о

    ручками удаляю ненужный фон, узор рядом с лицом матери и прочие вещи

    Да и количество цветов в финальной версии намного больше 1, что увеличит время нанесения граффити.

    А сколько суммарно потребовалось шаблонов для самой девочки, если заранее вырезать остальных людей?


    1. kesn Автор
      29.06.2022 17:40

      Не, там два цвета только, соответственно, всего было 2 шаблона: один для тёмно-серого, второй для чёрного.


  1. SeVlaT
    29.06.2022 14:48
    +5

    Неплохая статья про графику, Питон.

    Но в качестве в качестве исходной модели лучше было бы взять что-то более нейтральное, или старую фотографию, или картину. И шутки относительно девочки, мне кажется, неуместны.


    1. kesn Автор
      29.06.2022 17:37

      Насчёт исходной картины - согласен. Не только лишь все поняли, про что она, нужно было выбрать что-то другое.

      Про девочку я не шутил, там только злость и сочувствие, но, опять же, раз вам показалось иначе - значит, я не нашёл правильных слов. Учтём.


    1. Panda_sama
      30.06.2022 06:18

      Вы просто не поняли суть статьи - она не про графику, она про пропаганду.