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
|
@@ -0,0 +1,648 @@
|
|
|
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
|
+
"""Code generation utilities for scaffolding CUDAG projects.
|
|
6
|
+
|
|
7
|
+
Generates annotation-driven code that loads annotation.json at runtime
|
|
8
|
+
via AnnotationConfig, enabling UI updates without regenerating code.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from cudag.annotation.loader import ParsedAnnotation, ParsedElement, ParsedTask
|
|
14
|
+
|
|
15
|
+
COPYRIGHT_HEADER = '''# Auto-generated from annotation - feel free to modify.
|
|
16
|
+
'''
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_screen_py(annotation: ParsedAnnotation) -> str:
|
|
20
|
+
"""Generate screen.py with runtime annotation loading."""
|
|
21
|
+
# Identify element types present
|
|
22
|
+
has_grid = any(el.region_type == "grid" for el in annotation.elements)
|
|
23
|
+
has_buttons = any(el.region_type == "button" for el in annotation.elements)
|
|
24
|
+
has_text = any(el.element_type == "text" for el in annotation.elements)
|
|
25
|
+
|
|
26
|
+
# Build helper functions based on element types
|
|
27
|
+
helpers: list[str] = []
|
|
28
|
+
|
|
29
|
+
if has_grid:
|
|
30
|
+
helpers.append(_generate_grid_helpers())
|
|
31
|
+
|
|
32
|
+
if has_buttons:
|
|
33
|
+
helpers.append(_generate_button_helpers())
|
|
34
|
+
|
|
35
|
+
if has_text:
|
|
36
|
+
helpers.append(_generate_text_helpers())
|
|
37
|
+
|
|
38
|
+
# Always include image path helper
|
|
39
|
+
helpers.append(_generate_image_helpers())
|
|
40
|
+
|
|
41
|
+
helpers_str = "\n\n".join(helpers)
|
|
42
|
+
|
|
43
|
+
return f'''{COPYRIGHT_HEADER}
|
|
44
|
+
"""Screen definition for {annotation.screen_name}.
|
|
45
|
+
|
|
46
|
+
All UI data comes from the annotator (assets/annotations/annotation.json).
|
|
47
|
+
The generator only handles business logic (state randomization, valid click targets).
|
|
48
|
+
|
|
49
|
+
Data from annotation:
|
|
50
|
+
- Element positions, bboxes, tolerances
|
|
51
|
+
- Task prompts/templates
|
|
52
|
+
- Masked base image
|
|
53
|
+
|
|
54
|
+
Coordinate scaling:
|
|
55
|
+
- Annotation was made on {annotation.image_size} image
|
|
56
|
+
- Generator may produce different size images
|
|
57
|
+
- All coordinates are scaled at load time
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
|
|
64
|
+
from cudag.annotation import AnnotatedElement, AnnotatedTask, AnnotationConfig
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# -----------------------------------------------------------------------------
|
|
68
|
+
# Load Annotation (Single Source of Truth)
|
|
69
|
+
# -----------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
_ANNOTATIONS_DIR = Path(__file__).parent / "assets" / "annotations"
|
|
72
|
+
|
|
73
|
+
if not _ANNOTATIONS_DIR.exists():
|
|
74
|
+
raise FileNotFoundError(f"Annotations directory not found: {{_ANNOTATIONS_DIR}}")
|
|
75
|
+
|
|
76
|
+
ANNOTATION_CONFIG = AnnotationConfig.load(_ANNOTATIONS_DIR)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# -----------------------------------------------------------------------------
|
|
80
|
+
# Coordinate Scaling
|
|
81
|
+
# -----------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_ANNOTATION_SIZE = ANNOTATION_CONFIG.image_size # {annotation.image_size}
|
|
84
|
+
|
|
85
|
+
# Output size - override these if generating different size images
|
|
86
|
+
IMAGE_WIDTH, IMAGE_HEIGHT = _ANNOTATION_SIZE
|
|
87
|
+
_GENERATOR_SIZE = (IMAGE_WIDTH, IMAGE_HEIGHT)
|
|
88
|
+
|
|
89
|
+
_SCALE_X = _GENERATOR_SIZE[0] / _ANNOTATION_SIZE[0]
|
|
90
|
+
_SCALE_Y = _GENERATOR_SIZE[1] / _ANNOTATION_SIZE[1]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def scale_coord(x: int | float, y: int | float) -> tuple[int, int]:
|
|
94
|
+
"""Scale coordinates from annotation space to generator space."""
|
|
95
|
+
return (int(x * _SCALE_X), int(y * _SCALE_Y))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def scale_bbox(bbox: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
|
|
99
|
+
"""Scale bbox (x, y, width, height) from annotation to generator space."""
|
|
100
|
+
x, y, w, h = bbox
|
|
101
|
+
return (int(x * _SCALE_X), int(y * _SCALE_Y), int(w * _SCALE_X), int(h * _SCALE_Y))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scale_tolerance(tol_x: int, tol_y: int) -> tuple[int, int]:
|
|
105
|
+
"""Scale tolerance values from annotation to generator space."""
|
|
106
|
+
return (int(tol_x * _SCALE_X), int(tol_y * _SCALE_Y))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
{helpers_str}
|
|
110
|
+
'''
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _generate_grid_helpers() -> str:
|
|
114
|
+
"""Generate helper functions for grid elements."""
|
|
115
|
+
return '''# -----------------------------------------------------------------------------
|
|
116
|
+
# Grid Element Accessors
|
|
117
|
+
# -----------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def get_grid_element(label: str = "grid") -> AnnotatedElement:
|
|
120
|
+
"""Get a grid element by label."""
|
|
121
|
+
el = ANNOTATION_CONFIG.get_element_by_label(label)
|
|
122
|
+
if el is None:
|
|
123
|
+
raise ValueError(f"Grid element '{label}' not found in annotation")
|
|
124
|
+
return el
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_grid_bbox(label: str = "grid") -> tuple[int, int, int, int]:
|
|
128
|
+
"""Get scaled grid bounding box."""
|
|
129
|
+
return scale_bbox(get_grid_element(label).bbox)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_grid_dimensions(label: str = "grid") -> tuple[int, int]:
|
|
133
|
+
"""Get grid rows and cols from annotation."""
|
|
134
|
+
el = get_grid_element(label)
|
|
135
|
+
return (el.rows, el.cols)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_grid_tolerance(label: str = "grid") -> tuple[int, int]:
|
|
139
|
+
"""Get scaled grid cell tolerance."""
|
|
140
|
+
el = get_grid_element(label)
|
|
141
|
+
return scale_tolerance(el.tolerance_x, el.tolerance_y)'''
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _generate_button_helpers() -> str:
|
|
145
|
+
"""Generate helper functions for button elements."""
|
|
146
|
+
return '''# -----------------------------------------------------------------------------
|
|
147
|
+
# Button Element Accessors
|
|
148
|
+
# -----------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def get_button_element(name: str) -> AnnotatedElement:
|
|
151
|
+
"""Get a button element by name."""
|
|
152
|
+
el = ANNOTATION_CONFIG.get_element_by_label(name)
|
|
153
|
+
if el is None:
|
|
154
|
+
raise ValueError(f"Button element '{name}' not found in annotation")
|
|
155
|
+
return el
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_button_center(name: str) -> tuple[int, int]:
|
|
159
|
+
"""Get scaled button center coordinates."""
|
|
160
|
+
el = get_button_element(name)
|
|
161
|
+
return scale_coord(el.center[0], el.center[1])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_button_tolerance(name: str) -> tuple[int, int]:
|
|
165
|
+
"""Get scaled button tolerance."""
|
|
166
|
+
el = get_button_element(name)
|
|
167
|
+
return scale_tolerance(el.tolerance_x, el.tolerance_y)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_all_buttons() -> list[AnnotatedElement]:
|
|
171
|
+
"""Get all button elements from annotation."""
|
|
172
|
+
return [el for el in ANNOTATION_CONFIG.elements if el.element_type == "button"]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_button_task(button_name: str) -> AnnotatedTask:
|
|
176
|
+
"""Get the task for a specific button."""
|
|
177
|
+
el = get_button_element(button_name)
|
|
178
|
+
tasks = ANNOTATION_CONFIG.get_tasks_for_element(el.id)
|
|
179
|
+
if not tasks:
|
|
180
|
+
raise ValueError(f"No task found for button '{button_name}'")
|
|
181
|
+
return tasks[0]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_all_button_tasks() -> list[tuple[AnnotatedElement, AnnotatedTask]]:
|
|
185
|
+
"""Get all button elements with their tasks."""
|
|
186
|
+
result: list[tuple[AnnotatedElement, AnnotatedTask]] = []
|
|
187
|
+
for el in get_all_buttons():
|
|
188
|
+
tasks = ANNOTATION_CONFIG.get_tasks_for_element(el.id)
|
|
189
|
+
if tasks:
|
|
190
|
+
result.append((el, tasks[0]))
|
|
191
|
+
return result'''
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _generate_text_helpers() -> str:
|
|
195
|
+
"""Generate helper functions for text elements."""
|
|
196
|
+
return '''# -----------------------------------------------------------------------------
|
|
197
|
+
# Text Element Accessors
|
|
198
|
+
# -----------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def get_text_element(name: str) -> AnnotatedElement:
|
|
201
|
+
"""Get a text element by name."""
|
|
202
|
+
el = ANNOTATION_CONFIG.get_element_by_label(name)
|
|
203
|
+
if el is None:
|
|
204
|
+
raise ValueError(f"Text element '{name}' not found in annotation")
|
|
205
|
+
return el
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_text_bbox(name: str) -> tuple[int, int, int, int]:
|
|
209
|
+
"""Get scaled text element bounding box."""
|
|
210
|
+
el = get_text_element(name)
|
|
211
|
+
return scale_bbox(el.bbox)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_text_center(name: str) -> tuple[int, int]:
|
|
215
|
+
"""Get scaled text element center position."""
|
|
216
|
+
el = get_text_element(name)
|
|
217
|
+
return scale_coord(el.center[0], el.center[1])'''
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _generate_image_helpers() -> str:
|
|
221
|
+
"""Generate helper functions for image paths."""
|
|
222
|
+
return '''# -----------------------------------------------------------------------------
|
|
223
|
+
# Image Paths
|
|
224
|
+
# -----------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def get_masked_image_path() -> Path:
|
|
227
|
+
"""Get path to annotator's masked image."""
|
|
228
|
+
path = ANNOTATION_CONFIG.masked_image_path
|
|
229
|
+
if path is None or not path.exists():
|
|
230
|
+
raise FileNotFoundError("masked.png not found in annotations")
|
|
231
|
+
return path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_original_image_path() -> Path:
|
|
235
|
+
"""Get path to original screenshot."""
|
|
236
|
+
path = ANNOTATION_CONFIG.original_image_path
|
|
237
|
+
if path is None or not path.exists():
|
|
238
|
+
raise FileNotFoundError("original.png not found in annotations")
|
|
239
|
+
return path'''
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def generate_state_py(annotation: ParsedAnnotation) -> str:
|
|
243
|
+
"""Generate state.py from annotation."""
|
|
244
|
+
class_name = _to_pascal_case(annotation.screen_name) + "State"
|
|
245
|
+
|
|
246
|
+
# Extract potential state fields from elements and tasks
|
|
247
|
+
state_fields = _extract_state_fields(annotation)
|
|
248
|
+
fields_str = "\n".join(f" {f}" for f in state_fields) if state_fields else " pass"
|
|
249
|
+
|
|
250
|
+
return f'''{COPYRIGHT_HEADER}
|
|
251
|
+
"""State definition for {annotation.screen_name}."""
|
|
252
|
+
|
|
253
|
+
from dataclasses import dataclass
|
|
254
|
+
from random import Random
|
|
255
|
+
from typing import Any
|
|
256
|
+
|
|
257
|
+
from cudag import BaseState
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass
|
|
261
|
+
class {class_name}(BaseState):
|
|
262
|
+
"""State for rendering the screen.
|
|
263
|
+
|
|
264
|
+
Auto-generated from annotation. Add fields for dynamic content
|
|
265
|
+
that changes between samples (text, selections, etc.).
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
{fields_str}
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def generate(cls, rng: Random) -> "{class_name}":
|
|
272
|
+
"""Generate a random state for training.
|
|
273
|
+
|
|
274
|
+
Override this method to generate realistic variations
|
|
275
|
+
of the screen content.
|
|
276
|
+
"""
|
|
277
|
+
return cls()
|
|
278
|
+
'''
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _extract_state_fields(annotation: ParsedAnnotation) -> list[str]:
|
|
282
|
+
"""Extract potential state fields from annotation."""
|
|
283
|
+
fields: list[str] = []
|
|
284
|
+
|
|
285
|
+
# Look for text inputs
|
|
286
|
+
for el in annotation.elements:
|
|
287
|
+
if el.element_type == "textinput":
|
|
288
|
+
field_name = el.python_name + "_text"
|
|
289
|
+
fields.append(f'{field_name}: str = ""')
|
|
290
|
+
|
|
291
|
+
# Look for prior states in tasks
|
|
292
|
+
prior_state_fields = set()
|
|
293
|
+
for task in annotation.tasks:
|
|
294
|
+
for prior in task.prior_states:
|
|
295
|
+
field = prior.get("field", "")
|
|
296
|
+
if field:
|
|
297
|
+
prior_state_fields.add(field)
|
|
298
|
+
|
|
299
|
+
for field in sorted(prior_state_fields):
|
|
300
|
+
snake = _to_snake_case(field)
|
|
301
|
+
fields.append(f'{snake}: Any = None')
|
|
302
|
+
|
|
303
|
+
return fields
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def generate_renderer_py(annotation: ParsedAnnotation) -> str:
|
|
307
|
+
"""Generate renderer.py using masked.png from annotations."""
|
|
308
|
+
screen_class = _to_pascal_case(annotation.screen_name) + "Screen"
|
|
309
|
+
state_class = _to_pascal_case(annotation.screen_name) + "State"
|
|
310
|
+
|
|
311
|
+
return f'''{COPYRIGHT_HEADER}
|
|
312
|
+
"""Renderer for {annotation.screen_name}."""
|
|
313
|
+
|
|
314
|
+
from pathlib import Path
|
|
315
|
+
from typing import Any
|
|
316
|
+
|
|
317
|
+
from PIL import Image
|
|
318
|
+
|
|
319
|
+
from cudag import BaseRenderer
|
|
320
|
+
from screen import get_masked_image_path, IMAGE_WIDTH, IMAGE_HEIGHT
|
|
321
|
+
from state import {state_class}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class {screen_class}Renderer(BaseRenderer[{state_class}]):
|
|
325
|
+
"""Renderer for {annotation.screen_name} screen.
|
|
326
|
+
|
|
327
|
+
Uses masked.png from annotations as base image.
|
|
328
|
+
Customize the render() method to add dynamic content based on state.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
def __init__(self) -> None:
|
|
332
|
+
super().__init__()
|
|
333
|
+
masked_path = get_masked_image_path()
|
|
334
|
+
self._base_image = Image.open(masked_path)
|
|
335
|
+
# Resize if generator uses different size than annotation
|
|
336
|
+
if self._base_image.size != (IMAGE_WIDTH, IMAGE_HEIGHT):
|
|
337
|
+
self._base_image = self._base_image.resize(
|
|
338
|
+
(IMAGE_WIDTH, IMAGE_HEIGHT), Image.Resampling.LANCZOS
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def render(self, state: {state_class}) -> tuple[Image.Image, dict[str, Any]]:
|
|
342
|
+
"""Render the screen with the given state.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
state: State containing dynamic content
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
Tuple of (rendered_image, metadata_dict)
|
|
349
|
+
"""
|
|
350
|
+
# Start with base image
|
|
351
|
+
image = self._base_image.copy()
|
|
352
|
+
|
|
353
|
+
# TODO: Add dynamic rendering based on state
|
|
354
|
+
# Example:
|
|
355
|
+
# from PIL import ImageDraw
|
|
356
|
+
# draw = ImageDraw.Draw(image)
|
|
357
|
+
# draw.text((x, y), state.some_text, font=font, fill="black", anchor="mm")
|
|
358
|
+
|
|
359
|
+
metadata = {{
|
|
360
|
+
"screen_name": "{annotation.screen_name}",
|
|
361
|
+
"image_size": image.size,
|
|
362
|
+
}}
|
|
363
|
+
|
|
364
|
+
return image, metadata
|
|
365
|
+
'''
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def generate_generator_py(annotation: ParsedAnnotation) -> str:
|
|
369
|
+
"""Generate generator.py from annotation."""
|
|
370
|
+
screen_class = _to_pascal_case(annotation.screen_name) + "Screen"
|
|
371
|
+
state_class = _to_pascal_case(annotation.screen_name) + "State"
|
|
372
|
+
renderer_class = screen_class + "Renderer"
|
|
373
|
+
|
|
374
|
+
task_imports = []
|
|
375
|
+
task_registrations = []
|
|
376
|
+
for task in annotation.tasks:
|
|
377
|
+
task_imports.append(f"from tasks.{task.python_name} import {task.class_name}")
|
|
378
|
+
task_registrations.append(f' builder.register_task({task.class_name}(config, renderer))')
|
|
379
|
+
|
|
380
|
+
task_imports_str = "\n".join(task_imports) if task_imports else "# No tasks defined"
|
|
381
|
+
task_registrations_str = "\n".join(task_registrations) if task_registrations else " pass"
|
|
382
|
+
|
|
383
|
+
return f'''{COPYRIGHT_HEADER}
|
|
384
|
+
"""Generator entry point for {annotation.screen_name}."""
|
|
385
|
+
|
|
386
|
+
import argparse
|
|
387
|
+
from pathlib import Path
|
|
388
|
+
|
|
389
|
+
from cudag import DatasetBuilder, DatasetConfig, run_generator, check_script_invocation
|
|
390
|
+
|
|
391
|
+
from state import {state_class}
|
|
392
|
+
from renderer import {renderer_class}
|
|
393
|
+
{task_imports_str}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def main() -> None:
|
|
397
|
+
"""Run the generator."""
|
|
398
|
+
check_script_invocation(__file__)
|
|
399
|
+
|
|
400
|
+
parser = argparse.ArgumentParser(description="Generate {annotation.screen_name} dataset")
|
|
401
|
+
parser.add_argument("--samples", type=int, default=1000, help="Samples per task")
|
|
402
|
+
parser.add_argument("--output", type=str, default="datasets/{annotation.screen_name}", help="Output directory")
|
|
403
|
+
parser.add_argument("--seed", type=int, default=42, help="Random seed")
|
|
404
|
+
args = parser.parse_args()
|
|
405
|
+
|
|
406
|
+
config_path = Path(__file__).parent / "config" / "dataset.yaml"
|
|
407
|
+
config = DatasetConfig.from_yaml(config_path)
|
|
408
|
+
|
|
409
|
+
renderer = {renderer_class}()
|
|
410
|
+
|
|
411
|
+
builder = DatasetBuilder(
|
|
412
|
+
config=config,
|
|
413
|
+
output_dir=Path(args.output),
|
|
414
|
+
seed=args.seed,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Register tasks
|
|
418
|
+
{task_registrations_str}
|
|
419
|
+
|
|
420
|
+
# Generate dataset
|
|
421
|
+
builder.build(samples_per_task=args.samples)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
if __name__ == "__main__":
|
|
425
|
+
main()
|
|
426
|
+
'''
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def generate_task_py(task: ParsedTask, annotation: ParsedAnnotation) -> str:
|
|
430
|
+
"""Generate a task file using screen helpers for coordinates."""
|
|
431
|
+
state_class = _to_pascal_case(annotation.screen_name) + "State"
|
|
432
|
+
|
|
433
|
+
# Find target element
|
|
434
|
+
target_el = None
|
|
435
|
+
if task.target_element_id:
|
|
436
|
+
for el in annotation.elements:
|
|
437
|
+
if el.id == task.target_element_id:
|
|
438
|
+
target_el = el
|
|
439
|
+
break
|
|
440
|
+
|
|
441
|
+
# Determine which helper to use based on element type
|
|
442
|
+
if target_el:
|
|
443
|
+
if target_el.region_type == "button":
|
|
444
|
+
coord_code = f'pixel_coords = get_button_center("{target_el.label}")'
|
|
445
|
+
tolerance_code = f'tolerance = get_button_tolerance("{target_el.label}")'
|
|
446
|
+
imports = "get_button_center, get_button_tolerance"
|
|
447
|
+
elif target_el.region_type == "grid":
|
|
448
|
+
coord_code = '''# Grid element - coordinates depend on which cell
|
|
449
|
+
grid_bbox = get_grid_bbox()
|
|
450
|
+
# TODO: Calculate cell coordinates based on state
|
|
451
|
+
pixel_coords = (grid_bbox[0] + grid_bbox[2] // 2, grid_bbox[1] + grid_bbox[3] // 2)'''
|
|
452
|
+
tolerance_code = 'tolerance = get_grid_tolerance()'
|
|
453
|
+
imports = "get_grid_bbox, get_grid_tolerance"
|
|
454
|
+
else:
|
|
455
|
+
coord_code = f'''# Get element center
|
|
456
|
+
el = ANNOTATION_CONFIG.get_element_by_label("{target_el.label}")
|
|
457
|
+
pixel_coords = scale_coord(el.center[0], el.center[1])'''
|
|
458
|
+
tolerance_code = f'''el = ANNOTATION_CONFIG.get_element_by_label("{target_el.label}")
|
|
459
|
+
tolerance = scale_tolerance(el.tolerance_x, el.tolerance_y)'''
|
|
460
|
+
imports = "scale_coord, scale_tolerance, ANNOTATION_CONFIG"
|
|
461
|
+
else:
|
|
462
|
+
coord_code = "pixel_coords = (0, 0) # TODO: specify target coordinates"
|
|
463
|
+
tolerance_code = "tolerance = (50, 50) # TODO: calculate from element size"
|
|
464
|
+
imports = "scale_coord"
|
|
465
|
+
|
|
466
|
+
tool_call = _generate_tool_call(task)
|
|
467
|
+
|
|
468
|
+
return f'''{COPYRIGHT_HEADER}
|
|
469
|
+
"""Task: {task.prompt or task.python_name}"""
|
|
470
|
+
|
|
471
|
+
from random import Random
|
|
472
|
+
from typing import Any
|
|
473
|
+
|
|
474
|
+
from cudag import BaseTask, TaskContext, TaskSample, TestCase, ToolCall, normalize_coord
|
|
475
|
+
|
|
476
|
+
from screen import {imports}, IMAGE_WIDTH, IMAGE_HEIGHT
|
|
477
|
+
from state import {state_class}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class {task.class_name}(BaseTask):
|
|
481
|
+
"""Task for: {task.prompt}"""
|
|
482
|
+
|
|
483
|
+
task_type = "{task.task_type}"
|
|
484
|
+
|
|
485
|
+
def generate_sample(self, ctx: TaskContext) -> TaskSample:
|
|
486
|
+
"""Generate a training sample."""
|
|
487
|
+
state = {state_class}.generate(ctx.rng)
|
|
488
|
+
image, metadata = self.renderer.render(state)
|
|
489
|
+
|
|
490
|
+
# Get target coordinates from annotation
|
|
491
|
+
{coord_code}
|
|
492
|
+
normalized = normalize_coord(pixel_coords, (IMAGE_WIDTH, IMAGE_HEIGHT))
|
|
493
|
+
|
|
494
|
+
image_path = self.save_image(image, ctx)
|
|
495
|
+
|
|
496
|
+
return TaskSample(
|
|
497
|
+
id=self.build_id(ctx),
|
|
498
|
+
image_path=image_path,
|
|
499
|
+
human_prompt="{task.prompt}",
|
|
500
|
+
tool_call={tool_call},
|
|
501
|
+
pixel_coords=pixel_coords,
|
|
502
|
+
metadata={{
|
|
503
|
+
"task_type": self.task_type,
|
|
504
|
+
**metadata,
|
|
505
|
+
}},
|
|
506
|
+
image_size=image.size,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def generate_test(self, ctx: TaskContext) -> TestCase:
|
|
510
|
+
"""Generate a test case."""
|
|
511
|
+
sample = self.generate_sample(ctx)
|
|
512
|
+
|
|
513
|
+
# Get tolerance from annotation
|
|
514
|
+
{tolerance_code}
|
|
515
|
+
|
|
516
|
+
return TestCase(
|
|
517
|
+
test_id=f"test_{{sample.id}}",
|
|
518
|
+
screenshot=sample.image_path,
|
|
519
|
+
prompt=sample.human_prompt,
|
|
520
|
+
expected_action=sample.tool_call.to_dict(),
|
|
521
|
+
tolerance=tolerance,
|
|
522
|
+
metadata=sample.metadata,
|
|
523
|
+
pixel_coords=sample.pixel_coords,
|
|
524
|
+
)
|
|
525
|
+
'''
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _generate_tool_call(task: ParsedTask) -> str:
|
|
529
|
+
"""Generate ToolCall constructor for a task."""
|
|
530
|
+
action = task.action
|
|
531
|
+
params = task.action_params
|
|
532
|
+
|
|
533
|
+
if action == "left_click":
|
|
534
|
+
return "ToolCall.left_click(normalized)"
|
|
535
|
+
elif action == "right_click":
|
|
536
|
+
return "ToolCall.right_click(normalized)"
|
|
537
|
+
elif action == "double_click":
|
|
538
|
+
return "ToolCall.double_click(normalized)"
|
|
539
|
+
elif action == "type":
|
|
540
|
+
text = params.get("text", "")
|
|
541
|
+
return f'ToolCall.type("{text}")'
|
|
542
|
+
elif action == "key":
|
|
543
|
+
keys = params.get("keys", [])
|
|
544
|
+
return f"ToolCall.key({keys})"
|
|
545
|
+
elif action == "scroll":
|
|
546
|
+
pixels = params.get("pixels", 100)
|
|
547
|
+
return f"ToolCall.scroll(normalized, pixels={pixels})"
|
|
548
|
+
elif action == "wait":
|
|
549
|
+
ms = params.get("ms", 1000)
|
|
550
|
+
return f"ToolCall.wait(ms={ms})"
|
|
551
|
+
elif action == "drag_to":
|
|
552
|
+
return "ToolCall.drag(normalized, end_coord)"
|
|
553
|
+
elif action == "mouse_move":
|
|
554
|
+
return "ToolCall.mouse_move(normalized)"
|
|
555
|
+
else:
|
|
556
|
+
return f"ToolCall.left_click(normalized) # TODO: implement {action}"
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def generate_tasks_init_py(tasks: list[ParsedTask]) -> str:
|
|
560
|
+
"""Generate tasks/__init__.py."""
|
|
561
|
+
imports = []
|
|
562
|
+
exports = []
|
|
563
|
+
|
|
564
|
+
for task in tasks:
|
|
565
|
+
imports.append(f"from tasks.{task.python_name} import {task.class_name}")
|
|
566
|
+
exports.append(f' "{task.class_name}",')
|
|
567
|
+
|
|
568
|
+
imports_str = "\n".join(imports) if imports else "# No tasks"
|
|
569
|
+
exports_str = "\n".join(exports) if exports else ""
|
|
570
|
+
|
|
571
|
+
return f'''{COPYRIGHT_HEADER}
|
|
572
|
+
"""Task definitions for this generator."""
|
|
573
|
+
|
|
574
|
+
{imports_str}
|
|
575
|
+
|
|
576
|
+
__all__ = [
|
|
577
|
+
{exports_str}
|
|
578
|
+
]
|
|
579
|
+
'''
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def generate_config_yaml(annotation: ParsedAnnotation) -> str:
|
|
583
|
+
"""Generate config/dataset.yaml with image output settings."""
|
|
584
|
+
task_counts = "\n".join(
|
|
585
|
+
f" {task.task_type}: 1000" for task in annotation.tasks
|
|
586
|
+
) if annotation.tasks else " # No tasks defined"
|
|
587
|
+
|
|
588
|
+
return f'''# Dataset configuration for {annotation.screen_name}
|
|
589
|
+
# Auto-generated from annotation
|
|
590
|
+
|
|
591
|
+
dataset:
|
|
592
|
+
name: {annotation.screen_name}
|
|
593
|
+
version: "1.0.0"
|
|
594
|
+
description: "Training data for {annotation.screen_name}"
|
|
595
|
+
|
|
596
|
+
# Image output settings
|
|
597
|
+
# Defaults to annotation size - override to generate different size images
|
|
598
|
+
image:
|
|
599
|
+
width: {annotation.image_size[0]}
|
|
600
|
+
height: {annotation.image_size[1]}
|
|
601
|
+
|
|
602
|
+
generation:
|
|
603
|
+
seed: 42
|
|
604
|
+
train_split: 0.8
|
|
605
|
+
val_split: 0.1
|
|
606
|
+
test_split: 0.1
|
|
607
|
+
|
|
608
|
+
tasks:
|
|
609
|
+
{task_counts}
|
|
610
|
+
|
|
611
|
+
# Distribution types (optional)
|
|
612
|
+
# distributions:
|
|
613
|
+
# click_button:
|
|
614
|
+
# normal: 0.8
|
|
615
|
+
# edge_case: 0.15
|
|
616
|
+
# adversarial: 0.05
|
|
617
|
+
'''
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def generate_pyproject_toml(name: str) -> str:
|
|
621
|
+
"""Generate pyproject.toml."""
|
|
622
|
+
return f'''[project]
|
|
623
|
+
name = "{name}"
|
|
624
|
+
version = "0.1.0"
|
|
625
|
+
description = "CUDAG generator for {name}"
|
|
626
|
+
requires-python = ">=3.12"
|
|
627
|
+
dependencies = [
|
|
628
|
+
"cudag",
|
|
629
|
+
"pillow>=10.0.0",
|
|
630
|
+
]
|
|
631
|
+
|
|
632
|
+
[build-system]
|
|
633
|
+
requires = ["setuptools>=64", "wheel"]
|
|
634
|
+
build-backend = "setuptools.build_meta"
|
|
635
|
+
'''
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _to_pascal_case(name: str) -> str:
|
|
639
|
+
"""Convert name to PascalCase."""
|
|
640
|
+
parts = name.replace("-", "_").split("_")
|
|
641
|
+
return "".join(p.capitalize() for p in parts if p)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _to_snake_case(name: str) -> str:
|
|
645
|
+
"""Convert name to snake_case."""
|
|
646
|
+
import re
|
|
647
|
+
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
|
648
|
+
return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
|