FanLR commited on
Commit
8e0d79f
·
verified ·
1 Parent(s): 88f2502

Fix Chinese OCR labels with bundled CJK font

Browse files

Chinese OCR labels rendered as missing-glyph squares because the raster overlay used a Latin font. Bundle Noto Sans CJK and use the same selected font for label measurement and drawing. Preserve recognized text and coordinates. Validate mixed Chinese/English, simplified and traditional Chinese, punctuation, long labels and canvas edges.

Validation: 32 relevant unit tests passed; UI/case integrity check passed (9 routes, 22 cases); release bundle includes the font and license.

.gitattributes CHANGED
@@ -74,3 +74,4 @@ cases/referring/09_referring_round5_candidates_gam_humanref_2178.jpg filter=lfs
74
  cases/visual_prompt/20_visual_prompt_demo_cases_demo_visual_prompt_15.jpg filter=lfs diff=lfs merge=lfs -text
75
  cases/visual_prompt/21_visual_prompt_round5_candidates_gam_visual_dense200_142.jpg filter=lfs diff=lfs merge=lfs -text
76
  cases/visual_prompt/22_visual_prompt_round5_candidates_gam_visual_dense200_187.jpg filter=lfs diff=lfs merge=lfs -text
 
 
74
  cases/visual_prompt/20_visual_prompt_demo_cases_demo_visual_prompt_15.jpg filter=lfs diff=lfs merge=lfs -text
75
  cases/visual_prompt/21_visual_prompt_round5_candidates_gam_visual_dense200_142.jpg filter=lfs diff=lfs merge=lfs -text
76
  cases/visual_prompt/22_visual_prompt_round5_candidates_gam_visual_dense200_187.jpg filter=lfs diff=lfs merge=lfs -text
77
+ assets/NotoSansCJKsc-Regular.otf filter=lfs diff=lfs merge=lfs -text
assets/NotoSansCJK-OFL.txt ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This Font Software is licensed under the SIL Open Font License,
2
+ Version 1.1.
3
+
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+ -----------------------------------------------------------
8
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9
+ -----------------------------------------------------------
10
+
11
+ PREAMBLE
12
+ The goals of the Open Font License (OFL) are to stimulate worldwide
13
+ development of collaborative font projects, to support the font
14
+ creation efforts of academic and linguistic communities, and to
15
+ provide a free and open framework in which fonts may be shared and
16
+ improved in partnership with others.
17
+
18
+ The OFL allows the licensed fonts to be used, studied, modified and
19
+ redistributed freely as long as they are not sold by themselves. The
20
+ fonts, including any derivative works, can be bundled, embedded,
21
+ redistributed and/or sold with any software provided that any reserved
22
+ names are not used by derivative works. The fonts and derivatives,
23
+ however, cannot be released under any other type of license. The
24
+ requirement for fonts to remain under this license does not apply to
25
+ any document created using the fonts or their derivatives.
26
+
27
+ DEFINITIONS
28
+ "Font Software" refers to the set of files released by the Copyright
29
+ Holder(s) under this license and clearly marked as such. This may
30
+ include source files, build scripts and documentation.
31
+
32
+ "Reserved Font Name" refers to any names specified as such after the
33
+ copyright statement(s).
34
+
35
+ "Original Version" refers to the collection of Font Software
36
+ components as distributed by the Copyright Holder(s).
37
+
38
+ "Modified Version" refers to any derivative made by adding to,
39
+ deleting, or substituting -- in part or in whole -- any of the
40
+ components of the Original Version, by changing formats or by porting
41
+ the Font Software to a new environment.
42
+
43
+ "Author" refers to any designer, engineer, programmer, technical
44
+ writer or other person who contributed to the Font Software.
45
+
46
+ PERMISSION & CONDITIONS
47
+ Permission is hereby granted, free of charge, to any person obtaining
48
+ a copy of the Font Software, to use, study, copy, merge, embed,
49
+ modify, redistribute, and sell modified and unmodified copies of the
50
+ Font Software, subject to the following conditions:
51
+
52
+ 1) Neither the Font Software nor any of its individual components, in
53
+ Original or Modified Versions, may be sold by itself.
54
+
55
+ 2) Original or Modified Versions of the Font Software may be bundled,
56
+ redistributed and/or sold with any software, provided that each copy
57
+ contains the above copyright notice and this license. These can be
58
+ included either as stand-alone text files, human-readable headers or
59
+ in the appropriate machine-readable metadata fields within text or
60
+ binary files as long as those fields can be easily viewed by the user.
61
+
62
+ 3) No Modified Version of the Font Software may use the Reserved Font
63
+ Name(s) unless explicit written permission is granted by the
64
+ corresponding Copyright Holder. This restriction only applies to the
65
+ primary font name as presented to the users.
66
+
67
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68
+ Software shall not be used to promote, endorse or advertise any
69
+ Modified Version, except to acknowledge the contribution(s) of the
70
+ Copyright Holder(s) and the Author(s) or with their explicit written
71
+ permission.
72
+
73
+ 5) The Font Software, modified or unmodified, in part or in whole,
74
+ must be distributed entirely under this license, and must not be
75
+ distributed under any other license. The requirement for fonts to
76
+ remain under this license does not apply to any document created using
77
+ the Font Software.
78
+
79
+ TERMINATION
80
+ This license becomes null and void if any of the above conditions are
81
+ not met.
82
+
83
+ DISCLAIMER
84
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92
+ OTHER DEALINGS IN THE FONT SOFTWARE.
assets/NotoSansCJKsc-Regular.otf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b
3
+ size 16437364
assets/SOURCES.md CHANGED
@@ -2,6 +2,7 @@
2
 
3
  - `xpeng-logo.png`: unchanged asset reused from the existing Oxygen demo at `X-CG_demo/public/xpeng-logo.png`; SHA256 `ff0db78ddf8f9a122eec74e8eb79581f8e5dbc46803dddeb780a06f5ae295750`. XPENG brand ownership is unchanged. The image is not a newly drawn logo.
4
  - `Manrope-variable.ttf`: [Google Fonts Manrope source](https://github.com/google/fonts/tree/main/ofl/manrope), SIL Open Font License bundled as `Manrope-OFL.txt`. Embedded locally to avoid font-service requests through the Fuyao proxy.
 
5
  - Empty-state, close and focus marks: authored SVG geometry in `grounding_demo/console.py` and `console.js`; focus geometry is generated exclusively from validated GAM coordinates.
6
 
7
  Design guidance consulted: [Anthropic frontend-design](https://github.com/anthropics/skills/tree/main/skills/frontend-design) and [Impeccable](https://github.com/pbakaus/impeccable), animate/polish workflows. These are design references, not runtime dependencies.
 
2
 
3
  - `xpeng-logo.png`: unchanged asset reused from the existing Oxygen demo at `X-CG_demo/public/xpeng-logo.png`; SHA256 `ff0db78ddf8f9a122eec74e8eb79581f8e5dbc46803dddeb780a06f5ae295750`. XPENG brand ownership is unchanged. The image is not a newly drawn logo.
4
  - `Manrope-variable.ttf`: [Google Fonts Manrope source](https://github.com/google/fonts/tree/main/ofl/manrope), SIL Open Font License bundled as `Manrope-OFL.txt`. Embedded locally to avoid font-service requests through the Fuyao proxy.
5
+ - `NotoSansCJKsc-Regular.otf`: [Noto Sans CJK upstream](https://github.com/notofonts/noto-cjk/blob/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf), pinned at `f8d157532fbfaeda587e826d4cd5b21a49186f7c`. Unmodified font for Chinese and mixed-language image labels; SIL Open Font License bundled as `NotoSansCJK-OFL.txt`. Raster label measurement and drawing share this font; no system font installation is needed.
6
  - Empty-state, close and focus marks: authored SVG geometry in `grounding_demo/console.py` and `console.js`; focus geometry is generated exclusively from validated GAM coordinates.
7
 
8
  Design guidance consulted: [Anthropic frontend-design](https://github.com/anthropics/skills/tree/main/skills/frontend-design) and [Impeccable](https://github.com/pbakaus/impeccable), animate/polish workflows. These are design references, not runtime dependencies.
docs/ui_case_pool_22_lock.json CHANGED
@@ -3,7 +3,9 @@
3
  "app.py": "79282810a5f15138950ffb9d2ff108067f7f73cf52c797a1931920e1fbff40b8",
4
  "assets/Manrope-OFL.txt": "e01b637272e0cbdfb240184dd98ea5cc671556d9894dae2668d92ab2c906787c",
5
  "assets/Manrope-variable.ttf": "d0639be45d0af36e798172419d7bd173c4bd4f29e2b76cbb69db1d11bf8b0a40",
6
- "assets/SOURCES.md": "3cceb52136b55f86840e9585e3c37faed1adc762452b0fea50aecba696dac7fa",
 
 
7
  "assets/console.js": "3af36a3aeafb72e85018b1c663f624b92877bad0152ee9e264a1c440d689f60e",
8
  "assets/frosted.css": "393bf5f1c04a49a6e9b17e855e573646489b4add85bc7973eb6bb277686b5c80",
9
  "assets/original.css": "7a244c7f65e40797850dcde7d6b6505b1b5de60aa24b129f44b33ae52291fd2e",
@@ -35,7 +37,7 @@
35
  "grounding_demo/catalog.py": "75e543fed7f0ca018fb41c4b528efc2901aa6a0f05232959297d6d40798f676f",
36
  "grounding_demo/console.py": "5754564abe58099c9406de639663d9d1416b96bc65d7a89b24f402ec41978fb8",
37
  "grounding_demo/protocol.py": "cf043c9f026a1dcd63d55c5393f0d0921cd15eae4a0cc9a42e2834fab23486ec",
38
- "grounding_demo/render.py": "ba6201d2eda2f3bf7fb0988e35f76303a4c43455d18cc60c4db547d87155d73d"
39
  },
40
  "case_contract_sha256": "006774de24ef12d7cd8a05f73e27ec2d27278ba63fbe000db368541f946dec93",
41
  "cases": 22,
 
3
  "app.py": "79282810a5f15138950ffb9d2ff108067f7f73cf52c797a1931920e1fbff40b8",
4
  "assets/Manrope-OFL.txt": "e01b637272e0cbdfb240184dd98ea5cc671556d9894dae2668d92ab2c906787c",
5
  "assets/Manrope-variable.ttf": "d0639be45d0af36e798172419d7bd173c4bd4f29e2b76cbb69db1d11bf8b0a40",
6
+ "assets/NotoSansCJK-OFL.txt": "6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2",
7
+ "assets/NotoSansCJKsc-Regular.otf": "2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b",
8
+ "assets/SOURCES.md": "0299a8a04951a07c896b28168496209ff900e60f6ae0c98bfdc86a7cc47c1712",
9
  "assets/console.js": "3af36a3aeafb72e85018b1c663f624b92877bad0152ee9e264a1c440d689f60e",
10
  "assets/frosted.css": "393bf5f1c04a49a6e9b17e855e573646489b4add85bc7973eb6bb277686b5c80",
11
  "assets/original.css": "7a244c7f65e40797850dcde7d6b6505b1b5de60aa24b129f44b33ae52291fd2e",
 
37
  "grounding_demo/catalog.py": "75e543fed7f0ca018fb41c4b528efc2901aa6a0f05232959297d6d40798f676f",
38
  "grounding_demo/console.py": "5754564abe58099c9406de639663d9d1416b96bc65d7a89b24f402ec41978fb8",
39
  "grounding_demo/protocol.py": "cf043c9f026a1dcd63d55c5393f0d0921cd15eae4a0cc9a42e2834fab23486ec",
40
+ "grounding_demo/render.py": "3b3fe5901edc41586bf5f1a1c263265d47c0fbe4d2ee4c30697c1bd0f78d484b"
41
  },
42
  "case_contract_sha256": "006774de24ef12d7cd8a05f73e27ec2d27278ba63fbe000db368541f946dec93",
43
  "cases": 22,
grounding_demo/render.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from collections.abc import Iterable
 
4
  from pathlib import Path
5
 
6
  from PIL import Image, ImageDraw, ImageFont
@@ -14,9 +15,15 @@ CYAN = (55, 171, 183)
14
  PALETTE = (PURPLE, GREEN, PINK, CYAN)
15
 
16
 
17
- def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
 
 
 
 
 
 
18
  candidates = (
19
- str(Path(__file__).resolve().parents[1] / "assets/Manrope-variable.ttf"),
20
  "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
21
  "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
22
  )
@@ -29,6 +36,10 @@ def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
29
  return ImageFont.load_default()
30
 
31
 
 
 
 
 
32
  def _xy(values: tuple[int, ...], width: int, height: int) -> tuple[int, ...]:
33
  if len(values) == 2:
34
  return round(values[0] * (width - 1) / 999), round(values[1] * (height - 1) / 999)
@@ -57,9 +68,10 @@ def layout_box_labels(primitives, width, height, font, padding=4):
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 ""
@@ -89,7 +101,7 @@ def layout_box_labels(primitives, width, height, font, padding=4):
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
 
@@ -161,10 +173,11 @@ def draw_primitives(
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)
 
168
  draw.rounded_rectangle(
169
  (10, 10, min(width - 10, tb[2] + 28), tb[3] + 24),
170
  radius=10,
@@ -172,5 +185,5 @@ def draw_primitives(
172
  outline=(*PURPLE, 255),
173
  width=line,
174
  )
175
- draw.text((18, 16), title, font=font, fill=(*PURPLE, 255))
176
  return Image.alpha_composite(canvas, overlay).convert("RGB")
 
1
  from __future__ import annotations
2
 
3
  from collections.abc import Iterable
4
+ from functools import lru_cache
5
  from pathlib import Path
6
 
7
  from PIL import Image, ImageDraw, ImageFont
 
15
  PALETTE = (PURPLE, GREEN, PINK, CYAN)
16
 
17
 
18
+ @lru_cache(maxsize=32)
19
+ def _load_font(size: int, multilingual: bool) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
20
+ assets = Path(__file__).resolve().parents[1] / "assets"
21
+ if multilingual:
22
+ # Pillow does not perform the browser's font fallback. Bundle CJK glyphs
23
+ # so Chinese OCR labels render identically on macOS and the Linux Space.
24
+ return ImageFont.truetype(str(assets / "NotoSansCJKsc-Regular.otf"), size=size)
25
  candidates = (
26
+ str(assets / "Manrope-variable.ttf"),
27
  "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
28
  "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
29
  )
 
36
  return ImageFont.load_default()
37
 
38
 
39
+ def _font(size: int, text: str = "") -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
40
+ return _load_font(size, not text.isascii())
41
+
42
+
43
  def _xy(values: tuple[int, ...], width: int, height: int) -> tuple[int, ...]:
44
  if len(values) == 2:
45
  return round(values[0] * (width - 1) / 999), round(values[1] * (height - 1) / 999)
 
68
  # Give small targets first choice of nearby space. Large regions have more
69
  # candidate anchors. Ordering affects label placement only, never predictions.
70
  for label, boxes in sorted(groups.items(), key=lambda item: max(map(area,item[1]))):
71
+ label_font = _font(getattr(font, "size", 13), label) if not label.isascii() else font
72
  text = label if len(label)<=38 else label[:37]+"…"
73
  while text:
74
+ bounds = measure.textbbox((0,0),text,font=label_font)
75
  tw, th = bounds[2]-bounds[0]+2*padding, bounds[3]-bounds[1]+2*padding
76
  if tw<=width-2 and th<=height-2: break
77
  text = text[:-2]+"…" if len(text)>2 else ""
 
101
  _,b,t=min(free,key=lambda item:item[0]);chosen=(b,t)
102
  if chosen:
103
  b,target=chosen;occupied.append(b)
104
+ placed.append(dict(label=label,text=text,box=b,target=target,text_bounds=bounds,font=label_font))
105
  return placed
106
 
107
 
 
173
  draw.line((sx,sy,ex,ey),fill=(*color,230),width=1)
174
  light=tuple(round(c*.10+255*.90) for c in color)
175
  draw.rounded_rectangle(box,radius=3,fill=(*light,248),outline=(*color,255),width=1)
176
+ draw.text((tx+padding-tb[0],ty+padding-tb[1]),text,font=badge["font"],fill=(38,30,49,255))
177
 
178
  if title:
179
+ title_font = _font(getattr(font, "size", 13), title)
180
+ tb = draw.textbbox((0, 0), title, font=title_font)
181
  draw.rounded_rectangle(
182
  (10, 10, min(width - 10, tb[2] + 28), tb[3] + 24),
183
  radius=10,
 
185
  outline=(*PURPLE, 255),
186
  width=line,
187
  )
188
+ draw.text((18, 16), title, font=title_font, fill=(*PURPLE, 255))
189
  return Image.alpha_composite(canvas, overlay).convert("RGB")
scripts/build_bundle.py CHANGED
@@ -40,7 +40,7 @@ def main():
40
  paths.extend([lock, ROOT / "docs/UI_PAPER_R7.md"])
41
  if (ROOT / "docs/UI_STUDIO_R8.md").exists():
42
  paths.append(ROOT / "docs/UI_STUDIO_R8.md")
43
- for folder, suffixes in {"assets": {".css", ".js", ".png", ".ttf", ".txt", ".md"},
44
  "grounding_demo": {".py"}, "tests": {".py"}}.items():
45
  paths.extend(p for p in (ROOT / folder).rglob("*") if p.is_file() and p.suffix in suffixes)
46
  for row, public_row in zip(rows, public_rows, strict=True):
 
40
  paths.extend([lock, ROOT / "docs/UI_PAPER_R7.md"])
41
  if (ROOT / "docs/UI_STUDIO_R8.md").exists():
42
  paths.append(ROOT / "docs/UI_STUDIO_R8.md")
43
+ for folder, suffixes in {"assets": {".css", ".js", ".png", ".ttf", ".otf", ".txt", ".md"},
44
  "grounding_demo": {".py"}, "tests": {".py"}}.items():
45
  paths.extend(p for p in (ROOT / folder).rglob("*") if p.is_file() and p.suffix in suffixes)
46
  for row, public_row in zip(rows, public_rows, strict=True):
tests/test_bundle.py CHANGED
@@ -20,6 +20,8 @@ class BundleTests(unittest.TestCase):
20
  with ZipFile(target) as archive:
21
  names = archive.namelist()
22
  self.assertIn("space_app.py", names)
 
 
23
  self.assertIn("docs/HF_SPACE_DEPLOYMENT.md", names)
24
  self.assertFalse(any(n.startswith(("verification/", "logs/", ".runtime/", "deploy/")) for n in names))
25
  self.assertNotIn("docs/FUYAO_REPRODUCTION.md", names)
 
20
  with ZipFile(target) as archive:
21
  names = archive.namelist()
22
  self.assertIn("space_app.py", names)
23
+ self.assertIn("assets/NotoSansCJKsc-Regular.otf", names)
24
+ self.assertIn("assets/NotoSansCJK-OFL.txt", names)
25
  self.assertIn("docs/HF_SPACE_DEPLOYMENT.md", names)
26
  self.assertFalse(any(n.startswith(("verification/", "logs/", ".runtime/", "deploy/")) for n in names))
27
  self.assertNotIn("docs/FUYAO_REPRODUCTION.md", names)
tests/test_console.py CHANGED
@@ -88,6 +88,17 @@ class ConsoleTest(unittest.TestCase):
88
  "&lt;script&gt;", targets_html([{"label": "<script>", "coordinates": [1, 2]}])
89
  )
90
 
 
 
 
 
 
 
 
 
 
 
 
91
  def test_focus_buttons_carry_exact_safe_geometry(self):
92
  class Buttons(HTMLParser):
93
  def __init__(self):
 
88
  "&lt;script&gt;", targets_html([{"label": "<script>", "coordinates": [1, 2]}])
89
  )
90
 
91
+ def test_chinese_ocr_result_preserves_unicode_and_coordinates(self):
92
+ label = "测试文字:中文 OCR(2026年)"
93
+ raw = f"<|object_ref_start|>{label}<|object_ref_end|><|box_start|><100><200><800><400><|box_end|>"
94
+ result = app.result_bundle(str(app.ROOT / app.CASES[0]["image"]), "OCR", "ocr", raw)
95
+ self.assertEqual(json.loads(result["normalized"]), [
96
+ {"label": label, "coordinates": [100, 200, 800, 400]}
97
+ ])
98
+ self.assertIn(label, result["normalized"])
99
+ self.assertIn(label, result["targets"])
100
+ self.assertIn("Complete", result["status"])
101
+
102
  def test_focus_buttons_carry_exact_safe_geometry(self):
103
  class Buttons(HTMLParser):
104
  def __init__(self):
tests/test_render.py CHANGED
@@ -1,10 +1,48 @@
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)]
 
1
  import unittest
2
+ from PIL import Image, ImageDraw
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_chinese_and_mixed_text_have_real_glyphs(self):
9
+ font = _font(24, "中文 OCR:姓名,日期(2026年)")
10
+ missing = bytes(font.getmask("\u0378"))
11
+ masks = []
12
+ for char in "姓名出生日期地址簡體繁體,:()":
13
+ with self.subTest(char=char):
14
+ mask = bytes(font.getmask(char))
15
+ self.assertNotEqual(mask, missing)
16
+ self.assertTrue(any(mask))
17
+ masks.append(mask)
18
+ self.assertEqual(len(set(masks[:6])), 6)
19
+
20
+ def test_chinese_badges_measure_and_draw_with_the_same_font(self):
21
+ text = "中文 OCR:测试标签 2026年(示例)"
22
+ primitives = [Primitive(text, (100, 300, 800, 500))]
23
+ badge = layout_box_labels(primitives, 1000, 700, _font(20))[0]
24
+ self.assertEqual(badge["text"], text)
25
+ expected = ImageDraw.Draw(Image.new("RGB", (1, 1))).textbbox(
26
+ (0, 0), text, font=_font(20, text)
27
+ )
28
+ self.assertEqual(badge["text_bounds"], expected)
29
+ x1, y1, x2, y2 = badge["box"]
30
+ self.assertEqual(x2-x1, expected[2]-expected[0]+8)
31
+ self.assertEqual(y2-y1, expected[3]-expected[1]+8)
32
+ rendered = draw_primitives(Image.new("RGB", (1000, 700), "white"), primitives)
33
+ self.assertEqual(rendered.size, (1000, 700))
34
+
35
+ def test_long_chinese_badges_stay_inside_canvas_without_changing_text_data(self):
36
+ text = "这是一段很长的中文识别结果,包含标点和 English 2026。"*4
37
+ primitive = Primitive(text, (900, 0, 999, 999))
38
+ badges = layout_box_labels([primitive], 140, 120, _font(18))
39
+ self.assertEqual(len(badges), 1)
40
+ badge = badges[0]
41
+ self.assertEqual(badge["label"], text)
42
+ self.assertTrue(badge["text"].endswith("…"))
43
+ x1,y1,x2,y2 = badge["box"]
44
+ self.assertTrue(0<=x1<x2<=140 and 0<=y1<y2<=120)
45
+
46
  def test_dense_labels_are_unique_visible_and_do_not_overlap(self):
47
  boxes = [Primitive(f"category {i}", (50+(i%5)*180, 50+(i//5)*210,
48
  150+(i%5)*180, 190+(i//5)*210)) for i in range(20)]