FanLR commited on
Commit
52f0700
·
verified ·
1 Parent(s): 07f2e54

Label dense boxes once per category and avoid overlapping tags

Browse files
assets/studio.css CHANGED
@@ -51,7 +51,7 @@
51
  #targets-panel {padding:0 var(--stage-gutter) 20px!important;gap:0!important;border:0!important;min-width:0!important}
52
  .targets-summary {border-top:1px solid var(--line);padding-top:14px;font-size:12px}
53
  .targets-summary b {font-size:16px}.targets-summary>span {font-size:11px}
54
- .target-chips {display:flex!important;flex-direction:row;flex-wrap:wrap;max-height:140px!important;gap:6px;margin:10px 0 0;padding:2px}
55
  .target-chip {width:auto!important;flex:0 1 auto;min-width:0;min-height:34px!important;padding:7px 10px!important;gap:7px!important;background:#fff!important;border-radius:6px!important;font-size:11px!important}
56
  .target-chip>span {overflow-wrap:anywhere}.target-chip b {margin-left:3px}
57
  .target-chip[aria-pressed=true] {border-color:var(--purple)!important;background:#f0eaf7!important}
 
51
  #targets-panel {padding:0 var(--stage-gutter) 20px!important;gap:0!important;border:0!important;min-width:0!important}
52
  .targets-summary {border-top:1px solid var(--line);padding-top:14px;font-size:12px}
53
  .targets-summary b {font-size:16px}.targets-summary>span {font-size:11px}
54
+ .target-chips {display:flex!important;flex-direction:row;flex-wrap:wrap;max-height:280px!important;gap:6px;margin:10px 0 0;padding:2px}
55
  .target-chip {width:auto!important;flex:0 1 auto;min-width:0;min-height:34px!important;padding:7px 10px!important;gap:7px!important;background:#fff!important;border-radius:6px!important;font-size:11px!important}
56
  .target-chip>span {overflow-wrap:anywhere}.target-chip b {margin-left:3px}
57
  .target-chip[aria-pressed=true] {border-color:var(--purple)!important;background:#f0eaf7!important}
docs/UI_STUDIO_R8.md CHANGED
@@ -16,6 +16,11 @@ serve the standalone demo and the private Hugging Face Space.
16
  navigation and a magnified crop. Closing focus restores every prediction.
17
  - Dense boxes use fine edges and short corner marks, with no filled interiors.
18
  No prediction is suppressed, merged, moved, deduplicated or replaced.
 
 
 
 
 
19
  - Empty detail panels stay hidden; raw prompt/output remain available in accordions.
20
 
21
  ## Contract
 
16
  navigation and a magnified crop. Closing focus restores every prediction.
17
  - Dense boxes use fine edges and short corner marks, with no filled interiors.
18
  No prediction is suppressed, merged, moved, deduplicated or replaced.
19
+ - Box labels appear in sparse and dense scenes. Each exact category gets at most
20
+ one on-image name across its instances. Labels avoid each other and small boxes;
21
+ nearby free space and short leader lines handle crowded anchors. The category
22
+ inspector always retains every category, including labels that cannot physically
23
+ fit on a tiny canvas. Font weight is explicitly set for legible raster text.
24
  - Empty detail panels stay hidden; raw prompt/output remain available in accordions.
25
 
26
  ## Contract
docs/ui_studio_r8_lock.json CHANGED
@@ -8,7 +8,7 @@
8
  "assets/frosted.css": "393bf5f1c04a49a6e9b17e855e573646489b4add85bc7973eb6bb277686b5c80",
9
  "assets/original.css": "7a244c7f65e40797850dcde7d6b6505b1b5de60aa24b129f44b33ae52291fd2e",
10
  "assets/paper.css": "61fc60bf8c379a0894fbfe06574964ec2ed256791a23860fa4dbb872eb2869f9",
11
- "assets/studio.css": "3275fe0212672640f477e4273a6c8052648f361c7631400aaf6ad2aaebc89185",
12
  "assets/xpeng-logo.png": "ff0db78ddf8f9a122eec74e8eb79581f8e5dbc46803dddeb780a06f5ae295750",
13
  "cases/dense/dense-01-o365_bbox_gt50_00009110.jpg": "398038106cbac731516dd01de25354fe4d61f3599df64baa611f1d5a99e0e8fb",
14
  "cases/dense/dense-02-o365_bbox_gt50_00004676.jpg": "7ee3beb63bdfd50c183538bd56f9d7e079fa41bde78aad9d5fe0d22c041c2673",
@@ -40,7 +40,7 @@
40
  "grounding_demo/catalog.py": "75e543fed7f0ca018fb41c4b528efc2901aa6a0f05232959297d6d40798f676f",
41
  "grounding_demo/console.py": "dbc1a08c937f18d5c104d4ab18316937d21a2b0d2df9ba2cba296d9ae1e20f16",
42
  "grounding_demo/protocol.py": "aa4e634070ab7a17c6bc477051e97cb02e772e9e37db89d5de9b6497312635b0",
43
- "grounding_demo/render.py": "a4d43398a7d42a2f7884b16141bd43be3b687fdd7ce24fa89405341fb6152b21"
44
  },
45
  "case_contract_sha256": "d00f2091dcb3c866e5e3a331f89537839640b2374c4bc32685960706d18b58b1",
46
  "cases": 27,
 
8
  "assets/frosted.css": "393bf5f1c04a49a6e9b17e855e573646489b4add85bc7973eb6bb277686b5c80",
9
  "assets/original.css": "7a244c7f65e40797850dcde7d6b6505b1b5de60aa24b129f44b33ae52291fd2e",
10
  "assets/paper.css": "61fc60bf8c379a0894fbfe06574964ec2ed256791a23860fa4dbb872eb2869f9",
11
+ "assets/studio.css": "dcc22a94afbcff8079c5bcaeda2849f75e6ad7b204af9a49f2c3d954c3df46cd",
12
  "assets/xpeng-logo.png": "ff0db78ddf8f9a122eec74e8eb79581f8e5dbc46803dddeb780a06f5ae295750",
13
  "cases/dense/dense-01-o365_bbox_gt50_00009110.jpg": "398038106cbac731516dd01de25354fe4d61f3599df64baa611f1d5a99e0e8fb",
14
  "cases/dense/dense-02-o365_bbox_gt50_00004676.jpg": "7ee3beb63bdfd50c183538bd56f9d7e079fa41bde78aad9d5fe0d22c041c2673",
 
40
  "grounding_demo/catalog.py": "75e543fed7f0ca018fb41c4b528efc2901aa6a0f05232959297d6d40798f676f",
41
  "grounding_demo/console.py": "dbc1a08c937f18d5c104d4ab18316937d21a2b0d2df9ba2cba296d9ae1e20f16",
42
  "grounding_demo/protocol.py": "aa4e634070ab7a17c6bc477051e97cb02e772e9e37db89d5de9b6497312635b0",
43
+ "grounding_demo/render.py": "ba6201d2eda2f3bf7fb0988e35f76303a4c43455d18cc60c4db547d87155d73d"
44
  },
45
  "case_contract_sha256": "d00f2091dcb3c866e5e3a331f89537839640b2374c4bc32685960706d18b58b1",
46
  "cases": 27,
grounding_demo/render.py CHANGED
@@ -1,6 +1,5 @@
1
  from __future__ import annotations
2
 
3
- from collections import defaultdict
4
  from collections.abc import Iterable
5
  from pathlib import Path
6
 
@@ -23,7 +22,10 @@ def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
23
  )
24
  for candidate in candidates:
25
  if Path(candidate).is_file():
26
- return ImageFont.truetype(candidate, size=size)
 
 
 
27
  return ImageFont.load_default()
28
 
29
 
@@ -38,6 +40,59 @@ def _xy(values: tuple[int, ...], width: int, height: int) -> tuple[int, ...]:
38
  )
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def draw_primitives(
42
  image: Image.Image,
43
  primitives: Iterable[Primitive],
@@ -54,9 +109,8 @@ def draw_primitives(
54
  # boxes discoverable without filling, moving, or removing any prediction.
55
  line = max(1 if dense else 2, round(min(width, height) / (420 if dense else 260)))
56
  radius = max(6, line * 3)
57
- font = _font(max(13, round(min(width, height) / 42)))
58
  label_colors: dict[str, tuple[int, int, int]] = {}
59
- seen: defaultdict[str, int] = defaultdict(int)
60
 
61
  for ref in references:
62
  if len(ref.coordinates) != 4:
@@ -69,13 +123,11 @@ def draw_primitives(
69
  draw.line((x, y1, min(x + dash, x2), y1), fill=(*GREEN, 255), width=line)
70
  draw.line((x, y2, min(x + dash, x2), y2), fill=(*GREEN, 255), width=line)
71
 
72
- labels = []
73
  # Opaque RGBA writes replace earlier pixels in a Pillow layer. In particular,
74
  # a later lake/cloud fill used to erase every small object drawn inside it.
75
  # Keep region interiors untouched; geometry and labels are separate passes.
76
  for primitive in primitives:
77
  color = label_colors.setdefault(primitive.label, PALETTE[len(label_colors) % len(PALETTE)])
78
- seen[primitive.label] += 1
79
  coords = _xy(primitive.coordinates, width, height)
80
  if len(coords) == 2:
81
  x, y = coords
@@ -94,44 +146,22 @@ def draw_primitives(
94
  if corner > 0:
95
  for x, y, dx, dy in ((x1,y1,1,1),(x2,y1,-1,1),(x1,y2,1,-1),(x2,y2,-1,-1)):
96
  draw.line((x+dx*corner,y,x,y,x,y+dy*corner), fill=(*color,255), width=max(2,line))
97
- if seen[primitive.label] == 1:
98
- labels.append((primitive.label, color, coords))
99
-
100
- # Dense scenes use the linked category inspector instead of a wall of text.
101
- # Sparse labels are clamped to the canvas and never overlap one another or
102
- # cover a small target. Exact predictions are never suppressed or merged.
103
- occupied = []
104
- small_targets = [_xy(p.coordinates, width, height) for p in primitives
105
- if len(p.coordinates) == 4
106
- and (p.coordinates[2] - p.coordinates[0]) *
107
- (p.coordinates[3] - p.coordinates[1]) < 10000]
108
-
109
- def intersects(a, b):
110
- return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
111
-
112
- for text, color, (x1, y1, x2, y2) in labels if len(labels) <= 12 else []:
113
- text = text[:38]
114
- tb = draw.textbbox((0, 0), text, font=font)
115
- while tb[2] - tb[0] + line * 4 > width - 2 and text:
116
- text = text[:-1]
117
- tb = draw.textbbox((0, 0), text + "…", font=font)
118
- if not text:
119
- continue
120
- tw, th = tb[2] - tb[0] + line * 4, tb[3] - tb[1] + line * 2
121
- candidates = [(x1, y1-th-line), (x1, y2+line),
122
- (x2-tw, y1-th-line), (x1+line, y1+line)]
123
- for tx, ty in candidates:
124
- tx, ty = max(0, min(tx, width-tw-1)), max(0, min(ty, height-th-1))
125
- box = (tx, ty, tx+tw, ty+th)
126
- if any(intersects(box, other) for other in occupied + small_targets):
127
- continue
128
- occupied.append(box)
129
- light = tuple(round(c * .15 + 255 * .85) for c in color)
130
- draw.rounded_rectangle(box, radius=line*2, fill=(*light, 250),
131
- outline=(*color, 255), width=max(1, line//2))
132
- draw.text((tx+line*2-tb[0], ty+line-tb[1]), text, font=font,
133
- fill=(47, 31, 65, 255))
134
- break
135
 
136
  if title:
137
  tb = draw.textbbox((0, 0), title, font=font)
 
1
  from __future__ import annotations
2
 
 
3
  from collections.abc import Iterable
4
  from pathlib import Path
5
 
 
22
  )
23
  for candidate in candidates:
24
  if Path(candidate).is_file():
25
+ font = ImageFont.truetype(candidate, size=size)
26
+ if Path(candidate).name == "Manrope-variable.ttf":
27
+ font.set_variation_by_axes([600])
28
+ return font
29
  return ImageFont.load_default()
30
 
31
 
 
40
  )
41
 
42
 
43
+ def _intersects(a, b, gap=2):
44
+ return a[0] < b[2]+gap and b[0] < a[2]+gap and a[1] < b[3]+gap and b[1] < a[3]+gap
45
+
46
+
47
+ def layout_box_labels(primitives, width, height, font, padding=4):
48
+ """One collision-free badge per exact category; returned boxes stay untouched."""
49
+ groups = {}
50
+ for primitive in primitives:
51
+ if len(primitive.coordinates) == 4:
52
+ groups.setdefault(primitive.label, []).append(_xy(primitive.coordinates, width, height))
53
+ area = lambda b: max(0,b[2]-b[0])*max(0,b[3]-b[1])
54
+ small = [b for boxes in groups.values() for b in boxes if area(b)<width*height*.01]
55
+ measure = ImageDraw.Draw(Image.new("RGB", (1, 1)))
56
+ occupied, placed = [], []
57
+ # Give small targets first choice of nearby space. Large regions have more
58
+ # candidate anchors. Ordering affects label placement only, never predictions.
59
+ for label, boxes in sorted(groups.items(), key=lambda item: max(map(area,item[1]))):
60
+ text = label if len(label)<=38 else label[:37]+"…"
61
+ while text:
62
+ bounds = measure.textbbox((0,0),text,font=font)
63
+ tw, th = bounds[2]-bounds[0]+2*padding, bounds[3]-bounds[1]+2*padding
64
+ if tw<=width-2 and th<=height-2: break
65
+ text = text[:-2]+"…" if len(text)>2 else ""
66
+ if not text: continue
67
+ candidates=[]
68
+ for target in sorted(boxes,key=area,reverse=True):
69
+ x1,y1,x2,y2=target
70
+ for x,y in [(x1,y1-th-2),(x1,y1+2),(x1,y2+2),(x2-tw,y1-th-2),
71
+ (x2-tw,y2-th-2),(x2+2,y1),(x1-tw-2,y1)]:
72
+ x=max(1,min(x,width-tw-1));y=max(1,min(y,height-th-1))
73
+ candidates.append(((x,y,x+tw,y+th),target))
74
+ chosen=next(((b,t) for b,t in candidates if not any(_intersects(b,o) for o in occupied+small)),None)
75
+ if chosen is None:
76
+ # Rare crowded anchors use the nearest free area with a leader line.
77
+ # Never put a badge on another badge or cover a small prediction.
78
+ free=[]
79
+ for y in range(1,max(2,height-th),max(4,th//2)):
80
+ for x in range(1,max(2,width-tw),max(6,tw//4)):
81
+ b=(x,y,x+tw,y+th)
82
+ if any(_intersects(b,o) for o in occupied+small): continue
83
+ for t in boxes:
84
+ dx=max(t[0]-b[2],b[0]-t[2],0);dy=max(t[1]-b[3],b[1]-t[3],0)
85
+ # Prefer top-left anchors among equally near locations.
86
+ score=dx*dx+dy*dy+.01*((x-t[0])**2+(y-t[1])**2)
87
+ free.append((score,b,t))
88
+ if free:
89
+ _,b,t=min(free,key=lambda item:item[0]);chosen=(b,t)
90
+ if chosen:
91
+ b,target=chosen;occupied.append(b)
92
+ placed.append(dict(label=label,text=text,box=b,target=target,text_bounds=bounds))
93
+ return placed
94
+
95
+
96
  def draw_primitives(
97
  image: Image.Image,
98
  primitives: Iterable[Primitive],
 
109
  # boxes discoverable without filling, moving, or removing any prediction.
110
  line = max(1 if dense else 2, round(min(width, height) / (420 if dense else 260)))
111
  radius = max(6, line * 3)
112
+ font = _font(max(12 if dense else 13, round(min(width, height) / 48)))
113
  label_colors: dict[str, tuple[int, int, int]] = {}
 
114
 
115
  for ref in references:
116
  if len(ref.coordinates) != 4:
 
123
  draw.line((x, y1, min(x + dash, x2), y1), fill=(*GREEN, 255), width=line)
124
  draw.line((x, y2, min(x + dash, x2), y2), fill=(*GREEN, 255), width=line)
125
 
 
126
  # Opaque RGBA writes replace earlier pixels in a Pillow layer. In particular,
127
  # a later lake/cloud fill used to erase every small object drawn inside it.
128
  # Keep region interiors untouched; geometry and labels are separate passes.
129
  for primitive in primitives:
130
  color = label_colors.setdefault(primitive.label, PALETTE[len(label_colors) % len(PALETTE)])
 
131
  coords = _xy(primitive.coordinates, width, height)
132
  if len(coords) == 2:
133
  x, y = coords
 
146
  if corner > 0:
147
  for x, y, dx, dy in ((x1,y1,1,1),(x2,y1,-1,1),(x1,y2,1,-1),(x2,y2,-1,-1)):
148
  draw.line((x+dx*corner,y,x,y,x,y+dy*corner), fill=(*color,255), width=max(2,line))
149
+
150
+ # Labels remain visible in dense scenes, once per category across all boxes.
151
+ padding=max(4,line*2)
152
+ for badge in layout_box_labels(primitives,width,height,font,padding):
153
+ text,box,target=badge["text"],badge["box"],badge["target"]
154
+ color=label_colors[badge["label"]]
155
+ tx,ty=box[:2];tb=badge["text_bounds"]
156
+ # Attach displaced badges to their actual prediction with a fine leader.
157
+ if not _intersects(box,target,gap=4):
158
+ cx,cy=(box[0]+box[2])/2,(box[1]+box[3])/2
159
+ ex=max(target[0],min(cx,target[2]));ey=max(target[1],min(cy,target[3]))
160
+ sx=max(box[0],min(ex,box[2]));sy=max(box[1],min(ey,box[3]))
161
+ draw.line((sx,sy,ex,ey),fill=(*color,230),width=1)
162
+ light=tuple(round(c*.10+255*.90) for c in color)
163
+ draw.rounded_rectangle(box,radius=3,fill=(*light,248),outline=(*color,255),width=1)
164
+ draw.text((tx+padding-tb[0],ty+padding-tb[1]),text,font=font,fill=(38,30,49,255))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  if title:
167
  tb = draw.textbbox((0, 0), title, font=font)
tests/test_render.py CHANGED
@@ -1,10 +1,26 @@
1
  import unittest
2
  from PIL import Image
3
  from grounding_demo.protocol import Primitive
4
- from grounding_demo.render import draw_primitives, PURPLE
5
 
6
 
7
  class RenderRegression(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  def test_large_late_region_does_not_erase_small_target(self):
9
  image = Image.new("RGB", (1000, 1000), "white")
10
  small = Primitive("boat", (300, 400, 350, 450))
 
1
  import unittest
2
  from PIL import Image
3
  from grounding_demo.protocol import Primitive
4
+ from grounding_demo.render import draw_primitives, layout_box_labels, _font, PURPLE
5
 
6
 
7
  class RenderRegression(unittest.TestCase):
8
+ def test_dense_labels_are_unique_visible_and_do_not_overlap(self):
9
+ boxes = [Primitive(f"category {i}", (50+(i%5)*180, 50+(i//5)*210,
10
+ 150+(i%5)*180, 190+(i//5)*210)) for i in range(20)]
11
+ boxes += [Primitive("category 0", (700, 800, 850, 950))]
12
+ original = list(boxes)
13
+ labels = layout_box_labels(boxes, 1000, 1000, _font(16))
14
+ self.assertEqual(len(labels), 20)
15
+ self.assertEqual(len({row["label"] for row in labels}), 20)
16
+ for i, row in enumerate(labels):
17
+ x1,y1,x2,y2=row["box"]
18
+ self.assertTrue(0<=x1<x2<=1000 and 0<=y1<y2<=1000)
19
+ for other in labels[i+1:]:
20
+ a,b,c,d=other["box"]
21
+ self.assertFalse(x1<c and a<x2 and y1<d and b<y2)
22
+ self.assertEqual(boxes, original)
23
+
24
  def test_large_late_region_does_not_erase_small_target(self):
25
  image = Image.new("RGB", (1000, 1000), "white")
26
  small = Primitive("boat", (300, 400, 350, 450))