From 027ffe2cc72cd466916c05874f4c416b7d8710e7 Mon Sep 17 00:00:00 2001 From: Renato Bonomini <4005901+renatobo@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:32:18 -0800 Subject: [PATCH 1/6] fix: harden qwen ollama parsing and retry with strict json profile --- src/pickinsta/ig_image_selector.py | 279 ++++++++++++++++++++++++----- tests/test_scoring_and_reports.py | 117 +++++++++++- 2 files changed, 355 insertions(+), 41 deletions(-) diff --git a/src/pickinsta/ig_image_selector.py b/src/pickinsta/ig_image_selector.py index 7b832b2..6a77511 100644 --- a/src/pickinsta/ig_image_selector.py +++ b/src/pickinsta/ig_image_selector.py @@ -75,6 +75,11 @@ OLLAMA_MAX_RETRIES_ENV_VAR = "PICKINSTA_OLLAMA_MAX_RETRIES" OLLAMA_BACKOFF_BASE_ENV_VAR = "PICKINSTA_OLLAMA_RETRY_BACKOFF_SEC" OLLAMA_CIRCUIT_BREAKER_ENV_VAR = "PICKINSTA_OLLAMA_CIRCUIT_BREAKER_ERRORS" +OLLAMA_DEFAULT_NUM_PREDICT = 220 +OLLAMA_QWEN_NUM_PREDICT_SMALL_EDGE = 650 +OLLAMA_QWEN_NUM_PREDICT_LARGE_EDGE = 750 +OLLAMA_QWEN_SMALL_EDGE_THRESHOLD = 512 +OLLAMA_QWEN_MODEL_PREFIXES = ("qwen3-vl", "qwen2.5vl", "qwen2.5-vl") YOLO_MODEL_FILENAME = "yolov8n.pt" YOLO_MODEL_URL = "https://github.com/ultralytics/assets/releases/latest/download/yolov8n.pt" YOLO_MODEL_ENV_VAR = "PICKINSTA_YOLO_MODEL" @@ -1019,6 +1024,60 @@ def batch_technical_score( Return ONLY valid JSON, no markdown: {{"subject_clarity": N, "lighting": N, "color_pop": N, "emotion": N, "scroll_stop": N, "crop_4x5": N, "total": N, "one_line": "why this works or doesn't"}}""" +OLLAMA_COMPACT_JSON_PROMPT_TEMPLATE = """Evaluate this motorcycle photo for Instagram cover potential. + +Context: {account_context}. + +Return ONLY a JSON object with keys: +- subject_clarity +- lighting +- color_pop +- emotion +- scroll_stop +- crop_4x5 +- total +- one_line + +Rules: +- Score each criterion as an integer from 0 to 10. +- total must equal the sum of the 6 criterion scores (0 to 60). +- one_line must be exactly one concise sentence describing this specific image. +""" + +OLLAMA_STRICT_JSON_SCHEMA = { + "type": "object", + "properties": { + "subject_clarity": {"type": "integer", "minimum": 0, "maximum": 10}, + "lighting": {"type": "integer", "minimum": 0, "maximum": 10}, + "color_pop": {"type": "integer", "minimum": 0, "maximum": 10}, + "emotion": {"type": "integer", "minimum": 0, "maximum": 10}, + "scroll_stop": {"type": "integer", "minimum": 0, "maximum": 10}, + "crop_4x5": {"type": "integer", "minimum": 0, "maximum": 10}, + "total": {"type": "integer", "minimum": 0, "maximum": 60}, + "one_line": {"type": "string"}, + }, + "required": [ + "subject_clarity", + "lighting", + "color_pop", + "emotion", + "scroll_stop", + "crop_4x5", + "total", + "one_line", + ], + "additionalProperties": False, +} + +VISION_SCORE_KEYS = ( + "subject_clarity", + "lighting", + "color_pop", + "emotion", + "scroll_stop", + "crop_4x5", +) + def build_vision_prompt(account_context: str) -> str: """Build the Claude vision prompt with account-specific context.""" @@ -1026,6 +1085,102 @@ def build_vision_prompt(account_context: str) -> str: return VISION_PROMPT_TEMPLATE.format(account_context=context) +def build_ollama_compact_json_prompt(account_context: str) -> str: + """Build compact strict-json prompt for Ollama models.""" + context = account_context.strip() or DEFAULT_ACCOUNT_CONTEXT + return OLLAMA_COMPACT_JSON_PROMPT_TEMPLATE.format(account_context=context) + + +def _extract_account_context_from_prompt(prompt_text: str) -> str: + """Best-effort extraction of account context from a full vision prompt.""" + text = (prompt_text or "").strip() + if not text: + return DEFAULT_ACCOUNT_CONTEXT + + match = re.search( + r"(?is)\bcontext:\s*(.+?)(?:\.\s*rate each criterion|\.\s*return only|\n\n)", + text, + ) + if match: + context = match.group(1).strip().strip('"').strip("'") + if context: + return context + return DEFAULT_ACCOUNT_CONTEXT + + +def _is_qwen_ollama_model(model: str) -> bool: + """Identify Qwen VL models that need stricter output controls.""" + normalized = (model or "").strip().lower() + return any(normalized.startswith(prefix) for prefix in OLLAMA_QWEN_MODEL_PREFIXES) + + +def _resolve_ollama_num_predict(model: str, max_image_edge: int) -> int: + """Choose a model-aware token budget for Ollama responses.""" + if _is_qwen_ollama_model(model): + if max_image_edge <= OLLAMA_QWEN_SMALL_EDGE_THRESHOLD: + return OLLAMA_QWEN_NUM_PREDICT_SMALL_EDGE + return OLLAMA_QWEN_NUM_PREDICT_LARGE_EDGE + return OLLAMA_DEFAULT_NUM_PREDICT + + +def _sanitize_vision_one_line(raw_value: object, *, fallback_text: str = "") -> str: + """Clean up one-line summaries from partially formatted model output.""" + line = str(raw_value or "").strip() + if not line: + first_sentence = re.split(r"(?<=[.!?])\s+", fallback_text.strip(), maxsplit=1)[0].strip() + line = first_sentence if first_sentence else "Vision scoring summary" + + line = re.sub(r"\s+", " ", line).strip().strip("`") + if line.startswith(("'", '"')): + line = line[1:].strip() + if line.endswith(("'", '"')): + line = line[:-1].strip() + return line[:220] if line else "Vision scoring summary" + + +def _normalize_ollama_vision_payload(vision: dict, *, fallback_text: str = "") -> dict: + """Normalize parsed vision payload to stable score ranges and text.""" + if not isinstance(vision, dict): + return vision + + normalized = dict(vision) + scores: dict[str, int] = {} + for key in VISION_SCORE_KEYS: + value = normalized.get(key) + if value is None: + continue + score = int(max(0, min(10, round(_safe_float(value, default=5.0))))) + scores[key] = score + normalized[key] = score + + if len(scores) >= 3: + fill_value = int(round(sum(scores.values()) / len(scores))) + fill_value = max(0, min(10, fill_value)) + for key in VISION_SCORE_KEYS: + if key not in scores: + scores[key] = fill_value + normalized[key] = fill_value + criteria_sum = int(sum(scores[key] for key in VISION_SCORE_KEYS)) + else: + criteria_sum = None + + total_raw = normalized.get("total") + total: Optional[int] = None + if total_raw is not None: + total = int(max(0, min(60, round(_safe_float(total_raw, default=30.0))))) + if criteria_sum is not None: + if total is None or (total < 10 and criteria_sum >= 20): + total = criteria_sum + if total is None: + total = 30 + normalized["total"] = total + normalized["one_line"] = _sanitize_vision_one_line( + normalized.get("one_line", ""), + fallback_text=fallback_text, + ) + return normalized + + def score_with_claude( image_path: Path, api_key: Optional[str] = None, @@ -1127,31 +1282,32 @@ def _extract_json_payload(raw_text: str) -> str: return raw -def _parse_ollama_message_json(body: str, parsed: dict) -> dict: - """Parse JSON model output from Ollama chat `message.content` or `message.thinking`.""" +def _parse_ollama_message_json_with_mode(body: str, parsed: dict) -> tuple[dict, str]: + """Parse Ollama message and return both normalized payload and parse mode.""" message = parsed.get("message") if isinstance(parsed, dict) else None if not isinstance(message, dict): raise RuntimeError(f"Ollama response did not include message object: {body[:300]}") content = str(message.get("content") or "").strip() thinking = str(message.get("thinking") or "").strip() - for text in (content, thinking): + for source, text in (("content", content), ("thinking", thinking)): if not text: continue try: - return json.loads(_extract_json_payload(text)) + parsed_json = json.loads(_extract_json_payload(text)) + return _normalize_ollama_vision_payload(parsed_json, fallback_text=text), f"json-{source}" except Exception: continue - for text in (content, thinking): + for source, text in (("content", content), ("thinking", thinking)): fallback = _parse_ollama_plaintext_scores(text) if fallback is not None: - return fallback + return _normalize_ollama_vision_payload(fallback, fallback_text=text), f"plain-{source}" - for text in (content, thinking): + for source, text in (("content", content), ("thinking", thinking)): fallback = _ollama_neutral_fallback_from_text(text) if fallback is not None: - return fallback + return _normalize_ollama_vision_payload(fallback, fallback_text=text), f"neutral-{source}" raise RuntimeError( "Ollama response did not include parseable JSON in message content/thinking: " @@ -1159,6 +1315,12 @@ def _parse_ollama_message_json(body: str, parsed: dict) -> dict: ) +def _parse_ollama_message_json(body: str, parsed: dict) -> dict: + """Compatibility wrapper returning only the normalized parsed payload.""" + payload, _ = _parse_ollama_message_json_with_mode(body, parsed) + return payload + + def _parse_ollama_plaintext_scores(raw_text: str) -> Optional[dict]: """Fallback parser for rubric-like plain text when model ignores JSON format.""" text = (raw_text or "").strip() @@ -1337,42 +1499,79 @@ def score_with_ollama( jpeg_quality=jpeg_quality, ) - enhanced_prompt = prompt or build_vision_prompt(DEFAULT_ACCOUNT_CONTEXT) - if yolo_context: - enhanced_prompt = enhanced_prompt + yolo_context - - payload = { - "model": model, - "stream": False, - "think": False, - "format": "json", - "keep_alive": keep_alive, - "options": {"temperature": 0, "num_predict": 220}, - "messages": [{"role": "user", "content": enhanced_prompt, "images": [image_data]}], - } - endpoint = f"{base_url.rstrip('/')}/api/chat" - request = Request( - endpoint, - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urlopen(request, timeout=timeout_seconds) as response: - body = response.read().decode("utf-8") - except HTTPError as error: - details = "" + base_prompt = prompt or build_vision_prompt(DEFAULT_ACCOUNT_CONTEXT) + if yolo_context: + base_prompt = base_prompt + yolo_context + + def _send_ollama_request(*, active_prompt: str, response_format: object, num_predict: int) -> tuple[str, dict]: + payload = { + "model": model, + "stream": False, + "think": False, + "format": response_format, + "keep_alive": keep_alive, + "options": {"temperature": 0, "num_predict": num_predict}, + "messages": [{"role": "user", "content": active_prompt, "images": [image_data]}], + } + request = Request( + endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) try: - details = error.read().decode("utf-8", errors="ignore") - except Exception: + with urlopen(request, timeout=timeout_seconds) as response: + body = response.read().decode("utf-8") + except HTTPError as error: details = "" - raise RuntimeError(f"Ollama request failed ({error.code}) at {endpoint}: {details[:300]}") from error - except URLError as error: - raise RuntimeError(f"Ollama connection failed for {endpoint}: {error}") from error + try: + details = error.read().decode("utf-8", errors="ignore") + except Exception: + details = "" + raise RuntimeError( + f"Ollama request failed ({error.code}) at {endpoint}: {details[:300]}" + ) from error + except URLError as error: + raise RuntimeError(f"Ollama connection failed for {endpoint}: {error}") from error + return body, json.loads(body) + + use_qwen_profile = _is_qwen_ollama_model(model) + if use_qwen_profile: + account_context = _extract_account_context_from_prompt(base_prompt) + primary_prompt = build_ollama_compact_json_prompt(account_context) + if yolo_context: + primary_prompt = primary_prompt + yolo_context + primary_format: object = OLLAMA_STRICT_JSON_SCHEMA + else: + primary_prompt = base_prompt + primary_format = "json" + + num_predict = _resolve_ollama_num_predict(model, max_image_edge=max_image_edge) + body, parsed = _send_ollama_request( + active_prompt=primary_prompt, + response_format=primary_format, + num_predict=num_predict, + ) + payload, parse_mode = _parse_ollama_message_json_with_mode(body, parsed) + + # Retry once when first pass degraded into plain/neutral fallback output. + if parse_mode.startswith("plain-") or parse_mode.startswith("neutral-"): + retry_context = _extract_account_context_from_prompt(base_prompt) + retry_prompt = build_ollama_compact_json_prompt(retry_context) + if yolo_context: + retry_prompt = retry_prompt + yolo_context + retry_body, retry_parsed = _send_ollama_request( + active_prompt=retry_prompt, + response_format=OLLAMA_STRICT_JSON_SCHEMA, + num_predict=num_predict, + ) + retry_payload, retry_mode = _parse_ollama_message_json_with_mode(retry_body, retry_parsed) + if retry_mode.startswith("json-"): + return retry_payload + return retry_payload - parsed = json.loads(body) - return _parse_ollama_message_json(body, parsed) + return payload def _is_retryable_ollama_error(error: Exception) -> bool: diff --git a/tests/test_scoring_and_reports.py b/tests/test_scoring_and_reports.py index b3e1333..9344c85 100644 --- a/tests/test_scoring_and_reports.py +++ b/tests/test_scoring_and_reports.py @@ -354,7 +354,7 @@ def test_score_with_ollama_sets_think_false_in_payload(tmp_path, monkeypatch) -> image_file = tmp_path / "payload.jpg" image_file.write_bytes(b"fake-image") monkeypatch.setattr(selector, "_encode_image_for_ollama", lambda *_args, **_kwargs: "b64") - observed = {"think": None} + observed = {"think": None, "format": None, "num_predict": None, "prompt": None} class FakeResponse: def __enter__(self): @@ -370,6 +370,9 @@ def read(self): def fake_urlopen(request, timeout): observed_payload = json.loads(request.data.decode("utf-8")) observed["think"] = observed_payload.get("think") + observed["format"] = observed_payload.get("format") + observed["num_predict"] = observed_payload.get("options", {}).get("num_predict") + observed["prompt"] = observed_payload.get("messages", [{}])[0].get("content") return FakeResponse() monkeypatch.setattr(selector, "urlopen", fake_urlopen) @@ -382,6 +385,72 @@ def fake_urlopen(request, timeout): ) assert observed["think"] is False + assert isinstance(observed["format"], dict) + assert observed["num_predict"] == selector.OLLAMA_QWEN_NUM_PREDICT_LARGE_EDGE + assert "Return ONLY a JSON object" in (observed["prompt"] or "") + + +def test_score_with_ollama_retries_compact_schema_after_neutral_fallback(tmp_path, monkeypatch) -> None: + image_file = tmp_path / "retry-compact.jpg" + image_file.write_bytes(b"fake-image") + monkeypatch.setattr(selector, "_encode_image_for_ollama", lambda *_args, **_kwargs: "b64") + + calls: list[dict] = [] + responses = [ + { + "model": "openbmb/minicpm-v4.5:8b", + "message": { + "role": "assistant", + "content": "", + "thinking": "Got it, let's break down each criterion for this motorcycle photo.", + }, + }, + { + "model": "openbmb/minicpm-v4.5:8b", + "message": { + "role": "assistant", + "content": ( + '{"subject_clarity": 8, "lighting": 7, "color_pop": 8, "emotion": 8, ' + '"scroll_stop": 8, "crop_4x5": 7, "total": 46, ' + '"one_line": "Rider and bike are clearly framed with strong color contrast."}' + ), + "thinking": "", + }, + }, + ] + + class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + def fake_urlopen(request, timeout): + calls.append(json.loads(request.data.decode("utf-8"))) + return FakeResponse(responses[len(calls) - 1]) + + monkeypatch.setattr(selector, "urlopen", fake_urlopen) + + result = selector.score_with_ollama( + image_path=image_file, + base_url="http://127.0.0.1:11434", + model="openbmb/minicpm-v4.5:8b", + use_yolo_context=False, + ) + + assert len(calls) == 2 + assert calls[0]["format"] == "json" + assert isinstance(calls[1]["format"], dict) + assert "Return ONLY a JSON object" in calls[1]["messages"][0]["content"] + assert result["total"] == 46 + assert "Rider and bike are clearly framed" in result["one_line"] def test_score_with_ollama_parses_plaintext_thinking_rubric(tmp_path, monkeypatch) -> None: @@ -433,6 +502,52 @@ def read(self): assert result["total"] == 47 +def test_score_with_ollama_repairs_total_and_sanitizes_one_line(tmp_path, monkeypatch) -> None: + image_file = tmp_path / "thinking-repair.jpg" + image_file.write_bytes(b"fake-image") + monkeypatch.setattr(selector, "_encode_image_for_ollama", lambda *_args, **_kwargs: "b64") + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + def read(self): + payload = { + "model": "qwen3-vl:8b", + "message": { + "role": "assistant", + "content": "", + "thinking": ( + "SUBJECT_CLARITY: 8/10\n" + "LIGHTING: 9/10\n" + "COLOR_POP: 8/10\n" + "EMOTION: 6/10\n" + "SCROLL_STOP: 7/10\n" + "CROP_4x5: 8/10\n" + "TOTAL: 8\n" + 'one_line: "Golden hour Ducati rider framed against mountains' + ), + }, + } + return json.dumps(payload).encode("utf-8") + + monkeypatch.setattr(selector, "urlopen", lambda *_args, **_kwargs: FakeResponse()) + + result = selector.score_with_ollama( + image_path=image_file, + base_url="http://127.0.0.1:11434", + model="qwen3-vl:8b", + use_yolo_context=False, + ) + + assert result["total"] == 46 + assert not result["one_line"].startswith('"') + assert "Golden hour Ducati rider" in result["one_line"] + + def test_score_with_ollama_uses_neutral_fallback_for_prose_only_thinking(tmp_path, monkeypatch) -> None: image_file = tmp_path / "thinking-prose.jpg" image_file.write_bytes(b"fake-image") From 008c525b488aa45b24d95565adcf13860d64c62b Mon Sep 17 00:00:00 2001 From: Renato Bonomini <4005901+renatobo@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:07:46 -0800 Subject: [PATCH 2/6] Implement feature X to enhance user experience and fix bug Y in module Z --- docs/model-quality-speed-comparison.md | 513 +++++++++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 docs/model-quality-speed-comparison.md diff --git a/docs/model-quality-speed-comparison.md b/docs/model-quality-speed-comparison.md new file mode 100644 index 0000000..060fa90 --- /dev/null +++ b/docs/model-quality-speed-comparison.md @@ -0,0 +1,513 @@ +# Model Benchmark Report (Speed + Quality) + +- Generated: `2026-02-22T21:01:40` +- Input folder: `/Users/renatobo/development/pickinsta/input` +- Candidates scored per run: `42` +- Runs per variant: `1` +- Warmup enabled: `True` (1 image + 10s wait) +- Ollama base URL: `http://localhost:11434` +- Ollama concurrency: `2` +- Ollama max retries: `2` +- Ollama keep_alive: `15m` + +## Speed Summary + +| Variant | Scorer | Model | YOLO | Avg sec/img | Avg imgs/min | Avg duration (s) | Avg failures/run | Speed vs fastest | +|---|---|---|---|---:|---:|---:|---:|---:| +| claude-sonnet-4-6 \| scorer=claude | claude | claude-sonnet-4-6 | off | 0.03 | 2104.38 | 1.20 | 0.00 | 1.00x | +| blaifa/InternVL3_5:4B \| yolo=on | ollama | blaifa/InternVL3_5:4B | on | 5.64 | 10.63 | 237.05 | 0.00 | 197.95x | +| blaifa/InternVL3_5:4B \| yolo=off | ollama | blaifa/InternVL3_5:4B | off | 5.84 | 10.28 | 245.18 | 0.00 | 204.74x | +| blaifa/InternVL3_5:8b \| yolo=on | ollama | blaifa/InternVL3_5:8b | on | 8.56 | 7.01 | 359.58 | 0.00 | 300.28x | +| blaifa/InternVL3_5:8b \| yolo=off | ollama | blaifa/InternVL3_5:8b | off | 9.37 | 6.41 | 393.44 | 0.00 | 328.55x | +| openbmb/minicpm-v4.5:8b \| yolo=off | ollama | openbmb/minicpm-v4.5:8b | off | 10.88 | 5.51 | 456.96 | 0.00 | 381.59x | +| openbmb/minicpm-v4.5:8b \| yolo=on | ollama | openbmb/minicpm-v4.5:8b | on | 10.95 | 5.48 | 459.79 | 0.00 | 383.95x | +| qwen3-vl:8b \| yolo=off | ollama | qwen3-vl:8b | off | 60.76 | 0.99 | 2552.11 | 0.00 | 2131.19x | +| qwen3-vl:8b \| yolo=on | ollama | qwen3-vl:8b | on | 72.95 | 0.82 | 3064.07 | 0.00 | 2558.72x | + +## Per-run Timing + +| Variant | Run | Duration (s) | Sec/img | Imgs/min | Failures | +|---|---:|---:|---:|---:|---:| +| qwen3-vl:8b \| yolo=off | 1 | 2552.11 | 60.76 | 0.99 | 0 | +| blaifa/InternVL3_5:8b \| yolo=off | 1 | 393.44 | 9.37 | 6.41 | 0 | +| blaifa/InternVL3_5:4B \| yolo=off | 1 | 245.18 | 5.84 | 10.28 | 0 | +| openbmb/minicpm-v4.5:8b \| yolo=off | 1 | 456.96 | 10.88 | 5.51 | 0 | +| qwen3-vl:8b \| yolo=on | 1 | 3064.07 | 72.95 | 0.82 | 0 | +| blaifa/InternVL3_5:8b \| yolo=on | 1 | 359.58 | 8.56 | 7.01 | 0 | +| blaifa/InternVL3_5:4B \| yolo=on | 1 | 237.05 | 5.64 | 10.63 | 0 | +| openbmb/minicpm-v4.5:8b \| yolo=on | 1 | 459.79 | 10.95 | 5.48 | 0 | +| claude-sonnet-4-6 \| scorer=claude | 1 | 1.20 | 0.03 | 2104.38 | 0 | + +## Image-by-Image Score Comparison (Run 1) + +| Image | qwen3-vl:8b \| yolo=off final | qwen3-vl:8b \| yolo=off rank | blaifa/InternVL3_5:8b \| yolo=off final | blaifa/InternVL3_5:8b \| yolo=off rank | blaifa/InternVL3_5:4B \| yolo=off final | blaifa/InternVL3_5:4B \| yolo=off rank | openbmb/minicpm-v4.5:8b \| yolo=off final | openbmb/minicpm-v4.5:8b \| yolo=off rank | qwen3-vl:8b \| yolo=on final | qwen3-vl:8b \| yolo=on rank | blaifa/InternVL3_5:8b \| yolo=on final | blaifa/InternVL3_5:8b \| yolo=on rank | blaifa/InternVL3_5:4B \| yolo=on final | blaifa/InternVL3_5:4B \| yolo=on rank | openbmb/minicpm-v4.5:8b \| yolo=on final | openbmb/minicpm-v4.5:8b \| yolo=on rank | claude-sonnet-4-6 \| scorer=claude final | claude-sonnet-4-6 \| scorer=claude rank | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0823 | 40 | 0.0613 | 42 | 0.0280 | 42 | 0.0543 | 41 | 0.2660 | 33 | 0.0858 | 41 | 0.0508 | 42 | 0.0455 | 40 | 0.0385 | 42 | +| 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.7839 | 20 | 0.5618 | 24 | 0.4213 | 27 | 0.5505 | 21 | 0.5898 | 22 | 0.5618 | 26 | 0.7018 | 15 | 0.5505 | 15 | 0.2213 | 24 | +| 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.8304 | 11 | 0.8188 | 2 | 0.7577 | 3 | 0.6671 | 12 | 0.6644 | 15 | 0.6364 | 15 | 0.6364 | 21 | 0.6204 | 7 | 0.5524 | 9 | +| 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.8718 | 1 | 0.8252 | 1 | 0.4548 | 25 | 0.7085 | 11 | 0.8718 | 1 | 0.6321 | 16 | 0.7721 | 4 | 0.0573 | 35 | 0.6135 | 6 | +| 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.5674 | 34 | 0.5674 | 22 | 0.3206 | 32 | 0.5576 | 20 | 0.7326 | 14 | 0.3766 | 36 | 0.0801 | 33 | 0.0836 | 23 | 0.5114 | 10 | +| 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.6050 | 30 | 0.0819 | 39 | 0.0784 | 36 | 0.3246 | 34 | 0.6050 | 19 | 0.1204 | 40 | 0.1953 | 32 | 0.0679 | 33 | 0.1830 | 31 | +| AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.8697 | 2 | 0.6491 | 13 | 0.6584 | 10 | 0.9630 | 1 | 0.0814 | 39 | 0.8347 | 4 | 0.7704 | 5 | 0.9047 | 3 | 0.6968 | 1 | +| AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.8442 | 4 | 0.7976 | 5 | 0.6380 | 14 | 0.9376 | 2 | 0.8676 | 2 | 0.7500 | 8 | 0.7500 | 7 | 0.6109 | 9 | 0.5914 | 8 | +| AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.8498 | 3 | 0.8031 | 4 | 0.3812 | 29 | 0.9081 | 7 | 0.8148 | 7 | 0.9081 | 1 | 0.8265 | 2 | 0.5815 | 13 | 0.6499 | 4 | +| AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.8241 | 13 | 0.8125 | 3 | 0.7891 | 2 | 0.9175 | 6 | 0.1166 | 35 | 0.8125 | 5 | 0.8358 | 1 | 0.9175 | 2 | 0.6126 | 7 | +| C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.8415 | 5 | 0.6265 | 15 | 0.6359 | 15 | 0.9348 | 3 | 0.1245 | 34 | 0.6265 | 17 | 0.7479 | 9 | 0.9348 | 1 | 0.2414 | 22 | +| C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.8365 | 7 | 0.6412 | 14 | 0.8015 | 1 | 0.9298 | 4 | 0.0765 | 40 | 0.6132 | 19 | 0.6319 | 22 | 0.0695 | 30 | 0.2479 | 20 | +| C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.7560 | 22 | 0.5488 | 28 | 0.7443 | 5 | 0.8493 | 10 | 0.5955 | 21 | 0.7210 | 11 | 0.7443 | 11 | 0.5227 | 16 | 0.0802 | 36 | +| DSC-5436-NaraMedia.jpeg | 0.7953 | 17 | 0.5896 | 18 | 0.5989 | 17 | 0.8886 | 8 | 0.6176 | 18 | 0.7720 | 7 | 0.7836 | 3 | 0.8886 | 4 | 0.2130 | 27 | +| IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.0967 | 39 | 0.5344 | 32 | 0.6680 | 9 | 0.4411 | 28 | 0.5718 | 25 | 0.5058 | 34 | 0.5158 | 27 | 0.0687 | 31 | 0.2888 | 18 | +| IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.7167 | 26 | 0.5267 | 33 | 0.0690 | 39 | 0.4133 | 29 | 0.5827 | 23 | 0.1896 | 39 | 0.0690 | 39 | 0.0393 | 42 | 0.3180 | 16 | +| IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.5480 | 36 | 0.0748 | 40 | 0.0555 | 41 | 0.0713 | 40 | 0.5760 | 24 | 0.5160 | 32 | 0.0713 | 36 | 0.0415 | 41 | 0.3060 | 17 | +| IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.7312 | 24 | 0.6846 | 12 | 0.6503 | 13 | 0.5329 | 25 | 0.5290 | 28 | 0.5290 | 30 | 0.3897 | 31 | 0.0519 | 36 | 0.1824 | 32 | +| IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.7539 | 23 | 0.5471 | 29 | 0.6778 | 8 | 0.5672 | 18 | 0.7539 | 13 | 0.5564 | 28 | 0.5564 | 25 | 0.5672 | 14 | 0.4724 | 11 | +| IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.8070 | 15 | 0.7137 | 11 | 0.5803 | 20 | 0.5737 | 17 | 0.7603 | 12 | 0.8537 | 3 | 0.7603 | 6 | 0.6087 | 10 | 0.3372 | 13 | +| IG cali_carnivores - DSC00013.jpg | 0.5603 | 35 | 0.7937 | 6 | 0.6536 | 11 | 0.5954 | 14 | 0.8404 | 3 | 0.7376 | 10 | 0.7376 | 13 | 0.0683 | 32 | 0.6163 | 5 | +| IG cali_carnivores - DSC09850.jpg | 0.8350 | 8 | 0.6120 | 17 | 0.7427 | 6 | 0.9284 | 5 | 0.6400 | 17 | 0.8117 | 6 | 0.7427 | 12 | 0.0518 | 37 | 0.0920 | 35 | +| IG cali_carnivores - DSC09857.jpg | 0.7843 | 19 | 0.7493 | 10 | 0.5901 | 19 | 0.4496 | 27 | 0.8076 | 9 | 0.5808 | 21 | 0.7493 | 8 | 0.6326 | 6 | 0.2173 | 25 | +| IG desmo.donna - IMG-1015-Donna.jpeg | 0.5856 | 32 | 0.5576 | 26 | 0.5669 | 23 | 0.5920 | 16 | 0.5109 | 29 | 0.8720 | 2 | 0.4182 | 30 | 0.6153 | 8 | 0.1868 | 30 | +| IG desmo.donna - IMG-1080-Donna.jpeg | 0.7911 | 18 | 0.5769 | 19 | 0.5956 | 18 | 0.5928 | 15 | 0.4397 | 31 | 0.6982 | 12 | 0.5956 | 24 | 0.7445 | 5 | 0.1952 | 28 | +| IG desmo.donna - IMG-1151-Donna.jpeg | 0.5682 | 33 | 0.0698 | 41 | 0.2651 | 34 | 0.3952 | 30 | 0.5588 | 26 | 0.0698 | 42 | 0.0698 | 37 | 0.0733 | 27 | 0.2791 | 19 | +| IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.7691 | 21 | 0.5499 | 27 | 0.5686 | 22 | 0.8624 | 9 | 0.0664 | 42 | 0.6899 | 13 | 0.6899 | 18 | 0.0961 | 21 | 0.2283 | 23 | +| IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.7205 | 25 | 0.5204 | 34 | 0.0696 | 37 | 0.0731 | 37 | 0.4924 | 30 | 0.5093 | 33 | 0.0696 | 38 | 0.0591 | 34 | 0.1787 | 33 | +| IG kamiumitv - IMG-3981-Bao.jpeg | 0.8268 | 12 | 0.7568 | 9 | 0.3908 | 28 | 0.3351 | 32 | 0.8035 | 10 | 0.5774 | 23 | 0.7081 | 14 | 0.4748 | 18 | 0.6501 | 3 | +| IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.8212 | 14 | 0.5730 | 20 | 0.4297 | 26 | 0.5646 | 19 | 0.8096 | 8 | 0.5543 | 29 | 0.4297 | 29 | 0.0829 | 24 | 0.2139 | 26 | +| IG m92663m - IMG-6019-Mark Momot.jpeg | 0.6001 | 31 | 0.2013 | 38 | 0.0688 | 40 | 0.3068 | 35 | 0.6001 | 20 | 0.2013 | 38 | 0.0688 | 40 | 0.3068 | 20 | 0.0740 | 37 | +| IG martangelenos - PXL_20250310_132329430.jpg | 0.0690 | 42 | 0.3601 | 36 | 0.0795 | 35 | 0.1361 | 36 | 0.5535 | 27 | 0.3531 | 37 | 0.0795 | 34 | 0.0498 | 38 | 0.0603 | 40 | +| IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.8401 | 6 | 0.7934 | 7 | 0.7467 | 4 | 0.6534 | 13 | 0.8401 | 4 | 0.6254 | 18 | 0.7467 | 10 | 0.6067 | 11 | 0.6790 | 2 | +| IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.8018 | 16 | 0.7901 | 8 | 0.3708 | 30 | 0.3271 | 33 | 0.1133 | 36 | 0.5948 | 20 | 0.6414 | 20 | 0.3411 | 19 | 0.4461 | 12 | +| IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.8338 | 9 | 0.6204 | 16 | 0.6297 | 16 | 0.3463 | 31 | 0.8221 | 6 | 0.7417 | 9 | 0.6297 | 23 | 0.6005 | 12 | 0.2469 | 21 | +| IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.0719 | 41 | 0.3506 | 37 | 0.3506 | 31 | 0.0474 | 42 | 0.3366 | 32 | 0.4976 | 35 | 0.4582 | 28 | 0.0702 | 29 | 0.0544 | 41 | +| IG renatobo - IMG_5013.jpeg | 0.6063 | 29 | 0.5689 | 21 | 0.5689 | 21 | 0.5478 | 22 | 0.7695 | 11 | 0.5783 | 22 | 0.6996 | 16 | 0.0804 | 25 | 0.1917 | 29 | +| IG renatobo - IMG_5014.jpeg | 0.5384 | 37 | 0.5104 | 35 | 0.6504 | 12 | 0.4863 | 26 | 0.0922 | 38 | 0.6504 | 14 | 0.5290 | 26 | 0.4863 | 17 | 0.1743 | 34 | +| IMG_4984.jpeg | 0.5034 | 38 | 0.5594 | 25 | 0.6901 | 7 | 0.5360 | 24 | 0.6410 | 16 | 0.5594 | 27 | 0.6901 | 17 | 0.0944 | 22 | 0.3356 | 14 | +| IMG_5012.jpeg | 0.6975 | 27 | 0.5393 | 31 | 0.5393 | 24 | 0.5458 | 23 | 0.0696 | 41 | 0.5207 | 31 | 0.6607 | 19 | 0.0731 | 28 | 0.3345 | 15 | +| caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.8325 | 10 | 0.5446 | 30 | 0.3035 | 33 | 0.0724 | 39 | 0.0951 | 37 | 0.5633 | 25 | 0.0671 | 41 | 0.0461 | 39 | 0.0654 | 39 | +| caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.6129 | 28 | 0.5663 | 23 | 0.0694 | 38 | 0.0729 | 38 | 0.8245 | 5 | 0.5663 | 24 | 0.0764 | 35 | 0.0764 | 26 | 0.0729 | 38 | + +## Ranked Quality Details (All Images) + +_Each section below is from timed run 1 for that variant._ + +### qwen3-vl:8b | yolo=off + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.8718 | 0.8839 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | A crisp, sunlit shot of a rider on a red Ducati motorcycle on a Southern California track, ideal for enthusiast accounts. | +| 2 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.8697 | 0.8766 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Ducati rider in action with mountain backdrop, vibrant and dynamic for motorcycle fans. | +| 3 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.8498 | 0.6938 | 55 | 9 | 9 | 10 | 9 | 10 | 8 | no | Dynamic Ducati rider in red leans aggressively through a turn on a sunlit Southern California track. | +| 4 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.8442 | 0.7919 | 52 | 9 | 9 | 8 | 9 | 9 | 8 | no | Ducati rider in action on a track with mountain backdrop, capturing speed and precision for enthusiasts. | +| 5 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.8415 | 0.7827 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Dynamic Ducati rider leaning into a turn on a desert track with vibrant red against arid landscape. | +| 6 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.8401 | 0.7780 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Sunlit Ducati 848 motorcycle on a stand at a Southern California track, showcasing bold red, white, and black design. | +| 7 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.8365 | 0.7661 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Dynamic Ducati rider in motion on a desert track, vibrant red bike against arid landscape. | +| 8 | IG cali_carnivores - DSC09850.jpg | 0.8350 | 0.7612 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Ducati rider speeding on a desert track with mountain backdrop, dynamic and engaging. | +| 9 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.8338 | 0.7571 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Red Ducati motorcycle in motion on a Southern California track, showcasing speed and precision. | +| 10 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.8325 | 0.6360 | 55 | 9 | 10 | 9 | 9 | 10 | 8 | no | Ducati enthusiasts gather on a sunny racetrack with mountains, perfect for a motorcycle community Instagram cover. | +| 11 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.8304 | 0.8237 | 50 | 9 | 9 | 8 | 7 | 9 | 8 | no | Got it, let's evaluate this motorcycle photo for Instagram cover potential. | +| 12 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.8268 | 0.6171 | 55 | 9 | 9 | 10 | 9 | 10 | 8 | no | Two vibrant red Ducatis side by side against a clear blue sky, perfect for motorcycle enthusiasts. | +| 13 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.8241 | 0.7249 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Dynamic Ducati rider leaning into a turn with vibrant red against a desert backdrop. | +| 14 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.8212 | 0.6374 | 54 | 9 | 9 | 10 | 9 | 9 | 8 | no | Vibrant sport bikes lined up in a sunny Southern California parking lot, ideal for motorcycle enthusiast accounts. | +| 15 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.8070 | 0.5123 | 56 | 9 | 10 | 10 | 9 | 10 | 8 | no | Got it, let's evaluate this motorcycle photo for Instagram cover potential. | +| 16 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.8018 | 0.6504 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | concise sentence. Let's check each score. | +| 17 | DSC-5436-NaraMedia.jpeg | 0.7953 | 0.6287 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Three Ducati riders race across a sunlit desert track, capturing speed and scenic beauty. | +| 18 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.7911 | 0.5760 | 53 | 9 | 9 | 10 | 8 | 9 | 8 | no | A vibrant red Ducati motorcycle dominates a sunny Southern California bike event, with enthusiasts gathered around. | +| 19 | IG cali_carnivores - DSC09857.jpg | 0.7843 | 0.5921 | 52 | 9 | 9 | 8 | 9 | 9 | 8 | no | A Ducati rider executes a wheelie on a desert track, capturing dynamic motion and vibrant red against sandy terrain. | +| 20 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.7839 | 0.5907 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | A vibrant lineup of Ducati motorcycles in Southern California, showcasing their sleek design and enthusiast appeal. | +| 21 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.7691 | 0.5413 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Sunset-lit Ducati and motorcycles line up at Chuckwalla, capturing Southern California's biker culture. | +| 22 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.7560 | 0.4978 | 52 | 9 | 9 | 8 | 9 | 9 | 8 | no | Dynamic motorcycle race scene with riders leaning into turns on a sunny Southern California track. | +| 23 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.7539 | 0.4907 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | A Ducati rider in full gear poses on a vibrant red bike against a desert backdrop, perfect for motorcycle enthusiasts. | +| 24 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.7312 | 0.3764 | 53 | 9 | 9 | 10 | 8 | 9 | 8 | no | A vibrant lineup of Ducati riders on a desert track, ready for speed under clear blue skies. | +| 25 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.7205 | 0.4960 | 49 | 8 | 9 | 8 | 7 | 9 | 8 | no | Ducati enthusiast poses confidently with a race map on a van, showcasing SoCal event vibes. | +| 26 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.7167 | 0.4833 | 49 | 9 | 9 | 7 | 9 | 8 | 8 | no | Ducati enthusiasts pose joyfully in front of Chuckwalla's timing building during sunset. | +| 27 | IMG_5012.jpeg | 0.6975 | 0.4195 | 49 | 8 | 9 | 9 | 7 | 8 | 8 | no | Two Ducati riders pose on a sunny Southern California track with Chuckwalla sign in the background. | +| 28 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.6129 | 0.6484 | 49 | 8 | 9 | 8 | 9 | 8 | 7 | no | Ducati enthusiasts gather on a racetrack with vibrant group pose and clear branding. | +| 29 | IG renatobo - IMG_5013.jpeg | 0.6063 | 0.5817 | 50 | 9 | 9 | 8 | 8 | 9 | 7 | no | Got it, let's evaluate this motorcycle photo for Instagram cover potential. | +| 30 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.6050 | 0.6932 | 47 | 8 | 9 | 8 | 7 | 8 | 7 | no | Needs to be concise. "A Ducati enthusiast engages in conversation at a Southern California event, showcasing community spirit with vibrant branding and natural lighting. | +| 31 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.6001 | 0.6338 | 48 | 8 | 9 | 9 | 7 | 8 | 7 | no | Chuckwalla sign over a palm-lined desert road under a clear blue sky, ideal for a Ducati enthusiast's Instagram cover. | +| 32 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.5856 | 0.5732 | 48 | 8 | 9 | 9 | 7 | 8 | 7 | no | Need a concise sentence. "Sunset-lit Ducati gathering with vibrant red bikes and community vibes at Chuckwalla. | +| 33 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.5682 | 0.4230 | 50 | 9 | 9 | 8 | 9 | 8 | 7 | no | Three smiling Ducati enthusiasts pose at a Southern California track event with vibrant branding and scenic mountains. | +| 34 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.5674 | 0.7310 | 42 | 8 | 8 | 7 | 5 | 7 | 7 | no | Got it, let's evaluate this image for Instagram cover potential. | +| 35 | IG cali_carnivores - DSC00013.jpg | 0.5603 | 0.7401 | 41 | 9 | 8 | 10 | 8 | 9 | 7 | no | A vibrant red Ducati motorcycle with sharp details and strong visual appeal for motorcycle enthusiasts. | +| 36 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.5480 | 0.5335 | 45 | 7 | 8 | 6 | 9 | 8 | 7 | no | Ducati enthusiast smiles with a peace sign while holding food at a Southern California event. | +| 37 | IG renatobo - IMG_5014.jpeg | 0.5384 | 0.3765 | 48 | 8 | 7 | 9 | 8 | 9 | 7 | no | Ducati rider in Chuckwalla with vibrant red bike under bright sun | +| 38 | IMG_4984.jpeg | 0.5034 | 0.5421 | 40 | 9 | 8 | 9 | 8 | 9 | 7 | no | Close-up of a Ducati motorcycle's front with a thermal tech knee guard, showcasing vibrant colors and technical details. | +| 39 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.0967 | 0.4768 | 43 | 7 | 9 | 7 | 9 | 7 | 4 | no | Need a concise sentence. "A smiling rider on an electric scooter against a sunset backdrop with mountains, ideal for a Ducati enthusiast's Instagram cover. | +| 40 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0823 | 0.2334 | 41 | 7 | 8 | 7 | 8 | 7 | 4 | no | A group of motorcycle enthusiasts gathered in a meeting room, with a speaker addressing the audience. | +| 41 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.0719 | 0.4312 | 30 | 3 | 9 | 4 | 6 | 4 | 4 | no | Needs to be one concise sentence. The image shows two men on e-scooters in a parking lot with a clear sky, not motorcycles. So "Two men on e-scooters in a sunny Southern California parking lot, not motorcycle-focused. | +| 42 | IG martangelenos - PXL_20250310_132329430.jpg | 0.0690 | 0.4839 | 27 | 2 | 6 | 7 | 4 | 5 | 3 | no | A dusk scene at Chuckwalla with a golf cart, not a motorcycle, lacks relevance for a Ducati enthusiast account. | + +### blaifa/InternVL3_5:8b | yolo=off + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.8252 | 0.8839 | 48 | 9 | 8 | 8 | 7 | 8 | 8 | no | The motorcycle and rider are the clear focal point with a sharp subject-to-background contrast, balanced lighting, vibrant color harmony, and a composition that conveys motion and aspiration. The image is visually engagi | +| 2 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.8188 | 0.8237 | 49 | 9 | 8 | 7 | 8 | 9 | 8 | no | The motorcycle and rider are the clear focal point with good contrast against the background, balanced lighting, and dynamic composition that conveys motion and power. The image is visually engaging and would likely stop | +| 3 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.8125 | 0.7249 | 51 | 9 | 8 | 8 | 9 | 9 | 8 | no | The image effectively highlights the motorcycle and rider with strong contrast, dynamic lighting, vibrant colors, and a composition that conveys motion and power, making it engaging for Instagram. | +| 4 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.8031 | 0.6938 | 51 | 9 | 8 | 8 | 9 | 9 | 8 | no | The motorcycle and rider are the clear focal point with a sharp subject-to-background contrast, balanced lighting, vibrant colors, dynamic motion conveying power, and strong composition that would likely stop fast-scroll | +| 5 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.7976 | 0.7919 | 48 | 9 | 8 | 7 | 8 | 8 | 8 | no | The motorcycle is the clear focal point with a sharp subject-to-background contrast, balanced lighting, and dynamic composition that conveys motion and power. The image has strong leading lines and would likely stop fast | +| 6 | IG cali_carnivores - DSC00013.jpg | 0.7937 | 0.7401 | 49 | 9 | 8 | 9 | 7 | 8 | 8 | no | The motorcycle is the clear focal point with a sharp contrast against the background, balanced lighting, and vibrant color that stands out. The composition conveys power and aspiration, making it engaging for Instagram. | +| 7 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.7934 | 0.7780 | 48 | 9 | 8 | 8 | 7 | 8 | 8 | no | The motorcycle is the clear focal point with good contrast against the background, balanced lighting, and vibrant colors that stand out. The composition conveys power and aspiration, making it engaging for Instagram. | +| 8 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.7901 | 0.6504 | 51 | 9 | 8 | 8 | 9 | 9 | 8 | no | The motorcycle is the clear focal point with strong contrast against the background, balanced lighting, vibrant colors, and dynamic composition that conveys motion and power, making it engaging for Instagram. | +| 9 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.7568 | 0.6171 | 49 | 9 | 8 | 9 | 7 | 8 | 8 | no | The motorcycle is the clear focal point with a sharp contrast against the background, balanced lighting, and vibrant colors that stand out. The composition conveys power and aspiration, making it engaging for Instagram. | +| 10 | IG cali_carnivores - DSC09857.jpg | 0.7493 | 0.5921 | 49 | 9 | 8 | 7 | 8 | 9 | 8 | no | The motorcycle is the clear focal point with a sharp subject-to-background contrast, balanced lighting, and dynamic composition that conveys motion and power. The image has strong leading lines and would likely stop fast | +| 11 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.7137 | 0.5123 | 48 | 9 | 8 | 8 | 7 | 8 | 8 | no | The motorcycle and rider are clear focal points with good contrast against the background, balanced lighting, vibrant colors, and a composition that conveys power and aspiration. The image is visually engaging and would | +| 12 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.6846 | 0.3764 | 49 | 8 | 7 | 9 | 8 | 9 | 8 | no | The image effectively highlights the motorcycles with a clear focal point, balanced lighting, vibrant colors against the sky, and dynamic composition that would engage viewers on Instagram. | +| 13 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.6491 | 0.8766 | 47 | 9 | 8 | 7 | 8 | 8 | 7 | no | The motorcycle is the clear focal point with a sharp subject-to-background contrast, balanced lighting, and dynamic composition that conveys motion and power. The image has strong visual elements to stop fast-scrolling. | +| 14 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.6412 | 0.7661 | 49 | 9 | 8 | 8 | 8 | 9 | 7 | no | The motorcycle is the clear focal point with good contrast against the background, balanced lighting, and dynamic composition that conveys motion and power. It's visually engaging but may lose some elements when cropped | +| 15 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.6265 | 0.7827 | 47 | 9 | 8 | 7 | 8 | 8 | 7 | no | The motorcycle is the clear focal point with a sharp subject-to-background contrast, balanced lighting, and dynamic composition that conveys motion and power. The image has strong leading lines and would likely stop fast | +| 16 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.6204 | 0.7571 | 47 | 9 | 8 | 7 | 8 | 8 | 7 | no | The motorcycle is the clear focal point with good contrast against the background, balanced lighting, and dynamic composition that conveys motion and power. It has strong visual elements to stop scrolling but may need ad | +| 17 | IG cali_carnivores - DSC09850.jpg | 0.6120 | 0.7612 | 46 | 8 | 7 | 7 | 9 | 8 | 7 | no | The motorcycle is the clear focal point with good contrast against the background, balanced lighting, and dynamic composition that conveys motion and power. | +| 18 | DSC-5436-NaraMedia.jpeg | 0.5896 | 0.6287 | 47 | 9 | 8 | 7 | 8 | 8 | 7 | no | The image effectively highlights the motorcycles with a clear focal point, balanced lighting, and dynamic composition that conveys motion and power, making it engaging for Instagram. | +| 19 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.5769 | 0.5760 | 47 | 8 | 9 | 8 | 7 | 8 | 7 | no | The image effectively highlights the motorcycle with good lighting and color contrast, but could improve composition for a portrait crop. | +| 20 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.5730 | 0.6374 | 45 | 8 | 7 | 9 | 6 | 8 | 7 | no | The motorcycles are the clear focal point with vibrant colors contrasting against the blue sky, and the composition is strong enough to stop scrolling. | +| 21 | IG renatobo - IMG_5013.jpeg | 0.5689 | 0.5817 | 46 | 8 | 9 | 7 | 7 | 8 | 7 | no | The image effectively highlights the motorcycles and riders with good lighting and composition, but could improve in color contrast and crop flexibility. | +| 22 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.5674 | 0.7310 | 42 | 7 | 8 | 6 | 5 | 9 | 7 | no | The image captures a moment with good lighting and color contrast, but lacks motorcycle focus and dynamic emotion. | +| 23 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.5663 | 0.6484 | 44 | 7 | 8 | 6 | 7 | 9 | 7 | no | The image effectively captures a group photo with clear subjects and good lighting, but lacks strong color contrast and dynamic composition for motorcycle focus. | +| 24 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.5618 | 0.5907 | 45 | 8 | 7 | 9 | 6 | 8 | 7 | no | The photo effectively highlights the motorcycles with a clear focal point and vibrant colors, but could benefit from a lower angle to enhance emotion and power. | +| 25 | IMG_4984.jpeg | 0.5594 | 0.5421 | 46 | 8 | 7 | 9 | 7 | 8 | 7 | no | The motorcycle is the clear focal point with good contrast against the background, vibrant colors enhance visual appeal, and the composition would likely stop fast-scrolling users. | +| 26 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.5576 | 0.5732 | 45 | 8 | 9 | 7 | 6 | 8 | 7 | no | The image effectively highlights the motorcycles with good lighting and color contrast, but could improve emotion conveyance and crop flexibility. | +| 27 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.5499 | 0.5413 | 45 | 8 | 9 | 7 | 6 | 8 | 7 | no | The photo effectively uses golden hour lighting and a strong composition to highlight the motorcycles, but could improve emotion conveyance with a lower angle shot. | +| 28 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.5488 | 0.4978 | 46 | 8 | 7 | 7 | 9 | 8 | 7 | no | The image effectively captures motion and power with a clear subject against a contrasting background, balanced lighting, and dynamic composition that would likely stop fast-scrolling on Instagram. | +| 29 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.5471 | 0.4907 | 46 | 8 | 7 | 9 | 7 | 8 | 7 | no | The motorcycle and rider are clear focal points with good contrast against the background, vibrant colors enhance visual appeal, and the composition is engaging for Instagram. | +| 30 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.5446 | 0.6360 | 42 | 7 | 8 | 6 | 5 | 9 | 7 | no | The image effectively captures a group with clear composition and strong visual elements, but lacks dynamic emotion and could be improved for Instagram's scroll-stop criteria. | +| 31 | IMG_5012.jpeg | 0.5393 | 0.4195 | 47 | 8 | 7 | 9 | 8 | 8 | 7 | no | The image effectively highlights the motorcycles with clear subjects against a simple background, balanced lighting, and vibrant colors that pop, making it engaging for Instagram. | +| 32 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.5344 | 0.4768 | 45 | 8 | 9 | 7 | 6 | 8 | 7 | no | The image effectively highlights the rider and scooter with good lighting, but lacks strong emotion or dynamic composition to fully capture attention. | +| 33 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.5267 | 0.4833 | 44 | 7 | 8 | 6 | 7 | 9 | 7 | no | The image effectively captures a group in front of a building with good lighting and composition, but lacks focus on a motorcycle as the main subject. | +| 34 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.5204 | 0.4960 | 43 | 6 | 7 | 8 | 7 | 8 | 7 | no | The image effectively captures a dynamic scene with good color contrast and composition, but the motorcycle isn't the clear focal point due to the presence of other elements. | +| 35 | IG renatobo - IMG_5014.jpeg | 0.5104 | 0.3765 | 45 | 8 | 7 | 9 | 6 | 8 | 7 | no | The motorcycle stands out as the focal point with good contrast against the background, and the lighting adds a dramatic effect. The color pop is strong due to the red bike against the blue sky. | +| 36 | IG martangelenos - PXL_20250310_132329430.jpg | 0.3601 | 0.4839 | 39 | 7 | 8 | 6 | 5 | 7 | 6 | no | The image effectively uses golden hour lighting and has a clear subject, but lacks dynamic emotion and could improve composition for Instagram's 3:4 grid. | +| 37 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.3506 | 0.4312 | 39 | 7 | 8 | 6 | 5 | 7 | 6 | no | The image captures two individuals on scooters with a clear focus, good lighting, and moderate color contrast, but lacks strong emotion or dramatic composition to fully stop the scroll. | +| 38 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.2013 | 0.6338 | 33 | 3 | 7 | 6 | 4 | 8 | 5 | no | The image lacks a motorcycle as the focal point, but has good lighting and composition that could stop scrolling. | +| 39 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.0819 | 0.6932 | 29 | 3 | 7 | 6 | 4 | 5 | 4 | no | The image lacks a clear motorcycle focal point and has a busy background, but the lighting is balanced with moderate color contrast. It conveys some emotion but may not stop fast-scrolling due to composition. | +| 40 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.0748 | 0.5335 | 29 | 3 | 7 | 6 | 4 | 5 | 4 | no | The image lacks a clear motorcycle focus and has a busy background, but the lighting is good and there's some color contrast. It conveys a casual atmosphere but may not stand out as a strong Instagram cover due to compos | +| 41 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.0698 | 0.4230 | 29 | 3 | 7 | 6 | 5 | 4 | 4 | no | The image lacks a motorcycle as the focal point, but has good lighting and color contrast. It conveys some emotion but may not stop fast-scrolling due to composition. | +| 42 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0613 | 0.2334 | 29 | 3 | 6 | 7 | 5 | 4 | 4 | no | The image lacks a clear focal point on the motorcycle, and the busy background competes for attention. Lighting is balanced but not dramatic, colors are moderately vibrant, emotion is moderate with no strong sense of mot | + +### blaifa/InternVL3_5:4B | yolo=off + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.8015 | 0.7661 | 49 | 8 | 7 | 9 | 9 | 8 | 8 | no | The image effectively conveys motion and power with a clear focal point, vibrant colors contrasting against the background, and strong composition for Instagram. | +| 2 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.7891 | 0.7249 | 49 | 8 | 7 | 9 | 9 | 8 | 8 | no | The image effectively conveys motion and power with a clear subject, vibrant colors contrasting against the background, and strong composition for Instagram. | +| 3 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.7577 | 0.8237 | 60 | 8 | 9 | 7 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with good lighting and color contrast, but could benefit from a more dynamic composition for Instagram. | +| 4 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.7467 | 0.7780 | 60 | 8 | 9 | 7 | 8 | 8 | 7 | no | The image has a clear subject, good lighting, and conveys power, but could benefit from more contrast in color and better composition for cropping. | +| 5 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.7443 | 0.4978 | 51 | 8 | 7 | 9 | 10 | 9 | 8 | no | The image effectively conveys motion and power with clear subject focus, vibrant colors, and dramatic lighting, making it suitable for an Instagram cover. | +| 6 | IG cali_carnivores - DSC09850.jpg | 0.7427 | 0.7612 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The image effectively conveys motion and power with a clear focal point, strong color contrast, and balanced composition. | +| 7 | IMG_4984.jpeg | 0.6901 | 0.5421 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The image has a clear focus on the motorcycle, strong color contrast, and conveys power well, but could benefit from better lighting and composition for Instagram. | +| 8 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.6778 | 0.4907 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The image has a clear subject with good color contrast and conveys emotion, but the background is slightly busy. | +| 9 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.6680 | 0.4768 | 45 | 7 | 8 | 6 | 7 | 8 | 9 | no | The image has good subject clarity and lighting, but the color pop could be improved with a more contrasting bike color. | +| 10 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.6584 | 0.8766 | 48 | 8 | 7 | 9 | 9 | 8 | 7 | no | The image effectively conveys motion and power with a clear focal point, vibrant colors contrasting against the background, and strong emotion. However, some cropping adjustments could enhance composition. | +| 11 | IG cali_carnivores - DSC00013.jpg | 0.6536 | 0.7401 | 51 | 9 | 8 | 10 | 8 | 9 | 7 | no | The image has high subject clarity and color pop, with good lighting and emotion, but the crop could be improved for Instagram's portrait format. | +| 12 | IG renatobo - IMG_5014.jpeg | 0.6504 | 0.3765 | 60 | 8 | 7 | 9 | 7 | 8 | 7 | no | The image has a clear subject with good color contrast and lighting, but could benefit from better composition for Instagram. | +| 13 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.6503 | 0.3764 | 60 | 8 | 9 | 7 | 8 | 8 | 7 | no | The image effectively captures a group of motorcycles, with clear subject clarity and good lighting. The color pop is moderate, and the emotion conveyed is strong due to the lineup of bikes. However, cropping could impro | +| 14 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.6380 | 0.7919 | 48 | 8 | 9 | 7 | 9 | 8 | 7 | no | The image effectively conveys motion and power with a clear focal point, good lighting, and strong emotion, making it suitable for an Instagram cover. | +| 15 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.6359 | 0.7827 | 48 | 8 | 7 | 9 | 9 | 8 | 7 | no | The image effectively conveys motion and power with a clear focal point, vibrant colors contrasting against the background, and strong emotion. However, some cropping adjustments could enhance composition. | +| 16 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.6297 | 0.7571 | 48 | 8 | 7 | 9 | 9 | 8 | 7 | no | The image effectively conveys motion and power with a clear focal point, vibrant colors contrasting against the background, and strong emotional appeal. However, some cropping adjustments could enhance composition. | +| 17 | DSC-5436-NaraMedia.jpeg | 0.5989 | 0.6287 | 48 | 8 | 9 | 7 | 9 | 8 | 7 | no | The image effectively conveys motion and power with clear subject focus against a contrasting background, making it suitable for an Instagram cover. | +| 18 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.5956 | 0.5760 | 49 | 8 | 9 | 10 | 7 | 8 | 7 | no | The image has strong subject clarity and color pop, with good lighting, but could improve on emotion and cropping potential. | +| 19 | IG cali_carnivores - DSC09857.jpg | 0.5901 | 0.5921 | 48 | 8 | 7 | 9 | 9 | 8 | 7 | no | The image effectively conveys motion and power with a clear focal point, vibrant colors contrasting against the background, and strong emotional appeal. However, some cropping adjustments could enhance composition. | +| 20 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.5803 | 0.5123 | 49 | 8 | 9 | 10 | 7 | 8 | 7 | no | The image has high subject clarity and color pop, with good lighting. The emotion is somewhat low due to the standing shot angle. | +| 21 | IG renatobo - IMG_5013.jpeg | 0.5689 | 0.5817 | 46 | 8 | 9 | 7 | 7 | 8 | 7 | no | The image has good subject clarity and lighting, with a strong color contrast between the motorcycles and the sky. However, the composition could be improved for better emotion and scroll stop potential. | +| 22 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.5686 | 0.5413 | 47 | 8 | 9 | 7 | 8 | 8 | 7 | no | The image effectively captures the motorcycle and rider with good lighting, but could benefit from a more focused composition. | +| 23 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.5669 | 0.5732 | 46 | 8 | 9 | 7 | 7 | 8 | 7 | no | The image has good subject clarity and lighting, with a strong color contrast that makes the motorcycle stand out. However, the composition could be improved for better emotion and scroll stop potential. | +| 24 | IMG_5012.jpeg | 0.5393 | 0.4195 | 47 | 8 | 9 | 7 | 8 | 8 | 7 | no | The image effectively captures the motorcycle and rider with good lighting and color contrast, but could benefit from a more dynamic composition for Instagram. | +| 25 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.4548 | 0.8839 | 26 | 9 | 8 | 10 | 8 | 9 | 7 | no | The image has a clear subject, good lighting, and strong color contrast, but the composition could be improved for Instagram's portrait format. | +| 26 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.4297 | 0.6374 | 45 | 8 | 9 | 7 | 7 | 8 | 6 | no | The image has clear subject clarity and good lighting, but the composition could be improved for better emotion and crop potential. | +| 27 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.4213 | 0.5907 | 45 | 8 | 9 | 7 | 8 | 7 | 6 | no | The image has a clear focal point with good lighting and color contrast, but the composition could be improved for Instagram's grid. | +| 28 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.3908 | 0.6171 | 26 | 9 | 8 | 10 | 8 | 9 | 7 | no | The image has high subject clarity, strong color contrast with the red motorcycle against a blue sky, and conveys power and aspiration. The lighting is good but could be slightly improved for dramatic effect. | +| 29 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.3812 | 0.6938 | 23 | 9 | 8 | 10 | 9 | 9 | 7 | no | The image effectively captures the motorcycle and rider with clear subject clarity, vibrant colors, and a dynamic emotion of motion. The lighting is good but could be slightly improved for more dramatic effect. | +| 30 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.3708 | 0.6504 | 23 | 9 | 8 | 10 | 9 | 9 | 7 | no | The image effectively captures the motorcycle and rider with clear subject clarity, good lighting, vibrant colors, and a sense of motion. It would likely stop scrolling on Instagram due to its dramatic composition. | +| 31 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.3506 | 0.4312 | 39 | 6 | 7 | 8 | 5 | 7 | 6 | no | The image has good subject clarity and color pop, but the emotion is somewhat low due to the standing shot. | +| 32 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.3206 | 0.7310 | 27 | 2 | 7 | 3 | 4 | 5 | 6 | no | The image lacks a clear motorcycle subject and has low color contrast, but the lighting is decent. | +| 33 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.3035 | 0.6360 | 27 | 2 | 7 | 3 | 4 | 5 | 6 | no | The image has a group of people, not a motorcycle, so it doesn't focus on the subject clarity or emotion related to a bike. | +| 34 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.2651 | 0.4230 | 27 | 2 | 7 | 3 | 4 | 5 | 6 | no | The image lacks a motorcycle, focusing on people instead, which reduces subject clarity and relevance for a Ducati account. | +| 35 | IG martangelenos - PXL_20250310_132329430.jpg | 0.0795 | 0.4839 | 33 | 3 | 8 | 7 | 6 | 5 | 4 | no | The image has good lighting and color pop but lacks a clear motorcycle subject, making it less effective for an Instagram cover. | +| 36 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.0784 | 0.6932 | 27 | 3 | 6 | 7 | 4 | 5 | 2 | no | The image lacks a clear motorcycle subject and has an unbalanced composition, making it less suitable for Instagram cover. | +| 37 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.0696 | 0.4960 | 27 | 3 | 7 | 6 | 4 | 5 | 2 | no | The image has a clear subject but lacks strong emotion and composition for an Instagram cover. | +| 38 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.0694 | 0.6484 | 23 | 2 | 7 | 6 | 3 | 4 | 1 | no | The image lacks a clear motorcycle subject and has low subject-to-background contrast, but the lighting is good. | +| 39 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.0690 | 0.4833 | 27 | 3 | 7 | 6 | 4 | 5 | 2 | no | The image has good lighting and color pop but lacks a clear motorcycle subject, strong emotion, and effective composition for Instagram. | +| 40 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.0688 | 0.6338 | 23 | 2 | 7 | 6 | 3 | 4 | 1 | no | The image lacks a clear motorcycle subject, focusing instead on an entrance sign with palm trees, making it less suitable for a Ducati/motorcycle enthusiast Instagram cover. | +| 41 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.0555 | 0.5335 | 18 | 2 | 3 | 6 | 4 | 5 | 3 | no | The image is not suitable for an Instagram motorcycle cover due to poor subject clarity and lighting, but it has some color pop and emotion. | +| 42 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0280 | 0.2334 | 10 | 2 | 3 | 1 | 3 | 2 | 1 | no | The image does not focus on a motorcycle, lacks proper lighting and color contrast, and is not suitable for an Instagram cover. | + +### openbmb/minicpm-v4.5:8b | yolo=off + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.9630 | 0.8766 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic composition with clear subject, strong colors, and motion conveyance make it ideal for Ducati enthusiast Instagram cover. | +| 2 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.9376 | 0.7919 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic composition, strong subject isolation, and motion conveyance make it ideal for Ducati enthusiast accounts. | +| 3 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.9348 | 0.7827 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic composition, strong subject isolation, and motion conveyance make it ideal for Ducati enthusiast accounts. | +| 4 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.9298 | 0.7661 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic composition, strong subject isolation, and motion conveyance make it ideal for Ducati enthusiasts. | +| 5 | IG cali_carnivores - DSC09850.jpg | 0.9284 | 0.7612 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic motorcycle shot with clear subject, strong composition and color contrast. | +| 6 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.9175 | 0.7249 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic composition, strong subject isolation, and vibrant colors make this a compelling cover photo. | +| 7 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.9081 | 0.6938 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic composition, strong subject isolation, and motion conveyance make it ideal for Ducati enthusiast accounts. | +| 8 | DSC-5436-NaraMedia.jpeg | 0.8886 | 0.6287 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic composition, strong leading lines, and clear subject make this a compelling cover photo. | +| 9 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.8624 | 0.5413 | 60 | 9 | 10 | 8 | 7 | 9 | 8 | no | Golden hour lighting and clear subject make this a strong cover photo for Ducati enthusiasts. | +| 10 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.8493 | 0.4978 | 60 | 9 | 8 | 7 | 8 | 9 | 8 | no | Dynamic composition, strong subject focus, and clear visual hierarchy make it ideal for Ducati/motorcycle enthusiast Instagram cover. | +| 11 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.7085 | 0.8839 | 38 | 9 | 8 | 7 | 6 | 8 | 10 | no | Strong subject clarity and composition, with good color contrast and potential for Instagram engagement. | +| 12 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.6671 | 0.8237 | 36 | 9 | 8 | 7 | 6 | 8 | 8 | no | Strong subject clarity and lighting with good color contrast, but could better convey motion or power. | +| 13 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.6534 | 0.7780 | 36 | 9 | 8 | 7 | 6 | 8 | 8 | no | Strong subject clarity and lighting, good color contrast, but could convey more emotion with a lower angle. | +| 14 | IG cali_carnivores - DSC00013.jpg | 0.5954 | 0.7401 | 32 | 9 | 8 | 7 | 6 | 10 | 8 | no | High subject clarity and lighting make this a strong cover photo for Ducati enthusiasts. | +| 15 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.5928 | 0.5760 | 36 | 8 | 9 | 7 | 6 | 8 | 8 | no | Strong subject clarity and lighting, but could better convey emotion with a lower angle. | +| 16 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.5920 | 0.5732 | 36 | 8 | 9 | 7 | 6 | 8 | 8 | no | Strong subject clarity and lighting with a balanced composition that works well for Instagram cover. | +| 17 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.5737 | 0.5123 | 36 | 9 | 8 | 7 | 6 | 8 | 8 | no | Strong subject clarity and lighting, with good color contrast and composition for Instagram cover potential. | +| 18 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.5672 | 0.4907 | 36 | 9 | 8 | 7 | 6 | 8 | 8 | no | Strong subject clarity and lighting, good color contrast, but could convey more emotion with a lower angle. | +| 19 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.5646 | 0.6374 | 32 | 8 | 7 | 9 | 6 | 10 | 8 | no | Vibrant colors and strong composition make this a compelling cover photo for Ducati enthusiasts. | +| 20 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.5576 | 0.7310 | 29 | 3 | 6 | 2 | 1 | 7 | 8 | no | Weak subject clarity and low color contrast detract from the image's potential as an Instagram cover. | +| 21 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.5505 | 0.5907 | 32 | 9 | 8 | 7 | 6 | 10 | 8 | no | Strong subject clarity and lighting with a dynamic lineup that stops scrolling, but could be more emotionally charged. | +| 22 | IG renatobo - IMG_5013.jpeg | 0.5478 | 0.5817 | 32 | 9 | 8 | 7 | 6 | 10 | 8 | no | Dynamic interaction and clear subject make it strong for Instagram cover. | +| 23 | IMG_5012.jpeg | 0.5458 | 0.4195 | 36 | 9 | 8 | 7 | 6 | 8 | 8 | no | Strong subject clarity and lighting, good color contrast, but could convey more emotion with a lower angle. | +| 24 | IMG_4984.jpeg | 0.5360 | 0.5421 | 32 | 9 | 8 | 7 | 6 | 10 | 8 | no | Strong subject clarity and lighting with good color contrast, but could use more dynamic emotion or angle to fully engage viewers. | +| 25 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.5329 | 0.3764 | 36 | 9 | 8 | 7 | 6 | 8 | 8 | no | Dynamic lineup with clear focal points and strong composition for Ducati enthusiasts. | +| 26 | IG renatobo - IMG_5014.jpeg | 0.4863 | 0.3765 | 32 | 9 | 8 | 7 | 6 | 10 | 8 | no | Strong subject clarity and lighting with a pop of red against blue sky; composition works well for Instagram cover. | +| 27 | IG cali_carnivores - DSC09857.jpg | 0.4496 | 0.5921 | 49 | 9 | 8 | 7 | 10 | 9 | 6 | no | Dynamic action shot with clear subject and strong contrast against desert backdrop. | +| 28 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.4411 | 0.4768 | 35 | 8 | 9 | 7 | 6 | 8 | 7 | no | Strong subject clarity and lighting, but could better convey emotion or power. | +| 29 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.4133 | 0.4833 | 23 | 7 | 8 | 6 | 3 | 9 | 10 | no | Strong composition and lighting make it a good cover photo, but the group shot lacks emotional impact for motorcycle enthusiasts. | +| 30 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.3952 | 0.4230 | 23 | 7 | 8 | 6 | 3 | 9 | 10 | no | Strong composition and clear subject make it a good cover photo candidate. | +| 31 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.3463 | 0.7571 | 30 | 9 | 8 | 7 | 8 | 9 | 6 | no | Dynamic composition and clear subject make it strong for Ducati enthusiasts. | +| 32 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.3351 | 0.6171 | 32 | 9 | 8 | 10 | 7 | 8 | 6 | no | Strong subject clarity and color pop, but composition could be improved for Instagram's portrait crop. | +| 33 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.3271 | 0.6504 | 30 | 9 | 8 | 7 | 8 | 9 | 6 | no | Dynamic action shot with clear subject and strong color contrast, but may need slight adjustment for optimal portrait crop. | +| 34 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.3246 | 0.6932 | 10 | 3 | 6 | 7 | 2 | 8 | 9 | no | Strong colors and composition make it a good cover candidate despite lack of clear motorcycle focus. | +| 35 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.3068 | 0.6338 | 10 | 2 | 3 | 6 | 7 | 8 | 9 | no | The image has strong leading lines and a clear focal point but lacks the motorcycle subject. | +| 36 | IG martangelenos - PXL_20250310_132329430.jpg | 0.1361 | 0.4839 | 7 | 2 | 3 | 1 | 0 | 0 | 6 | no | Poor lighting and lack of motorcycle subject make this image unsuitable for a Ducati enthusiast Instagram cover. | +| 37 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.0731 | 0.4960 | 29 | 7 | 8 | 6 | 3 | 2 | 1 | no | Lacks motorcycle subject and dynamic composition needed for Ducati enthusiast IG cover. | +| 38 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.0729 | 0.6484 | 25 | 7 | 8 | 6 | 9 | 10 | 3 | no | Strong group shot with clear subject and high emotion, but composition struggles for Instagram cover crop. | +| 39 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.0724 | 0.6360 | 25 | 7 | 8 | 6 | 9 | 10 | 3 | no | Strong group shot with clear subject and high emotion, but composition struggles for Instagram cover crop. | +| 40 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.0713 | 0.5335 | 27 | 6 | 7 | 8 | 3 | 2 | 1 | no | Busy background and lack of motorcycle focus detract from cover potential. | +| 41 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0543 | 0.2334 | 25 | 6 | 7 | 8 | 9 | 10 | 3 | no | Great for scroll stop and emotion but struggles with subject clarity and cropability. | +| 42 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.0474 | 0.4312 | 16 | 2 | 3 | 6 | 1 | 4 | 0 | no | photo is not suitable for Instagram cover due to unclear subject, poor composition and lack of motorcycle focus. | + +### qwen3-vl:8b | yolo=on + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.8718 | 0.8839 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | A confident rider on a vibrant red Ducati dominates the frame against a scenic Southern California backdrop. | +| 2 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.8676 | 0.7919 | 54 | 9 | 9 | 9 | 9 | 10 | 8 | no | Dynamic Ducati rider leaning into a turn with mountain backdrop, perfect for motorcycle enthusiasts. | +| 3 | IG cali_carnivores - DSC00013.jpg | 0.8404 | 0.7401 | 53 | 9 | 9 | 10 | 8 | 9 | 8 | no | A vibrant red Ducati motorcycle with sharp details and bold color contrast against a clear sky, perfect for a motorcycle enthusiast's Instagram cover. | +| 4 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.8401 | 0.7780 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Sharp Ducati 848 motorcycle photo with bold colors and clear details, perfect for enthusiasts. | +| 5 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.8245 | 0.6484 | 54 | 8 | 10 | 9 | 10 | 9 | 8 | no | Got it, let's evaluate this photo for Instagram cover potential. | +| 6 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.8221 | 0.7571 | 51 | 8 | 9 | 9 | 8 | 9 | 8 | no | Got it, let's evaluate this motorcycle photo for Instagram cover potential. | +| 7 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.8148 | 0.6938 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | A dynamic Ducati rider leaning into a turn on a sunny Southern California track. | +| 8 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.8096 | 0.6374 | 53 | 9 | 9 | 10 | 8 | 9 | 8 | no | Vibrant motorcycle lineup with bold colors and clear skies, ideal for a Ducati fan's Instagram cover. | +| 9 | IG cali_carnivores - DSC09857.jpg | 0.8076 | 0.5921 | 54 | 9 | 9 | 9 | 9 | 10 | 8 | no | Dynamic Ducati wheelie on a desert track captures speed and adrenaline for motorcycle enthusiasts. | +| 10 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.8035 | 0.6171 | 53 | 9 | 9 | 10 | 8 | 9 | 8 | no | Two red Ducati motorcycles posed against a clear blue sky, showcasing their sleek design for motorcycle enthusiasts. | +| 11 | IG renatobo - IMG_5013.jpeg | 0.7695 | 0.5817 | 51 | 9 | 9 | 8 | 8 | 9 | 8 | no | Two Ducati riders shake hands at Chuckwalla, highlighting friendly motorcycle culture in Southern California. | +| 12 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.7603 | 0.5123 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Confident rider in red Ducati gear thumbs-up on a sunny Southern California track with multiple bikes. | +| 13 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.7539 | 0.4907 | 52 | 9 | 9 | 9 | 8 | 9 | 8 | no | Must be one concise sentence. "A confident Ducati rider on a vibrant red bike, set against a desert backdrop with mountains, perfect for motorcycle enthusiasts. | +| 14 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.7326 | 0.7310 | 44 | 8 | 8 | 7 | 6 | 7 | 8 | no | Got it, let's evaluate this image for Instagram cover potential. | +| 15 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.6644 | 0.8237 | 50 | 9 | 9 | 8 | 8 | 9 | 7 | no | Must be one concise sentence. Describe the image: "A confident Ducati rider in full gear dominates the frame against a sunny Southern California backdrop, perfect for motorcycle enthusiasts. | +| 16 | IMG_4984.jpeg | 0.6410 | 0.5421 | 41 | 8 | 9 | 9 | 8 | 9 | 8 | no | Close-up of a Ducati motorcycle's front featuring thermal technology, vibrant red/white color scheme, and workshop setting. | +| 17 | IG cali_carnivores - DSC09850.jpg | 0.6400 | 0.7612 | 49 | 8 | 9 | 8 | 9 | 8 | 7 | no | A Ducati rider leans into a turn on a desert track with mountains in the background, capturing speed and Southern California's rugged beauty. | +| 18 | DSC-5436-NaraMedia.jpeg | 0.6176 | 0.6287 | 50 | 8 | 9 | 9 | 8 | 9 | 7 | no | Need a concise sentence. "Dynamic desert motorcycle race with vibrant red Ducatis against a clear sky, capturing speed and passion for Southern California riding. | +| 19 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.6050 | 0.6932 | 47 | 9 | 8 | 7 | 8 | 8 | 7 | no | Ducati fan at a Southern California event holding a branded mug, engaging in conversation. | +| 20 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.6001 | 0.6338 | 48 | 8 | 9 | 9 | 7 | 8 | 7 | no | Chuckwalla sign over a palm-lined desert road under a clear blue sky, ideal for a Ducati enthusiast's Instagram cover. | +| 21 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.5955 | 0.4978 | 51 | 9 | 9 | 8 | 9 | 9 | 7 | no | Four motorcyclists racing on a sunlit track with mountain backdrop, capturing speed and passion. | +| 22 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.5898 | 0.5907 | 48 | 9 | 8 | 9 | 7 | 8 | 7 | no | Need a concise sentence. "A vibrant lineup of Ducati motorcycles, prominently featuring a red Panigale, arranged in a sunny Southern California setting with strong visual appeal for enthusiasts. | +| 23 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.5827 | 0.4833 | 50 | 8 | 9 | 8 | 9 | 9 | 7 | no | Got it, let's evaluate this photo for Instagram cover potential. | +| 24 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.5760 | 0.5335 | 48 | 9 | 8 | 7 | 9 | 8 | 7 | no | Ducati enthusiast smiles and makes a peace sign at a lively outdoor gathering, showcasing community spirit. | +| 25 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.5718 | 0.4768 | 49 | 9 | 8 | 7 | 9 | 9 | 7 | no | A smiling person on a scooter in Southern California during golden hour, with mountains and Ducati branding visible. | +| 26 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.5588 | 0.4230 | 49 | 8 | 9 | 8 | 9 | 8 | 7 | no | Three Ducati enthusiasts smile confidently in a sunny Southern California desert with branded caps and shirts. | +| 27 | IG martangelenos - PXL_20250310_132329430.jpg | 0.5535 | 0.4839 | 35 | 2 | 6 | 7 | 5 | 7 | 8 | no | Got it, let's evaluate this image for Instagram cover potential. | +| 28 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.5290 | 0.3764 | 47 | 7 | 9 | 8 | 7 | 9 | 7 | no | Got it, let's evaluate this motorcycle photo for Instagram cover potential. | +| 29 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.5109 | 0.5732 | 40 | 8 | 9 | 9 | 8 | 9 | 7 | no | Got it, let's evaluate this motorcycle photo for Instagram cover potential. | +| 30 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.4924 | 0.4960 | 40 | 9 | 9 | 8 | 9 | 8 | 7 | no | Ducati enthusiast poses playfully in front of a van with a race map, showcasing Southern California vibes. | +| 31 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.4397 | 0.5760 | 48 | 9 | 9 | 9 | 7 | 8 | 6 | no | A striking red Ducati motorcycle is the focal point at a sunny Southern California motorcycle event. | +| 32 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.3366 | 0.4312 | 37 | 2 | 9 | 8 | 7 | 5 | 6 | no | Got it, let's evaluate this image for Instagram cover potential. | +| 33 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.2660 | 0.2334 | 32 | 3 | 7 | 3 | 8 | 5 | 6 | no | A group of motorcycle enthusiasts gathered in a room, with a speaker addressing the crowd, but lacks visible motorcycles for the Ducati account. | +| 34 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.1245 | 0.7827 | 51 | 5 | 4 | 4 | 8 | 4 | 4 | no | Must be concise. "Dynamic Ducati rider leaning into a turn on a desert track with vibrant red against earthy tones. | +| 35 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.1166 | 0.7249 | 48 | 8 | 9 | 9 | 9 | 9 | 4 | no | Need a concise sentence. "Dynamic Ducati rider in red gear leaning into a turn on a sunny Southern California track, showcasing speed and control. | +| 36 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.1133 | 0.6504 | 48 | 9 | 9 | 9 | 8 | 9 | 4 | no | Got it, let's evaluate this motorcycle photo for Instagram cover potential. | +| 37 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.0951 | 0.6360 | 38 | 8 | 9 | 9 | 4 | 4 | 4 | no | Must be one concise sentence. "A vibrant group photo of Ducati enthusiasts on a racetrack with clear branding and sunny skies. | +| 38 | IG renatobo - IMG_5014.jpeg | 0.0922 | 0.3765 | 43 | 8 | 8 | 9 | 7 | 7 | 4 | no | A rider on a red Ducati in a sunny Chuckwalla parking lot with strong shadows and vibrant colors. | +| 39 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.0814 | 0.8766 | 24 | 4 | 4 | 4 | 4 | 4 | 4 | no | Need a concise sentence. "Dynamic Ducati rider leaning into a turn with mountain backdrop, perfect for motorcycle enthusiasts. | +| 40 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.0765 | 0.7661 | 24 | 4 | 4 | 4 | 4 | 4 | 4 | no | Got it, let's evaluate this motorcycle photo for Instagram cover potential. | +| 41 | IMG_5012.jpeg | 0.0696 | 0.4195 | 29 | 9 | 4 | 4 | 4 | 4 | 4 | no | Need a concise sentence. "Two Ducati riders pose on a sunny track with Chuckwalla sign, vibrant colors and clear focus make it ideal for motorcycle enthusiasts. | +| 42 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.0664 | 0.5413 | 24 | 4 | 4 | 4 | 4 | 4 | 4 | no | Need a concise sentence. "Sunset-lit motorcycle gathering with vibrant red Ducati and community vibes at Chuckwalla. | + +### blaifa/InternVL3_5:8b | yolo=on + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.9081 | 0.6938 | 60 | 9 | 8 | 8 | 9 | 9 | 8 | no | The image effectively captures motion and power with a clear subject, balanced lighting, vibrant colors, and dynamic composition that would likely stop fast-scrolling on Instagram. | +| 2 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.8720 | 0.5732 | 60 | 9 | 10 | 8 | 7 | 9 | 8 | no | The image effectively highlights the motorcycle with strong lighting and color contrast, conveying power and aspiration. It has a balanced composition that would likely stop fast-scrolling on Instagram. | +| 3 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.8537 | 0.5123 | 60 | 9 | 8 | 9 | 8 | 9 | 8 | no | The image effectively highlights the motorcycle and rider with strong color contrast, balanced lighting, and dynamic composition that would engage viewers on Instagram. | +| 4 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.8347 | 0.8766 | 49 | 8 | 7 | 9 | 8 | 9 | 8 | no | The image effectively highlights the motorcycle and rider with strong color contrast, dynamic composition, and balanced lighting, making it engaging for Instagram. | +| 5 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.8125 | 0.7249 | 51 | 9 | 8 | 8 | 9 | 9 | 8 | no | The image effectively highlights the motorcycle and rider with strong contrast, dynamic lighting, and vibrant colors, creating a sense of motion and power that would engage viewers on Instagram. | +| 6 | IG cali_carnivores - DSC09850.jpg | 0.8117 | 0.7612 | 50 | 9 | 8 | 8 | 8 | 9 | 8 | no | The motorcycle stands out clearly against the background with balanced lighting and vibrant colors, conveying motion and aspiration. The composition is strong for Instagram scrolling. | +| 7 | DSC-5436-NaraMedia.jpeg | 0.7720 | 0.6287 | 50 | 9 | 8 | 8 | 8 | 9 | 8 | no | The image effectively highlights the motorcycle as the focal point with strong lighting and color contrast, conveying motion and power. It has a balanced composition that would likely stop fast-scrolling on Instagram. | +| 8 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.7500 | 0.7919 | 60 | 8 | 7 | 8 | 9 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with strong contrast, dynamic lighting, and a sense of motion. The composition is engaging but may need adjustment for optimal cropping to Instagram's portrait fo | +| 9 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.7417 | 0.7571 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle with strong color contrast and dynamic composition, making it engaging for viewers. | +| 10 | IG cali_carnivores - DSC00013.jpg | 0.7376 | 0.7401 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The motorcycle is the clear focal point with vibrant color contrast against a simple background, balanced lighting, and dynamic composition that conveys power and aspiration. | +| 11 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.7210 | 0.4978 | 49 | 8 | 7 | 9 | 8 | 9 | 8 | no | The image effectively captures motion and power with a clear subject, balanced lighting, vibrant colors, and dynamic composition that would likely stop fast-scrolling on Instagram. | +| 12 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.6982 | 0.5760 | 60 | 8 | 9 | 8 | 7 | 8 | 7 | no | The image effectively highlights the motorcycle with good lighting and color contrast, but could improve composition for Instagram's crop. | +| 13 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.6899 | 0.5413 | 60 | 8 | 9 | 8 | 7 | 8 | 7 | no | The photo effectively captures the motorcycle with strong lighting and color contrast, but could improve composition for Instagram's crop. | +| 14 | IG renatobo - IMG_5014.jpeg | 0.6504 | 0.3765 | 60 | 8 | 9 | 8 | 7 | 8 | 7 | no | The image effectively highlights the motorcycle with strong lighting and color contrast, conveying motion and aspiration. It has a balanced composition that could work well as an Instagram cover. | +| 15 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.6364 | 0.8237 | 47 | 9 | 7 | 8 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with good contrast, balanced lighting, and dynamic composition that conveys motion and power. | +| 16 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.6321 | 0.8839 | 45 | 8 | 7 | 8 | 7 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with good color contrast, balanced lighting, and a dynamic composition that conveys motion and aspiration. | +| 17 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.6265 | 0.7827 | 47 | 9 | 7 | 8 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle with a clear focal point, balanced lighting, and dynamic composition that conveys motion and aspiration, making it engaging for Instagram. | +| 18 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.6254 | 0.7780 | 47 | 9 | 8 | 8 | 7 | 8 | 7 | no | The motorcycle is the clear focal point with strong contrast against the background, balanced lighting, and vibrant colors that convey power and aspiration. The composition would likely stop fast-scrolling due to its dra | +| 19 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.6132 | 0.7661 | 46 | 8 | 7 | 8 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with good color contrast, dynamic composition, and a sense of motion, making it engaging for Instagram. | +| 20 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.5948 | 0.6504 | 47 | 9 | 7 | 8 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with strong contrast, dynamic motion, and vibrant colors, making it engaging for Instagram. | +| 21 | IG cali_carnivores - DSC09857.jpg | 0.5808 | 0.5921 | 47 | 9 | 7 | 8 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with strong contrast, dynamic lighting, and a sense of motion, making it engaging for Instagram. | +| 22 | IG renatobo - IMG_5013.jpeg | 0.5783 | 0.5817 | 47 | 9 | 8 | 7 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with good lighting and composition, making it engaging for Instagram. | +| 23 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.5774 | 0.6171 | 46 | 8 | 7 | 9 | 7 | 8 | 7 | no | The motorcycle stands out with vibrant color and good lighting, but the composition could be improved for Instagram's 3:4 grid. | +| 24 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.5663 | 0.6484 | 44 | 6 | 7 | 8 | 7 | 9 | 7 | no | The image effectively captures a group dynamic with good lighting and color contrast, but lacks focus on the motorcycle as the primary subject. | +| 25 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.5633 | 0.6360 | 44 | 7 | 8 | 9 | 5 | 8 | 7 | no | The image has strong color contrast and good lighting, but the motorcycle isn't the focal point due to the group photo. | +| 26 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.5618 | 0.5907 | 45 | 8 | 7 | 9 | 6 | 8 | 7 | no | The photo effectively highlights the motorcycle with strong color contrast and clear composition, making it visually appealing for Instagram. | +| 27 | IMG_4984.jpeg | 0.5594 | 0.5421 | 46 | 8 | 7 | 9 | 7 | 8 | 7 | no | The photo effectively highlights the motorcycle with strong color contrast and a clear subject, but could benefit from better lighting and composition for Instagram's scroll-stop effect. | +| 28 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.5564 | 0.4907 | 47 | 9 | 7 | 8 | 8 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with strong color contrast, balanced lighting, and dynamic composition that conveys motion and aspiration. | +| 29 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.5543 | 0.6374 | 43 | 8 | 7 | 8 | 6 | 7 | 7 | no | The image effectively highlights the motorcycles with good color contrast and lighting, but could improve emotion conveyance and composition for Instagram's scroll-stop potential. | +| 30 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.5290 | 0.3764 | 47 | 9 | 7 | 8 | 8 | 8 | 7 | no | The image effectively highlights the motorcycles with a clear focal point, balanced lighting, and vibrant colors against the sky. The composition is engaging but may need adjustment for optimal cropping to Instagram's po | +| 31 | IMG_5012.jpeg | 0.5207 | 0.4195 | 45 | 8 | 7 | 8 | 7 | 8 | 7 | no | The image effectively highlights the motorcycle and rider with good color contrast and composition, but could benefit from improved lighting to enhance drama. | +| 32 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.5160 | 0.5335 | 60 | 7 | 8 | 6 | 5 | 7 | 6 | no | The image has good lighting and color but lacks a clear motorcycle focus, which is essential for an Instagram cover photo for a Ducati/motorcycle enthusiast account. | +| 33 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.5093 | 0.4960 | 60 | 6 | 7 | 8 | 5 | 7 | 6 | no | The image has good color contrast and lighting, but the motorcycle isn't clearly visible due to the focus on the person. It conveys some emotion with a dynamic pose. | +| 34 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.5058 | 0.4768 | 60 | 7 | 8 | 6 | 5 | 7 | 6 | no | The image has good lighting and color contrast but lacks strong emotion or dynamic composition to fully capture attention. | +| 35 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.4976 | 0.4312 | 60 | 7 | 8 | 6 | 5 | 7 | 6 | no | The image captures a moment with riders on scooters, but lacks the focus and dynamic composition typical of motorcycle photography. | +| 36 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.3766 | 0.7310 | 35 | 6 | 7 | 5 | 4 | 7 | 6 | no | The image captures a casual moment with good lighting and color contrast, but lacks strong emotion or dynamic composition typical for motorcycle enthusiasm. | +| 37 | IG martangelenos - PXL_20250310_132329430.jpg | 0.3531 | 0.4839 | 38 | 6 | 8 | 7 | 4 | 7 | 6 | no | The image has good lighting and color contrast but lacks a clear focus on the motorcycle, which is overshadowed by the truck. | +| 38 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.2013 | 0.6338 | 33 | 3 | 7 | 6 | 4 | 8 | 5 | no | The image lacks a motorcycle as the focal point, but has good lighting and composition that could stop scrolling. | +| 39 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.1896 | 0.4833 | 34 | 6 | 7 | 7 | 4 | 5 | 5 | no | The image has good lighting and color contrast but lacks a clear motorcycle focus, which is essential for a Ducati enthusiast account. | +| 40 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.1204 | 0.6932 | 51 | 6 | 7 | 5 | 4 | 5 | 4 | no | The image features a person with a clear focus, decent lighting, and moderate color contrast. However, it lacks strong emotion or dramatic composition to stand out quickly on Instagram. | +| 41 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0858 | 0.2334 | 43 | 3 | 6 | 5 | 4 | 5 | 4 | no | The image lacks a clear motorcycle focal point and has a busy background, but the lighting is balanced and there's moderate color contrast. | +| 42 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.0698 | 0.4230 | 29 | 3 | 7 | 6 | 5 | 4 | 4 | no | The image lacks a motorcycle as the focal point, but has good lighting and color contrast. It conveys friendliness but may not strongly capture motion or power typical for a Ducati enthusiast account. | + +### blaifa/InternVL3_5:4B | yolo=on + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.8358 | 0.7249 | 53 | 8 | 9 | 10 | 9 | 9 | 8 | no | The image has a clear subject with good lighting and color contrast, conveying motion and power effectively. | +| 2 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.8265 | 0.6938 | 53 | 8 | 9 | 10 | 9 | 9 | 8 | no | The image has high subject clarity and emotion, with strong color pop and good lighting, making it suitable for an Instagram cover. | +| 3 | DSC-5436-NaraMedia.jpeg | 0.7836 | 0.6287 | 51 | 9 | 8 | 8 | 9 | 9 | 8 | no | The image effectively conveys motion and power with clear subject focus, good lighting, and vibrant colors, making it suitable for an Instagram cover. | +| 4 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.7721 | 0.8839 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The image has a clear subject and strong color contrast, but could benefit from better lighting and composition for Instagram. | +| 5 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.7704 | 0.8766 | 60 | 8 | 9 | 7 | 9 | 8 | 7 | no | The image effectively captures the motorcycle and rider with clear subject clarity against a mountainous background. The lighting is excellent, highlighting the bike's colors well. The emotion of motion and power is stro | +| 6 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.7603 | 0.5123 | 52 | 9 | 8 | 10 | 8 | 9 | 8 | no | The image has high subject clarity, vibrant colors, and conveys emotion well, making it suitable for an Instagram cover. | +| 7 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.7500 | 0.7919 | 60 | 8 | 9 | 7 | 9 | 8 | 7 | no | The image has a clear subject and strong emotion, with good lighting and color contrast, making it suitable for an Instagram cover. | +| 8 | IG cali_carnivores - DSC09857.jpg | 0.7493 | 0.5921 | 49 | 9 | 7 | 8 | 9 | 8 | 8 | no | The image effectively conveys motion and power with a clear focal point, good color contrast, and strong emotion, making it suitable for an Instagram cover. | +| 9 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.7479 | 0.7827 | 60 | 7 | 9 | 8 | 8 | 8 | 7 | no | The image effectively captures motion and emotion with a clear subject against a contrasting background, making it suitable for an Instagram cover. | +| 10 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.7467 | 0.7780 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The motorcycle is the clear focal point with good color contrast and balanced lighting, but could benefit from a more dynamic angle to enhance emotion. | +| 11 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.7443 | 0.4978 | 51 | 8 | 9 | 7 | 10 | 9 | 8 | no | The image effectively captures motion and emotion with clear subject focus against a scenic background, making it suitable for an Instagram cover. | +| 12 | IG cali_carnivores - DSC09850.jpg | 0.7427 | 0.7612 | 60 | 9 | 8 | 7 | 8 | 8 | 7 | no | The image has a clear subject and good lighting, but the color contrast could be improved for better visual impact. | +| 13 | IG cali_carnivores - DSC00013.jpg | 0.7376 | 0.7401 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The motorcycle is the clear focal point with good color contrast and lighting, but could benefit from a more dynamic angle to enhance emotion. | +| 14 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.7081 | 0.6171 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The image has a clear subject and strong color contrast, but could benefit from better lighting and composition for Instagram. | +| 15 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.7018 | 0.5907 | 60 | 8 | 9 | 8 | 7 | 8 | 7 | no | The image has a clear focal point with good lighting and color contrast, but the composition could be improved for Instagram's portrait format. | +| 16 | IG renatobo - IMG_5013.jpeg | 0.6996 | 0.5817 | 60 | 7 | 9 | 8 | 8 | 8 | 7 | no | The image has good subject clarity and lighting, with vibrant colors that stand out against the background. The composition conveys emotion through the interaction between riders, but could benefit from a more dynamic an | +| 17 | IMG_4984.jpeg | 0.6901 | 0.5421 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The motorcycle is the clear focal point with good color contrast and lighting, conveying power and aspiration. | +| 18 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.6899 | 0.5413 | 60 | 8 | 9 | 7 | 8 | 8 | 7 | no | The image effectively captures the motorcycle and rider with clear subject clarity, excellent lighting during golden hour, and a balanced composition that conveys emotion and power. However, some cropping could enhance t | +| 19 | IMG_5012.jpeg | 0.6607 | 0.4195 | 60 | 8 | 7 | 9 | 8 | 8 | 7 | no | The image has a clear subject and good color contrast, but could benefit from better lighting and composition for Instagram. | +| 20 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.6414 | 0.6504 | 52 | 9 | 8 | 10 | 9 | 9 | 7 | no | The image has high subject clarity and emotion, with strong color pop, but the crop could be better for Instagram's portrait format. | +| 21 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.6364 | 0.8237 | 47 | 9 | 8 | 7 | 8 | 8 | 7 | no | The image has a clear subject and good lighting, with the motorcycle standing out against the background. The composition conveys power and motion well. | +| 22 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.6319 | 0.7661 | 48 | 8 | 7 | 9 | 9 | 8 | 7 | no | The image has a clear subject and strong emotion, with good color contrast and lighting, making it suitable for an Instagram cover. | +| 23 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.6297 | 0.7571 | 48 | 8 | 7 | 9 | 9 | 8 | 7 | no | The image effectively conveys motion and power with a clear focal point, vibrant colors contrasting against the background, and strong emotion. However, cropping could improve composition. | +| 24 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.5956 | 0.5760 | 49 | 8 | 9 | 10 | 7 | 8 | 7 | no | The image has a clear subject with good lighting and color contrast, but the composition could be improved for better emotion and crop potential. | +| 25 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.5564 | 0.4907 | 47 | 9 | 7 | 8 | 8 | 8 | 7 | no | The image has a clear focal point with good subject-to-background contrast, vibrant colors, and conveys power and aspiration, making it suitable for an Instagram cover. | +| 26 | IG renatobo - IMG_5014.jpeg | 0.5290 | 0.3765 | 47 | 8 | 9 | 8 | 7 | 8 | 7 | no | The motorcycle is clear against the background with good lighting and color contrast, but the truck slightly distracts from the main subject. | +| 27 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.5158 | 0.4768 | 43 | 7 | 8 | 6 | 7 | 8 | 7 | no | The image has good subject clarity and lighting but could improve color pop and emotion for a stronger Instagram cover. | +| 28 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.4582 | 0.4312 | 38 | 7 | 6 | 5 | 7 | 6 | 7 | no | The image has a clear subject and good lighting, but the background is busy, reducing overall impact. | +| 29 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.4297 | 0.6374 | 45 | 8 | 7 | 9 | 7 | 8 | 6 | no | The image has clear subject clarity and strong color pop, but the composition could be improved for Instagram's portrait format. | +| 30 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.4182 | 0.5732 | 45 | 7 | 9 | 8 | 7 | 8 | 6 | no | The image has good lighting and color pop, but the subject could be more centered for better composition. | +| 31 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.3897 | 0.3764 | 46 | 7 | 9 | 8 | 8 | 8 | 6 | no | The image has strong subject clarity and good lighting, but the composition could be improved for Instagram's portrait format. | +| 32 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.1953 | 0.6932 | 30 | 3 | 7 | 6 | 5 | 4 | 5 | no | The image focuses on people rather than a motorcycle, reducing subject clarity and emotional impact. | +| 33 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.0801 | 0.7310 | 27 | 3 | 7 | 6 | 4 | 5 | 2 | no | The image focuses on a person with a coffee mug, lacking clear motorcycle subject and strong emotion. | +| 34 | IG martangelenos - PXL_20250310_132329430.jpg | 0.0795 | 0.4839 | 33 | 6 | 8 | 7 | 4 | 5 | 3 | no | The image has good lighting and color pop, but the subject is not a motorcycle, which may not align with the account's focus. | +| 35 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.0764 | 0.6484 | 27 | 3 | 7 | 6 | 5 | 4 | 2 | no | The image has a group of people, not a motorcycle, so it doesn't meet the subject clarity criteria for a Ducati account. | +| 36 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.0713 | 0.5335 | 27 | 3 | 7 | 4 | 5 | 6 | 2 | no | The image has a busy background and the subject is not clearly focused on a motorcycle, making it less suitable for an Instagram cover. | +| 37 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.0698 | 0.4230 | 29 | 3 | 7 | 6 | 5 | 4 | 4 | no | The image focuses on people rather than a motorcycle, reducing subject clarity and making it less suitable for a Ducati enthusiast account. | +| 38 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.0696 | 0.4960 | 27 | 3 | 7 | 5 | 4 | 6 | 2 | no | The image has a clear subject but lacks strong emotion and composition for an Instagram cover. | +| 39 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.0690 | 0.4833 | 27 | 3 | 7 | 6 | 4 | 5 | 2 | no | The image has a group of people in front of a building, which may not clearly highlight the motorcycle aspect for an Instagram cover focused on Ducati/motorcycle enthusiasts. | +| 40 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.0688 | 0.6338 | 23 | 2 | 7 | 6 | 3 | 4 | 1 | no | The image lacks a clear motorcycle subject, focusing instead on an entrance sign with palm trees, making it less suitable for a Ducati/motorcycle enthusiast Instagram cover. | +| 41 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.0671 | 0.6360 | 22 | 3 | 7 | 5 | 4 | 2 | 1 | no | The image features a group of people, not a motorcycle, making it less suitable for a Ducati enthusiast account. | +| 42 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0508 | 0.2334 | 23 | 3 | 6 | 4 | 5 | 2 | 3 | no | The image lacks a clear motorcycle subject and has poor lighting, making it less suitable for an Instagram cover. | + +### openbmb/minicpm-v4.5:8b | yolo=on + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.9348 | 0.7827 | 60 | 9 | 7 | 10 | 8 | 9 | 8 | no | Dynamic composition, strong color contrast, and clear focal point make this a compelling Instagram cover. | +| 2 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.9175 | 0.7249 | 60 | 9 | 8 | 10 | 9 | 9 | 8 | no | Dynamic composition, strong color contrast, and clear subject make this a compelling Instagram cover. | +| 3 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.9047 | 0.8766 | 55 | 9 | 8 | 10 | 9 | 10 | 9 | no | Dynamic composition with clear subject and vibrant colors makes it ideal for Ducati enthusiasts. | +| 4 | DSC-5436-NaraMedia.jpeg | 0.8886 | 0.6287 | 60 | 9 | 8 | 10 | 9 | 9 | 8 | no | Dynamic composition, strong color contrast, and clear subject make this a compelling Instagram cover. | +| 5 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.7445 | 0.5760 | 49 | 9 | 10 | 9 | 5 | 8 | 8 | no | Strong subject clarity and lighting, but lower emotion due to static pose. | +| 6 | IG cali_carnivores - DSC09857.jpg | 0.6326 | 0.5921 | 39 | 9 | 7 | 6 | 10 | 9 | 8 | no | Dynamic action shot with clear subject and strong composition for Ducati enthusiasts. | +| 7 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.6204 | 0.8237 | 32 | 9 | 7 | 8 | 5 | 8 | 10 | no | Strong subject clarity and color contrast make this a compelling cover photo for Ducati enthusiasts. | +| 8 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.6153 | 0.5732 | 38 | 9 | 10 | 7 | 6 | 8 | 8 | no | Strong subject clarity and lighting make this a compelling cover photo for Ducati enthusiasts. | +| 9 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.6109 | 0.7919 | 32 | 9 | 8 | 6 | 10 | 9 | 8 | no | Dynamic composition with clear subject and strong color contrast makes it ideal for Ducati enthusiast accounts. | +| 10 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.6087 | 0.5123 | 39 | 9 | 8 | 8 | 6 | 10 | 8 | no | Strong subject clarity and color contrast make it ideal for Ducati enthusiast accounts. | +| 11 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.6067 | 0.7780 | 32 | 9 | 7 | 8 | 6 | 8 | 10 | no | Strong composition with clear subject and vibrant colors makes it ideal for Ducati enthusiasts. | +| 12 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.6005 | 0.7571 | 32 | 8 | 7 | 9 | 10 | 9 | 8 | no | Dynamic composition and vibrant colors make this a strong cover candidate. | +| 13 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.5815 | 0.6938 | 32 | 9 | 8 | 10 | 9 | 9 | 8 | no | Dynamic composition, strong color contrast, and clear subject make this a compelling Instagram cover. | +| 14 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.5672 | 0.4907 | 36 | 9 | 7 | 8 | 6 | 8 | 8 | no | Strong subject clarity and color contrast, but could enhance emotion with lower angle. | +| 15 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.5505 | 0.5907 | 32 | 9 | 8 | 10 | 6 | 9 | 8 | no | Strong composition with clear subject and vibrant colors makes it ideal for Ducati enthusiasts. | +| 16 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.5227 | 0.4978 | 32 | 8 | 9 | 7 | 10 | 9 | 8 | no | Dynamic composition and strong leading lines make this a compelling cover photo for Ducati enthusiasts. | +| 17 | IG renatobo - IMG_5014.jpeg | 0.4863 | 0.3765 | 32 | 8 | 6 | 9 | 5 | 8 | 10 | no | Dynamic composition with clear subject and strong color contrast makes this a compelling Instagram cover. | +| 18 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.4748 | 0.6171 | 35 | 8 | 7 | 9 | 6 | 8 | 7 | no | Strong subject clarity and color pop, but could improve emotion with lower angle or motion blur. | +| 19 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.3411 | 0.6504 | 32 | 9 | 7 | 8 | 8 | 10 | 6 | no | Dynamic composition and clear subject make this a strong cover photo, but limited lead room may affect portrait cropping. | +| 20 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.3068 | 0.6338 | 10 | 2 | 3 | 6 | 7 | 8 | 9 | no | The image has strong leading lines and a clear focal point but lacks the motorcycle subject. | +| 21 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.0961 | 0.5413 | 41 | 8 | 9 | 6 | 5 | 10 | 3 | no | Strong lighting and clarity make it a scroll stopper, but composition struggles with cropability and background busyness. | +| 22 | IMG_4984.jpeg | 0.0944 | 0.5421 | 40 | 8 | 6 | 9 | 4 | 10 | 3 | no | Strong colors and clear subject but composition struggles with cropability and emotion | +| 23 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.0836 | 0.7310 | 29 | 7 | 6 | 3 | 4 | 5 | 2 | no | Weak subject focus and composition; lacks motorcycle or dynamic elements. | +| 24 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.0829 | 0.6374 | 31 | 8 | 7 | 6 | 5 | 4 | 3 | no | Needs better composition and lighting to be a strong Instagram cover | +| 25 | IG renatobo - IMG_5013.jpeg | 0.0804 | 0.5817 | 31 | 9 | 7 | 6 | 4 | 3 | 2 | no | Strong subject clarity and lighting but lacks dynamic emotion and composition for Instagram | +| 26 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.0764 | 0.6484 | 27 | 7 | 8 | 6 | 9 | 10 | 3 | no | Strong group shot with clear subject and vibrant colors, but composition struggles for Instagram cover due to wide angle and lack of leading lines. | +| 27 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.0733 | 0.4230 | 31 | 8 | 7 | 6 | 5 | 4 | 3 | no | Lacks motorcycle focus; composition struggles for Instagram cover | +| 28 | IMG_5012.jpeg | 0.0731 | 0.4195 | 31 | 8 | 7 | 6 | 5 | 4 | 3 | no | Good subject clarity and lighting but lacks strong emotion or composition to stand out on Instagram | +| 29 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.0702 | 0.4312 | 29 | 7 | 6 | 5 | 3 | 2 | 1 | no | photo lacks motorcycle focus, weak lighting and composition for Instagram cover | +| 30 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.0695 | 0.7661 | 20 | 8 | 7 | 6 | 5 | 4 | 3 | no | The image is clear but lacks strong composition and emotion for Instagram cover potential. | +| 31 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.0687 | 0.4768 | 27 | 7 | 6 | 3 | 4 | 5 | 2 | no | The subject is clear but the background competes for attention; lighting and color are average, emotion low due to stationary pose. | +| 32 | IG cali_carnivores - DSC00013.jpg | 0.0683 | 0.7401 | 20 | 8 | 7 | 6 | 5 | 4 | 3 | no | Strong subject but lacks dynamic emotion and optimal composition for Instagram's grid | +| 33 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.0679 | 0.6932 | 21 | 7 | 6 | 8 | 5 | 4 | 3 | no | Lacks motorcycle focus; strong colors and text but composition misses key elements for cover | +| 34 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.0591 | 0.4960 | 21 | 8 | 7 | 6 | 5 | 4 | 3 | no | Busy background distracts from the main subject; composition struggles to work in portrait format. | +| 35 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.0573 | 0.8839 | 10 | 8 | 7 | 6 | 5 | 4 | 3 | no | Strong subject clarity and color contrast but lacks dynamic emotion and optimal composition for Instagram's grid | +| 36 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.0519 | 0.3764 | 20 | 9 | 7 | 6 | 5 | 4 | 3 | no | Strong subject clarity and lighting but lacks dynamic emotion and scroll-stopping composition for Instagram cover. | +| 37 | IG cali_carnivores - DSC09850.jpg | 0.0518 | 0.7612 | 10 | 9 | 6 | 5 | 4 | 3 | 2 | no | Subject clarity is high, but lighting and composition lack dynamism for Instagram cover. | +| 38 | IG martangelenos - PXL_20250310_132329430.jpg | 0.0498 | 0.4839 | 16 | 2 | 3 | 6 | 4 | 1 | 0 | no | Image is of a truck at sunset with low clarity and composition issues for Instagram cover. | +| 39 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.0461 | 0.6360 | 10 | 7 | 9 | 3 | 5 | 4 | 2 | no | Strong group shot with clear subject and good lighting, but lacks color contrast and dynamic composition for Instagram | +| 40 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0455 | 0.2334 | 20 | 3 | 6 | 2 | 4 | 5 | 1 | no | Busy background and small subject detract from motorcycle focus; better composition needed for Instagram | +| 41 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.0415 | 0.5335 | 10 | 7 | 9 | 3 | 5 | 4 | 2 | no | Focus on the person with a Ducati cap and burger at an event, but lacks motorcycle focus for Instagram cover. | +| 42 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.0393 | 0.4833 | 10 | 7 | 9 | 6 | 4 | 3 | 2 | no | Busy background detracts from subject; lighting and color are strong but composition is weak for Instagram | + +### claude-sonnet-4-6 | scorer=claude + +| Rank | Image | Final | Tech | Vision | Subject | Light | Color | Emotion | Scroll | Crop | Failed | One line | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---| +| 1 | AB Group - Session 1 (Turn 16 Entry) - CVR_0148_Mar1025_815AM_CaliPhoto.jpg | 0.6968 | 0.8766 | 77 | 8 | 7 | 9 | 8 | 8 | 6 | no | Red Ducati pops brilliantly against the blue-grey desert mountain backdrop but the bottom-left placement and limited lead room ahead of the bike hurt portrait crop potential for the Instagram grid. | +| 2 | IG naramedia_official - DSC-5365-NaraMedia.jpeg | 0.6790 | 0.7780 | 77 | 9 | 7 | 8 | 8 | 8 | 6 | no | Aggressive low-angle worm's-eye view makes the 848 Corse look menacing and powerful, but harsh midday light flattens the chrome and the near-centered composition with symmetrical trailers boxing both sides kills portrait crop flexibility. | +| 3 | IG kamiumitv - IMG-3981-Bao.jpeg | 0.6501 | 0.6171 | 77 | 8 | 7 | 9 | 8 | 8 | 6 | no | Two red Ducatis (Streetfighter V4 + Panigale V4) against clean SoCal blue sky deliver strong color contrast and aspirational dual-bike energy, but the overlapping subjects and center-heavy composition hurt crop flexibility for the 4x5 grid. | +| 4 | AB Group - Session 2 (Turn 11) - CVR_1047_Mar1025_908AM_CaliPhoto.jpg | 0.6499 | 0.6938 | 75 | 8 | 6 | 8 | 9 | 8 | 6 | no | Aggressive lean angle and vivid Ducati red create instant scroll-stopping energy, but flat midday light and centered composition with tight lead room limit portrait crop flexibility and Instagram grid impact. | +| 5 | IG cali_carnivores - DSC00013.jpg | 0.6163 | 0.7401 | 47 | 9 | 7 | 8 | 8 | 8 | 7 | no | Low-angle 3/4 front shot makes the Panigale V4 look menacing and powerful, but flat midday light robs the chrome and red fairings of the drama that golden hour would deliver. | +| 6 | 1-Around the Pits - CVR_0792_Mar1025_832AM_CaliPhoto.jpg | 0.6135 | 0.8839 | 43 | 8 | 6 | 8 | 7 | 7 | 7 | no | Red Ducati Panigale pops hard against the desert-blue backdrop but flat midday light flattens drama — golden hour would elevate this from solid to scroll-stopping. | +| 7 | AB Group - Session 4 (Turn 16) - CVR_6545_Mar1025_1122AM_CaliPhoto.jpg | 0.6126 | 0.7249 | 47 | 8 | 6 | 9 | 9 | 8 | 7 | no | Aggressive corner lean with red Ducati suit against desert scrub and mountain backdrop delivers strong emotion and color contrast, but flat midday light and bottom-center placement limit scroll-stop hierarchy and portrait crop flexibility | +| 8 | AB Group - Session 1 (Turn 16 Entry) - CVR_0230_Mar1025_817AM_CaliPhoto.jpg | 0.5914 | 0.7919 | 43 | 8 | 6 | 7 | 8 | 7 | 7 | no | Low aggressive cornering angle and red Ducati against hazy mountain backdrop create strong motorsport energy, but flat midday backlight flattens depth and reduces chrome pop that golden-hour would deliver. | +| 9 | 1-Around the Pits - CVR_0098_Mar1025_802AM_CaliPhoto.jpg | 0.5524 | 0.8237 | 38 | 8 | 6 | 6 | 5 | 6 | 7 | no | Strong subject presence with a sharp Ducati Panigale V4 and full Alpinestars kit, but flat midday light, dead-center framing, and a cluttered pit-lane background drain the drama needed for a compelling cover. | +| 10 | 2-Group Photo - Raffle - CVR_7677_Mar1025_1249PM_CaliPhoto.jpg | 0.5114 | 0.7310 | 36 | 7 | 5 | 5 | 6 | 6 | 7 | no | Clever 'Official Pit Crew' mug concept has Ducati community charm but lacks any motorcycle presence, flat midday lighting, and muted palette limit scroll-stop impact for a bike-focused account | +| 11 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-02 HDR.jpeg | 0.4724 | 0.4907 | 38 | 7 | 6 | 7 | 5 | 6 | 7 | no | Strong Ducati Corse livery and matching Dainese kit create visual cohesion, but flat midday light, eye-level camera angle, and background clutter from other riders undercut the aspirational power this bike deserves | +| 12 | IG naramedia_official - DSC-5897-NaraMedia.jpeg | 0.4461 | 0.6504 | 47 | 8 | 7 | 9 | 9 | 8 | 6 | no | Aggressive lean angle and vivid Ducati red against neutral grey tarmac deliver strong emotion and color pop, but the wide landscape frame with centered subject and tight lead room ahead of the bike make a clean 4x5 portrait crop challenging without losing the front wheel or killing the motion energy. | +| 13 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-12 HDR.jpeg | 0.3372 | 0.5123 | 35 | 7 | 5 | 7 | 5 | 5 | 6 | no | Red Hypermotard SP with matching leathers has strong brand color cohesion but flat midday light, eye-level static pose, and cluttered background with second rider undercut scroll-stop potential. | +| 14 | IMG_4984.jpeg | 0.3356 | 0.5421 | 34 | 7 | 5 | 6 | 5 | 5 | 6 | no | Ducati Panigale front shot with tire warmers is a niche garage detail photo — strong bike ID but flat indoor lighting, dead-center composition, and the tire warmer controller dominates the foreground, killing aspirational emotion for a cover shot. | +| 15 | IMG_5012.jpeg | 0.3345 | 0.4195 | 37 | 7 | 6 | 7 | 5 | 6 | 6 | no | Chuckwalla backdrop and Ducati branding add context but flat midday light, standing eye-height angle, and two competing bikes dilute the hero shot impact needed to stop scrollers. | +| 16 | IG - renatobo - March 10 DROC Track Day-28.jpeg | 0.3180 | 0.4833 | 33 | 4 | 7 | 5 | 6 | 5 | 6 | no | Fun group energy at Chuckwalla with beautiful golden-hour sky, but no motorcycle visible and the subject is a group of people — strong club moment but weak fit for a motorcycle-focused Instagram cover. | +| 17 | IG - renatobo - March 10 DROC Track Day-58 HDR.jpeg | 0.3060 | 0.5335 | 30 | 6 | 5 | 4 | 5 | 4 | 6 | no | No motorcycle present — BBQ social snapshot with zero riding/bike content makes it unsuitable for a Ducati enthusiast cover photo | +| 18 | IG - renatobo - March 10 DROC Track Day-26 HDR.jpeg | 0.2888 | 0.4768 | 29 | 6 | 7 | 4 | 3 | 3 | 6 | no | A fun candid of a DROC member on an e-scooter — not a motorcycle shot, low drama, monochromatic black palette blends into pavement, and eye-level framing kills any sense of power or aspiration. | +| 19 | IG desmo.donna - IMG-1151-Donna.jpeg | 0.2791 | 0.4230 | 29 | 5 | 6 | 5 | 3 | 4 | 6 | no | Three smiling fans at a Ducati track day is warm and community-driven but lacks a motorcycle as the hero subject, low camera angle drama, or scroll-stopping visual tension needed for a strong cover. | +| 20 | C Group - Session 2 Back Straight Speed Pans - CVR_2850_Mar1025_946AM_CaliPhoto.jpg | 0.2479 | 0.7661 | 41 | 8 | 6 | 8 | 7 | 7 | 5 | no | Strong red-on-desert panning shot with good motion blur, but harsh midday light and wide landscape framing hurt portrait crop potential for Instagram cover use. | +| 21 | IG naramedia_official - DSC-5992-NaraMedia.jpeg | 0.2469 | 0.7571 | 41 | 7 | 6 | 8 | 8 | 7 | 5 | no | Red Ducati Panigale leaned hard into a desert track corner delivers strong emotion and color pop, but flat midday light, centered subject, and wide landscape framing hurt thumbnail impact and portrait crop potential. | +| 22 | C Group - Session 2 Back Straight Speed Pans - CVR_2628_Mar1025_943AM_CaliPhoto.jpg | 0.2414 | 0.7827 | 39 | 7 | 6 | 8 | 7 | 6 | 5 | no | Strong red-on-desert color contrast and genuine track action work well, but flat midday light, centered composition with limited lead room, and a cluttered mid-ground hurt Instagram cover potential. | +| 23 | IG guardiansvoice - IMG-4129-Delenian.jpeg | 0.2283 | 0.5413 | 42 | 6 | 9 | 7 | 7 | 8 | 5 | no | Stunning Chuckwalla golden-hour sunset with a strong Aprilia foreground creates real scroll-stop drama, but the crowded multi-bike paddock scene splits focal attention and the wide landscape orientation crops awkwardly to 4x5 without losing either the sunset or a clean hero bike. | +| 24 | 1-Around the Pits - 1-Around the Pits - CVR_0025_Mar1025_728AM_CaliPhoto.jpg | 0.2213 | 0.5907 | 39 | 7 | 7 | 8 | 6 | 6 | 5 | no | Strong Ducati red pops well and the lineup creates depth, but the busy pit-lane clutter, flat angle, and trailer/tent background prevent this from being a scroll-stopper despite great subject matter. | +| 25 | IG cali_carnivores - DSC09857.jpg | 0.2173 | 0.5921 | 38 | 7 | 5 | 6 | 8 | 7 | 5 | no | Strong wheelie energy and desert track setting sell SoCal Ducati culture, but harsh midday backlight silhouettes the red bodywork and the wide 16:9 frame leaves awkward dead space when cropped to portrait. | +| 26 | IG luckie.moto - 20250310-075123-luckie.moto.jpg | 0.2139 | 0.6374 | 36 | 6 | 7 | 7 | 5 | 6 | 5 | no | Strong lineup of superbikes with nice golden-hour light and color variety, but static parked row composition, standing eye-level angle, and cluttered background with tents/trucks kill the aspirational energy a Ducati account needs. | +| 27 | DSC-5436-NaraMedia.jpeg | 0.2130 | 0.6287 | 36 | 6 | 5 | 7 | 7 | 6 | 5 | no | Three Ducatis on a desert straight creates energy, but flat midday light, split focal attention between two lead bikes, and a cluttered horizon kill thumbnail impact for a cover shot. | +| 28 | IG desmo.donna - IMG-1080-Donna.jpeg | 0.1952 | 0.5760 | 33 | 6 | 7 | 7 | 4 | 4 | 5 | no | The red Ducati Panigale pops nicely in golden-hour light but the standing-height perspective, two people conversing mid-frame, and cluttered pit-lane background kill the aspiration factor — shoot from tank level, isolate the bike, and remove the human chatter for a scroll-stopping cover. | +| 29 | IG renatobo - IMG_5013.jpeg | 0.1917 | 0.5817 | 32 | 6 | 5 | 6 | 5 | 5 | 5 | no | A genuine Chuckwalla track-day moment between two Ducati riders has community appeal, but harsh midday flat lighting, eye-level camera height, centered composition, and a cluttered background prevent this from stopping the scroll as a cover image. | +| 30 | IG desmo.donna - IMG-1015-Donna.jpeg | 0.1868 | 0.5732 | 31 | 5 | 7 | 6 | 4 | 4 | 5 | no | Golden-hour light at Chuckwalla adds atmosphere but the wide, eye-level shot with multiple bikes, people, and tent signage creates a cluttered scene that lacks a clear hero subject and won't stop a scroll. | +| 31 | 2-Group Photo - Raffle - CVR_7682_Mar1025_1249PM_CaliPhoto.jpg | 0.1830 | 0.6932 | 27 | 6 | 5 | 4 | 4 | 3 | 5 | no | Community/event candid with good brand presence but no motorcycle visible — strong for club social content, weak as a scroll-stopping cover for a motorcycle enthusiast account | +| 32 | IG - renatobo - lineup 1st session - March 10 DROC Track Day-01 HDR.jpeg | 0.1824 | 0.3764 | 35 | 5 | 6 | 7 | 6 | 6 | 5 | no | Strong Ducati brand energy and desert backdrop have potential, but crowded grid lineup with no single focal hero and flat midday light prevents this from stopping the scroll. | +| 33 | IG guardiansvoice - IMG-4160-Delenian.jpeg | 0.1787 | 0.4960 | 31 | 6 | 6 | 5 | 5 | 4 | 5 | no | Fun track-day energy with DROC branding but no motorcycle in frame kills it for a Ducati enthusiast account — the map and pose are engaging but this works better as a story or carousel slide than a cover. | +| 34 | IG renatobo - IMG_5014.jpeg | 0.1743 | 0.3765 | 33 | 6 | 5 | 6 | 5 | 6 | 5 | no | Chuckwalla track context adds credibility but harsh midday backlight silhouettes the Ducati, killing color and detail — reshoot at golden hour from a low 3/4 angle to unlock the V4's visual drama. | +| 35 | IG cali_carnivores - DSC09850.jpg | 0.0920 | 0.7612 | 33 | 6 | 5 | 6 | 7 | 5 | 4 | no | Compelling desert track setting and slight wheelie energy, but small centered subject, flat midday light, and wide landscape framing make portrait cropping awkward and thumbnail readability weak for a cover shot. | +| 36 | C Group - Session 3 (Turn 9 and 8) - CVR_4982_Mar1025_1040AM_CaliPhoto.jpg | 0.0802 | 0.4978 | 33 | 5 | 5 | 6 | 7 | 6 | 4 | no | Compelling multi-bike track action at what appears to be Thermal/Willow Springs, but four competing subjects split visual hierarchy, flat midday light kills drama, and the lead BMW M1000RR pair dominates while the intended Ducati subject is small and peripheral — no single hero bike owns the frame. | +| 37 | IG m92663m - IMG-6019-Mark Momot.jpeg | 0.0740 | 0.6338 | 26 | 2 | 6 | 6 | 3 | 5 | 4 | no | No motorcycle present — entry gate sign with palm-lined desert road has mild scroll-stop appeal but zero relevance as a motorcycle hero shot | +| 38 | caliphotovideo - Group picture - DROC Track Day March 10 202500002.jpg | 0.0729 | 0.6484 | 25 | 3 | 5 | 4 | 6 | 4 | 3 | no | Large group shot on a desert track works as a club event photo but lacks a clear motorcycle focal point, making it a poor Instagram cover — better suited as a carousel slide than a profile hero image. | +| 39 | caliphotovideo - Group picture - DROC Track Day March 10 202500001.jpg | 0.0654 | 0.6360 | 21 | 3 | 5 | 4 | 4 | 3 | 2 | no | Large group club photo at a desert track tells a great community story but lacks a dominant motorcycle focal point, flat midday light, and resists portrait crop — better suited for a Facebook event recap than an Instagram cover. | +| 40 | IG martangelenos - PXL_20250310_132329430.jpg | 0.0603 | 0.4839 | 22 | 2 | 6 | 4 | 3 | 4 | 3 | no | No motorcycle present — this is a Chuckwalla Valley Raceway timing trailer at dusk, making it irrelevant for a Ducati/moto enthusiast feed regardless of the atmospheric sunset backdrop. | +| 41 | IG ocredevil - DROC-Track-Day-3-10-2-filippo pagan.jpeg | 0.0544 | 0.4312 | 20 | 3 | 6 | 4 | 2 | 2 | 3 | no | Two people on kick scooters in a parking lot — no motorcycle as subject, no drama, no speed, nothing aspirational for a Ducati enthusiast account. | +| 42 | 1-Around the Pits - 1-Around the Pits - CVR_0012_Mar1025_702AM_CaliPhoto.jpg | 0.0385 | 0.2334 | 16 | 2 | 4 | 3 | 2 | 2 | 3 | no | No motorcycle visible — this is an indoor rider briefing/meeting scene that lacks any of the motion, metal, or aspiration needed for a compelling Ducati Instagram cover. | From 6c4178e02a1daa190c934e591e6bb54295b4a5c4 Mon Sep 17 00:00:00 2001 From: Renato Bonomini <4005901+renatobo@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:26:21 +0000 Subject: [PATCH 3/6] fix: suppress YOLO banner/warnings and install all extras by default Silence ultralytics startup banner and task-guessing warning by setting YOLO_VERBOSE=false, logging level, and explicit task="detect". Update make install-dev to include all scorer extras (clip, claude, yolo). Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 +-- Makefile | 2 +- src/pickinsta/ig_image_selector.py | 5 ++++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6da569c..b3e20e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,10 +69,9 @@ make pre-commit-install # Install pre-commit hooks (ruff + formatting on git com ```bash python3 -m venv .venv && source .venv/bin/activate make install-dev -python -m pip install -e ".[clip,claude,yolo]" ``` -Optional scorers can be installed individually: `.[clip]`, `.[claude]`, `.[yolo]`. +This installs dev tools and all scorer extras (clip, claude, yolo). To install only specific scorers: `pip install -e ".[dev,claude]"`, etc. ### Running the Pipeline diff --git a/Makefile b/Makefile index 3f1bf88..08c44af 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ PIP ?= $(PYTHON) -m pip install-dev: $(PIP) install --upgrade pip setuptools - $(PIP) install -e ".[dev]" + $(PIP) install -e ".[dev,clip,claude,yolo]" test: $(PYTHON) -m pytest diff --git a/src/pickinsta/ig_image_selector.py b/src/pickinsta/ig_image_selector.py index 6a77511..3be8af9 100644 --- a/src/pickinsta/ig_image_selector.py +++ b/src/pickinsta/ig_image_selector.py @@ -2016,10 +2016,13 @@ def _load_yolo_model(debug: bool = False): if _YOLO_MODEL is not None: return _YOLO_MODEL + import logging + os.environ.setdefault("YOLO_VERBOSE", "false") + logging.getLogger("ultralytics").setLevel(logging.WARNING) from ultralytics import YOLO model_path = resolve_yolo_model_path(debug=debug) - _YOLO_MODEL = YOLO(str(model_path)) + _YOLO_MODEL = YOLO(str(model_path), task="detect", verbose=False) return _YOLO_MODEL From b0a875360b5ef5e93223466b3112d8175dee2b68 Mon Sep 17 00:00:00 2001 From: Renato Bonomini <4005901+renatobo@users.noreply.github.com> Date: Wed, 18 Mar 2026 05:53:28 +0000 Subject: [PATCH 4/6] feat: burst dedup, technical score cache, Claude cost estimate, and output improvements - Add histogram correlation pass to dedup burst shots (same scene, shifted framing) - Cache technical scores per image (keyed on mtime) to speed up re-runs - Downsize images to 1024px/q75 before sending to Claude (reduces API cost) - Show estimated Claude API cost before scoring (accounting for cached images) - Add --rescore flag to force re-evaluation ignoring cache - Add --work flag to put intermediate files in a separate directory - Output three variants: full (original), hd (1920px), cropped (1080x1440 IG) - Ducati brand priority in Claude/Ollama scoring prompts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pickinsta/ig_image_selector.py | 226 ++++++++++++++++++++++++----- tests/test_main.py | 2 + 2 files changed, 195 insertions(+), 33 deletions(-) diff --git a/src/pickinsta/ig_image_selector.py b/src/pickinsta/ig_image_selector.py index 3be8af9..c6379e0 100644 --- a/src/pickinsta/ig_image_selector.py +++ b/src/pickinsta/ig_image_selector.py @@ -62,7 +62,7 @@ DEFAULT_OLLAMA_MODEL = "qwen2.5vl:7b" DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434" CLAUDE_MIN_CROP4X5_OUTPUT_SCORE = 6.0 -DEFAULT_ACCOUNT_CONTEXT = "motorcycle enthusiast account" +DEFAULT_ACCOUNT_CONTEXT = "Ducati motorcycle enthusiast account" ACCOUNT_CONTEXT_ENV_VAR = "PICKINSTA_ACCOUNT_CONTEXT" PICKINSTA_OLLAMA_BASE_URL_ENV_VAR = "PICKINSTA_OLLAMA_BASE_URL" PICKINSTA_OLLAMA_MODEL_ENV_VAR = "PICKINSTA_OLLAMA_MODEL" @@ -402,8 +402,13 @@ def load_claude_score_from_file_cache( source_sha256: str, model: str, prompt_sha256: str, + strict_model: bool = True, ) -> Optional[dict]: - """Load cached Claude score for a specific source file if still valid.""" + """Load cached Claude score for a specific source file if still valid. + + Args: + strict_model: When False, accept cached scores from any model (skip model check). + """ cache_file = claude_cache_file_for_source(source_path) if not cache_file.exists(): return None @@ -417,7 +422,7 @@ def load_claude_score_from_file_cache( return None if payload.get("source_sha256") != source_sha256: return None - if payload.get("model") != model: + if strict_model and payload.get("model") != model: return None if payload.get("prompt_sha256") != prompt_sha256: return None @@ -609,13 +614,34 @@ def resize_for_processing( # --------------------------------------------------------------------------- +HIST_DEDUP_THRESHOLD = 0.92 # histogram correlation; higher = stricter +HIST_DEDUP_THUMB_SIZE = (256, 256) + + +def _image_histogram(img_path: Path) -> Optional[np.ndarray]: + """Compute a normalized color histogram for burst-shot dedup.""" + try: + img = cv2.imread(str(img_path)) + if img is None: + return None + thumb = cv2.resize(img, HIST_DEDUP_THUMB_SIZE) + hist = cv2.calcHist([thumb], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]) + cv2.normalize(hist, hist) + return hist + except Exception: + return None + + def deduplicate(images: list[Path], threshold: int = DEDUP_THRESHOLD) -> list[Path]: """ - Remove near-duplicate images using perceptual hashing. + Remove near-duplicate images using perceptual hashing, + then a second pass using histogram correlation to catch burst shots + with shifted framing that hash-dedup misses. Returns one representative from each group of similar images. """ import imagehash + # Pass 1: perceptual hash dedup hash_groups: dict[imagehash.ImageHash, list[Path]] = {} for img_path in images: @@ -633,15 +659,43 @@ def deduplicate(images: list[Path], threshold: int = DEDUP_THRESHOLD) -> list[Pa print(f" ⚠ Hash failed for {img_path.name}: {e}") # From each group, pick the largest file (usually highest quality) - unique = [] - duplicates_removed = 0 + hash_unique = [] + hash_removed = 0 for group in hash_groups.values(): best = max(group, key=lambda p: p.stat().st_size) + hash_unique.append(best) + hash_removed += len(group) - 1 + + # Pass 2: histogram correlation to catch burst shots + hist_groups: list[list[tuple[Path, np.ndarray]]] = [] + for img_path in hash_unique: + hist = _image_histogram(img_path) + if hist is None: + hist_groups.append([(img_path, np.array([]))]) + continue + placed = False + for group in hist_groups: + ref_hist = group[0][1] + if ref_hist.size > 0: + corr = cv2.compareHist(ref_hist, hist, cv2.HISTCMP_CORREL) + if corr >= HIST_DEDUP_THRESHOLD: + group.append((img_path, hist)) + placed = True + break + if not placed: + hist_groups.append([(img_path, hist)]) + + unique = [] + hist_removed = 0 + for group in hist_groups: + paths = [p for p, _ in group] + best = max(paths, key=lambda p: p.stat().st_size) unique.append(best) - duplicates_removed += len(group) - 1 + hist_removed += len(group) - 1 print( - f" āœ… Dedup: {len(images)} → {len(unique)} unique ({duplicates_removed} duplicates removed)" + f" āœ… Dedup: {len(images)} → {len(unique)} unique " + f"({hash_removed} hash dupes, {hist_removed} burst dupes removed)" ) return unique @@ -959,14 +1013,53 @@ def _print_score_distribution(results: list[ImageScore]) -> None: print(" ā””" + "─" * (bar_width + 18) + "ā”˜") +def _tech_cache_path(img_path: Path) -> Path: + """Return the technical score cache file path for an image.""" + return img_path.with_suffix(img_path.suffix + ".techscore.json") + + +def _load_tech_cache(img_path: Path) -> Optional[dict]: + """Load cached technical score if still valid (mtime match).""" + cache = _tech_cache_path(img_path) + if not cache.exists(): + return None + try: + payload = json.loads(cache.read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(payload, dict): + return None + if payload.get("mtime") != img_path.stat().st_mtime: + return None + scores = payload.get("scores") + return scores if isinstance(scores, dict) else None + + +def _save_tech_cache(img_path: Path, tech: dict) -> None: + """Persist technical scores keyed on file mtime.""" + cache = _tech_cache_path(img_path) + payload = {"mtime": img_path.stat().st_mtime, "scores": tech} + try: + cache.write_text(json.dumps(payload), encoding="utf-8") + except Exception: + pass + + def batch_technical_score( images: list[Path], source_map: Optional[dict[Path, Path]] = None ) -> list[ImageScore]: """Score all images technically and return sorted list.""" results = [] + cache_hits = 0 for img_path in images: try: - tech = score_technical(img_path) + cached = _load_tech_cache(img_path) + if cached is not None: + tech = cached + cache_hits += 1 + else: + tech = score_technical(img_path) + _save_tech_cache(img_path, tech) results.append( ImageScore( path=img_path, @@ -979,7 +1072,8 @@ def batch_technical_score( results.sort(key=lambda x: x.technical.get("composite", 0), reverse=True) print( - f" āœ… Technical scoring complete. Top: {results[0].path.name} ({results[0].technical['composite']:.3f})" + f" āœ… Technical scoring complete ({cache_hits}/{len(results)} cached). " + f"Top: {results[0].path.name} ({results[0].technical['composite']:.3f})" ) _print_score_distribution(results) return results @@ -1021,6 +1115,10 @@ def batch_technical_score( - Would cropping to portrait cut off wheels, handlebars, or exhaust? - Would the subject remain well-composed in Instagram's 3:4 grid thumbnail? +BRAND PRIORITY: This is a Ducati-focused account. If the motorcycle is identifiably NOT a Ducati +(e.g. Japanese brands, BMW, KTM, Harley, etc.), reduce SUBJECT_CLARITY and EMOTION scores by 3 +points each (minimum 1). Ducati bikes or unidentifiable brands score normally. + Return ONLY valid JSON, no markdown: {{"subject_clarity": N, "lighting": N, "color_pop": N, "emotion": N, "scroll_stop": N, "crop_4x5": N, "total": N, "one_line": "why this works or doesn't"}}""" @@ -1042,6 +1140,7 @@ def batch_technical_score( - Score each criterion as an integer from 0 to 10. - total must equal the sum of the 6 criterion scores (0 to 60). - one_line must be exactly one concise sentence describing this specific image. +- BRAND PRIORITY: This is a Ducati-focused account. If the motorcycle is identifiably NOT a Ducati (e.g. Japanese brands, BMW, KTM, Harley, etc.), reduce subject_clarity and emotion by 3 each (minimum 1). Ducati or unidentifiable brands score normally. """ OLLAMA_STRICT_JSON_SCHEMA = { @@ -1237,12 +1336,24 @@ def score_with_claude( # Silently fail - YOLO context is optional pass - # Read and encode — images are already resized to max 1920px - with open(image_path, "rb") as f: - image_data = base64.standard_b64encode(f.read()).decode("utf-8") - - suffix = image_path.suffix.lower() - media_type = "image/png" if suffix == ".png" else "image/jpeg" + # Downsize for scoring — 1024px is sufficient for composition evaluation + # and significantly reduces API token cost vs sending full 1920px images. + CLAUDE_SCORING_MAX_EDGE = 1024 + CLAUDE_SCORING_JPEG_QUALITY = 75 + with Image.open(image_path) as img: + from PIL import ImageOps + img = ImageOps.exif_transpose(img) + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + w, h = img.size + longest = max(w, h) + if longest > CLAUDE_SCORING_MAX_EDGE: + scale = CLAUDE_SCORING_MAX_EDGE / longest + img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) + buf = io.BytesIO() + img.save(buf, "JPEG", quality=CLAUDE_SCORING_JPEG_QUALITY, optimize=True) + image_data = base64.standard_b64encode(buf.getvalue()).decode("utf-8") + media_type = "image/jpeg" # Enhance prompt with YOLO detection context if available enhanced_prompt = prompt or build_vision_prompt(DEFAULT_ACCOUNT_CONTEXT) @@ -1653,6 +1764,7 @@ def batch_vision_score( scorer: str = "clip", env_search_dir: Optional[Path] = None, claude_model: Optional[str] = None, + rescore: bool = False, ) -> list[ImageScore]: """Run vision scoring on candidate images.""" @@ -1912,11 +2024,12 @@ def score_one_with_retry(item: ImageScore) -> dict: if scorer == "claude": source_for_cache = item.source_path or item.path source_sha256 = _file_sha256(source_for_cache) - cached = load_claude_score_from_file_cache( + cached = None if rescore else load_claude_score_from_file_cache( source_path=source_for_cache, source_sha256=source_sha256, model=active_claude_model, prompt_sha256=claude_prompt_hash, + strict_model=False, ) if cached is not None: item.vision = cached @@ -3053,12 +3166,14 @@ def _prepare_claude_crop_first_candidates( def run_pipeline( input_folder: str, output_folder: str = "selected", + work_folder: Optional[str] = None, top_n: int = 10, scorer: str = "clip", vision_candidates_pct: float = 0.5, claude_model: Optional[str] = None, score_all: bool = False, claude_crop_first: bool = False, + rescore: bool = False, ): """ Full pipeline: resize → deduplicate → technical score → vision score → crop → output. @@ -3066,6 +3181,7 @@ def run_pipeline( Args: input_folder: Path to folder with raw event photos output_folder: Path for final 1080x1440 output images + work_folder: Path for intermediate work files (default: _work next to input) top_n: Number of top images to output scorer: "clip" (free/local), "claude" (best quality, API costs), or "ollama" (self-hosted) vision_candidates_pct: Send top N% of technically-scored images to vision scoring @@ -3074,7 +3190,7 @@ def run_pipeline( """ src = Path(input_folder) out = Path(output_folder) - work = src.parent / f"{src.name}_work" + work = Path(work_folder) if work_folder else src.parent / f"{src.name}_work" if not src.exists(): print(f"āŒ Input folder not found: {src}") @@ -3118,12 +3234,36 @@ def run_pipeline( ) candidates = _prepare_claude_crop_first_candidates(candidates, work_folder=work) scope = "all" if score_all else f"top {len(candidates)}" + if scorer == "claude": + cost_per_image = 0.005 + if rescore: + uncached = len(candidates) + else: + claude_est_prompt = build_vision_prompt(DEFAULT_ACCOUNT_CONTEXT) + claude_est_hash = claude_prompt_sha256(claude_est_prompt) + claude_est_model = claude_model or resolve_claude_model() + uncached = 0 + for item in candidates: + sp = item.source_path or item.path + cached = load_claude_score_from_file_cache( + source_path=sp, source_sha256=_file_sha256(sp), + model=claude_est_model, prompt_sha256=claude_est_hash, + strict_model=False, + ) + if cached is None: + uncached += 1 + est_cost = uncached * cost_per_image + print( + f"\nšŸ’° Claude estimate: {uncached}/{len(candidates)} images to score, " + f"~${est_cost:.2f} ({len(candidates) - uncached} cached)" + ) print(f"\n🧠 Stage 3: Vision scoring {scope} candidates ({scorer})...") ranked = batch_vision_score( candidates, scorer=scorer, env_search_dir=src, claude_model=claude_model, + rescore=rescore, ) # --- Stage 4: Output top N cropped to 1080x1440 --- @@ -3160,20 +3300,22 @@ def run_pipeline( for i, item in enumerate(top): rank = i + 1 output_stem = (item.source_path or item.path).stem - dest = out / f"{rank:02d}_{output_stem}.jpg" - dest_full = out / f"{rank:02d}_full_{output_stem}.jpg" + dest_cropped = out / f"{rank:02d}_cropped_{output_stem}.jpg" + dest_hd = out / f"{rank:02d}_hd_{output_stem}.jpg" + dest_full = out / f"{rank:02d}_full_{output_stem}{(item.source_path or item.path).suffix}" display_name = (item.source_path or item.path).name padded_written = False crop_meta: dict[str, object] = {} + + # Cropped: IG 1080x1440 smart crop try: - smart_crop(item.path, dest, save_debug=True, meta_out=crop_meta) + smart_crop(item.path, dest_cropped, save_debug=True, meta_out=crop_meta) except Exception: - # Fallback: simple center crop via PIL try: with Image.open(item.path) as img: img = img.convert("RGB") img = img.resize((OUTPUT_WIDTH, OUTPUT_HEIGHT), Image.LANCZOS) - img.save(dest, "JPEG", quality=95) + img.save(dest_cropped, "JPEG", quality=95) crop_meta = { "uncertain_crop": True, "uncertain_crop_reasons": ["smart_crop_failed_used_center_resize_fallback"], @@ -3182,13 +3324,20 @@ def run_pipeline( print(f" ⚠ Could not process {item.path.name}: {e2}") continue - # Additional non-ranked full-subject portrait variant only when crop is uncertain. - if bool(crop_meta.get("uncertain_crop", False)): - try: - write_padded_full_subject(item.source_path or item.path, dest_full) - padded_written = True - except Exception as e3: - print(f" ⚠ Could not write full-subject variant for {display_name}: {e3}") + # HD: 1920px longest edge (work copy) + import shutil + try: + shutil.copy2(str(item.path), str(dest_hd)) + except Exception as e3: + print(f" ⚠ Could not copy HD version for {display_name}: {e3}") + + # Full: original source file + try: + source = item.source_path or item.path + shutil.copy2(str(source), str(dest_full)) + padded_written = True + except Exception as e3: + print(f" ⚠ Could not copy full version for {display_name}: {e3}") report.append( { @@ -3198,14 +3347,15 @@ def run_pipeline( "technical_composite": round(item.technical.get("composite", 0), 4), "vision_total": item.vision.get("total", 0), "one_line": item.one_line, - "output": dest.name, - "output_full_subject": dest_full.name if padded_written else None, + "output_cropped": dest_cropped.name, + "output_hd": dest_hd.name, + "output_full": dest_full.name if padded_written else None, "uncertain_crop": bool(crop_meta.get("uncertain_crop", False)), "uncertain_crop_reasons": crop_meta.get("uncertain_crop_reasons", []), } ) - print(f" #{rank}: {display_name} → {dest.name}") + print(f" #{rank}: {display_name} → {dest_cropped.name}") print( f" Score: {item.final_score:.3f} | Tech: {item.technical.get('composite', 0):.3f} | Vision: {item.vision.get('total', 0)}" ) @@ -3266,6 +3416,9 @@ def main(): parser.add_argument( "--output", "-o", default="selected", help="Output folder (default: selected)" ) + parser.add_argument( + "--work", "-w", default=None, help="Work folder for intermediate files (default: _work next to input)" + ) parser.add_argument( "--top", "-n", type=int, default=10, help="Number of top images to output (default: 10)" ) @@ -3305,18 +3458,25 @@ def main(): "Claude scoring to better align ranking with final crop quality." ), ) + parser.add_argument( + "--rescore", + action="store_true", + help="Force re-scoring all images, ignoring cached vision scores.", + ) args = parser.parse_args() run_pipeline( input_folder=args.input, output_folder=args.output, + work_folder=args.work, top_n=args.top, scorer=args.scorer, vision_candidates_pct=args.vision_pct, claude_model=args.claude_model, score_all=args.score_all, claude_crop_first=args.claude_crop_first, + rescore=args.rescore, ) diff --git a/tests/test_main.py b/tests/test_main.py index c682b38..14f9495 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -47,12 +47,14 @@ def fake_run_pipeline(**kwargs): assert captured == { "input_folder": str(input_dir), "output_folder": str(output_dir), + "work_folder": None, "top_n": 3, "scorer": "claude", "vision_candidates_pct": 0.75, "claude_model": "claude-test-model", "score_all": True, "claude_crop_first": True, + "rescore": False, } From 89ef876557a97d935c548535685ffc88c708ae39 Mon Sep 17 00:00:00 2001 From: Renato Bonomini <4005901+renatobo@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:33:53 +0000 Subject: [PATCH 5/6] feat: ORB burst detection, adaptive Claude concurrency, HTML gallery, parallelization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Burst detection: - Three-tier matching: temporal+ORB (hist≄0.60), temporal (hist≄0.80+ORB≄0.25), non-temporal (hist≄0.92+ORB≄0.25) - ORB feature verification prevents grouping different riders at same track position - EXIF preserved during resize (orientation tag reset) for timestamp-based chaining - Sharpness-first selection, then technical re-evaluation for top candidates Performance: - All CPU stages parallelized (ProcessPool for resize/dedup features, ThreadPool for tech scoring/burst re-eval/smart crop to avoid YOLO fork deadlocks) - Claude API: adaptive concurrency (3→8), rate limit backoff, retry with exponential delay Gallery: - Built-in HTML gallery with detail panel (preview tabs, YOLO overlay, EXIF, scores, burst info) - Recursive folder indexes with breadcrumb navigation - Standalone regeneration via scripts/generate_gallery.py - Crop icon badge for uncertain crops, burst count badge Other: - Default model changed to claude-haiku-4-5-20251001 - Brand bonus prompt for Ducati identification - Images downsized to 1024px/q75 for Claude API to reduce cost - Cost estimator uses resolved account context (fixes false cache misses) - Three output variants: full (original), hd (1920px), cropped (1080x1440) - --rescore and --work CLI flags Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 106 +- scripts/generate_gallery.py | 39 + src/pickinsta/ig_image_selector.py | 1555 ++++++++++++++++++++++++---- tests/test_full_integration.py | 19 +- 4 files changed, 1506 insertions(+), 213 deletions(-) create mode 100755 scripts/generate_gallery.py diff --git a/CLAUDE.md b/CLAUDE.md index b3e20e2..46e7637 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,33 +8,72 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Architecture -### 5-Stage Pipeline +### 6-Stage Pipeline The core pipeline in `src/pickinsta/ig_image_selector.py` processes images through: -1. **Stage 0 - Resize**: Resize to max 1920px (saves compute, preserves EXIF orientation) -2. **Stage 1 - Deduplicate**: Perceptual hashing (imagehash) removes near-duplicates -3. **Stage 2 - Technical Scoring**: OpenCV-based quality metrics (sharpness, lighting, composition, color harmony) -4. **Stage 3 - Vision Scoring**: CLIP (local/free), Claude API, or self-hosted Ollama for aesthetic evaluation -5. **Stage 4 - Smart Crop**: YOLO-guided crop to 1080x1440 following composition rules +1. **Stage 0 - Resize**: Resize to max 1920px, parallel across CPU cores (saves compute, preserves EXIF orientation) +2. **Stage 1 - Deduplicate**: Two-pass dedup: perceptual hashing + histogram correlation with EXIF temporal burst detection. Feature extraction parallelized, grouping sequential. +3. **Stage 2 - Technical Scoring**: OpenCV-based quality metrics, parallel across CPU cores with per-image cache (`.techscore.json`) +4. **Stage 2b - Burst Re-evaluation**: For top candidates from burst groups, all burst members are technically scored in parallel; best replaces the sharpness pick if it scores higher. +5. **Stage 3 - Vision Scoring**: CLIP (local/free), Claude API (adaptive concurrency with rate limit backoff), or self-hosted Ollama for aesthetic evaluation +6. **Stage 4 - Smart Crop + Output**: YOLO-guided crop to 1080x1440, parallel via ThreadPoolExecutor. Outputs three variants per image: full (original), hd (1920px), cropped (IG 1080x1440). ### Key Design Decisions -**YOLO Integration (Recent Enhancement)**: +**Parallelization**: +- Stage 0 (resize): `ProcessPoolExecutor` — PIL only, safe to fork +- Stage 1 (dedup feature extraction): `ProcessPoolExecutor` — hash/histogram/EXIF, no YOLO +- Stages 2, 2b, 4 (tech scoring, burst re-eval, smart crop): `ThreadPoolExecutor` — these use YOLO/PyTorch which deadlocks with `fork()`, but OpenCV/YOLO release the GIL so threads still get parallelism +- Stage 3 Claude: `ThreadPoolExecutor` with adaptive concurrency (starts at 3, scales to 8, backs off on 429/rate limits, retries up to 3 times with exponential backoff) +- Cached results skip worker pools entirely +- **Important**: Never use `ProcessPoolExecutor` for code paths that load YOLO/PyTorch — use `ThreadPoolExecutor` instead to avoid fork deadlocks + +**Burst Detection** (three-layer dedup): +- Pass 1: perceptual hash (distance ≤8) groups pixel-identical images, selects sharpest (Laplacian variance) +- Pass 2: histogram correlation + EXIF temporal chaining + ORB feature verification + - Images sorted by EXIF timestamp; each candidate compared against last group member (chain tail) + - Must be within 3 seconds of the chain tail to be considered temporal + - Three matching tiers: + - **Temporal + strong ORB** (≄0.25): histogram only needs 0.60 (handles exposure shifts in burst) + - **Temporal only**: histogram ≄0.80 + ORB ≄0.25 + - **Non-temporal**: histogram ≄0.92 + ORB ≄0.25 + - **ORB verification** confirms the subject matches, not just the scene — prevents grouping different riders at the same track position (histogram alone can't distinguish these since the background dominates) +- Stage 2b re-evaluates top candidates from bursts using full technical scoring in parallel +- Burst metadata (count, selection method, members) tracked in report and gallery +- **Important**: Stage 0 resize preserves EXIF data (with orientation tag reset to 1) so timestamps are available for burst detection on work images + +**YOLO Integration**: - YOLOv8 detects subjects (motorcycles, people, vehicles) before cropping - Ensures crops keep the full subject in frame (previously used unreliable saliency detection) - YOLO context is passed to Claude and Ollama to improve scoring accuracy +- Ultralytics banner/warnings suppressed (`YOLO_VERBOSE=false`, `task="detect"`, `verbose=False`) - Graceful fallback to saliency detection if YOLO finds nothing - See `debug/README.md` and `debug/debug_yolo_claude.py` for debugging details **Three-Scorer Architecture**: - **CLIP** (`--scorer clip`): Free, local, zero-shot classification. Uses 4 positive + 2 negative prompts. Maps logits to 0-60 scale to match Claude's range. -- **Claude** (`--scorer claude`): Best quality, API costs ~$0.50/100 images. Scores 6 criteria (subject_clarity, lighting, color_pop, emotion, scroll_stop, crop_4x5). Returns JSON with scores + one-line summary. +- **Claude** (`--scorer claude`): Default model `claude-haiku-4-5-20251001`. Images downsized to 1024px/q75 before API call to reduce token cost. Scores 6 criteria (subject_clarity, lighting, color_pop, emotion, scroll_stop, crop_4x5). Returns JSON with scores + one-line summary. Brand bonus: Ducati bikes get +2 on subject_clarity and emotion. - **Ollama** (`--scorer ollama`): Self-hosted vision scoring with the same 0-60 rubric. Supports retry/backoff, circuit breaker, and configurable request concurrency. **Final Score Calculation**: `final_score = 0.3 * technical_composite + 0.7 * vision_normalized` -**Caching Strategy**: Claude responses are cached per original source file as `.pickinsta.json`. Cache includes image SHA256 + model + prompt hash for validity checking. +**Caching Strategy**: +- Claude vision responses cached per source file as `.pickinsta.json` (SHA256 + prompt hash; model check skipped by default for cross-model reuse, forced with `--rescore`). Prompt hash includes account context — changing `PICKINSTA_ACCOUNT_CONTEXT` invalidates caches. Cost estimator resolves the same account context as the scorer. +- Technical scores cached per work image as `.jpg.techscore.json` (keyed on file mtime) +- Stage 0 resize cached via mtime check on work folder output + +**Output Variants**: +- `XX_cropped_.jpg` — 1080x1440 IG smart crop (with blur padding if needed) +- `XX_hd_.jpg` — 1920px longest edge, original aspect ratio +- `XX_full_.` — original source file, untouched + +**HTML Gallery** (`index.html`): +- Auto-generated at end of pipeline run in output folder +- Standalone regeneration: `python scripts/generate_gallery.py ` +- Recursive folder indexes with image counts and thumbnails +- Detail panel: preview (cropped/hd/full tabs), YOLO detection overlay, EXIF info, score bars, burst info, AI assessment +- Breadcrumb navigation, GitHub link, uncertain crop warning badges, burst count badges ### Composition Rules Implementation @@ -56,7 +95,7 @@ Smart cropping uses these rules to: ## Development Commands ```bash -make install-dev # Install dev dependencies (pytest, ruff, pre-commit) +make install-dev # Install dev + all scorer extras (clip, claude, yolo) make test # Run pytest suite make lint # Run ruff linting checks make format # Auto-format with ruff @@ -79,11 +118,11 @@ This installs dev tools and all scorer extras (clip, claude, yolo). To install o # CLIP scorer (free, local), top 10 pickinsta ./input --output ./selected --top 10 --scorer clip -# Claude scorer, all images, with Claude-guided crop ordering -pickinsta ./input --output ./selected --scorer claude --all --claude-crop-first +# Claude scorer, all images, with separate work folder +pickinsta ./input --output ./selected --work ./work --scorer claude --all -# As module / override model -python -m pickinsta ./input --scorer claude --claude-model claude-sonnet-4-6 +# Override model, force re-scoring +pickinsta ./input --scorer claude --claude-model claude-sonnet-4-6 --rescore # Ollama scorer (self-hosted), score all candidates pickinsta ./input --output ./selected --scorer ollama --all @@ -99,10 +138,10 @@ cp .env.example .env Key variables: - `ANTHROPIC_API_KEY` — required for Claude scorer -- `ANTHROPIC_MODEL` — override default model (default: `claude-sonnet-4-6`) +- `ANTHROPIC_MODEL` — override default model (default: `claude-haiku-4-5-20251001`) - `CLAUDE_MODEL` — alias fallback for Claude model resolution - `HF_TOKEN` — reduces HuggingFace rate limit warnings (CLIP) -- `PICKINSTA_ACCOUNT_CONTEXT` — custom account context injected into Claude/Ollama prompts +- `PICKINSTA_ACCOUNT_CONTEXT` — custom account context injected into Claude/Ollama prompts (e.g. `"Ducati/motorcycle enthusiast account in Southern California."`) - `PICKINSTA_OLLAMA_BASE_URL` — Ollama endpoint (default: `http://127.0.0.1:11434`) - `PICKINSTA_OLLAMA_MODEL` — Ollama model tag (default: `qwen2.5vl:7b`) - `PICKINSTA_OLLAMA_CONCURRENCY` — parallel requests submitted by pickinsta (default: `2`, min `1`, max `16`) @@ -111,6 +150,18 @@ Key variables: - `PICKINSTA_OLLAMA_CIRCUIT_BREAKER_ERRORS` — consecutive failure threshold before fallback (default: `6`) - `PICKINSTA_YOLO_MODEL` — override YOLO model path (default: `~/.cache/pickinsta/models/yolov8n.pt`) +### CLI Flags + +- `--output, -o` — output folder (default: `selected`) +- `--work, -w` — work folder for intermediate files (default: `_work` next to input) +- `--top, -n` — number of top images to output (default: 10) +- `--scorer, -s` — vision scorer: `clip`, `claude`, or `ollama` +- `--all` — score all Stage 2 images (ignore `--vision-pct`) +- `--vision-pct` — fraction of images to send to vision scoring (default: 0.5) +- `--claude-model` — override Claude model +- `--claude-crop-first` — pre-crop to 1080x1440 before Claude scoring +- `--rescore` — force re-scoring, ignoring all cached vision scores + ### Testing ```bash @@ -127,10 +178,23 @@ Debug mode creates visualizations showing: - Yellow lines: Rule-of-thirds grid - Cyan lines: Phi Grid +### Gallery Generation + +```bash +# Regenerate galleries for all output folders +python scripts/generate_gallery.py ~/Photos/td6_selected + +# Single folder +python scripts/generate_gallery.py ~/Photos/td6_selected/2ab/Session_1_Turn_2 +``` + +Gallery is also auto-generated at the end of each pipeline run. + ## Important File Locations -- **Main pipeline**: `src/pickinsta/ig_image_selector.py` (~3,100 lines, all 5 stages + CLI) +- **Main pipeline**: `src/pickinsta/ig_image_selector.py` (~4,000 lines, all stages + gallery + CLI) - **CLIP scorer**: `src/pickinsta/clip_scorer.py` (separate module, loaded lazily on `--scorer clip`) +- **Gallery generator**: `scripts/generate_gallery.py` (standalone, imports from main module) - **Config**: `pyproject.toml` (defines `pickinsta` console script, optional dependencies, ruff config) - **Docs**: `docs/composition-rules.md` (technical scoring weights, cropping heuristics) @@ -145,7 +209,7 @@ Debug mode creates visualizations showing: ### Changing Claude Scoring Criteria 1. Edit `VISION_PROMPT_TEMPLATE` / `build_vision_prompt(...)` in `ig_image_selector.py` -2. Update prompt hash will invalidate caches (intentional for consistency) +2. Updated prompt hash will invalidate caches (intentional for consistency) 3. Ensure Claude returns JSON with expected keys: `subject_clarity`, `lighting`, `color_pop`, `emotion`, `scroll_stop`, `crop_4x5`, `total`, `one_line` ### Adjusting Crop Behavior @@ -162,7 +226,7 @@ Add extension to `SUPPORTED_EXTENSIONS` set (line 53). Ensure PIL can open the f **CLIP model download fails**: First run requires internet to download ~1.7GB model from HuggingFace. Set `HF_TOKEN` in environment to avoid rate limits. -**Claude model not found**: Use `--claude-model` flag or set `ANTHROPIC_MODEL`/`CLAUDE_MODEL` in `.env`. Pipeline tries fallback models: preferred → undated alias → `claude-sonnet-4-6` → `claude-3-5-sonnet-latest`. +**Claude model not found**: Use `--claude-model` flag or set `ANTHROPIC_MODEL`/`CLAUDE_MODEL` in `.env`. Pipeline tries fallback models: preferred → undated alias → `claude-haiku-4-5-20251001` → `claude-3-5-sonnet-latest`. **Ollama scoring fails/unavailable**: Verify `PICKINSTA_OLLAMA_BASE_URL` and `PICKINSTA_OLLAMA_MODEL`, then test with `curl /api/tags`. Tune server (`OLLAMA_NUM_PARALLEL`, `OLLAMA_NUM_THREAD`) and client (`PICKINSTA_OLLAMA_CONCURRENCY`) together. @@ -173,7 +237,11 @@ Add extension to `SUPPORTED_EXTENSIONS` set (line 53). Ensure PIL can open the f ## API Cost Management Claude scoring costs ~$0.005/image (varies by model/prompt): +- Images are downsized to 1024px/q75 before sending to reduce token cost +- Cost estimate shown before scoring: `šŸ’° Claude estimate: 23/42 images to score, ~$0.12 (19 cached)` - Use `--vision-pct 0.5` to score only top 50% of technically-filtered images - Use `--all` to score all images (more accurate but higher cost) - Caching prevents re-scoring unchanged images (cache files: `*.pickinsta.json`) +- Cache is model-agnostic by default — switching models reuses cached scores unless `--rescore` is passed +- Adaptive concurrency throttles down on rate limits and scales up on success - Consider CLIP pre-filtering for large batches (>100 images) diff --git a/scripts/generate_gallery.py b/scripts/generate_gallery.py new file mode 100755 index 0000000..7010054 --- /dev/null +++ b/scripts/generate_gallery.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Generate static HTML galleries from pickinsta selection output folders. + +Usage: + python scripts/generate_gallery.py ~/Photos/td6_selected/2ab/Session_1_Pit_Lane_Entry + python scripts/generate_gallery.py ~/Photos/td6_selected # recursively generates galleries + folder indexes +""" + +import sys +from pathlib import Path + +from pickinsta.ig_image_selector import generate_gallery, generate_gallery_index + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + print(" Generates index.html in each folder containing selection_report.json") + print(" Parent folders get a directory listing linking to galleries") + sys.exit(1) + + root = Path(sys.argv[1]).resolve() + + if (root / "selection_report.json").exists(): + result = generate_gallery(root) + if result: + print(f" {result}") + else: + generated = generate_gallery_index(root) + if not generated: + print(f"No selection_report.json found under {root}") + sys.exit(1) + print(f"Generated {len(generated)} index/gallery pages under {root}") + for p in generated: + print(f" {p}") + + +if __name__ == "__main__": + main() diff --git a/src/pickinsta/ig_image_selector.py b/src/pickinsta/ig_image_selector.py index c6379e0..70042b4 100644 --- a/src/pickinsta/ig_image_selector.py +++ b/src/pickinsta/ig_image_selector.py @@ -58,7 +58,7 @@ OUTPUT_HEIGHT = 1440 # Instagram output height (3:4 ratio) SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".tiff", ".bmp"} DEDUP_THRESHOLD = 8 # perceptual hash distance; lower = stricter -DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6" +DEFAULT_CLAUDE_MODEL = "claude-haiku-4-5-20251001" DEFAULT_OLLAMA_MODEL = "qwen2.5vl:7b" DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434" CLAUDE_MIN_CROP4X5_OUTPUT_SCORE = 6.0 @@ -466,6 +466,8 @@ class ImageScore: vision: dict = field(default_factory=dict) final_score: float = 0.0 one_line: str = "" + burst_group: Optional[list[Path]] = None # all images in the burst (if any) + burst_selected_by: str = "" # "sharpness" or "technical" def _md_escape(value: object) -> str: @@ -550,13 +552,52 @@ def write_markdown_report( # --------------------------------------------------------------------------- +def _resize_one_image(args: tuple[Path, Path]) -> Optional[tuple[Path, Path]]: + """Resize a single image. Used by ProcessPoolExecutor. Returns (dest, source) or None.""" + img_path, dest = args + try: + from PIL import ImageOps + + with Image.open(img_path) as img: + # Preserve EXIF data (timestamps needed for burst detection) + # but reset orientation tag since exif_transpose already rotated pixels + exif_bytes = img.info.get("exif") + img = ImageOps.exif_transpose(img) + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + w, h = img.size + longest = max(w, h) + if longest > MAX_RESIZE_PX: + scale = MAX_RESIZE_PX / longest + img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) + save_kwargs = {"quality": 90} + if exif_bytes: + try: + from PIL.Image import Exif + exif_obj = Exif() + exif_obj.load(exif_bytes) + # 0x0112 = Orientation tag — reset to normal (1) + if 0x0112 in exif_obj: + exif_obj[0x0112] = 1 + save_kwargs["exif"] = exif_obj.tobytes() + except Exception: + save_kwargs["exif"] = exif_bytes + img.save(dest, "JPEG", **save_kwargs) + return (dest, img_path) + except Exception: + return None + + def resize_for_processing( src_folder: Path, work_folder: Path ) -> tuple[list[Path], dict[Path, Path]]: """ Copy all images to work_folder, resized so the longest edge <= MAX_RESIZE_PX. - Preserves EXIF orientation. Returns list of resized image paths. + Preserves EXIF orientation. Uses multiple CPU cores for resizing. + Returns list of resized image paths and source map. """ + from concurrent.futures import ProcessPoolExecutor + work_folder.mkdir(parents=True, exist_ok=True) resized: list[Path] = [] source_map: dict[Path, Path] = {} @@ -567,44 +608,36 @@ def resize_for_processing( ] print(f"šŸ“ Found {len(src_images)} images in {src_folder}") + # Separate cached vs needs-resize + to_resize: list[tuple[Path, Path]] = [] for img_path in src_images: - try: - dest = work_folder / f"{img_path.stem}.jpg" - # Reuse prior output when it is up-to-date to avoid re-encoding. - if dest.exists() and dest.stat().st_mtime >= img_path.stat().st_mtime: - resized.append(dest) - source_map[dest] = img_path - reused += 1 - continue - - with Image.open(img_path) as img: - # Handle EXIF rotation - from PIL import ImageOps - - img = ImageOps.exif_transpose(img) - - # Convert to RGB if needed (handles RGBA, P mode, etc.) - if img.mode not in ("RGB", "L"): - img = img.convert("RGB") - - w, h = img.size - longest = max(w, h) - - if longest > MAX_RESIZE_PX: - scale = MAX_RESIZE_PX / longest - new_size = (int(w * scale), int(h * scale)) - img = img.resize(new_size, Image.LANCZOS) + dest = work_folder / f"{img_path.stem}.jpg" + if dest.exists() and dest.stat().st_mtime >= img_path.stat().st_mtime: + resized.append(dest) + source_map[dest] = img_path + reused += 1 + else: + to_resize.append((img_path, dest)) + + # Parallel resize + if to_resize: + n_workers = min(len(to_resize), os.cpu_count() or 4) + with ProcessPoolExecutor(max_workers=n_workers) as pool: + for result in pool.map(_resize_one_image, to_resize): + if result is not None: + dest, img_path = result + resized.append(dest) + source_map[dest] = img_path + else: + print(" ⚠ Skipping an image: resize failed") - img.save(dest, "JPEG", quality=90) - resized.append(dest) - source_map[dest] = img_path - except Exception as e: - print(f" ⚠ Skipping {img_path.name}: {e}") + # Sort to maintain deterministic order + resized.sort(key=lambda p: p.name) newly_resized = len(resized) - reused print( f" āœ… Prepared {len(resized)} images (new: {newly_resized}, reused: {reused})" - f" to max {MAX_RESIZE_PX}px → {work_folder}" + f" to max {MAX_RESIZE_PX}px → {work_folder} ({os.cpu_count() or '?'} cores)" ) return resized, source_map @@ -615,7 +648,10 @@ def resize_for_processing( HIST_DEDUP_THRESHOLD = 0.92 # histogram correlation; higher = stricter +HIST_DEDUP_TEMPORAL_THRESHOLD = 0.80 # relaxed threshold when EXIF timestamps are close HIST_DEDUP_THUMB_SIZE = (256, 256) +BURST_MAX_INTERVAL_SEC = 3.0 # max seconds between shots to consider them a burst +ORB_MATCH_THRESHOLD = 0.25 # minimum ORB good-match ratio to confirm burst membership def _image_histogram(img_path: Path) -> Optional[np.ndarray]: @@ -632,72 +668,227 @@ def _image_histogram(img_path: Path) -> Optional[np.ndarray]: return None -def deduplicate(images: list[Path], threshold: int = DEDUP_THRESHOLD) -> list[Path]: +def _exif_timestamp(img_path: Path) -> Optional[float]: + """Extract EXIF DateTimeOriginal as epoch seconds, or None.""" + try: + from PIL.ExifTags import TAGS as _TAGS + with Image.open(img_path) as img: + raw = img._getexif() + if not raw: + return None + tagged = {_TAGS.get(k, k): v for k, v in raw.items()} + dt_str = tagged.get("DateTimeOriginal") or tagged.get("DateTime") + if not dt_str: + return None + # Handle optional subsecond precision + subsec = tagged.get("SubSecTimeOriginal") or tagged.get("SubSecTime") or "0" + from datetime import datetime + dt = datetime.strptime(str(dt_str), "%Y:%m:%d %H:%M:%S") + return dt.timestamp() + float(f"0.{subsec}") + except Exception: + return None + + +def _quick_sharpness(img_path: Path) -> float: + """Quick Laplacian variance sharpness score for burst selection.""" + try: + img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE) + if img is None: + return 0.0 + return float(cv2.Laplacian(img, cv2.CV_64F).var()) + except Exception: + return 0.0 + + +def _compute_phash(img_path: Path) -> Optional[tuple[Path, object]]: + """Compute perceptual hash for one image. For ProcessPoolExecutor.""" + try: + import imagehash + h = imagehash.phash(Image.open(img_path), hash_size=16) + return (img_path, h) + except Exception: + return None + + +def _compute_orb_descriptors(img_path: Path) -> Optional[np.ndarray]: + """Compute ORB descriptors for burst verification.""" + try: + img = cv2.imread(str(img_path)) + if img is None: + return None + thumb = cv2.resize(img, (512, 512)) + gray = cv2.cvtColor(thumb, cv2.COLOR_BGR2GRAY) + orb = cv2.ORB_create(500) + _, descriptors = orb.detectAndCompute(gray, None) + return descriptors + except Exception: + return None + + +def _orb_match_ratio(desc_a: Optional[np.ndarray], desc_b: Optional[np.ndarray]) -> float: + """Compute ORB good-match ratio between two descriptor sets.""" + if desc_a is None or desc_b is None: + return 0.0 + try: + bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + matches = bf.match(desc_a, desc_b) + if not matches: + return 0.0 + good = [m for m in matches if m.distance < 50] + return len(good) / len(matches) + except Exception: + return 0.0 + + +def _compute_dedup_features(img_path: Path) -> tuple[Path, Optional[np.ndarray], Optional[float], float, Optional[np.ndarray]]: + """Compute histogram, EXIF timestamp, sharpness, and ORB descriptors for one image.""" + hist = _image_histogram(img_path) + ts = _exif_timestamp(img_path) + sharpness = _quick_sharpness(img_path) + orb_desc = _compute_orb_descriptors(img_path) + return (img_path, hist, ts, sharpness, orb_desc) + + +def deduplicate( + images: list[Path], threshold: int = DEDUP_THRESHOLD +) -> tuple[list[Path], dict[Path, list[Path]]]: """ - Remove near-duplicate images using perceptual hashing, - then a second pass using histogram correlation to catch burst shots - with shifted framing that hash-dedup misses. - Returns one representative from each group of similar images. + Remove near-duplicate images using: + 1. Perceptual hashing for pixel-identical duplicates. + 2. Histogram correlation + EXIF temporal proximity for burst shots. + Images taken within BURST_MAX_INTERVAL_SEC use a relaxed histogram + threshold; others use the strict threshold. + + Per-image feature computation (hashing, histograms, EXIF, sharpness) is + parallelized across CPU cores. Grouping logic remains sequential. + + Returns (unique_images, burst_map) where burst_map maps each + representative to the full list of burst members (only for groups > 1). + Initial representative is selected by sharpness (Laplacian variance). """ - import imagehash + from concurrent.futures import ProcessPoolExecutor - # Pass 1: perceptual hash dedup - hash_groups: dict[imagehash.ImageHash, list[Path]] = {} + n_workers = min(len(images), os.cpu_count() or 4) - for img_path in images: - try: - h = imagehash.phash(Image.open(img_path), hash_size=16) - placed = False - for existing_hash, group in hash_groups.items(): - if abs(h - existing_hash) <= threshold: - group.append(img_path) - placed = True - break - if not placed: - hash_groups[h] = [img_path] - except Exception as e: - print(f" ⚠ Hash failed for {img_path.name}: {e}") + # Pass 1: compute perceptual hashes in parallel + path_hash_map: dict[Path, object] = {} + with ProcessPoolExecutor(max_workers=n_workers) as pool: + for result in pool.map(_compute_phash, images): + if result is not None: + img_path, h = result + path_hash_map[img_path] = h + else: + print(" ⚠ Hash failed for an image") - # From each group, pick the largest file (usually highest quality) + # Sequential grouping by hash distance + hash_groups: dict[object, list[Path]] = {} + for img_path in images: + h = path_hash_map.get(img_path) + if h is None: + continue + placed = False + for existing_hash, group in hash_groups.items(): + if abs(h - existing_hash) <= threshold: + group.append(img_path) + placed = True + break + if not placed: + hash_groups[h] = [img_path] + + # Compute sharpness in parallel for hash groups with >1 member + sharpness_cache: dict[Path, float] = {} + multi_groups = [g for g in hash_groups.values() if len(g) > 1] + if multi_groups: + all_multi = [p for g in multi_groups for p in g] + with ProcessPoolExecutor(max_workers=n_workers) as pool: + for path, sharpness in zip(all_multi, pool.map(_quick_sharpness, all_multi)): + sharpness_cache[path] = sharpness + + # From each hash group, pick sharpest hash_unique = [] hash_removed = 0 for group in hash_groups.values(): - best = max(group, key=lambda p: p.stat().st_size) + if len(group) == 1: + best = group[0] + else: + best = max(group, key=lambda p: sharpness_cache.get(p, 0.0)) hash_unique.append(best) hash_removed += len(group) - 1 - # Pass 2: histogram correlation to catch burst shots - hist_groups: list[list[tuple[Path, np.ndarray]]] = [] - for img_path in hash_unique: - hist = _image_histogram(img_path) + # Pass 2: compute histogram + EXIF + sharpness in parallel for burst detection + img_data: list[tuple[Path, Optional[np.ndarray], Optional[float], float]] = [] + with ProcessPoolExecutor(max_workers=n_workers) as pool: + img_data = list(pool.map(_compute_dedup_features, hash_unique)) + + # Sort by timestamp so temporal chaining works naturally + img_data.sort(key=lambda e: e[2] if e[2] is not None else float("inf")) + + raw_burst_groups: list[list[tuple[Path, Optional[np.ndarray], Optional[float], float, Optional[np.ndarray]]]] = [] + for entry in img_data: + img_path, hist, ts, sharpness, orb_desc = entry if hist is None: - hist_groups.append([(img_path, np.array([]))]) + raw_burst_groups.append([entry]) continue placed = False - for group in hist_groups: - ref_hist = group[0][1] - if ref_hist.size > 0: - corr = cv2.compareHist(ref_hist, hist, cv2.HISTCMP_CORREL) - if corr >= HIST_DEDUP_THRESHOLD: - group.append((img_path, hist)) - placed = True - break + for group in raw_burst_groups: + last_path, last_hist, last_ts, _, last_orb = group[-1] + first_path, first_hist, first_ts, _, first_orb = group[0] + + # Must be temporally close to the last member (chaining) + if ts is not None and last_ts is not None: + time_to_last = abs(ts - last_ts) + if time_to_last > BURST_MAX_INTERVAL_SEC: + continue + elif ts is not None or last_ts is not None: + continue + + # Must be visually similar (histogram) to the chain tail + if last_hist is None or last_hist.size == 0: + continue + corr_last = cv2.compareHist(last_hist, hist, cv2.HISTCMP_CORREL) + is_temporal = (ts is not None and last_ts is not None and time_to_last <= BURST_MAX_INTERVAL_SEC) + + # ORB verification: confirm the subject matches, not just the scene + orb_ratio = _orb_match_ratio(last_orb, orb_desc) + + if is_temporal and orb_ratio >= ORB_MATCH_THRESHOLD: + # Strong ORB match + temporal proximity: very lenient histogram + # (exposure can shift between burst frames) + if corr_last < 0.60: + continue + elif is_temporal: + if corr_last < HIST_DEDUP_TEMPORAL_THRESHOLD: + continue + if orb_ratio < ORB_MATCH_THRESHOLD: + continue + else: + if corr_last < HIST_DEDUP_THRESHOLD: + continue + if orb_ratio < ORB_MATCH_THRESHOLD: + continue + + group.append(entry) + placed = True + break if not placed: - hist_groups.append([(img_path, hist)]) + raw_burst_groups.append([entry]) unique = [] - hist_removed = 0 - for group in hist_groups: - paths = [p for p, _ in group] - best = max(paths, key=lambda p: p.stat().st_size) - unique.append(best) - hist_removed += len(group) - 1 + burst_map: dict[Path, list[Path]] = {} + burst_removed = 0 + for group in raw_burst_groups: + best_path = max(group, key=lambda e: e[3])[0] + paths = [p for p, _, _, _, _ in group] + unique.append(best_path) + if len(paths) > 1: + burst_map[best_path] = paths + burst_removed += len(group) - 1 print( f" āœ… Dedup: {len(images)} → {len(unique)} unique " - f"({hash_removed} hash dupes, {hist_removed} burst dupes removed)" + f"({hash_removed} hash dupes, {burst_removed} burst dupes removed)" ) - return unique + return unique, burst_map # --------------------------------------------------------------------------- @@ -1045,30 +1236,65 @@ def _save_tech_cache(img_path: Path, tech: dict) -> None: pass +def _score_technical_with_cache(img_path: Path) -> Optional[tuple[Path, dict]]: + """Score one image technically, with cache. For use in ProcessPoolExecutor.""" + try: + cached = _load_tech_cache(img_path) + if cached is not None: + return (img_path, cached) + tech = score_technical(img_path) + _save_tech_cache(img_path, tech) + return (img_path, tech) + except Exception: + return None + + def batch_technical_score( images: list[Path], source_map: Optional[dict[Path, Path]] = None ) -> list[ImageScore]: - """Score all images technically and return sorted list.""" + """Score all images technically using multiple threads and return sorted list.""" + from concurrent.futures import ThreadPoolExecutor + results = [] cache_hits = 0 + + # Split cached vs uncached + cached_results: list[tuple[Path, dict]] = [] + to_score: list[Path] = [] for img_path in images: - try: - cached = _load_tech_cache(img_path) - if cached is not None: - tech = cached - cache_hits += 1 - else: - tech = score_technical(img_path) - _save_tech_cache(img_path, tech) - results.append( - ImageScore( - path=img_path, - source_path=source_map.get(img_path) if source_map else None, - technical=tech, - ) + cached = _load_tech_cache(img_path) + if cached is not None: + cached_results.append((img_path, cached)) + cache_hits += 1 + else: + to_score.append(img_path) + + # Add cached results + for img_path, tech in cached_results: + results.append( + ImageScore( + path=img_path, + source_path=source_map.get(img_path) if source_map else None, + technical=tech, ) - except Exception as e: - print(f" ⚠ Tech score failed for {img_path.name}: {e}") + ) + + # Parallel score uncached (ThreadPool — YOLO/OpenCV release GIL, avoids fork deadlocks) + if to_score: + n_workers = min(len(to_score), os.cpu_count() or 4) + with ThreadPoolExecutor(max_workers=n_workers) as pool: + for result in pool.map(_score_technical_with_cache, to_score): + if result is not None: + img_path, tech = result + results.append( + ImageScore( + path=img_path, + source_path=source_map.get(img_path) if source_map else None, + technical=tech, + ) + ) + else: + print(" ⚠ Tech score failed for an image") results.sort(key=lambda x: x.technical.get("composite", 0), reverse=True) print( @@ -1115,9 +1341,10 @@ def batch_technical_score( - Would cropping to portrait cut off wheels, handlebars, or exhaust? - Would the subject remain well-composed in Instagram's 3:4 grid thumbnail? -BRAND PRIORITY: This is a Ducati-focused account. If the motorcycle is identifiably NOT a Ducati -(e.g. Japanese brands, BMW, KTM, Harley, etc.), reduce SUBJECT_CLARITY and EMOTION scores by 3 -points each (minimum 1). Ducati bikes or unidentifiable brands score normally. +BRAND BONUS: This is a Ducati-focused account. If you can identify the motorcycle as a Ducati +(by logo, livery, bodywork shape, or distinctive features like trellis frame, desmo, Panigale +fairings, etc.), add 2 bonus points to SUBJECT_CLARITY and EMOTION (max 10 each). +All other brands or unidentifiable bikes score normally — do NOT penalize them. Return ONLY valid JSON, no markdown: {{"subject_clarity": N, "lighting": N, "color_pop": N, "emotion": N, "scroll_stop": N, "crop_4x5": N, "total": N, "one_line": "why this works or doesn't"}}""" @@ -1140,7 +1367,7 @@ def batch_technical_score( - Score each criterion as an integer from 0 to 10. - total must equal the sum of the 6 criterion scores (0 to 60). - one_line must be exactly one concise sentence describing this specific image. -- BRAND PRIORITY: This is a Ducati-focused account. If the motorcycle is identifiably NOT a Ducati (e.g. Japanese brands, BMW, KTM, Harley, etc.), reduce subject_clarity and emotion by 3 each (minimum 1). Ducati or unidentifiable brands score normally. +- BRAND BONUS: This is a Ducati-focused account. If the motorcycle is identifiably a Ducati, add 2 bonus points to subject_clarity and emotion (max 10 each). All other brands or unidentifiable bikes score normally — do NOT penalize them. """ OLLAMA_STRICT_JSON_SCHEMA = { @@ -2002,83 +2229,181 @@ def score_one_with_retry(item: ImageScore) -> dict: scored = 0 failed = 0 - score_iter = candidates - progress_write = print active_claude_model = claude_model_used or resolve_claude_model(cli_model=claude_model) + if scorer == "claude": + # --- Adaptive concurrent Claude scoring --- + CLAUDE_INITIAL_CONCURRENCY = 3 + CLAUDE_MAX_CONCURRENCY = 8 + CLAUDE_MIN_CONCURRENCY = 1 + CLAUDE_MAX_RETRIES = 3 + CLAUDE_RETRY_BASE_SEC = 1.0 + + concurrency = CLAUDE_INITIAL_CONCURRENCY + rate_limit_hits = 0 + progress_bar = None + progress_write = print try: from tqdm.auto import tqdm - - score_iter = tqdm( - candidates, - total=len(candidates), - desc=f" {scorer.capitalize()} scoring", - unit="img", + progress_bar = tqdm( + total=len(candidates), desc=" Claude scoring", unit="img", ) progress_write = tqdm.write - except Exception as e: - print(f" ⚠ Progress bar unavailable: {e}") + except Exception: + pass - for item in score_iter: - try: - if scorer == "claude": - source_for_cache = item.source_path or item.path - source_sha256 = _file_sha256(source_for_cache) - cached = None if rescore else load_claude_score_from_file_cache( + def _is_rate_limit(e: Exception) -> bool: + text = str(e).lower() + return "429" in text or "rate" in text or "overloaded" in text or "529" in text + + def _claude_score_one(item: ImageScore) -> tuple[ImageScore, Optional[dict], Optional[Exception], bool]: + """Score one image with Claude. Returns (item, vision_dict, error, was_cached).""" + source_for_cache = item.source_path or item.path + source_sha = _file_sha256(source_for_cache) + if not rescore: + cached = load_claude_score_from_file_cache( source_path=source_for_cache, - source_sha256=source_sha256, + source_sha256=source_sha, model=active_claude_model, prompt_sha256=claude_prompt_hash, strict_model=False, ) if cached is not None: - item.vision = cached - claude_cache_hits += 1 - else: - item.vision = score_with_claude( - item.path, - api_key=claude_api_key, + return (item, cached, None, True) + try: + vision = score_with_claude( + item.path, + api_key=claude_api_key, + model=active_claude_model, + client=claude_client, + use_yolo_context=True, + prompt=claude_base_prompt, + ) + try: + save_claude_score_to_file_cache( + source_path=source_for_cache, + source_sha256=source_sha, model=active_claude_model, - client=claude_client, - use_yolo_context=True, # Enable YOLO context for Claude scoring - prompt=claude_base_prompt, + prompt_sha256=claude_prompt_hash, + vision=vision, ) - try: - save_claude_score_to_file_cache( - source_path=source_for_cache, - source_sha256=source_sha256, - model=active_claude_model, - prompt_sha256=claude_prompt_hash, - vision=item.vision, - ) - except Exception as e: - progress_write( - f" ⚠ Could not write cache for {source_for_cache.name}: {e}" - ) - claude_api_calls += 1 - else: - item.vision = score_with_clip(item.path, clip_model, clip_processor) + except Exception: + pass + return (item, vision, None, False) + except Exception as e: + return (item, None, e, False) - # Final composite: 30% technical + 70% vision - vision_normalized = item.vision.get("total", 30) / 60.0 + def _finalize(item: ImageScore, vision: dict) -> None: + item.vision = vision + vision_normalized = vision.get("total", 30) / 60.0 base_score = item.technical["composite"] * 0.3 + vision_normalized * 0.7 - if scorer == "claude": - gate = _claude_crop_gate_multiplier(item.vision) - item.final_score = base_score * gate - else: - item.final_score = base_score - item.one_line = item.vision.get("one_line", "") - scored += 1 - except Exception as e: - progress_write(f" ⚠ Vision score failed for {item.path.name}: {e}") - item.final_score = item.technical["composite"] * 0.3 - item.one_line = "Vision scoring failed — ranked by technical score only" - failed += 1 + gate = _claude_crop_gate_multiplier(vision) + item.final_score = base_score * gate + item.one_line = vision.get("one_line", "") - if scorer == "claude" and hasattr(score_iter, "close"): - score_iter.close() - if scorer == "claude": - print(f" šŸ“¦ Claude cache hits: {claude_cache_hits} | API calls: {claude_api_calls}") + pending: dict = {} + next_idx = 0 + retry_queue: list[tuple[ImageScore, int]] = [] # (item, attempt) + + with ThreadPoolExecutor(max_workers=CLAUDE_MAX_CONCURRENCY) as executor: + # Fill initial batch + while next_idx < len(candidates) and len(pending) < concurrency: + future = executor.submit(_claude_score_one, candidates[next_idx]) + pending[future] = (candidates[next_idx], 0) + next_idx += 1 + + while pending or retry_queue: + # Submit retries if we have capacity + while retry_queue and len(pending) < concurrency: + retry_item, attempt = retry_queue.pop(0) + future = executor.submit(_claude_score_one, retry_item) + pending[future] = (retry_item, attempt) + + if not pending: + break + + done, _ = wait(pending.keys(), return_when=FIRST_COMPLETED) + for future in done: + item, attempt = pending.pop(future) + result_item, vision, error, was_cached = future.result() + + if vision is not None: + _finalize(result_item, vision) + if was_cached: + claude_cache_hits += 1 + else: + claude_api_calls += 1 + scored += 1 + # Scale up on success (slowly) + if concurrency < CLAUDE_MAX_CONCURRENCY and claude_api_calls % 5 == 0: + concurrency = min(concurrency + 1, CLAUDE_MAX_CONCURRENCY) + elif error is not None: + if _is_rate_limit(error) and attempt < CLAUDE_MAX_RETRIES: + rate_limit_hits += 1 + # Back off: reduce concurrency and retry with delay + concurrency = max(CLAUDE_MIN_CONCURRENCY, concurrency - 1) + backoff = CLAUDE_RETRY_BASE_SEC * (2 ** attempt) + progress_write( + f" ā³ Rate limited, backing off {backoff:.1f}s " + f"(concurrency → {concurrency})" + ) + time.sleep(backoff) + retry_queue.append((result_item, attempt + 1)) + elif attempt < CLAUDE_MAX_RETRIES and "timeout" in str(error).lower(): + retry_queue.append((result_item, attempt + 1)) + else: + progress_write( + f" ⚠ Vision score failed for {result_item.path.name}: {error}" + ) + result_item.final_score = result_item.technical["composite"] * 0.3 + result_item.one_line = "Vision scoring failed — ranked by technical score only" + failed += 1 + + if progress_bar is not None: + progress_bar.update(1) + + # Submit next items up to current concurrency + while next_idx < len(candidates) and len(pending) < concurrency: + future = executor.submit(_claude_score_one, candidates[next_idx]) + pending[future] = (candidates[next_idx], 0) + next_idx += 1 + + if progress_bar is not None: + progress_bar.close() + print( + f" šŸ“¦ Claude: {claude_cache_hits} cached, {claude_api_calls} API calls, " + f"{failed} failed, {rate_limit_hits} rate limits, concurrency peak {concurrency}" + ) + + else: + # CLIP scoring (sequential, local) + progress_write = print + score_iter = candidates + try: + from tqdm.auto import tqdm + score_iter = tqdm( + candidates, total=len(candidates), + desc=f" {scorer.capitalize()} scoring", unit="img", + ) + progress_write = tqdm.write + except Exception: + pass + + for item in score_iter: + try: + item.vision = score_with_clip(item.path, clip_model, clip_processor) + vision_normalized = item.vision.get("total", 30) / 60.0 + item.final_score = item.technical["composite"] * 0.3 + vision_normalized * 0.7 + item.one_line = item.vision.get("one_line", "") + scored += 1 + except Exception as e: + progress_write(f" ⚠ Vision score failed for {item.path.name}: {e}") + item.final_score = item.technical["composite"] * 0.3 + item.one_line = "Vision scoring failed — ranked by technical score only" + failed += 1 + + if hasattr(score_iter, "close"): + score_iter.close() candidates.sort(key=lambda x: x.final_score, reverse=True) print(f" āœ… Vision scoring: {scored} scored, {failed} failed") @@ -3163,6 +3488,27 @@ def _prepare_claude_crop_first_candidates( # --------------------------------------------------------------------------- +def _crop_one_image(args: tuple) -> tuple[int, dict]: + """Smart-crop one image for Stage 4. Module-level for ProcessPoolExecutor pickling.""" + idx, src_path, dest_path = args + meta: dict[str, object] = {} + try: + smart_crop(src_path, dest_path, save_debug=True, meta_out=meta) + except Exception: + try: + with Image.open(src_path) as img: + img = img.convert("RGB") + img = img.resize((OUTPUT_WIDTH, OUTPUT_HEIGHT), Image.LANCZOS) + img.save(dest_path, "JPEG", quality=95) + meta = { + "uncertain_crop": True, + "uncertain_crop_reasons": ["smart_crop_failed_used_center_resize_fallback"], + } + except Exception: + meta = {"_failed": True} + return (idx, meta) + + def run_pipeline( input_folder: str, output_folder: str = "selected", @@ -3215,12 +3561,62 @@ def run_pipeline( # --- Stage 1: Deduplicate --- print("\nšŸ” Stage 1: Deduplicating...") - unique = deduplicate(resized) + unique, burst_map = deduplicate(resized) # --- Stage 2: Technical scoring --- print("\nšŸ“Š Stage 2: Technical quality scoring...") scored = batch_technical_score(unique, source_map=source_map) + # Tag burst groups on scored items + for item in scored: + if item.path in burst_map: + item.burst_group = burst_map[item.path] + item.burst_selected_by = "sharpness" + + # --- Stage 2b: Re-evaluate bursts for top candidates --- + # For images selected from a burst, score all burst members technically + # in parallel and swap in the best one if different from the sharpness pick. + n_reeval = max(top_n * 2, int(len(scored) * 0.5)) + burst_items = [item for item in scored[:n_reeval] if item.burst_group and len(item.burst_group) >= 2] + burst_swaps = 0 + if burst_items: + from concurrent.futures import ThreadPoolExecutor as _TPE2b + # Collect all alt paths that need scoring (exclude already-scored representative) + alt_paths: list[Path] = [] + for item in burst_items: + for p in item.burst_group: + if p != item.path: + alt_paths.append(p) + # Score them all in parallel (ThreadPool — avoids YOLO fork deadlocks) + alt_scores: dict[Path, dict] = {} + if alt_paths: + n_workers = min(len(alt_paths), os.cpu_count() or 4) + with _TPE2b(max_workers=n_workers) as pool: + for result in zip(alt_paths, pool.map(_score_technical_with_cache, alt_paths)): + path, res = result + if res is not None: + alt_scores[res[0]] = res[1] + # Pick the best from each burst group + for item in burst_items: + best_path = item.path + best_composite = item.technical.get("composite", 0) + for alt_path in item.burst_group: + if alt_path == item.path: + continue + alt_tech = alt_scores.get(alt_path) + if alt_tech and alt_tech.get("composite", 0) > best_composite: + best_composite = alt_tech["composite"] + best_path = alt_path + if best_path != item.path: + item.path = best_path + item.source_path = source_map.get(best_path, item.source_path) + item.technical = alt_scores.get(best_path, item.technical) + item.burst_selected_by = "technical" + burst_swaps += 1 + if burst_swaps: + print(f" šŸ”„ Burst re-evaluation: swapped {burst_swaps} images for better technical scores") + scored.sort(key=lambda x: x.technical.get("composite", 0), reverse=True) + # --- Stage 3: Vision scoring --- if score_all: n_candidates = len(scored) @@ -3239,7 +3635,7 @@ def run_pipeline( if rescore: uncached = len(candidates) else: - claude_est_prompt = build_vision_prompt(DEFAULT_ACCOUNT_CONTEXT) + claude_est_prompt = build_vision_prompt(resolve_account_context(search_dir=src)) claude_est_hash = claude_prompt_sha256(claude_est_prompt) claude_est_model = claude_model or resolve_claude_model() uncached = 0 @@ -3297,35 +3693,49 @@ def run_pipeline( else: top = ranked[:top_n] + import shutil + from concurrent.futures import ThreadPoolExecutor as _TPE4 + + # Prepare output paths for each ranked image + output_plan: list[dict] = [] for i, item in enumerate(top): rank = i + 1 output_stem = (item.source_path or item.path).stem - dest_cropped = out / f"{rank:02d}_cropped_{output_stem}.jpg" - dest_hd = out / f"{rank:02d}_hd_{output_stem}.jpg" - dest_full = out / f"{rank:02d}_full_{output_stem}{(item.source_path or item.path).suffix}" - display_name = (item.source_path or item.path).name + output_plan.append({ + "rank": rank, + "item": item, + "dest_cropped": out / f"{rank:02d}_cropped_{output_stem}.jpg", + "dest_hd": out / f"{rank:02d}_hd_{output_stem}.jpg", + "dest_full": out / f"{rank:02d}_full_{output_stem}{(item.source_path or item.path).suffix}", + "display_name": (item.source_path or item.path).name, + }) + + crop_args = [ + (i, plan["item"].path, plan["dest_cropped"]) + for i, plan in enumerate(output_plan) + ] + crop_results: dict[int, dict] = {} + n_crop_workers = min(len(crop_args), os.cpu_count() or 4) + with _TPE4(max_workers=n_crop_workers) as pool: + for idx, meta in pool.map(_crop_one_image, crop_args): + crop_results[idx] = meta + + # Sequential: file copies + report assembly (fast I/O, needs ordering) + for i, plan in enumerate(output_plan): + item = plan["item"] + rank = plan["rank"] + dest_cropped = plan["dest_cropped"] + dest_hd = plan["dest_hd"] + dest_full = plan["dest_full"] + display_name = plan["display_name"] + crop_meta = crop_results.get(i, {}) padded_written = False - crop_meta: dict[str, object] = {} - # Cropped: IG 1080x1440 smart crop - try: - smart_crop(item.path, dest_cropped, save_debug=True, meta_out=crop_meta) - except Exception: - try: - with Image.open(item.path) as img: - img = img.convert("RGB") - img = img.resize((OUTPUT_WIDTH, OUTPUT_HEIGHT), Image.LANCZOS) - img.save(dest_cropped, "JPEG", quality=95) - crop_meta = { - "uncertain_crop": True, - "uncertain_crop_reasons": ["smart_crop_failed_used_center_resize_fallback"], - } - except Exception as e2: - print(f" ⚠ Could not process {item.path.name}: {e2}") - continue + if crop_meta.get("_failed"): + print(f" ⚠ Could not process {item.path.name}") + continue # HD: 1920px longest edge (work copy) - import shutil try: shutil.copy2(str(item.path), str(dest_hd)) except Exception as e3: @@ -3339,6 +3749,14 @@ def run_pipeline( except Exception as e3: print(f" ⚠ Could not copy full version for {display_name}: {e3}") + burst_info = None + if item.burst_group and len(item.burst_group) > 1: + burst_info = { + "count": len(item.burst_group), + "selected_by": item.burst_selected_by, + "members": [p.name for p in item.burst_group], + } + report.append( { "rank": rank, @@ -3352,6 +3770,7 @@ def run_pipeline( "output_full": dest_full.name if padded_written else None, "uncertain_crop": bool(crop_meta.get("uncertain_crop", False)), "uncertain_crop_reasons": crop_meta.get("uncertain_crop_reasons", []), + "burst": burst_info, } ) @@ -3376,16 +3795,786 @@ def run_pipeline( analyzed_items=ranked, ) + # Generate HTML gallery + gallery_path = generate_gallery(out, src) + print(f"\n{'=' * 60}") print(f"šŸ† Done! {len(top)} images saved to {out}/") print(f"šŸ—‚ļø Work folder retained: {work}") print(f"šŸ“‹ JSON Report: {report_json_path}") print(f"šŸ“ Markdown Report: {report_md_path}") + if gallery_path: + print(f"🌐 Gallery: {gallery_path}") print(f"{'=' * 60}") return report +# --------------------------------------------------------------------------- +# HTML Gallery +# --------------------------------------------------------------------------- + +_GALLERY_HTML_TEMPLATE = """\ + + + + + +{title} + + + +
+

+ + + + pickinsta {breadcrumb} {title} +

+
+
+ +
+
+ {summary_stats} +
+
+
+
+
+
+ {tiles} +
+
+
+

+
+
+
+

Preview

+
+ +
+
+ +
+
+ + +
+

AI Assessment

+

+
+
+

Scores

+
+
+
+
+
+ + + +""" + + +def _gallery_build_data(output_folder: Path, input_folder: Optional[Path] = None) -> list[dict]: + """Build gallery data from a selection output folder.""" + from urllib.parse import quote as _quote + + report_path = output_folder / "selection_report.json" + if not report_path.exists(): + return [] + + report_data = json.loads(report_path.read_text(encoding="utf-8")) + + # Try to find input folder from markdown report if not provided + if input_folder is None: + md = output_folder / "selection_report.md" + if md.exists(): + for line in md.read_text(encoding="utf-8").splitlines()[:10]: + if line.startswith("- Input:") and "`" in line: + p = Path(line.split("`")[1]) + if p.exists(): + input_folder = p + break + + criteria = ["subject_clarity", "lighting", "color_pop", "emotion", "scroll_stop", "crop_4x5"] + data = [] + for item in report_data: + cropped_name = item.get("output_cropped") or item.get("output", "") + + # YOLO debug + yolo_json_path = output_folder / f"debug_yolo_{cropped_name}.json" + yolo = None + if yolo_json_path.exists(): + try: + yolo = json.loads(yolo_json_path.read_text(encoding="utf-8")) + except Exception: + pass + yolo_debug_img = f"debug_yolo_{cropped_name}" + yolo_debug_exists = (output_folder / yolo_debug_img).exists() + + # Vision detail from cache + vision_detail = {} + source_name = item.get("filename", "") + if input_folder: + cache_file = input_folder / f"{source_name}.pickinsta.json" + if cache_file.exists(): + try: + cache = json.loads(cache_file.read_text(encoding="utf-8")) + vision = cache.get("vision", {}) + if any(c in vision for c in criteria): + vision_detail = {c: vision.get(c, 0) for c in criteria} + except Exception: + pass + + # EXIF + exif_info = {} + if input_folder: + source_file = input_folder / source_name + if source_file.exists(): + try: + from PIL.ExifTags import TAGS as _EXIF_TAGS + with Image.open(source_file) as _eimg: + raw_exif = _eimg._getexif() or {} + tagged = {_EXIF_TAGS.get(k, k): v for k, v in raw_exif.items()} + if tagged.get("Make"): + make = tagged["Make"].strip() + model = tagged.get("Model", "").strip() + if model.startswith(make): + exif_info["camera"] = model + else: + exif_info["camera"] = f"{make} {model}".strip() + if tagged.get("LensModel"): + exif_info["lens"] = str(tagged["LensModel"]).strip() + if tagged.get("FocalLength"): + fl = tagged["FocalLength"] + exif_info["focal"] = f"{float(fl):.0f}mm" + if tagged.get("FNumber"): + exif_info["aperture"] = f"f/{float(tagged['FNumber']):.1f}" + if tagged.get("ExposureTime"): + et = float(tagged["ExposureTime"]) + if et >= 1: + exif_info["shutter"] = f"{et:.1f}s" + else: + exif_info["shutter"] = f"1/{int(round(1/et))}s" + if tagged.get("ISOSpeedRatings"): + exif_info["iso"] = f"ISO {tagged['ISOSpeedRatings']}" + if tagged.get("DateTimeOriginal"): + exif_info["date"] = str(tagged["DateTimeOriginal"]) + except Exception: + pass + + data.append({ + "rank": item["rank"], + "filename": source_name, + "final_score": item.get("final_score", 0), + "technical_composite": item.get("technical_composite", 0), + "vision_total": item.get("vision_total", 0), + "one_line": item.get("one_line", ""), + "output_cropped": _quote(item.get("output_cropped") or item.get("output", "")), + "output_hd": _quote(item.get("output_hd", "")), + "output_full": _quote(item.get("output_full", "")), + "uncertain_crop": item.get("uncertain_crop", False), + "vision_detail": vision_detail, + "yolo": yolo, + "exif": exif_info, + "burst": item.get("burst"), + "yolo_debug_img": _quote(yolo_debug_img) if yolo_debug_exists else None, + }) + return data + + +def generate_gallery( + output_folder: Path, + input_folder: Optional[Path] = None, + gallery_root: Optional[Path] = None, +) -> Optional[Path]: + """Generate an index.html gallery in the output folder. Returns path or None.""" + data = _gallery_build_data(output_folder, input_folder) + if not data: + return None + + title = output_folder.name + + if data: + avg_final = sum(d["final_score"] for d in data) / len(data) + avg_tech = sum(d["technical_composite"] for d in data) / len(data) + avg_vision = sum(d["vision_total"] for d in data) / len(data) + stats = [ + ("Images", str(len(data))), + ("Top score", f"{data[0]['final_score']:.3f}"), + ("Avg final", f"{avg_final:.3f}"), + ("Avg technical", f"{avg_tech:.3f}"), + ("Avg vision", f"{avg_vision:.1f}/60"), + ("Uncertain crops", str(sum(1 for d in data if d["uncertain_crop"]))), + ] + else: + stats = [("Images", "0")] + + summary_html = "\n".join( + f'
{label}
' + f'
{value}
' + for label, value in stats + ) + tiles_html = "" + for i, d in enumerate(data): + uncertain_badge = ( + '' + '' + '' + if d.get("uncertain_crop") else "" + ) + burst = d.get("burst") + burst_badge = ( + f'' + f'📷{burst["count"]}' + if burst else "" + ) + tiles_html += ( + f'
' + f'' + f'#{d["rank"]}' + f'{uncertain_badge}' + f'{burst_badge}' + f'{d["final_score"]:.3f}' + f'
\n' + ) + + # Breadcrumb + breadcrumb = "" + if gallery_root and output_folder != gallery_root: + try: + parts = output_folder.relative_to(gallery_root).parts + crumbs = ['Home'] + for i, part in enumerate(parts[:-1]): + depth = len(parts) - i - 1 + href = "../" * depth + crumbs.append(f'{part}') + sep = '/' + breadcrumb = f'{sep.join(crumbs)}{sep}' + except ValueError: + pass + + html = _GALLERY_HTML_TEMPLATE.format( + title=title, + summary_stats=summary_html, + tiles=tiles_html, + json_data=json.dumps(data, indent=None), + breadcrumb=breadcrumb, + ) + gallery_path = output_folder / "index.html" + gallery_path.write_text(html, encoding="utf-8") + return gallery_path + + +_INDEX_HTML_TEMPLATE = """\ + + + + + +{title} + + + +

+ + + + pickinsta {breadcrumb} {title} +

+
    +{rows} +
+ + +""" + + +def generate_gallery_index(root: Path) -> list[Path]: + """Generate index.html files for all directories, recursively. + + Leaf directories with selection_report.json get a gallery. + Parent directories get a folder listing linking to children. + Returns list of all generated index.html paths. + """ + from urllib.parse import quote as _quote + + # First, generate all leaf galleries + generated: list[Path] = [] + reports = sorted(root.rglob("selection_report.json")) + gallery_dirs: set[Path] = set() + for rpt in reports: + result = generate_gallery(rpt.parent, gallery_root=root) + if result: + generated.append(result) + gallery_dirs.add(rpt.parent) + + # Now generate index pages for every ancestor directory + # that contains galleries (directly or nested) + index_dirs: set[Path] = set() + for gdir in gallery_dirs: + parent = gdir.parent + while parent >= root: + index_dirs.add(parent) + if parent == root: + break + parent = parent.parent + + for idx_dir in sorted(index_dirs): + # Collect immediate children that either have a gallery or have their own index + children = [] + for child in sorted(idx_dir.iterdir()): + if not child.is_dir(): + continue + # Count galleries under this child + child_reports = list(child.rglob("selection_report.json")) + if not child_reports: + continue + total_images = 0 + for cr in child_reports: + try: + data = json.loads(cr.read_text(encoding="utf-8")) + total_images += len(data) + except Exception: + pass + # Find a thumbnail from the first gallery + thumb = "" + first_report = child_reports[0] + try: + data = json.loads(first_report.read_text(encoding="utf-8")) + if data: + cropped = data[0].get("output_cropped") or data[0].get("output", "") + if cropped: + rel_path = first_report.parent.relative_to(idx_dir) + thumb = str(rel_path / cropped) + except Exception: + pass + + children.append({ + "name": child.name, + "path": child.name + "/", + "galleries": len(child_reports), + "images": total_images, + "thumb": _quote(thumb) if thumb else "", + }) + + if not children: + continue + + rows_html = "" + for c in children: + thumb_html = ( + f'' + if c["thumb"] else "" + ) + sub = f'{c["galleries"]} sessions, ' if c["galleries"] > 1 else "" + rows_html += ( + f'
  • ' + f'{thumb_html}' + f'{c["name"]}' + f'{sub}{c["images"]} images' + f'
  • \n' + ) + + # Breadcrumb + breadcrumb = "" + if idx_dir != root: + parts = idx_dir.relative_to(root).parts + crumbs = ['Home'] + for i, part in enumerate(parts[:-1]): + depth = len(parts) - i - 1 + href = "../" * depth + crumbs.append(f'{part}') + sep = '/' + breadcrumb = f'{sep.join(crumbs)}{sep}' + + html = _INDEX_HTML_TEMPLATE.format( + title=idx_dir.name, + breadcrumb=breadcrumb, + rows=rows_html, + ) + idx_path = idx_dir / "index.html" + idx_path.write_text(html, encoding="utf-8") + generated.append(idx_path) + + return generated + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- diff --git a/tests/test_full_integration.py b/tests/test_full_integration.py index 057424d..d293053 100644 --- a/tests/test_full_integration.py +++ b/tests/test_full_integration.py @@ -68,7 +68,7 @@ def fake_smart_crop(image_path, output_path, **_kwargs): return output_path monkeypatch.setattr(selector, "resize_for_processing", fake_resize_for_processing) - monkeypatch.setattr(selector, "deduplicate", lambda images: images) + monkeypatch.setattr(selector, "deduplicate", lambda images: (images, {})) monkeypatch.setattr(selector, "batch_technical_score", fake_batch_technical_score) monkeypatch.setattr(selector, "batch_vision_score", fake_batch_vision_score) monkeypatch.setattr(selector, "smart_crop", fake_smart_crop) @@ -82,13 +82,11 @@ def fake_smart_crop(image_path, output_path, **_kwargs): assert len(report) == 1 assert report[0]["filename"] == "a.jpg" - assert report[0]["output"] == f"01_{work_a.stem}.jpg" - assert report[0]["output_full_subject"] == f"01_full_{work_a.stem}.jpg" + assert report[0]["output_cropped"] == f"01_cropped_{work_a.stem}.jpg" + assert report[0]["output_full"] is not None - output_image = output_dir / report[0]["output"] + output_image = output_dir / report[0]["output_cropped"] assert output_image.exists() - output_full = output_dir / report[0]["output_full_subject"] - assert output_full.exists() report_json = output_dir / "selection_report.json" report_md = output_dir / "selection_report.md" @@ -119,7 +117,7 @@ def test_run_pipeline_skips_padded_variant_when_crop_is_confident(tmp_path, monk "resize_for_processing", lambda _src, _work: ([work_a], {work_a: source_a}), ) - monkeypatch.setattr(selector, "deduplicate", lambda images: images) + monkeypatch.setattr(selector, "deduplicate", lambda images: (images, {})) monkeypatch.setattr( selector, "batch_technical_score", @@ -152,9 +150,8 @@ def fake_smart_crop(image_path, output_path, **kwargs): ) assert len(report) == 1 - assert report[0]["output_full_subject"] is None assert report[0]["uncertain_crop"] is False - assert not any(p.name.startswith("01_full_") for p in output_dir.glob("*.jpg")) + assert report[0]["output_full"] is not None def test_run_pipeline_missing_input_exits(tmp_path) -> None: @@ -211,7 +208,7 @@ def fake_smart_crop(image_path, output_path, **kwargs): return output_path monkeypatch.setattr(selector, "resize_for_processing", fake_resize_for_processing) - monkeypatch.setattr(selector, "deduplicate", lambda images: images) + monkeypatch.setattr(selector, "deduplicate", lambda images: (images, {})) monkeypatch.setattr(selector, "batch_technical_score", fake_batch_technical_score) monkeypatch.setattr(selector, "batch_vision_score", fake_batch_vision_score) monkeypatch.setattr(selector, "smart_crop", fake_smart_crop) @@ -272,7 +269,7 @@ def fake_smart_crop(image_path, output_path, **kwargs): return output_path monkeypatch.setattr(selector, "resize_for_processing", fake_resize_for_processing) - monkeypatch.setattr(selector, "deduplicate", lambda images: images) + monkeypatch.setattr(selector, "deduplicate", lambda images: (images, {})) monkeypatch.setattr(selector, "batch_technical_score", fake_batch_technical_score) monkeypatch.setattr(selector, "batch_vision_score", fake_batch_vision_score) monkeypatch.setattr(selector, "smart_crop", fake_smart_crop) From ccb9db91b2e81be2affdf804d4741a6b2ed15707 Mon Sep 17 00:00:00 2001 From: Renato Bonomini <4005901+renatobo@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:41:57 +0000 Subject: [PATCH 6/6] feat: add dedup-only mode and ollama benchmark report --- CLAUDE.md | 7 + ...-model-speed-benchmark-report-serverone.md | 30 ++ src/pickinsta/ig_image_selector.py | 360 ++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 docs/ollama-model-speed-benchmark-report-serverone.md diff --git a/CLAUDE.md b/CLAUDE.md index 46e7637..49d002b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,12 @@ pickinsta ./input --scorer claude --claude-model claude-sonnet-4-6 --rescore # Ollama scorer (self-hosted), score all candidates pickinsta ./input --output ./selected --scorer ollama --all + +# Dedup-only: best shot per burst, all unique images, no scoring/ranking +pickinsta ./input --output ./deduped --dedup-only + +# Dedup-only with separate work folder +pickinsta ./input --output ./deduped --work ./work --dedup-only ``` ### Environment Setup @@ -161,6 +167,7 @@ Key variables: - `--claude-model` — override Claude model - `--claude-crop-first` — pre-crop to 1080x1440 before Claude scoring - `--rescore` — force re-scoring, ignoring all cached vision scores +- `--dedup-only` — dedup-only mode: best shot per burst, output all unique images as full/hd/cropped (no scoring, no ranking, no debug) ### Testing diff --git a/docs/ollama-model-speed-benchmark-report-serverone.md b/docs/ollama-model-speed-benchmark-report-serverone.md new file mode 100644 index 0000000..deffd23 --- /dev/null +++ b/docs/ollama-model-speed-benchmark-report-serverone.md @@ -0,0 +1,30 @@ +# Ollama Model Speed Benchmark Report + +- Generated: `2026-02-22T23:39:26` +- Input folder: `/home/renatobo/pickinsta/input` +- Candidates scored per run: `42` +- Runs per model: `1` +- Warmup run before timed runs: `True` +- YOLO context enabled: `False` +- Ollama base URL: `http://localhost:11434` +- Concurrency: `2` +- Max retries: `2` +- Keep alive: `15m` + +## Summary + +| Model | Avg sec/img | Avg imgs/min | Avg duration (s) | Avg failures/run | Speed vs fastest | +|---|---:|---:|---:|---:|---:| +| blaifa/InternVL3_5:4B | 29.54 | 2.03 | 1240.89 | 0.00 | 1.00x | +| blaifa/InternVL3_5:8b | 47.09 | 1.27 | 1977.66 | 0.00 | 1.59x | +| openbmb/minicpm-v4.5:8b | 79.36 | 0.76 | 3333.28 | 0.00 | 2.69x | +| qwen3-vl:8b | 168.54 | 0.36 | 7078.74 | 3.00 | 5.70x | + +## Per-run Details + +| Model | Run | Duration (s) | Sec/img | Imgs/min | Failures | +|---|---:|---:|---:|---:|---:| +| qwen3-vl:8b | 1 | 7078.74 | 168.54 | 0.36 | 3 | +| blaifa/InternVL3_5:8b | 1 | 1977.66 | 47.09 | 1.27 | 0 | +| blaifa/InternVL3_5:4B | 1 | 1240.89 | 29.54 | 2.03 | 0 | +| openbmb/minicpm-v4.5:8b | 1 | 3333.28 | 79.36 | 0.76 | 0 | diff --git a/src/pickinsta/ig_image_selector.py b/src/pickinsta/ig_image_selector.py index 70042b4..9155732 100644 --- a/src/pickinsta/ig_image_selector.py +++ b/src/pickinsta/ig_image_selector.py @@ -3488,6 +3488,349 @@ def _prepare_claude_crop_first_candidates( # --------------------------------------------------------------------------- +def run_dedup_only( + input_folder: str, + output_folder: str = "selected", + work_folder: Optional[str] = None, +): + """ + Dedup-only mode: resize → deduplicate → output all unique images. + Best shot per burst selected by sharpness, then technical re-evaluation. + Outputs full/hd/cropped variants only — no scoring, no ranking, no debug. + """ + import shutil + from concurrent.futures import ThreadPoolExecutor + + src = Path(input_folder) + out = Path(output_folder) + work = Path(work_folder) if work_folder else src.parent / f"{src.name}_work" + + if not src.exists(): + print(f"āŒ Input folder not found: {src}") + sys.exit(1) + + print("=" * 60) + print("šŸļø Dedup-Only Mode") + print(f" Input: {src}") + print(f" Output: {out}") + print("=" * 60) + + # Stage 0: Resize + print(f"\nšŸ“ Stage 0: Resizing to max {MAX_RESIZE_PX}px...") + resized, source_map = resize_for_processing(src, work) + if not resized: + print("āŒ No valid images found.") + sys.exit(1) + + # Stage 1: Deduplicate + print("\nšŸ” Stage 1: Deduplicating (burst detection)...") + unique, burst_map = deduplicate(resized) + + # Stage 2b: Burst re-evaluation with technical scoring + burst_items = [ + (path, burst_map[path]) for path in unique if path in burst_map + ] + burst_swaps = 0 + if burst_items: + from concurrent.futures import ThreadPoolExecutor as _TPEb + alt_paths = [] + for rep, members in burst_items: + for p in members: + if p != rep: + alt_paths.append(p) + alt_scores: dict[Path, dict] = {} + if alt_paths: + n_workers = min(len(alt_paths), os.cpu_count() or 4) + with _TPEb(max_workers=n_workers) as pool: + for result in zip(alt_paths, pool.map(_score_technical_with_cache, alt_paths)): + path, res = result + if res is not None: + alt_scores[res[0]] = res[1] + # Also score the representatives + rep_paths = [rep for rep, _ in burst_items] + if rep_paths: + n_workers = min(len(rep_paths), os.cpu_count() or 4) + with _TPEb(max_workers=n_workers) as pool: + for result in zip(rep_paths, pool.map(_score_technical_with_cache, rep_paths)): + path, res = result + if res is not None: + alt_scores[res[0]] = res[1] + + new_unique = [] + for path in unique: + if path not in burst_map: + new_unique.append(path) + continue + members = burst_map[path] + best_path = path + best_composite = alt_scores.get(path, {}).get("composite", 0) + for m in members: + mc = alt_scores.get(m, {}).get("composite", 0) + if mc > best_composite: + best_composite = mc + best_path = m + if best_path != path: + burst_swaps += 1 + new_unique.append(best_path) + unique = new_unique + + if burst_swaps: + print(f" šŸ”„ Burst re-evaluation: swapped {burst_swaps} images for better technical scores") + + # Build burst lookup for gallery (keyed on selected path) + burst_info_map: dict[Path, list[Path]] = {} + for orig_rep, members in burst_map.items(): + # Find which unique path corresponds to this burst + for u in unique: + if u == orig_rep or u in members: + burst_info_map[u] = members + break + + # Output + out.mkdir(parents=True, exist_ok=True) + print(f"\nāœ‚ļø Outputting {len(unique)} images (full/hd/cropped)...") + + # Naming: _full., _hd.jpg, _cropped.jpg + output_plan = [] + for i, work_path in enumerate(unique): + output_stem = (source_map.get(work_path) or work_path).stem + source_ext = (source_map.get(work_path) or work_path).suffix + output_plan.append({ + "work_path": work_path, + "source_path": source_map.get(work_path), + "dest_cropped": out / f"{output_stem}_cropped.jpg", + "dest_hd": out / f"{output_stem}_hd.jpg", + "dest_full": out / f"{output_stem}_full{source_ext}", + "display_name": (source_map.get(work_path) or work_path).name, + }) + + crop_args = [ + (i, plan["work_path"], plan["dest_cropped"]) + for i, plan in enumerate(output_plan) + ] + crop_results: dict[int, dict] = {} + n_workers = min(len(crop_args), os.cpu_count() or 4) + with ThreadPoolExecutor(max_workers=n_workers) as pool: + for idx, meta in pool.map(_crop_one_image_no_debug, crop_args): + crop_results[idx] = meta + + gallery_items = [] + written = 0 + for i, plan in enumerate(output_plan): + crop_meta = crop_results.get(i, {}) + if crop_meta.get("_failed"): + print(f" ⚠ Could not crop {plan['work_path'].name}") + continue + + # HD + try: + shutil.copy2(str(plan["work_path"]), str(plan["dest_hd"])) + except Exception: + pass + + # Full + source = plan["source_path"] or plan["work_path"] + try: + shutil.copy2(str(source), str(plan["dest_full"])) + except Exception: + pass + + burst_members = burst_info_map.get(unique[i]) + + # EXIF + exif_info = {} + source_file = plan["source_path"] or plan["work_path"] + try: + from PIL.ExifTags import TAGS as _EXIF_TAGS + with Image.open(source_file) as _eimg: + raw_exif = _eimg._getexif() or {} + tagged = {_EXIF_TAGS.get(k, k): v for k, v in raw_exif.items()} + if tagged.get("Make"): + make = tagged["Make"].strip() + model = tagged.get("Model", "").strip() + exif_info["camera"] = model if model.startswith(make) else f"{make} {model}".strip() + if tagged.get("LensModel"): + exif_info["lens"] = str(tagged["LensModel"]).strip() + if tagged.get("FocalLength"): + exif_info["focal"] = f"{float(tagged['FocalLength']):.0f}mm" + if tagged.get("FNumber"): + exif_info["aperture"] = f"f/{float(tagged['FNumber']):.1f}" + if tagged.get("ExposureTime"): + et = float(tagged["ExposureTime"]) + exif_info["shutter"] = f"{et:.1f}s" if et >= 1 else f"1/{int(round(1/et))}s" + if tagged.get("ISOSpeedRatings"): + exif_info["iso"] = f"ISO {tagged['ISOSpeedRatings']}" + if tagged.get("DateTimeOriginal"): + exif_info["date"] = str(tagged["DateTimeOriginal"]) + except Exception: + pass + + gallery_items.append({ + "filename": plan["display_name"], + "cropped": plan["dest_cropped"].name, + "hd": plan["dest_hd"].name, + "full": plan["dest_full"].name, + "burst_count": len(burst_members) if burst_members else 0, + "exif": exif_info, + }) + written += 1 + + # Simplified gallery + _generate_dedup_gallery(out, gallery_items) + + print(f"\n{'=' * 60}") + print(f"šŸ† Done! {written} unique images saved to {out}/") + print(f"šŸ—‚ļø Work folder retained: {work}") + print(f"🌐 Gallery: {out / 'index.html'}") + print(f"{'=' * 60}") + + +_DEDUP_GALLERY_TEMPLATE = """\ + + + + + +{title} + + + +

    pickinsta {title} — dedup

    +
    {count} unique images
    +
    +
    {tiles}
    +
    +

    +
    + +
    +
    +
    + + + +""" + + +def _generate_dedup_gallery(output_folder: Path, items: list[dict]) -> None: + """Generate a simplified gallery for dedup-only mode.""" + from urllib.parse import quote as _q + + title = output_folder.name + tiles = "" + for i, item in enumerate(items): + burst_badge = ( + f'📷{item["burst_count"]}' + if item.get("burst_count", 0) > 1 else "" + ) + tiles += ( + f'
    ' + f'' + f'{burst_badge}' + f'
    \n' + ) + + json_items = [ + {"filename": it["filename"], "cropped": _q(it["cropped"]), + "hd": _q(it["hd"]), "full": _q(it["full"]), + "exif": it.get("exif", {})} + for it in items + ] + + html = _DEDUP_GALLERY_TEMPLATE.format( + title=title, + count=len(items), + tiles=tiles, + json_data=json.dumps(json_items, indent=None), + ) + (output_folder / "index.html").write_text(html, encoding="utf-8") + + +def _crop_one_image_no_debug(args: tuple) -> tuple[int, dict]: + """Smart-crop one image without debug output. For dedup-only mode.""" + idx, src_path, dest_path = args + meta: dict[str, object] = {} + try: + smart_crop(src_path, dest_path, save_debug=False, meta_out=meta) + except Exception: + try: + with Image.open(src_path) as img: + img = img.convert("RGB") + img = img.resize((OUTPUT_WIDTH, OUTPUT_HEIGHT), Image.LANCZOS) + img.save(dest_path, "JPEG", quality=95) + meta = {"uncertain_crop": True} + except Exception: + meta = {"_failed": True} + return (idx, meta) + + def _crop_one_image(args: tuple) -> tuple[int, dict]: """Smart-crop one image for Stage 4. Module-level for ProcessPoolExecutor pickling.""" idx, src_path, dest_path = args @@ -4652,9 +4995,26 @@ def main(): action="store_true", help="Force re-scoring all images, ignoring cached vision scores.", ) + parser.add_argument( + "--dedup-only", + dest="dedup_only", + action="store_true", + help=( + "Dedup-only mode: select best shot per burst, output all unique images " + "as full/hd/cropped. No scoring, no ranking, no debug files." + ), + ) args = parser.parse_args() + if args.dedup_only: + run_dedup_only( + input_folder=args.input, + output_folder=args.output, + work_folder=args.work, + ) + sys.exit(0) + run_pipeline( input_folder=args.input, output_folder=args.output,