Веб-сервис распознавания изображений на Go с HTMX
опубликовано: ⏰ 9Продолжение Веб-сервис на Spring Boot со встраиваемым приложением ReactJS , но теперь с Go и HTMX. У меня за долгое время накопились различные схемы, диаграммы, графики в виде кучу изображений раскиданных по папкам. Искать по ним не очень удобно, поэтому решил по-быстрому сделать каталогизатор изображений с распознаванием содержимого.
Введение
В качестве эксперимента хотелось попробовать сделать веб-сервис на Go со встроенным UI. В целом, давно есть желание уйти от Java, на что-то более удобное и производительно вроде Go, Rust, C++. Распознавание изображений сделано довольно прямолинейно и примитивно, поэтому не так важно и интересно в данном случае.
Функционал
Управление каталогами
- Рекурсивное сканирование вложенных каталогов.
- Автоматическая фильтрация поддерживаемых форматов изображений.
- Генерация index.json для каждого каталога.
- Создание корневого индекса для всей коллекции.
Распознавание изображений через ИИ.
- Распознавание изображений с использованием моделей LLM (LLaVA, Qwen-VL).
- Генерация метаданных в формате JSON.
- Параллельная обработка для ускорения.
- Механизм повторных попыток в случае ошибок.
Веб-интерфейс
- Пользовательский интерфейс для просмотра каталога.
- Поиск по всей коллекции и в отдельных каталогах.
- Сортировка и фильтрация результатов.
- Автоматические обновления при изменении файлов.
- Предварительный просмотр изображений.
Почему именно Go и HTMX
Идея – быстрое создание небольшого микросервиса для решения небольшой задачи. При этом необходимо, чтобы соответствовало следующим требованиям:
- Возможность кросплатформенной сборки.
- Возможность поставки с минимальными зависимостями. То есть это отсутствующий или минимальный рантайм, который необходимо устанавливать для запуска приложения. В случае Go не требуется ничего, всё в одном исполняемом файле.
- Требуется высокая производительность и минимальное влияние на ОС по потребляемым ресурсам. Сервис небольшой, с возможностью запуска фоном на локальной машине. Так же может запускаться в нескольких экземплярах для разных каталогов.
- Должен быть встроенный веб-сервис с UI для возможности работы через браузер, в том числе удалённо. Ресурсы UI должны быть встроены в приложение без необходимости развёртывания дополнительного сервиса.
- Потребности в аутентификации, СУБД, мониторинге на текущем этапе нет, но должна быть возможность добавить при необходимости.
- Минимум внешних зависимостей от библиотек в коде для упрощения.
Go вполне подходил под эти критерии, как и Rust. Go выиграл из-за более обширной документации и готовых библиотек. Думаю, со временем Rust догонит и перегонит его, но пока так.
Для UI был выбор собирать классическое приложение с NodeJS или использовать шаблонизатор с SSR. Второй вариант показался более подходящим. HTMX небольшая библиотека, которая позволяет работать с сервером для отправки и получения данных. Что важно – умеет перерисовывать только часть UI из ответа от сервера (похоже hydration).
Проект и структура
Новый проект можно создать либо вручную, либо через стандартную init для Go.
go mod init kbase-catalogСтруктура каталогов примерно стандартная:
kbase-catalog/
├─ cmd # Application entry point
├─ internal/ # Internal packages (non-exportable)
├─ web/ # Web resources
├─ docs/ # Documentation
├─ config.yaml # Application configuration
├─ go.mod # Go module
├─ Dockerfile # Container build
├─ docker-compose.yml # Docker deployment
└─ README.md # This documentationВся логика и веб-сервис скрыты в internal и публичных пакетов (pkg) нет.
Сборка выполняется под нужную архитектуру следующей командой.
GOOS=linux GOARCH=amd64 go build -o kbase-image-catalog cmd/kbase-catalog/main.goРазмер итогового приложения получается небольшой
# ls
9.4M kbic-linux-amd64
9.7M kbic-windows-amd64.exeОбщая логика работы
Чего особенного здесь нет, простой мониторинг переданного в качестве аргумента каталога на появление новых изменений и
индексация с распознаванием изображений. В процессе индексации, в каждом каталоге создаются файлы с метаданными
index.json и дополнительные Markdown для возможности просмотра без веб-сервиса.
{
"log4brains.png": {
"description": "A screenshot of a decision log entry from the 'tekBlueprint' architecture knowledge base, detailing the selection of XML as the blueprint format. The page outlines the context (need for a portable, widely-used format), considered options (JSON, YAML, XML, Custom DSL, TOML), and the rationale for choosing XML due to its commonality, broad editor support, and strong typing capabilities with XSD.",
"original_name": "log4brains.png",
"short_name": "Blueprint Format Decision",
"update_date": "2026-01-08T13:55:56+04:00",
"vl_model": "qwen3-vl-8b-instruct"
},
"structurizr.png": {
"description": "This image displays a system context diagram for the Financial Risk System, illustrating its interactions with various components such as Business Users, Configuration Users, E-mail systems, Active Directory, Trade Data Systems, and Reference Data Systems. The diagram outlines how these elements communicate to calculate risk exposure, manage data, and handle alerts within a banking environment.",
"original_name": "structurizr.png",
"short_name": "Financial Risk System Architecture",
"update_date": "2026-01-08T13:55:54+04:00",
"vl_model": "qwen3-vl-8b-instruct"
},
"thoughtworks-radar.png": {
"description": "This image displays a technology adoption radar chart divided into four quadrants: Techniques, Tools, Platforms, and Languages \u0026 Frameworks. Each quadrant shows numbered items plotted along a continuum from 'Hold' to 'Adopt', indicating their maturity or readiness for adoption within an organization. The legend indicates whether each item is new, moved in/out, or unchanged.",
"original_name": "thoughtworks-radar.png",
"short_name": "Technology Adoption Radar",
"update_date": "2026-01-08T13:55:52+04:00",
"vl_model": "qwen3-vl-8b-instruct"
}
}Работа напрямую с файловой системой, без усложнения. Код можно найти в пакете internal в проекте.
Далее полученный индекс может отображаться на UI без дополнительно обработки. Все операции поиска выполняются по нему же.
Встроенный вебсервис и UI
В качестве веб-сервиса используется стандартный net/http, который запускается если в качетсве аргумента приложения
передан web. Шаблоны UI здесь хранятся в пакете web и являются обычными текстовыми файлами, которые потом
обрабатываются стандартным шаблонизатором html/template.
Встроенный вебсервис
Настройка довольно простая: описываем обработчики API и получения файлов, добавляем логирование, CORS и так далее.
Сервер
mux := http.NewServeMux()
// Static files handler for images
mux.HandleFunc("/archive/", s.apiHandler.HandleArchiveFiles)
// Static files handler for static assets
mux.HandleFunc("/static/", web.HandleEmbeddedFile)
// Web interface handlers
mux.HandleFunc("/", s.apiHandler.HandleIndex)
mux.HandleFunc("/api/catalog", s.apiHandler.HandleApiCatalog)
...
// Apply middleware
var handler http.Handler = mux
handler = api.LoggingMiddleware(handler)
handler = api.RecoveryMiddleware(handler)
handler = api.CORSMiddleware(handler)
s.httpServer = &http.Server{
Addr: ":" + strconv.Itoa(s.port),
Handler: handler,
}
log.Printf("Starting web server on http://localhost:%d\n", s.port)
if err := s.apiHandler.Start(); err != nil {
return err
}
// Start the server in a goroutine so we can handle shutdown signals
go func() {
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("Server error: %v", err)
}
}()Важно не забыть о graceful shutdown. Без него при остановке сервис будет зависать.
// Wait for shutdown signal
<-ctx.Done()
fmt.Println("Shutting down gracefully...")
err = server.Stop(ctx)
if err != nil {
log.Printf("Error during shutdown: %v", err)
}Статические ресурсы
Со статическими ресурсами можно работать двумя основными способами:
- Выдавать из каталога на диске.
- Использовать
go:embedи выдавать из ресурсов приложения.
Ниже пример организации файлов.
web
├─static
│ ├─htmx.min.js
│ ├─styles.css
│ ├─viewer.min.css
│ └─viewer.min.js
└─templates
├─catalog-detail.html
├─catalog-images-fragment.html
├─catalog-images-template.html
├─catalog-list-fragment.html
├─catalog-list-template.html
├─catalog-navigation-template.html
├─index.html
├─search-result.html
└─search-result-fragment.htmlПервый вариант самый простой и легко реализуется через http.FileServer.
fileServer := http.FileServer(http.Dir("./static"))
http.Handle("/static/", http.StripPrefix("/static/", fileServer))У такого подхода есть дополнительный плюс – можно в реальном времени менять ресурсы и шаблоны без пересборки приложения.
Второй вариант немного сложнее. Необходимо создать отдельный модуль описать встраиваемую FS. Ниже пример объявления пакета со статическими ресурсами.
ℹ️ Предупреждение
Важно чтобы код лежал рядом с ресурсами, так как пути к статическим ресурсам относительные.
go:embed
package web
...
//go:embed static/*
//go:embed templates/*
var FS embed.FS
// getContentType returns the appropriate content type for a file path
func getContentType(path string) string {
...
}
// HandleEmbeddedFile serves a file from the embedded filesystem
func HandleEmbeddedFile(w http.ResponseWriter, r *http.Request) {
realPath := strings.TrimPrefix(r.URL.Path, "/")
if realPath == "" {
http.NotFound(w, r)
return
}
// Read the file from embedded realPath
content, err := FS.ReadFile(realPath)
if err != nil {
log.Printf("Error reading embedded file %s: %v", realPath, err)
http.NotFound(w, r)
return
}
// Set content type
w.Header().Set("Content-Type", getContentType(r.URL.Path))
// Set cache headers for static assets
if strings.HasPrefix(realPath, "static/") {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
// Write the content
w.Write(content)
}Далее обработчик HandleEmbeddedFile можно использоваться веб-сервиса.
mux := http.NewServeMux()
...
mux.HandleFunc("/static/", web.HandleEmbeddedFile)Пользовательский интерфейс
Ниже пример пользовательского интерфейса каталогизатора. Всего два экрана:
- Поиск и фильтрация
- Отображение карточек каталогов и изображений с описанием.
- При изменении параметров фильтрации перерисовывается только список, а не вся страница целиком.




Распознавание изображений
Используется максимально простой подход с передачей изображения во внешний сервис для распознавания. В моём случае это llama.cpp. Естественно есть здесь огромная область для тонкой настройки, оптимизации и подбора подходящих моделей. Для такой задачи это было не нужно, решения “в лоб” было достаточно эффективно.
В упрощенном виде процесс выглядит так:
- Определить формат изображения.
- Убрать прозрачность (если есть).
- Преобразовать к PNG.
- Передать изображение в LLM с указанным промптом.
- Разобрать полученный ответ в виде JSON и сохранить его в каталоге.
Ниже конфигурация с параметрами, которые используются для управления промптом и поддерживаемыми форматами изображений.
api_url: "http://192.168.1.7:1234/v1/chat/completions"
model: "llava-v1.5-7b"
timeout: 60
system_prompt: |-
You are a helpful assistant specialized in image analysis.
You must respond in valid JSON format ONLY, without any extra text.
The JSON must contain two keys:
1. "short_name": a short, descriptive name for the image.
2. "description": a detailed description of the image in English.
Example output format:
{"short_name": "Sunset on the beach", "description": "The image shows a sunset at sea..."}
supported_extensions:
- ".png"
- ".jpg"
- ".jpeg"
- ".webp"
- ".gif"
- ".bmp"
parallel_requests: 3
max_retries: 3
retry_delay: 5Заключение
Если оценивать в целом, то получилось быстро сделать утилитарный сервис для небольшой локальной задачи. Больше всего мне понравилось, что нет внешних зависимостей вроде JRE и Docker, приложение нативное два в одном (CLI + Web), кросплатформенное и небольшое по размерам.
ℹ️ Примечание
Пример проекта доступен в открытом доступе.