Spaces:
Running on Zero
Running on Zero
Fix Chinese OCR labels with bundled CJK font
Browse filesChinese 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 +1 -0
- assets/NotoSansCJK-OFL.txt +92 -0
- assets/NotoSansCJKsc-Regular.otf +3 -0
- assets/SOURCES.md +1 -0
- docs/ui_case_pool_22_lock.json +4 -2
- grounding_demo/render.py +20 -7
- scripts/build_bundle.py +1 -1
- tests/test_bundle.py +2 -0
- tests/test_console.py +11 -0
- tests/test_render.py +39 -1
.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/
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
candidates = (
|
| 19 |
-
str(
|
| 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=
|
| 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 |
-
|
|
|
|
| 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=
|
| 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 |
"<script>", 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 |
"<script>", 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)]
|