Spaces:
Running on Zero
Running on Zero
Label dense boxes once per category and avoid overlapping tags
Browse files- assets/studio.css +1 -1
- docs/UI_STUDIO_R8.md +5 -0
- docs/ui_studio_r8_lock.json +2 -2
- grounding_demo/render.py +74 -44
- tests/test_render.py +17 -1
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:
|
| 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": "
|
| 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": "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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) /
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 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))
|