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