If you run a wallpaper app long enough, you hit the same wall: images accumulate faster than you can classify them.
My wallpaper app has over 3,000 images. Early on I categorized everything by hand. At a few hundred additions per month, that turned into several hours of classification work every week — just labeling, not building.
So I built an auto-classification pipeline using Gemini Vision API. The short result: 93% accuracy on custom categories, at roughly a few cents per hundred images. Here's the full account of how I built it, what broke, and what I'd do differently.
Why Manual Classification Breaks Down
Wallpaper categorization looks trivial from the outside. "This is nature scenery. This is minimalist. This is dark theme." Straightforward.
Three problems emerge in practice:
Inconsistency. My judgment varies depending on focus level. Several App Store reviews mentioned categories that felt mismatched — they were right. I wasn't consistent across 3,000 decisions.
Scale. 100 images by hand is fine. 1,000 takes a day. 3,000 breaks the release cycle.
Multilingual overhead. My app supports multiple languages. Every manual category decision also meant translating category names and descriptions. The cognitive overhead compounds.
Why Gemini Vision API
Other options exist — Google Cloud Vision API, AWS Rekognition. I chose Gemini for three reasons:
Custom category support. Standard vision APIs return generic labels: "mountain," "sky," "person." My app uses custom categories: "Lo-fi chill," "Gradient," "Cyberpunk neon." Gemini accepts category lists in natural language, so I can define the exact taxonomy my app uses.
Simultaneous description generation. The same API call that classifies an image can also generate a short description — useful for App Store metadata and language-specific tags.
Predictable cost. With Gemini Flash, processing 1,000 images runs to a few cents. For an indie app, that's a manageable and foreseeable cost.
Implementation: The Classification Pipeline
The flow is straightforward:
List image files → Send image + category list to Gemini
→ Parse response → Save to DB → Low-confidence → manual review queue
import google.generativeai as genai
import json
from pathlib import Path
genai.configure(api_key="YOUR_GEMINI_API_KEY")
model = genai.GenerativeModel("gemini-2.0-flash")
CATEGORIES = [
"Nature Scenery", "Minimalist", "Dark Theme", "Gradient",
"Abstract Art", "Cityscape", "Sunset", "Ocean",
"Forest", "Space", "Flowers & Plants", "Animals",
"Texture", "Pattern", "Typography", "Architecture",
"Food", "Sports", "Technology", "Gaming",
"Anime & Illustration", "Watercolor & Art", "Monochrome", "Vintage",
"Neon & Cyberpunk", "Japanese Style", "Nordic Style", "Lo-fi",
"Seasonal", "Other"
]
def classify_wallpaper(image_path: str) -> dict:
image_file = genai.upload_file(image_path)
prompt = f"""Analyze this wallpaper image and select the most appropriate category.
Available categories:
{json.dumps(CATEGORIES)}
Respond in this exact JSON format:
{{
"primary_category": "category name from the list",
"secondary_category": "optional secondary category",
"confidence": 0.0 to 1.0,
"description_en": "atmosphere description in 30 words or fewer",
"tags": ["tag1", "tag2", "tag3"]
}}
Return JSON only."""
response = model.generate_content([image_file, prompt])
try:
return json.loads(response.text.strip())
except json.JSONDecodeError:
return {"primary_category": "Other", "confidence": 0.0, "error": response.text}Batch Processing 3,000 Images Without Hitting Rate Limits
Processing one image at a time runs into rate limits quickly. Add a brief delay between calls and log progress to recover gracefully from interruptions.
import time
from pathlib import Path
def classify_batch(image_dir: str, output_file: str):
images = list(Path(image_dir).glob("*.jpg")) + list(Path(image_dir).glob("*.png"))
results = []
manual_review_queue = []
print(f"Processing {len(images)} images")
for i, img_path in enumerate(images):
result = classify_wallpaper(str(img_path))
result["filename"] = img_path.name
results.append(result)
if result.get("confidence", 0) < 0.7:
manual_review_queue.append(img_path.name)
if (i + 1) % 50 == 0:
print(f"Progress: {i+1}/{len(images)} | Manual review queue: {len(manual_review_queue)}")
time.sleep(0.5) # Rate limit buffer
with open(output_file, "w", encoding="utf-8") as f:
json.dump({
"total": len(images),
"manual_review_count": len(manual_review_queue),
"manual_review_queue": manual_review_queue,
"results": results
}, f, indent=2)
print(f"Done. Manual review needed: {len(manual_review_queue)} images")What Actually Happened When I Ran This
Accuracy. 93% of images were classified at confidence ≥ 0.7 and routed to automatic assignment. The remaining 7% (~210 images) landed in the manual queue. Almost all were images that genuinely span two categories — "nature scenery" that's also "minimalist," for example. That ambiguity isn't a model failure; it's a real classification problem that humans find equally difficult.
Cost. Processing 3,000 images with Gemini 2.0 Flash, including image input tokens, cost approximately ¥450 (roughly $3 USD). Compare that to the time cost of manual classification and it's not a close comparison.
Failure patterns I hit:
- Near-black or near-white images: the model wavers between "Monochrome" and "Minimalist"
- Complex art pieces: classification stability drops when an image has competing visual elements
- Low-resolution thumbnails: detail is lost, accuracy falls
Prompt refinements improved these cases significantly. Adding "If uncertain between categories, prefer 'Other'" reduced the number of overconfident wrong answers.
What's Next: Closing the Feedback Loop
I'm currently experimenting with feeding user engagement data — save rate, favorites count, time spent viewing — back into the classification prompts. The hypothesis is that images users strongly prefer share classifiable visual characteristics, and Gemini can learn to weight those.
This is still early, but it's the direction: not just "classify what the image looks like" but "classify how users respond to it." The gap between those two things is where interesting product decisions live.
For an indie app, replacing 5–10 hours per month of manual classification with a $3 API call is the kind of leverage that makes the difference between sustainable and not. Small automations compound.