visionforge-studio 0.1.0__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.
Files changed (137) hide show
  1. visionforge/__init__.py +11 -0
  2. visionforge/__main__.py +281 -0
  3. visionforge/blocks/__init__.py +23 -0
  4. visionforge/blocks/_search_utils.py +175 -0
  5. visionforge/blocks/anomaly.py +118 -0
  6. visionforge/blocks/anomaly_runner.py +55 -0
  7. visionforge/blocks/base.py +25 -0
  8. visionforge/blocks/batch_prediction.py +166 -0
  9. visionforge/blocks/classification.py +177 -0
  10. visionforge/blocks/classification_runner.py +54 -0
  11. visionforge/blocks/cross_validation.py +455 -0
  12. visionforge/blocks/detection.py +51 -0
  13. visionforge/blocks/detection_runner.py +58 -0
  14. visionforge/blocks/export_onnx.py +63 -0
  15. visionforge/blocks/grid_search.py +153 -0
  16. visionforge/blocks/model_comparison.py +161 -0
  17. visionforge/blocks/random_search.py +221 -0
  18. visionforge/blocks/registry.py +31 -0
  19. visionforge/blocks/regression.py +111 -0
  20. visionforge/blocks/regression_cv.py +250 -0
  21. visionforge/blocks/regression_runner.py +55 -0
  22. visionforge/blocks/segmentation.py +110 -0
  23. visionforge/blocks/segmentation_cv.py +223 -0
  24. visionforge/blocks/segmentation_runner.py +55 -0
  25. visionforge/blocks/transfer_learning.py +264 -0
  26. visionforge/core/__init__.py +14 -0
  27. visionforge/core/anomaly_data.py +154 -0
  28. visionforge/core/anomaly_trainer.py +430 -0
  29. visionforge/core/batch_predict.py +109 -0
  30. visionforge/core/comparison.py +88 -0
  31. visionforge/core/data.py +173 -0
  32. visionforge/core/dataset_fingerprint.py +168 -0
  33. visionforge/core/detection_data.py +128 -0
  34. visionforge/core/detection_dataset.py +90 -0
  35. visionforge/core/detection_metrics.py +108 -0
  36. visionforge/core/detection_trainer.py +744 -0
  37. visionforge/core/evaluator.py +131 -0
  38. visionforge/core/gradcam.py +176 -0
  39. visionforge/core/latex_export.py +302 -0
  40. visionforge/core/onnx_export.py +163 -0
  41. visionforge/core/plotter.py +318 -0
  42. visionforge/core/preprocessing.py +167 -0
  43. visionforge/core/regression_data.py +162 -0
  44. visionforge/core/regression_trainer.py +487 -0
  45. visionforge/core/replicated_comparison.py +250 -0
  46. visionforge/core/replicates.py +187 -0
  47. visionforge/core/segmentation_data.py +225 -0
  48. visionforge/core/segmentation_trainer.py +556 -0
  49. visionforge/core/significance.py +346 -0
  50. visionforge/core/sweep.py +308 -0
  51. visionforge/core/task_runner.py +42 -0
  52. visionforge/core/tracking.py +52 -0
  53. visionforge/core/trainer.py +495 -0
  54. visionforge/gui/__init__.py +0 -0
  55. visionforge/gui/api/__init__.py +0 -0
  56. visionforge/gui/api/dataset_download.py +383 -0
  57. visionforge/gui/api/detection_export.py +79 -0
  58. visionforge/gui/api/detection_testing.py +162 -0
  59. visionforge/gui/api/routes.py +3699 -0
  60. visionforge/gui/api/schemas.py +642 -0
  61. visionforge/gui/api/torch_batch_predict.py +162 -0
  62. visionforge/gui/api/torch_gradcam.py +153 -0
  63. visionforge/gui/api/torch_onnx_export.py +107 -0
  64. visionforge/gui/server.py +58 -0
  65. visionforge/gui/static/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  66. visionforge/gui/static/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  67. visionforge/gui/static/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  68. visionforge/gui/static/assets/familjen-grotesk-latin-ext-wght-normal-Cvgdx4cy.woff2 +0 -0
  69. visionforge/gui/static/assets/familjen-grotesk-latin-wght-normal-CfZa4wxB.woff2 +0 -0
  70. visionforge/gui/static/assets/familjen-grotesk-vietnamese-wght-normal-DKRsWTd7.woff2 +0 -0
  71. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  72. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  73. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  74. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  75. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  76. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  77. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  78. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  79. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  80. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  81. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  82. visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  83. visionforge/gui/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  84. visionforge/gui/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  85. visionforge/gui/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  86. visionforge/gui/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  87. visionforge/gui/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  88. visionforge/gui/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  89. visionforge/gui/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  90. visionforge/gui/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  91. visionforge/gui/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  92. visionforge/gui/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  93. visionforge/gui/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  94. visionforge/gui/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  95. visionforge/gui/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  96. visionforge/gui/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  97. visionforge/gui/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  98. visionforge/gui/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  99. visionforge/gui/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  100. visionforge/gui/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  101. visionforge/gui/static/assets/index-C9dhnPJf.css +2 -0
  102. visionforge/gui/static/assets/index-CYSrGwLj.js +46 -0
  103. visionforge/gui/static/favicon.svg +1 -0
  104. visionforge/gui/static/icons.svg +24 -0
  105. visionforge/gui/static/index.html +16 -0
  106. visionforge/models/__init__.py +3 -0
  107. visionforge/models/anomaly_factory.py +208 -0
  108. visionforge/models/detection_factory.py +135 -0
  109. visionforge/models/factory.py +125 -0
  110. visionforge/models/registry.py +127 -0
  111. visionforge/models/regression_factory.py +63 -0
  112. visionforge/models/segmentation_factory.py +183 -0
  113. visionforge/models/timm_source.py +35 -0
  114. visionforge/tasks/__init__.py +33 -0
  115. visionforge/tasks/base.py +139 -0
  116. visionforge/tasks/engine.py +367 -0
  117. visionforge/tasks/registry.py +180 -0
  118. visionforge/tasks/runner.py +57 -0
  119. visionforge/tasks/scaffold.py +217 -0
  120. visionforge/utils/__init__.py +0 -0
  121. visionforge/utils/anomaly_config.py +150 -0
  122. visionforge/utils/config.py +521 -0
  123. visionforge/utils/cuda.py +102 -0
  124. visionforge/utils/detection_config.py +306 -0
  125. visionforge/utils/doctor.py +284 -0
  126. visionforge/utils/environment.py +67 -0
  127. visionforge/utils/logger.py +52 -0
  128. visionforge/utils/regression_config.py +241 -0
  129. visionforge/utils/segmentation_config.py +219 -0
  130. visionforge/utils/selftest.py +719 -0
  131. visionforge/utils/selftest_data.py +157 -0
  132. visionforge_studio-0.1.0.dist-info/METADATA +372 -0
  133. visionforge_studio-0.1.0.dist-info/RECORD +137 -0
  134. visionforge_studio-0.1.0.dist-info/WHEEL +5 -0
  135. visionforge_studio-0.1.0.dist-info/entry_points.txt +2 -0
  136. visionforge_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  137. visionforge_studio-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,11 @@
1
+ """VisionForge — local-first computer-vision experimentation platform."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _version
5
+
6
+ try:
7
+ __version__ = _version("visionforge-studio")
8
+ except PackageNotFoundError: # pragma: no cover - source tree without an install
9
+ __version__ = "0.0.0+unknown"
10
+
11
+ __all__ = ["__version__"]
@@ -0,0 +1,281 @@
1
+ """VisionForge CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from visionforge import __version__
11
+
12
+ # Map ExperimentConfig.block snake_case literals to BlockRegistry class names.
13
+ _BLOCK_ALIASES: dict[str, str] = {
14
+ "classification": "ClassificationBlock",
15
+ "cross_validation": "CrossValidationBlock",
16
+ "grid_search": "GridSearchBlock",
17
+ "random_search": "RandomSearchBlock",
18
+ "transfer_learning": "TransferLearningBlock",
19
+ "model_comparison": "ModelComparisonBlock",
20
+ "batch_prediction": "BatchPredictionBlock",
21
+ "export_onnx": "ExportONNXBlock",
22
+ }
23
+
24
+
25
+ def _peek_task(config_path: Path) -> str:
26
+ """Read just the ``task`` field from a YAML config (defaults to multiclass)."""
27
+ import yaml
28
+
29
+ with config_path.open(encoding="utf-8") as f:
30
+ raw: Any = yaml.safe_load(f)
31
+ if not isinstance(raw, dict):
32
+ return "multiclass"
33
+ return str(raw.get("task", "multiclass"))
34
+
35
+
36
+ def build_task_block(config_path: Path) -> tuple[Any, Any]:
37
+ """Load a config and return ``(config, block)`` ready to run, dispatched by task.
38
+
39
+ The standalone tasks (detection/regression/segmentation/anomaly) go to their
40
+ own config loader + block; the classification family goes to
41
+ ``ExperimentConfig`` + the ``BlockRegistry`` block named by ``config.block``.
42
+ The block is ``setup()``-d but not run.
43
+
44
+ Raises:
45
+ ValueError: if the config's ``task`` is not recognized.
46
+ """
47
+ task = _peek_task(config_path)
48
+
49
+ if task == "detection":
50
+ from visionforge.blocks.detection import DetectionBlock
51
+ from visionforge.utils.detection_config import load_detection_config
52
+
53
+ config: Any = load_detection_config(config_path)
54
+ block: Any = DetectionBlock()
55
+ elif task == "regression":
56
+ from visionforge.blocks.regression import RegressionBlock
57
+ from visionforge.utils.regression_config import load_regression_config
58
+
59
+ config = load_regression_config(config_path)
60
+ block = RegressionBlock()
61
+ elif task == "segmentation":
62
+ from visionforge.blocks.segmentation import SegmentationBlock
63
+ from visionforge.utils.segmentation_config import load_segmentation_config
64
+
65
+ config = load_segmentation_config(config_path)
66
+ block = SegmentationBlock()
67
+ elif task == "anomaly":
68
+ from visionforge.blocks.anomaly import AnomalyBlock
69
+ from visionforge.utils.anomaly_config import load_anomaly_config
70
+
71
+ config = load_anomaly_config(config_path)
72
+ block = AnomalyBlock()
73
+ elif task in ("binary", "multiclass"):
74
+ import visionforge.blocks.batch_prediction # noqa: F401
75
+ import visionforge.blocks.classification # noqa: F401
76
+ import visionforge.blocks.cross_validation # noqa: F401
77
+ import visionforge.blocks.export_onnx # noqa: F401
78
+ import visionforge.blocks.grid_search # noqa: F401
79
+ import visionforge.blocks.model_comparison # noqa: F401
80
+ import visionforge.blocks.random_search # noqa: F401
81
+ import visionforge.blocks.transfer_learning # noqa: F401
82
+ from visionforge.blocks.registry import BlockRegistry
83
+ from visionforge.utils.config import load_config
84
+
85
+ config = load_config(config_path)
86
+ registry = BlockRegistry.discover()
87
+ block = registry[_BLOCK_ALIASES[config.block]]()
88
+ else:
89
+ raise ValueError(
90
+ f"Unknown task '{task}'. Expected one of: binary, multiclass, "
91
+ f"detection, regression, segmentation, anomaly."
92
+ )
93
+
94
+ block.setup(config)
95
+ return config, block
96
+
97
+
98
+ def main() -> None:
99
+ """Run a VisionForge experiment or start the GUI from the command line."""
100
+ from visionforge.utils.logger import setup_logger
101
+
102
+ parser = argparse.ArgumentParser(
103
+ prog="visionforge",
104
+ description="VisionForge — Computer Vision experimentation platform.",
105
+ )
106
+ # A bug report without a version is a bug report nobody can act on.
107
+ parser.add_argument(
108
+ "--version", action="version", version=f"visionforge {__version__}"
109
+ )
110
+ subparsers = parser.add_subparsers(dest="command")
111
+
112
+ run_parser = subparsers.add_parser("run", help="Run an experiment from YAML.")
113
+ run_parser.add_argument("config", type=Path, help="path to .yaml config")
114
+
115
+ gui_parser = subparsers.add_parser("gui", help="Start the VisionForge web GUI.")
116
+ gui_parser.add_argument(
117
+ "--host", default="127.0.0.1", help="bind host (default 127.0.0.1)"
118
+ )
119
+ gui_parser.add_argument(
120
+ "--port", type=int, default=8000, help="bind port (default 8000)"
121
+ )
122
+
123
+ doctor_parser = subparsers.add_parser(
124
+ "doctor", help="Check the environment and recommend the correct torch wheel."
125
+ )
126
+ doctor_parser.add_argument(
127
+ "--fix",
128
+ action="store_true",
129
+ help="Run the recommended install after prompting for confirmation.",
130
+ )
131
+
132
+ selftest_parser = subparsers.add_parser(
133
+ "selftest",
134
+ help="Train every task through the real GUI API on synthetic data (ADR-060).",
135
+ )
136
+ selftest_parser.add_argument(
137
+ "--tasks",
138
+ default="all",
139
+ help="comma-separated task keys, or 'all' (default): "
140
+ "classification,regression,segmentation,anomaly,detection,custom",
141
+ )
142
+ selftest_parser.add_argument(
143
+ "--strategies",
144
+ default="all",
145
+ help="comma-separated strategies, or 'all' (default): simple,cv,sweep,replicates",
146
+ )
147
+ selftest_parser.add_argument(
148
+ "--quick",
149
+ action="store_true",
150
+ help="only the 'simple' strategy — a fast is-my-install-sane check",
151
+ )
152
+ selftest_parser.add_argument(
153
+ "--workdir",
154
+ type=Path,
155
+ default=None,
156
+ help="where datasets and outputs land (default: a temp dir, kept on failure)",
157
+ )
158
+ selftest_parser.add_argument(
159
+ "--json", type=Path, default=None, help="also write the outcomes as JSON here"
160
+ )
161
+
162
+ newtask_parser = subparsers.add_parser(
163
+ "new-task",
164
+ help="Scaffold a custom task under user_tasks/ (ADR-058).",
165
+ )
166
+ newtask_parser.add_argument(
167
+ "key", help="task key: lowercase letters/digits/underscores"
168
+ )
169
+ newtask_parser.add_argument(
170
+ "--package",
171
+ action="store_true",
172
+ help="create user_tasks/<key>/task.py (room for assets) instead of a flat file",
173
+ )
174
+ newtask_parser.add_argument(
175
+ "--force", action="store_true", help="overwrite an existing file"
176
+ )
177
+
178
+ args = parser.parse_args()
179
+
180
+ setup_logger()
181
+
182
+ if args.command == "selftest":
183
+ import sys as _sys
184
+ import tempfile
185
+
186
+ from visionforge.utils.logger import logger
187
+ from visionforge.utils.selftest import (
188
+ STRATEGIES,
189
+ TASKS,
190
+ format_report,
191
+ run_selftest,
192
+ )
193
+
194
+ tasks = TASKS if args.tasks == "all" else tuple(args.tasks.split(","))
195
+ if args.quick:
196
+ strategies: tuple[str, ...] = ("simple",)
197
+ else:
198
+ strategies = (
199
+ STRATEGIES
200
+ if args.strategies == "all"
201
+ else tuple(args.strategies.split(","))
202
+ )
203
+ unknown = [t for t in tasks if t not in TASKS] + [
204
+ s for s in strategies if s not in STRATEGIES
205
+ ]
206
+ if unknown:
207
+ logger.error("Unknown task/strategy: {}", ", ".join(unknown))
208
+ _sys.exit(2)
209
+
210
+ workdir = args.workdir or Path(tempfile.mkdtemp(prefix="visionforge_selftest_"))
211
+ logger.info("Self-test workdir: {}", workdir)
212
+ outcomes = run_selftest(workdir, tasks=tasks, strategies=strategies)
213
+ report = format_report(outcomes)
214
+ print(f"\n{report}\n") # noqa: T201 — the table IS the command's output
215
+
216
+ if args.json is not None:
217
+ args.json.parent.mkdir(parents=True, exist_ok=True)
218
+ args.json.write_text(
219
+ json.dumps([vars(o) for o in outcomes], indent=2), encoding="utf-8"
220
+ )
221
+ logger.info("Wrote {}", args.json)
222
+
223
+ _sys.exit(0 if all(o.status == "passed" for o in outcomes) else 1)
224
+
225
+ if args.command == "new-task":
226
+ import sys as _sys
227
+
228
+ from visionforge.tasks.scaffold import scaffold_task
229
+ from visionforge.utils.logger import logger
230
+
231
+ try:
232
+ target = scaffold_task(args.key, package=args.package, force=args.force)
233
+ except (ValueError, FileExistsError) as exc:
234
+ logger.error("{}", exc)
235
+ _sys.exit(1)
236
+ logger.success("Task template created: {}", target)
237
+ logger.info(
238
+ "Next: edit the TODOs in {}, then `visionforge gui` — the "
239
+ "'{}' tab appears automatically. Guide: user_tasks/README.md",
240
+ target,
241
+ args.key,
242
+ )
243
+ return
244
+
245
+ if args.command == "doctor":
246
+ import sys as _sys
247
+
248
+ from visionforge.utils.doctor import _default_confirm, run_doctor
249
+
250
+ _sys.exit(run_doctor(fix=args.fix, confirm_fn=_default_confirm))
251
+
252
+ if args.command == "gui":
253
+ from visionforge.gui.server import start_server
254
+
255
+ start_server(host=args.host, port=args.port)
256
+ return
257
+
258
+ if args.command == "run":
259
+ from visionforge.utils.logger import logger
260
+
261
+ logger.info("Loading config: {}", args.config)
262
+ config, block = build_task_block(args.config)
263
+
264
+ logger.info(
265
+ "Experiment: {} | task: {} | block: {}",
266
+ config.name,
267
+ config.task,
268
+ type(block).__name__,
269
+ )
270
+ logger.info("Running {}...", type(block).__name__)
271
+ block.run()
272
+
273
+ report = block.report()
274
+ logger.success("Done. Report: {}", report)
275
+
276
+ else:
277
+ parser.print_help()
278
+
279
+
280
+ if __name__ == "__main__":
281
+ main()
@@ -0,0 +1,23 @@
1
+ from visionforge.blocks.base import ExperimentBlock
2
+ from visionforge.blocks.batch_prediction import BatchPredictionBlock
3
+ from visionforge.blocks.classification import ClassificationBlock
4
+ from visionforge.blocks.cross_validation import CrossValidationBlock
5
+ from visionforge.blocks.export_onnx import ExportONNXBlock
6
+ from visionforge.blocks.grid_search import GridSearchBlock
7
+ from visionforge.blocks.model_comparison import ModelComparisonBlock
8
+ from visionforge.blocks.random_search import RandomSearchBlock
9
+ from visionforge.blocks.registry import BlockRegistry
10
+ from visionforge.blocks.transfer_learning import TransferLearningBlock
11
+
12
+ __all__ = [
13
+ "ExperimentBlock",
14
+ "BatchPredictionBlock",
15
+ "ClassificationBlock",
16
+ "CrossValidationBlock",
17
+ "ExportONNXBlock",
18
+ "GridSearchBlock",
19
+ "ModelComparisonBlock",
20
+ "RandomSearchBlock",
21
+ "BlockRegistry",
22
+ "TransferLearningBlock",
23
+ ]
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import torch
9
+ import yaml
10
+ from loguru import logger
11
+
12
+ from visionforge.blocks.classification import ClassificationBlock
13
+ from visionforge.utils.config import ExperimentConfig
14
+
15
+
16
+ def set_nested(d: dict[str, Any], dot_key: str, value: Any) -> None:
17
+ """Set a value in a nested dict using a dot-notation key.
18
+
19
+ Raises:
20
+ ValueError: if any intermediate key does not exist in the dict.
21
+ """
22
+ keys = dot_key.split(".")
23
+ node = d
24
+ for k in keys[:-1]:
25
+ if k not in node or not isinstance(node[k], dict):
26
+ raise ValueError(
27
+ f"Unknown hyperparameter path: '{dot_key}' (failed at '{k}')"
28
+ )
29
+ node = node[k]
30
+ if keys[-1] not in node:
31
+ raise ValueError(
32
+ f"Unknown hyperparameter path: '{dot_key}' (failed at '{keys[-1]}')"
33
+ )
34
+ node[keys[-1]] = value
35
+
36
+
37
+ def validate_dot_keys(base_raw: dict[str, Any], search_space: dict[str, Any]) -> None:
38
+ """Raise ValueError for any dot-key that doesn't exist in the base config dict."""
39
+ for dot_key in search_space:
40
+ probe = dict(base_raw.items())
41
+ set_nested(probe, dot_key, None)
42
+
43
+
44
+ def best_trial(trials: list[dict[str, Any]]) -> dict[str, Any] | None:
45
+ """Return the successful trial with the lowest best_val_loss, or None."""
46
+ successful = [t for t in trials if t["status"] == "success"]
47
+ if not successful:
48
+ return None
49
+ return min(
50
+ successful,
51
+ key=lambda t: (t["best_val_loss"] is None, t["best_val_loss"] or float("inf")),
52
+ )
53
+
54
+
55
+ def write_trials_csv(csv_path: Path, trials: list[dict[str, Any]]) -> None:
56
+ """Write a list of trial records to a CSV file."""
57
+ if not trials:
58
+ return
59
+ fieldnames = list(trials[0].keys())
60
+ with csv_path.open("w", newline="", encoding="utf-8") as f:
61
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
62
+ writer.writeheader()
63
+ writer.writerows(trials)
64
+
65
+
66
+ def write_best_config_yaml(
67
+ yaml_path: Path,
68
+ base_config: ExperimentConfig,
69
+ best_idx: int,
70
+ best_overrides: dict[str, Any],
71
+ ) -> None:
72
+ """Write the best trial's config as a YAML file."""
73
+ best_raw: dict[str, Any] = base_config.model_dump(mode="json")
74
+ best_raw["training"]["seed"] = base_config.training.seed + best_idx
75
+ for dot_key, val in best_overrides.items():
76
+ set_nested(best_raw, dot_key, val)
77
+ with yaml_path.open("w", encoding="utf-8") as f:
78
+ yaml.dump(best_raw, f, default_flow_style=False, allow_unicode=True)
79
+
80
+
81
+ def make_trial_progress_wrapper(
82
+ progress_callback: Callable[[dict[str, Any]], None] | None,
83
+ trial_idx: int,
84
+ total_trials: int,
85
+ ) -> Callable[[dict[str, Any]], None] | None:
86
+ """Wrap a sweep-level callback so a trial's inner Trainer events carry trial
87
+ context and don't masquerade as the terminal 'end' event.
88
+
89
+ The GUI closes the SSE stream on a bare 'end'; since each trial's Trainer
90
+ emits its own 'end', the inner one is rewritten to 'trial_end' to keep the
91
+ stream open across trials. The inner 'start' is dropped because the sweep
92
+ emits its own 'trial_start'. Returns None when there is nothing to forward.
93
+ """
94
+ if progress_callback is None:
95
+ return None
96
+
97
+ def _wrapped(event: dict[str, Any]) -> None:
98
+ kind = event.get("event")
99
+ if kind == "start":
100
+ return
101
+ annotated = {**event, "trial_index": trial_idx, "total_trials": total_trials}
102
+ if kind == "end":
103
+ annotated["event"] = "trial_end"
104
+ progress_callback(annotated)
105
+
106
+ return _wrapped
107
+
108
+
109
+ def run_trial(
110
+ base_config: ExperimentConfig,
111
+ trial_idx: int,
112
+ total_trials: int,
113
+ trial_seed: int,
114
+ trial_overrides: dict[str, Any],
115
+ trial_record: dict[str, Any],
116
+ progress_callback: Callable[[dict[str, Any]], None] | None = None,
117
+ ) -> None:
118
+ """Execute one trial and mutate trial_record in place with results or error.
119
+
120
+ Always calls torch.cuda.empty_cache() after the trial regardless of outcome.
121
+
122
+ Args:
123
+ base_config: the base experiment config to derive the trial config from.
124
+ trial_idx: 0-based trial index (used for logging).
125
+ total_trials: total number of trials in the sweep (used for logging).
126
+ trial_seed: seed to set for this trial's training config.
127
+ trial_overrides: dot-notation key→value pairs to apply on top of base_config.
128
+ trial_record: mutable dict that receives status, metrics, and error fields.
129
+ progress_callback: injected onto the inner block so the trial's Trainer
130
+ streams epoch progress to the GUI; None disables streaming.
131
+ """
132
+ base_raw: dict[str, Any] = base_config.model_dump(mode="json")
133
+ base_raw["training"]["seed"] = trial_seed
134
+ for dot_key, val in trial_overrides.items():
135
+ set_nested(base_raw, dot_key, val)
136
+
137
+ block = ClassificationBlock()
138
+ try:
139
+ trial_config = ExperimentConfig.model_validate(base_raw)
140
+ block.setup(trial_config)
141
+ block._progress_callback = progress_callback
142
+ block.run()
143
+ report = block.report()
144
+
145
+ trial_record["status"] = "success"
146
+ trial_record["best_val_loss"] = report.get("train", {}).get("best_val_loss")
147
+ trial_record["test_accuracy"] = report.get("eval", {}).get("accuracy")
148
+ trial_record["test_f1"] = report.get("eval", {}).get("f1")
149
+
150
+ logger.info(
151
+ "Trial {}/{} succeeded: val_loss={} accuracy={}",
152
+ trial_idx + 1,
153
+ total_trials,
154
+ trial_record["best_val_loss"],
155
+ trial_record["test_accuracy"],
156
+ )
157
+
158
+ except Exception as exc: # noqa: BLE001
159
+ trial_record["error"] = str(exc)
160
+ logger.warning("Trial {}/{} failed: {}", trial_idx + 1, total_trials, exc)
161
+
162
+ finally:
163
+ del block
164
+ torch.cuda.empty_cache()
165
+
166
+
167
+ __all__ = [
168
+ "set_nested",
169
+ "validate_dot_keys",
170
+ "best_trial",
171
+ "write_trials_csv",
172
+ "write_best_config_yaml",
173
+ "make_trial_progress_wrapper",
174
+ "run_trial",
175
+ ]
@@ -0,0 +1,118 @@
1
+ """Anomaly-detection experiment block (Phase 9).
2
+
3
+ Mirrors the `ExperimentBlock` setup/run/report contract but over the standalone
4
+ `AnomalyConfig` tree rather than `ExperimentConfig` (see ADR-038). It is not an
5
+ `ExperimentBlock` subclass — that ABC is bound to the classification config — so
6
+ it is dispatched directly by the anomaly run endpoint, not the block registry.
7
+ Same rationale as ADR-033 (detection) / ADR-036 (regression) / ADR-037 (segmentation).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import torch
18
+
19
+ from visionforge.core.anomaly_data import AnomalyDataModule
20
+ from visionforge.core.anomaly_trainer import AnomalyTrainer, AnomalyTrainResult
21
+ from visionforge.core.plotter import MetricsPlotter
22
+ from visionforge.models.anomaly_factory import AnomalyModelFactory
23
+ from visionforge.utils.anomaly_config import AnomalyConfig
24
+
25
+
26
+ class AnomalyBlock:
27
+ """End-to-end anomaly-detection training block over `AnomalyConfig`."""
28
+
29
+ def setup(self, config: AnomalyConfig) -> None:
30
+ self._config = config
31
+ self._train_result: AnomalyTrainResult | None = None
32
+ self._test_metrics: tuple[float, float, float] | None = None
33
+ # Injected by the GUI layer to stream live progress via SSE.
34
+ self._progress_callback: Callable[[dict[str, Any]], None] | None = None
35
+
36
+ def run(self) -> None:
37
+ model = AnomalyModelFactory.create(self._config.model)
38
+ data = AnomalyDataModule(self._config)
39
+ trainer = AnomalyTrainer(self._config)
40
+
41
+ self._train_result = trainer.fit(
42
+ model, data, progress_callback=self._progress_callback
43
+ )
44
+
45
+ # Reload the best checkpoint before final scoring.
46
+ state_dict = torch.load(
47
+ str(self._train_result.model_path), map_location="cpu", weights_only=True
48
+ )
49
+ model.load_state_dict(state_dict) # type: ignore[arg-type]
50
+
51
+ self._test_metrics = trainer.evaluate(
52
+ model, data.train_loader(), data.test_loader()
53
+ )
54
+
55
+ run_dir = self._train_result.model_path.parent
56
+ graphics = self._render_plots(run_dir)
57
+ self._update_run_json(run_dir, graphics)
58
+
59
+ def report(self) -> dict[str, Any]:
60
+ """Return a summary of the run for logging and GUI display."""
61
+ result: dict[str, Any] = {}
62
+ if self._train_result is not None:
63
+ r = self._train_result
64
+ result["train"] = {
65
+ "best_epoch": r.best_epoch,
66
+ "best_auroc": r.best_auroc,
67
+ "total_epochs": r.total_epochs,
68
+ "device_used": r.device_used,
69
+ "run_dir": str(r.model_path.parent),
70
+ }
71
+ if self._test_metrics is not None:
72
+ auroc, threshold, image_f1 = self._test_metrics
73
+ result["test"] = {
74
+ "auroc": auroc,
75
+ "threshold": threshold,
76
+ "image_f1": image_f1,
77
+ }
78
+ return result
79
+
80
+ # ── private ───────────────────────────────────────────────────────────────
81
+
82
+ def _render_plots(self, run_dir: Path) -> list[Path]:
83
+ """Render the AUROC-over-epochs curve (reuses the generic plotter curve)."""
84
+ assert self._train_result is not None
85
+ auroc_path = run_dir / "auroc.png"
86
+ epochs = [r.epoch for r in self._train_result.history]
87
+ values = [r.val_auroc for r in self._train_result.history]
88
+ MetricsPlotter.metric_curve(
89
+ epochs,
90
+ values,
91
+ auroc_path,
92
+ label="AUROC",
93
+ ylabel="Image AUROC",
94
+ title="Image-level AUROC over epochs",
95
+ )
96
+ return [auroc_path]
97
+
98
+ def _update_run_json(self, run_dir: Path, graphics: list[Path]) -> None:
99
+ """Rewrite run.json with test metrics and artifact paths."""
100
+ run_json_path = run_dir / "run.json"
101
+ if not run_json_path.exists():
102
+ return
103
+
104
+ data: dict[str, Any] = json.loads(run_json_path.read_text(encoding="utf-8"))
105
+ if self._test_metrics is not None:
106
+ auroc, threshold, image_f1 = self._test_metrics
107
+ data["metrics"].update(
108
+ {
109
+ "test_auroc": auroc,
110
+ "test_threshold": threshold,
111
+ "test_image_f1": image_f1,
112
+ }
113
+ )
114
+ data["artifacts"]["graphics"] = [str(p) for p in graphics]
115
+ run_json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
116
+
117
+
118
+ __all__ = ["AnomalyBlock"]
@@ -0,0 +1,55 @@
1
+ """TaskRunner adapter for anomaly detection (ADR-041 / ADR-044 / ADR-045)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from visionforge.blocks.anomaly import AnomalyBlock
9
+ from visionforge.core.task_runner import RunResult
10
+ from visionforge.utils.anomaly_config import AnomalyConfig
11
+
12
+ # Anomaly's test metrics; auroc (higher is better) is the ranking default.
13
+ _METRIC_KEYS = ("auroc", "image_f1")
14
+
15
+
16
+ class AnomalyRunner:
17
+ """Drives AnomalyBlock for one training run, exposing the uniform handle.
18
+
19
+ GPU cleanup is the caller's responsibility (the generic comparison/sweep
20
+ runners flush between trials).
21
+ """
22
+
23
+ config_type = AnomalyConfig
24
+
25
+ def run(self, cfg: Any) -> RunResult:
26
+ """Run a single anomaly trial and return a uniform RunResult."""
27
+ block = AnomalyBlock()
28
+ try:
29
+ block.setup(cfg)
30
+ t0 = time.monotonic()
31
+ block.run()
32
+ elapsed = time.monotonic() - t0
33
+
34
+ test: dict[str, Any] = block.report().get("test", {})
35
+ metrics = {
36
+ k: float(test[k]) for k in _METRIC_KEYS if test.get(k) is not None
37
+ }
38
+ return RunResult(
39
+ metrics=metrics, status="success", training_time_s=elapsed, error=""
40
+ )
41
+ except Exception as exc: # noqa: BLE001
42
+ return RunResult(
43
+ metrics={}, status="failed", training_time_s=None, error=str(exc)
44
+ )
45
+
46
+ def metrics(self, result: RunResult) -> dict[str, float]:
47
+ """Return result.metrics unchanged."""
48
+ return result.metrics
49
+
50
+ def primary_metric(self) -> str:
51
+ """Return 'auroc' — the anomaly default ranking metric."""
52
+ return "auroc"
53
+
54
+
55
+ __all__ = ["AnomalyRunner"]
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from visionforge.utils.config import ExperimentConfig
7
+
8
+
9
+ class ExperimentBlock(ABC):
10
+ """Base class for all experiment strategies."""
11
+
12
+ @abstractmethod
13
+ def setup(self, config: ExperimentConfig) -> None:
14
+ """Receive the experiment config and prepare internal state."""
15
+
16
+ @abstractmethod
17
+ def run(self) -> None:
18
+ """Execute the experiment strategy."""
19
+
20
+ @abstractmethod
21
+ def report(self) -> dict[str, Any]:
22
+ """Return a dict of results for logging and GUI display."""
23
+
24
+
25
+ __all__ = ["ExperimentBlock"]