openfcd 0.0.1__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.
- openfcd/__init__.py +1 -0
- openfcd/__main__.py +4 -0
- openfcd/cli/__init__.py +242 -0
- openfcd/cli/_output.py +42 -0
- openfcd/cli/cmd_calibrate.py +120 -0
- openfcd/cli/cmd_new.py +17 -0
- openfcd/cli/cmd_project.py +61 -0
- openfcd/cli/cmd_qc.py +145 -0
- openfcd/cli/cmd_replay.py +169 -0
- openfcd/cli/cmd_run.py +852 -0
- openfcd/core/__init__.py +31 -0
- openfcd/core/fcd.py +525 -0
- openfcd/core/figures.py +30 -0
- openfcd/core/flatfield.py +26 -0
- openfcd/core/heading.py +44 -0
- openfcd/core/inpaint.py +56 -0
- openfcd/core/mask.py +324 -0
- openfcd/core/polygon_source.py +102 -0
- openfcd/core/profile.py +88 -0
- openfcd/core/profile_composite.py +980 -0
- openfcd/core/reference_builder.py +127 -0
- openfcd/core/registration.py +132 -0
- openfcd/core/temporal.py +80 -0
- openfcd/geometry/__init__.py +0 -0
- openfcd/geometry/optical.py +66 -0
- openfcd/gui/__init__.py +5 -0
- openfcd/gui/__main__.py +3 -0
- openfcd/gui/app.py +18 -0
- openfcd/gui/controllers/__init__.py +6 -0
- openfcd/gui/controllers/run_controller.py +204 -0
- openfcd/gui/controllers/session_controller.py +260 -0
- openfcd/gui/controllers/view_router.py +28 -0
- openfcd/gui/dialogs/__init__.py +5 -0
- openfcd/gui/dialogs/frame_picker.py +147 -0
- openfcd/gui/dialogs/image_picker.py +324 -0
- openfcd/gui/dialogs/new_project_wizard.py +384 -0
- openfcd/gui/icons.py +144 -0
- openfcd/gui/mainwindow.py +2131 -0
- openfcd/gui/panels/__init__.py +7 -0
- openfcd/gui/panels/properties.py +1099 -0
- openfcd/gui/panels/scene_tabs.py +91 -0
- openfcd/gui/panels/sim_tree.py +571 -0
- openfcd/gui/preferences.py +279 -0
- openfcd/gui/renderers/__init__.py +14 -0
- openfcd/gui/renderers/_eta_view.py +64 -0
- openfcd/gui/renderers/eta_heatmap.py +46 -0
- openfcd/gui/renderers/eta_timeseries.py +23 -0
- openfcd/gui/renderers/rms_map.py +21 -0
- openfcd/gui/renderers/sample_grid.py +28 -0
- openfcd/gui/renderers/wavelength_profile.py +51 -0
- openfcd/gui/resources/__init__.py +0 -0
- openfcd/gui/resources/brand/app_icon.svg +39 -0
- openfcd/gui/resources/brand/logo_mark.svg +23 -0
- openfcd/gui/resources/icons/__init__.py +0 -0
- openfcd/gui/resources/icons/arrow_back.svg +1 -0
- openfcd/gui/resources/icons/arrow_forward.svg +1 -0
- openfcd/gui/resources/icons/brush.svg +1 -0
- openfcd/gui/resources/icons/check_circle.svg +1 -0
- openfcd/gui/resources/icons/close.svg +1 -0
- openfcd/gui/resources/icons/crop_square.svg +1 -0
- openfcd/gui/resources/icons/dark_mode.svg +1 -0
- openfcd/gui/resources/icons/error.svg +1 -0
- openfcd/gui/resources/icons/folder_open.svg +1 -0
- openfcd/gui/resources/icons/light_mode.svg +1 -0
- openfcd/gui/resources/icons/note_add.svg +1 -0
- openfcd/gui/resources/icons/pan_tool.svg +1 -0
- openfcd/gui/resources/icons/pentagon.svg +1 -0
- openfcd/gui/resources/icons/photo.svg +1 -0
- openfcd/gui/resources/icons/photo_library.svg +1 -0
- openfcd/gui/resources/icons/pie_chart.svg +1 -0
- openfcd/gui/resources/icons/play_arrow.svg +1 -0
- openfcd/gui/resources/icons/rectangle.svg +1 -0
- openfcd/gui/resources/icons/save.svg +1 -0
- openfcd/gui/resources/icons/schedule.svg +1 -0
- openfcd/gui/resources/icons/speed.svg +1 -0
- openfcd/gui/resources/icons/star.svg +1 -0
- openfcd/gui/resources/icons/stop.svg +1 -0
- openfcd/gui/resources/icons/warning.svg +1 -0
- openfcd/gui/scenes/__init__.py +8 -0
- openfcd/gui/scenes/annotation.py +919 -0
- openfcd/gui/scenes/composite.py +36 -0
- openfcd/gui/scenes/eta_map.py +133 -0
- openfcd/gui/scenes/eta_map_scene.py +152 -0
- openfcd/gui/scenes/image_list.py +817 -0
- openfcd/gui/scenes/profile_scene.py +546 -0
- openfcd/gui/scenes/rms_scene.py +179 -0
- openfcd/gui/scenes/run_monitor.py +73 -0
- openfcd/gui/scenes/scene_container.py +82 -0
- openfcd/gui/scenes/scene_view_placeholder.py +38 -0
- openfcd/gui/scenes/viz_defaults.py +83 -0
- openfcd/gui/tokens.py +144 -0
- openfcd/gui/widgets/__init__.py +8 -0
- openfcd/gui/widgets/display_mode.py +44 -0
- openfcd/gui/widgets/mpl_canvas.py +14 -0
- openfcd/gui/widgets/preview_widget.py +966 -0
- openfcd/gui/widgets/stage_timeline.py +166 -0
- openfcd/gui/widgets/status_bar.py +119 -0
- openfcd/gui/widgets/title_bar.py +170 -0
- openfcd/gui/widgets/toolbar.py +364 -0
- openfcd/io/__init__.py +9 -0
- openfcd/io/annotation.py +215 -0
- openfcd/io/image.py +48 -0
- openfcd/io/project.py +178 -0
- openfcd/io/result.py +159 -0
- openfcd/io/scene.py +72 -0
- openfcd/io/store.py +217 -0
- openfcd/pipeline/__init__.py +0 -0
- openfcd/pipeline/aggregate.py +49 -0
- openfcd/pipeline/base.py +50 -0
- openfcd/pipeline/compute.py +558 -0
- openfcd/pipeline/frame.py +615 -0
- openfcd/pipeline/qc.py +282 -0
- openfcd/pipeline/runner.py +34 -0
- openfcd-0.0.1.dist-info/METADATA +226 -0
- openfcd-0.0.1.dist-info/RECORD +118 -0
- openfcd-0.0.1.dist-info/WHEEL +4 -0
- openfcd-0.0.1.dist-info/entry_points.txt +2 -0
- openfcd-0.0.1.dist-info/licenses/LICENSE +21 -0
openfcd/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
openfcd/__main__.py
ADDED
openfcd/cli/__init__.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""OpenFCD CLI entry point."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(name="openfcd", help="Fast Checkerboard Demodulation pipeline", no_args_is_help=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Version
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def version() -> None:
|
|
17
|
+
"""Print version."""
|
|
18
|
+
import openfcd
|
|
19
|
+
|
|
20
|
+
typer.echo(openfcd.__version__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# New
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def new(
|
|
29
|
+
name: str = typer.Argument(..., help="Project name"),
|
|
30
|
+
path: Path = typer.Argument(..., help="Directory path for new .ofcd project"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Create a new .ofcd project directory."""
|
|
33
|
+
from openfcd.io.store import FileSessionStore
|
|
34
|
+
|
|
35
|
+
FileSessionStore.new(path, name)
|
|
36
|
+
typer.echo(f"Created project '{name}' at {path}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Open
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def open(
|
|
45
|
+
project_path: Path = typer.Argument(..., help="Path to .ofcd project directory"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Load and print project summary."""
|
|
48
|
+
from openfcd.io.store import FileSessionStore
|
|
49
|
+
|
|
50
|
+
store = FileSessionStore.open(project_path)
|
|
51
|
+
project = store.project
|
|
52
|
+
typer.echo(f"Name: {project.name}")
|
|
53
|
+
typer.echo(f"Created: {project.created}")
|
|
54
|
+
typer.echo(f"Format version: {project.format_version}")
|
|
55
|
+
typer.echo(f"Geometry preset: {project.geometry.optical_stack.preset}")
|
|
56
|
+
typer.echo(f"Frames dir: {project.data.frames_dir}")
|
|
57
|
+
typer.echo(f"Pattern: {project.data.pattern}")
|
|
58
|
+
store.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Validate
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def validate(
|
|
67
|
+
project_path: Path = typer.Argument(..., help="Path to .ofcd project directory"),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Validate project.yaml schema. Exit 0 if OK, exit 2 if invalid."""
|
|
70
|
+
from openfcd.io.project import ProjectModel
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
ProjectModel.from_yaml(project_path / "project.yaml")
|
|
74
|
+
typer.echo("Project schema is valid.")
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
typer.echo(f"Validation error: {exc}", err=True)
|
|
77
|
+
raise typer.Exit(code=2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Run
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def run(
|
|
86
|
+
project_path: Path = typer.Argument(..., help="Path to .ofcd project directory"),
|
|
87
|
+
workers: int = typer.Option(-1, help="Parallel workers (-1=auto)"),
|
|
88
|
+
frames: str | None = typer.Option(None, help="Frame range filter (e.g. '0:100' or 'Img200*.jpg')"),
|
|
89
|
+
run_id: str | None = typer.Option(None, help="Run ID (default: run-YYYYMMDD-HHMMSS)"),
|
|
90
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON lines"),
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Run the FCD pipeline on a project. Writes runs/{id}/results.h5."""
|
|
93
|
+
# Delegate to cmd_run module to avoid circular imports
|
|
94
|
+
from openfcd.cli.cmd_run import run_cmd as _run_impl
|
|
95
|
+
|
|
96
|
+
_run_impl(project_path=project_path, workers=workers, frames=frames, run_id=run_id, json_output=json_output)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Replay
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def replay(
|
|
105
|
+
project_path: Path = typer.Argument(..., help="Path to .ofcd project directory"),
|
|
106
|
+
run: str | None = typer.Option(None, help="Run ID (default: latest)"),
|
|
107
|
+
figure: str | None = typer.Option(None, help="Figure ID to render (eta_heatmap, etc.)"),
|
|
108
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Figure output path"),
|
|
109
|
+
json_output: bool = typer.Option(False, "--json", help="Output results as JSON"),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Replay a previous run's results (no recomputation)."""
|
|
112
|
+
from openfcd.cli.cmd_replay import replay_cmd as _replay_impl
|
|
113
|
+
|
|
114
|
+
_replay_impl(project_path=project_path, run=run, figure=figure, output=output, json_output=json_output)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Manual grid calibration
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
@app.command("calibrate-grid")
|
|
122
|
+
def calibrate_grid(
|
|
123
|
+
image: Path = typer.Argument(..., help="Checkerboard/reference image to mark"),
|
|
124
|
+
cells: float = typer.Option(..., "--cells", help="Number of checker cells between the two marked endpoints"),
|
|
125
|
+
cell_mm: float = typer.Option(1.2, "--cell-mm", help="Physical checker cell size in mm"),
|
|
126
|
+
points: str | None = typer.Option(None, "--points", help="Non-interactive endpoints: x1,y1,x2,y2"),
|
|
127
|
+
compare_px_per_mm: float | None = typer.Option(
|
|
128
|
+
None,
|
|
129
|
+
"--compare-px-per-mm",
|
|
130
|
+
help="Optional existing px/mm value to compare against",
|
|
131
|
+
),
|
|
132
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Measure pixel/mm by marking a known checkerboard span."""
|
|
135
|
+
from openfcd.cli.cmd_calibrate import calibrate_grid_cmd
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
calibrate_grid_cmd(
|
|
139
|
+
image=image,
|
|
140
|
+
cells=cells,
|
|
141
|
+
cell_mm=cell_mm,
|
|
142
|
+
points=points,
|
|
143
|
+
compare_px_per_mm=compare_px_per_mm,
|
|
144
|
+
json_output=json_output,
|
|
145
|
+
)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
typer.echo(f"Calibration error: {exc}", err=True)
|
|
148
|
+
raise typer.Exit(code=2)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# QC diagnostics
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
@app.command("qc-summary")
|
|
156
|
+
def qc_summary(
|
|
157
|
+
project_path: Path = typer.Argument(..., help="Path to .ofcd project directory"),
|
|
158
|
+
run: str | None = typer.Option(None, help="Run ID (default: latest)"),
|
|
159
|
+
frame: int | None = typer.Option(None, help="Frame ID (default: first frame)"),
|
|
160
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Single-frame QC PNG output"),
|
|
161
|
+
output_dir: Path | None = typer.Option(None, "--output-dir", help="Directory for --all QC PNGs"),
|
|
162
|
+
all_frames: bool = typer.Option(False, "--all", help="Render QC summary PNGs for all frames"),
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Render QC summary figures from an existing run."""
|
|
165
|
+
from openfcd.cli.cmd_qc import qc_summary_cmd
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
qc_summary_cmd(project_path, run, frame, output, output_dir, all_frames)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
typer.echo(f"QC summary error: {exc}", err=True)
|
|
171
|
+
raise typer.Exit(code=2)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command("noise-floor")
|
|
175
|
+
def noise_floor(
|
|
176
|
+
project_path: Path = typer.Argument(..., help="Path to flat-water .ofcd project directory"),
|
|
177
|
+
frames: str | None = typer.Option(None, help="Frame range/filter for the flat-water run"),
|
|
178
|
+
run_id: str | None = typer.Option(None, help="Run ID for a new flat-water run"),
|
|
179
|
+
workers: int = typer.Option(-1, help="Parallel workers (-1=auto)"),
|
|
180
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Noise-floor JSON summary path"),
|
|
181
|
+
hdf5_output: Path | None = typer.Option(None, "--hdf5-output", help="Noise-floor HDF5 summary path"),
|
|
182
|
+
from_run: str | None = typer.Option(None, "--from-run", help="Analyze an existing run instead of recomputing"),
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Run or analyze a flat-water sequence and write noise-floor summary stats."""
|
|
185
|
+
from openfcd.cli.cmd_qc import noise_floor_cmd
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
noise_floor_cmd(project_path, frames, run_id, workers, output, hdf5_output, from_run)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
typer.echo(f"Noise-floor error: {exc}", err=True)
|
|
191
|
+
raise typer.Exit(code=2)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command("sensitivity")
|
|
195
|
+
def sensitivity(
|
|
196
|
+
project_path: Path = typer.Argument(..., help="Path to .ofcd project directory"),
|
|
197
|
+
run: str | None = typer.Option(None, help="Run ID (default: latest)"),
|
|
198
|
+
frame: int | None = typer.Option(None, help="Frame ID (default: first frame)"),
|
|
199
|
+
output: Path | None = typer.Option(None, "--output", "-o", help="Sensitivity JSON output"),
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Estimate eta amplitude sensitivity to calibration parameter perturbations."""
|
|
202
|
+
from openfcd.cli.cmd_qc import sensitivity_cmd
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
sensitivity_cmd(project_path, run, frame, output)
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
typer.echo(f"Sensitivity error: {exc}", err=True)
|
|
208
|
+
raise typer.Exit(code=2)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Project subcommand group
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
project_app = typer.Typer(name="project", help="Project management")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@project_app.command("info")
|
|
219
|
+
def project_info(project_path: Path = typer.Argument(...)) -> None:
|
|
220
|
+
"""Print project.yaml summary."""
|
|
221
|
+
from openfcd.cli.cmd_project import project_info_cmd as _info_impl
|
|
222
|
+
|
|
223
|
+
_info_impl(project_path=project_path)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@project_app.command("runs")
|
|
227
|
+
def project_runs(project_path: Path = typer.Argument(...)) -> None:
|
|
228
|
+
"""List all runs with STALE status."""
|
|
229
|
+
from openfcd.cli.cmd_project import project_runs_cmd as _runs_impl
|
|
230
|
+
|
|
231
|
+
_runs_impl(project_path=project_path)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@project_app.command("fingerprint")
|
|
235
|
+
def project_fingerprint(project_path: Path = typer.Argument(...)) -> None:
|
|
236
|
+
"""Print current config_fingerprint."""
|
|
237
|
+
from openfcd.cli.cmd_project import project_fingerprint_cmd as _fp_impl
|
|
238
|
+
|
|
239
|
+
_fp_impl(project_path=project_path)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
app.add_typer(project_app)
|
openfcd/cli/_output.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""StageEvent serialization and display helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from openfcd.pipeline.base import StageEvent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def stage_event_to_json(event: StageEvent) -> str:
|
|
10
|
+
"""Serialize a StageEvent to a single-line JSON string with all 12 fields."""
|
|
11
|
+
data: dict[str, object] = {
|
|
12
|
+
"kind": event.kind,
|
|
13
|
+
"stage": event.stage,
|
|
14
|
+
"batch": event.batch,
|
|
15
|
+
"frame_idx": event.frame_idx,
|
|
16
|
+
"substage": event.substage,
|
|
17
|
+
"progress": event.progress,
|
|
18
|
+
"total": event.total,
|
|
19
|
+
"completed": event.completed,
|
|
20
|
+
"metrics": event.metrics,
|
|
21
|
+
"run_id": event.run_id,
|
|
22
|
+
"seq": event.seq,
|
|
23
|
+
"timestamp": event.timestamp,
|
|
24
|
+
}
|
|
25
|
+
return json.dumps(data)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_progress(event: StageEvent) -> str:
|
|
29
|
+
"""Human-readable progress string for non-JSON mode."""
|
|
30
|
+
if event.kind == "progress":
|
|
31
|
+
pct = f"{event.progress:.0%}" if event.progress is not None else "??"
|
|
32
|
+
frame = f"frame {event.frame_idx}" if event.frame_idx is not None else ""
|
|
33
|
+
batch = f"[{event.batch}]" if event.batch else ""
|
|
34
|
+
return f"[{event.stage}] {pct} {batch} {frame}"
|
|
35
|
+
elif event.kind == "finish":
|
|
36
|
+
return f"[{event.stage}] DONE"
|
|
37
|
+
elif event.kind == "error":
|
|
38
|
+
return f"[{event.stage}] ERROR: {event.metrics.get('message', 'unknown')}"
|
|
39
|
+
elif event.kind == "cancel":
|
|
40
|
+
return f"[{event.stage}] CANCELLED"
|
|
41
|
+
else:
|
|
42
|
+
return f"[{event.stage}] {event.kind}"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Point = tuple[float, float]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_point_pair(value: str) -> tuple[Point, Point]:
|
|
14
|
+
"""Parse 'x1,y1,x2,y2' into two image points."""
|
|
15
|
+
parts = [p.strip() for p in value.split(",")]
|
|
16
|
+
if len(parts) != 4:
|
|
17
|
+
raise ValueError("points must be formatted as x1,y1,x2,y2")
|
|
18
|
+
try:
|
|
19
|
+
x1, y1, x2, y2 = (float(p) for p in parts)
|
|
20
|
+
except ValueError as exc:
|
|
21
|
+
raise ValueError("points must contain numeric coordinates") from exc
|
|
22
|
+
return (x1, y1), (x2, y2)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def grid_pixel_calibration(
|
|
26
|
+
p0: Point,
|
|
27
|
+
p1: Point,
|
|
28
|
+
*,
|
|
29
|
+
cells: float,
|
|
30
|
+
cell_mm: float,
|
|
31
|
+
) -> dict[str, float]:
|
|
32
|
+
"""Calculate pixel/mm from two manually marked endpoints."""
|
|
33
|
+
cells = float(cells)
|
|
34
|
+
cell_mm = float(cell_mm)
|
|
35
|
+
if cells <= 0:
|
|
36
|
+
raise ValueError("cells must be positive")
|
|
37
|
+
if cell_mm <= 0:
|
|
38
|
+
raise ValueError("cell_mm must be positive")
|
|
39
|
+
dx = float(p1[0]) - float(p0[0])
|
|
40
|
+
dy = float(p1[1]) - float(p0[1])
|
|
41
|
+
pixel_distance = math.hypot(dx, dy)
|
|
42
|
+
if pixel_distance <= 0:
|
|
43
|
+
raise ValueError("marked points must not be identical")
|
|
44
|
+
length_mm = cells * cell_mm
|
|
45
|
+
return {
|
|
46
|
+
"pixel_distance": pixel_distance,
|
|
47
|
+
"cells": cells,
|
|
48
|
+
"cell_mm": cell_mm,
|
|
49
|
+
"length_mm": length_mm,
|
|
50
|
+
"pixel_per_mm": pixel_distance / length_mm,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _pick_points_interactive(image_path: Path) -> tuple[Point, Point]:
|
|
55
|
+
import matplotlib.pyplot as plt
|
|
56
|
+
from matplotlib.image import imread
|
|
57
|
+
|
|
58
|
+
img = imread(str(image_path))
|
|
59
|
+
fig, ax = plt.subplots()
|
|
60
|
+
ax.imshow(img, cmap="gray")
|
|
61
|
+
ax.set_title("Click the two endpoints of a known checkerboard span")
|
|
62
|
+
ax.set_axis_off()
|
|
63
|
+
typer.echo("Click two endpoints in the image window, then close is not needed.")
|
|
64
|
+
pts = plt.ginput(2, timeout=0)
|
|
65
|
+
plt.close(fig)
|
|
66
|
+
if len(pts) != 2:
|
|
67
|
+
raise RuntimeError("expected exactly two clicked points")
|
|
68
|
+
return (float(pts[0][0]), float(pts[0][1])), (float(pts[1][0]), float(pts[1][1]))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _format_report(result: dict[str, float], compare_px_per_mm: float | None) -> list[str]:
|
|
72
|
+
lines = [
|
|
73
|
+
f"pixel_distance_px: {result['pixel_distance']:.3f}",
|
|
74
|
+
f"physical_length_mm: {result['length_mm']:.6g}",
|
|
75
|
+
f"pixel_per_mm: {result['pixel_per_mm']:.6f}",
|
|
76
|
+
]
|
|
77
|
+
if compare_px_per_mm and compare_px_per_mm > 0:
|
|
78
|
+
measured = result["pixel_per_mm"]
|
|
79
|
+
ratio = compare_px_per_mm / measured
|
|
80
|
+
lines.extend(
|
|
81
|
+
[
|
|
82
|
+
f"compare_pixel_per_mm: {compare_px_per_mm:.6f}",
|
|
83
|
+
f"px_per_mm_ratio_compare_over_measured: {ratio:.6f}",
|
|
84
|
+
f"eta_scale_if_replace_compare_with_measured: {ratio * ratio:.6f}",
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
return lines
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def calibrate_grid_cmd(
|
|
91
|
+
image: Path,
|
|
92
|
+
*,
|
|
93
|
+
cells: float,
|
|
94
|
+
cell_mm: float,
|
|
95
|
+
points: str | None = None,
|
|
96
|
+
compare_px_per_mm: float | None = None,
|
|
97
|
+
json_output: bool = False,
|
|
98
|
+
) -> dict[str, float]:
|
|
99
|
+
if not image.exists():
|
|
100
|
+
raise FileNotFoundError(f"image not found: {image}")
|
|
101
|
+
if points:
|
|
102
|
+
p0, p1 = parse_point_pair(points)
|
|
103
|
+
else:
|
|
104
|
+
p0, p1 = _pick_points_interactive(image)
|
|
105
|
+
result = grid_pixel_calibration(p0, p1, cells=cells, cell_mm=cell_mm)
|
|
106
|
+
result["x0"] = float(p0[0])
|
|
107
|
+
result["y0"] = float(p0[1])
|
|
108
|
+
result["x1"] = float(p1[0])
|
|
109
|
+
result["y1"] = float(p1[1])
|
|
110
|
+
if compare_px_per_mm and compare_px_per_mm > 0:
|
|
111
|
+
ratio = float(compare_px_per_mm) / result["pixel_per_mm"]
|
|
112
|
+
result["compare_pixel_per_mm"] = float(compare_px_per_mm)
|
|
113
|
+
result["px_per_mm_ratio_compare_over_measured"] = ratio
|
|
114
|
+
result["eta_scale_if_replace_compare_with_measured"] = ratio * ratio
|
|
115
|
+
if json_output:
|
|
116
|
+
typer.echo(json.dumps(result, indent=2, sort_keys=True))
|
|
117
|
+
else:
|
|
118
|
+
for line in _format_report(result, compare_px_per_mm):
|
|
119
|
+
typer.echo(line)
|
|
120
|
+
return result
|
openfcd/cli/cmd_new.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""`openfcd new` — create a new .ofcd project directory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from openfcd.io.store import FileSessionStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def new_cmd(
|
|
12
|
+
name: str = typer.Argument(..., help="Project name"),
|
|
13
|
+
path: Path = typer.Argument(..., help="Directory path for new .ofcd project"),
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Create a new .ofcd project directory."""
|
|
16
|
+
FileSessionStore.new(path, name)
|
|
17
|
+
typer.echo(f"Created project '{name}' at {path}")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""`openfcd project` — project management subcommand group."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from openfcd.io.store import FileSessionStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def project_info_cmd(
|
|
12
|
+
project_path: Path = typer.Argument(...),
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Print project.yaml summary."""
|
|
15
|
+
store = FileSessionStore.open(project_path)
|
|
16
|
+
project = store.project
|
|
17
|
+
typer.echo(f"Name: {project.name}")
|
|
18
|
+
typer.echo(f"Created: {project.created}")
|
|
19
|
+
typer.echo("Geometry:")
|
|
20
|
+
typer.echo(f" Checker cell side: {project.geometry.checker_cell_mm} mm")
|
|
21
|
+
typer.echo(f" Optical stack: {project.geometry.optical_stack.preset}")
|
|
22
|
+
typer.echo("Data:")
|
|
23
|
+
typer.echo(f" Frames dir: {project.data.frames_dir}")
|
|
24
|
+
typer.echo(f" Pattern: {project.data.pattern}")
|
|
25
|
+
if project.data.time_step_ms is not None:
|
|
26
|
+
typer.echo(f" Time step: {project.data.time_step_ms} ms")
|
|
27
|
+
typer.echo("Process:")
|
|
28
|
+
typer.echo(f" Flatfield sigma: {project.process.flatfield_sigma}")
|
|
29
|
+
typer.echo(f" Detrend: {project.process.detrend}")
|
|
30
|
+
store.close()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def project_runs_cmd(
|
|
34
|
+
project_path: Path = typer.Argument(...),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""List all runs with STALE status."""
|
|
37
|
+
store = FileSessionStore.open(project_path)
|
|
38
|
+
runs = store.list_runs()
|
|
39
|
+
|
|
40
|
+
if not runs:
|
|
41
|
+
typer.echo("No runs found.")
|
|
42
|
+
store.close()
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
for run_info in runs:
|
|
46
|
+
run_id = run_info["run_id"]
|
|
47
|
+
stale = "STALE" if store.is_stale(run_id) else "OK"
|
|
48
|
+
fingerprint = run_info.get("config_fingerprint", "unknown")[:12]
|
|
49
|
+
typer.echo(f" {run_id} [{stale}] fingerprint={fingerprint}...")
|
|
50
|
+
|
|
51
|
+
store.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def project_fingerprint_cmd(
|
|
55
|
+
project_path: Path = typer.Argument(...),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Print current config_fingerprint."""
|
|
58
|
+
store = FileSessionStore.open(project_path)
|
|
59
|
+
fp = store.config_fingerprint()
|
|
60
|
+
typer.echo(fp)
|
|
61
|
+
store.close()
|
openfcd/cli/cmd_qc.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from openfcd.io.result import HDF5ResultStore
|
|
9
|
+
from openfcd.io.store import FileSessionStore
|
|
10
|
+
from openfcd.pipeline.qc import (
|
|
11
|
+
eta_noise_summary,
|
|
12
|
+
parameter_sensitivity_report,
|
|
13
|
+
save_qc_summary_figure,
|
|
14
|
+
write_noise_summary,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _latest_run_id(store: FileSessionStore) -> str:
|
|
19
|
+
runs = store.list_runs()
|
|
20
|
+
if not runs:
|
|
21
|
+
raise RuntimeError("no runs found")
|
|
22
|
+
return str(runs[-1]["run_id"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _open_results(project_path: Path, run: str | None) -> tuple[FileSessionStore, HDF5ResultStore, str]:
|
|
26
|
+
store = FileSessionStore.open(project_path, read_only=True)
|
|
27
|
+
run_id = run or _latest_run_id(store)
|
|
28
|
+
h5_path = store._dir / "runs" / run_id / "results.h5"
|
|
29
|
+
if not h5_path.exists():
|
|
30
|
+
store.close()
|
|
31
|
+
raise RuntimeError(f"results not found: {h5_path}")
|
|
32
|
+
return store, HDF5ResultStore.open(h5_path, "r"), run_id
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _frame_image(store: FileSessionStore, results: HDF5ResultStore, frame_id: int):
|
|
36
|
+
frame_path = results.read_frame_attrs("default", frame_id).get("frame_path")
|
|
37
|
+
if not frame_path:
|
|
38
|
+
return None
|
|
39
|
+
path = Path(str(frame_path))
|
|
40
|
+
if not path.is_absolute():
|
|
41
|
+
path = Path(store.project.data.frames_dir) / path
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return None
|
|
44
|
+
from openfcd.pipeline.compute import load_gray
|
|
45
|
+
return load_gray(path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def qc_summary_cmd(
|
|
49
|
+
project_path: Path,
|
|
50
|
+
run: str | None,
|
|
51
|
+
frame: int | None,
|
|
52
|
+
output: Path | None,
|
|
53
|
+
output_dir: Path | None,
|
|
54
|
+
all_frames: bool,
|
|
55
|
+
) -> None:
|
|
56
|
+
store, results, run_id = _open_results(project_path, run)
|
|
57
|
+
try:
|
|
58
|
+
frame_ids = results.list_frames("default")
|
|
59
|
+
if not frame_ids:
|
|
60
|
+
raise RuntimeError("no frame results found")
|
|
61
|
+
selected = frame_ids if all_frames else [frame if frame is not None else frame_ids[0]]
|
|
62
|
+
if all_frames:
|
|
63
|
+
out_dir = output_dir or (store._dir / "runs" / run_id / "qc")
|
|
64
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
for fid in selected:
|
|
66
|
+
save_qc_summary_figure(
|
|
67
|
+
results,
|
|
68
|
+
"default",
|
|
69
|
+
fid,
|
|
70
|
+
out_dir / f"frame_{fid:06d}_qc.png",
|
|
71
|
+
image=_frame_image(store, results, fid),
|
|
72
|
+
)
|
|
73
|
+
typer.echo(json.dumps({"run_id": run_id, "frames": selected, "output_dir": str(out_dir)}))
|
|
74
|
+
else:
|
|
75
|
+
out = output or (store._dir / "runs" / run_id / f"frame_{selected[0]:06d}_qc.png")
|
|
76
|
+
save_qc_summary_figure(
|
|
77
|
+
results,
|
|
78
|
+
"default",
|
|
79
|
+
selected[0],
|
|
80
|
+
out,
|
|
81
|
+
image=_frame_image(store, results, selected[0]),
|
|
82
|
+
)
|
|
83
|
+
typer.echo(json.dumps({"run_id": run_id, "frame": selected[0], "output": str(out)}))
|
|
84
|
+
finally:
|
|
85
|
+
results.close()
|
|
86
|
+
store.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def noise_floor_cmd(
|
|
90
|
+
project_path: Path,
|
|
91
|
+
frames: str | None,
|
|
92
|
+
run_id: str | None,
|
|
93
|
+
workers: int,
|
|
94
|
+
output: Path | None,
|
|
95
|
+
hdf5_output: Path | None,
|
|
96
|
+
from_run: str | None,
|
|
97
|
+
) -> None:
|
|
98
|
+
if from_run is None:
|
|
99
|
+
from openfcd.cli.cmd_run import run_cmd
|
|
100
|
+
run_id = run_id or "flat-water-noise"
|
|
101
|
+
try:
|
|
102
|
+
run_cmd(project_path=project_path, workers=workers, frames=frames, run_id=run_id, json_output=False)
|
|
103
|
+
except typer.Exit as exc:
|
|
104
|
+
if exc.exit_code != 0:
|
|
105
|
+
raise
|
|
106
|
+
from_run = run_id
|
|
107
|
+
|
|
108
|
+
store, results, resolved = _open_results(project_path, from_run)
|
|
109
|
+
try:
|
|
110
|
+
summary = eta_noise_summary(results, "default")
|
|
111
|
+
summary["run_id"] = resolved
|
|
112
|
+
json_path = output or (store._dir / "runs" / resolved / "noise_floor_summary.json")
|
|
113
|
+
h5_path = hdf5_output or (store._dir / "runs" / resolved / "noise_floor_summary.h5")
|
|
114
|
+
write_noise_summary(summary, json_path, h5_path)
|
|
115
|
+
typer.echo(json.dumps(summary, sort_keys=True))
|
|
116
|
+
finally:
|
|
117
|
+
results.close()
|
|
118
|
+
store.close()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def sensitivity_cmd(
|
|
122
|
+
project_path: Path,
|
|
123
|
+
run: str | None,
|
|
124
|
+
frame: int | None,
|
|
125
|
+
output: Path | None,
|
|
126
|
+
) -> None:
|
|
127
|
+
store, results, run_id = _open_results(project_path, run)
|
|
128
|
+
try:
|
|
129
|
+
frame_ids = results.list_frames("default")
|
|
130
|
+
if not frame_ids:
|
|
131
|
+
raise RuntimeError("no frame results found")
|
|
132
|
+
fid = frame if frame is not None else frame_ids[0]
|
|
133
|
+
report = parameter_sensitivity_report(
|
|
134
|
+
results.read_frame("default", fid),
|
|
135
|
+
results.read_frame_attrs("default", fid),
|
|
136
|
+
)
|
|
137
|
+
report["run_id"] = run_id
|
|
138
|
+
report["frame_id"] = fid
|
|
139
|
+
out = output or (store._dir / "runs" / run_id / f"frame_{fid:06d}_sensitivity.json")
|
|
140
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
out.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
|
|
142
|
+
typer.echo(json.dumps({"run_id": run_id, "frame": fid, "output": str(out)}))
|
|
143
|
+
finally:
|
|
144
|
+
results.close()
|
|
145
|
+
store.close()
|