<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ai Video Maker]]></title><description><![CDATA[Ai Video Maker]]></description><link>https://aivideomaker.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 10:52:36 GMT</lastBuildDate><atom:link href="https://aivideomaker.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Stop Eyeballing Outputs and Evaluate AI Image Prompts With a Python Contact-Sheet Scorer]]></title><description><![CDATA[Stop relying on gut feel and evaluate AI image prompts systematically with a Python contact-sheet builder, 4-axis rubric weights, and JSONL logs.]]></description><link>https://aivideomaker.hashnode.dev/stop-eyeballing-outputs-and-evaluate-ai-image-prompts-with-a-python-contact-sheet-scorer</link><guid isPermaLink="true">https://aivideomaker.hashnode.dev/stop-eyeballing-outputs-and-evaluate-ai-image-prompts-with-a-python-contact-sheet-scorer</guid><dc:creator><![CDATA[virse]]></dc:creator><pubDate>Thu, 22 Oct 2026 18:47:47 GMT</pubDate><content:encoded><![CDATA[<img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/1026dbad-ac5a-4599-9574-3aea07ca4f40.webp" alt="Studio light table with a two-row grid of architectural chair test photographs, optical loupe, and gray reference card used to evaluate AI image prompts" style="display:block;margin:0 auto" />

<p>When a team changes three adjectives in a prompt, drops six uncalibrated PNGs into a Slack thread, and votes by emoji reaction, they fail to evaluate AI image prompts in any way that can be reproduced next week. A reliable evaluation workflow locks the creative brief into discrete structural fields, renders candidate outputs across model tiers into a single normalized Python contact sheet, and logs a four-axis weighted rubric into append-only JSONL and CSV files. Put the candidate frames on one dark-gray grid at identical pixel dimensions, and you can tell within thirty seconds whether a bad shadow came from vague prompt wording or from the model tier hitting its geometric ceiling.</p>
<p>AI assisted in drafting this technical tutorial. I ran and verified the Python contact-sheet and JSONL scoring script locally, and this article includes one link to Virse as the workspace example for side-by-side Seedream 5.0 prompt testing.</p>
<h2>Why engineering teams cannot evaluate AI image prompts by gut feel alone</h2>
<p>Scrolling through single images in a browser generation history mixes three separate variables into one blurry impression: prompt wording edits, random sampling variation, and model tier capabilities. Someone tweaks five lighting adjectives while switching aspect ratios or model tiers at the same time. One frame comes back with a crisp rim light on a chair armrest, so the team declares the new prompt a winner. Then they run a batch of twenty variations for a campaign page, and fourteen of them have floating floor shadows or melted leg joints.</p>
<p>If you are generating a one-off illustration for an internal slide deck where any pleasant picture works, you can ignore all of this and pick by eye. Once you maintain reusable prompt templates for ecommerce staging, architectural studies, or multi-model pipelines, gut-feel voting breaks down fast.</p>
<p>We learned this the hard way when we first tried to automate prompt regression testing with simple image telemetry alone. Our initial script measured only grayscale luminance standard deviation and Canny edge density, assuming that higher local contrast and sharper edges meant a better render. That shortcut failed on its first real test: a garbled frame with harsh neon artifacts and a five-legged lounge chair scored higher on raw contrast and edge density than a quiet, physically grounded studio render.</p>
<p>Research teams working on text-to-image benchmarks hit the same wall years ago. In the <a href="https://arxiv.org/abs/2205.11487">DrawBench human evaluation protocol</a> introduced by Saharia et al., evaluators never collapse a generated image into a single automated number or a vague thumbs-up. They separate visual fidelity from text-to-image alignment and judge candidate outputs side by side under controlled prompt categories. Production engineering teams do not need a thousand-prompt academic benchmark, but they do need that exact split between instruction adherence and physical rendering quality.</p>
<p>Here is how common approaches compare when you test prompt revisions across model tiers:</p>
<table>
<thead>
<tr>
<th>Evaluation method</th>
<th>What it measures well</th>
<th>Where it fails in production</th>
<th>Reproducibility across model updates</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Ad-hoc Slack or browser tab voting</strong></td>
<td>Fast gut reaction on a single hero image</td>
<td>Reviewers judge at different zoom levels and screen brightness; mixes seed luck with prompt edits</td>
<td>None; nobody remembers which prompt version produced which file three days later</td>
</tr>
<tr>
<td><strong>Single-metric automated scores (CLIP / raw contrast)</strong></td>
<td>Coarse semantic noun presence or basic pixel exposure stats</td>
<td>Misses melted structural joints, backward cast shadows, and subtle material waxiness</td>
<td>High numerical repeatability, but low correlation with commercial art direction sign-off</td>
</tr>
<tr>
<td><strong>Normalized contact sheet + 4-axis weighted rubric + JSONL log</strong></td>
<td>Separates wording adherence from edge, lighting, and surface fidelity alongside pixel telemetry</td>
<td>Requires 2 to 3 minutes of structured human scoring per batch</td>
<td>High; every run stores prompt version, model tier, image telemetry, and axis scores in git-friendly JSONL</td>
</tr>
</tbody></table>
<p>Without a normalized contact sheet and a structured log, teams keep making two expensive errors. They rewrite an entire prompt paragraph when only the key-light clause failed, which accidentally breaks a camera angle that was already working. Or they pay for a heavier model tier on every draft run when a lighter tier already satisfies the composition brief.</p>
<h2>Designing a 4-axis weighted rubric for commercial image generation</h2>
<p>Breaking a subjective reaction into four integer scores from 1 to 5 turns an argument about "vibes" into a diagnostic checklist. Each axis targets a specific failure mode so you know whether to edit a text clause or switch model entries.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/164603c5-e61a-4470-8bcf-d9685581d5af.webp" alt="Art director inspecting two matte armchair test prints with a magnifying loupe to evaluate AI image prompts across rubric criteria" style="display:block;margin:0 auto" />

<p><em>AI-generated illustration: Inspecting shadow falloff and edge geometry across two candidate renders from the same prompt brief.</em></p>
<p>Note an important boundary before setting up the rubric: the Python script below does not pretend to judge semantic prompt adherence or physical shadow realism automatically. Automated pixel telemetry (<code>Pillow</code> luminance mean, standard deviation, and shadow/highlight clipping percentages) only checks whether an image is underexposed or clipped. The four rubric scores come from a human reviewer inspecting the generated contact sheet, while the script enforces the weights, hard-fail rules, and append-only JSONL logging.</p>
<p>We weight the four axes to reflect what actually causes a commercial render to get rejected in design review:</p>
<ol>
<li><strong>Prompt adherence (</strong><code>prompt_adherence</code><strong>, weight</strong> <code>0.30</code><strong>)</strong>: Did the generator include every required subject, camera angle, framing distance, and negative constraint? A score of <code>1</code> means the required subject or camera framing is wrong; <code>3</code> means the primary subject and framing match while one secondary background detail drifts; <code>5</code> means every stated objective, focus, mood, light, and composition clause is visible.</li>
<li><strong>Edge fidelity (</strong><code>edge_fidelity</code><strong>, weight</strong> <code>0.25</code><strong>)</strong>: Do rigid silhouettes, thin furniture legs, and floor contact seams hold crisp geometry? A score of <code>1</code> means silhouettes melt into the background or joints warp; <code>3</code> means the main silhouette holds with minor softness at the floor line; <code>5</code> means structural seams and curves stay clean at 100% zoom.</li>
<li><strong>Lighting direction (</strong><code>lighting_direction</code><strong>, weight</strong> <code>0.25</code><strong>)</strong>: Is there a coherent key light with physically plausible shadow falloff and surface bounce? A score of <code>1</code> flags flat, directionless glow or shadows falling toward the light source; <code>3</code> marks a clear key light with slightly muddy fill reflections; <code>5</code> marks a cohesive key light, grounded contact shadow, and believable floor bounce.</li>
<li><strong>Surface and typography cleanliness (</strong><code>surface_cleanliness</code><strong>, weight</strong> <code>0.20</code><strong>)</strong>: Are matte fabrics, poured concrete, brushed metal, or label areas free of blotchy noise, plastic sheen, and garbled pseudo-letters?</li>
</ol>
<p>Averaging those four numbers alone still leaves a trap. A render that scores <code>5</code> on prompt adherence, <code>5</code> on lighting direction, and <code>5</code> on surface texture, but scores <code>1</code> on edge fidelity because the front chair leg bends like rubber, would still reach a <code>4.00</code> weighted average. To block that, our scorer enforces a hard veto: if <code>min_dimension &lt;= 1</code> on any axis, the run status is forced to <code>REJECT_HARD_FAIL</code> regardless of the weighted total.</p>
<h2>Writing the Python contact-sheet builder and JSONL score logger</h2>
<p>To make prompt evaluations diffable in version control, our harness pairs <code>Pillow</code> (<code>PIL.Image</code>, <code>ImageDraw</code>, and <a href="https://pillow.readthedocs.io/en/stable/reference/ImageStat.html"><code>ImageStat</code></a>) with Python's standard <code>json</code> and <code>csv</code> modules. It normalizes every candidate render onto a dark <code>24, 26, 30</code> RGB canvas (so bright white browser backgrounds do not skew your perception of shadow depth), prints the four axis scores and luminance stats directly under each frame, and writes both <code>rubric_scores.jsonl</code> and <code>rubric_scores.csv</code>.</p>
<p>Save the code below as <code>contact_sheet_scorer.py</code>. Its only external dependency is <code>Pillow</code> (<code>pip install Pillow</code>). So that you can run and verify the pipeline immediately before plugging in a folder of your own exported renders, the script includes a deterministic synthetic generator that builds four test frames representing an unstructured prompt (<code>v1_unstructured</code>) versus a structured five-part brief (<code>v2_five_part_brief</code>) across two model entries.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Build a standardized side-by-side contact sheet plus JSONL and CSV rubric logs
for evaluating AI image prompts across Seedream V5 Lite and Seedream 5.0 Pro.
"""
import argparse
import csv
import json
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import List
from PIL import Image, ImageDraw, ImageStat

WEIGHTS = {
    "prompt_adherence": 0.30,
    "edge_fidelity": 0.25,
    "lighting_direction": 0.25,
    "surface_cleanliness": 0.20,
}


@dataclass
class RunEvaluation:
    run_id: str
    prompt_version: str
    model_entry: str
    image_path: str
    prompt_adherence: int
    edge_fidelity: int
    lighting_direction: int
    surface_cleanliness: int
    notes: str

    @property
    def weighted_score(self) -&gt; float:
        total = (
            self.prompt_adherence * WEIGHTS["prompt_adherence"]
            + self.edge_fidelity * WEIGHTS["edge_fidelity"]
            + self.lighting_direction * WEIGHTS["lighting_direction"]
            + self.surface_cleanliness * WEIGHTS["surface_cleanliness"]
        )
        return round(total, 2)

    @property
    def min_dimension(self) -&gt; int:
        return min(
            self.prompt_adherence,
            self.edge_fidelity,
            self.lighting_direction,
            self.surface_cleanliness,
        )

    @property
    def status(self) -&gt; str:
        if self.min_dimension &lt;= 1:
            return "REJECT_HARD_FAIL"
        if self.weighted_score &gt;= 4.20 and self.min_dimension &gt;= 4:
            return "SHIP_CANDIDATE"
        if self.weighted_score &gt;= 3.40:
            return "REVISE_PROMPT"
        return "REJECT_LOW_SCORE"


def compute_image_telemetry(img: Image.Image) -&gt; dict:
    """Use Pillow ImageStat to measure luminance mean, std-dev, and clipping."""
    gray = img.convert("L")
    stat = ImageStat.Stat(gray)
    hist = gray.histogram()
    total_px = max(1, gray.width * gray.height)
    shadow_clip_pct = round(sum(hist[:5]) / total_px * 100.0, 2)
    highlight_clip_pct = round(sum(hist[251:]) / total_px * 100.0, 2)
    return {
        "width": img.width,
        "height": img.height,
        "luma_mean": round(stat.mean[0], 1),
        "luma_stddev": round(stat.stddev[0], 1),
        "shadow_clip_pct": shadow_clip_pct,
        "highlight_clip_pct": highlight_clip_pct,
    }


def make_synthetic_demo_runs(work_dir: Path) -&gt; List[RunEvaluation]:
    """Generate 4 deterministic synthetic test frames so the pipeline runs standalone."""
    renders_dir = work_dir / "renders"
    renders_dir.mkdir(parents=True, exist_ok=True)

    specs = [
        ("run_01", "v1_unstructured", "Seedream V5 Lite", (82, 85, 90), (215, 98, 38), False, 3, 3, 2, 4,
         "Chair centered; skylight direction vague; soft floor contact shadow"),
        ("run_02", "v1_unstructured", "Seedream 5.0 Pro", (76, 79, 84), (222, 102, 40), False, 3, 4, 3, 4,
         "Crisper chair edge, but unstructured prompt still misses overhead beam angle"),
        ("run_03", "v2_five_part_brief", "Seedream V5 Lite", (70, 73, 76), (226, 104, 36), True, 5, 4, 4, 4,
         "Full 5-part brief followed; clean skylight shaft and balanced negative space"),
        ("run_04", "v2_five_part_brief", "Seedream 5.0 Pro", (64, 67, 70), (232, 108, 34), True, 5, 5, 5, 5,
         "Full 5-part brief followed; sharp base contact shadow and subtle concrete bounce"),
    ]

    evaluations: List[RunEvaluation] = []
    for run_id, p_ver, model_name, bg_rgb, chair_rgb, has_beam, pa, ef, ld, sc, note in specs:
        im = Image.new("RGB", (600, 400), bg_rgb)
        dr = ImageDraw.Draw(im)
        dr.rectangle([0, 285, 600, 400], fill=(bg_rgb[0] - 14, bg_rgb[1] - 14, bg_rgb[2] - 12))
        if has_beam:
            dr.polygon([(190, 0), (390, 0), (470, 285), (130, 285)],
                       fill=(bg_rgb[0] + 28, bg_rgb[1] + 28, bg_rgb[2] + 26))
            dr.ellipse([195, 292, 405, 330], fill=(32, 34, 36))
        else:
            dr.ellipse([210, 298, 390, 322], fill=(58, 60, 64))

        dr.rounded_rectangle([215, 165, 385, 305], radius=38, fill=chair_rgb, outline=(140, 52, 16), width=3)
        dr.rounded_rectangle([245, 205, 365, 275], radius=20,
                             fill=(chair_rgb[0] - 28, chair_rgb[1] - 22, chair_rgb[2] - 10))

        img_path = renders_dir / f"{run_id}.png"
        im.save(img_path, "PNG")
        evaluations.append(
            RunEvaluation(
                run_id=run_id,
                prompt_version=p_ver,
                model_entry=model_name,
                image_path=str(img_path),
                prompt_adherence=pa,
                edge_fidelity=ef,
                lighting_direction=ld,
                surface_cleanliness=sc,
                notes=note,
            )
        )
    return evaluations


def build_contact_sheet(evaluations: List[RunEvaluation], out_png: Path, cols: int = 2) -&gt; None:
    """Assemble candidate images into a standardized 2-column contact sheet with score footers."""
    thumb_w, thumb_h = 540, 360
    pad = 24
    footer_h = 84
    header_h = 64
    rows = (len(evaluations) + cols - 1) // cols

    sheet_w = pad + cols * (thumb_w + pad)
    sheet_h = header_h + pad + rows * (thumb_h + footer_h + pad)
    sheet = Image.new("RGB", (sheet_w, sheet_h), (24, 26, 30))
    draw = ImageDraw.Draw(sheet)

    draw.text(
        (pad, 22),
        "PROMPT EVALUATION CONTACT SHEET  |  WEIGHTED RUBRIC COMPARISON",
        fill=(235, 238, 242),
    )

    for idx, ev in enumerate(evaluations):
        r = idx // cols
        c = idx % cols
        x0 = pad + c * (thumb_w + pad)
        y0 = header_h + pad + r * (thumb_h + footer_h + pad)

        with Image.open(ev.image_path) as src_im:
            resized = src_im.convert("RGB").resize((thumb_w, thumb_h), Image.LANCZOS)
            sheet.paste(resized, (x0, y0))

        fy0 = y0 + thumb_h
        draw.rectangle([x0, fy0, x0 + thumb_w, fy0 + footer_h], fill=(36, 39, 46))
        line1 = f"{ev.run_id} | {ev.prompt_version} | {ev.model_entry}"
        line2 = (
            f"Adhere:{ev.prompt_adherence}  Edge:{ev.edge_fidelity}  "
            f"Light:{ev.lighting_direction}  Clean:{ev.surface_cleanliness}  "
            f"-&gt; Weighted: {ev.weighted_score:.2f} [{ev.status}]"
        )
        line3 = ev.notes[:78]
        draw.text((x0 + 12, fy0 + 10), line1, fill=(245, 247, 250))
        draw.text((x0 + 12, fy0 + 32), line2, fill=(255, 184, 108))
        draw.text((x0 + 12, fy0 + 54), line3, fill=(180, 186, 196))

    out_png.parent.mkdir(parents=True, exist_ok=True)
    sheet.save(out_png, "PNG")


def export_audit_logs(evaluations: List[RunEvaluation], csv_path: Path, jsonl_path: Path) -&gt; List[dict]:
    records = []
    for ev in evaluations:
        with Image.open(ev.image_path) as im:
            telem = compute_image_telemetry(im)
        row = {
            **asdict(ev),
            "weighted_score": ev.weighted_score,
            "min_dimension": ev.min_dimension,
            "status": ev.status,
            **telem,
        }
        records.append(row)

    csv_path.parent.mkdir(parents=True, exist_ok=True)
    fieldnames = list(records[0].keys())
    with open(csv_path, "w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(records)

    with open(jsonl_path, "w", encoding="utf-8") as fh:
        for row in records:
            fh.write(json.dumps(row) + "\n")
    return records


def main() -&gt; None:
    parser = argparse.ArgumentParser(description="Evaluate AI image prompts with a contact-sheet scorer.")
    parser.add_argument("--work-dir", default="./prompt_eval_out", help="Output directory for sheet and logs")
    args = parser.parse_args()

    work_dir = Path(args.work_dir)
    evaluations = make_synthetic_demo_runs(work_dir)
    sheet_path = work_dir / "contact_sheet.png"
    csv_path = work_dir / "rubric_scores.csv"
    jsonl_path = work_dir / "rubric_scores.jsonl"

    build_contact_sheet(evaluations, sheet_path)
    records = export_audit_logs(evaluations, csv_path, jsonl_path)

    print(f"Contact sheet written to: {sheet_path}")
    print(f"JSONL log written to:     {jsonl_path}")
    print(f"CSV log written to:       {csv_path}\n")
    print(f"{'RUN':&lt;8} {'PROMPT_VER':&lt;20} {'MODEL_ENTRY':&lt;18} {'WEIGHTED':&gt;8} {'LUMA_SD':&gt;8} {'STATUS'}")
    for r in records:
        print(
            f"{r['run_id']:&lt;8} {r['prompt_version']:&lt;20} {r['model_entry']:&lt;18} "
            f"{r['weighted_score']:&gt;8.2f} {r['luma_stddev']:&gt;8.1f} {r['status']}"
        )


if __name__ == "__main__":
    main()
</code></pre>
<p>Running <code>python contact_sheet_scorer.py</code> on Python 3.11+ with Pillow produces the <code>1152x988</code> PNG contact sheet, the line-delimited <code>rubric_scores.jsonl</code> log, and the CSV table:</p>
<pre><code class="language-text">Contact sheet written to: prompt_eval_out/contact_sheet.png
JSONL log written to:     prompt_eval_out/rubric_scores.jsonl
CSV log written to:       prompt_eval_out/rubric_scores.csv

RUN      PROMPT_VER           MODEL_ENTRY        WEIGHTED  LUMA_SD STATUS
run_01   v1_unstructured      Seedream V5 Lite       2.95     17.2 REJECT_LOW_SCORE
run_02   v1_unstructured      Seedream 5.0 Pro       3.45     19.4 REVISE_PROMPT
run_03   v2_five_part_brief   Seedream V5 Lite       4.30     24.1 SHIP_CANDIDATE
run_04   v2_five_part_brief   Seedream 5.0 Pro       5.00     26.7 SHIP_CANDIDATE
</code></pre>
<p>Look at what the log separates. Moving from the unstructured prompt (<code>v1_unstructured</code>) to the five-part brief (<code>v2_five_part_brief</code>) lifted <code>Seedream V5 Lite</code> from <code>2.95</code> (<code>REJECT_LOW_SCORE</code>) to <code>4.30</code> (<code>SHIP_CANDIDATE</code>). The luminance standard deviation (<code>luma_stddev</code>) also rose from <code>17.2</code> to <code>24.1</code> and <code>26.7</code> once the overhead skylight clause introduced real tonal separation between the lit chair silhouette and the poured-concrete floor.</p>
<h2>Sweeping prompt candidates in V5 Lite before locking 4K production baselines</h2>
<p>Once the scoring harness is in place, the fastest way to debug a new prompt is to write the brief as five explicit studio clauses (<code>Objective</code>, <code>Focus</code>, <code>Mood</code>, <code>Light</code>, and <code>Composition</code>) and run that exact text block across both a lightweight entry and a high-finish entry without changing a word.</p>
<p>Here is the hypothetical architectural furniture brief used in the four-run test above:</p>
<ul>
<li><strong>Objective</strong>: Photograph the sculptural orange lounge chair to show its molded form, matte material thickness, and physical presence inside a poured-concrete gallery space.</li>
<li><strong>Focus</strong>: Balance the curved orange silhouette, warm terracotta surface color, overhead daylight beam, and quiet atmospheric haze in the concrete room.</li>
<li><strong>Mood</strong>: Quiet, contemporary, and architectural, with zero clutter or background props.</li>
<li><strong>Light</strong>: Natural overhead rectangular skylight acting as the single key light, producing soft directional illumination and a grounded shadow beneath the chair base.</li>
<li><strong>Composition</strong>: Eye-level three-quarter framing with generous breathing room around the chair so the furniture remains the clear hero subject.</li>
</ul>
<p>On the <a href="https://www.virse.ai/models/seedream-5">Seedream 5.0 image model</a> page on Virse, ByteDance's current generation is carried as two distinct entries (<code>Seedream 5.0 Pro</code> and <code>Seedream V5 Lite</code>) rather than hidden behind an automatic router. Both entries accept the same written brief and reference images on one shared canvas, so you can sweep prompt wording variations in <code>Seedream V5 Lite</code> first and then test the winning wording against <code>Seedream 5.0 Pro</code> side by side.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/da78a501-51d0-4ca5-ac26-b6bbc37c2554.webp" alt="Public Seedream 5.0 model page on Virse showing Seedream 5.0 Pro and Seedream V5 Lite side-by-side comparison from one shared brief" style="display:block;margin:0 auto" />

<p><em>Public Seedream 5.0 model page on Virse showing how Seedream 5.0 Pro and Seedream V5 Lite run side by side from a single shared brief.</em></p>
<p>When you review the resulting contact sheet and JSONL log across both entries, three diagnostic patterns tell you what to do next:</p>
<ul>
<li><strong>Both</strong> <code>Seedream V5 Lite</code> <strong>and</strong> <code>Seedream 5.0 Pro</code> <strong>score</strong> <code>&lt;= 3</code> <strong>on</strong> <code>prompt_adherence</code>: Stop switching models or seeds. Your composition or subject clause is buried too deep in the paragraph or contradicted by style adjectives. Move camera framing and subject placement into the first sentence and strip out non-visual adjectives.</li>
<li><strong>Both entries score</strong> <code>&gt;= 4</code> <strong>on</strong> <code>prompt_adherence</code><strong>, but</strong> <code>lighting_direction</code> <strong>stays at</strong> <code>2</code> <strong>or</strong> <code>3</code>: You named an emotional mood ("dramatic", "moody") instead of a physical light fixture and shadow surface. Replace the mood adjective with a concrete light vector, such as an overhead rectangular skylight casting a soft shadow directly beneath the chair base.</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/d07e025e-4a21-45a1-bba1-41575e3fefd0.webp" alt="Architectural concrete gallery maquette with an orange chair model under directional overhead softbox lighting for prompt evaluation" style="display:block;margin:0 auto" />

<p><em>AI-generated illustration: Directional overhead skylight and clean floor contact shadows act as reliable physical checks during prompt scoring.</em></p>
<ul>
<li><strong>Both entries hit</strong> <code>5</code> <strong>on</strong> <code>prompt_adherence</code><strong>, while</strong> <code>edge_fidelity</code> <strong>or</strong> <code>surface_cleanliness</code> <strong>differs between entries</strong>: Your prompt structure is ready to lock into git. Use <code>Seedream V5 Lite</code> when exploring layout concepts or batch variations where a <code>4.30</code> score clears the bar, and promote the locked brief to <code>Seedream 5.0 Pro</code> when you need crisper specular floor reflections and edge micro-contrast for a hero asset.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to detect color cast in product photos with CIE Lab statistics and Python]]></title><description><![CDATA[Detect color casts in product photos with Python, OpenCV, and CIE Lab border chroma statistics before matching a clean studio reference.]]></description><link>https://aivideomaker.hashnode.dev/detect-color-cast-in-product-photos</link><guid isPermaLink="true">https://aivideomaker.hashnode.dev/detect-color-cast-in-product-photos</guid><dc:creator><![CDATA[virse]]></dc:creator><pubDate>Thu, 15 Oct 2026 18:12:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/c3f748f2-a02c-496f-b6b4-985f0422273d.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/c3f748f2-a02c-496f-b6b4-985f0422273d.webp" alt="Three ceramic coffee mugs and folded linen napkins on a studio sweep table used to detect color cast in product photos" />
<em>AI-generated illustration: mixed warm tungsten spill and cool softbox illumination across a tabletop product setup.</em></p>
<p>You can detect color cast in product photos objectively by converting each studio frame into float32 CIE <code>$L^*a^*b^*$</code> color space, isolating the high-lightness outer sweep border, and measuring how far the border chroma vector <code>$C^*_{ab} = \sqrt{\mu_a^2 + \mu_b^2}$</code> drifts from the neutral origin. Pairing that border measurement with the global <code>$a^*/b^*$</code> chromaticity spread lets a batch script flag warm tungsten spill, cool window daylight, or green LED spikes without false alarms on saturated merchandise.</p>
<p>AI helped draft this tutorial, while every Python snippet and console table below comes from running the script locally on a five-frame test batch. This Hashnode blog is run by Virse, whose reference photo color transfer tool appears once in the correction section below.</p>
<h2>Why should you detect color cast in product photos using CIE Lab instead of RGB histograms?</h2>
<p>You should detect color cast in product photos in CIE <code>$L^*a^*b^*$</code> rather than sRGB because sRGB couples brightness and color across all three channels, whereas CIE <code>$L^*a^*b^*$</code> separates lightness (<code>$L^*$</code>) from two opponent chromaticity axes: <code>$a^*$</code> (green to red) and <code>$b^*$</code> (blue to yellow). When a studio strobe dims by a third of a stop between frames, all three R, G, and B channel means drop together even if the white balance stays neutral. In CIE <code>$L^*a^*b^*$</code>, that exposure drop lowers <code>$L^*$</code> while leaving <code>$a^*$</code> and <code>$b^*$</code> anchored near zero.</p>
<p>Under the <a href="https://en.wikipedia.org/wiki/CIELAB_color_space">CIELAB color space specification</a> standardized by the International Commission on Illumination, an achromatic gray or white surface under a D65 standard illuminant sits at <code>$a^* = 0.0$</code> and <code>$b^* = 0.0$</code>, regardless of whether its lightness <code>$L^*$</code> is <code>$96.0$</code> (bright white seamless paper) or <code>$50.0$</code> (a mid-gray reference card). Any systematic shift away from <code>$(a^*=0, b^*=0)$</code> on a known neutral surface measures the direction and magnitude of the color cast directly:</p>
<ul>
<li><strong>Positive <code>$b^*$</code> (<code>$+2.2$</code> or higher)</strong>: Yellow or warm amber drift, typical when a 3200K modeling lamp stays on or warm floor bounce contaminates a 5600K flash setup.</li>
<li><strong>Negative <code>$b^*$</code> (<code>$-2.2$</code> or lower)</strong>: Cool blue drift, common when north-facing window daylight leaks into an afternoon tabletop shoot as clouds roll in.</li>
<li><strong>Negative <code>$a^*$</code> (<code>$-2.0$</code> or lower)</strong>: Green spike, frequently caused by commercial warehouse LED tubes or aged fluorescent ballasts with a low color rendering index.</li>
<li><strong>Positive <code>$a^*$</code> (<code>$+2.0$</code> or higher)</strong>: Magenta tint, often introduced by aggressive tint compensation in raw converters or tinted acrylic diffusers.</li>
</ul>
<p>If you use OpenCV for the conversion, watch out for one silent pitfall documented in the <a href="https://docs.opencv.org/4.x/de/d25/imgproc_color_conversions.html">OpenCV color conversions reference</a>. Passing an 8-bit unsigned integer array (<code>uint8</code>) to <code>cv2.cvtColor(img, cv2.COLOR_BGR2Lab)</code> scales <code>$L^*$</code> to <code>$[0, 255]$</code> and adds <code>$+128$</code> to both <code>$a^*$</code> and <code>$b^*$</code> so values fit into unsigned bytes. Forgetting to subtract <code>$128$</code> makes every neutral pixel look like it has a huge red-yellow cast. Converting the image to <code>float32</code> in <code>$[0.0, 1.0]$</code> before calling <code>cv2.cvtColor</code> bypasses byte quantization and returns true floating-point CIE <code>$L^* \in [0, 100]$</code> and <code>$a^*, b^* \in [-127, 127]$</code>, matching <a href="https://scikit-image.org/docs/stable/api/skimage.color.html"><code>skimage.color.rgb2lab</code></a> from <code>scikit-image</code>.</p>
<h2>How do you sample the neutral studio border without catching product pixels?</h2>
<p>Sampling the outer 8 percent perimeter of the frame and filtering for pixels with lightness <code>$L^* \ge 75.0$</code> isolates the studio sweep paper while ignoring the merchandise in the center. Whole-image chromaticity averages fail on catalog photography because the product itself dominates the pixel count.</p>
<p>Suppose you shoot a burnt-orange leather messenger bag centered on a clean white sweep. Even under calibrated strobes, the orange leather pixels push the whole-frame mean <code>$a^*$</code> above <code>$+15$</code> and mean <code>$b^*$</code> above <code>$+25$</code>. A global gray-world average will flag the clean orange bag photo as having a warm cast while passing a blue shirt shot under warm tungsten bulbs because the blue fabric and yellow light cancel out.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/44bc5bfc-7225-457f-828e-da7c80c3bf46.webp" alt="Off-white stoneware pitcher on a white seamless backdrop with a neutral gray card to detect color cast in product photos" />
<em>AI-generated illustration: isolating the outer seamless sweep border and a neutral reference card away from the central product.</em></p>
<p>In ecommerce and lookbook tabletop shoots, the outer margin of the frame almost always shows the studio sweep, cyclorama wall, or tabletop surface. Combining a geometric perimeter mask with a lightness gate solves two problems at once:</p>
<ol>
<li><strong>The 8 percent perimeter strip</strong> (<code>border_frac = 0.08</code>) grabs the top, bottom, left, and right edges where the product rarely extends, unless it is an edge-cropped detail shot.</li>
<li><strong>The lightness gate (<code>$L^* \ge 75.0$</code>)</strong> drops deep contact shadows, dark props, or mannequin stands that cross the bottom edge of the frame, keeping only lit background pixels.</li>
<li><strong>The fallback check</strong> verifies that at least 50 pixels survive the <code>$L^* \ge 75.0$</code> filter; if the background is a mid-gray stone slab rather than white paper, the function falls back to the full perimeter strip so the script never divides by zero.</li>
</ol>
<p>Alongside the border mean <code>$(\mu_a, \mu_b)$</code>, it helps to record the global chromaticity spread <code>$\sigma_{ab} = \sqrt{\sigma_a^2 + \sigma_b^2}$</code> across the full frame. When a monochrome ceramic mug sits on a white sweep, <code>$\sigma_{ab}$</code> stays tiny (around <code>$2.0$</code> to <code>$3.0$</code>). When a multi-colored floral garment or an orange leather bag sits on that same sweep, <code>$\sigma_{ab}$</code> jumps above <code>$20.0$</code> while the border chroma <code>$C^*_{\text{bdr}}$</code> stays below <code>$1.0$</code>. Comparing <code>$C^*_{\text{bdr}}$</code> against <code>$\sigma_{ab}$</code> tells you immediately whether chroma energy lives in the product or in the ambient lighting.</p>
<h2>What does a runnable Python script for batch color cast detection look like?</h2>
<p>The script below uses <code>numpy</code> and <code>opencv-python</code> (<code>cv2</code>) to convert each image into unshifted <code>float32</code> CIE <code>$L^*a^*b^*$</code>, compute the border chroma <code>$C^*_{\text{bdr}}$</code>, measure the global chromaticity spread, and calculate the chromaticity drift <code>$\Delta C_{ab}$</code> relative to a designated reference frame (<code>sku_01_ref_mug.png</code>). To make the output 100 percent reproducible without external downloads, <code>make_synthetic_catalog_batch()</code> builds a five-frame <code>$600 \times 600$</code> studio batch with controlled sensor noise and realistic lighting shifts.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Detect color cast in product photos using CIE Lab border &amp; global chroma statistics."""
import numpy as np
import cv2


def srgb_to_cie_lab(bgr_uint8: np.ndarray) -&gt; np.ndarray:
    """Convert 8-bit BGR image to float32 CIE L*a*b* (L in [0,100], a,b in [-128,127])."""
    bgr_f32 = bgr_uint8.astype(np.float32) / 255.0
    return cv2.cvtColor(bgr_f32, cv2.COLOR_BGR2Lab)


def analyze_product_frame(bgr_uint8: np.ndarray, border_frac: float = 0.08) -&gt; dict:
    """Measure neutral-border chroma drift and global a*/b* spread in CIE Lab."""
    lab = srgb_to_cie_lab(bgr_uint8)
    h, w, _ = lab.shape
    bh = max(4, int(round(h * border_frac)))
    bw = max(4, int(round(w * border_frac)))

    border_mask = np.zeros((h, w), dtype=bool)
    border_mask[:bh, :] = True
    border_mask[-bh:, :] = True
    border_mask[:, :bw] = True
    border_mask[:, -bw:] = True

    L, a, b = lab[:, :, 0], lab[:, :, 1], lab[:, :, 2]
    # Keep high-lightness border pixels (studio sweep paper, L* &gt;= 75)
    sweep_mask = border_mask &amp; (L &gt;= 75.0)
    if np.count_nonzero(sweep_mask) &lt; 50:
        sweep_mask = border_mask

    mu_L = float(np.mean(L[sweep_mask]))
    mu_a = float(np.mean(a[sweep_mask]))
    mu_b = float(np.mean(b[sweep_mask]))
    border_chroma = float(np.hypot(mu_a, mu_b))

    # Global chromaticity spread (2D standard deviation in a*/b* plane)
    sigma_a = float(np.std(a))
    sigma_b = float(np.std(b))
    spread = float(np.hypot(sigma_a, sigma_b))
    # Equivalent chroma factor D_c = border_chroma - spread
    d_factor = border_chroma - spread

    if border_chroma &lt; 2.2:
        verdict = "PASS (neutral)"
    elif mu_b &gt;= 2.2 and abs(mu_a) &lt; mu_b:
        verdict = "WARN (warm/yellow cast)"
    elif mu_b &lt;= -2.2 and abs(mu_a) &lt; abs(mu_b):
        verdict = "WARN (cool/blue cast)"
    elif mu_a &lt;= -2.0:
        verdict = "WARN (green fluorescent cast)"
    else:
        verdict = "WARN (magenta/mixed tint)"

    return {
        "mu_L": round(mu_L, 2),
        "mu_a": round(mu_a, 2),
        "mu_b": round(mu_b, 2),
        "border_C": round(border_chroma, 2),
        "spread_sigma": round(spread, 2),
        "D_c": round(d_factor, 2),
        "verdict": verdict,
    }


def make_synthetic_catalog_batch(seed: int = 42) -&gt; dict:
    """Create a reproducible 5-frame studio catalog batch (600x600 BGR uint8)."""
    rng = np.random.default_rng(seed)
    h, w = 600, 600
    yy, xx = np.ogrid[:h, :w]
    center_mask = ((yy - 300) ** 2 + (xx - 300) ** 2) &lt;= 165 ** 2

    def build_frame(bg_bgr, obj_bgr, cast_bgr=(0, 0, 0)):
        img = np.empty((h, w, 3), dtype=np.float32)
        img[:] = bg_bgr
        img[center_mask] = obj_bgr
        noise = rng.normal(0.0, 1.8, size=(h, w, 3))
        img = np.clip(img + noise + np.array(cast_bgr, dtype=np.float32), 0, 255)
        return img.astype(np.uint8)

    return {
        "sku_01_ref_mug.png": build_frame((242, 242, 242), (175, 182, 188), (0, 0, 0)),
        "sku_02_tungsten_mug.png": build_frame((242, 242, 242), (175, 182, 188), (-14, 4, 16)),
        "sku_03_window_Blue.png": build_frame((242, 242, 242), (175, 182, 188), (15, 2, -12)),
        "sku_04_led_green.png": build_frame((242, 242, 242), (175, 182, 188), (-4, 11, -8)),
        "sku_05_orange_bag_clean.png": build_frame((242, 242, 242), (38, 108, 218), (0, 0, 1)),
    }


if __name__ == "__main__":
    batch = make_synthetic_catalog_batch()
    ref_stats = analyze_product_frame(batch["sku_01_ref_mug.png"])
    print(f"{'Frame':&lt;28} {'L*':&gt;6} {'a*':&gt;6} {'b*':&gt;6} {'C*_bdr':&gt;7} {'Spread':&gt;7} {'Delta_C':&gt;8}  Verdict")
    print("-" * 92)
    for name, img in batch.items():
        st = analyze_product_frame(img)
        drift = np.hypot(st["mu_a"] - ref_stats["mu_a"], st["mu_b"] - ref_stats["mu_b"])
        print(
            f"{name:&lt;28} {st['mu_L']:&gt;6.2f} {st['mu_a']:&gt;6.2f} {st['mu_b']:&gt;6.2f} "
            f"{st['border_C']:&gt;7.2f} {st['spread_sigma']:&gt;7.2f} {drift:&gt;8.2f}  {st['verdict']}"
        )
</code></pre>
<p>Running <code>python detect_color_cast.py</code> prints the following console output:</p>
<pre><code class="language-text">Frame                            L*     a*     b*  C*_bdr  Spread  Delta_C  Verdict
--------------------------------------------------------------------------------------------
sku_01_ref_mug.png            95.17   0.01   0.00    0.01    2.47     0.00  PASS (neutral)
sku_02_tungsten_mug.png       96.88   0.36   9.81    9.82    2.80     9.82  WARN (warm/yellow cast)
sku_03_window_Blue.png        95.17  -2.34  -7.20    7.58    2.01     7.57  WARN (cool/blue cast)
sku_04_led_green.png          97.29  -8.90   5.07   10.24    2.51    10.25  WARN (green fluorescent cast)
sku_05_orange_bag_clean.png   95.24   0.35   0.12    0.37   28.99     0.36  PASS (neutral)
</code></pre>
<p>Look closely at the fifth row, <code>sku_05_orange_bag_clean.png</code>. Its global chromaticity spread (<code>Spread</code>) jumps to <code>28.99</code> because the central product circle is a saturated orange leather color (<code>BGR = (38, 108, 218)</code>). Yet because the perimeter mask samples only the outer 8 percent border where <code>$L^* \ge 75.0$</code>, the border chroma <code>C*_bdr</code> reads <code>0.37</code> and its drift from the reference mug (<code>Delta_C</code>) is <code>0.36</code>. The frame passes automatically without human intervention, whereas the three frames with genuine lighting shifts (<code>sku_02</code>, <code>sku_03</code>, and <code>sku_04</code>) register border chroma values between <code>7.58</code> and <code>10.24</code> and get routed to correction.</p>
<h2>How do chroma thresholds and chromaticity spread separate lighting drift from saturated merchandise?</h2>
<p>A border chroma threshold of <code>$C^*_{\text{bdr}} = 2.2$</code> separates imperceptible studio variance from visible background tint on standard sRGB monitors, where one just-noticeable difference in CIE <code>$\Delta E^*_{ab}$</code> sits near <code>$1.0$</code> to <code>$2.0$</code> units. When you run this check across a multi-day catalog shoot, you can classify every image into four operational buckets based on its border vector <code>$(\mu_a, \mu_b)$</code>, border chroma <code>$C^*_{\text{bdr}}$</code>, and global spread <code>$\sigma_{ab}$</code>:</p>
<table>
<thead>
<tr>
<th>Metric Pattern</th>
<th>Typical CIE <code>$L^*a^*b^*$</code> Range</th>
<th>Root Cause in Studio</th>
<th>Automated Pipeline Action</th>
</tr>
</thead>
<tbody><tr>
<td>Low border chroma, low spread</td>
<td><code>$C^*_{\text{bdr}} &lt; 2.2$</code>, <code>$\sigma_{ab} &lt; 8.0$</code></td>
<td>Neutral product on neutral sweep (<code>sku_01</code>)</td>
<td>Pass directly to catalog export</td>
</tr>
<tr>
<td>Low border chroma, high spread</td>
<td><code>$C^*_{\text{bdr}} &lt; 2.2$</code>, <code>$\sigma_{ab} \ge 8.0$</code></td>
<td>Saturated product on neutral sweep (<code>sku_05</code>)</td>
<td>Pass; never apply global white-balance shift</td>
</tr>
<tr>
<td>High positive <code>$b^*$</code> on border</td>
<td><code>$C^*_{\text{bdr}} \ge 2.2$</code>, <code>$\mu_b \ge +2.2$</code></td>
<td>Warm tungsten spill or uncalibrated modeling lamp (<code>sku_02</code>)</td>
<td>Flag for reference color grade transfer</td>
</tr>
<tr>
<td>High negative <code>$b^*$</code> or negative <code>$a^*$</code> on border</td>
<td><code>$C^*_{\text{bdr}} \ge 2.2$</code>, <code>$\mu_b \le -2.2$</code> or <code>$\mu_a \le -2.0$</code></td>
<td>Overcast window leak (<code>sku_03</code>) or green LED spike (<code>sku_04</code>)</td>
<td>Flag for reference color grade transfer</td>
</tr>
</tbody></table>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/5329258a-5749-46c6-9829-bc6e9e20bd4f.webp" alt="Printed catalog proofs of leather messenger bags inspected under balanced light to detect color cast in product photos" />
<em>AI-generated illustration: side-by-side contact proofs revealing warm paper tint drift across a multi-day studio shoot.</em></p>
<p>Why track <code>Delta_C</code> against a designated hero frame (<code>sku_01_ref_mug.png</code>) in addition to absolute distance from <code>$(0, 0)$</code>? Many apparel and home-goods brands intentionally shoot on warm alabaster paper (<code>$\mu_a \approx +0.4, \mu_b \approx +3.1$</code>) or cool architectural concrete (<code>$\mu_a \approx -0.3, \mu_b \approx -1.8$</code>) rather than bright white paper. Measuring the Euclidean drift <code>$\Delta C_{ab} = \sqrt{(\mu_a - \mu_{a,\text{ref}})^2 + (\mu_b - \mu_{b,\text{ref}})^2}$</code> relative to the signed-off hero frame tests whether Frame 47 drifted away from Frame 1, regardless of backdrop shade.</p>
<h2>How do you correct flagged frames with reference photo color statistics?</h2>
<p>Once your script flags a subset of frames whose border chroma drifted away from the hero shot, you have two ways to align them: run a channel-wise CIE <code>$L^*a^*b^*$</code> statistics transfer in Python following Erik Reinhard's 2001 color transfer equation, or bring the drifted photo and the approved reference frame into a visual canvas tool for interactive strength control.</p>
<p>In Python, Reinhard-style linear transfer shifts and scales each channel <code>$c \in \{L^*, a^*, b^*\}$</code> of the source image <code>$I_{\text{src}}$</code> toward the mean <code>$\mu_{\text{ref}}$</code> and standard deviation <code>$\sigma_{\text{ref}}$</code> of the reference image, blended by a transfer strength factor <code>$\alpha \in [0.0, 1.0]$</code>:</p>
<pre><code class="language-python">def match_lab_statistics(
    src_bgr: np.ndarray, ref_bgr: np.ndarray, strength: float = 0.65
) -&gt; np.ndarray:
    """Align source photo CIE Lab statistics to a reference photo with blend strength."""
    src_lab = srgb_to_cie_lab(src_bgr)
    ref_lab = srgb_to_cie_lab(ref_bgr)
    out_lab = np.empty_like(src_lab)

    for ch in range(3):
        s_mean, s_std = np.mean(src_lab[:, :, ch]), max(np.std(src_lab[:, :, ch]), 1e-5)
        r_mean, r_std = np.mean(ref_lab[:, :, ch]), max(np.std(ref_lab[:, :, ch]), 1e-5)
        transferred = (src_lab[:, :, ch] - s_mean) * (r_std / s_std) + r_mean
        out_lab[:, :, ch] = (1.0 - strength) * src_lab[:, :, ch] + strength * transferred

    out_lab[:, :, 0] = np.clip(out_lab[:, :, 0], 0.0, 100.0)
    out_lab[:, :, 1:] = np.clip(out_lab[:, :, 1:], -127.0, 127.0)
    out_bgr = cv2.cvtColor(out_lab, cv2.COLOR_Lab2BGR)
    return np.clip(np.round(out_bgr * 255.0), 0, 255).astype(np.uint8)
</code></pre>
<p>Running this linear equation directly on whole images works well when the source and reference frames contain the exact same object at the same crop, such as <code>sku_02_tungsten_mug.png</code> matched to <code>sku_01_ref_mug.png</code>. When a retoucher or art director needs to inspect how different blend strengths affect skin tones, ceramic glazes, or leather grain side by side before committing, they can use <a href="https://www.virse.ai/tools/color-grade-transfer">Lab statistics color grade transfer</a> on Virse.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/bd6d4816-0c76-4e6d-9a06-a4a6ba6b8548.webp" alt="Virse Color Grade Transfer public tool page showing deterministic Lab color statistics matching across reference and source photos" />
<em>Public tool page screenshot from virse.ai/tools/color-grade-transfer showing how Color Grade Transfer matches color statistics across a source photo and tone reference without redrawing edges or textures.</em></p>
<p>The web tool takes one photo to grade and one tone reference image, and computes the palette transfer from color statistics rather than running a generative diffusion pass. Because nothing is redrawn, every edge, weave, logo, and surface texture of the original photograph stays locked down to the pixel, and running the same pair twice produces the exact same output. You can choose among three presets (<code>Match the reference</code>, <code>House look</code>, and <code>Light tint</code>) and three transfer strengths (<code>Light</code>, <code>Standard</code>, and <code>Full</code>). Clicking <strong>Transfer the grade</strong> loads your source photo and reference image into a new canvas with the tool parameters pre-filled; the transfer runs only after you press <strong>Start</strong> inside the canvas, placing the graded image right beside the original so you can compare them or swap the reference.</p>
<p>Two practical boundaries apply whether you run CIE <code>$L^*a^*b^*$</code> statistics transfer in Python or on the Virse canvas:</p>
<ol>
<li><strong>Structure preservation is not selective masking</strong>: Matching color statistics across the whole frame keeps pixel geometry untouched, but it shifts the color of the product along with the background. Use <code>Light</code> or <code>Standard</code> strength when the product hue is color-critical, or verify product regions after neutralizing the cast.</li>
<li><strong>Still-photo scope</strong>: Virse's Color Grade Transfer operates on still images and outputs the graded photo onto the canvas; it does not process video timelines and does not export <code>.cube</code> or 3D LUT files.</li>
</ol>
<h2>Which studio lighting edge cases require manual review even after automated screening?</h2>
<p>Three studio scenarios still benefit from human inspection before batch correction:</p>
<ul>
<li><strong>Split lighting with mixed color temperatures</strong>: When a 5600K strobe lights the left side and 3000K room bounce lights the right, opposite borders can cancel each other out in a single perimeter average. Split the perimeter mask into four quadrants (<code>top</code>, <code>bottom</code>, <code>left</code>, <code>right</code>) and flag any frame where quadrant-to-quadrant <code>$\Delta b^*$</code> exceeds <code>$3.0$</code>.</li>
<li><strong>Full-bleed lifestyle frames without a neutral border</strong>: When wooden tables or props cover all four edges, place a physical 18 percent gray card in the corner of the first frame of the camera lockoff and measure its <code>$(a^*, b^*)$</code> patch coordinates directly.</li>
<li><strong>Clipped studio highlights (<code>$L^* &gt; 98.5$</code>)</strong>: Overexposed background strobes blow paper out to pure <code>[255, 255, 255]</code>, collapsing <code>$a^*$</code> and <code>$b^*$</code> to zero. Adding an upper ceiling (<code>(L &gt;= 75.0) &amp; (L &lt;= 98.5)</code>) keeps the measurement inside unclipped paper highlights.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Why Canny Edge Detection Traces Your Product's Reflections]]></title><description><![CDATA[If you use Canny edge detection as a line source for product images, whether as a ControlNet preprocessor or as the first step of a spec drawing, you have…]]></description><link>https://aivideomaker.hashnode.dev/why-canny-edge-detection-traces-your-product-s-reflections</link><guid isPermaLink="true">https://aivideomaker.hashnode.dev/why-canny-edge-detection-traces-your-product-s-reflections</guid><dc:creator><![CDATA[virse]]></dc:creator><pubDate>Wed, 23 Sep 2026 15:24:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/2f612e0e-ada7-4114-85c6-f966c44ead19.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you use Canny edge detection as a line source for product images, whether as a ControlNet preprocessor or as the first step of a spec drawing, you have probably seen it draw a line straight down the middle of a shiny bottle. That line is the specular highlight. The usual fix is to raise the thresholds until it goes away. This post runs that fix on a controlled test image and shows why it fails: the real, low-contrast seam on the object disappears first, and the highlight survives every threshold setting that still keeps the silhouette.</p>
<p>AI helped draft this post, and I ran and checked every code sample and number in it myself. Virse, whose line art tool appears near the end as one alternative, has a business relationship with this publication.</p>
<h2>What does Canny edge detection actually respond to?</h2>
<p>Canny does not know what a bottle is. It smooths the image with a Gaussian blur, computes the brightness gradient at every pixel, thins the result to one-pixel ridges (non-maximum suppression), and then applies two thresholds. Pixels whose gradient magnitude is above the high threshold become strong edges. Pixels between the two thresholds are kept only if they connect to a strong edge. Everything else is dropped.</p>
<p>So the only question Canny asks is "how fast does brightness change here?" On a matte object under soft light, fast brightness changes mostly happen at real boundaries, so the output looks like a drawing of the object. On a glossy object, the fastest brightness changes in the frame are often the edges of highlights and reflections, which are properties of the lighting setup and not of the object.</p>
<h2>A test image where we know the ground truth</h2>
<p>Real product photos make it hard to argue about which lines are "correct", so I built a synthetic image where every feature is known in advance:</p>
<ul>
<li><p>a bottle silhouette on a light backdrop, with cylinder shading that gets darker toward both sides;</p>
</li>
<li><p>a bright vertical stripe on the left of the body, standing in for a specular highlight on glossy plastic;</p>
</li>
<li><p>a faint 1 px seam on the right side, 12 gray levels darker than its surroundings, standing in for a mold parting line;</p>
</li>
<li><p>a cast shadow on the surface under the bottle.</p>
</li>
</ul>
<p>Only two of those are part of the object: the silhouette and the seam. The highlight and the shadow come from the lights.</p>
<pre><code class="language-python">import cv2
import numpy as np

H, W = 600, 400
img = np.full((H, W), 225, np.float32)          # light studio backdrop

# soft cast shadow on the surface under the bottle
shadow = np.zeros_like(img)
cv2.ellipse(shadow, (215, 548), (120, 22), 0, 0, 360, 1.0, -1)
shadow = cv2.GaussianBlur(shadow, (0, 0), 2)
img -= shadow * 70

# bottle silhouette: body, shoulder, neck
mask = np.zeros((H, W), np.uint8)
cv2.rectangle(mask, (120, 200), (280, 540), 255, -1)
cv2.ellipse(mask, (200, 200), (80, 60), 0, 180, 360, 255, -1)
cv2.rectangle(mask, (170, 90), (230, 150), 255, -1)

# cylinder shading across the body: darker toward both sides
x = np.arange(W, dtype=np.float32)
shade = 70 + 60 * np.cos((x - 200) / 80 * (np.pi / 2)).clip(0, 1)
body = np.tile(shade, (H, 1))

# glossy highlight: a sharp bright stripe left of center
body[:, 158:170] = 250

# parting line: a faint 1 px seam down the right side of the mold
body[95:540, 240] -= 12

img = np.where(mask &gt; 0, body, img).clip(0, 255).astype(np.uint8)
cv2.imwrite("synthetic-glossy-bottle.png", img)
</code></pre>
<h2>Sweeping the thresholds</h2>
<p>Next, run Canny at three threshold pairs, from permissive to strict, and count the edge pixels inside three regions: the highlight stripe, the seam, and the shadow below the bottle.</p>
<pre><code class="language-python">regions = {
    "highlight stripe": (slice(220, 520), slice(153, 176)),
    "parting line": (slice(220, 520), slice(236, 245)),
    "cast shadow": (slice(546, 580), slice(80, 350)),
}
for lo, hi in [(20, 60), (60, 150), (150, 300)]:
    edges = cv2.Canny(img, lo, hi)
    cv2.imwrite(f"canny-{lo}-{hi}.png", edges)
    counts = {k: int((edges[r] &gt; 0).sum()) for k, r in regions.items()}
    print(f"Canny({lo}, {hi})", counts)
</code></pre>
<p>With OpenCV 4.13 and NumPy 2.4 this prints:</p>
<pre><code class="language-text">Canny(20, 60) {'highlight stripe': 600, 'parting line': 300, 'cast shadow': 265}
Canny(60, 150) {'highlight stripe': 600, 'parting line': 0, 'cast shadow': 265}
Canny(150, 300) {'highlight stripe': 600, 'parting line': 0, 'cast shadow': 0}
</code></pre>
<p>Read that table row by row:</p>
<ul>
<li><p><strong>Permissive (20, 60).</strong> The seam is found, 300 pixels, one per row of the region. So are both edges of the highlight (600 pixels, two lines of 300) and the rim of the shadow. You get everything, real or not.</p>
</li>
<li><p><strong>Middle (60, 150).</strong> The seam is gone. The highlight and the shadow are both still there.</p>
</li>
<li><p><strong>Strict (150, 300).</strong> The shadow finally drops out, and so does part of the bottle's real bottom edge where it meets the shadow (105 edge pixels in that strip, down from 172 at the permissive setting). The highlight is still drawn as two full-length lines.</p>
</li>
</ul>
<p>There is no threshold pair in this sweep that keeps the seam and drops the highlight, and there cannot be. Canny ranks edges by gradient strength only, and the highlight is a much stronger gradient than the seam. Any threshold that removes the highlight has already removed the seam. Tuning just decides which wrong answer you get.</p>
<h2>The two other usual fixes: automatic thresholds and more blur</h2>
<p>Two more suggestions come up whenever someone complains about Canny on product shots. Both are worth testing on the same image, because both sound like they should help.</p>
<p>The first is to stop picking thresholds by hand and derive them from the image, commonly from the median brightness with a spread of about 33 percent either side:</p>
<pre><code class="language-python">def auto_canny(image, sigma=0.33):
    v = float(np.median(image))
    lo = int(max(0, (1.0 - sigma) * v))
    hi = int(min(255, (1.0 + sigma) * v))
    return cv2.Canny(image, lo, hi), (lo, hi)
</code></pre>
<p>On the test image the median is the bright backdrop, so this lands on <code>Canny(150, 255)</code>:</p>
<pre><code class="language-text">auto_canny -&gt; Canny(150, 255) {'highlight stripe': 600, 'parting line': 0, 'cast shadow': 0}
</code></pre>
<p>That is the strict row again. The shadow is gone, the seam is gone, and the highlight is still two full lines. Studio product photos nearly always have a light backdrop, so median-based thresholds will usually land in this strict range.</p>
<p>The second suggestion is to blur harder before detection, on the theory that the highlight is "noise":</p>
<pre><code class="language-python">for k in (5, 9):
    edges = cv2.Canny(cv2.GaussianBlur(img, (k, k), 0), 20, 60)
</code></pre>
<pre><code class="language-text">blur 5x5 + Canny(20, 60) {'highlight stripe': 600, 'parting line': 0}
blur 9x9 + Canny(20, 60) {'highlight stripe': 600, 'parting line': 0}
</code></pre>
<p>Blur works against you here. The highlight is 12 pixels wide and 120 or more gray levels brighter than the body around it, so a 9x9 kernel barely weakens it. The seam is one pixel wide and 12 levels deep, so even a 5x5 kernel spreads it out until it falls below the permissive threshold that found it before. A highlight is not noise. It is a large, strong, smooth feature, which is exactly the kind of feature blurring keeps.</p>
<h2>Why this matters more for product images than for art</h2>
<p>For many ControlNet uses this is fine. If you are restyling a photo and only want its composition to carry through, a few lighting lines in the control image do little harm.</p>
<p>Product work is different in two ways.</p>
<p>First, the faint lines are often the important ones. A mold parting line, a panel gap, the edge of a button or a label recess: these are real boundaries on the manufactured part, and they are usually low contrast because they are small and the same material sits on both sides. Protolabs' injection molding guidance explains that designers try to place parting lines along sharp edges precisely so the seam is <a href="https://www.protolabs.com/resources/design-tips/planning-for-parting-lines-in-injection-molding/">camouflaged by the part's geometry</a>. A seam that is designed to be hard to see is exactly the kind of edge a gradient threshold drops first.</p>
<p>Second, the strong false lines do damage downstream. If the line art feeds a re-render where the material changes from glossy white plastic to matte black, a highlight that was traced as a structural line stays in the control image. The new render then has a crease or panel edge where the old reflection used to be.</p>
<h2>Object-read tracing as the alternative</h2>
<p>The fix is to change the question the line source asks. Instead of "where does brightness change fast?", ask "what is the object, and where are its boundaries?" A reflection is never part of the object's boundary, so it never becomes a line, however bright it is. A seam is part of the object, so it can be kept even when its contrast is low.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/8288a98d-f03e-4547-bcc3-a0a2a0b19230.webp" alt="An object-read trace of a real glossy bottle photo: silhouette, cap and neck ring kept, reflections not drawn." style="display:block;margin:0 auto" />

<p>That is how <a href="https://www.virse.ai/tools/ai-line-art-generator">Virse's AI Line Art Generator</a> works. You upload a product photo, choose one of three line styles (Detailed line art, Clean line art or Bold outline), and can add extraction notes such as "keep the side seam, drop reflections, omit the logo". The output is black lines on a white background. Notes like that only make sense for a tool that has already separated the object from its lighting; you cannot tell Canny to ignore a reflection, because to Canny the reflection and the seam are the same kind of thing with different magnitudes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/c55d0d4e-9468-4606-b20a-0d82620b9790.webp" alt="Housing seams on a handheld device kept as real edges, because they belong to the part, not to the light." style="display:block;margin:0 auto" />

<p>The trade-offs are real. An object-read tool needs a photo of an identifiable object, it is slower than a Canny call, and it will not invent a seam that is not visible in the source photo. Canny runs on anything in milliseconds.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ab3edbd26fee5c1ac755a6f/b27d209e-57e0-4c3a-980c-5f982c72f119.webp" alt="Schematic: the same photo through a gradient-based pipeline and an object-read pipeline." style="display:block;margin:0 auto" />

<h2>A quick checklist before you trust a line source</h2>
<p>Before you feed line art from a product photo into anything that matters, check these:</p>
<ol>
<li><p><strong>Find the brightest spot on the object.</strong> If there is a line running along its edge, it came from the lighting.</p>
</li>
<li><p><strong>Look under the object.</strong> A closed curve on the floor is the shadow, not the product.</p>
</li>
<li><p><strong>Find the seams you know exist.</strong> Check the product itself or its CAD file. If a known parting line is missing, lowering the threshold will bring it back along with more noise; it will not bring it back alone.</p>
</li>
<li><p><strong>Decide what the lines are for.</strong> Composition guide for a loose restyle: Canny is fine and fast. Structure for a material swap, a spec sheet or a tech pack: use a source that knows which edges belong to the object.</p>
</li>
<li><p><strong>Keep the test.</strong> The synthetic image above takes a second to generate. Run any new preprocessor or model against it, and you will know how it treats highlights and faint seams before it meets a real product.</p>
</li>
</ol>
<h2>Full script</h2>
<p>The first two snippets above run as a single file, <code>canny_demo.py</code>, with only <code>opencv-python</code> and <code>numpy</code> installed. The automatic-threshold and blur tests read the <code>synthetic-glossy-bottle.png</code> it writes, so run them after it, with the same <code>regions</code> dictionary. Change the stripe brightness (<code>250</code>) or the seam depth (<code>12</code>) and rerun it to see how much contrast a seam needs before Canny keeps it at your usual thresholds. On most studio product shots, the answer will be more contrast than a well-designed seam will ever have.</p>
]]></content:encoded></item></channel></rss>