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.
Before embarking on this quest, ensure you have the following tools installed:
Let's begin by conjuring a new Spring Boot project. You can use Spring Initializr (start.spring.io) with the following dependencies:
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
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);
}
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:
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`:
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());
}
}
}
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 .
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
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!
This MVP sets the foundation. Consider these next steps:
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!