Теперь я могу признаться, что мутанты для меня изначально были Самой Главной Фичей форума. Серьезно! Вообще, идея написать "правильный форум" была у меня очень-очень давно (так давно, что в те времена я думал написать его на 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) монохромные значения превращаются так:

МонохромРезультат
0102, 0, 0
255255, 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

  1. Мрачный аббат

    Гениально!
    На самом деле никогда раньше подобной фичи не видел, но до чего красивое решение.
    (сказал, косясь в сторону своего мутанта)

  2. pythy

    Феерично!

  3. Ivan A-R

    Да Вам, батенька, в пору свой сервис по типу gravatar делать =)

  4. Максим

    Супер, а много таких "составных частей" ?

  5. Данила

    OMFG, ШИКАРНО!

    Максим, а текст статьи читать не пробовали? :)

  6. FX Poster

    Иван, тебе +10000000000000000000!

  7. Alexander Solovyov

    Иван, а вы случайно книги Кристофера Лаумера про Ретифа не читали?

    Да Вам, батенька, в пору свой сервис по типу gravatar делать =)

    Поддерживаю!

    Супер, а много таких “составных частей” ?

    В тексте написано. ;)

  8. Alexander Solovyov

    Сорри, Кита Лаумера.

  9. Kpoxa

    Супер!

  10. Анатолий

    Очень хорошо и сделано, и написано! Даже мне, биологу, и то почти все понятно :)

    Мутанты симпатичные :)

  11. Александр

    Очень интересно получилось? А этот способ составления аватаров, он случаем не патентованый? Его можно где угодно использовать?

  12. Евгений

    Создать несколько наборов и сделать большой и интересный сервис для форумов, чатов и прочей.

  13. Давид Мзареулян

    О чорт. Чувствую непреодолимое желание реализовать это всюду, где у меня есть форумы:)

    Разберут идею, ой разберут…

  14. Alex

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

  15. Иван Сагалаев

    Насчет "разберут идею" и "не патентованный ли". Нет, конечно, не патентованный. Я для того и пишу пост с подробным разбором и даю в начале ссылки на полный код, чтобы все разбирали идею, реализовывали и улучшали.

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

  16. Аркадий Чумаченко

    Иван, эта идея просто гениальна!

  17. Chupa

    Браво, маэстро!
    Срочно патентуйте. Потом гугл купит )

  18. DarkFighter

    Браво!

  19. Вася

    md5 хэш это всегда 32 байта, или я что-то путаю ? :)

  20. Иван Сагалаев

    Байт — 16. А 32 символа в его представлении шестнадцатеричными цифрами, где каждый байт — 2 символа (A0, DE, FF).

  21. GRAy

    Чувствуется по кол-ву сообщений на форуме хостинг может рухнуть под количеством сгенерённых гифов ;))))

  22. Ant

    Чувак, ты крут! Перед тобой снимаю шляпу.

  23. Иван Сагалаев

    Хостинг вряд ли рухнет... Там сейчас 250 картинок общим объемом чуть больше 1 МБ. Но поскольку их отдает lighttpd — этого практически незаметно.

    Вот что там плохо — это то, что я не занимался еще оптимизацией запросов на странице топика, и их там происходит 104 штуки :-). Но средняя загрузка сервера, на удивление, не превышает единицы.

  24. Виктор

    Идея просто великолепна!

  25. Vestel

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

  26. [...] [Источник] [...]

  27. Serj

    Очень цепляет, в этой идее есть какой-то тихий фатализм %)))

  28. Mike Ozornin

    Иконки для всяких КДЕ и смайлы для асек рисуют.
    Скоро можно ждать произвольных монстров для Цицеро (=

  29. Иван Сагалаев

    Собственно, ничто не мешает: форум легко ставится в любое Django-приложение, а картинки мутантов лежат в отдельной директории.

    Хотя... Форум еще дописать надо бы :-)

  30. igorekk

    Кстати, о форуме :)
    Мне было бы удобно иметь возможность голосовать за посты. Ну или хотя бы отмечать их как "особо ценный комментарий" ;) Такая возможность будет или это баловство?

  31. Иван Сагалаев

    Нет, голосований у меня не будет. Но у вас будет исходник и Django, на котором очень просто такую штуку прикрутить!

  32. igorekk

    Иван, понял :) Я так и думал, в принципе.
    На всякий случай просто спросил, мало ли...

  33. Dark-Demon

    супер. жалко, что автор зациклился на OpenID в результате чего для меня монстрика не окаалось :(
    а ведь email - это тоже уникальный идентификатор пользователя!

  34. Сергеев Сергей

    да,это решает проблему тёзок, но совпадения всё таки будут :)

  35. Лось Бульвинкль

    Казырно!
    Идея - есть. Тест реализация - есть. Куда расти - есть! А главное - очень улыбает ))))

    Во всей этой веб2.0 потетике, мутанты, пожалуй самая добрая идея, которую я видел. Буду юзать.

  36. HEm

    а при чем тут вебдваноль? или это рефлекс уже?

  37. HEm

    на основе имейла, кстате, лучше

  38. dik

    Супер.

  39. Tema

    Dark-Demon
    К сожалению у e-mail есть недостаток. Зная его не спросишь у mail-сервера имя, псевдоним, возраст, адрес, список друзей, интересы и т.п. автора.
    OpenID тем и хорош, что позволяет автору открывать некоторую информацию о себе, которая может быть использована на множестве веб-сервисов.

    Я вот только не понимаю почему не все социальные сети поддерживают OpenID, ведь они созданы друг для друга?

  40. dark-demon

    Tema, зачем все эти данные для формирования монстрика? Так я прям и рассказал кому попало свой адрес, список друзей, номер паспорта и код от банковской карты...

  41. Дамир

    dark-demon, есть и другой недостаток - в отличие от OpenID, email никак не идентифицирует пользователя и никто не помешает вам воспользоваться чужим email'ом. Если, конечно, не прикручивать сюда фазу активации аккаунта, но зачем, когда есть OpenID?

  42. Dyadya Zed

    Иван,
    с нетерпением жду новых этапов разработки cicero. Изучаю Джанго в ходе написания форума :) Успехов Вам и почаще пишите посты :)

  43. [...] Мутанты из OpenID Ссылка на все статьи про форум: Форум [...]

  44. Денис Барушев

    Наткнулся на забавную флешку, где 14 человек меняются частями своих лиц:
    http://www.mono-1.com/monoface/main.html

    И подумалось, ведь это бы намного интересней было, если мутанты были бы не пиксельными рисованными картинками, а составлялись из частей реальных фотографий. Если для основы взять приведенный материал, то можно получить 14^5 = 537824 варианта.

  45. Yuri

    Идея хорошая.

    Но... Плодить МУТАНТОВ?

    Почему нельзя рождать нечто ПРИЯТНО выглядещее?

    И ведь легко - если оставить руки-ноги симметричными, а менять лицо, одежду, ее цвет и пропрорции фигуры? Даже где-то болтается полный прототип того, что вам нужно - генератор персонажей South Park!

  46. dark-demon

    Денис Барушев, ага, а лучше распотрошить базу частей лиц для составления физиономии со слов очевидцев. :)

  47. Майк

    Ваня, полюбопытствуй, еще один вариант lazy-avatars: http://www.docuverse.com/blog/donpark/2007/01/18/visual-security-9-block-ip-identification

  48. Иван Сагалаев

    А... Так это же то, откуда взял свою идею Андреас Гор, который придумал MonsterID, о которых я писал в начале :-)

  49. Дмитрий Шехтман

    Отличная идея, отличная реализация. Вот только не согласен только с пунктом "если везде будут одни и те же картинки, это будет скучно".

    По-моему, наоборот, картинки вполне могут стать универсальной "второй сущностью". Очень хочется запортить механизм в phpBB (с указанием автора идеи). Поделитесь, пожалуйста, ручками-ножками!

  50. Иван Сагалаев

    Дмитрий, пардон, ручками-ножками поделиться не могу — эксклюзивная работа. Я на самом деле считаю, что лучше, чтобы на разных сайтах были свои картинки, подходящие сайту по стилю. И конечно, если бы в phpBB появилась такая штука, это было бы непомерно круто :-). Может стоит тоже обратиться к девушкам на PixelLand.Ru, чтобы еще наборчик нарисовали?

  51. pythy

    У Joe Gregorio тоже есть свои мутанты, только генерятся они по произвольной строке, а не по OpenID URI.

  52. BOLK

    На Kvnru.ru теперь тоже есть мутанты :) Правда, стандартные, но там они меньше и смотряться симпатично.

  53. [...] Маниакальный Веблог » Мутанты из OpenID import Image # главный модуль PIL # создается в памяти новая пустая картинка для конечностей extremities = Image.new(’RGBA’, (48, 48)) arms = Image.new(’RGBA’, (48, 48)) # выбираются (tags: avatar pil python code sample sagalaev openid) [...]

  54. jetxee

    Здорово!

    Я видел мутантов Сплитбрэйна (Докувики), но это — гораздо круче и симпатичнее. Прямо таки хоть блог на пайтон/джанго переводи ради таких чудиков :)

    Большой респект за организацию всего этого дела и художницам — за старание!

  55. [...] Не то, чтобы "в принципе", но это крайне маловероятно. Вот тут я описывал механизм подробно. [...]

  56. carsellers-ru.blogspot.com

    Красиво!

  57. carsellers-ru.blogspot.com

    Только.. Как посмотреть своего монстрика? )

  58. Иван Сагалаев

    Там ошибка была. Починил, вроде.

Добавить комментарий