cudag 0.3.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cudag/__init__.py +334 -0
- cudag/annotation/__init__.py +77 -0
- cudag/annotation/codegen.py +648 -0
- cudag/annotation/config.py +545 -0
- cudag/annotation/loader.py +342 -0
- cudag/annotation/scaffold.py +121 -0
- cudag/annotation/transcription.py +296 -0
- cudag/cli/__init__.py +5 -0
- cudag/cli/main.py +315 -0
- cudag/cli/new.py +873 -0
- cudag/core/__init__.py +364 -0
- cudag/core/button.py +137 -0
- cudag/core/canvas.py +222 -0
- cudag/core/config.py +70 -0
- cudag/core/coords.py +233 -0
- cudag/core/data_grid.py +804 -0
- cudag/core/dataset.py +678 -0
- cudag/core/distribution.py +136 -0
- cudag/core/drawing.py +75 -0
- cudag/core/fonts.py +156 -0
- cudag/core/generator.py +163 -0
- cudag/core/grid.py +367 -0
- cudag/core/grounding_task.py +247 -0
- cudag/core/icon.py +207 -0
- cudag/core/iconlist_task.py +301 -0
- cudag/core/models.py +1251 -0
- cudag/core/random.py +130 -0
- cudag/core/renderer.py +190 -0
- cudag/core/screen.py +402 -0
- cudag/core/scroll_task.py +254 -0
- cudag/core/scrollable_grid.py +447 -0
- cudag/core/state.py +110 -0
- cudag/core/task.py +293 -0
- cudag/core/taskbar.py +350 -0
- cudag/core/text.py +212 -0
- cudag/core/utils.py +82 -0
- cudag/data/surnames.txt +5000 -0
- cudag/modal_apps/__init__.py +4 -0
- cudag/modal_apps/archive.py +103 -0
- cudag/modal_apps/extract.py +138 -0
- cudag/modal_apps/preprocess.py +529 -0
- cudag/modal_apps/upload.py +317 -0
- cudag/prompts/SYSTEM_PROMPT.txt +104 -0
- cudag/prompts/__init__.py +33 -0
- cudag/prompts/system.py +43 -0
- cudag/prompts/tools.py +382 -0
- cudag/py.typed +0 -0
- cudag/schemas/filesystem.json +90 -0
- cudag/schemas/test_record.schema.json +113 -0
- cudag/schemas/train_record.schema.json +90 -0
- cudag/server/__init__.py +21 -0
- cudag/server/app.py +232 -0
- cudag/server/services/__init__.py +9 -0
- cudag/server/services/generator.py +128 -0
- cudag/templates/scripts/archive.sh +35 -0
- cudag/templates/scripts/build.sh +13 -0
- cudag/templates/scripts/extract.sh +54 -0
- cudag/templates/scripts/generate.sh +116 -0
- cudag/templates/scripts/pre-commit.sh +44 -0
- cudag/templates/scripts/preprocess.sh +46 -0
- cudag/templates/scripts/upload.sh +63 -0
- cudag/templates/scripts/verify.py +428 -0
- cudag/validation/__init__.py +35 -0
- cudag/validation/validate.py +508 -0
- cudag-0.3.10.dist-info/METADATA +570 -0
- cudag-0.3.10.dist-info/RECORD +69 -0
- cudag-0.3.10.dist-info/WHEEL +4 -0
- cudag-0.3.10.dist-info/entry_points.txt +2 -0
- cudag-0.3.10.dist-info/licenses/LICENSE +66 -0
cudag/core/task.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Copyright (c) 2025 Tylt LLC. All rights reserved.
|
|
2
|
+
# CONFIDENTIAL AND PROPRIETARY. Unauthorized use, copying, or distribution
|
|
3
|
+
# is strictly prohibited. For licensing inquiries: hello@claimhawk.app
|
|
4
|
+
|
|
5
|
+
"""Base task class and data structures for VLM training samples.
|
|
6
|
+
|
|
7
|
+
Tasks are the "Controller" in VLMGen's Screen/State/Renderer/Task architecture.
|
|
8
|
+
Each task type (click-day, scroll-grid, etc.) defines:
|
|
9
|
+
- How to generate prompts
|
|
10
|
+
- What tool calls are expected
|
|
11
|
+
- How to create state for rendering
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from random import Random
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from cudag.prompts.tools import ToolCall, format_tool_call
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from cudag.core.dataset import DatasetConfig
|
|
26
|
+
from cudag.core.renderer import BaseRenderer
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TaskSample:
|
|
31
|
+
"""Output of a task generation.
|
|
32
|
+
|
|
33
|
+
This represents a single training sample in the dataset.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
id: str
|
|
37
|
+
"""Unique identifier for this sample."""
|
|
38
|
+
|
|
39
|
+
image_path: Path
|
|
40
|
+
"""Path to the generated image file."""
|
|
41
|
+
|
|
42
|
+
human_prompt: str
|
|
43
|
+
"""The human instruction (without <image> prefix)."""
|
|
44
|
+
|
|
45
|
+
tool_call: ToolCall
|
|
46
|
+
"""The expected tool call response."""
|
|
47
|
+
|
|
48
|
+
pixel_coords: tuple[int, int]
|
|
49
|
+
"""Pixel coordinates of the target (for real_coords in metadata)."""
|
|
50
|
+
|
|
51
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
"""Task-specific metadata (task_type is added automatically)."""
|
|
53
|
+
|
|
54
|
+
image_size: tuple[int, int] = (1000, 1000)
|
|
55
|
+
"""Size of the generated image (width, height)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class TestCase:
|
|
60
|
+
"""Output of test case generation.
|
|
61
|
+
|
|
62
|
+
This represents a single test case for evaluating model accuracy.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
test_id: str
|
|
66
|
+
"""Unique identifier for this test case."""
|
|
67
|
+
|
|
68
|
+
screenshot: Path
|
|
69
|
+
"""Path to the test screenshot."""
|
|
70
|
+
|
|
71
|
+
prompt: str
|
|
72
|
+
"""The human instruction (without <image> prefix)."""
|
|
73
|
+
|
|
74
|
+
expected_action: dict[str, Any]
|
|
75
|
+
"""Expected tool call as dict (for JSON serialization)."""
|
|
76
|
+
|
|
77
|
+
tolerance: tuple[int, int] | int
|
|
78
|
+
"""Allowed coordinate tolerance (x, y) in RU units."""
|
|
79
|
+
|
|
80
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
"""Test-specific metadata."""
|
|
82
|
+
|
|
83
|
+
pixel_coords: tuple[int, int] | None = None
|
|
84
|
+
"""Original pixel coordinates (before normalization)."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class TaskContext:
|
|
89
|
+
"""Context passed to task generation methods.
|
|
90
|
+
|
|
91
|
+
Provides access to shared resources like RNG, output directories, and config.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
rng: Random
|
|
95
|
+
"""Seeded random number generator for reproducibility."""
|
|
96
|
+
|
|
97
|
+
index: int
|
|
98
|
+
"""Current sample index (for ID generation)."""
|
|
99
|
+
|
|
100
|
+
output_dir: Path
|
|
101
|
+
"""Directory for generated images."""
|
|
102
|
+
|
|
103
|
+
config: dict[str, Any]
|
|
104
|
+
"""Task-specific configuration."""
|
|
105
|
+
|
|
106
|
+
dataset_name: str
|
|
107
|
+
"""Name prefix for generated IDs."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class BaseTask(ABC):
|
|
111
|
+
"""Abstract base class for task types.
|
|
112
|
+
|
|
113
|
+
Subclass this to create new task types. Each task type defines:
|
|
114
|
+
- task_type: Unique identifier (e.g., "click-day", "scroll-grid")
|
|
115
|
+
- generate_samples(): How to generate training samples from one image (1:N)
|
|
116
|
+
- generate_tests(): How to generate test cases from one image (1:N)
|
|
117
|
+
|
|
118
|
+
The key insight is that one rendered image can produce MULTIPLE training
|
|
119
|
+
samples. For example, a claim window image can have:
|
|
120
|
+
- "Click the procedure code" → one coordinate
|
|
121
|
+
- "Click the fee column" → different coordinate
|
|
122
|
+
- "Scroll down in the grid" → scroll action
|
|
123
|
+
|
|
124
|
+
Example:
|
|
125
|
+
class ClaimWindowTask(BaseTask):
|
|
126
|
+
task_type = "claim-window"
|
|
127
|
+
|
|
128
|
+
def generate_samples(self, ctx: TaskContext) -> list[TaskSample]:
|
|
129
|
+
# 1. Generate state and render ONCE
|
|
130
|
+
state = ClaimWindowState.generate(ctx.rng)
|
|
131
|
+
image, metadata = self.renderer.render(state)
|
|
132
|
+
image_path = self.save_image(image, ctx)
|
|
133
|
+
|
|
134
|
+
# 2. Derive MULTIPLE samples from this one image
|
|
135
|
+
samples = []
|
|
136
|
+
|
|
137
|
+
# Sample 1: Click procedure code
|
|
138
|
+
samples.append(TaskSample(
|
|
139
|
+
id=self.build_id(ctx, "_click_code"),
|
|
140
|
+
image_path=image_path,
|
|
141
|
+
human_prompt="Click the procedure code",
|
|
142
|
+
tool_call=ToolCall.left_click(code_coords),
|
|
143
|
+
pixel_coords=code_coords,
|
|
144
|
+
...
|
|
145
|
+
))
|
|
146
|
+
|
|
147
|
+
# Sample 2: Click fee
|
|
148
|
+
samples.append(TaskSample(
|
|
149
|
+
id=self.build_id(ctx, "_click_fee"),
|
|
150
|
+
image_path=image_path, # SAME IMAGE
|
|
151
|
+
human_prompt="Click the fee column",
|
|
152
|
+
tool_call=ToolCall.left_click(fee_coords),
|
|
153
|
+
pixel_coords=fee_coords,
|
|
154
|
+
...
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
return samples
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
task_type: str
|
|
161
|
+
"""Unique identifier for this task type (e.g., 'click-day', 'scroll-grid')."""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self, config: DatasetConfig | dict[str, Any], renderer: BaseRenderer[Any]
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Initialize the task.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
config: Task-specific configuration from generator.yaml (DatasetConfig or dict)
|
|
170
|
+
renderer: Renderer instance for generating images
|
|
171
|
+
"""
|
|
172
|
+
self.config = config
|
|
173
|
+
self.renderer = renderer
|
|
174
|
+
|
|
175
|
+
def generate_samples(self, ctx: TaskContext) -> list[TaskSample]:
|
|
176
|
+
"""Generate training samples from one rendered image.
|
|
177
|
+
|
|
178
|
+
Override this to generate multiple samples from a single render.
|
|
179
|
+
Default implementation calls generate_sample() once for backwards compat.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
ctx: Task context with RNG, index, output directory, etc.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of TaskSample objects (can share the same image_path).
|
|
186
|
+
"""
|
|
187
|
+
return [self.generate_sample(ctx)]
|
|
188
|
+
|
|
189
|
+
@abstractmethod
|
|
190
|
+
def generate_sample(self, ctx: TaskContext) -> TaskSample:
|
|
191
|
+
"""Generate one training sample.
|
|
192
|
+
|
|
193
|
+
For simple 1:1 image-to-sample tasks, implement this.
|
|
194
|
+
For 1:N image-to-samples, override generate_samples() instead.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
ctx: Task context with RNG, index, output directory, etc.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
TaskSample with all required fields populated.
|
|
201
|
+
"""
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
def generate_tests(self, ctx: TaskContext) -> list[TestCase]:
|
|
205
|
+
"""Generate test cases from one rendered image.
|
|
206
|
+
|
|
207
|
+
Override this to generate multiple tests from a single render.
|
|
208
|
+
Default implementation calls generate_test() once for backwards compat.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
ctx: Task context with RNG, index, output directory, etc.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of TestCase objects (can share the same screenshot).
|
|
215
|
+
"""
|
|
216
|
+
return [self.generate_test(ctx)]
|
|
217
|
+
|
|
218
|
+
@abstractmethod
|
|
219
|
+
def generate_test(self, ctx: TaskContext) -> TestCase:
|
|
220
|
+
"""Generate one test case.
|
|
221
|
+
|
|
222
|
+
For simple 1:1 image-to-test tasks, implement this.
|
|
223
|
+
For 1:N image-to-tests, override generate_tests() instead.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
ctx: Task context with RNG, index, output directory, etc.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
TestCase with all required fields populated.
|
|
230
|
+
"""
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
def format_gpt_response(self, tool_call: ToolCall) -> str:
|
|
234
|
+
"""Format the GPT response for this sample.
|
|
235
|
+
|
|
236
|
+
Override this to customize the response format (e.g., add <think> tags).
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
tool_call: The tool call to format
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Formatted string for the "gpt" conversation turn
|
|
243
|
+
"""
|
|
244
|
+
return format_tool_call(tool_call)
|
|
245
|
+
|
|
246
|
+
def save_image(
|
|
247
|
+
self,
|
|
248
|
+
image: Any, # PIL.Image.Image
|
|
249
|
+
ctx: TaskContext,
|
|
250
|
+
extension: str = "jpg",
|
|
251
|
+
quality: int = 85,
|
|
252
|
+
prefix: str | None = None,
|
|
253
|
+
) -> Path:
|
|
254
|
+
"""Save a generated image to the output directory.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
image: PIL Image to save
|
|
258
|
+
ctx: Task context
|
|
259
|
+
extension: Image format (default: jpg)
|
|
260
|
+
quality: JPEG quality (ignored for PNG)
|
|
261
|
+
prefix: Optional prefix for filename (e.g., "eval" for eval images)
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Path to saved image
|
|
265
|
+
"""
|
|
266
|
+
images_dir = ctx.output_dir / "images"
|
|
267
|
+
images_dir.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
|
|
269
|
+
if prefix:
|
|
270
|
+
filename = f"{prefix}_{ctx.index:05d}.{extension}"
|
|
271
|
+
else:
|
|
272
|
+
filename = f"{ctx.dataset_name}_{ctx.index:05d}.{extension}"
|
|
273
|
+
path = images_dir / filename
|
|
274
|
+
|
|
275
|
+
if extension.lower() in ("jpg", "jpeg"):
|
|
276
|
+
image.save(path, quality=quality)
|
|
277
|
+
else:
|
|
278
|
+
image.save(path)
|
|
279
|
+
|
|
280
|
+
return path
|
|
281
|
+
|
|
282
|
+
def build_id(self, ctx: TaskContext, suffix: str = "") -> str:
|
|
283
|
+
"""Build a sample ID from context.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
ctx: Task context
|
|
287
|
+
suffix: Optional suffix to add (e.g., "_task")
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Formatted ID string
|
|
291
|
+
"""
|
|
292
|
+
base = f"{ctx.dataset_name}_{ctx.index:05d}"
|
|
293
|
+
return f"{base}{suffix}" if suffix else base
|
cudag/core/taskbar.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# Copyright (c) 2025 Tylt LLC. All rights reserved.
|
|
2
|
+
# CONFIDENTIAL AND PROPRIETARY. Unauthorized use, copying, or distribution
|
|
3
|
+
# is strictly prohibited. For licensing inquiries: hello@claimhawk.app
|
|
4
|
+
|
|
5
|
+
"""Taskbar primitive for Windows-style taskbar rendering.
|
|
6
|
+
|
|
7
|
+
Provides reusable TaskbarState and TaskbarRenderer for any generator
|
|
8
|
+
that needs a taskbar at the bottom of the screen.
|
|
9
|
+
|
|
10
|
+
The taskbar includes:
|
|
11
|
+
- Icons (configurable position, varying N, random order)
|
|
12
|
+
- DateTime display (time + date in Windows format)
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
from cudag.core import TaskbarState, TaskbarRenderer
|
|
16
|
+
|
|
17
|
+
# Generate random taskbar state
|
|
18
|
+
state = TaskbarState.generate(
|
|
19
|
+
rng=rng,
|
|
20
|
+
icon_config=annotation_config.get_element_by_label("taskbar"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Render onto existing image
|
|
24
|
+
renderer = TaskbarRenderer(assets_dir="assets")
|
|
25
|
+
metadata = renderer.render_onto(image, state)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import date
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from random import Random
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
37
|
+
|
|
38
|
+
from cudag.core.icon import IconPlacement, IconSpec, TASKBAR_ICON
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TaskbarState:
|
|
43
|
+
"""State for a Windows-style taskbar.
|
|
44
|
+
|
|
45
|
+
Contains icon placements and datetime text for rendering.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
icons: list[IconPlacement] = field(default_factory=list)
|
|
49
|
+
"""Icons placed on the taskbar."""
|
|
50
|
+
|
|
51
|
+
datetime_text: str = ""
|
|
52
|
+
"""DateTime string (e.g., '1:30 PM\\n12/15/2025')."""
|
|
53
|
+
|
|
54
|
+
datetime_position: tuple[int, int] = (0, 0)
|
|
55
|
+
"""Position for datetime text (x, y)."""
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def generate(
|
|
59
|
+
cls,
|
|
60
|
+
rng: Random,
|
|
61
|
+
icon_config: Any | None = None,
|
|
62
|
+
datetime_position: tuple[int, int] = (1868, 1043),
|
|
63
|
+
icon_spec: IconSpec | None = None,
|
|
64
|
+
taskbar_left_margin: int = 946,
|
|
65
|
+
taskbar_y_offset: int = 1042,
|
|
66
|
+
icon_gap: int = 8,
|
|
67
|
+
target_date: date | None = None,
|
|
68
|
+
) -> "TaskbarState":
|
|
69
|
+
"""Generate random taskbar state.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
rng: Random number generator
|
|
73
|
+
icon_config: AnnotatedElement with icons, varyN, randomOrder settings
|
|
74
|
+
datetime_position: Position for datetime text
|
|
75
|
+
icon_spec: Icon specification (defaults to TASKBAR_ICON)
|
|
76
|
+
taskbar_left_margin: X position where icons start
|
|
77
|
+
taskbar_y_offset: Y position for icons
|
|
78
|
+
icon_gap: Gap between icons
|
|
79
|
+
target_date: Specific date to display (for consistent calendar/taskbar)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
TaskbarState with randomized icons and datetime
|
|
83
|
+
"""
|
|
84
|
+
state = cls()
|
|
85
|
+
state.datetime_text = cls._generate_datetime(rng, target_date)
|
|
86
|
+
state.datetime_position = datetime_position
|
|
87
|
+
|
|
88
|
+
if icon_config is not None:
|
|
89
|
+
state.icons = cls._place_icons(
|
|
90
|
+
rng,
|
|
91
|
+
icon_config,
|
|
92
|
+
icon_spec or TASKBAR_ICON,
|
|
93
|
+
taskbar_left_margin,
|
|
94
|
+
taskbar_y_offset,
|
|
95
|
+
icon_gap,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return state
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def _generate_datetime(cls, rng: Random, target_date: date | None = None) -> str:
|
|
102
|
+
"""Generate datetime string in Windows 11 format.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
rng: Random number generator for time component
|
|
106
|
+
target_date: Specific date to use (or None for random)
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Formatted datetime string like "1:30 PM\\n12/15/2025"
|
|
110
|
+
"""
|
|
111
|
+
# Time is always random
|
|
112
|
+
hour = rng.randint(1, 12)
|
|
113
|
+
minute = rng.randint(0, 59)
|
|
114
|
+
am_pm = rng.choice(["AM", "PM"])
|
|
115
|
+
|
|
116
|
+
# Date from target_date or random
|
|
117
|
+
if target_date is not None:
|
|
118
|
+
month = target_date.month
|
|
119
|
+
day = target_date.day
|
|
120
|
+
year = target_date.year
|
|
121
|
+
else:
|
|
122
|
+
month = rng.randint(1, 12)
|
|
123
|
+
day = rng.randint(1, 28)
|
|
124
|
+
year = rng.randint(2024, 2025)
|
|
125
|
+
|
|
126
|
+
return f"{hour}:{minute:02d} {am_pm}\n{month}/{day}/{year}"
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def _place_icons(
|
|
130
|
+
cls,
|
|
131
|
+
rng: Random,
|
|
132
|
+
icon_config: Any,
|
|
133
|
+
icon_spec: IconSpec,
|
|
134
|
+
left_margin: int,
|
|
135
|
+
y_offset: int,
|
|
136
|
+
gap: int,
|
|
137
|
+
) -> list[IconPlacement]:
|
|
138
|
+
"""Place icons based on annotation config settings."""
|
|
139
|
+
placements: list[IconPlacement] = []
|
|
140
|
+
|
|
141
|
+
# Get settings from annotation config
|
|
142
|
+
vary_n = getattr(icon_config, "vary_n", False)
|
|
143
|
+
random_order = getattr(icon_config, "random_order", False)
|
|
144
|
+
icons = getattr(icon_config, "icons", [])
|
|
145
|
+
|
|
146
|
+
if not icons:
|
|
147
|
+
return placements
|
|
148
|
+
|
|
149
|
+
# Build icon list based on varyN setting
|
|
150
|
+
required = [i for i in icons if getattr(i, "required", False)]
|
|
151
|
+
optional = [i for i in icons if not getattr(i, "required", False)]
|
|
152
|
+
|
|
153
|
+
if vary_n and optional:
|
|
154
|
+
min_optional = max(1, int(len(optional) * 0.4))
|
|
155
|
+
max_optional = len(optional)
|
|
156
|
+
k = rng.randint(min_optional, max_optional)
|
|
157
|
+
selected_optional = rng.sample(optional, k)
|
|
158
|
+
selected = required + selected_optional
|
|
159
|
+
else:
|
|
160
|
+
selected = required + optional
|
|
161
|
+
|
|
162
|
+
# Shuffle if randomOrder is enabled
|
|
163
|
+
if random_order:
|
|
164
|
+
rng.shuffle(selected)
|
|
165
|
+
|
|
166
|
+
# Place icons left to right
|
|
167
|
+
x = left_margin
|
|
168
|
+
for icon_data in selected:
|
|
169
|
+
icon_id = getattr(icon_data, "icon_file_id", "") or getattr(
|
|
170
|
+
icon_data, "label", ""
|
|
171
|
+
)
|
|
172
|
+
label = getattr(icon_data, "label", "")
|
|
173
|
+
|
|
174
|
+
placements.append(
|
|
175
|
+
IconPlacement(
|
|
176
|
+
icon_id=icon_id,
|
|
177
|
+
x=x,
|
|
178
|
+
y=y_offset,
|
|
179
|
+
spec=icon_spec,
|
|
180
|
+
label=label,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
x += icon_spec.width + gap
|
|
184
|
+
|
|
185
|
+
return placements
|
|
186
|
+
|
|
187
|
+
def get_icon_by_id(self, icon_id: str) -> IconPlacement | None:
|
|
188
|
+
"""Find icon by ID."""
|
|
189
|
+
for icon in self.icons:
|
|
190
|
+
if icon.icon_id == icon_id:
|
|
191
|
+
return icon
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def to_ground_truth(self) -> dict[str, Any]:
|
|
195
|
+
"""Export state as ground truth dict."""
|
|
196
|
+
return {
|
|
197
|
+
"icons": [
|
|
198
|
+
{
|
|
199
|
+
"id": icon.icon_id,
|
|
200
|
+
"label": icon.label,
|
|
201
|
+
"bounds": icon.bounds,
|
|
202
|
+
"center": icon.center,
|
|
203
|
+
}
|
|
204
|
+
for icon in self.icons
|
|
205
|
+
],
|
|
206
|
+
"datetime": {
|
|
207
|
+
"text": self.datetime_text,
|
|
208
|
+
"position": self.datetime_position,
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TaskbarRenderer:
|
|
214
|
+
"""Renderer for Windows-style taskbar.
|
|
215
|
+
|
|
216
|
+
Composites taskbar icons and datetime onto existing images.
|
|
217
|
+
Designed to be used as a mixin or called directly.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
assets_dir: Path | str = "assets",
|
|
223
|
+
datetime_font_size: int = 9,
|
|
224
|
+
):
|
|
225
|
+
"""Initialize the taskbar renderer.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
assets_dir: Path to assets directory containing icons/taskbar/
|
|
229
|
+
datetime_font_size: Font size for datetime text
|
|
230
|
+
"""
|
|
231
|
+
self.assets_dir = Path(assets_dir)
|
|
232
|
+
self.datetime_font_size = datetime_font_size
|
|
233
|
+
self._icon_cache: dict[str, Image.Image] = {}
|
|
234
|
+
self._datetime_font: ImageFont.FreeTypeFont | ImageFont.ImageFont | None = None
|
|
235
|
+
self._loaded = False
|
|
236
|
+
|
|
237
|
+
def load_assets(self) -> None:
|
|
238
|
+
"""Load fonts and icon images."""
|
|
239
|
+
if self._loaded:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
# Load datetime font
|
|
243
|
+
font_path = self.assets_dir / "fonts" / "segoeui.ttf"
|
|
244
|
+
if font_path.exists():
|
|
245
|
+
self._datetime_font = ImageFont.truetype(
|
|
246
|
+
str(font_path), self.datetime_font_size
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
self._datetime_font = ImageFont.load_default()
|
|
250
|
+
|
|
251
|
+
# Load taskbar icons
|
|
252
|
+
icons_dir = self.assets_dir / "icons" / "taskbar"
|
|
253
|
+
if icons_dir.exists():
|
|
254
|
+
for icon_path in icons_dir.glob("*.png"):
|
|
255
|
+
icon_id = self._extract_icon_id(icon_path.stem)
|
|
256
|
+
self._icon_cache[icon_id] = Image.open(icon_path).convert("RGBA")
|
|
257
|
+
|
|
258
|
+
self._loaded = True
|
|
259
|
+
|
|
260
|
+
def _extract_icon_id(self, filename: str) -> str:
|
|
261
|
+
"""Extract icon ID from filename.
|
|
262
|
+
|
|
263
|
+
Examples:
|
|
264
|
+
icon-tb-od -> od
|
|
265
|
+
icon-od-clean -> od
|
|
266
|
+
taskbar_m365 -> m365
|
|
267
|
+
taskbar_open-dental -> open-dental
|
|
268
|
+
"""
|
|
269
|
+
name = filename.lower()
|
|
270
|
+
for prefix in ("icon-", "icon_", "tb-", "tb_", "taskbar_", "taskbar-"):
|
|
271
|
+
if name.startswith(prefix):
|
|
272
|
+
name = name[len(prefix) :]
|
|
273
|
+
for suffix in ("-clean", "_clean"):
|
|
274
|
+
if name.endswith(suffix):
|
|
275
|
+
name = name[: -len(suffix)]
|
|
276
|
+
return name
|
|
277
|
+
|
|
278
|
+
def render_onto(
|
|
279
|
+
self,
|
|
280
|
+
image: Image.Image,
|
|
281
|
+
state: TaskbarState,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
"""Render taskbar onto an existing image.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
image: PIL Image to render onto (modified in place)
|
|
287
|
+
state: TaskbarState with icons and datetime
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Metadata dict with icon positions and datetime info
|
|
291
|
+
"""
|
|
292
|
+
self.load_assets()
|
|
293
|
+
|
|
294
|
+
draw = ImageDraw.Draw(image)
|
|
295
|
+
|
|
296
|
+
# Draw icons (paste with alpha compositing)
|
|
297
|
+
for icon in state.icons:
|
|
298
|
+
self._draw_icon(image, icon)
|
|
299
|
+
|
|
300
|
+
# Draw datetime
|
|
301
|
+
self._draw_datetime(draw, state)
|
|
302
|
+
|
|
303
|
+
return state.to_ground_truth()
|
|
304
|
+
|
|
305
|
+
def _draw_icon(self, image: Image.Image, icon: IconPlacement) -> None:
|
|
306
|
+
"""Draw a single taskbar icon."""
|
|
307
|
+
icon_id = icon.icon_id.lower()
|
|
308
|
+
|
|
309
|
+
# Try to find icon in cache
|
|
310
|
+
icon_img = self._icon_cache.get(icon_id)
|
|
311
|
+
if icon_img is None:
|
|
312
|
+
# Try aliases
|
|
313
|
+
aliases = {
|
|
314
|
+
"open_dental": "od",
|
|
315
|
+
"open-dental": "od",
|
|
316
|
+
"file_explorer": "explorer",
|
|
317
|
+
"microsoft_edge": "edge",
|
|
318
|
+
}
|
|
319
|
+
aliased_id = aliases.get(icon_id)
|
|
320
|
+
if aliased_id:
|
|
321
|
+
icon_img = self._icon_cache.get(aliased_id)
|
|
322
|
+
|
|
323
|
+
if icon_img is not None:
|
|
324
|
+
# Ensure icon has alpha channel for compositing
|
|
325
|
+
if icon_img.mode != "RGBA":
|
|
326
|
+
icon_img = icon_img.convert("RGBA")
|
|
327
|
+
|
|
328
|
+
# Use alpha channel as mask for proper compositing
|
|
329
|
+
if image.mode == "RGB":
|
|
330
|
+
# For RGB images, paste with alpha mask
|
|
331
|
+
image.paste(icon_img, (icon.x, icon.y), icon_img.split()[3])
|
|
332
|
+
else:
|
|
333
|
+
# For RGBA images, paste directly with alpha
|
|
334
|
+
image.paste(icon_img, (icon.x, icon.y), icon_img)
|
|
335
|
+
|
|
336
|
+
def _draw_datetime(self, draw: ImageDraw.ImageDraw, state: TaskbarState) -> None:
|
|
337
|
+
"""Draw datetime text."""
|
|
338
|
+
if not state.datetime_text or not self._datetime_font:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
x, y = state.datetime_position
|
|
342
|
+
lines = state.datetime_text.split("\n")
|
|
343
|
+
|
|
344
|
+
for i, line in enumerate(lines):
|
|
345
|
+
line_y = y + i * (self.datetime_font_size + 2)
|
|
346
|
+
# Center align text
|
|
347
|
+
bbox = draw.textbbox((0, 0), line, font=self._datetime_font)
|
|
348
|
+
text_width = bbox[2] - bbox[0]
|
|
349
|
+
text_x = x - text_width // 2
|
|
350
|
+
draw.text((text_x, line_y), line, fill="black", font=self._datetime_font)
|