neuroseq 0.30.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.
- neuroseq/__init__.py +5 -0
- neuroseq/__main__.py +256 -0
- neuroseq/add_experiment_data_dialog.py +241 -0
- neuroseq/analysis_plugins.py +182 -0
- neuroseq/aoi_shapes.py +65 -0
- neuroseq/b2_model.py +2772 -0
- neuroseq/b2_units.py +105 -0
- neuroseq/background_jobs.py +447 -0
- neuroseq/cellpose_annotation.py +138 -0
- neuroseq/cellpose_test_data.py +285 -0
- neuroseq/console_art.py +98 -0
- neuroseq/console_widget.py +292 -0
- neuroseq/data.py +101 -0
- neuroseq/data_manager_dialog.py +197 -0
- neuroseq/device_control/__init__.py +1 -0
- neuroseq/device_control/bss2_connection.py +375 -0
- neuroseq/device_control/bss2_panel.py +908 -0
- neuroseq/device_control/bss2_protocol.py +975 -0
- neuroseq/device_control/bss2_stimulation_host.py +293 -0
- neuroseq/device_control/scanimage_connection.py +351 -0
- neuroseq/device_control/scanimage_controls.py +424 -0
- neuroseq/device_control/scanimage_engine_worker.py +128 -0
- neuroseq/device_control/scanimage_files.py +49 -0
- neuroseq/device_control/stimulation_setup_widget.py +38 -0
- neuroseq/device_control/syscon2_client.py +439 -0
- neuroseq/device_control/syscon2_connection_manager.py +196 -0
- neuroseq/device_control/syscon2_panel.py +1523 -0
- neuroseq/device_control/syscon2_protocol.py +207 -0
- neuroseq/device_control/syscon2_stimulation_host.py +689 -0
- neuroseq/docs/common_issues.md +49 -0
- neuroseq/docs/developer/analysis_plugins.md +43 -0
- neuroseq/docs/developer/contributing.md +41 -0
- neuroseq/docs/developer/custom_simulators.md +156 -0
- neuroseq/docs/developer/developer_installation.md +137 -0
- neuroseq/docs/developer/documentation.md +52 -0
- neuroseq/docs/developer/miscellaneous.md +5 -0
- neuroseq/docs/developer/project_history.md +37 -0
- neuroseq/docs/developer/releases.md +43 -0
- neuroseq/docs/developer/testing.md +168 -0
- neuroseq/docs/examples.md +64 -0
- neuroseq/docs/getting_started/installation.md +62 -0
- neuroseq/docs/getting_started/quickstart.md +11 -0
- neuroseq/docs/getting_started/windows_shortcut.md +112 -0
- neuroseq/docs/images/SutterPatch_TemplateEditor.png +0 -0
- neuroseq/docs/images/amplitude_selection.png +0 -0
- neuroseq/docs/images/button_appearance.png +0 -0
- neuroseq/docs/images/data_selection.png +0 -0
- neuroseq/docs/images/draw_mode.png +0 -0
- neuroseq/docs/images/favicon.svg +1 -0
- neuroseq/docs/images/hodgkin_huxley_neuron.png +0 -0
- neuroseq/docs/images/layout.png +0 -0
- neuroseq/docs/images/screenshot.png +0 -0
- neuroseq/docs/images/select_mode.png +0 -0
- neuroseq/docs/images/stimulus_intensity.png +0 -0
- neuroseq/docs/images/toggle_widgets.png +0 -0
- neuroseq/docs/index.md +47 -0
- neuroseq/docs/user_guide/analysis_and_plugins.md +69 -0
- neuroseq/docs/user_guide/brainscales2_delivery.md +260 -0
- neuroseq/docs/user_guide/chat_assistant.md +98 -0
- neuroseq/docs/user_guide/experiments.md +72 -0
- neuroseq/docs/user_guide/export_patch_clamp_stimuli.md +19 -0
- neuroseq/docs/user_guide/gui_overview.md +262 -0
- neuroseq/docs/user_guide/importing_igor2_waves.md +107 -0
- neuroseq/docs/user_guide/importing_imaging_data.md +207 -0
- neuroseq/docs/user_guide/multiphoton_holographic_stimulation.md +310 -0
- neuroseq/docs/user_guide/run_external_simulators.md +72 -0
- neuroseq/docs/user_guide/scanimage_acquisition.md +263 -0
- neuroseq/docs/user_guide/simulations.md +153 -0
- neuroseq/docs/user_guide/simulations_and_experiments.md +68 -0
- neuroseq/docs/user_guide/stdp_synaptic_tuning.md +178 -0
- neuroseq/elise_support/__init__.py +1 -0
- neuroseq/elise_support/protocol_dialog.py +92 -0
- neuroseq/elise_support/replay_metrics.py +298 -0
- neuroseq/elise_support/workflows.py +318 -0
- neuroseq/example_models/NN.py +70 -0
- neuroseq/example_models/__init__.py +0 -0
- neuroseq/example_models/elise.py +1603 -0
- neuroseq/example_models/pynn_demo.py +366 -0
- neuroseq/example_models/sinus_offset.py +209 -0
- neuroseq/example_projects/__init__.py +81 -0
- neuroseq/example_projects/_registry.py +36 -0
- neuroseq/example_projects/networks/__init__.py +1 -0
- neuroseq/example_projects/networks/default_project.py +178 -0
- neuroseq/example_projects/networks/elise_project.py +10 -0
- neuroseq/example_projects/networks/getting_started_project.py +48 -0
- neuroseq/example_projects/networks/hh_neuron_with_synapses.py +204 -0
- neuroseq/example_projects/networks/hh_neuron_with_synthetic_synapses.py +206 -0
- neuroseq/example_projects/networks/hh_neurons_ei_model.py +144 -0
- neuroseq/example_projects/networks/hh_neurons_with_plasticity.py +215 -0
- neuroseq/example_projects/networks/locally_connected_network.py +107 -0
- neuroseq/example_projects/networks/non_linear.py +58 -0
- neuroseq/example_projects/networks/pynn_demo_project.py +65 -0
- neuroseq/example_projects/networks/sinus_offset_project.py +57 -0
- neuroseq/example_projects/networks/two_hh_neurons_with_plasticity.py +239 -0
- neuroseq/example_projects/neurons/__init__.py +1 -0
- neuroseq/example_projects/neurons/pyrho_6state_chr2_project.py +154 -0
- neuroseq/example_projects/neurons/single_adex_neuron.py +81 -0
- neuroseq/example_projects/neurons/single_hh_neuron.py +137 -0
- neuroseq/example_projects/neurons/single_lif_neuron.py +63 -0
- neuroseq/example_projects/neurons/single_lif_neuron_many_stims.py +115 -0
- neuroseq/example_projects/neurons/single_lif_neuron_without_stim.py +44 -0
- neuroseq/export/__init__.py +1 -0
- neuroseq/export/exporter_igor2.py +408 -0
- neuroseq/export/exporter_syscon2.py +4076 -0
- neuroseq/export/igor2_pxp_stim_export_dialog.py +106 -0
- neuroseq/export/igor2_single_wave_export_dialog.py +179 -0
- neuroseq/export/power_lut.py +142 -0
- neuroseq/export/stimulation_generator_dialog.py +1284 -0
- neuroseq/export/stimulus_export_dialog.py +244 -0
- neuroseq/export/syscon2_export_service.py +332 -0
- neuroseq/external_tools.py +73 -0
- neuroseq/fiji.py +128 -0
- neuroseq/gpu.py +102 -0
- neuroseq/gui.py +341 -0
- neuroseq/gui_settings.py +277 -0
- neuroseq/icon_manager.py +108 -0
- neuroseq/importers/__init__.py +1 -0
- neuroseq/importers/cellpose_import_dialog.py +1770 -0
- neuroseq/importers/importer_cellpose.py +849 -0
- neuroseq/importers/importer_patch_clamp_data.py +801 -0
- neuroseq/importers/importer_scanimage.py +111 -0
- neuroseq/importers/importer_scanimage_traces.py +188 -0
- neuroseq/importers/importer_suite2p.py +210 -0
- neuroseq/importers/importer_syscon2.py +1239 -0
- neuroseq/importers/importer_tif.py +586 -0
- neuroseq/importers/patch_clamp_data_input_import_dialog.py +795 -0
- neuroseq/importers/patch_clamp_data_output_import_dialog.py +1076 -0
- neuroseq/importers/suite2p_import_dialog.py +606 -0
- neuroseq/importers/syscon2_import_dialog.py +495 -0
- neuroseq/importers/tif_import_dialog.py +961 -0
- neuroseq/io.py +542 -0
- neuroseq/llm/__init__.py +25 -0
- neuroseq/llm/anthropic_backend.py +73 -0
- neuroseq/llm/backend.py +76 -0
- neuroseq/llm/chat_widget.py +399 -0
- neuroseq/llm/llm_settings_dialog.py +234 -0
- neuroseq/llm/ollama.py +113 -0
- neuroseq/llm/openai_backend.py +79 -0
- neuroseq/llm/plugin_write_dialog.py +100 -0
- neuroseq/llm/read_access.py +339 -0
- neuroseq/llm/secrets.py +43 -0
- neuroseq/llm/system_prompt.py +85 -0
- neuroseq/main_scroll.py +187 -0
- neuroseq/model_descriptions.py +140 -0
- neuroseq/model_editor.py +1231 -0
- neuroseq/project.py +1706 -0
- neuroseq/project_history.py +166 -0
- neuroseq/project_workspace.py +3966 -0
- neuroseq/py.typed +0 -0
- neuroseq/qt_helpers.py +142 -0
- neuroseq/resources/__init__.py +0 -0
- neuroseq/resources/analysis_plugins/advanced_mean_activity.py +147 -0
- neuroseq/resources/analysis_plugins/elise_learning_curves.py +108 -0
- neuroseq/resources/analysis_plugins/elise_target_mse.py +189 -0
- neuroseq/resources/analysis_plugins/epsp_amplitudes.py +249 -0
- neuroseq/resources/analysis_plugins/mean_activity.py +69 -0
- neuroseq/resources/analysis_plugins/membrane_parameters.py +326 -0
- neuroseq/resources/analysis_plugins/replay_mse.py +176 -0
- neuroseq/resources/bss2/neuroseq_bss2_helper.py +532 -0
- neuroseq/resources/console_brain.png +0 -0
- neuroseq/resources/example_data/2p_imaging_FOV.png +0 -0
- neuroseq/resources/example_data/2p_imaging_FOV_seg.npy +0 -0
- neuroseq/resources/retro.qss +583 -0
- neuroseq/resources/retro_arrow_down.svg +3 -0
- neuroseq/resources/retro_arrow_down_disabled.svg +3 -0
- neuroseq/resources/retro_arrow_up.svg +3 -0
- neuroseq/resources/retro_arrow_up_disabled.svg +3 -0
- neuroseq/resources/retro_check.svg +6 -0
- neuroseq/resources/retro_check_disabled.svg +4 -0
- neuroseq/resources/retro_checkbox.svg +5 -0
- neuroseq/resources/retro_checkbox_disabled.svg +3 -0
- neuroseq/resources/retro_close.svg +3 -0
- neuroseq/resources/retro_indeterminate.svg +6 -0
- neuroseq/resources/retro_indeterminate_disabled.svg +4 -0
- neuroseq/resources/retro_radio.svg +5 -0
- neuroseq/resources/retro_radio_disabled.svg +4 -0
- neuroseq/resources/retro_radio_off.svg +4 -0
- neuroseq/resources/retro_radio_off_disabled.svg +3 -0
- neuroseq/resources/rheobase_chronaxie.svg +195 -0
- neuroseq/resources/scanimage/neuroseq_scanimage.m +212 -0
- neuroseq/resources/scanimage/neuroseq_scanimage_event.m +4 -0
- neuroseq/resources/startup_brain.png +0 -0
- neuroseq/resources/syscon_luts/__init__.py +0 -0
- neuroseq/resources/syscon_luts/default_1100nm.lut +16 -0
- neuroseq/resources/syscon_luts/default_940nm.lut +9 -0
- neuroseq/save_imaging_dialog.py +115 -0
- neuroseq/save_load_options.py +89 -0
- neuroseq/spike_detection.py +262 -0
- neuroseq/startup_dialog.py +157 -0
- neuroseq/stimulation/__init__.py +0 -0
- neuroseq/stimulation/dynamic_input/__init__.py +1 -0
- neuroseq/stimulation/dynamic_input/dialog_helpers.py +66 -0
- neuroseq/stimulation/dynamic_input/dynamic_input_blocks.py +84 -0
- neuroseq/stimulation/dynamic_input/dynamic_input_controller.py +164 -0
- neuroseq/stimulation/dynamic_input/dynamic_input_measurement.py +221 -0
- neuroseq/stimulation/dynamic_input/dynamic_input_routine.py +101 -0
- neuroseq/stimulation/dynamic_input/hardware_settings_widget.py +223 -0
- neuroseq/stimulation/dynamic_input/hardware_targets.py +106 -0
- neuroseq/stimulation/dynamic_input/rheobase.py +27 -0
- neuroseq/stimulation/dynamic_input/rheobase_dialog.py +2235 -0
- neuroseq/stimulation/dynamic_input/routine_overview_dialog.py +333 -0
- neuroseq/stimulation/dynamic_input/stdp_synaptic_tuning_dialog.py +1035 -0
- neuroseq/stimulation/dynamic_input/stdp_window.py +44 -0
- neuroseq/stimulation/dynamic_input/stdp_window_dialog.py +1764 -0
- neuroseq/stimulation/static_input/__init__.py +1 -0
- neuroseq/stimulation/static_input/static_input_controller.py +65 -0
- neuroseq/stimulation/static_input/stim_pattern_dialog.py +434 -0
- neuroseq/stimulation/static_input/stim_patterns.py +336 -0
- neuroseq/stimulation/stim.py +55 -0
- neuroseq/stimulation/stim_array_modifier.py +340 -0
- neuroseq/stimulation/stim_dialog.py +182 -0
- neuroseq/stimulation/stim_editor.py +1648 -0
- neuroseq/stimulation/stim_intensity_dialog.py +168 -0
- neuroseq/stimulation/stim_loop_dialog.py +66 -0
- neuroseq/stimulation/stim_timing.py +171 -0
- neuroseq/synthetic_data_dialog.py +985 -0
- neuroseq/theme.py +362 -0
- neuroseq/utils.py +581 -0
- neuroseq/visualization/__init__.py +1 -0
- neuroseq/visualization/calcium_video_widget.py +867 -0
- neuroseq/visualization/cell_overlay_widget.py +1655 -0
- neuroseq/visualization/custom_analysis_widget.py +362 -0
- neuroseq/visualization/data_widget.py +351 -0
- neuroseq/visualization/fpl_widget.py +287 -0
- neuroseq/visualization/heatmap_widget.py +341 -0
- neuroseq/visualization/spatial_footprint_widget.py +676 -0
- neuroseq/visualization/spike_widget.py +141 -0
- neuroseq/visualization/stim_widget.py +651 -0
- neuroseq/visualization/time_slider_widget.py +167 -0
- neuroseq/visualization/timeseries_widget.py +1071 -0
- neuroseq/visualization/trace_widget.py +827 -0
- neuroseq/visualization_support/__init__.py +1 -0
- neuroseq/visualization_support/brightness_contrast.py +282 -0
- neuroseq/visualization_support/cellpose_ids.py +49 -0
- neuroseq/visualization_support/constrained_pan_zoom_controller.py +388 -0
- neuroseq/visualization_support/gridlines.py +190 -0
- neuroseq/visualization_support/neuron_subset_dialog.py +74 -0
- neuroseq/visualization_support/region_of_interest.py +82 -0
- neuroseq/visualization_support/segmentation_brightness_dialog.py +84 -0
- neuroseq/visualization_support/spatial_footprint_state.py +241 -0
- neuroseq/visualization_support/trace_widget_state.py +69 -0
- neuroseq/workspace_acquisition_controller.py +708 -0
- neuroseq/workspace_devices.py +217 -0
- neuroseq-0.30.0.dist-info/METADATA +103 -0
- neuroseq-0.30.0.dist-info/RECORD +248 -0
- neuroseq-0.30.0.dist-info/WHEEL +4 -0
- neuroseq-0.30.0.dist-info/entry_points.txt +2 -0
- neuroseq-0.30.0.dist-info/licenses/LICENSE +21 -0
neuroseq/__init__.py
ADDED
neuroseq/__main__.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from types import FrameType
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from neuroseq.cellpose_test_data import save_cellpose_test_data
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _install_interrupt_handlers(
|
|
17
|
+
app: Any,
|
|
18
|
+
*,
|
|
19
|
+
timer_factory: Callable[[Any], Any] | None = None,
|
|
20
|
+
signal_module: Any = signal,
|
|
21
|
+
) -> Any | None:
|
|
22
|
+
"""Keep Ctrl-C responsive while the Qt event loop is running."""
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
if timer_factory is None:
|
|
27
|
+
from qtpy import QtCore
|
|
28
|
+
|
|
29
|
+
timer = QtCore.QTimer(app)
|
|
30
|
+
else:
|
|
31
|
+
timer = timer_factory(app)
|
|
32
|
+
|
|
33
|
+
def _quit_on_signal(signum: int, _frame: FrameType | None) -> None:
|
|
34
|
+
logging.getLogger(__name__).info(
|
|
35
|
+
"Received signal %s, quitting GUI event loop", signum
|
|
36
|
+
)
|
|
37
|
+
app.quit()
|
|
38
|
+
|
|
39
|
+
signal_module.signal(signal_module.SIGINT, _quit_on_signal)
|
|
40
|
+
signal_module.signal(signal_module.SIGTERM, _quit_on_signal)
|
|
41
|
+
timer.setInterval(200)
|
|
42
|
+
timer.timeout.connect(lambda: None)
|
|
43
|
+
timer.start()
|
|
44
|
+
return timer
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.command()
|
|
48
|
+
@click.option(
|
|
49
|
+
"--log-level",
|
|
50
|
+
default="INFO",
|
|
51
|
+
type=click.Choice(
|
|
52
|
+
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False
|
|
53
|
+
),
|
|
54
|
+
help="Log level",
|
|
55
|
+
show_default=True,
|
|
56
|
+
show_choices=True,
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--log-file",
|
|
60
|
+
default=None,
|
|
61
|
+
type=click.Path(path_type=pathlib.Path, dir_okay=False, writable=True),
|
|
62
|
+
help="Log to this file instead of the console",
|
|
63
|
+
show_default=True,
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--generate-cellpose-test-data",
|
|
67
|
+
"test_cellpose_image_path",
|
|
68
|
+
default=None,
|
|
69
|
+
type=click.Path(path_type=pathlib.Path, dir_okay=False, writable=True),
|
|
70
|
+
help=(
|
|
71
|
+
"Generate a synthetic Cellpose test TIFF at this path and matching "
|
|
72
|
+
"segmentation .npy, then exit."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
@click.option(
|
|
76
|
+
"--cellpose-segmentation-output",
|
|
77
|
+
"test_cellpose_segmentation_path",
|
|
78
|
+
default=None,
|
|
79
|
+
type=click.Path(path_type=pathlib.Path, dir_okay=False, writable=True),
|
|
80
|
+
help=(
|
|
81
|
+
"Optional output path for generated Cellpose segmentation .npy "
|
|
82
|
+
"(requires --generate-cellpose-test-data)."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
@click.option(
|
|
86
|
+
"--cellpose-grid-rows",
|
|
87
|
+
default=10,
|
|
88
|
+
type=click.IntRange(min=1),
|
|
89
|
+
show_default=True,
|
|
90
|
+
help="Number of target rows in generated Cellpose test data.",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--cellpose-grid-cols",
|
|
94
|
+
default=10,
|
|
95
|
+
type=click.IntRange(min=1),
|
|
96
|
+
show_default=True,
|
|
97
|
+
help="Number of target columns in generated Cellpose test data.",
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--cellpose-radius",
|
|
101
|
+
default=10,
|
|
102
|
+
type=click.IntRange(min=1),
|
|
103
|
+
show_default=True,
|
|
104
|
+
help="Circle radius in pixels for generated Cellpose test data.",
|
|
105
|
+
)
|
|
106
|
+
@click.option(
|
|
107
|
+
"--cellpose-gap",
|
|
108
|
+
default=10,
|
|
109
|
+
type=click.IntRange(min=0),
|
|
110
|
+
show_default=True,
|
|
111
|
+
help="Gap in pixels between adjacent circles in generated Cellpose test data.",
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--cellpose-square-size",
|
|
115
|
+
default=None,
|
|
116
|
+
type=click.IntRange(min=1),
|
|
117
|
+
help=(
|
|
118
|
+
"Optional square output size in pixels for generated Cellpose test data. "
|
|
119
|
+
"When set, radius and gap are auto-fitted while preserving rows/cols."
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--cellpose-preserve-radius-gap/--cellpose-auto-fit-radius-gap",
|
|
124
|
+
default=False,
|
|
125
|
+
show_default=True,
|
|
126
|
+
help=(
|
|
127
|
+
"When --cellpose-square-size is set, preserve the provided "
|
|
128
|
+
"--cellpose-radius and --cellpose-gap instead of auto-fitting them."
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
@click.option(
|
|
132
|
+
"--cellpose-preserve-radius-auto-gap",
|
|
133
|
+
is_flag=True,
|
|
134
|
+
default=False,
|
|
135
|
+
show_default=True,
|
|
136
|
+
help=(
|
|
137
|
+
"When --cellpose-square-size is set, preserve --cellpose-radius and "
|
|
138
|
+
"auto-fit only --cellpose-gap."
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
@click.argument("filename", required=False)
|
|
142
|
+
def main(
|
|
143
|
+
log_level: str,
|
|
144
|
+
log_file: pathlib.Path | None,
|
|
145
|
+
test_cellpose_image_path: pathlib.Path | None,
|
|
146
|
+
test_cellpose_segmentation_path: pathlib.Path | None,
|
|
147
|
+
cellpose_grid_rows: int,
|
|
148
|
+
cellpose_grid_cols: int,
|
|
149
|
+
cellpose_radius: int,
|
|
150
|
+
cellpose_gap: int,
|
|
151
|
+
cellpose_square_size: int | None,
|
|
152
|
+
cellpose_preserve_radius_gap: bool,
|
|
153
|
+
cellpose_preserve_radius_auto_gap: bool,
|
|
154
|
+
filename: str | None,
|
|
155
|
+
) -> None:
|
|
156
|
+
if test_cellpose_segmentation_path is not None and test_cellpose_image_path is None:
|
|
157
|
+
raise click.UsageError(
|
|
158
|
+
"--cellpose-segmentation-output requires --generate-cellpose-test-data"
|
|
159
|
+
)
|
|
160
|
+
if cellpose_preserve_radius_gap and cellpose_square_size is None:
|
|
161
|
+
raise click.UsageError(
|
|
162
|
+
"--cellpose-preserve-radius-gap requires --cellpose-square-size"
|
|
163
|
+
)
|
|
164
|
+
if cellpose_preserve_radius_auto_gap and cellpose_square_size is None:
|
|
165
|
+
raise click.UsageError(
|
|
166
|
+
"--cellpose-preserve-radius-auto-gap requires --cellpose-square-size"
|
|
167
|
+
)
|
|
168
|
+
if cellpose_preserve_radius_gap and cellpose_preserve_radius_auto_gap:
|
|
169
|
+
raise click.UsageError(
|
|
170
|
+
"--cellpose-preserve-radius-gap and --cellpose-preserve-radius-auto-gap "
|
|
171
|
+
"cannot be used together"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if test_cellpose_image_path is not None:
|
|
175
|
+
image_path, segmentation_path = save_cellpose_test_data(
|
|
176
|
+
test_cellpose_image_path,
|
|
177
|
+
segmentation_path=test_cellpose_segmentation_path,
|
|
178
|
+
rows=cellpose_grid_rows,
|
|
179
|
+
cols=cellpose_grid_cols,
|
|
180
|
+
radius=cellpose_radius,
|
|
181
|
+
gap=cellpose_gap,
|
|
182
|
+
square_size=cellpose_square_size,
|
|
183
|
+
preserve_radius_gap=cellpose_preserve_radius_gap,
|
|
184
|
+
preserve_radius_auto_gap=cellpose_preserve_radius_auto_gap,
|
|
185
|
+
)
|
|
186
|
+
click.echo(
|
|
187
|
+
"Generated Cellpose test data:\n"
|
|
188
|
+
f"- image: {image_path}\n"
|
|
189
|
+
f"- segmentation: {segmentation_path}"
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
from neuroseq.utils import init_brian2_logging
|
|
194
|
+
|
|
195
|
+
init_brian2_logging()
|
|
196
|
+
logging.basicConfig(
|
|
197
|
+
level=log_level,
|
|
198
|
+
format="%(asctime)s :: %(levelname)s :: %(name)s :: %(funcName)s :: %(message)s",
|
|
199
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
200
|
+
filename=log_file,
|
|
201
|
+
)
|
|
202
|
+
from qtpy import API_NAME
|
|
203
|
+
from qtpy import QtCore
|
|
204
|
+
from qtpy import QtGui
|
|
205
|
+
from qtpy import QtWidgets
|
|
206
|
+
|
|
207
|
+
from neuroseq.gui import Gui
|
|
208
|
+
from neuroseq.gui_settings import GuiSettings
|
|
209
|
+
from neuroseq.project import Project
|
|
210
|
+
from neuroseq.startup_dialog import StartupDialog
|
|
211
|
+
from neuroseq.theme import apply_app_theme
|
|
212
|
+
|
|
213
|
+
msg = f"Launching {API_NAME} GUI with {log_level} logging to {log_file if log_file else 'console'}"
|
|
214
|
+
click.echo(msg)
|
|
215
|
+
logging.getLogger(__name__).info(msg)
|
|
216
|
+
|
|
217
|
+
# Keep icon raster sizes on integer scale steps to reduce blur on HiDPI displays.
|
|
218
|
+
if hasattr(QtGui.QGuiApplication, "setHighDpiScaleFactorRoundingPolicy"):
|
|
219
|
+
QtGui.QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
|
220
|
+
QtCore.Qt.HighDpiScaleFactorRoundingPolicy.RoundPreferFloor
|
|
221
|
+
)
|
|
222
|
+
if hasattr(QtCore.Qt.ApplicationAttribute, "AA_UseHighDpiPixmaps"):
|
|
223
|
+
QtWidgets.QApplication.setAttribute(
|
|
224
|
+
QtCore.Qt.ApplicationAttribute.AA_UseHighDpiPixmaps,
|
|
225
|
+
True,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
app = QtWidgets.QApplication(sys.argv)
|
|
229
|
+
app._interrupt_timer = _install_interrupt_handlers(app)
|
|
230
|
+
|
|
231
|
+
initial_project: str | pathlib.Path | Project | None = filename
|
|
232
|
+
if initial_project is None:
|
|
233
|
+
settings = GuiSettings()
|
|
234
|
+
# Applied to the whole application (not just the eventual main
|
|
235
|
+
# window) so the startup dialog itself already matches the user's
|
|
236
|
+
# persisted theme, instead of appearing unthemed until a project
|
|
237
|
+
# is chosen.
|
|
238
|
+
apply_app_theme(app, settings.theme)
|
|
239
|
+
startup_dialog = StartupDialog(settings)
|
|
240
|
+
if not startup_dialog.exec():
|
|
241
|
+
logging.getLogger(__name__).info(
|
|
242
|
+
"Startup dialog dismissed without a choice, exiting"
|
|
243
|
+
)
|
|
244
|
+
return
|
|
245
|
+
initial_project = (
|
|
246
|
+
startup_dialog.chosen_filename or startup_dialog.chosen_project
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
gui = Gui(filename=initial_project)
|
|
250
|
+
gui.show()
|
|
251
|
+
logging.getLogger(__name__).info("GUI initialized, entering Qt event loop")
|
|
252
|
+
app.exec()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
main()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from qtpy import QtWidgets
|
|
4
|
+
|
|
5
|
+
from neuroseq.data import Data
|
|
6
|
+
from neuroseq.data import Experiment
|
|
7
|
+
from neuroseq.data import SpikeData
|
|
8
|
+
from neuroseq.data import SynapseData
|
|
9
|
+
from neuroseq.data import TraceData
|
|
10
|
+
from neuroseq.project import Project
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def describe_data(data: Data) -> str:
|
|
14
|
+
if isinstance(data, TraceData):
|
|
15
|
+
return f"Trace data '{data.name}': {data.traces.shape[0]} neurons, {data.traces.shape[1]} timestamps"
|
|
16
|
+
elif isinstance(data, SpikeData):
|
|
17
|
+
return f"Spike data '{data.name}': {len(data.spike_indices)} spikes"
|
|
18
|
+
elif isinstance(data, SynapseData):
|
|
19
|
+
return f"Synapse data '{data.name}': {data.values.shape[0]}x{data.values.shape[1]} synapses, {data.values.shape[2]} timestamps"
|
|
20
|
+
return ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def describe_experiment(experiment: Experiment) -> str:
|
|
24
|
+
description = f"Importing {experiment.duration}s of experimental data:\n\n"
|
|
25
|
+
for datum in experiment.data:
|
|
26
|
+
description += f" - {describe_data(datum)}\n"
|
|
27
|
+
return description
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AddExperimentDataDialog(QtWidgets.QDialog):
|
|
31
|
+
"""A dialog that allows the user to import data, either as a new Experiment or appended to an existing one."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
experiment: Experiment,
|
|
36
|
+
project: Project,
|
|
37
|
+
parent: QtWidgets.QWidget | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
super().__init__(parent)
|
|
40
|
+
self.project = project
|
|
41
|
+
self.existing_experiments = list(project.experiments.values())
|
|
42
|
+
|
|
43
|
+
self.experiment = experiment
|
|
44
|
+
self.append_to_experiment = False
|
|
45
|
+
self.append_experiment_name: str = ""
|
|
46
|
+
self.offset: float = 0.0
|
|
47
|
+
|
|
48
|
+
self.setWindowTitle("Import data")
|
|
49
|
+
self.setModal(True)
|
|
50
|
+
|
|
51
|
+
self.layout = QtWidgets.QVBoxLayout()
|
|
52
|
+
self.setLayout(self.layout)
|
|
53
|
+
|
|
54
|
+
self.lbl_description = QtWidgets.QLabel(describe_experiment(self.experiment))
|
|
55
|
+
self.layout.addWidget(self.lbl_description)
|
|
56
|
+
|
|
57
|
+
self.rad_new = QtWidgets.QRadioButton("Create new experiment")
|
|
58
|
+
self.layout.addWidget(self.rad_new)
|
|
59
|
+
self.rad_append = QtWidgets.QRadioButton("Apply to existing experiment")
|
|
60
|
+
self.layout.addWidget(self.rad_append)
|
|
61
|
+
self.rad_new.setChecked(True)
|
|
62
|
+
|
|
63
|
+
self.new_box = QtWidgets.QWidget()
|
|
64
|
+
layout_new_box = QtWidgets.QVBoxLayout(self.new_box)
|
|
65
|
+
layout_new_box.addWidget(QtWidgets.QLabel("Experiment name:"))
|
|
66
|
+
self.txt_new_name = QtWidgets.QLineEdit()
|
|
67
|
+
layout_new_box.addWidget(self.txt_new_name)
|
|
68
|
+
self.layout.addWidget(self.new_box)
|
|
69
|
+
self.txt_new_name.setText(self.experiment.name)
|
|
70
|
+
|
|
71
|
+
self.append_box = QtWidgets.QWidget()
|
|
72
|
+
layout_append_box = QtWidgets.QVBoxLayout(self.append_box)
|
|
73
|
+
layout_append_box.addWidget(QtWidgets.QLabel("Existing experiment:"))
|
|
74
|
+
self.cmb_experiments = QtWidgets.QComboBox()
|
|
75
|
+
self.cmb_experiments.addItems(
|
|
76
|
+
[experiment.name for experiment in self.existing_experiments]
|
|
77
|
+
)
|
|
78
|
+
layout_append_box.addWidget(self.cmb_experiments)
|
|
79
|
+
layout_append_box.addWidget(QtWidgets.QLabel("Timepoint to insert data:"))
|
|
80
|
+
self.cmb_offset = QtWidgets.QComboBox()
|
|
81
|
+
layout_append_box.addWidget(self.cmb_offset)
|
|
82
|
+
self.layout.addWidget(self.append_box)
|
|
83
|
+
self.append_box.setVisible(False)
|
|
84
|
+
|
|
85
|
+
self.lbl_error = QtWidgets.QLabel("")
|
|
86
|
+
self.lbl_error.setStyleSheet("color: red")
|
|
87
|
+
self.layout.addWidget(self.lbl_error)
|
|
88
|
+
|
|
89
|
+
self.layout.addStretch()
|
|
90
|
+
|
|
91
|
+
button_layout = QtWidgets.QHBoxLayout()
|
|
92
|
+
self.save_button = QtWidgets.QPushButton("Import")
|
|
93
|
+
self.cancel_button = QtWidgets.QPushButton("Cancel")
|
|
94
|
+
button_layout.addWidget(self.save_button)
|
|
95
|
+
button_layout.addWidget(self.cancel_button)
|
|
96
|
+
self.layout.addLayout(button_layout)
|
|
97
|
+
|
|
98
|
+
if not self.existing_experiments:
|
|
99
|
+
self.rad_append.setEnabled(False)
|
|
100
|
+
|
|
101
|
+
self.rad_new.toggled.connect(self.rad_new_append_toggled)
|
|
102
|
+
self.txt_new_name.textEdited.connect(self.new_name_text_edited)
|
|
103
|
+
self.cmb_experiments.currentIndexChanged.connect(
|
|
104
|
+
self.cmb_experiments_index_changed
|
|
105
|
+
)
|
|
106
|
+
self.cmb_offset.currentIndexChanged.connect(self.cmb_offset_index_changed)
|
|
107
|
+
self.save_button.clicked.connect(self.accept)
|
|
108
|
+
self.cancel_button.clicked.connect(self.reject)
|
|
109
|
+
self._apply_default_selection()
|
|
110
|
+
self.resize(800, 600)
|
|
111
|
+
|
|
112
|
+
def _apply_default_selection(self) -> None:
|
|
113
|
+
if not self.existing_experiments:
|
|
114
|
+
self.rad_new.setChecked(True)
|
|
115
|
+
self.cmb_experiments_index_changed(0)
|
|
116
|
+
self.new_name_text_edited(self.txt_new_name.text())
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
matching_index = next(
|
|
120
|
+
(
|
|
121
|
+
i
|
|
122
|
+
for i, experiment in enumerate(self.existing_experiments)
|
|
123
|
+
if experiment.name == self.experiment.name
|
|
124
|
+
),
|
|
125
|
+
-1,
|
|
126
|
+
)
|
|
127
|
+
if matching_index >= 0:
|
|
128
|
+
self.rad_append.setChecked(True)
|
|
129
|
+
self.cmb_experiments.setCurrentIndex(matching_index)
|
|
130
|
+
self.cmb_experiments_index_changed(matching_index)
|
|
131
|
+
else:
|
|
132
|
+
self.rad_new.setChecked(True)
|
|
133
|
+
self.cmb_experiments_index_changed(0)
|
|
134
|
+
self.new_name_text_edited(self.txt_new_name.text())
|
|
135
|
+
|
|
136
|
+
def rad_new_append_toggled(self):
|
|
137
|
+
self.append_to_experiment = self.rad_append.isChecked()
|
|
138
|
+
self.new_box.setVisible(not self.append_to_experiment)
|
|
139
|
+
self.txt_new_name.setEnabled(not self.append_to_experiment)
|
|
140
|
+
self.append_box.setVisible(self.append_to_experiment)
|
|
141
|
+
self.cmb_experiments.setEnabled(self.append_to_experiment)
|
|
142
|
+
self.cmb_offset.setEnabled(self.append_to_experiment)
|
|
143
|
+
if self.append_to_experiment:
|
|
144
|
+
self.cmb_experiments_index_changed(self.cmb_experiments.currentIndex())
|
|
145
|
+
self._refresh_append_error()
|
|
146
|
+
else:
|
|
147
|
+
self.new_name_text_edited(self.txt_new_name.text())
|
|
148
|
+
|
|
149
|
+
def cmb_offset_index_changed(self, index: int) -> None:
|
|
150
|
+
if index < 0:
|
|
151
|
+
self.offset = 0.0
|
|
152
|
+
return
|
|
153
|
+
experiment_index = self.cmb_experiments.currentIndex()
|
|
154
|
+
if experiment_index < 0 or experiment_index >= len(self.existing_experiments):
|
|
155
|
+
self.offset = 0.0
|
|
156
|
+
return
|
|
157
|
+
self.offset = self.existing_experiments[experiment_index].timepoints[index]
|
|
158
|
+
self._refresh_append_error()
|
|
159
|
+
|
|
160
|
+
def cmb_experiments_index_changed(self, index: int) -> None:
|
|
161
|
+
self.cmb_offset.clear()
|
|
162
|
+
if index < 0 or index >= len(self.existing_experiments):
|
|
163
|
+
return
|
|
164
|
+
self.append_experiment_name = self.existing_experiments[index].name
|
|
165
|
+
timepoints = list(self.existing_experiments[index].timepoints)
|
|
166
|
+
option_labels = []
|
|
167
|
+
for i, timepoint in enumerate(timepoints):
|
|
168
|
+
label = f"{timepoint * 1000:.5g} ms"
|
|
169
|
+
if i == len(timepoints) - 1:
|
|
170
|
+
label = f"{label} (Append)"
|
|
171
|
+
else:
|
|
172
|
+
label = f"{label} ({self._insert_mode_label(index, timepoint)})"
|
|
173
|
+
option_labels.append(label)
|
|
174
|
+
self.cmb_offset.addItems(option_labels)
|
|
175
|
+
# if there is only start/end, default to start; otherwise default to append
|
|
176
|
+
if self.cmb_offset.count() > 0:
|
|
177
|
+
default_offset_index = 0 if len(timepoints) <= 2 else len(timepoints) - 1
|
|
178
|
+
self.cmb_offset.setCurrentIndex(default_offset_index)
|
|
179
|
+
# check if appending this data to the selected experiment is valid
|
|
180
|
+
self._refresh_append_error()
|
|
181
|
+
|
|
182
|
+
def new_name_text_edited(self, text: str) -> None:
|
|
183
|
+
self.experiment.name = text
|
|
184
|
+
self.lbl_error.clear()
|
|
185
|
+
self.save_button.setEnabled(True)
|
|
186
|
+
|
|
187
|
+
def _insert_mode_label(self, experiment_index: int, offset: float) -> str:
|
|
188
|
+
if experiment_index < 0 or experiment_index >= len(self.existing_experiments):
|
|
189
|
+
return "partially replace + insert"
|
|
190
|
+
existing_experiment = self.existing_experiments[experiment_index]
|
|
191
|
+
if offset >= existing_experiment.duration:
|
|
192
|
+
return "Append"
|
|
193
|
+
|
|
194
|
+
has_overlap = False
|
|
195
|
+
has_new_only = False
|
|
196
|
+
|
|
197
|
+
for new_datum in self.experiment.data:
|
|
198
|
+
existing_datum = next(
|
|
199
|
+
(
|
|
200
|
+
datum
|
|
201
|
+
for datum in existing_experiment.data
|
|
202
|
+
if datum.name == new_datum.name and type(datum) is type(new_datum)
|
|
203
|
+
),
|
|
204
|
+
None,
|
|
205
|
+
)
|
|
206
|
+
if existing_datum is None:
|
|
207
|
+
continue
|
|
208
|
+
if isinstance(existing_datum, TraceData) and isinstance(
|
|
209
|
+
new_datum, TraceData
|
|
210
|
+
):
|
|
211
|
+
existing_ids = set(map(int, existing_datum.indices))
|
|
212
|
+
new_ids = set(map(int, new_datum.indices))
|
|
213
|
+
elif isinstance(existing_datum, SpikeData) and isinstance(
|
|
214
|
+
new_datum, SpikeData
|
|
215
|
+
):
|
|
216
|
+
existing_ids = set(map(int, existing_datum.spike_indices))
|
|
217
|
+
new_ids = set(map(int, new_datum.spike_indices))
|
|
218
|
+
elif isinstance(existing_datum, SynapseData) and isinstance(
|
|
219
|
+
new_datum, SynapseData
|
|
220
|
+
):
|
|
221
|
+
return "overwrite"
|
|
222
|
+
else:
|
|
223
|
+
continue
|
|
224
|
+
if existing_ids & new_ids:
|
|
225
|
+
has_overlap = True
|
|
226
|
+
if new_ids - existing_ids:
|
|
227
|
+
has_new_only = True
|
|
228
|
+
|
|
229
|
+
if has_overlap and has_new_only:
|
|
230
|
+
return "partially replace + insert"
|
|
231
|
+
if has_overlap:
|
|
232
|
+
return "overwrite"
|
|
233
|
+
return "insert alongside existing data"
|
|
234
|
+
|
|
235
|
+
def _refresh_append_error(self) -> None:
|
|
236
|
+
error = self.project.issues_with_appending_experimental_data(
|
|
237
|
+
self.append_experiment_name,
|
|
238
|
+
self.experiment.data,
|
|
239
|
+
)
|
|
240
|
+
self.lbl_error.setText(error)
|
|
241
|
+
self.save_button.setEnabled(error == "")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
|
|
12
|
+
from qtpy import QtCore
|
|
13
|
+
|
|
14
|
+
from neuroseq.data import SpikeData
|
|
15
|
+
from neuroseq.data import SynapseData
|
|
16
|
+
from neuroseq.data import TraceData
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AnalysisPlugin:
|
|
21
|
+
id: str
|
|
22
|
+
label: str
|
|
23
|
+
icon: str
|
|
24
|
+
render: Callable[
|
|
25
|
+
[object, TraceData | SpikeData | SynapseData, slice | None, slice | None], None
|
|
26
|
+
]
|
|
27
|
+
open_dialog: (
|
|
28
|
+
Callable[
|
|
29
|
+
[
|
|
30
|
+
object,
|
|
31
|
+
object,
|
|
32
|
+
TraceData | SpikeData | SynapseData,
|
|
33
|
+
slice | None,
|
|
34
|
+
slice | None,
|
|
35
|
+
],
|
|
36
|
+
None,
|
|
37
|
+
]
|
|
38
|
+
| None
|
|
39
|
+
) = None
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return self.label
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#: Environment variable overriding the per-user plugin folder (used by tests).
|
|
46
|
+
USER_PLUGIN_DIR_ENV = "NEUROSEQ_USER_PLUGIN_DIR"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _plugins_directory() -> Path:
|
|
50
|
+
"""Built-in plugins shipped inside the package."""
|
|
51
|
+
return Path(__file__).resolve().parent / "resources" / "analysis_plugins"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def user_plugins_directory(create: bool = False) -> Path:
|
|
55
|
+
"""Per-user plugin folder (outside the package), e.g. for chat-authored plugins.
|
|
56
|
+
|
|
57
|
+
``NEUROSEQ_USER_PLUGIN_DIR`` overrides the location. Otherwise the folder
|
|
58
|
+
lives under the platform's user data location (``.../neuroseq/analysis_plugins``).
|
|
59
|
+
"""
|
|
60
|
+
override = os.environ.get(USER_PLUGIN_DIR_ENV)
|
|
61
|
+
if override:
|
|
62
|
+
directory = Path(override).expanduser()
|
|
63
|
+
else:
|
|
64
|
+
base = QtCore.QStandardPaths.writableLocation(
|
|
65
|
+
QtCore.QStandardPaths.StandardLocation.GenericDataLocation
|
|
66
|
+
)
|
|
67
|
+
root = Path(base) / "neuroseq" if base else Path.home() / ".neuroseq"
|
|
68
|
+
directory = root / "analysis_plugins"
|
|
69
|
+
if create:
|
|
70
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
return directory
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def builtin_plugin_names() -> set[str]:
|
|
75
|
+
"""Module stems in the built-in plugin folder, including helper modules."""
|
|
76
|
+
plugins_dir = _plugins_directory()
|
|
77
|
+
if not plugins_dir.is_dir():
|
|
78
|
+
return set()
|
|
79
|
+
return {path.stem for path in plugins_dir.glob("*.py")}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_plugin_module(
|
|
83
|
+
plugin_file: Path, namespace: str = "neuroseq.analysis_plugin"
|
|
84
|
+
) -> ModuleType | None:
|
|
85
|
+
logger = logging.getLogger(__name__)
|
|
86
|
+
spec = importlib.util.spec_from_file_location(
|
|
87
|
+
f"{namespace}.{plugin_file.stem}",
|
|
88
|
+
plugin_file,
|
|
89
|
+
)
|
|
90
|
+
if spec is None or spec.loader is None:
|
|
91
|
+
logger.warning(
|
|
92
|
+
f"Could not create module spec for analysis plugin: {plugin_file}"
|
|
93
|
+
)
|
|
94
|
+
return None
|
|
95
|
+
module = importlib.util.module_from_spec(spec)
|
|
96
|
+
# Match normal import semantics. Decorators such as ``@dataclass`` resolve
|
|
97
|
+
# annotations through ``sys.modules`` while the module is executing.
|
|
98
|
+
sys.modules[spec.name] = module
|
|
99
|
+
try:
|
|
100
|
+
spec.loader.exec_module(module)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
sys.modules.pop(spec.name, None)
|
|
103
|
+
logger.exception(f"Failed to load analysis plugin {plugin_file}: {exc}")
|
|
104
|
+
return None
|
|
105
|
+
return module
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _plugin_files() -> list[tuple[Path, str]]:
|
|
109
|
+
"""Built-in plugin files followed by non-shadowing user plugin files."""
|
|
110
|
+
logger = logging.getLogger(__name__)
|
|
111
|
+
files: list[tuple[Path, str]] = []
|
|
112
|
+
plugins_dir = _plugins_directory()
|
|
113
|
+
if plugins_dir.is_dir():
|
|
114
|
+
files.extend(
|
|
115
|
+
(path, "neuroseq.analysis_plugin")
|
|
116
|
+
for path in sorted(plugins_dir.glob("*.py"))
|
|
117
|
+
if not path.stem.startswith("_")
|
|
118
|
+
)
|
|
119
|
+
builtin_names = builtin_plugin_names()
|
|
120
|
+
user_dir = user_plugins_directory()
|
|
121
|
+
if user_dir.is_dir() and user_dir.resolve() != plugins_dir.resolve():
|
|
122
|
+
for path in sorted(user_dir.glob("*.py")):
|
|
123
|
+
if path.stem.startswith("_"):
|
|
124
|
+
continue
|
|
125
|
+
if path.stem in builtin_names:
|
|
126
|
+
logger.warning(
|
|
127
|
+
f"Skipping user analysis plugin {path}: name collides with a "
|
|
128
|
+
f"built-in plugin"
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
files.append((path, "neuroseq.user_analysis_plugin"))
|
|
132
|
+
return files
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def discover_analysis_plugins() -> list[AnalysisPlugin]:
|
|
136
|
+
logger = logging.getLogger(__name__)
|
|
137
|
+
plugins: list[AnalysisPlugin] = []
|
|
138
|
+
builtin_ids: set[str] = set()
|
|
139
|
+
|
|
140
|
+
for plugin_file, namespace in _plugin_files():
|
|
141
|
+
is_user = namespace != "neuroseq.analysis_plugin"
|
|
142
|
+
module = _load_plugin_module(plugin_file, namespace)
|
|
143
|
+
if module is None:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
render = getattr(module, "render_analysis", None)
|
|
147
|
+
if not callable(render):
|
|
148
|
+
logger.warning(
|
|
149
|
+
f"Skipping analysis plugin {plugin_file}: missing callable render_analysis"
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
plugin_id = str(getattr(module, "PLUGIN_ID", plugin_file.stem))
|
|
154
|
+
if is_user and plugin_id in builtin_ids:
|
|
155
|
+
logger.warning(
|
|
156
|
+
f"Skipping user analysis plugin {plugin_file}: PLUGIN_ID "
|
|
157
|
+
f"{plugin_id!r} collides with a built-in plugin"
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
160
|
+
if not is_user:
|
|
161
|
+
builtin_ids.add(plugin_id)
|
|
162
|
+
plugin_label = getattr(module, "PLUGIN_NAME", plugin_file.stem)
|
|
163
|
+
plugin_icon = getattr(module, "PLUGIN_ICON", "mdi6.puzzle")
|
|
164
|
+
open_dialog = getattr(module, "open_analysis_dialog", None)
|
|
165
|
+
if open_dialog is not None and not callable(open_dialog):
|
|
166
|
+
logger.warning(
|
|
167
|
+
f"Ignoring non-callable open_analysis_dialog in analysis plugin "
|
|
168
|
+
f"{plugin_file}"
|
|
169
|
+
)
|
|
170
|
+
open_dialog = None
|
|
171
|
+
|
|
172
|
+
plugins.append(
|
|
173
|
+
AnalysisPlugin(
|
|
174
|
+
id=plugin_id,
|
|
175
|
+
label=str(plugin_label),
|
|
176
|
+
icon=str(plugin_icon),
|
|
177
|
+
render=render,
|
|
178
|
+
open_dialog=open_dialog,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return plugins
|