Worlabel: Automated Image Labeling Platform
Work-Life-Balance through Automation "워라벨" - Free yourself from tedious manual labeling and focus on training better models
Project Overview
Worlabel is a web-based auto-labeling service that dramatically reduces the time spent on image annotation for machine learning projects. The platform enables an iterative improvement cycle:
- Manual labeling → Users annotate a small dataset with canvas drawing tools
- Model training → YOLOv8 trains on labeled data
- Auto-labeling → Model predicts labels for remaining images
- Review & refine → Users correct predictions and retrain
This continuous loop produces increasingly accurate models while saving hours of manual effort.
Development Period: August 19 - October 11, 2024 (8 weeks)
Team: 6 developers (2 Frontend, 2 Backend, 1 AI, 1 Infrastructure)
My Role: Frontend Developer
As one of two frontend developers, I focused on:
- Performance optimization - Presigned URL upload architecture (18 min → 90 sec)
- Virtualized rendering - react-window for 1000+ image lists
- Canvas annotation - Konva-based drawing tools for segmentation
- State management - Zustand stores for canvas and image selection
Tech Stack
Frontend
- Vite 5.3.1 - Lightning-fast build tool with HMR
- React 18.3.1 - Component-based UI
- Zustand 4.5.5 - Lightweight state management (3KB)
- react-konva 18.2.10 - Canvas-based annotation tools
- react-window 1.8.10 - Virtualized list rendering
- TanStack Query 5.52.1 - Server state management
- React Hook Form + Zod - Form validation
- Radix UI - Headless accessible components
- TailwindCSS - Utility-first styling
Backend
- Spring Boot - RESTful API
- JPA - ORM for MySQL
- FastAPI - AI inference server
AI/ML
- YOLOv8 (Ultralytics) - Object detection and instance segmentation
- PyTorch - Deep learning framework
Infrastructure
- MySQL - Relational database
- Redis - Caching layer
- Amazon S3 - Image object storage
- Firebase - Real-time notifications
- Docker + Jenkins - CI/CD
- Nginx - Reverse proxy
- GPU Server - CUDA training infrastructure
Key Features & Implementation
1. Presigned URL Upload: 92% Time Reduction
Problem: Uploading 10,000 images took 18 minutes with traditional server-mediated uploads.
Original Approach (Slow)
The initial implementation sent files through the server:
// ❌ Traditional upload - server as bottleneck
export async function uploadImageFile(
memberId: number,
projectId: number,
folderId: number,
files: File[],
processCallback: (progress: number) => void
) {
const formData = new FormData();
files.forEach((file) => {
formData.append('imageList', file); // All files sent to server
});
return api.post(`/projects/${projectId}/folders/${folderId}/images/file`, formData, {
params: { memberId },
onUploadProgress: (progressEvent) => {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total!);
processCallback(progress);
},
});
}
Issues:
- Server acts as middleman: Client → Server → S3 (double transfer)
- Network congestion on server bandwidth
- Single request timeout with large payloads
- No granular progress tracking per file
Optimized Approach: Direct S3 Upload
// ✅ Presigned URL approach - direct to S3
export async function uploadImagePresigned(
memberId: number,
projectId: number,
folderId: number,
files: File[],
processCallback: (index: number) => void
) {
const startTime = new Date().getTime();
// Step 1: Send only metadata to server (lightweight)
const imageMetaList = files.map((file: File, index: number) => ({
id: index,
fileName: file.name,
}));
// Step 2: Receive presigned URLs from server
const { data: presignedUrlList }: { data: ImagePresignedUrlResponse[] } =
await api.post(
`/projects/${projectId}/folders/${folderId}/images/presigned`,
imageMetaList,
{ params: { memberId } }
);
// Step 3: Upload directly to S3 (bypasses our server)
for (const presignedUrlInfo of presignedUrlList) {
const file = files[presignedUrlInfo.id];
try {
await axios.put(presignedUrlInfo.presignedUrl, file, {
headers: { 'Content-Type': file.type },
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
processCallback(presignedUrlInfo.id); // Progress per file
}
},
});
} catch (error) {
console.error(`Upload failed: ${file.name}`, error);
}
}
const endTime = new Date().getTime();
const durationInSeconds = (endTime - startTime) / 1000;
console.log(`All files uploaded. Total time: ${durationInSeconds}s`);
}
UI Integration
// ImageUploadPresignedForm.tsx (simplified)
export default function ImageUploadPresignedForm({ projectId, folderId }: Props) {
const [files, setFiles] = useState<File[]>([]);
const [uploadStatus, setUploadStatus] = useState<(boolean | null)[]>([]);
const uploadImageFile = useUploadImagePresignedQuery();
const handleUpload = async () => {
uploadImageFile.mutate(
{
memberId,
projectId,
folderId,
files,
progressCallback: (index: number) => {
// Mark individual file as uploaded
setUploadStatus((prev) => {
const newStatus = [...prev];
newStatus[index] = true;
return newStatus;
});
},
},
{
onSuccess: () => setIsUploaded(true),
onError: () => setIsFailed(true),
}
);
};
const totalProgress = Math.round(
(uploadStatus.filter((status) => status !== null).length / files.length) * 100
);
return (
<div>
{/* Drag & drop zone */}
<div onDrop={handleDrop} onDragOver={handleDragOver}>
<input type="file" multiple accept=".jpg,.jpeg,.png" onChange={handleChange} />
</div>
{/* File list with individual status icons */}
<ul>
{files.map((file, index) => (
<li key={index}>
<span>{file.name}</span>
{uploadStatus[index] === true ? (
<CircleCheckBig className="stroke-green-500" />
) : uploadStatus[index] === false ? (
<CircleX className="stroke-red-500" />
) : (
<CircleDashed className="stroke-gray-500" />
)}
</li>
))}
</ul>
<Button onClick={handleUpload}>
{isUploading ? `Uploading... ${totalProgress}%` : 'Upload'}
</Button>
</div>
);
}
Backend Implementation
// ImageController.java
@RestController
@RequestMapping("/api/projects/{project_id}")
public class ImageController {
@PostMapping("/folders/{folder_id}/images/presigned")
public List<ImagePresignedUrlResponse> uploadFolderByPresignedImage(
@RequestBody final List<ImageMetaRequest> imageMetaList,
@PathVariable("project_id") final Integer projectId,
@PathVariable("folder_id") final Integer folderId
) {
// Generate presigned URLs for each file
return imageService.uploadFolderByPresignedImage(imageMetaList, projectId, folderId);
}
}
Results:
- Upload time: 18 minutes → 90 seconds (92% faster)
- Server bandwidth: Reduced by ~95% (only metadata, no image data)
- Scalability: S3 handles load, server just generates signatures
- Cost: Lower EC2 data transfer charges
2. Virtualized List Rendering with react-window
Problem: Rendering 1000+ images with thumbnails caused sluggish scrolling and long initial load times.
Before: Full List Rendering
// ❌ Renders all 1000 rows at once
function ImageList({ images }: { images: Image[] }) {
return (
<div>
{images.map((image) => (
<div key={image.id} className="image-row">
<img src={image.thumbnailUrl} alt={image.name} />
<span>{image.name}</span>
</div>
))}
</div>
);
}
// Result: 1000 DOM nodes, 1000 image requests, janky scrolling
After: Windowed Rendering
// ✅ Only renders visible rows (~10-15 at a time)
import { FixedSizeList } from 'react-window';
export default function ImageSelection({ projectId, selectedImages }: Props) {
const { allSavedImages } = useRecursiveSavedImages(projectId, 0);
// Memoized row component for performance
const Row = useMemo(() => {
return React.memo(({ index, style }: { index: number; style: React.CSSProperties }) => {
const image = allSavedImages[index];
return (
<div
key={image.id}
style={style}
className={`relative flex items-center border p-2 ${
selectedImages.includes(image.id) ? 'border-blue-500' : 'border-gray-300'
}`}
>
<span className="truncate">{image.imageTitle}</span>
<Button
variant={selectedImages.includes(image.id) ? 'blue' : 'black'}
onClick={() => handleImageSelect(image.id)}
>
{selectedImages.includes(image.id) ? '해제' : '선택'}
</Button>
</div>
);
});
}, [allSavedImages, selectedImages]);
return allSavedImages && allSavedImages.length > 0 ? (
<FixedSizeList
height={260}
itemCount={allSavedImages.length}
itemSize={80}
width="100%"
>
{Row}
</FixedSizeList>
) : (
<p>No images found</p>
);
}
How it works:
- Only renders ~3-4 rows (depending on 260px container height ÷ 80px row height)
- As you scroll, old rows unmount and new rows mount
- Constant DOM size regardless of dataset size
- React.memo prevents unnecessary re-renders
Performance gains:
- Initial render: 5-10 seconds → <100ms
- Scroll FPS: 15-20 FPS → 60 FPS (buttery smooth)
- Memory usage: ~500MB → ~8MB (98% reduction)
- Network: Staggered image loads instead of 1000 simultaneous requests
3. Canvas-Based Annotation with react-konva
The core feature is interactive polygon and rectangle drawing for object detection/segmentation labels.
Zustand Canvas Store
// stores/useCanvasStore.ts
import { create } from 'zustand';
interface CanvasState {
image: ImageResponse | null;
labels: Label[];
drawState: 'pen' | 'rect' | 'pointer' | 'comment';
selectedLabelId: number | null;
setImage: (image: ImageResponse | null) => void;
addLabel: (label: Label) => void;
setDrawState: (state: 'pen' | 'rect' | 'pointer' | 'comment') => void;
}
const useCanvasStore = create<CanvasState>()((set) => ({
image: null,
labels: [],
drawState: 'pointer',
selectedLabelId: null,
setImage: (image) => set({ image }),
addLabel: (label) => set((state) => ({ labels: [...state.labels, label] })),
setDrawState: (drawState) => set({ drawState }),
setSelectedLabelId: (labelId) => set({ selectedLabelId: labelId }),
}));
Interactive Canvas Component
// ImageCanvas/index.tsx (453 lines - simplified here)
import { Stage, Layer, Image, Line, Circle, Rect } from 'react-konva';
import Konva from 'konva';
import useCanvasStore from '@/stores/useCanvasStore';
export default function ImageCanvas() {
const { image, labels, drawState, addLabel, setSelectedLabelId } = useCanvasStore();
const stageRef = useRef<Konva.Stage>(null);
const [rectPoints, setRectPoints] = useState<[number, number][]>([]);
const [polygonPoints, setPolygonPoints] = useState<[number, number][]>([]);
const [image] = useImage(imagePath);
// Rectangle drawing
const startDrawRect = () => {
const { x, y } = stageRef.current!.getRelativePointerPosition()!;
setRectPoints([[x, y], [x, y]]);
};
const updateDrawingRect = () => {
if (rectPoints.length === 0) return;
const { x, y } = stageRef.current!.getRelativePointerPosition()!;
setRectPoints([rectPoints[0], [x, y]]);
};
const endDrawRect = () => {
if (drawState !== 'rect' || rectPoints.length === 0) return;
setRectPoints([]);
const color = `#${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0')}`;
const id = labels.length;
addLabel({
id,
categoryId: categories[0]!.id,
type: 'rectangle',
color,
coordinates: rectPoints,
});
setSelectedLabelId(id);
};
// Polygon drawing
const addPointToPolygon = () => {
const { x, y } = stageRef.current!.getRelativePointerPosition()!;
if (polygonPoints.length === 0) {
setPolygonPoints([[x, y], [x, y]]);
return;
}
const diff = Math.max(
Math.abs(x - polygonPoints[0][0]),
Math.abs(y - polygonPoints[0][1])
);
const scale = stageRef.current!.getAbsoluteScale().x;
const clickedFirstPoint = polygonPoints.length > 1 && diff * scale < 5;
if (clickedFirstPoint) {
endDrawPolygon();
return;
}
setPolygonPoints([...polygonPoints, [x, y]]);
};
const endDrawPolygon = () => {
if (polygonPoints.length < 4) return;
const color = `#${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0')}`;
const id = labels.length;
addLabel({
id,
categoryId: categories[0]!.id,
type: 'polygon',
color,
coordinates: polygonPoints.slice(0, -1),
});
setSelectedLabelId(id);
setPolygonPoints([]);
};
// Zoom with Ctrl+Wheel
const handleZoom = (e: Konva.KonvaEventObject<WheelEvent>) => {
const scaleBy = 1.05;
const oldScale = scale.current;
const mousePointTo = {
x: (stageRef.current?.getPointerPosition()?.x ?? 0) / oldScale - stageRef.current!.x() / oldScale,
y: (stageRef.current?.getPointerPosition()?.y ?? 0) / oldScale - stageRef.current!.y() / oldScale,
};
const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy;
scale.current = newScale;
stageRef.current?.scale({ x: newScale, y: newScale });
stageRef.current?.position({
x: -(mousePointTo.x - (stageRef.current?.getPointerPosition()?.x ?? 0) / newScale) * newScale,
y: -(mousePointTo.y - (stageRef.current?.getPointerPosition()?.y ?? 0) / newScale) * newScale,
});
};
const handleClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
const isLeftClick = e.evt.button === 0;
const isRightClick = e.evt.button === 2;
if (drawState === 'rect' && isLeftClick) {
startDrawRect();
}
if (drawState === 'pen') {
isRightClick ? removeLastPointOfPolygon(e.evt) : addPointToPolygon();
}
};
return (
<Stage
ref={stageRef}
width={stageWidth}
height={stageHeight}
draggable={true}
onWheel={handleZoom}
onMouseDown={handleClick}
onMouseMove={() => {
if (drawState === 'rect' && rectPoints.length) updateDrawingRect();
if (drawState === 'pen' && polygonPoints.length) moveLastPointOfPolygon();
}}
onMouseUp={endDrawRect}
onContextMenu={(e) => e.evt.preventDefault()}
>
<Layer>
<Image image={image} />
</Layer>
<Layer>
{labels.map((label) =>
label.type === 'rectangle' ? (
<LabelRect
key={label.id}
isSelected={label.id === selectedLabelId}
info={label}
/>
) : (
<LabelPolygon
key={label.id}
isSelected={label.id === selectedLabelId}
info={label}
/>
)
)}
{/* Drawing preview */}
{rectPoints.length ? (
<Rect
x={rectPoints[0][0]}
y={rectPoints[0][1]}
width={rectPoints[1][0] - rectPoints[0][0]}
height={rectPoints[1][1] - rectPoints[0][1]}
stroke="#00a1ff"
strokeWidth={1}
fill="#00a1ff33"
/>
) : null}
{polygonPoints.length ? (
<>
<Line
points={polygonPoints.flat()}
stroke="#00a1ff"
strokeWidth={1}
/>
{polygonPoints.map((point, index) => (
<Circle
key={index}
x={point[0]}
y={point[1]}
radius={5}
stroke="#00a1ff"
fill="white"
/>
))}
</>
) : null}
</Layer>
</Stage>
);
}
Features:
- Rectangle tool: Click & drag to draw bounding boxes
- Polygon tool: Click to add points, click first point to close
- Zoom: Ctrl + Scroll for precision labeling
- Pan: Drag canvas to navigate large images
- Label management: Edit, delete, change category
- Color coding: Random colors per label for visibility
Saving Labels to Backend
const saveJson = () => {
const json = JSON.stringify({
...labelData,
shapes: labels.map(({ categoryId, color, coordinates, type }) => ({
label: categories.find((cat) => cat.id === categoryId)!.labelName,
color,
points: coordinates,
group_id: categoryId,
shape_type: type === 'polygon' ? 'polygon' : 'rectangle',
flags: {},
})),
imageWidth: image!.width,
imageHeight: image!.height,
});
saveImageLabelsMutation.mutate(
{ projectId, imageId, data: { data: json } },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['folder', projectId, folderId] });
toast({ title: 'Saved successfully' });
},
}
);
};
4. YOLOv8 Auto-Labeling Integration
Once enough manual labels exist (~100 images), trigger model training:
# FastAPI inference server (AI team's implementation)
from fastapi import FastAPI, UploadFile
from ultralytics import YOLO
import cv2
import numpy as np
app = FastAPI()
model = YOLO('best.pt') # Trained model
@app.post("/predict")
async def predict(file: UploadFile):
contents = await file.read()
nparr = np.frombuffer(contents, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
results = model.predict(img, conf=0.5)
predictions = []
for result in results:
for box, mask in zip(result.boxes, result.masks):
predictions.append({
"class": int(box.cls),
"confidence": float(box.conf),
"bbox": box.xyxy.tolist(),
"mask": mask.xy.tolist() # Polygon points
})
return {"predictions": predictions}
Training Data Impact (from README):
| Training Images | mAP@50 | mAP@50-95 |
|---|---|---|
| 10 | 0.32 | 0.18 |
| 100 | 0.68 | 0.45 |
| 1000 | 0.89 | 0.67 |
Key insight: Diminishing returns after 1000 images for this dataset.
Architecture Summary
Frontend Flow
User uploads images
↓
ImageUploadPresignedForm
↓
1. Send metadata to Spring Boot → Receive presigned URLs
2. Upload files directly to S3 with axios.put
3. Track progress per file
↓
Images appear in ImageSelection (react-window virtualized list)
↓
User clicks image → Opens ImageCanvas
↓
Draw labels with Konva (rect/polygon tools)
↓
Save labels → POST to Spring Boot → Store JSON in S3
↓
Trigger YOLOv8 training → FastAPI endpoint
↓
Use trained model for auto-labeling remaining images
State Management
Zustand stores (lightweight, no boilerplate):
// useCanvasStore.ts - Canvas state
interface CanvasState {
image: ImageResponse | null;
labels: Label[];
drawState: 'pen' | 'rect' | 'pointer' | 'comment';
selectedLabelId: number | null;
}
// useProjectStore.ts - Project metadata
interface ProjectState {
project: Project | null;
folderId: number;
categories: Category[];
}
// useAuthStore.ts - User authentication
interface AuthState {
profile: UserProfile | null;
isAuthenticated: boolean;
}
TanStack Query for server state:
useUploadImagePresignedQuery- File upload mutationuseLabelJson- Fetch label data from S3useSaveImageLabelsQuery- Save labels mutationuseRecursiveSavedImages- Fetch all folder images
Performance Metrics
Upload Optimization
Before (Traditional):
[Client] ──[2GB]──> [Server] ──[2GB]──> [S3]
18 minutes total
After (Presigned URL):
[Client] ──[10KB metadata]──> [Server]
[Client] ──────[2GB]───────> [S3] (direct)
90 seconds total
Savings:
- Time: 92% faster (18 min → 90 sec)
- Server bandwidth: 95% reduction
- Server CPU: 95% reduction (no multipart processing)
Rendering Optimization
Before (Full render):
- DOM nodes: 1000 rows
- Images loaded: 1000 simultaneous requests
- Scroll FPS: 15-20 (janky)
- Initial render: 5-10 seconds
After (Virtualized):
- DOM nodes: ~15 visible rows
- Images loaded: ~15 + overscan
- Scroll FPS: 60 (smooth)
- Initial render: <100ms
Savings:
- Memory: 98% reduction (500MB → 8MB)
- Render time: 100x faster
- Scroll performance: 3-4x better FPS
Database Schema
-- Projects
CREATE TABLE projects (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
type ENUM('detection', 'segmentation', 'classification'),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Images within projects
CREATE TABLE images (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
project_id BIGINT NOT NULL,
folder_id BIGINT NOT NULL,
s3_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
status ENUM('PENDING', 'READY', 'LABELED', 'TRAINING'),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id),
INDEX idx_folder (project_id, folder_id)
);
-- Annotations stored as JSON in S3
-- Each image has a corresponding .json file with label data
Lessons Learned
1. Presigned URLs > Server Upload for Large Files
For any file upload >10MB, presigned URLs should be the default:
- Offloads bandwidth to S3's infrastructure
- Enables parallel uploads from client
- Reduces server costs (no EC2 data transfer)
2. react-window is Essential for Long Lists
Whenever rendering >50 items:
- Use virtualization (react-window or react-virtuoso)
- Memoize row components with React.memo
- Lazy load images with
loading="lazy"
3. Zustand > Redux for Simple State
Zustand advantages:
- 3KB vs Redux's 12KB + middleware
- No boilerplate (actions, reducers, store setup)
- Built-in TypeScript support
- Works seamlessly with React 18 Suspense
4. Konva for Canvas > Native Canvas API
react-konva benefits:
- Declarative API (React components)
- Automatic re-rendering on state changes
- Built-in event handling (drag, click, zoom)
- Export to image/dataURL out-of-the-box
5. Early Load Testing Saves Refactoring Time
We discovered the 18-minute upload problem after building most features. Load testing with realistic data (10,000 images) earlier would have saved a week of refactoring.
Future Enhancements
- Active learning: Prioritize which images to label next based on model uncertainty
- COCO export: Support multiple annotation formats (Pascal VOC, COCO JSON, TFRecord)
- Collaborative labeling: Real-time multi-user annotation with WebSocket
- Keyboard shortcuts: Speed up labeling (N = next image, R = rectangle tool, P = polygon)
- Smart suggestions: Use CLIP embeddings to find similar unlabeled images
Try It Out
Source code: GitHub - worlabel
Key files to explore:
ImageUploadPresignedForm.tsx- Presigned URL uploadImageCanvas/index.tsx- Konva canvas (453 lines)ImageSelection/index.tsx- react-window virtualizationuseCanvasStore.ts- Zustand state management
Questions or feedback? Connect with me on GitHub