Я познакомился с ним недавно, где-то в феврале, в своём телеграм-канале про питон и всякое. Если честно, я очень легко схожусь с людьми, но с друзьями у меня плоховато - то ли я такой избирательный, то ли просто характер у меня паршивый. Беспокойства я по этому поводу не испытываю - мне и так норм, в общем-то. А с этим парнем я всё-таки подружился: он оказался достаточно необычной личностью, но при этом с ним удивительно легко общаться. Легко настолько, что у нас была достаточно эпичная по масштабам беседа насчёт творчества в целом и рисования в частности, и я решил превратить её в пост, добавляя свою редактуру, но сохраняя идеи и повествование моего друга от первого лица.
Шерхан
Друг вон той гиены
Вообще как художник я бездарность.
Объясняется это принципом RPG: вы либо качаете воина, либо мага, либо бесполезное существо (полувоин-полумаг, который бесполезен и как маг, и как воин). И я вкачал всё в программирование, поэтому с рисованием у меня примерно на уровне четвёртого класса.
Но иногда встречаются вещи, которые влетают мне прямо в душу (которой у меня нет) и переворачивают всё вверх дном. Увы, я ничего не умею, и в такие моменты я остро жалею, что не могу взять и накидать что-то на бумаге, может и не идеальное вовсе, но чтобы хоть как-то сохранить и передать эту эмоцию сквозь время.
А может, всё не так однозначно?
Входные параметры (я) накладывают жёсткие ограничения на тип рисунков: никаких градиентов, полутонов, игры света и чего там ещё придумали эти художники. Ещё не хотелось бы для разовых рисунков сильно раскошеливаться и покупать кисточки из хвоста единорога шерсти волка, специальную хлопковую бумагу и другие магические предметы из биолабораторий. Тем более никаких курсов по рисованию... Ничего лишнего! Человек просто хочет порисовать на выходных.
Подумав над всем этим, я усмехнулся про себя, ведь под мои критерии подходили только разве что наскальные рисунки... Так, стоп! Наскальные рисунки! Вот оно!
Да не это! Вот это:
Поэтому решено: это будет трафаретное граффити. Как раз для таких великих художников, как я.
Есть только одна проблема: граффити на самом деле граффити. Уж не знаю как вам, а меня это знатно подбешивало первое время, но потом я привык.
Рисование
Но давайте вернёмся к, собсна, рисованию.
Чтобы нарисовать очень грустную девочку, нужно найти любую девочку и сделать её грустной. Очень.
Как? Изи. Ну, например, убить её отца. За нас это уже сделали, поэтому украдём Софию прямо с панихиды и попробуем нарисовать.
Как говорил Боромир, "нельзя просто взять и нарисовать". Сначала нужно понять, в каких цветах мы это будем делать. А чтобы понять это, нужно вообще узнать, какие цвета есть в наличии.
Какие цвета есть в наличии
Варианта тут два: либо соскрапать какой-нибудь сайт с красками, либо пойти в ближайший магазин и соскрапать его глазами. Программисты - народ ленивый, поэтому выбираем первый вариант.
Мы могли бы использовать 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))
Немного теории
Далее начинается самое сложное.
Если вы смотрели на трафарет, вы заметили, что у букв типа О или В есть перемычки, потому что внутренние области не умеют висеть в воздухе. Поэтому первый главный вывод: никаких висящих областей быть не должно. Конечно, их можно смоделировать при помощи всяких трюков с маскированием (например, бывают маскирующая жидкость и малярный скотч ). Но это сложно и долго (в основном из-за позиционирования), поэтому будем юзать лайфхаки.
Трафарет - дело небыстрое, если у вас нет Гарри Плоттера. У меня нет. Поэтому чем проще, тем проще... В смысле, чем проще рисунок, чем грубее линии, тем проще вырезать. Но при этом важно не увлекаться, иначе всё превратится в какую-то абстракцию.
Важные детали грубыми делать нельзя! Наоборот, добавляйте как можно больше деталей туда, куда нужно смотреть. Например, на лица. Поможет вам в этом следующий пункт.
Чем больше полотно - тем проще вырезать. Это играет на руку, когда у вас много мелких деталей на трафарете. Но такой трафарет сложнее нести. Так что палка о двух концах.
Вот, в общем-то, и всё: нужно удалить и упростить максимальное количество областей, сохранив при этом самое важное.
Ассистент, скальпель!
Режем по живому
Я ручками удаляю ненужный фон, узор рядом с лицом матери и прочие вещи, отвлекающие внимание. Потому что я хочу показать, что это девочка тут главная, что это только про неё, что весь мир сейчас на ней сконцентрирован - и нет ничего больше! Поэтому она так чётко видна, а всё остальное будто размыто.
Далее выделяем всё, кроме лица. Для выделения лиц можно использовать 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 реальность
Одна из моих любимых фраз: "даже самый великолепный план не выдерживает встречи с реальностью". Как вы понимаете, мой план "о, серая стена, нарисую-ка я на ней" был достаточно далёк от великолепного. И я мог бы напридумывать, как здорово я всё сделал, и что сам Бэнкси вылез из кустов, чтобы пожать мне руку, но реальность, мне кажется, интереснее.
У меня нихрена не получилось. И вот почему.
Граффити должно быть видно
Ну тут как бы всё понятно: если граффити будет непонятно где, то его никто и не увидит. И даже если оно будет где надо, но слишком мелким или под неправильным углом, то его всё равно никто не увидит.
Поверхность должна быть идеальной
Сама стена оказалась неонородной, с подтёками и неровностями (это прям моя школа ремонта). Для трафарета это смерти подобно, поэтому подбирать поверхность нужно очень тщательно - от этого зависит всё. У меня не только шаблон неплотно прилегал к стене, но и проблемые части очень плохо клеились к ней из-за пыли и рельефности последней.
Короче, моя серая стена меня подвела. Не доверяйте серым стенам.
Шаблон должен быть крепким
Мой шаблон был "связным" - ну то есть не было висячих областей. Но я не учёл, что этого недостаточно. Если из шаблона вырезать всё больше и больше областей, то он становится всё более и более хрупким и гнущимся и перестаёт сохранять форму. Это не проблема, если шаблон из пластика, но у меня был из плотной -недостаточно плотной - бумаги, и он был похож на паутину, когда я пытался его присобачить к стене.
В общем, я, конечно, нарисовал на стене что-то, но показывать вам это мне стыдно. Но...
Дорогу осилит идущий
Жизнь научила меня одному классному правилу: если действительно хочешь чего-то добиться - страйся до последнего и никогда не опускай руки. Иди до конца.
У меня были неиспользованные листы, и я перенёс рисунок на них. Так как листы - не серая стена, и лежат они горизонтально - то все три проблемы из плана "А" были нивелированы, и, наконец-то, у меня получилось!
Да, косячно, но уже немного лучше, чем член на заборе! И главное: нарисовано это тем, кто вообще не умеет рисовать. Ну а то, что не на стене.. это только пока.
К чему всё это
Я искренне восхищаюсь теми, кто может взять и нарисовать - по памяти, или по картинке. Они не извращаются, как я, со всеми этими областями, заливками и пиксель-хантингом, а просто берут и делают как им хочется. Я завидую. Утешает только, что, наверно, они думают так же про мой кодинг: я беру и пишу, что хочется, а они но-кодят.
Граффити - настоящее граффити, а не убогие подписи на заборах - это борьба искусства и тупости. Добра и нейтралитета, если хотите. Потому что райтеры рисуют иногда просто прекрасные вещи, а потом приходит какой-нибудь коммунальщик и всё закрашивает (чаще всего даже не в тон), потому что ему так сказали и он хочет, чтобы от него отстали. Но в этом-то и есть некоторая прелесть: граффити рисуется, осознавая, что оно может оставаться на стене десятилетиями, а может быть закрашено уже завтра, - а значит, нет этого чувства "владения" рисунком, его просто рисуешь и с последним пшиком баллончика этот рисунок тут же перестаёт быть твоим. Так зачем же рисуют граффити?
Может, потому что не могут молчать?
Садитесь в мой философский пароход, билет бесплатный.