Теперь я могу признаться, что мутанты для меня изначально были Самой Главной Фичей форума. Серьезно! Вообще, идея написать "правильный форум" была у меня очень-очень давно (так давно, что в те времена я думал написать его на C++). В начале прошедшей зимы она возродилась уже на новом уровне, когда я стал осознавать, чего же именно я от форума хочу. И одной из первых ключевых фич стала поддержка OpenID.
И тогда же я осознал, что у OpenID есть проблема: в том небольшом количестве мест, где им таки уже можно пользоваться, он выглядит скучно: это просто URL! И тут же родилась идея считывать hCard, чтобы вместо URL'а было хотя бы имя пользователя.
А потом я наткнулся на MonsterID — идею рисования составных монстриков из IP-адреса посетителя. И тут-то у меня все и сложилось: если монстров составлять не на основе IP-адреса, а на основе OpenID, то у каждого человека будет свой неизменный персональный виртуальный "представитель".
Что мне еще не нравилось в реализации MonsterID — это "програмерская графика". Да, это прикольно и показывает идею, но выглядит откровенно коряво. И тогда я пошел на форум знакомой пиксельной художницы — Pixel Land — где подробно описал задачу. Девушки с форума (Germanika, Illusion и сама хозяйка Llama) так быстро нарисовали таких прикольных "кричеров", что я понял — теперь форум писать нужно обязательно, даже просто ради мутантов. И вот вчера я эту все в первом виде и реализовал: "Мутанты из OpenID".
За подробностями реализации — читайте дальше.
Bzr-репозиторий | http://softwaremaniacs.org/code/cicero/ |
---|---|
Работающий форум | http://softwaremaniacs.org/forum/ |
Алгоритм
Есть OpenID и есть набор нарисованных частей картинок, которые складываются в целую. Задача — превратить разные URL'ы в разные наборы частей картинок. Способ сделать это надежно — вести таблицу URL'ов (которая, кстати, и так уже есть), и для каждого генерировать один раз собственную картинку, при этом следить, чтобы картинка была уникальна. Чтобы картинки были уникальными, я вижу два способа:
Пересчитать части и брать их по-порядку, каждый раз новую, получается такая интересная "система счисления", где вместо цифр — руки, ноги, головы. Но этот вариант дает четко видимую предсказуемость, чего мне не хотелось.
Генерировать каждую картинку случайно, но проверять, чтобы она не совпадала с уже нагенеренными. Этот вариант не понравился, потому что для сравнения пришлось бы помимо картинки хранить еще ее структуру, что кажется излишне сложным. А также еще и время генерации с увеличением числа пользователей росло бы.
Поэтому я задвинул на надежный способ генерации неповторяющихся мутантов и решил, что хватит и просто того, чтобы они повторялись с очень маленькой вероятностью. Для этого надо множество похожих URL'ов превратить в как можно более равномерно распределенную множество каких-то других непохожих значений. А это — ни что иное, как давно реализованные и отлаженные алгоритмы хеш-функций. Бинго!
В итоге выходит так. Берется md5-хеш от OpenID, который представляет собой 16 байт, которые можно для практических целей считать равномерно случайными. Каждый байт отвечает за какую-то собственную случайность:
- первый байт — выбор левой руки мутанта
- второй байт — выбор правой руки мутанта
- третий байт — нужно ли зеркально отражать две сложенные руки
- ... (аналогично с ногами, головами и туловищами)
- одиннадцатый байт — цвет конечностей
- двенадцатый байт — цвет туловища
- тринадцатый байт — цвет головы
Три байта остаются незадействованными, ну да и ладно: md5, насколько я помню, придуман как раз для того, чтобы все части хеша были независимыми.
Выбор конкретного варианта картинок или цветов по байту происходит просто: варианты составляются в список, а значение байта (0..255) по модулю количества вариантов дает индекс.
def partfile(part, byte):
# директория с конкретным набором картинок
path = os.path.join(settings.OPENID_MUTANT_PARTS, part)
# список файлов
files = os.listdir(path)
files.sort()
# выбор файла по переданному значению
return os.path.join(path, files[ord(byte) % len(files)])
Кстати, в начале я думал получить около 250 000 различных вариантов картинок. Но девушки с Pixel Land'а принялись за дело с энтузиазмом и нарисовали картинок слегка больше, чем я рассчитывал. И в итоге получилась такая математика:
8 левых рук * 11 правых рук * 2 (зеркальные отражения) = 176 вариантов рук
9 левых ног * 9 правых ног * 2 = 162 варианта ног
176 * 162 = 28512 вариантов конечностей
5 несимметричных туловищ * 2 + 4 симметричных туловища = 14 туловищ
8 несимметричных голов * 2 + 3 симметричные головы = 19 голов
4 цвета на каждую группу: конечности, голова, туловище:
(28512 * 4) * (14 * 4) * (19 * 4) = 485 388 288 вариантов
Думаю, полумиллиарда вариантов будет достаточно для того, чтобы вероятность выпадания одного мутанта разным людям была очень маленькой. А если и выпадет — будем считать, что у них связанные кармы :-).
Другое дело, что полная случайность обычно не так интересна, как случайность, над которой слегка поработали (спросите вашего знакомого владельца казино). Поэтому для формирования цветов я беру не тот же исходный md5-хеш из OpenID, а делаю другой хеш — из домена OpenID-сервера (именно сервера из <link rel="openid.server" ...>
). Поэтому все люди, обслуживающиеся на одном сервере, имеют одинаковые наборы цветов. Например сейчас все ЖЖшники — с голубой головой, зеленым туловищем и красными конечностями.
И я все таки должен признаться, что мой собственный OpenID с самого начала был целиком серым, а когда этот цвет был отчислен за невзрачность, я стал целиком голубым! Тогда я не выдержал и переставил цвета в наборе, чтобы быть красным, в стиле цветов softwaremaniacs.org :-)
Рисование
Все рисование делается с помощью библиотеки PIL — стандарта де-факто в питоновском мире для работы с графикой. Библиотека, на самом деле, заслуживает очень большого уважения. Она очень мощная в смысле фич (фильтры, геометрия, рисование, шрифты, прозрачность), она понимает много форматов файлов, умеет работать с разными внутренними форматами изображений: RGB- и RGBA-плоскости, индексные цвета, монохром, и что немаловажно, ее ядро написано на Си, из-за чего она очень быстрая. Ну и кроме того еще маленькая и бесплатная :-).
Есть еще ImageMagick, тоже, говорят, хороший, но я с ним не работал, ничего сказать не могу.
Составление частей
Мои исходные данные — монохромные GIF-картинки размером 48х48, на каждой из которых нарисована одна рука, нога, туловище или голова. Соответственно, мне нужно их выбрать (по описанному алгоритму), расцветить и составить в одну. Вот, например, как составляются конечности:
import Image # главный модуль PIL
# создается в памяти новая пустая картинка для конечностей
extremities = Image.new('RGBA', (48, 48))
arms = Image.new('RGBA', (48, 48))
# выбираются и загружаются файлы из директорий левых и правых рук
for filename in (partfile('arm-left', hash[0]), partfile('arm-right', hash[1])):
# картинка конвертируются в RGBA, прозрачность PIL берет из GIF'а сам
image = Image.open(filename).convert('RGBA')
# рука вставляется в arms, маска прозрачности берется из руки же
arms.paste(image, mask=image)
# картинка рук вероятностно переворачивается по горизонтали,
# это встроенная функция PIL: image.transpose
arms = transpose(arms, hash[2])
# руки вставляются в картинку конечностей
extremities.paste(arms, mask=arms)
Это все жутко просто, на самом деле. Хотя я убил чуток времени, чтобы разобраться с одной заковыкой. Дело в том, что если составлять GIF'ы напрямую, PIL не учитывает их прозрачность (видимо потому что номер прозрачного цвета хранит отдельно от "мяса" изображения). Поэтому все переводится в RGBA.
Расцветка
После такой же операции с ногами, туловищами и головами у меня получаются три картинки, которые надо расцветить. Сначала я принялся было делать это попиксельно вручную. У PIL есть очень хитрая функция "Image.point", которая каждую точку изображения прогоняет через переданную в нее функцию или таблицу.
Хитрость ее заключается в том, что она принимает функцию с питоновским математическим выражением, изменяющим цвет одного пиксела, а сам цикл гоняет уже в сишной части, отчего работает быстро.
Соответственно, я могу взять какой-нибудь достаточно темный цвет (например (102, 0, 0)). А потому пройтись по всем исходным пикселам, считая, что их значения пропорционально гоняют RGB-компоненты моего цвета от исходного до (255, 255, 255). То есть для цвета (102, 0, 0) монохромные значения превращаются так:
Монохром | Результат |
---|---|
0 | 102, 0, 0 |
255 | 255, 255, 255 |
128 (то есть 255 / 2) | (255 + 102) / 2, (255 + 0) / 2, (255 + 0) / 2 = 179, 128, 128 |
Математика простая, и я ее даже написал. Но потом вдруг открыл для себя, что в PIL уже есть функция, которая делает как раз эту математику :-). Пришлось все выкинуть и переписать по-простому:
from ImageOps import colorize
color = # выбрать цвет из списка по md5-хешу
colorized = colorize(image.convert('L'), color, (255, 255, 255))
"Colorize" принимает картинку, конвертированную в монохромный режим, цвет, соответствующий черным пикселам и белым. Вот и все :-). Дальше три цветные картинки составляются в одну.
Тень
После этого я почти было успокоился: цветные набранные картинки выглядели замечательно, и у меня был ими засажен весь десктоп (а жена жаловалась, что на монстров я трачу внимания больше, чем на нее) . Но мне в голову пришла еще одна идея — сделать от картинок правильную размытую тень. Благо, про то, что в PIL есть поддержка операции "blur", я к тому времени уже знал. А размытие — идеальный инструмент для создания тени. Достаточно взять контуры картинки, залить их черным цветом и размыть, чтобы по краям они смешались с фоном, и этот контур подсунуть под исходную картинку со смещением. Эта функция получилась у меня даже никак не привязанной к логике мутантов, поэтому ее можно прямо использовать для чего угодно:
def shadow(image):
# картинка с тенью будет очевидно чуть больше
shadow = Image.new('RGBA', (image.size[0] + 4, image.size[1] + 4))
# в пустую картинку вставляется контур функцией Image.paste:
# - делается заливка полупрозрачным черным (0, 0, 0, 180)
# - квадрат равен размеру изображения, смещен на 1 пиксел по обеим осям
# - исходное изображение используется как маска, чтобы заливался только контур
shadow.paste((0, 0, 0, 180), (1, 1, image.size[0] + 1, image.size[1] + 1), image)
# фильтр Sharpness умеет и sharp'ить и blur'ить
from ImageEnhance import Sharpness
# 0.1 -- максимальный blur
shadow = Sharpness(shadow).enhance(0.1)
# но его мало, повторим еще :-) (нет, 0.001 такого эффекта не дает)
shadow = Sharpness(shadow).enhance(0.1)
shadow = Sharpness(shadow).enhance(0.1)
# поверх размытого контура лепится исходное изображение
shadow.paste(image, (0, 0, image.size[0], image.size[1]), image)
return shadow
Последний штрих, который пришлось сделать — это сделать легкий кивок в сторону... Internet Explorer'а. Как известно, PNG с полупрозрачной тенью он хорошо и просто не покажет, поэтому в настройках Cicero есть настройка с цветом фона подложки, белым по умолчанию. Последним шагом создается непрозрачная (RGB) картинка с этим цветом и в нее вставляется нарисованный мутант с тенью. Если же настройку сбросить в None, то этот шаг просто пропускается.
Хранение
Сразу откинул идею генерировать картинку на лету из view: она для пользователя практически никогда не меняется, а выводится часто, значит этот вариант будет излишне тормозить. Поэтому сгенерированные картинки хранятся в стандартном джанговском поле ImageField. Что автоматически избавляет от необходимости думать над многими вопросами: где оно будет храниться (внутри MEDIA_ROOT, который настроен в подавляющем большинстве джанговских проектов), как составлять URL, и когда его удалять.
Что нужно решить — это когда и как его заполнять. Для этого в Profile сделана функция generate_mutant, которая вызывается в авторизационном коде при первом создании Profile'а:
def generate_mutant(self):
'''
Создает, если возможно, картинку мутанта из OpenID.
'''
import os
if os.path.exists(self.get_mutant_filename()):
os.remove(self.get_mutant_filename())
if not settings.OPENID_MUTANT_PARTS or not self.openid or not self.openid_server:
return
from cicero.mutants import mutant
from StringIO import StringIO
content = StringIO()
mutant(self.openid, self.openid_server).save(content, 'PNG')
self.save_mutant_file('%s.png' % self._get_pk_val(), content.getvalue())
Слегка интересный момент — это то, что картинка сохраняется не во временный файл, а в буфер в памяти (StringIO), из которого потом в виде строки передается в метод "save_mutant_file", который Django автоматически создает по факту наличия поля mutant.
В шаблоне картинка выводится примерно так:
{% if profile.mutant %}<img src="{{ profile.get_mutant_url }}" alt="">{% endif %}
Страница профиля
Также в этой итерации Cicero обзавелся страницей профиля, который, правда, пока нельзя редактировать. Сама страница очень тупая, там просто выводятся поля пользователя. Работает она через generic view. И вот потому-то я о нем и пишу, что мне пришлось написать тег — давнюю мечту многих джанговцев — который автоматически составляет URL к объекту. Мой предыдущий тег {% url %} в этом случае не справляется, потому что не работает для generic views. Новый {% object_url %} как раз только с generic views и работает и выглядит так:
{% object_url profile %}
Вот!
Прямо здесь код приводить не буду, потому что он довольно дремуч (за основу взят джанговский reverse и переписан слегка). Посмотреть его можно в файлике templatetags/cicero.py. Осталось договориться с ядреными развивателями ("core developers"), что это — полезная штука :-).
Комментарии: 59
Гениально!
На самом деле никогда раньше подобной фичи не видел, но до чего красивое решение.
(сказал, косясь в сторону своего мутанта)
Феерично!
Да Вам, батенька, в пору свой сервис по типу gravatar делать =)
Супер, а много таких "составных частей" ?
OMFG, ШИКАРНО!
Максим, а текст статьи читать не пробовали? :)
Иван, тебе +10000000000000000000!
Иван, а вы случайно книги Кристофера Лаумера про Ретифа не читали?
Поддерживаю!
В тексте написано. ;)
Сорри, Кита Лаумера.
Супер!
Очень хорошо и сделано, и написано! Даже мне, биологу, и то почти все понятно :)
Мутанты симпатичные :)
Очень интересно получилось? А этот способ составления аватаров, он случаем не патентованый? Его можно где угодно использовать?
Создать несколько наборов и сделать большой и интересный сервис для форумов, чатов и прочей.
О чорт. Чувствую непреодолимое желание реализовать это всюду, где у меня есть форумы:)
Разберут идею, ой разберут…
Блеск! Сам в Питоне не разбираюсь, но с упоением слежу за тем, как рождается форум. Сегодняшнее нововведение - это нечто! Сдается мне, этот форум обещает стать самым нетривиальным форумом в сети!
Насчет "разберут идею" и "не патентованный ли". Нет, конечно, не патентованный. Я для того и пишу пост с подробным разбором и даю в начале ссылки на полный код, чтобы все разбирали идею, реализовывали и улучшали.
Единственно, сама исходная графика, не лежит в коде. Но это, в общем-то, и не нужно, потому что если везде будут одни и те же картинки, это будет скучно. Так что можно или припрячь знакомых дизайнеров, или вообще обратиться на тот же самый Pixel Land и заказать собственый сет: там девушки рвались рисовать еще больше, но у меня бюджет был ограничен :-)
Иван, эта идея просто гениальна!
Браво, маэстро!
Срочно патентуйте. Потом гугл купит )
Браво!
md5 хэш это всегда 32 байта, или я что-то путаю ? :)
Байт — 16. А 32 символа в его представлении шестнадцатеричными цифрами, где каждый байт — 2 символа (A0, DE, FF).
Чувствуется по кол-ву сообщений на форуме хостинг может рухнуть под количеством сгенерённых гифов ;))))
Чувак, ты крут! Перед тобой снимаю шляпу.
Хостинг вряд ли рухнет... Там сейчас 250 картинок общим объемом чуть больше 1 МБ. Но поскольку их отдает lighttpd — этого практически незаметно.
Вот что там плохо — это то, что я не занимался еще оптимизацией запросов на странице топика, и их там происходит 104 штуки :-). Но средняя загрузка сервера, на удивление, не превышает единицы.
Идея просто великолепна!
Иван! Огромное спасибо за ваши статьи. Очень интересно почитать. Оставил несколько замечаний на форуме. Пишите еще, заметки с этого блога настоящий источник вдохновения для работы и учебы.
Очень цепляет, в этой идее есть какой-то тихий фатализм %)))
Иконки для всяких КДЕ и смайлы для асек рисуют.
Скоро можно ждать произвольных монстров для Цицеро (=
Собственно, ничто не мешает: форум легко ставится в любое Django-приложение, а картинки мутантов лежат в отдельной директории.
Хотя... Форум еще дописать надо бы :-)
Кстати, о форуме :)
Мне было бы удобно иметь возможность голосовать за посты. Ну или хотя бы отмечать их как "особо ценный комментарий" ;) Такая возможность будет или это баловство?
Нет, голосований у меня не будет. Но у вас будет исходник и Django, на котором очень просто такую штуку прикрутить!
Иван, понял :) Я так и думал, в принципе.
На всякий случай просто спросил, мало ли...
супер. жалко, что автор зациклился на OpenID в результате чего для меня монстрика не окаалось :(
а ведь email - это тоже уникальный идентификатор пользователя!
да,это решает проблему тёзок, но совпадения всё таки будут :)
Казырно!
Идея - есть. Тест реализация - есть. Куда расти - есть! А главное - очень улыбает ))))
Во всей этой веб2.0 потетике, мутанты, пожалуй самая добрая идея, которую я видел. Буду юзать.
а при чем тут вебдваноль? или это рефлекс уже?
на основе имейла, кстате, лучше
Супер.
Dark-Demon
К сожалению у e-mail есть недостаток. Зная его не спросишь у mail-сервера имя, псевдоним, возраст, адрес, список друзей, интересы и т.п. автора.
OpenID тем и хорош, что позволяет автору открывать некоторую информацию о себе, которая может быть использована на множестве веб-сервисов.
Я вот только не понимаю почему не все социальные сети поддерживают OpenID, ведь они созданы друг для друга?
Tema, зачем все эти данные для формирования монстрика? Так я прям и рассказал кому попало свой адрес, список друзей, номер паспорта и код от банковской карты...
dark-demon, есть и другой недостаток - в отличие от OpenID, email никак не идентифицирует пользователя и никто не помешает вам воспользоваться чужим email'ом. Если, конечно, не прикручивать сюда фазу активации аккаунта, но зачем, когда есть OpenID?
Иван,
с нетерпением жду новых этапов разработки cicero. Изучаю Джанго в ходе написания форума :) Успехов Вам и почаще пишите посты :)
Наткнулся на забавную флешку, где 14 человек меняются частями своих лиц:
http://www.mono-1.com/monoface/main.html
И подумалось, ведь это бы намного интересней было, если мутанты были бы не пиксельными рисованными картинками, а составлялись из частей реальных фотографий. Если для основы взять приведенный материал, то можно получить 14^5 = 537824 варианта.
Идея хорошая.
Но... Плодить МУТАНТОВ?
Почему нельзя рождать нечто ПРИЯТНО выглядещее?
И ведь легко - если оставить руки-ноги симметричными, а менять лицо, одежду, ее цвет и пропрорции фигуры? Даже где-то болтается полный прототип того, что вам нужно - генератор персонажей South Park!
Денис Барушев, ага, а лучше распотрошить базу частей лиц для составления физиономии со слов очевидцев. :)
Ваня, полюбопытствуй, еще один вариант lazy-avatars: http://www.docuverse.com/blog/donpark/2007/01/18/visual-security-9-block-ip-identification
А... Так это же то, откуда взял свою идею Андреас Гор, который придумал MonsterID, о которых я писал в начале :-)
Отличная идея, отличная реализация. Вот только не согласен только с пунктом "если везде будут одни и те же картинки, это будет скучно".
По-моему, наоборот, картинки вполне могут стать универсальной "второй сущностью". Очень хочется запортить механизм в phpBB (с указанием автора идеи). Поделитесь, пожалуйста, ручками-ножками!
Дмитрий, пардон, ручками-ножками поделиться не могу — эксклюзивная работа. Я на самом деле считаю, что лучше, чтобы на разных сайтах были свои картинки, подходящие сайту по стилю. И конечно, если бы в phpBB появилась такая штука, это было бы непомерно круто :-). Может стоит тоже обратиться к девушкам на PixelLand.Ru, чтобы еще наборчик нарисовали?
У Joe Gregorio тоже есть свои мутанты, только генерятся они по произвольной строке, а не по OpenID URI.
На Kvnru.ru теперь тоже есть мутанты :) Правда, стандартные, но там они меньше и смотряться симпатично.
Здорово!
Я видел мутантов Сплитбрэйна (Докувики), но это — гораздо круче и симпатичнее. Прямо таки хоть блог на пайтон/джанго переводи ради таких чудиков :)
Большой респект за организацию всего этого дела и художницам — за старание!
Красиво!
Только.. Как посмотреть своего монстрика? )
Там ошибка была. Починил, вроде.