Всем привет, Меня зовут Богдан Печёнкин. Многие Меня знают как соавтора Симулятора DS на Karpov.Courses. Сейчас Я фаундер стартапа Vibe AI – это AI Dating Copilot для парней и девушек, который помогает в переписках на сайтах знакомств и в мессенджерах (на май 2024 у нас 13,000+ пользователей).
Одна из ключевых фичей нашего бота – это AI-разборы переписок (ещё у нас подсказки, что написать или ответить, которые занимают 90%+ всех запросов, но они под капотом тоже задействуют тот же модуль парсинга скриншота переписки).
Что дают эти разборы пользователям?
Для парней – подсказывают, на какой стадии коммуникации ты находишься, какой уровень интереса у девушки, какие у тебя ошибки в переписке, т.е. по сути тренирует эмпатию, навык чувствовать другого человека, его эмоциональное состояние.
Для девушек – подсвечивает красные флажки у парней, иногда тоже подсказывает, что можно было делать лучше; где это реальный искренний интерес у парня, а где он просто делает вид, а на самом деле он токсичный абьюзер и/или нарцисс.
Чтобы эти разборы работали хорошо и визуально приятно глазу отрисовывались, нужно чтобы часть с распознаванием сообщений со скриншота работала стабильно и безошибочно (эта задача называется OCR – Optical Character Recognition).
Распознанные диалоги отправляются в GPT и происходит дальнейшая ИИ-магия...
Но нас сегодня интересует именно часть с OCR, в результате которой мы хотим получать сообщения + их координаты + кое-что ещё*
Почему OCR это сложнее, чем кажется?
Пользователи отправляют скриншоты со всех возможных дейтинг-приложений и со всех мессенджеров.
У каждого приложения своё собственное форматирование (где расположены сообщения, какие отступы), свои элементы (время отправки, "прочитано", реплаи); бывает, что пользователь отправляет сообщения, где видно клавиатуру, и ещё много-много других нюансов.
Примеры, что может пойти не так:
Любой разработчик знает поговорку: "мусор на входе – мусор на выходе".
Поэтому нам нужно приложить усилия, чтобы на стадии парсинга переписки со скриншота удостовериться, что нет мусора. Однако, есть и другой вариант развития событий...
А можно ли совсем без OCR?
Сейчас у нас OCR производится через Yandex Vision. Блоки текста проходят через огонь, воду и медные трубы... через ряд if-else, которые отбраковывают системные сообщения, лишние блоки, объединяют блоки (если относятся к одному сообщению) и тому подобное.
То есть, по факту, здесь у нас rule-based модель, которая с помощью внешнего API и короткой постобработки за 200-300ms выдаёт готовый диалог с координатами сообщений, притом приемлемого качества (уже без отваливающихся сообщений, без лишних элементов и цитат, с правильной разбивкой по отправителям и т.д.).
У современных мультимодальных языковых моделей (LLM – Large Language Model) часто есть родное "зрение", которые позволяет обрабатывать картинки уже как текст напрямую.
Но есть пара нюансов...
Проблема №1: Скорость. Вызов LLM это... дольше. LLM генерируют ответ токен за токеном (токен – кусочек слова), а сообщения и, как минимум, их координаты - это, в случае всего 1 скриншота, уже очень много токенов. Такая обработка занимает как минимум пару секунд.
Но эта проблема не самая страшная, есть легковесные Haiku, Phi-3, Gemini Flash, которые достаточно шустрые, чтобы их вызов не дамажил user experience наших пользователей.
Проблема №2: Цена. В наш сервис сейчас поступает в среднем ±2000 скриншотов в день, что уже выливается в хорошую такую копеечку, если хотя бы на салфетке не прикидывать юнит-экономику.
Но опять же, есть озвученная ниша легковесных моделей, наверняка и будут появляться новые, ещё быстрее и точнее. Но пока эти быстрые-дешевые модели слабо работают с распознаванием сообщений со скриншота...
Проблема №3: Координаты. Вот где иголка кощея.
Yandex Vision, т.е. специализированная API-шка под OCR выдаёт точные координаты для сообщений, по крайней мере, с помощью DBSCAN или аналогичных алгоритмов кластеризации их потом можно дополнять до полной рамки сообщения на основе близости в "пространстве цвета пикселей и координат между".
Предположим, сейчас SOTA среди моделей это GPT-4o (возможно на момент публикации это уже не так), вот как она размечает сообщения и координаты:
Я пробовал в ChatGPT, в Playground, по API, с разными промптами, разным разрешением скриншотов, с "low" и c "high" детализацией – всё очень-очень плохо (и вы можете сами поиграться со своими промптами и картинками). Всегда координаты куда-то съезжают, всегда что-то не то.
Бывает, GPT старается, и координаты, действительно, примерно идут в правильном порядке... и масштаб пикселей примерно соответствует разрешению картинки... но всё равно всё не то:
Всё куда-то улетает, съезжает. Почему так происходит?
Я не знаю, как именно GPT-4o работает с картинками, но Моя гипотеза:
Картинка нарезается на квадратики ("патчи") по сеточке, по аналогии с тем, как текст нарезается на токены ("кусочки слов");
Каждый такой квадратик кодируется в вектор (набор чисел, снова, по аналогии). Важно: на это этапе теряется информация о координатах (это нужно, чтобы патчи можно было обработать параллельно, т.е. без явного "шеринга информацией" между патчами);
А чтобы всё же в эмбеддинг "просачивалась" информация о взаимном расположении, к эмбеддингу добавляют позиционное кодирование, чтобы GPT, обрабатывая эти квадратики параллельно, всё же понимал близость к другим таким же кусочкам и на основе неё ("смысла в пространстве координат") делал те или иные умозаключения.
И далее всё прогоняется через модель как с обычным текстом...
По сути, этот шаг #3 (на диаграмме обозначен как ☯) – это, вероятно, единственное место, откуда GPT понимает, где что находится. И, вероятно, это единственный источник сигнала, за счёт чего он может догадаться, где какое сообщение расположено.
Кстати, а почему, вообще говоря, игра стоит свеч?
Какие бенефиты мы получим, решив эту загадку, «как сделать детекцию сообщений, используя только LLM»?
Думаю, нагляднее всего это продемонстрировать так:
То есть, по факту, GPT-4o при несложном промптинге (если закрыть глаза на скорость, стоимость и координаты, тем не менее, понимает кучу нюансов, которые через стандартный OCR и постобработку на нашей стороне сделать либо сложно, либо невозможно):
точно фильтрует всё лишнее «из коробки»;
понимает, где реплаи, а где нет;
выписывает время каждого сообщения (что важно, чтобы понять, кто кому отвечает сразу, а кто тянет время);
понимает, было ли сообщение отредактировано;
замечает реакции на сообщения (может выписать, какие);
сообщения из одних только эмодзи, стикеры;
картинки внутри переписки, кружочки, голосовые;
и многое другое...
Ну это же охереть как полезно для более тонкого понимания динамки коммуникации!
В общем, игра определённо стоит свеч!
Но вернёмся к препятствиям:
Скорость – на самом деле, если задуматься, не проблема, ведь мы можем работать в streaming режиме, когда генерация происходит не сразу, а пользователь видит процесс (как в ChatGPT и других UI) – что в принципе будет для пользователя уже достаточно увлекательно, чтобы он не ждал от отправки скриншота до первого отклика системы.
Цена... мы видим, что модели становятся дешевле (ноябрь 2023: GPT-4 → GPT-4-turbo сократилось в 2.5 раза; май 2024: GPT-4-turbo → GPT-4o ещё в 2 раза). Делаем ставку, что этот тренд будет продолжаться. Да и Сэм Альтман рекомендует делать на это ставку, а не жить в парадигме, что AI сегодня == AI завтра.
Остаётся «победить» координаты...
Итак, как же подступиться к проблеме?
Пробуем наивный промпт:
instruction = """
You are given an image of a conversation between two people.
Precisely detect coordinates of each message (in number of pixels).
Return them as a JSON object with the following format:
```json
{
"language": "en",
"dialogue": [
{
"sender": "left",
"text": "Hello!",
"coordinates": [x0, y0, x1, y1],
},
...
{
"sender": "right",
"text": "Hi there!",
"coordinates": [x0, y0, x1, y1],
}
]
}
"""
Прогоняем через GPT-4o:
import json
import base64
# Load initital image
file_bytes = open(image_path, "rb").read()
base64_image = base64.b64encode(file_bytes).decode("utf-8")
# Get response
response = client.chat.completions.create(
model="gpt-4o",
temperature=0,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": instruction},
{"role": "user", "content": [
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{base64_image}",
"detail": "high",
},
},
],
}
]
)
json_response = json.loads(response.choices[0].message.content)
print(response.choices[0].message.content)
Отрисовываем результат:
from PIL import Image
import cv2
# Load initial image using Image
image = cv2.imread(image_path, cv2.COLOR_GRAY2BGR)
print(image.shape)
# Now let's visualize parsed blocks and senders in image
for message in json_response['dialogue']:
x0, y0, x1, y1 = message['coordinates']
# Draw a rectangle around the message
color = (103, 68, 255) if message['sender'] == 'left' else (255, 1, 91)
cv2.rectangle(
image,
(x0, y0),
(x1, y1),
color,
)
# Save image to /tmp
final_img_path = 'tmp/image_boxes.jpeg'
cv2.imwrite(final_img_path, image)
Image.open(final_img_path)
Что получилось?
Сыграем в «Морской Бой»?
И разгадка этого квеста через 3... 2... 1... Нужно было всего лишь воспользоваться проверенным советским средством – сыграть в «морской бой».
Да, морской бой!
На этот раз вместо того, чтобы сразу подавать картинку на вход, мы сначала сделаем дополнительную "разметку" для GPT. Мы банально отрисуем метки пикселей по бокам через тот же matplotlib, накинем сеточку на изображение и вуаля:
import matplotlib.pyplot as plt
# Load the image with bounding boxes
image = Image.open(image_path)
# Create a figure with the specified size
fig, ax = plt.subplots(figsize=(20, 20))
ax.imshow(image)
# Set labels
ax.set_xlabel("Width (pixels)")
ax.set_ylabel("Height (pixels)")
# Add grid lines
ax.grid(True)
# Set xticks and yticks on both sides
ax.set_xticks(range(0, image.width, 100))
ax.set_yticks(range(0, image.height, 100))
ax.xaxis.set_ticks_position('both')
ax.yaxis.set_ticks_position('both')
ax.tick_params(axis='x', which='both', bottom=True, top=True, labelbottom=True, labeltop=True)
ax.tick_params(axis='y', which='both', left=True, right=True, labelleft=True, labelright=True)
# Save the image displayed with matplotlib
new_image_path = "tmp/image_with_coords.png"
plt.savefig(new_image_path)
Слегка дополненный промпт:
instruction = """
You are given an image of a conversation between two people.
Precisely detect the coordinates of each message (in the number
of pixels, use marks on the x-axis and y-axis as guidance).
Return them as a JSON object with the following format:
```json
{
"language": "en",
"dialogue": [
{
"sender": "left",
"text": "Hello!",
"coordinates": [x0, y0, x1, y1],
},
...
{
"sender": "right",
"text": "Hi there!",
"coordinates": [x0, y0, x1, y1],
}
]
}
"""
Результат:
Всё ещё не идеально (можно усложнять подход и дальше), НО! Учитывая то, зачем это нужно, этой точности нам более чем достаточно (а именно, визуально выделить сегмента диалога, на который GPT даёт обратную связь, чтобы в него попали все упоминаемые сообщения).
Почему это работает?
Первым делом, раскопав находку, Я рассказал о ней своему другу Игорю (мы работали вместе в AliExpress Russia; в прошлом году он опубликовал ту самую статью про ChatGPT, завёл канал "Сиолошная" и стал известным в СНГ AI-инфлюенсером).
Мы делимся разными прикольными новостями и статьями в мире AI. Как человек, который постоянно в потоке новостей из мира ИИ, наверняка он видел или знает что-то из подобных приёмов или сервисов. В ответ на Мою находку он подкинул статью, кто заводил что-то подобное: https://som-gpt4v.github.io/
По сути, делается что-то аналогичное: сперва мы "обогащаем" изображение цифрами (например, объекты) с помощью вспомогательной модели, затем задаём интересующий нас вопрос. Метод называется Set-of-Mark (SoM) – "набор меток".
Такое предварительное "впрыскивание" дополнительной информации в языковую модель тоже помогает ей не запутаться между взаимным расположением пар похожих объектов, а также заранее «якорит» её рассуждение на уже известных координатах.
UPD. В комментариях блога поделились ещё одной статьёй, где также по сеточке писались координаты, которые помогают GPT-4V ориентироваться на картинке:
Моя интуиция здесь такая: это очень похоже на обмен информацией по шумному / ограниченному каналу связи.
Возьмём классический пример из криптографии Алисы и Боба. Пусть Алиса и Боб – агенты под прикрытием и вместе находятся на задании. Их слышат посторонние, да и вообще, они находятся в шумной обстановке. Им "в моменте" обязательно необходима координация действий: что-то может идти не по плану, либо в какой-то момент нужно приступать к другому плану.
Если они будут каждый раз, когда им нужно переговорить, отходить в сторону и обсуждать, это вызовет большие подозрения (да и вы физически не можете это делать часто). Но если они заранее придумают некий секретный код, договорятся о небольшом числе тайных сигналов, то они смогут осуществлять обмен такими сигналами сильно-сильно чаще.
(пример: если кто-то из вас двоих упомянул в разговоре музыку/вайб места на вечеринке, значит, "здесь всё идёт хорошо и нужно задержаться"; если кто-то из вас двоих сделал кому-то третьему комплимент обуви, значит, "с минуты на минуту нужно уходить").
Ключевой момент, что эти условные обозначения нужно зафиксировать заранее.
Аналогично, в случае языковой модели: после разбивки на "патчи" у GPT теряется информация о координатах, и нужно придумать костыль, который помогал бы ей "просачиваться" в каждый из патчей.
Таким механизмом могут выступать сетка (с указанными в явном виде координатами в пикселях, если), метки-цифры и т.д. Если эти разметки попадают в соответствующие патчи, то они уже будут служить кодом/ориентиром, благодаря которому модель сможет восстановить позицию/координаты исходного патча.
Наверняка, в GPT-4.5/GPT-5 некая такая мета-информация о позиции будет подаваться на вход модели вместе с сырыми пикселями, поскольку то, каким образом сейчас происходит позиционное кодирование патчей картинки – не тянет для покрытия классических задач компьютерного зрения (детекции/сегментации), хотя, конечно, "интеллекта" языковых моделей для них вполне хватало бы уже сейчас. Будем посмотреть.
P.S. На случай вопросов о публикации переписок:
В заключение
Данного подхода лично для нашего OCR уже достаточно, чтобы уметь по высоте уже вырезать нужные сегменты, пропуская шаг с "честным OCR". Может быть, пока что рано полностью переезжать на GPT-4o, но здесь мы стали на шаг ближе.
Надеюсь, для ваших Vision-задач, где нужно получать на выходе расположение объекта этот приём (либо приём из статьи) будет полезен. Если речь про объекты сильно больше, чем сообщения (каждое из которых занимает всего ничего), погрешность координат безусловно окажется существенно меньше.
Спасибо что дочитали о конца!
Ссылки:
uberkinder Автор
Cтатья на arxiv где предлагается похожий подход https://arxiv.org/abs/2402.12058