Веб-сервис распознавания изображений на 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.

Shell
go mod init kbase-catalog

Структура каталогов примерно стандартная:

Text
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) нет.

Сборка выполняется под нужную архитектуру следующей командой.

Shell
GOOS=linux GOARCH=amd64 go build -o kbase-image-catalog cmd/kbase-catalog/main.go

Размер итогового приложения получается небольшой

Shell
# ls
9.4M kbic-linux-amd64
9.7M kbic-windows-amd64.exe

Общая логика работы

Чего особенного здесь нет, простой мониторинг переданного в качестве аргумента каталога на появление новых изменений и индексация с распознаванием изображений. В процессе индексации, в каждом каталоге создаются файлы с метаданными index.json и дополнительные Markdown для возможности просмотра без веб-сервиса.

JSON
{
  "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 и так далее.

Сервер
Groovy
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. Без него при остановке сервис будет зависать.

Groovy
// 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 и выдавать из ресурсов приложения.

Ниже пример организации файлов.

Text
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.

Go
fileServer := http.FileServer(http.Dir("./static"))
http.Handle("/static/", http.StripPrefix("/static/", fileServer))

У такого подхода есть дополнительный плюс – можно в реальном времени менять ресурсы и шаблоны без пересборки приложения.

Второй вариант немного сложнее. Необходимо создать отдельный модуль описать встраиваемую FS. Ниже пример объявления пакета со статическими ресурсами.

ℹ️ Предупреждение

Важно чтобы код лежал рядом с ресурсами, так как пути к статическим ресурсам относительные.

go:embed
Groovy
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 можно использоваться веб-сервиса.

Groovy
mux := http.NewServeMux()
...
mux.HandleFunc("/static/", web.HandleEmbeddedFile)

Пользовательский интерфейс

Ниже пример пользовательского интерфейса каталогизатора. Всего два экрана:

  • Поиск и фильтрация
  • Отображение карточек каталогов и изображений с описанием.
  • При изменении параметров фильтрации перерисовывается только список, а не вся страница целиком.

Распознавание изображений

Используется максимально простой подход с передачей изображения во внешний сервис для распознавания. В моём случае это llama.cpp. Естественно есть здесь огромная область для тонкой настройки, оптимизации и подбора подходящих моделей. Для такой задачи это было не нужно, решения “в лоб” было достаточно эффективно.

В упрощенном виде процесс выглядит так:

  • Определить формат изображения.
  • Убрать прозрачность (если есть).
  • Преобразовать к PNG.
  • Передать изображение в LLM с указанным промптом.
  • Разобрать полученный ответ в виде JSON и сохранить его в каталоге.

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

YAML
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), кросплатформенное и небольшое по размерам.

ℹ️ Примечание

Пример проекта доступен в открытом доступе.

Go Htmx Image-Recognition Self-Contained System Devmix