Skip to content
Tutorials10 min read

Image Comparison and Preference Tasks

Build an image comparison task in Potato with two images side by side, a preference scheme, randomized presentation order, and a ranking setup for larger pools.

Potato Team

This tutorial builds an image comparison task in Potato with two generated images side by side, a five-point preference scale, per-criterion follow-up questions, and the ranking and Best-Worst Scaling variants for pools larger than two. The same construction covers RLHF preference data, compression and enhancement comparisons, and design A/B tests.

A comparison asks the annotator to choose between two images rather than to place one image on a scale, which removes the need for every annotator to share a calibration for what a 4 out of 5 means. The cost is that a pool of n images needs many more judgments to produce a full ordering, which is what the ranking and Best-Worst Scaling sections at the end address.

The data file

Each row carries an identifier, the two image paths, and whatever context the annotator needs to judge them. Potato reads the field names from item_properties, so the names themselves are yours to choose:

json
{"id": "pair_001", "prompt": "A sunset over mountains", "image_a": "/media/model_a_001.png", "image_b": "/media/model_b_001.png"}
{"id": "pair_002", "prompt": "A cat on a windowsill", "image_a": "/media/model_a_002.png", "image_b": "/media/model_b_002.png"}

Showing two images side by side

Content and questions are configured separately in Potato. The instance_display block says what the annotator looks at, and annotation_schemes says what they are asked. Two image fields with a horizontal layout put the images next to each other, and zoomable lets the annotator inspect artifacts that are invisible at display size:

yaml
instance_display:
  layout:
    direction: horizontal
    gap: 24px
  fields:
    - key: image_a
      type: image
      label: "Image A"
      display_options:
        max_height: 512
        zoomable: true
        alt_text: "First generated image"
    - key: image_b
      type: image
      label: "Image B"
      display_options:
        max_height: 512
        zoomable: true
        alt_text: "Second generated image"

Here is what the annotator sees once the display and the scheme are both configured:

Two images side by side with a preference scale underneathA comparison task showing both candidates with the preference question below them

The preference question

A five-point scale separates a strong preference from a weak one, which matters when you train on the result, because a pair the annotators split evenly carries a different training signal from one they all called clearly. sequential_key_binding gives the five options the keys 1 through 5, which is the difference between a comparison that takes four seconds and one that takes twelve.

yaml
annotation_schemes:
  - annotation_type: radio
    name: preference
    description: "Which image better matches the prompt?"
    labels:
      - name: "A is clearly better"
      - name: "A is slightly better"
      - name: "About equal"
      - name: "B is slightly better"
      - name: "B is clearly better"
    sequential_key_binding: true
    label_requirement:
      required: true

Add this block to the config above. Make the middle option say "About equal" rather than "No preference", since the two describe different judgments and annotators use a "no preference" option as a skip button.

Position bias

Annotators pick the left option more often than they should, so the side an image lands on has to vary. Randomize it when you build the data file, by swapping image_a and image_b for a random half of the rows and recording which way round each row went, then flip the recorded judgments back before you analyze them. Potato's display fields render in the order you list them, so the config cannot do this for you.

Text comparisons are different. The pairwise schema draws its two candidates from one field of the item data, so randomize_order can permute them, and Potato records the order each annotator actually saw. The seed is derived from the annotator, the item, and the scheme together, which is what makes a person's first-position preference land on different candidates across items instead of correlating with one side for the whole study. See the pairwise comparison documentation when the things being compared are text.

Asking about specific criteria

An overall preference tells you which image won and nothing about why. Adding two or three narrow questions underneath makes the disagreements readable, since annotators who split on the overall judgment often agree about prompt adherence and disagree about aesthetics.

yaml
  - annotation_type: radio
    name: prompt_adherence
    description: "Which image better matches the prompt?"
    labels:
      - name: A
      - name: Tie
      - name: B
 
  - annotation_type: multiselect
    name: issues_a
    description: "Problems in Image A (select all that apply)"
    labels:
      - name: "Distorted faces or hands"
      - name: "Text rendering errors"
      - name: "Unnatural lighting"
      - name: "Missing elements from the prompt"
      - name: "Blurry or low quality"
      - name: "None"

Add these schemes to the block above. Keep the list of criteria short. Every extra question multiplies through the whole study, and a five-criterion form on 2,000 pairs is 10,000 judgments that each need their own definition in the instructions.

Before-and-after comparison

Enhancement, restoration, and editing tasks use the same two-image construction with the sides named rather than randomized, because which side is the original is part of the question:

yaml
instance_display:
  layout:
    direction: horizontal
    gap: 24px
  fields:
    - key: original
      type: image
      label: "Original"
      display_options:
        max_height: 512
        zoomable: true
    - key: enhanced
      type: image
      label: "Enhanced"
      display_options:
        max_height: 512
        zoomable: true
 
annotation_schemes:
  - annotation_type: radio
    name: enhancement_quality
    description: "How did the edit change the image?"
    labels:
      - name: "Clearly improved"
      - name: "Slightly improved"
      - name: "No noticeable change"
      - name: "Made worse"
    label_requirement:
      required: true
 
  - annotation_type: multiselect
    name: problems_introduced
    description: "Problems the edit introduced (select all that apply)"
    labels:
      - name: "Over-sharpening or halos"
      - name: "Color shift"
      - name: "Loss of detail"
      - name: "New artifacts"
      - name: "None"

Ask about problems introduced separately from overall quality. An edit that sharpens the subject and adds halos around it reads as an improvement on a single scale, and the halos are what you need to know about.

More than two images

For three to eight candidates per item, show them in a gallery field and collect a complete ordering with the ranking schema. The gallery takes a list of image paths from one field, so the data file holds an array rather than two separate keys:

yaml
item_properties:
  id_key: id
  text_key: prompt
 
instance_display:
  fields:
    - key: prompt
      type: text
      label: "Prompt"
    - key: images
      type: gallery
      display_options:
        layout: grid
        columns: 2
        thumbnail_size: 320
        zoomable: true
 
annotation_schemes:
  - annotation_type: ranking
    name: preference_rank
    description: "Drag the image numbers to rank them from best at the top to worst at the bottom."
    labels:
      - "Image 1"
      - "Image 2"
      - "Image 3"
      - "Image 4"
    allow_ties: false
    label_requirement:
      required: true

The labels name the positions in the gallery, so the list has to carry one entry per image and every item needs the same number of candidates. Ranking suits small sets, since the annotator compares every candidate against every other one at once. Above about eight candidates the effort per item grows faster than the information you get back.

Larger pools

Best-Worst Scaling is the option when the pool is too large to rank in one screen. Annotators see a tuple of items, usually four, and pick only the best and the worst, and the scores are computed across many overlapping tuples:

yaml
  - annotation_type: bws
    name: quality
    description: "Select the BEST and the WORST image in this set."
    tuple_size: 4
    best_description: "Best"
    worst_description: "Worst"

Add this scheme to the config above. Scoring runs after collection, from the admin dashboard's BWS tab or from the command line, and writes bws_scores.tsv to the output directory:

bash
python -m potato.bws_scoring --config config.yaml --method bradley_terry

The counting method needs no extra dependencies and is the transparent baseline. bradley_terry fits a model over the implied pairwise comparisons and needs pip install choix. plackett_luce models each best and worst choice directly and needs nothing beyond Potato itself. The Best-Worst Scaling documentation covers the tuple generation that has to happen before any of this.

Instructions and attention checks

Say what "better" means for this task in the instructions, because annotators otherwise supply their own definition and you find out at the agreement stage. annotation_instructions puts the text on every annotation page:

yaml
annotation_instructions: |
  Judge each pair on how well it matches the prompt first, then on visual
  quality, then on how appealing it looks. Zoom in before deciding: hands,
  text, and fine texture are where generation failures show up. Mark "About
  equal" only when you have looked at both and cannot separate them.

Attention checks for a comparison task are pairs with an obvious winner, mixed into the queue from their own file so they stay out of your dataset:

yaml
attention_checks:
  enabled: true
  items_file: "data/attention_checks.json"
  frequency: 20
  min_response_time: 3.0
  failure_handling:
    warn_threshold: 2
    block_threshold: 5

Add both blocks to the config above. min_response_time catches the failure mode a comparison task is most exposed to, which is an annotator clicking through at a rate that leaves no time to have looked at the second image.

The complete configuration

This config runs an RLHF preference collection with two model outputs per item, a five-point preference, one criterion question, and a confidence rating. Three annotators see each pair, which is enough to report agreement and to take a majority label.

yaml
annotation_task_name: "Generative Model Comparison"
task_description: "Compare two generated images against the prompt that produced them."
 
data_files:
  - data/model_outputs.json
 
item_properties:
  id_key: id
  text_key: prompt
 
instance_display:
  layout:
    direction: horizontal
    gap: 24px
  fields:
    - key: image_a
      type: image
      label: "Image A"
      display_options:
        max_height: 512
        zoomable: true
        alt_text: "First generated image"
    - key: image_b
      type: image
      label: "Image B"
      display_options:
        max_height: 512
        zoomable: true
        alt_text: "Second generated image"
 
annotation_instructions: |
  Judge each pair on prompt match first, then visual quality, then appeal.
  Zoom in before deciding. Mark "About equal" only when you cannot separate them.
 
annotation_schemes:
  - annotation_type: radio
    name: preference
    description: "Which image better represents the prompt?"
    labels:
      - name: "A is clearly better"
      - name: "A is slightly better"
      - name: "About equal"
      - name: "B is slightly better"
      - name: "B is clearly better"
    sequential_key_binding: true
    label_requirement:
      required: true
 
  - annotation_type: radio
    name: prompt_adherence
    description: "Which image matches the prompt more closely?"
    labels:
      - name: A
      - name: Tie
      - name: B
 
  - annotation_type: likert
    name: confidence
    description: "How confident are you in this judgment?"
    size: 5
    min_label: "Guessing"
    max_label: "Certain"
 
attention_checks:
  enabled: true
  items_file: "data/attention_checks.json"
  frequency: 20
  min_response_time: 3.0
  failure_handling:
    warn_threshold: 2
    block_threshold: 5
 
num_annotators_per_item: 3
output_annotation_dir: annotation_output/image-comparison/
export_annotation_format: jsonl

Start it with potato start config.yaml -p 8000, open the port in a browser, and log in to check the pair renders the way you expect before you send the link anywhere. Annotate a few items yourself as well, because a scheme that reads clearly in the config often turns out to be ambiguous on the third real pair.

What the output contains

Potato writes per-annotator files under output_annotation_dir, and export_annotation_format adds a periodic export under <output_annotation_dir>/exports/<format>/. Each exported record names the instance, the annotator, and the label chosen for each scheme:

json
{"instance_id": "pair_001", "user_id": "rater_01", "labels": {"preference": {"A is slightly better": "A is slightly better"}, "confidence": {"4": "4"}}, "spans": {}, "links": {}}

Join that back to your data file on instance_id to recover which model produced which side, and undo the left-right swap you applied when building the file before you aggregate anything. Doing that step last, rather than in the annotation config, is what keeps the raw record of what each annotator actually saw.

Next steps