π§π· Leia em PortuguΓͺs
Event-driven image processing pipeline with automatic format normalization, thumbnail generation, AI-powered captioning, and full lifecycle management β 100% local, fully offline.
Upload any image format supported by Magick.NET (JPEG, PNG, AVIF, HEIC, TIFF, WebP, BMP, and 200+ more) and have it automatically:
- Normalized to a web-safe format (PNG)
- Thumbnailed for quick preview (200Γ200 PNG)
- Described in natural language by an AI model (BLIP)
- Deletable across all artifacts with a single click
Everything runs locally on your machine with no paid cloud services. Google Cloud Pub/Sub and Cloud Storage are emulated via Docker, and infrastructure is provisioned automatically with Terraform.
| Tool | Version | Purpose |
|---|---|---|
| Docker Desktop | 24+ | Container runtime |
| Terraform | 1.5+ | Infrastructure provisioning |
| .NET SDK | 10.0+ | Build & run C# services (optional for dev) |
| Git | 2.x | Version control |
curl |
β | Health checks in start script |
Note: You do not need Python, PyTorch, or any ML libraries installed locally. The Vision API runs entirely inside its Docker container.
# Clone the repository
git clone https://github.com/Marcus-V-Freitas/MVFC.ImageProcessing.git
cd MVFC.ImageProcessing
# Start all containers + provision infrastructure
./scripts/start.sh
# Stop everything and clean up
./scripts/stop.shThe start.sh script performs the following steps in order:
- Checks for existing infrastructure (use
./scripts/start.sh --cleanto force a full tear down) - Builds and starts or updates all services via
docker compose up -d --build - Waits for PubSub, GCS, and Vision API health checks
- Runs
terraform init && terraform applyto ensure topics, subscriptions, and buckets exist
After startup, open the Dashboard at http://localhost:3000.
| Service | URL |
|---|---|
| Dashboard | http://localhost:3000 |
| Upload API | http://localhost:8081/upload |
| Vision API | http://localhost:5000/health |
| GCS Buckets | http://localhost:4443/storage/v1/b |
| PubSub Emulator | http://localhost:8681 |
The pipeline follows an event-driven microservices architecture using GCS Object Notifications. Each processing stage is an independent service. When a worker writes a file to a bucket, Cloud Storage automatically emits an OBJECT_FINALIZE notification to a Pub/Sub topic, which triggers the next stage β workers never publish events explicitly.
Files are stored in Google Cloud Storage (emulated via fake-gcs-server) and events flow through Google Cloud Pub/Sub (emulated).
β οΈ Emulator vs Production: In production (GCP), Cloud Storage natively sendsOBJECT_FINALIZEnotifications to Pub/Sub topics viagoogle_storage_notification. The PubSub emulator does not support this feature, so a lightweight GCS Router sidecar (scripts/gcs_router.py) polls a genericgcs-object-eventstopic and routes messages to the correct per-bucket topic. This router exists only in the local/emulation environment and is not needed in production.
graph LR
U["π€ User"] -->|Drag & Drop| DASH["Dashboard :3000"]
U -->|"ποΈ Delete"| DASH
DASH -->|POST /upload| API["mvfc-image-upload-api :8081"]
API -->|Saves original image| GCS[("Cloud Storage")]
GCS -->|"OBJECT_FINALIZE"| PS{{"PubSub"}}
PS -->|Push| CONV["mvfc-image-converter-worker :8084"]
CONV -->|"Download + Normalize PNG"| GCS
GCS -->|"OBJECT_FINALIZE"| PS
PS -->|Push| TW["mvfc-image-thumbnail-worker :8082"]
PS -->|Push| IA["mvfc-image-analysis-worker :8083"]
TW -->|"Download + Generate thumbnail"| GCS
IA -->|"Download + Base64"| VA["mvfc-image-vision-api :5000"]
VA -->|BLIP captioning| VA
IA -->|Saves analysis.json| GCS
GCS -->|"OBJECT_FINALIZE"| PS
DASH -->|"Pub: file-delete-requested"| PS
PS -->|Push| DEL["mvfc-image-delete-worker :8086"]
DEL -->|"Deletes from 4 buckets"| GCS
PS -->|SSE Push /pubsub/notify| DASH
CONV -..->|"DLQ (after 5 fails)"| DLQ[("Dead-Letter Topic")]
TW -..->|"DLQ (after 5 fails)"| DLQ
IA -..->|"DLQ (after 5 fails)"| DLQ
DEL -..->|"DLQ (after 5 fails)"| DLQ
| Component | Technology | Port | Responsibility |
|---|---|---|---|
| mvfc-image-upload-api | .NET 10 Minimal API | :8081 |
Receives uploads, saves to GCS (triggers pipeline via notification) |
| mvfc-image-converter-worker | .NET 10 + Magick.NET | :8084 |
Normalizes any format β PNG, saves to converted bucket |
| mvfc-image-thumbnail-worker | .NET 10 + Magick.NET | :8082 |
Generates 200Γ200 PNG thumbnail |
| mvfc-image-analysis-worker | .NET 10 + Refit | :8083 |
Sends converted image to AI vision API, saves analysis JSON |
| mvfc-image-vision-api | Python 3.12 + Flask + BLIP | :5000 |
Generates natural language description |
| mvfc-image-delete-worker | .NET 10 | :8086 |
Deletes image from all 4 buckets |
| mvfc-image-dashboard-ui | .NET 10 + HTML/JS | :3000 |
Visual interface with gallery and controls |
| mvfc-gcs-router | Python 3.10 (emulator only) | β | Routes GCS notifications to per-bucket Pub/Sub topics |
| PubSub Emulator | thekevjames/gcloud-pubsub-emulator | :8681 |
Event bus (emulated) |
| Cloud Storage | fake-gcs-server | :4443 |
Object storage (emulated, with notification support) |
| Terraform | HCL | β | Provisions topics, subscriptions, buckets, and notifications |
This is the main flow. When a user uploads an image, it passes through 3 processing stages. Each stage is triggered automatically by a GCS Object Notification (OBJECT_FINALIZE) β workers never publish events; they simply write files to the appropriate bucket and the notification triggers the next stage.
Key change: After conversion, the Thumbnail and AI Analysis stages now run in parallel (both subscribe to
file-converted-topic), reducing total processing time.
sequenceDiagram
actor U as π€ User
participant D as Dashboard
participant API as mvfc-image-upload-api
participant GCS as Cloud Storage
participant PS as PubSub
participant CONV as mvfc-image-converter-worker
participant TW as mvfc-image-thumbnail-worker
participant IA as mvfc-image-analysis-worker
participant VA as mvfc-image-vision-api
U->>D: Drag & Drop "photo.avif"
D->>API: POST /upload (multipart)
API->>GCS: Upload β uploads/{guid}-photo.avif
API-->>D: 202 Accepted
Note over GCS,PS: OBJECT_FINALIZE (uploads bucket)
GCS->>PS: Notification β "file-uploaded-topic"
Note over PS,CONV: β Normalization
PS->>CONV: Push /pubsub/push
CONV->>GCS: Download uploads/{guid}-photo.avif
CONV->>CONV: MagickImage β Format = PNG
CONV->>GCS: Upload β converted/{guid}-photo.avif (PNG)
Note over GCS,PS: OBJECT_FINALIZE (converted bucket)
GCS->>PS: Notification β "file-converted-topic"
Note over PS,TW: β‘ Thumbnail (parallel)
Note over PS,IA: β’ AI Analysis (parallel)
par Thumbnail generation
PS->>TW: Push /pubsub/push
TW->>GCS: Download converted/{guid}-photo.avif (PNG)
TW->>TW: MagickImage β Resize(200,200) + PNG
TW->>GCS: Upload thumbnails/thumb-{guid}-photo.png
and AI Analysis
PS->>IA: Push /pubsub/push
IA->>GCS: Download converted/{guid}-photo.avif
IA->>VA: POST /analyze (base64)
VA->>VA: BLIP image captioning (~3-5s)
VA-->>IA: {"description": "...", "dominant_colors": [...]}
IA->>GCS: Upload analysis-results/analysis-{guid}-photo.avif.json
end
Note over GCS,PS: OBJECT_FINALIZE notifications for thumbnails & analysis-results
Note over D: β£ Dashboard updates via SSE (real-time)
PS->>D: Push /pubsub/notify β gallery-updated event
D-->>U: Fetches /api/files and re-renders gallery
The user can delete any image directly from the interface. Deletion removes all related artifacts from all 4 buckets at once.
Note: Deletion is the only flow that still uses explicit Pub/Sub publishing (from the Dashboard), since it is a user-initiated action and not a GCS write event.
sequenceDiagram
actor U as π€ User
participant D as Dashboard
participant PS as PubSub
participant DW as mvfc-image-delete-worker
participant GCS as Cloud Storage
U->>D: Clicks ποΈ on image card
D->>D: confirm("Delete photo.avif?")
D->>PS: Pub "file-delete-requested-topic"
D-->>U: Visual feedback
PS->>DW: Push /pubsub/push
par Parallel deletion
DW->>GCS: DELETE uploads/{fileName}
DW->>GCS: DELETE converted/{fileName}
DW->>GCS: DELETE thumbnails/thumb-{fileName}
DW->>GCS: DELETE analysis-results/analysis-{fileName}.json
end
DW-->>PS: 200 OK (ack)
Note over D: SSE event triggers gallery refresh
The converter is the first stage of the pipeline. It ensures that regardless of the original format (AVIF, HEIC, TIFF, BMP...), all downstream files are treated as PNG. The converted file is saved to a dedicated converted bucket, preserving the original in uploads.
sequenceDiagram
participant PS as PubSub
participant CONV as mvfc-image-converter-worker
participant GCS as Cloud Storage
PS->>CONV: Push (file-uploaded via GCS notification)
CONV->>GCS: Download from uploads/ bucket
alt Supported format (AVIF, HEIC, TIFF, WebP, BMP...)
CONV->>CONV: new MagickImage(stream)
CONV->>CONV: image.Format = MagickFormat.Png
CONV->>GCS: Upload to converted/ bucket as PNG
Note over GCS,PS: OBJECT_FINALIZE triggers file-converted-topic β
else Corrupted or invalid file
CONV->>CONV: catch(Exception)
CONV->>CONV: Log critical error
CONV-->>PS: 500 Internal Server Error (Nack)
Note over PS: Retries up to 5 times
PS->>PS: Route to dead-letter-topic
end
Why normalize? Browsers cannot natively display formats like TIFF, HEIC, or BMP. By converting everything to PNG at the beginning of the pipeline, we ensure the image displayed in the Dashboard always works β no broken image icons.
In production GCP, google_storage_notification resources automatically send OBJECT_FINALIZE events from buckets to Pub/Sub topics. The fake-gcs-server emulator supports sending events to a single generic topic (gcs-object-events), but cannot route to different topics per bucket.
The GCS Router (scripts/gcs_router.py) bridges this gap:
sequenceDiagram
participant GCS as fake-gcs-server
participant GENERIC as gcs-object-events topic
participant ROUTER as mvfc-gcs-router
participant TARGET as Per-Bucket Topic
GCS->>GENERIC: OBJECT_FINALIZE (any bucket)
ROUTER->>GENERIC: Pull messages
ROUTER->>ROUTER: Inspect payload.bucket
alt bucket = "uploads"
ROUTER->>TARGET: Publish β file-uploaded-topic
else bucket = "converted"
ROUTER->>TARGET: Publish β file-converted-topic
else bucket = "thumbnails"
ROUTER->>TARGET: Publish β thumbnail-created-topic
else bucket = "analysis-results"
ROUTER->>TARGET: Publish β analysis-completed-topic
end
This component does not exist in production. In GCP, each
google_storage_notificationresource sends events directly to the correct topic. The Terraform configuration includes these resources but skips them locally viacount = var.is_local ? 0 : 1.
Each arrow represents a Pub/Sub topic with its respective push subscription. Events are produced by GCS Object Notifications (not by workers), except for the delete flow which is user-initiated.
graph TD
GCS[("Cloud Storage")] -->|"OBJECT_FINALIZE"| T1["file-uploaded-topic"]
T1 -->|mvfc-image-converter-worker-sub| CONV["mvfc-image-converter-worker"]
GCS -->|"OBJECT_FINALIZE"| T2["file-converted-topic"]
T2 -->|mvfc-image-thumbnail-worker-sub| TW["mvfc-image-thumbnail-worker"]
T2 -->|mvfc-image-analysis-worker-sub| IA["mvfc-image-analysis-worker"]
GCS -->|"OBJECT_FINALIZE"| T3["thumbnail-created-topic"]
GCS -->|"OBJECT_FINALIZE"| T5["analysis-completed-topic"]
T5 -->|mvfc-dashboard-analysis-sub| DASH["mvfc-image-dashboard-ui"]
T4["file-delete-requested-topic"] -->|mvfc-image-delete-worker-sub| DEL["mvfc-image-delete-worker"]
T1 -->|mvfc-dashboard-upload-sub| DASH
T2 -->|mvfc-dashboard-convert-sub| DASH
T3 -->|mvfc-dashboard-thumbnail-sub| DASH
T4 -->|mvfc-dashboard-delete-sub| DASH
CONV -..->|Max retries| DLQ["dead-letter-topic"]
TW -..->|Max retries| DLQ
IA -..->|Max retries| DLQ
DEL -..->|Max retries| DLQ
| Topic | Trigger | Consumer | Ack Deadline |
|---|---|---|---|
file-uploaded-topic |
GCS notification (uploads bucket) |
mvfc-image-converter-worker, mvfc-image-dashboard-ui | 60s |
file-converted-topic |
GCS notification (converted bucket) |
mvfc-image-thumbnail-worker, mvfc-image-analysis-worker, mvfc-image-dashboard-ui | 600s |
thumbnail-created-topic |
GCS notification (thumbnails bucket) |
mvfc-image-dashboard-ui | 600s |
analysis-completed-topic |
GCS notification (analysis-results bucket) |
mvfc-image-dashboard-ui | 10s |
file-delete-requested-topic |
mvfc-image-dashboard-ui (explicit publish) | mvfc-image-delete-worker, mvfc-image-dashboard-ui | 30s |
gcs-object-events |
fake-gcs-server (emulator only) | mvfc-gcs-router | β |
| Bucket | Contents | Written by | Read by | GCS Notification β Topic |
|---|---|---|---|---|
uploads |
Original image (any format) | mvfc-image-upload-api | mvfc-image-converter-worker | file-uploaded-topic |
converted |
Normalized PNG image | mvfc-image-converter-worker | mvfc-image-thumbnail-worker, mvfc-image-analysis-worker, mvfc-image-dashboard-ui | file-converted-topic |
thumbnails |
200Γ200 PNG thumbnails | mvfc-image-thumbnail-worker | mvfc-image-dashboard-ui | thumbnail-created-topic |
analysis-results |
JSON with AI-generated description and dominant colors | mvfc-image-analysis-worker | mvfc-image-dashboard-ui | analysis-completed-topic |
The image processing library is essential for two workers: the converter (normalization) and the thumbnail generator.
| Criterion | Magick.NET β | |
|---|---|---|
| License | Paid (v4+) or vulnerable (v3.x) | Apache 2.0 (free) |
| AVIF | β Not supported | β Native |
| HEIC/HEIF | β | β |
| Total formats | ~12 | 200+ |
| Native deps in Docker | None | Bundled in NuGet |
Package used: Magick.NET-Q8-AnyCPU v14.13.1 (Q8 = 8 bits per channel β sufficient for web and lighter on memory).
For generating natural language image descriptions, we use the BLIP (Bootstrapping Language-Image Pre-training) model.
| Criterion | Decision |
|---|---|
| Model | Salesforce/blip-image-captioning-base |
| Runtime | PyTorch CPU-only |
| Latency | ~3-5 seconds per image |
| Quality | Natural and readable descriptions |
| Offline | β Model pre-downloaded during Docker build |
Discarded alternatives:
- YOLOv8 β Returned generic and imprecise tags ("person", "dining table")
- Ollama (LLaVA) β Too slow on CPU (~30s), too heavy for local use
The mvfc-image-analysis-worker uses Refit to call the Python Vision API. This provides a type-safe, declarative HTTP client via an interface (IVisionApiClient), replacing raw HttpClient calls and making the service easier to test and maintain.
Instead of having each worker explicitly publish to the next Pub/Sub topic, we leverage GCS Object Notifications (OBJECT_FINALIZE). This means:
- Zero coupling between stages: Workers only need to know which bucket to write to. They don't need Pub/Sub clients, topic names, or publishing logic.
- Simpler handlers: Domain handlers no longer depend on
IPublishServiceβ they just store files and return. - Automatic event emission: Writing a file to a bucket is enough to trigger the next stage. The GCS β Pub/Sub integration is managed at the infrastructure level (Terraform).
- Push subscriptions: Each worker is a Minimal API exposing a
/pubsub/pushendpoint. Pub/Sub delivers messages automatically. - Automatic retry: If a worker is unavailable, Pub/Sub redelivers the message after
ack_deadline_seconds.
Emulator note: Since
fake-gcs-servercannot route to per-bucket topics, themvfc-gcs-routersidecar handles this routing locally. In production,google_storage_notificationTerraform resources handle it natively.
| Service | Emulator | Reason |
|---|---|---|
| Pub/Sub | gcloud beta emulators pubsub |
Zero cost, works offline |
| Cloud Storage | fake-gcs-server |
API compatible with real GCS |
| Terraform | Google Provider | Provisions against the emulators |
Advantage: The worker code is identical to what would run on real GCP. The only difference is the *_EMULATOR_HOST environment variable.
The project includes a test project ready for unit and integration tests:
dotnet testYou can also use the HTTP file at scripts/mvfc.image-processing.http for manual API testing (compatible with VS Code REST Client / JetBrains HTTP Client).
MVFC.ImageProcessing/
βββ src/
β βββ MVFC.Image.Domain/ # Core business logic, Contracts and CQRS Handlers
β βββ MVFC.Image.Infra/ # GCP Implementations (Storage and Pub/Sub)
β βββ MVFC.Image.IoC/ # Dependency Injection and Configuration
β βββ MVFC.Image.Shareable/ # Shared events, DTOs and GCS notification mapper
β βββ MVFC.ImageUpload.Api/ # Receives uploads via HTTP
β βββ MVFC.ImageConverter.Worker/ # Normalizes any format β PNG (saves to converted bucket)
β βββ MVFC.ImageThumbnail.Worker/ # Generates 200Γ200 thumbnails
β βββ MVFC.ImageAnalysis.Worker/ # Orchestrates AI analysis (Refit + Polly)
β βββ MVFC.ImageVision.Api/ # BLIP model (Python/Flask)
β βββ MVFC.ImageDelete.Worker/ # Deletes files from 4 buckets
β βββ MVFC.ImageDashboard.UI/ # Web interface (HTML/JS)
βββ tests/
β βββ MVFC.Image.Domain.Tests/ # Unit tests for Domain layer
β βββ MVFC.Image.Infra.Tests/ # Unit tests for Infra layer
β βββ MVFC.Image.Shareable.Tests/ # Unit tests for Shareable layer (incl. GcsNotificationMapper)
β βββ MVFC.ImageUpload.Api.Tests/ # Integration tests for Upload API
β βββ MVFC.ImageConverter.Worker.Tests/ # Integration tests for Converter Worker
β βββ MVFC.ImageThumbnail.Worker.Tests/ # Integration tests for Thumbnail Worker
β βββ MVFC.ImageAnalysis.Worker.Tests/ # Integration tests for Analysis Worker
β βββ MVFC.ImageDelete.Worker.Tests/ # Integration tests for Delete Worker
β βββ MVFC.ImageDashboard.UI.Tests/ # Integration tests for Dashboard UI
βββ scripts/
β βββ start.sh # Start all infrastructure
β βββ stop.sh # Tear down everything
β βββ gcs_router.py # GCS notification router (emulator only)
β βββ mvfc.image-processing.http # HTTP request samples
βββ terraform/ # IaC: topics, subs, buckets, notifications
βββ samples/ # Sample images for testing
βββ docker-compose.yml # Container orchestration
βββ MVFC.ImageProcessing.slnx # Solution file
βββ Directory.Build.props # Shared MSBuild properties
βββ Directory.Build.targets # Shared MSBuild targets (analyzers)
βββ Directory.Packages.props # Central package management
βββ CONTRIBUTING.md # Contribution guidelines
βββ SECURITY.md # Security policy
βββ LICENSE # Apache 2.0
βββ README.md # β You are here! (English)
βββ README.pt-BR.md # Portuguese version
This project implements enterprise-grade distributed system patterns:
- GCS Object Notifications: Workers don't publish events β they write files to buckets, and GCS automatically emits
OBJECT_FINALIZEnotifications to the corresponding Pub/Sub topic. This eliminatesIPublishServicefrom all handlers (except the Dashboard's delete flow) and reduces coupling. - GCS Notification Router (Emulator): Since the
fake-gcs-serveremulator can only publish to a single generic topic, a lightweight Python sidecar (scripts/gcs_router.py) pollsgcs-object-eventsand re-publishes to the correct per-bucket topic. This component is conditionally deployed and has no equivalent in production. - Dead-Letter Queues (DLQ): Configured via Terraform. If a worker fails to process a poison message (e.g., an invalid file) 5 times, it is safely routed to the
dead-letter-topicinstead of causing infinite retries. - Circuit Breakers & Retries: The HTTP calls to the Vision API are wrapped with
Microsoft.Extensions.Http.Resilience, providing automatic retries, timeouts, and circuit breakers against transient AI model failures. - Parallel Processing: After conversion, Thumbnail and Analysis workers both subscribe to
file-converted-topicand run concurrently, reducing total pipeline latency.
A core principle of this project is Data Privacy. Because the entire pipeline (including the AI vision model) runs locally via Docker:
- Your images never leave your machine.
- No third-party API keys are required.
- No cloud storage costs or data mining.
- Suitable for processing sensitive, personal, or confidential media.
- Ports already in use: If ports like
:3000,:5000, or:8081are occupied, the containers won't start. Stop conflicting services or map different ports indocker-compose.yml. - First run is slow: The first time you run
./scripts/start.sh, Docker will download the Salesforce BLIP model (~1.5GB). Subsequent starts will be immediate. - Images not appearing in Dashboard: Check if the Pub/Sub emulator and Terraform provisioning completed successfully. You can view worker logs via
docker compose logs -f. - Thumbnails not loading: The thumbnail filename always uses
.pngextension regardless of the original file format (e.g.,thumb-{guid}-photo.png). Verify this matches what the dashboard requests. - Upload rejected with 400: The upload validator accepts any
image/*content type. Ensure your file is a valid image and its filename does not contain OS-reserved characters (\,/,:,*,?,",<,>,|).
See CONTRIBUTING.md.
This project is licensed under the Apache License 2.0.