Image recognition web service in Go with HTML

published: ⏰ 8

Continuation of Spring Boot web service with embedded ReactJS application, but now with Go and HTMX. Over the course of a long time, I have accumulated various diagrams, diagrams, and graphs in the form of a bunch of images scattered in folders. It’s not very convenient to search through them, so I decided to quickly make an image cataloger with content recognition.

Introduction

As an experiment, I wanted to try to make a web service in Go with a built-in UI. In general, there has long been a desire to move away from Java to something more convenient and productive like Go, Rust, C++. Image recognition is done in a rather straightforward and primitive manner, so it is not so important or interesting in this case.

Functional

  • Directory management

    • Recursive scanning of nested directories.
    • Automatic filtering of supported image formats.
    • Generation of index.json for each directory.
    • Creating a root index for the entire collection.
  • Image recognition through AI.

    • Image recognition using LLM models (LLaVA, Qwen-VL).
    • Generation of metadata in JSON format.
    • Parallel processing for speed.
    • Retry mechanism in case of errors.
  • Web interface

    • User interface for catalog browsing.
    • Search throughout the entire collection and in individual catalogs.
    • Sorting and filtering results.
    • Automatic updates when files change.
    • Preview images.

Why Go and HTMX

The idea is to quickly create a small microservice to solve a small problem. In this case, it is necessary to meet the following requirements:

  • Possibility of cross-platform assembly.
  • Possibility of delivery with minimal dependencies. That is, this is the missing or minimum runtime that must be set to launch the application. With Go, nothing is required, everything is in one executable.
  • Requires high performance and minimal impact on the OS in terms of resources consumed. The service is small, with the ability to run in the background on a local machine. It can also be launched in several copies for different directories.
  • There must be a built-in web service with UI for the ability to work through a browser, including remotely. UI resources should be built into the application without the need to deploy an additional service.
  • There is no need for authentication, DBMS, or monitoring at the current stage, but it should be possible to add it if necessary.
  • Minimum external dependencies on libraries in the code for simplicity.

Go fit this criteria quite well, as did Rust. Go benefited from more extensive documentation and ready-made libraries. I think over time Rust will catch up and surpass it, but for now that’s it.

For the UI, there was a choice to build a classic application with NodeJS or use a template engine with SSR. The second option seemed more suitable. HTMX a small library that allows you to work with a server to send and receive data. What’s important is that it can redraw only part of the UI from the response from the server (similar to hydration).

Project and structure

A new project can be created either manually or via the standard Go init.

Shell
go mod init kbase-catalog

The directory structure is approximately standard:

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

All logic and web service are hidden in internal and there are no public packages (pkg).

The assembly is performed for the desired architecture with the following command.

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

The final application size is small

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

General operating logic

What’s not special here is simple monitoring of the directory passed as an argument for new changes and indexing with image recognition. During the indexing process, in each directory, files with metadata index.json and additional Markdown are created for viewing without a web service.

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"
  }
}

Work directly with the file system, without complexity. The code can be found in the internal package in the project.

Then the resulting index can be displayed on the UI without additional processing. All search operations are performed using it.

Built-in web service and UI

The standard net/http is used as a web service, which is launched if web is passed as an application argument. UI templates here are stored in the web package and are ordinary text files, which are then processed by the standard html/template template engine.

Built-in web service

The setup is quite simple: we describe the API and file receiving handlers, add logging, CORS, and so on.

Сервер
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)
  }
}()

It is important not to forget about graceful shutdown. Without it, the service will freeze when stopped.

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)
}

Static resources

You can work with static resources in two main ways:

  • Issue from a directory on disk.
  • Use go:embed and serve from application resources.

Below is an example of file organization.

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

The first option is the simplest and can be easily implemented via http.FileServer.

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

This approach has an additional advantage - you can change resources and templates in real time without rebuilding the application.

The second option is a little more complicated. It is necessary to create a separate module to describe the embedded FS. Below is an example of a package declaration with static resources.

ℹ️ Warning

It is important that the code is located next to the resources, since the paths to static resources are relative.

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)
}

Next, the HandleEmbeddedFile handler can be used by the web service.

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

User Interface

Below is an example of the cataloger user interface. There are only two screens:

  • Search and filtering
  • Display catalog cards and images with descriptions.
  • When changing filtering parameters, only the list is redrawn, and not the entire page.

Image recognition

The simplest possible approach is used, transferring the image to an external service for recognition. In my case it is llama.cpp. Naturally, there is a huge area here for fine tuning, optimization and selection of suitable models. For such a task this was not necessary; the “head-on” solution was quite effective.

In a simplified form, the process looks like this:

  • Determine the image format.
  • Remove transparency (if any).
  • Convert to PNG.
  • Transfer the image to LLM with the specified prompt.
  • Parse the received response as JSON and save it in a directory.

Below is a configuration with parameters that are used to control the prompt and supported image formats.

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

Заключение

Overall, we managed to quickly create a utilitarian service for a small local task. What I liked most is that there are no external dependencies like JRE and Docker, the application is native two in one (CLI + Web), cross-platform and small in size.

ℹ️ Note

A sample project is available in open source.

Go Htmx Image-Recognition Self-Contained System Devmix