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.
- visionforge/__init__.py +11 -0
- visionforge/__main__.py +281 -0
- visionforge/blocks/__init__.py +23 -0
- visionforge/blocks/_search_utils.py +175 -0
- visionforge/blocks/anomaly.py +118 -0
- visionforge/blocks/anomaly_runner.py +55 -0
- visionforge/blocks/base.py +25 -0
- visionforge/blocks/batch_prediction.py +166 -0
- visionforge/blocks/classification.py +177 -0
- visionforge/blocks/classification_runner.py +54 -0
- visionforge/blocks/cross_validation.py +455 -0
- visionforge/blocks/detection.py +51 -0
- visionforge/blocks/detection_runner.py +58 -0
- visionforge/blocks/export_onnx.py +63 -0
- visionforge/blocks/grid_search.py +153 -0
- visionforge/blocks/model_comparison.py +161 -0
- visionforge/blocks/random_search.py +221 -0
- visionforge/blocks/registry.py +31 -0
- visionforge/blocks/regression.py +111 -0
- visionforge/blocks/regression_cv.py +250 -0
- visionforge/blocks/regression_runner.py +55 -0
- visionforge/blocks/segmentation.py +110 -0
- visionforge/blocks/segmentation_cv.py +223 -0
- visionforge/blocks/segmentation_runner.py +55 -0
- visionforge/blocks/transfer_learning.py +264 -0
- visionforge/core/__init__.py +14 -0
- visionforge/core/anomaly_data.py +154 -0
- visionforge/core/anomaly_trainer.py +430 -0
- visionforge/core/batch_predict.py +109 -0
- visionforge/core/comparison.py +88 -0
- visionforge/core/data.py +173 -0
- visionforge/core/dataset_fingerprint.py +168 -0
- visionforge/core/detection_data.py +128 -0
- visionforge/core/detection_dataset.py +90 -0
- visionforge/core/detection_metrics.py +108 -0
- visionforge/core/detection_trainer.py +744 -0
- visionforge/core/evaluator.py +131 -0
- visionforge/core/gradcam.py +176 -0
- visionforge/core/latex_export.py +302 -0
- visionforge/core/onnx_export.py +163 -0
- visionforge/core/plotter.py +318 -0
- visionforge/core/preprocessing.py +167 -0
- visionforge/core/regression_data.py +162 -0
- visionforge/core/regression_trainer.py +487 -0
- visionforge/core/replicated_comparison.py +250 -0
- visionforge/core/replicates.py +187 -0
- visionforge/core/segmentation_data.py +225 -0
- visionforge/core/segmentation_trainer.py +556 -0
- visionforge/core/significance.py +346 -0
- visionforge/core/sweep.py +308 -0
- visionforge/core/task_runner.py +42 -0
- visionforge/core/tracking.py +52 -0
- visionforge/core/trainer.py +495 -0
- visionforge/gui/__init__.py +0 -0
- visionforge/gui/api/__init__.py +0 -0
- visionforge/gui/api/dataset_download.py +383 -0
- visionforge/gui/api/detection_export.py +79 -0
- visionforge/gui/api/detection_testing.py +162 -0
- visionforge/gui/api/routes.py +3699 -0
- visionforge/gui/api/schemas.py +642 -0
- visionforge/gui/api/torch_batch_predict.py +162 -0
- visionforge/gui/api/torch_gradcam.py +153 -0
- visionforge/gui/api/torch_onnx_export.py +107 -0
- visionforge/gui/server.py +58 -0
- visionforge/gui/static/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
- visionforge/gui/static/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
- visionforge/gui/static/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
- visionforge/gui/static/assets/familjen-grotesk-latin-ext-wght-normal-Cvgdx4cy.woff2 +0 -0
- visionforge/gui/static/assets/familjen-grotesk-latin-wght-normal-CfZa4wxB.woff2 +0 -0
- visionforge/gui/static/assets/familjen-grotesk-vietnamese-wght-normal-DKRsWTd7.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- visionforge/gui/static/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- visionforge/gui/static/assets/index-C9dhnPJf.css +2 -0
- visionforge/gui/static/assets/index-CYSrGwLj.js +46 -0
- visionforge/gui/static/favicon.svg +1 -0
- visionforge/gui/static/icons.svg +24 -0
- visionforge/gui/static/index.html +16 -0
- visionforge/models/__init__.py +3 -0
- visionforge/models/anomaly_factory.py +208 -0
- visionforge/models/detection_factory.py +135 -0
- visionforge/models/factory.py +125 -0
- visionforge/models/registry.py +127 -0
- visionforge/models/regression_factory.py +63 -0
- visionforge/models/segmentation_factory.py +183 -0
- visionforge/models/timm_source.py +35 -0
- visionforge/tasks/__init__.py +33 -0
- visionforge/tasks/base.py +139 -0
- visionforge/tasks/engine.py +367 -0
- visionforge/tasks/registry.py +180 -0
- visionforge/tasks/runner.py +57 -0
- visionforge/tasks/scaffold.py +217 -0
- visionforge/utils/__init__.py +0 -0
- visionforge/utils/anomaly_config.py +150 -0
- visionforge/utils/config.py +521 -0
- visionforge/utils/cuda.py +102 -0
- visionforge/utils/detection_config.py +306 -0
- visionforge/utils/doctor.py +284 -0
- visionforge/utils/environment.py +67 -0
- visionforge/utils/logger.py +52 -0
- visionforge/utils/regression_config.py +241 -0
- visionforge/utils/segmentation_config.py +219 -0
- visionforge/utils/selftest.py +719 -0
- visionforge/utils/selftest_data.py +157 -0
- visionforge_studio-0.1.0.dist-info/METADATA +372 -0
- visionforge_studio-0.1.0.dist-info/RECORD +137 -0
- visionforge_studio-0.1.0.dist-info/WHEEL +5 -0
- visionforge_studio-0.1.0.dist-info/entry_points.txt +2 -0
- visionforge_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- visionforge_studio-0.1.0.dist-info/top_level.txt +1 -0
visionforge/__init__.py
ADDED
|
@@ -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__"]
|
visionforge/__main__.py
ADDED
|
@@ -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"]
|