Custom UI for combining bounding boxes

Hey!

Is it possible to define a custom button that combines multiple bounding boxes rendered on an image and then re-draws the combined outline on the image?

I know that Prodigy does not support selecting multiple bounding boxes, but perhaps a workaround could involve assigning the same label to the boxes to be combined?

Hi @tuomo_h !

Great workaround idea! You should be able to add a custom button injected via JavaScript that does just that. However, you'll need to implement the polygon merging logic yourself. Prodigy has no built-in function for combining/merging bounding boxes, so how the boxes combine into a single outline is entirely up to you.
The example below uses a convex hull of the boxes' corners because it's simple and dependency-free — but that's just one choice. Depending on your images you might want a bounding box of all, a true polygon union, etc. The button is the easy part; the geometry is the meat of it.

Another thing to have in mind is that the redraw relies on rehashing the task. Prodigy's image canvas only re-syncs from the task data when the task's _task_hash changes — so to make the merged outline appear live, the script has to give the task a new hash. The script below does a naive bump (_task_hash + 1) for simplicity, but be aware of what that means: you're effectively re-hashing the example mid-annotation. Because the annotations genuinely changed, a new task hash is legitimate, and the input-level hash (_input_hash, used for input deduplication) is left untouched — so dedup of your source images is unaffected. If you want it done "properly" rather than a +1, you'd recompute a real hash, but the bump is enough to trigger the redraw.

With an example solution I propose below the workflow would be:

  1. Draw your boxes as usual.
  2. Give every box you want to merge a shared label (e.g. COMBINE).
  3. Click a custom "Combine boxes" button → those boxes are replaced by a single polygon span (the convex hull of their corners) that redraws live on the image.

Save the script as static/combine_boxes.js:

const MERGE_LABEL = 'COMBINE'
const RESULT_LABEL = 'COMBINED'

function spanPoints(span) {
  if (Array.isArray(span.points) && span.points.length) return span.points
  if ([span.x, span.y, span.width, span.height].every(v => typeof v === 'number')) {
    const { x, y, width: w, height: h } = span
    return [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
  }
  return []
}

// --- YOUR MERGING LOGIC LIVES HERE ---
// This example computes the convex hull (Andrew's monotone chain). Swap this out
// for whatever "combined outline" semantics you need (bounding box, true union, ...).
function convexHull(points) {
  const pts = points.map(p => [p[0], p[1]]).sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]))
  if (pts.length < 3) return pts
  const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
  const lower = []
  for (const p of pts) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop()
    lower.push(p)
  }
  const upper = []
  for (let i = pts.length - 1; i >= 0; i--) {
    const p = pts[i]
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop()
    upper.push(p)
  }
  lower.pop(); upper.pop()
  return lower.concat(upper)
}

function combineBoxes() {
  const task = window.prodigy.content
  const spans = task.spans || []
  const toMerge = spans.filter(s => s.label === MERGE_LABEL)
  if (toMerge.length < 2) {
    console.warn(`[combine] need >= 2 boxes labelled "${MERGE_LABEL}", found ${toMerge.length}`)
    return
  }
  const hull = convexHull(toMerge.flatMap(spanPoints))
  if (hull.length < 3) return

  const combined = {
    type: 'polygon',
    label: RESULT_LABEL,
    points: hull.map(([x, y]) => [Math.round(x), Math.round(y)]),
  }
  const kept = spans.filter(s => s.label !== MERGE_LABEL)

  window.prodigy.update({
    spans: [...kept, combined],
    // Rehash the task so the image card re-syncs its canvas and redraws.
    // The canvas only refreshes when _task_hash changes.
    _task_hash: (task._task_hash || 0) + 1,
  })
}

function mountButton() {
  if (document.getElementById('combine-boxes-btn')) return
  const btn = document.createElement('button')
  btn.id = 'combine-boxes-btn'
  btn.textContent = 'Combine boxes'
  Object.assign(btn.style, {
    position: 'fixed', bottom: '20px', right: '20px', zIndex: 9999,
    padding: '10px 16px', fontSize: '14px', borderRadius: '6px', border: 'none',
    cursor: 'pointer', background: '#583fcf', color: '#fff',
    boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
  })
  btn.onclick = combineBoxes
  document.body.appendChild(btn)
}

// Scripts loaded via javascript_dir are injected asynchronously, so the one-shot
// `prodigyload` event has usually already fired by the time this runs. Mount now,
// and re-assert on each task render (mountButton is idempotent).
mountButton()
document.addEventListener('prodigyupdate', mountButton)

Wire it up — no custom recipe needed. In your prodigy.json:

{ "javascript_dir": "./static" }

Then run the built-in recipe with your merge label in the label set:

prodigy image.manual my_dataset ./images --label COMBINE,COMBINED,OTHER

The clicked merge produces a single saved span, e.g.:

{ "type": "polygon", "label": "COMBINED", "points": [[42,16],[95,16],[258,43], ...] }

One important note: the merge is destructive — the original COMBINE boxes are removed and replaced by the merged polygon (boxes with other labels are kept untouched). If you'd rather keep the originals and just overlay the outline, change [...kept, combined] to [...spans, combined].



(the button in question is actually not visible in my screenshots but it will appear in the lower right corner)

Hope that helps!