Здравствуйте, меня зовут Дмитрий Карловский и я люблю рисовать шедевры, но у меня совсем не хватает терпения довести хоть один из них до конца.

Ранее я уже показывал вам самописного убийцу Гугл Поиска. Пользуюсь им до сих пор и доволен чистотой выдачи. Теперь же мы сделаем убийцу Artstation для творческих личностей, у которых терпения хватает лишь на несколько минут, за которые надо успеть создать настоящую красоту. И в этом нам помогут нейронные сети.

$hyoo_artist_app $mol_page	title @ \Artificial Artist
$hyoo_artist_app $mol_page title @ \Artificial Artist

Далее вас ждёт реверс‑инжениринг HuggingFace API для использования модели Kandinsky, поддержка запросов на 100 языках мира благодаря модели Small100, проектирование бесконечной виртуальной ленты в несколько строк кода на $mol и, конечно, примеры творчества Искусственного Художника.

Server API

Итак, первым делом идём на github.com huggingface.co. Там можно найти несколько сотен тысяч разных моделей на любой вкус. Нас же будет интересовать свежая ai-forever/Kandinsky_2.1:

Очень интересно, но нифига не понятно
Очень интересно, но нифига не понятно

Скачивать мы её, конечно, не будем, а будем использовать из виртуалки в облаке. К счастью, рядом можно найти так называемый спейс, на странице которого можно поиграться с этой моделью:

Железная леди
Железная леди

Ещё одно счастье — этот спейс гоняет нейронки на GPU. Его можно было бы форкнуть и настроить под себя, как я это сделал, например, тут, соединив вместе 3 нейронки:

Два гиноида и ЛГБТ-лошадка
Два гиноида и ЛГБТ-лошадка

Но тогда нейронки будут работать на CPU, что медленно и, как показывает практика, менее стабильно. Так что будем использовать то, что есть. Благо, спейс с Kandinsky — не такой звездолёт, как, например, этот:

Звездолёт класса "Стабильный Диффузор"
Звездолёт класса "Стабильный Диффузор"

Код спейса пишется с использованием питоновского фреймворка Gradio, в котором описываются интерфейсы, по которым автоматически строятся REST и WS API, а также формируется фронтенд на Svelte и Tailwind, доступный по ссылкам вида: https://ai‑forever‑kandinsky2–1.hf.space/.

Не смотря на легковесность Svelte, фронтенд получается весом в пол мегабайта. И до чего я миролюбивый, но того, кто придумал Tailwind, тянущий за собой 200КБ стилей, хочется кастрировать. Дважды, чтобы точно не размножился, ибо кастомизация его стилей — это просто !inhumane.

Заднеприводный передний конец
Заднеприводный передний конец

Вот сделали бы они фронт на $mol — изменить дизайн можно было бы за считанные минуты вместо нескольких часов, упарываясь солями, чтобы не сойти с ума. Да и получился бы он куда легковесней.

Инсайдерский слив
Инсайдерский слив

Так же как в $mol, в Gradio интерфейс строится как композиция готовых компонент путём их кастомизации. Средства кастомизации беднее, конечно, зато там есть интересная фича: любой компонент может выступать как поток входных и выходных данных. То есть буквально, в качестве аргументов можно передавать не значения, а другие компоненты, и они будут связаны реактивной связью:

Prompt = gr.Textbox( label="Prompt" )
Image = gr.Image()
Imagine = gr.Button( "Imagine" )

Imagine.click(
	imagine,
	inputs=[ Prompt ],
	outputs=[ Image ],
	api_name="imagine"
)

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

Если автор кода задал API‑имя для кнопки, то привязанная к ней функция будет доступна по REST эндпоинту вида hyoo‑translate.hf.space/run/translate, а в подвале фронтенда вы найдёте кнопку, открывающую неплохую автогенерируемую доку по API:

Искусственный ху..
Искусственный ху..

Но у этого API есть одна беда — если запрос будет слишком долгим, то он отвалится по таймауту. А так как это явление не редкое, то мы будем использовать WebSocket API, которым и пользуется сгенерированный Gradio фронтенд. Тут уже нет никаких таймаутов, зато есть сообщения о прогрессе выполнения запроса.

WebSocket API

К сожалению, на WS API никакой документации нет, так что воспользуемся отладчиком и методом научного тыка. Запустив несколько раз генерацию, заметим, что каждый раз устанавливается новое соединение на эндпоинт вида:

wss://ai-forever-kandinsky2-1.hf.space/queue/join

По завершении задачи соединение закрывается. То есть нельзя просто один раз поднять соединение и далее слать RPC запросы. Ок, не осилили, смотрим, что за сообщения там передаются. Первым делом сервер запрашивает у нас хеш:

{ "msg": "send_hash" }

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

Что ж, клиент отсылает хеш и номер функции:

{
	"session_hash": "jnjiyncjiub",
	"fn_index": 2
}

Да, по сокету все функции, а точнее кнопки, а ещё точнее обработчики кликов по кнопкам, идентифицируются порядковым номером в коде, а не по имени как в случае REST. Надо ли говорить, что такой API очень хрупкий, и так делать не надо? Ну раз мы встречаем такое в софте от крупной компании, то видимо да, надо:

???? Лучше заставить разработчика задать семантичное имя явно, чем неявно завязываться на порядок объявлений в коде, обрекая разработчика на отладку мистических багов, вылезающих как грибы после обновления сервера, из‑за того, что старый клиент всё ещё вызывает функцию по старому номеру.

Во view.tree, кстати, это требование реализовано на уровне синтаксиса — там вообще все сущности имеют семантичные имена, но не будем забегать вперёд.

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

{
	"avg_event_concurrent_process_time": 9.63406398266656,
	"avg_event_process_time": 9.63406398266656,
	"msg": "estimation",
	"queue_eta": 125,
	"queue_size": 15,
	"rank": 14,
	"rank_eta": 154.14502372266497
}

Тут мы видим, что в очереди сейчас 15 запросов, мы на 14 месте, а результата придётся ждать аж две с половиной минуты. Ну, это сейчас аншлаг, а так, в лучшем случае картинка может появиться уже и через 10 секунд, если верить среднему времени выполнения запроса.

Когда сервер будет готов заняться нашим запросом, то попросит у нас аргументы функции:

{ "msg": "send_data" }

На что клиент отвечает:

{
	"session_hash": "jnjiyncjiub",
	"fn_index": 2,
	"data": [ "Artificial Artist", "ugly mug" ]
}

А сервер подтверждает, что пошёл работать:

{ "msg": "process_starts" }

И если звёзды сложатся удачно, то вскоре мы получим результат:

{
    "msg": "process_completed",
    "success": true,
    "output": {
        "data": [[
            {
                "is_file": true,
                "data": null,
                "name": "/tmp/tmpwln8_d7p/tmprk7jefgn.png"
            }
        ]],
        "is_generating": false,
        "duration": 9.59670877456665,
        "average_duration": 9.432154195723134
    }
}

Флаг is_generating по всей видимости показывает является ли результат финальным или промежуточным. А флаг is_file показывает, находятся ли данные в data (для картинок это будет data‑uri) или же их надо скачивать отдельно из файла, путь к которому указан в name.

Что ж, берём полученный путь, приклеиваем к эндпоинту для получения файлов, и получаем, наконец, абсолютную ссылку на картинку вида:

https://ai-forever-kandinsky2-1.hf.space/file=/tmp/tmpwln8_d7p/tmprk7jefgn.png

Ну а если что-то пойдёт не так, то сервер пришлёт лаконичное:

{
    "msg": "process_completed",
    "success": false,
    "output": {
        "error": null
    }
}

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

{ "msg": "queue_full" }
Протечки в абстракциях
Протечки в абстракциях

TypeScript API

Что ж, протокол не сложный. Давайте реализуем его в обобщённой форме, чтобы использовать модели с HuggingFace можно было даже средним пальцем левой ноги.

Реализовать адаптер к REST API проще простого:

export function $mol_huggingface_rest(
	space: string,
	method: string,
	... data: readonly any[]
) {
	
	const uri = `https://${space}.hf.space/run/${method}`
	const response = $mol_fetch.json( uri, {
		method: 'post',
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ data }),
	} ) as any
	
	if( 'error' in response ) {
		$mol_fail( new Error( response.error ?? 'Unknown API error' ) )
	}
	
	return response.data as readonly any[]
	
}

Чуть сложнее реализовать WS API:

export function $mol_huggingface_ws(
	space: string,
	fn_index: number,
	... data: readonly any[]
) {
	
	const session_hash = $mol_guid()
	const socket = new WebSocket( `wss://${space}.hf.space/queue/join` )
	
	const promise = new Promise< readonly any[] >( ( done, fail )=> {
		
		socket.onclose = event => {
			if( event.reason ) fail( new Error( event.reason ) )
		}
	
		socket.onerror = event => {
			fail( new Error( `Socket error` ) )
		}
	
		socket.onmessage = event => {
			
			const message = JSON.parse( event.data )
			switch( message.msg ) {
				
				case 'send_hash':
					
					return socket.send(
						JSON.stringify({ session_hash, fn_index })
					)
			
				case 'estimation': return
				
				case 'queue_full':
					fail( new Error( `Queue full` ) )
			
				case 'send_data':
					
					return socket.send(
						JSON.stringify({ session_hash, fn_index, data })
					)
			
				case 'process_starts': return
			
				case 'process_completed':
					
					if( message.success ) {
						return done( message.output.data )
					} else {
						return fail(
							new Error( message.output.error ?? `Unknown API error` )
						)
					}
				
				default:
					
					return fail(
						new Error( `Unknown message type: ${ message.msg }` )
					)
				
			}
			
		}
	
	} )
	
	return Object.assign( promise, {
		destructor: ()=> socket.close()
	} )
	
}

Обратите внимание на деструктор в обещании. Он будет вызван автоматически, когда пользователь введёт новый текст, и инициирует новый вызов. Подробнее про эту проблематику рассказывается в статье: Отменить нельзя продолжить.

Наконец, напишем обёртку над этими двумя функциями, которая умеет автоматически повторять запросы в случае, когда сервер посылает нас на фиг:

export function $mol_huggingface_run(
	space: string,
	method: string | number,
	... data: readonly any[]
) {
	while( true ) {
		
		try {
			
			if( typeof method === 'number' ) {
				const run = $mol_wire_sync( $mol_huggingface_ws )
				return run( space, method, ... data )
			} else {
				return $mol_huggingface_rest( space, method, ... data )
			}
			
		} catch( error ) {
			
			if( $mol_promise_like( error ) ) $mol_fail_hidden( error )
			
			if( error instanceof Error && error.message === `Queue full` ) {
				$mol_fail_log( error )
				continue
			}
			
			$mol_fail_hidden( error )
		}
		
	}
}

Если метод указан как число, то будет запрос через WS API, а если как строка, то через REST API. Так как функция $mol_huggingface_ws у нас асинхронная, то её приходится сначала синхронизировать, используя магию вне Хогвартса. А вот $mol_huggingface_rest уже синхронная, а вся магия спрятана внутри, так что дополнительная синхронизация ей не нужна.

Теперь создадим более конкретную функцию, которая формирует ссылку на картинку, сгенерированную через Kandinsky по переданным позитивному и негативному запросам:

export function $hyoo_artist_imagine(
	prompt: string,
	forbid = '',
) {
	
	if( !prompt ) return ''
	
	const space = 'ai-forever-kandinsky2-1'
	
	const path = $mol_huggingface_run(
		space,
		2,
		prompt,
		forbid,
	)[0][0].name as string
	
	return `https://${space}.hf.space/file=${path}`
	
}

А чтобы научить её понимать разные языки, напишем и функцию для переводов с любого языка на заданный через модель small100:

export function $hyoo_lingua_translate(
	lang: string,
	text: string,
) {
	
	if( !text.trim() ) return ''
	
	return $mol_huggingface_run(
		'hyoo-translate',
		0,
		lang,
		text
	)[0] as string
	
}

Тадам! Вся сложность использования нужных нам нейронок инкапсулирована в паре функций с простыми сигнатурами:

function $hyoo_lingua_translate( lang: string, text: string ): string
function $hyoo_artist_imagine( prompt: string, forbid?: string ): string
У мене внутре… гм… не… нейронка
У мене внутре… гм… не… нейронка

Интерфейс

Теперь следите за руками внимательно, иначе всё пропустите:

$hyoo_artist_app $mol_page
	title <= title_default @ \Artificial Artist
	head /
		<= Query $mol_search
			hint <= title_default
			query? <=> query_changed? \
			submit? <=> imagine? null
		<= Source $mol_link_source
			uri \https://github.com/hyoo-ru/artist.hyoo.ru
	body /
		<= Images $mol_infinite
			row_ids? <=> images? /
			after* <= images_more* /
			Row* <= Image* $mol_image
				minimal_width 256
				minimal_height 256
				uri <= image* \

Тут мы создали типичную страницу, где в шапке находится поисковое поле и ссылка на исходники приложения. А в теле — бесконечная лента из картинок с не менее, чем 256 пикселя размером.

Если этот код кажется вам не понятным, то попробуйте поиграться с ним в песочнице. Ну или, если вы не спортсмен, то почитать документацию, которой, как известно, нет.

Название приложения автоматически локализуется на язык пользователя, и используется в качестве примера запроса. Тексты на разных языках можно положить рядом:

{
	"$hyoo_artist_app_title_default": "Искусственный Художник"
}

Но если какого‑то текста не окажется — не беда. $mol_locale на лету переведёт английский текст на нужный язык. А если не получится, то покажет английский текст. В крайнем случае отобразит человекопонятный ключ.

Искусственный пример
Искусственный пример

Воу, просто накидали компонент на страницу, а уже всё опрятно выглядит!

Логика

Теперь добавим немного бойлерплейта, отнаследовавшись от описанного выше компонента:

namespace $.$$ {
	export class $hyoo_artist_app extends $.$hyoo_artist_app {
		// whole logic here
	}
}

Запрос синхронизируем с безымянным параметром в урле:

@ $mol_mem
query( next?: string ) {
	return this.$.$mol_state_arg.value( '', next ) ?? ''
}

Разобьём его на две группы токенов: позитивные и негативные.

@ $mol_mem
tokens() {
	
	const {
		prefer = [],
		forbid = [],
	} = $mol_array_groups(
		this.query().split( /\s+/g ).filter( v => v ),
		token => token.startsWith( '-' ) ? 'forbid' : 'prefer',
	)
	
	return {
		prefer,
		forbid: forbid.map( token => token.slice(1) ),
	}
		
}

Таким образом, если пользователь хочет, чтобы нейронка постаралась, чтобы что‑то не попало на картину, то достаточно как в Гугл Поиске описать это, предваряя минусами каждое слово:

Полноцветное из
Полноцветное изображение

Идём далее. Каждую группу токенов соединим через пробел и переведём на английский:

@ $mol_mem
prompts() {
	const { prefer, forbid } = this.tokens()
	return [ prefer, forbid ].map(
		tokens => this.$.$hyoo_lingua_translate(
			'en',
			tokens.join( ' ' ),
		)
	) as [ string, string ]
}

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

images_more( from: string | null ) {
	const uri = this.$.$hyoo_artist_imagine( ... this.prompts() )
	return uri ? [ uri ] : []
}

В заголовке окна выводим название приложения и оригинальный запрос, если он не пустой:

@ $mol_mem
title() {
	if( !this.query() ) return this.title_default()
	return `${ this.query() } / ${ this.title_default() }`
}

При очистке поискового поля, стираем и запрос :

@ $mol_mem
query_changed( next?: string ) {
	if( next === '' ) this.query( '' )
	return next ?? this.query()
}

Когда пользователь жмёт Enter, меняем запрос на введённый:

imagine() {
	this.query( this.query_changed() )
}

Вуаля! Бесконечная лента готова:

Вечерний макияж вышел из под контроля
Вечерний макияж вышел из под контроля

А как же хуки жизненного цикла, спиннеры, обработка исключений, удаление пролистанных картинок и другие оптимизации? В $mol мы такой хернёй не занимаемся, а пишем только чистую и не замутнённую бизнес‑логику. Остальное же всё — автоматизируется не долетая до прикладной логики.

Совсем красота

Осталось лишь добавить несколько мелких штрихов на статически типизированных каскадных стилях для единообразного отображения картин и индикатора загрузки на любых размерах экранов:

namespace $.$$ {
	
	const Frame: $mol_style_properties = {
		margin: 'auto',
		width: '768px',
		maxWidth: '100%',
		height: 'auto',
		aspectRatio: 1,
	}
	
	$mol_style_define( $hyoo_artist_app, {
		
		Body: {
			padding: [ 0, $mol_gap.block ],
		},
		
		Images: {
			gap: $mol_gap.block,
			After: Frame,
		},
		
		Image: Frame,
		
	} )
	
}

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

Последствия чтения кода на $mol
Последствия чтения кода на $mol

Приятного вам думскроллинга!

Пост-Мета-Сарказм

Попутно, на той же small100 был разработан так же и сервис онлайн перевода Lingua Franca. К сожалению, переводит он не очень качественно и не очень быстро ибо нейронка крутится на CPU. Но, в отличие от популярных переводчиков, он куда удобнее, когда надо выверять формулировки переводя поочерёдно в обе стороны. Но это уже отдельная история..

Если вам этого рассказа не хватило и хочется больше хардкорных подробностей с разными нейронками, можете глянуть стрим с одним из этапов эволюции Искусственного Художника под руководством художника идентичного натуральному:

Обсудить эти и другие наши проекты можно в соответствующей теме на форуме Hyper Dev. А если не боитесь инноваций и коопераций — пишите мне телеграмы.


Актуальный оригинал на $hyoo_page.

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


  1. inscriptios
    14.04.2023 11:02
    +1

    Итак, первым делом идём на github.com huggingface.com.

    Захожу я туда, а там... эмоджи ???? Оказывается вы имели ввиду https://huggingface.co/.