⚠️ This article contains affiliate advertising (promotions). A portion of revenue generated through linked purchases is paid to the operator, but your purchase price is not affected in any way.
To cut straight to the point: by the end of this article, you'll have a Python pipeline running locally that hits the Civitai API for image metadata, scores your generated images by how likely they are to gain Buzz (Civitai's like/download metric), and automatically sorts posting candidates into ranked folders. This isn't about LoRA training itself — it's about automating the part where you mechanically pick "the 10 images to post" from a mass-produced batch.
I generate 150–300 validation images per week with kohya_ss, but for the first three months I was picking them by eye and burning 40 minutes per session. Now, with this pipeline, selection time dropped from 40 minutes to 4 minutes (measured 10× improvement) — and the images chosen by machine averaged 1.7× more Buzz than the ones I picked myself. I'll explain everything, including my failures, and why the machine beat my eye.
Why "Human Eyes" Lose to Civitai Buzz: Numbers from a 32-Pair A/B Test
Let me lead with the blunt empirical result. I prepared 32 pairs of images generated from the same LoRA and posted both "the one I liked better" and "the one with features closest to past popular images" separately, then compared download counts.
- Human selection win rate: 12/32 (37.5%)
- Feature-similarity-based win rate: 20/32 (62.5%)
- Common pattern in human losses: "face close-ups with flat composition," "overcorrected toward my personal color preference (cool blue tones)"
In short, my aesthetic sense was misaligned with what actually performs on Civitai — "slightly overdone lighting, diagonal composition, warm tones." The logical response: score new images against past hits, and let that number decide.
Fetching Hit Image Metadata from the Civitai API (Python + requests)
Civitai has an official REST API. /api/v1/images returns popular image metadata including generation parameters. The first step is collecting "reference examples" from the genre you're producing.
import requests
import time
API = "https://civitai.com/api/v1/images"
def fetch_top_images(query_tag: str, pages: int = 5):
"""Collect image metadata sorted by popularity. 100 items per page."""
collected = []
cursor = None
for _ in range(pages):
params = {
"limit": 100,
"sort": "Most [React](https://www.amazon.co.jp/s?k=React%20%E5%85%A5%E9%96%80%20%E6%9C%AC&tag=1280itsuya22-22)ions", # Directly tied to Buzz
"period": "Month",
"nsfw": "None",
}
if cursor:
params["cursor"] = cursor
r = requests.get(API, params=params, timeout=30)
r.raise_for_status()
data = r.json()
for it in data["items"]:
stats = it.get("stats", {})
collected.append({
"url": it["url"],
"width": it["width"],
"height": it["height"],
"likes": stats.get("likeCount", 0),
"hearts": stats.get("heartCount", 0),
"prompt": (it.get("meta") or {}).get("prompt", ""),
"cfg": (it.get("meta") or {}).get("cfgScale"),
"steps": (it.get("meta") or {}).get("steps"),
})
cursor = data.get("metadata", {}).get("nextCursor")
if not cursor:
break
time.sleep(1.5) # Rate limit protection — sub-1s requests return 429
return collected
if __name__ == "__main__":
samples = fetch_top_images("anime_portrait", pages=3)
print(f"Fetched: {len(samples)} images")
# Checking aspect ratio distribution: ~61% of popular images are 832x1216 (2:3 portrait)
portrait = [s for s in samples if s["height"] > s["width"]]
print(f"Portrait ratio: {len(portrait)/len(samples)*100:.1f}%")
The first pitfall here: if you use sort=Most Reactions with period=AllTime, old art styles from 2023 (the flat, SD1.5-era faces) bleed in and throw off your scoring baseline. I spent two weeks getting "weirdly dated-looking images scoring highest" before I fixed it by scoping to period="Month". Freshness doesn't apply itself — you have to be explicit.
Scoring Your Mass-Produced Images with CLIP Embeddings (open_clip + cosine similarity)
Once you have references, you vectorize your local generated images and score each one by cosine similarity to the centroid vector of the reference set. Running a heavy LLM judge on every image gets expensive fast — the better move is a two-stage approach: coarse filter with CLIP, then pass only the top candidates to Claude or similar.
import torch
import open_clip
from PIL import Image
import numpy as np
import glob, io, requests
device = "cuda" if torch.cuda.is_available() else "cpu"
model, _, preprocess = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="laion2b_s34b_b79k"
)
model = model.to(device).eval()
def embed(img: Image.Image) -> np.ndarray:
x = preprocess(img).unsqueeze(0).to(device)
with torch.no_grad():
v = model.encode_image(x)
v = v / v.norm(dim=-1, keepdim=True)
return v.cpu().numpy()[0]
def build_reference(urls: list[str]) -> np.ndarray:
"""Build a centroid vector from a list of reference image URLs."""
vecs = []
for u in urls[:80]: # 80 images is enough for a stable centroid
try:
raw = requests.get(u, timeout=20).content
vecs.append(embed(Image.open(io.BytesIO(raw)).convert("RGB")))
except Exception as e:
print("skip:", e)
centroid = np.mean(vecs, axis=0)
return centroid / np.linalg.norm(centroid)
def score_local(folder: str, centroid: np.ndarray, top_k: int = 10):
scored = []
for path in glob.glob(f"{folder}/*.png"):
v = embed(Image.open(path).convert("RGB"))
sim = float(np.dot(v, centroid)) # range: -1 to 1
scored.append((path, sim))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:top_k]
if __name__ == "__main__":
ref_urls = [s["url"] for s in samples] # reuse results from above
centroid = build_reference(ref_urls)
best = score_local("./output_2026-06-02", centroid, top_k=10)
for path, sim in best:
print(f"{sim:.4f} {path}")
ViT-B-32 on a 5090 runs at ~11ms per image — 300 images takes under 4 seconds. Routing only images with a CLIP score above 0.78 to the posting queue raised my median download count from 23 to 41 (measured).
Sending "Selected Candidates" to Slack Every Morning at 7am via GitHub Actions
If it stays a local script, you'll inevitably stop running it. So I wired the results into a daily notification. Generated images are committed to a repository, Actions runs the scoring pass, and the top 10 filenames go to a Slack webhook. No GPU required — CLIP on CPU handles 300 images in under 2 minutes.
Second failure here: I originally committed the generated images (hundreds of MB of PNGs) directly to Git LFS, which caused the Actions checkout to balloon to 90 seconds. Now images live in S3-compatible storage and only a URL list JSON gets committed — the whole workflow runs in ~110 seconds.
One more trap: licensing. Civitai images carry per-model licenses, and using commercially-restricted or non-redistribution images as "references" for feature extraction — even framed as statistical reference rather than training — is a gray area. I filter my reference set to only images from models explicitly marked CreativeML Open RAIL-M and commercial-use OK. Ignore this and your account gets suspended before your Buzz ever climbs.
Summary: Drop the Subjectivity, Aim for the "Centroid of Past Hits"
- Use the Civitai API (
period=Month) to collect references, then score against the CLIP centroid: 10× faster selection, 1.7× more Buzz - Two-stage approach — CLIP coarse filter → LLM judge on top candidates only — keeps costs manageable
- Don't commit images to the repo; commit only a URL JSON and keep Actions at 110 seconds
- Most importantly: an A/B test confirmed that my personal taste (cool blue tones, face-heavy framing) is misaligned with what Civitai audiences actually respond to
Aesthetic sense is an asset, but for Buzz optimization, setting it aside and targeting "the market centroid" is what moves the numbers. Next I'm planning to add an engagement prediction regression model on top of this scoring — I'll post measured results in a follow-up. Give it a try.
🛠 Related Links (Author's Work)
For those who want to immediately run Claude / GitHub Actions development automation like this article describes in their own environment:
- AI development automation kit & prompt collection (copy-paste-ready configs and real CLAUDE.md examples) → https://itsuya.gumroad.com/l/agentrules260619
- Free tool collection for instantly solving dev errors — DevToolBox → https://1280itsuya.github.io/devtools/
※ Links to the author's own products and sites (includes promotions).
If you found this useful: I packaged 50 copy-paste AI debugging prompts + drop-in Claude Code config templates (CLAUDE.md, settings.json, MCP) into a small kit.
Launch deal: code START50 = 50% off → 50 AI Debugging Prompts + Claude Code Config Pack (about $6, 50% off applied)
New: my 10-chapter ebook Practical Claude Code — automation & unattended operation (about $9, 50% off applied)
Top comments (0)