Building an Image Description API with Angular, Spring Boot, and AI

Full-stack AI Image Description with Angular and Spring Boot

🎯 What We're Building Today

In this hands-on guide, we'll create a full-stack application that can analyze images and generate detailed descriptions using AI. We'll use Angular for the frontend, Spring Boot for the backend, and integrate with a powerful AI service. The end result will be a modern web application that can process images and provide meaningful descriptions.


πŸ› οΈ Tech Stack Deep Dive

Before embarking on this quest, ensure you have the following tools installed:

  • JDK 17+: For Spring Boot development.
  • Maven 3.8+ or Gradle: For managing Spring Boot project dependencies.
  • Node.js and npm (or Yarn): For Angular development.
  • Angular CLI: Install globally via `npm install -g @angular/cli`.
  • Docker Desktop: For containerization.
  • Minikube or Kubernetes cluster: For local or cloud deployment.
  • Ollama: The open-source framework for running large language models locally. Download from ollama.ai.
  • A pulled vision-capable model: Such as `ollama pull llava` or `ollama pull qwen2.5vl`.
  • A test image: E.g., `/tmp/test_images/sample.jpg` (or `C:\tmp\test_images\sample.png` on Windows).

πŸš€ Let's Get Started - Project Setup

Let's begin by conjuring a new Spring Boot project. You can use Spring Initializr (start.spring.io) with the following dependencies:

  • Spring Web
  • Spring Data JPA (Optional, but good for future expansion)
  • Lombok (Optional)
  • LangChain4j Ollama (Add this manually later or as a custom dependency if not on Initializr)

After generating, add the LangChain4j Ollama dependency to your `pom.xml` (for Maven) or `build.gradle` (for Gradle):




    dev.langchain4j
    langchain4j-ollama
    0.28.0 


    io.quarkiverse.langchain4j
    quarkus-langchain4j-ollama
    0.10.0 


// Gradle (build.gradle)
implementation 'dev.langchain4j:langchain4j-ollama:0.28.0' // Use the latest version
implementation 'io.quarkiverse.langchain4j:quarkus-langchain4j-ollama:0.10.0' // Use the latest version

Configure your `application.properties` (or `application.yml`) for Ollama:


# application.properties
langchain4j.ollama.chat-model.base-url=http://localhost:11434
langchain4j.ollama.chat-model.model-name=llava # or qwen2.5vl
langchain4j.ollama.chat-model.timeout=180s

πŸ’» Building the Backend - Spring Boot Magic

Create an interface for your AI service. This leverages LangChain4j's `AiService` abstraction.

File: `src/main/java/com/techoral/imagedescriber/service/ImageDescriberAiService.java`


package com.techoral.imagedescriber.service;

import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.data.image.Image;
import dev.langchain4j.service.AiService;
import org.springframework.stereotype.Service;

@AiService
@Service // Spring annotation for component scanning
public interface ImageDescriberAiService {

    @SystemMessage("You are an expert image analyst. Describe this image in a concise and informative manner.")
    @UserMessage("Describe this image.")
    String describeImage(Image image);
}

🎨 Creating the Frontend - Angular Awesomeness

Now, let's craft the Angular frontend to upload images and display descriptions.

Create a new Angular project:


ng new image-describer-frontend --defaults
cd image-describer-frontend

Generate a component for image upload and display:


ng generate component image-uploader

Update `src/app/image-uploader/image-uploader.component.html`:


Upload Image for Description

Description:

{{ description }}

Error:

{{ error }}

Uploaded Image Preview:

Selected Image

Update `src/app/image-uploader/image-uploader.component.ts`:


import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-image-uploader',
  templateUrl: './image-uploader.component.html',
  styleUrls: ['./image-uploader.component.css']
})
export class ImageUploaderComponent {
  selectedFile: File | null = null;
  imageUrl: string | ArrayBuffer | null = null;
  description: string | null = null;
  error: string | null = null;
  loading: boolean = false;

  constructor(private http: HttpClient) { }

  onFileSelected(event: any): void {
    this.selectedFile = event.target.files[0];
    this.description = null; // Clear previous description
    this.error = null; // Clear previous error
    if (this.selectedFile) {
      const reader = new FileReader();
      reader.onload = e => this.imageUrl = reader.result;
      reader.readAsDataURL(this.selectedFile);
    }
  }

  uploadImage(): void {
    if (this.selectedFile) {
      this.loading = true;
      this.description = null;
      this.error = null;

      const formData = new FormData();
      formData.append('image', this.selectedFile, this.selectedFile.name);

      this.http.post('http://localhost:8080/api/image/describe', formData, { responseType: 'text' })
        .subscribe({
          next: (response) => {
            this.description = response;
            this.loading = false;
          },
          error: (err) => {
            this.error = err.error || 'An unknown error occurred.';
            this.loading = false;
            console.error('Upload error:', err);
          }
        });
    }
  }
}

Make sure to import `HttpClientModule` in `src/app/app.module.ts`:


import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http'; // Import this

import { AppComponent } from './app.component';
import { ImageUploaderComponent } from './image-uploader/image-uploader.component';

@NgModule({
  declarations: [
    AppComponent,
    ImageUploaderComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule // Add this to imports
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Finally, update `src/app/app.component.html` to display the `ImageUploaderComponent`:




πŸ”§ Connecting the Dots - API Integration

Now, create a REST controller to handle image uploads and invoke the AI service.

File: `src/main/java/com/techoral/imagedescriber/controller/ImageDescriberController.java`


package com.techoral.imagedescriber.controller;

import com.techoral.imagedescriber.service.ImageDescriberAiService;
import dev.langchain4j.data.image.Image;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.Base64;

@RestController
@RequestMapping("/api/image")
@CrossOrigin(origins = "http://localhost:4200") // Allow Angular frontend to access
public class ImageDescriberController {

    @Autowired
    private ImageDescriberAiService imageDescriberAiService;

    @PostMapping(value = "/describe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity describeImage(@RequestParam("image") MultipartFile file) {
        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body("No file uploaded.");
        }
        try {
            byte[] imageBytes = file.getBytes();
            String mimeType = file.getContentType();

            if (mimeType == null || (!mimeType.equals("image/png") && !mimeType.equals("image/jpeg"))) {
                return ResponseEntity.badRequest().body("Only PNG and JPEG images are supported. Uploaded type: " + mimeType);
            }

            String base64String = Base64.getEncoder().encodeToString(imageBytes);

            Image langchainImage = Image.builder()
                    .base64Data(base64String)
                    .mimeType(mimeType)
                    .build();

            String description = imageDescriberAiService.describeImage(langchainImage);
            return ResponseEntity.ok(description);

        } catch (IOException e) {
            return ResponseEntity.internalServerError().body("Error processing image: " + e.getMessage());
        } catch (Exception e) {
            return ResponseEntity.internalServerError().body("Error getting description from AI: " + e.getMessage());
        }
    }
}

🐳 Containerizing Our App - Docker Time

To containerize the Spring Boot application, create a `Dockerfile` in the root of your Spring Boot project:


# Use a base image with Java
FROM openjdk:17-jdk-slim

# Set the working directory
WORKDIR /app

# Copy the Mave/Gradle wrapper and project files
COPY mvnw ./
COPY .mvn ./.mvn
COPY pom.xml ./
COPY src ./src

# Grant execute permissions to the Maven wrapper
RUN chmod +x mvnw

# Build the application
RUN ./mvnw package -DskipTests

# Expose the port your Spring Boot app runs on
EXPOSE 8080

# Command to run the application
CMD ["java", "-jar", "target/image-describer-0.0.1-SNAPSHOT.jar"] # Adjust JAR name if necessary

Build the Docker image:


docker build -t spring-image-describer .

☸️ Scaling Up - Kubernetes Deployment

Now, let's deploy our application and Ollama to Kubernetes.

First, ensure Ollama is running, either locally or within your Kubernetes cluster. For a local Minikube setup, you might consider running Ollama outside the cluster and exposing it, or deploying it directly.

Ollama Deployment (Optional, if running in Kubernetes):

Create `ollama-deployment.yaml`:


apiVersion: apps/v1
kind: Deployment
metadata:
  name: ollama
spec:
  selector:
    matchLabels:
      app: ollama
  template:
    metadata:
      labels:
        app: ollama
    spec:
      containers:
      - name: ollama
        image: ollama/ollama:latest # Use the latest Ollama image
        ports:
        - containerPort: 11434
        volumeMounts:
        - name: ollama-models
          mountPath: /root/.ollama
      volumes:
      - name: ollama-models
        emptyDir: {}
      # If you need to pull a model on startup (optional)
      # initContainers:
      # - name: pull-llava
      #   image: ollama/ollama:latest
      #   command: ["ollama", "pull", "llava"]
      #   volumeMounts:
      #   - name: ollama-models
      #     mountPath: /root/.ollama
---
apiVersion: v1
kind: Service
metadata:
  name: ollama
spec:
  selector:
    app: ollama
  ports:
  - protocol: TCP
    port: 11434
    targetPort: 11434
  type: ClusterIP

Apply the Ollama deployment:


kubectl apply -f ollama-deployment.yaml

Spring Boot Backend Deployment:

Create `backend-deployment.yaml`:


apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-image-describer
spec:
  selector:
    matchLabels:
      app: spring-image-describer
  template:
    metadata:
      labels:
        app: spring-image-describer
    spec:
      containers:
      - name: spring-image-describer
        image: spring-image-describer:latest # Use the Docker image you built
        imagePullPolicy: Never # Use 'Never' for local images, 'Always' for remote registries
        ports:
        - containerPort: 8080
        env:
        - name: LANGCHAIN4J_OLLAMA_CHAT_MODEL_BASE_URL
          value: http://ollama:11434 # Point to the Ollama service in Kubernetes
        - name: LANGCHAIN4J_OLLAMA_CHAT_MODEL_MODEL_NAME
          value: llava # or qwen2.5vl
        - name: LANGCHAIN4J_OLLAMA_CHAT_MODEL_TIMEOUT
          value: 180s
---
apiVersion: v1
kind: Service
metadata:
  name: spring-image-describer
spec:
  selector:
    app: spring-image-describer
  ports:
  - protocol: TCP
    port: 8080
    targetPort: 8080
  type: ClusterIP # Use LoadBalancer for external access in cloud, NodePort for Minikube demo

Apply the backend deployment:


kubectl apply -f backend-deployment.yaml

Exposing the Angular Frontend (Local or Kubernetes Ingress):

For local development, simply run `ng serve` for the Angular app. If deploying Angular to Kubernetes, you'd typically use a static web server image (like Nginx) and an Ingress controller.

Example for Nginx (for Angular, saved as `frontend-deployment.yaml`):


apiVersion: apps/v1
kind: Deployment
metadata:
  name: angular-image-describer-frontend
spec:
  selector:
    matchLabels:
      app: angular-image-describer-frontend
  template:
    metadata:
      labels:
        app: angular-image-describer-frontend
    spec:
      containers:
      - name: angular-image-describer-frontend
        image: nginx:latest # Or your custom Angular image
        ports:
        - containerPort: 80
        volumeMounts:
        - name: angular-dist
          mountPath: /usr/share/nginx/html
      volumes:
      - name: angular-dist
        hostPath:
          path: /path/to/your/angular/dist # Mount your Angular dist folder
          type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
  name: angular-image-describer-frontend
spec:
  selector:
    app: angular-image-describer-frontend
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
  type: NodePort # For Minikube demo

Before applying the frontend deployment, run `ng build --output-path ../nginx-dist` in your Angular project to create the `dist` folder. Then, adjust the `hostPath` in the YAML to point to the `nginx-dist` folder in the parent directory, or copy contents to a known path your Minikube can access, or build a custom Docker image for Angular.


kubectl apply -f frontend-deployment.yaml

πŸ” Testing Our Creation

1. Start Ollama: Ensure `ollama serve` is running, and you have pulled your chosen vision model (e.g., `ollama pull llava`). If deploying Ollama to Kubernetes, verify its pods are running and service is accessible.


ollama serve
ollama pull llava # Or your chosen model

2. Run Spring Boot Backend: Build and run your Spring Boot application locally, or ensure its Kubernetes deployment is active.


./mvnw spring-boot:run
# OR if deployed to K8s: kubectl get pods, kubectl get svc

3. Run Angular Frontend: Start your Angular development server.


ng serve --open

4. Access the Application: Open your browser to `http://localhost:4200` (or the NodePort exposed by your Kubernetes service for the frontend). Upload an image and click "Describe Image."

Expected result: A concise description of your image!

πŸŽ‰ Wrapping Up - What's Next?

This MVP sets the foundation. Consider these next steps:

  • Error Handling: More robust error messages and UI feedback.
  • Scalability: Implement horizontal pod autoscaling (HPA) in Kubernetes for your Spring Boot and Ollama deployments.
  • Observability: Integrate Prometheus and Grafana for monitoring, and centralized logging (e.g., ELK stack).
  • Security: Add authentication (e.g., OAuth2 with Spring Security) and authorization.
  • Advanced AI: Explore chaining multiple AI calls (e.g., object detection before description), or fine-tuning models.
  • UI/UX: Enhance the Angular UI for a richer user experience.

Final Thoughts

You've successfully built a full-stack image description application, bridging the gap between modern web development and cutting-edge AI. This project demonstrates the power of integrating Angular, Spring Boot, LangChain4j, Ollama, Docker, and Kubernetes to create intelligent and scalable solutions. Keep building, keep exploring!