flimkit 0.12.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.
- flimkit/FLIM/__init__.py +0 -0
- flimkit/FLIM/assemble.py +254 -0
- flimkit/FLIM/batch.py +681 -0
- flimkit/FLIM/bg_tools.py +51 -0
- flimkit/FLIM/fit_tools.py +244 -0
- flimkit/FLIM/fitters.py +1471 -0
- flimkit/FLIM/irf_tools.py +617 -0
- flimkit/FLIM/models.py +391 -0
- flimkit/GPU/__init__.py +85 -0
- flimkit/GPU/_base.py +391 -0
- flimkit/GPU/cuda.py +10 -0
- flimkit/GPU/mlx_backend.py +381 -0
- flimkit/GPU/mps.py +10 -0
- flimkit/GPU/rocm.py +10 -0
- flimkit/GPU/torch_backend.py +385 -0
- flimkit/UI/app_state.py +10 -0
- flimkit/UI/controller.py +139 -0
- flimkit/UI/expert_settings.py +248 -0
- flimkit/UI/fit_help.py +206 -0
- flimkit/UI/fov_preview.py +1085 -0
- flimkit/UI/gui.py +3919 -0
- flimkit/UI/icon.icns +0 -0
- flimkit/UI/icon.ico +0 -0
- flimkit/UI/icon.png +0 -0
- flimkit/UI/irf_widget.py +103 -0
- flimkit/UI/mode_controller.py +118 -0
- flimkit/UI/modes/__init__.py +0 -0
- flimkit/UI/modes/base.py +3 -0
- flimkit/UI/modes/batch_mode.py +312 -0
- flimkit/UI/modes/fov_mode.py +164 -0
- flimkit/UI/modes/irf_mode.py +80 -0
- flimkit/UI/modes/phasor_mode.py +131 -0
- flimkit/UI/modes/stitch_mode.py +254 -0
- flimkit/UI/phasor_panel.py +1087 -0
- flimkit/UI/progress_window.py +113 -0
- flimkit/UI/project_panel.py +262 -0
- flimkit/UI/results_panel.py +332 -0
- flimkit/UI/roi_tools.py +794 -0
- flimkit/UI/utils.py +217 -0
- flimkit/__init__.py +0 -0
- flimkit/_version.py +41 -0
- flimkit/cli.py +120 -0
- flimkit/configs.py +148 -0
- flimkit/dialogs.py +46 -0
- flimkit/formats/BH/__init__.py +0 -0
- flimkit/formats/BH/reader.py +296 -0
- flimkit/formats/BH/writer.py +86 -0
- flimkit/formats/ISS/__init__.py +0 -0
- flimkit/formats/ISS/fdflim.py +86 -0
- flimkit/formats/ISS/image.py +114 -0
- flimkit/formats/ISS/reader.py +223 -0
- flimkit/formats/PS/__init__.py +0 -0
- flimkit/formats/PS/reader.py +202 -0
- flimkit/formats/PTU/__init__.py +0 -0
- flimkit/formats/PTU/decode.py +27 -0
- flimkit/formats/PTU/phu.py +85 -0
- flimkit/formats/PTU/reader.py +235 -0
- flimkit/formats/PTU/series.py +258 -0
- flimkit/formats/PTU/stitch.py +1182 -0
- flimkit/formats/PTU/tools.py +94 -0
- flimkit/formats/__init__.py +2 -0
- flimkit/formats/flim_file.py +232 -0
- flimkit/formats/phasor.py +132 -0
- flimkit/formats/signal.py +170 -0
- flimkit/image/tools.py +124 -0
- flimkit/interactive.py +1857 -0
- flimkit/mpl_backend.py +22 -0
- flimkit/phasor/__init__.py +40 -0
- flimkit/phasor/filters.py +127 -0
- flimkit/phasor/fret.py +654 -0
- flimkit/phasor/interactive.py +556 -0
- flimkit/phasor/peaks.py +186 -0
- flimkit/phasor/signal.py +90 -0
- flimkit/phasor_launcher.py +314 -0
- flimkit/plugins/__init__.py +137 -0
- flimkit/plugins/bindings.py +116 -0
- flimkit/plugins/builtin/__init__.py +3 -0
- flimkit/plugins/builtin/core_tools.py +28 -0
- flimkit/plugins/loader.py +371 -0
- flimkit/plugins/registry.py +406 -0
- flimkit/project.py +197 -0
- flimkit/synth.py +145 -0
- flimkit/utils/__init__.py +0 -0
- flimkit/utils/batch_fit.py +301 -0
- flimkit/utils/config_manager.py +119 -0
- flimkit/utils/config_snapshot.py +30 -0
- flimkit/utils/crash_handler.py +183 -0
- flimkit/utils/display.py +197 -0
- flimkit/utils/enhanced_outputs.py +345 -0
- flimkit/utils/fancy.py +103 -0
- flimkit/utils/lifetime_image.py +243 -0
- flimkit/utils/misc.py +111 -0
- flimkit/utils/plotting.py +190 -0
- flimkit/utils/roi.py +370 -0
- flimkit/utils/session.py +51 -0
- flimkit/utils/update_check.py +198 -0
- flimkit/utils/xlsx_tools.py +97 -0
- flimkit/utils/xml_utils.py +219 -0
- flimkit-0.12.0.dist-info/METADATA +356 -0
- flimkit-0.12.0.dist-info/RECORD +104 -0
- flimkit-0.12.0.dist-info/WHEEL +5 -0
- flimkit-0.12.0.dist-info/entry_points.txt +2 -0
- flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
- flimkit-0.12.0.dist-info/top_level.txt +1 -0
flimkit/utils/misc.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
def setup_loggers(log_dir: str = '.', log_prefix: str = 'run'):
|
|
5
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
6
|
+
loggers = {}
|
|
7
|
+
|
|
8
|
+
# Main run logger
|
|
9
|
+
run_logger = logging.getLogger('run')
|
|
10
|
+
run_logger.setLevel(logging.INFO)
|
|
11
|
+
run_fh = logging.FileHandler(os.path.join(log_dir, f'{log_prefix}.log'))
|
|
12
|
+
run_fh.setLevel(logging.INFO)
|
|
13
|
+
run_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
|
14
|
+
run_fh.setFormatter(run_formatter)
|
|
15
|
+
run_logger.handlers = [run_fh]
|
|
16
|
+
loggers['run'] = run_logger
|
|
17
|
+
|
|
18
|
+
# Error logger
|
|
19
|
+
error_logger = logging.getLogger('error')
|
|
20
|
+
error_logger.setLevel(logging.ERROR)
|
|
21
|
+
error_fh = logging.FileHandler(os.path.join(log_dir, 'error.log'))
|
|
22
|
+
error_fh.setLevel(logging.ERROR)
|
|
23
|
+
error_fh.setFormatter(run_formatter)
|
|
24
|
+
error_logger.handlers = [error_fh]
|
|
25
|
+
loggers['error'] = error_logger
|
|
26
|
+
|
|
27
|
+
# Warning logger
|
|
28
|
+
warning_logger = logging.getLogger('warning')
|
|
29
|
+
warning_logger.setLevel(logging.WARNING)
|
|
30
|
+
warning_fh = logging.FileHandler(os.path.join(log_dir, 'warning.log'))
|
|
31
|
+
warning_fh.setLevel(logging.WARNING)
|
|
32
|
+
warning_fh.setFormatter(run_formatter)
|
|
33
|
+
warning_logger.handlers = [warning_fh]
|
|
34
|
+
loggers['warning'] = warning_logger
|
|
35
|
+
|
|
36
|
+
return loggers
|
|
37
|
+
|
|
38
|
+
def print_summary(summary: dict, strategy: str, n_exp: int):
|
|
39
|
+
s = summary
|
|
40
|
+
tcspc_res = s['tcspc_res']
|
|
41
|
+
is_dist = 'tau_centers_ns' in s
|
|
42
|
+
is_tail = s.get('fit_model') == 'tail'
|
|
43
|
+
|
|
44
|
+
print(f"\n{'─'*60}")
|
|
45
|
+
if is_tail:
|
|
46
|
+
print(f" Fit: {n_exp}-exp tail (no reconvolution)")
|
|
47
|
+
print(f"{'─'*60}")
|
|
48
|
+
for i, (tau, amp, frac, inten, ifrac) in enumerate(
|
|
49
|
+
zip(s['taus_ns'], s['amps'], s['fractions'],
|
|
50
|
+
s['intensities'], s['intensity_fractions'])):
|
|
51
|
+
print(f" τ{i+1} = {tau:8.4f} ns A{i+1} = {amp:.3e} "
|
|
52
|
+
f"f{i+1} = {frac:.4f} I{i+1} = {inten:.3e} ({ifrac:.4f})")
|
|
53
|
+
print(f" A_sum = {s['a_sum']:.3e} I_sum = {s['i_sum']:.3e} cts")
|
|
54
|
+
elif is_dist:
|
|
55
|
+
width_label = 'σ' if s['dist_type'] == 'gaussian' else 'Γ'
|
|
56
|
+
print(f" Fit: {s['n_components']}-component {s['dist_type']} dist | IRF: {strategy}")
|
|
57
|
+
print(f"{'─'*60}")
|
|
58
|
+
for i, (tau_c, w, fwhm, amp, frac) in enumerate(
|
|
59
|
+
zip(s['tau_centers_ns'], s['widths_ns'], s['fwhms_ns'],
|
|
60
|
+
s['amps'], s['fractions'])):
|
|
61
|
+
print(f" τ_c{i+1} = {tau_c:7.4f} ns {width_label}{i+1} = {w:.4f} ns "
|
|
62
|
+
f"FWHM = {fwhm:.4f} ns f{i+1} = {frac:.4f}")
|
|
63
|
+
else:
|
|
64
|
+
print(f" Fit: {n_exp}-exp | IRF: {strategy}")
|
|
65
|
+
print(f"{'─'*60}")
|
|
66
|
+
for i, (tau, amp, frac) in enumerate(
|
|
67
|
+
zip(s['taus_ns'], s['amps'], s['fractions'])):
|
|
68
|
+
print(f" τ{i+1} = {tau:8.4f} ns α{i+1} = {amp:.3e} f{i+1} = {frac:.4f}")
|
|
69
|
+
|
|
70
|
+
print(f" τ_mean (amplitude-weighted) = {s['tau_mean_amp_ns']:.4f} ns")
|
|
71
|
+
print(f" τ_mean (intensity-weighted) = {s['tau_mean_int_ns']:.4f} ns")
|
|
72
|
+
print(f" bg (fitted, Tail Offset) = {s['bg_fit']:.2f} cts/bin")
|
|
73
|
+
if is_tail:
|
|
74
|
+
print(f" t0 (lifetime offset) = {s['t0_ns']:.4f} ns")
|
|
75
|
+
print(f" Fit window = {s['fit_window_ns'][0]:.2f}-"
|
|
76
|
+
f"{s['fit_window_ns'][1]:.2f} ns")
|
|
77
|
+
else:
|
|
78
|
+
print(f" IRF shift = {s['irf_shift_bins']:.3f} bins "
|
|
79
|
+
f"({s['irf_shift_bins'] * tcspc_res * 1e12:.1f} ps)")
|
|
80
|
+
print(f" IRF σ (prompt broadening) = {s['irf_sigma_bins']:.3f} bins")
|
|
81
|
+
print(f" IRF FWHM (effective) = {s['irf_fwhm_eff_ns']:.4f} ns")
|
|
82
|
+
if not is_dist and not is_tail and s.get('tail_amp', 0) > 0:
|
|
83
|
+
print(f" IRF tail amp = {s['tail_amp']:.4f}")
|
|
84
|
+
print(f" IRF tail τ = {s['tail_tau_ns']:.3f} ns")
|
|
85
|
+
if s['tail_tau_ns'] > 18:
|
|
86
|
+
print(f" tail τ near upper bound - consider acquiring a scatter PTU")
|
|
87
|
+
p_val = s.get('p_val')
|
|
88
|
+
p_str = f", p={p_val:.4f}" if p_val is not None else ""
|
|
89
|
+
print(f" χ²_r = {s['reduced_chi2']:.4f} "
|
|
90
|
+
f"(χ²={s['chi2']:.1f}, DoF={s['dof']}{p_str}) [full window, Neyman]")
|
|
91
|
+
print(f" χ²_r = {s['reduced_chi2_pearson']:.4f} "
|
|
92
|
+
f"[full window, Pearson]")
|
|
93
|
+
if not is_tail:
|
|
94
|
+
print(f" χ²_r = {s['reduced_chi2_tail']:.4f} "
|
|
95
|
+
f"(tail only, t>{s['tail_start_bin']*tcspc_res*1e9:.2f} ns) [Neyman]")
|
|
96
|
+
print(f" χ²_r = {s['reduced_chi2_tail_pearson']:.4f} "
|
|
97
|
+
f"(tail only, t>{s['tail_start_bin']*tcspc_res*1e9:.2f} ns) [Pearson]")
|
|
98
|
+
if p_val is not None:
|
|
99
|
+
flag = '' if 0.001 < p_val < 0.999 else ''
|
|
100
|
+
print(f" {flag} Optimizer: {s['optimizer_msg']}")
|
|
101
|
+
else:
|
|
102
|
+
print(f" Optimizer: {s['optimizer_msg']}")
|
|
103
|
+
|
|
104
|
+
def check_full_path(path):
|
|
105
|
+
if os.path.isabs(path) == True:
|
|
106
|
+
return path
|
|
107
|
+
else:
|
|
108
|
+
if os.path.isabs(os.getcwd() + '/' + path) == True:
|
|
109
|
+
return os.getcwd() + '/' + path
|
|
110
|
+
else:
|
|
111
|
+
raise Exception('Path not found')
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import matplotlib.gridspec as gridspec
|
|
5
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
6
|
+
from flimkit.configs import FLIM_CMAP
|
|
7
|
+
|
|
8
|
+
# Use non-interactive 'Agg' backend for thread-safe file saving
|
|
9
|
+
# This prevents segfaults when matplotlib operations happen in worker threads
|
|
10
|
+
try:
|
|
11
|
+
matplotlib.use('Agg', force=True)
|
|
12
|
+
except Exception:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# rcParams for file exports - overrides any dark-theme globals set by the GUI
|
|
17
|
+
_EXPORT_RC = {
|
|
18
|
+
'text.color': 'black',
|
|
19
|
+
'axes.labelcolor': 'black',
|
|
20
|
+
'xtick.color': 'black',
|
|
21
|
+
'ytick.color': 'black',
|
|
22
|
+
'axes.titlecolor': 'black',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def plot_summed(decay, summary, ptu, xlsx, n_exp, strategy, out_prefix,
|
|
27
|
+
irf_prompt=None):
|
|
28
|
+
plt.rcParams.update({'figure.dpi': 130, 'font.size': 10,
|
|
29
|
+
'axes.spines.top': False, 'axes.spines.right': False,
|
|
30
|
+
**_EXPORT_RC})
|
|
31
|
+
s = summary
|
|
32
|
+
t_ns = np.arange(ptu.n_bins) * ptu.tcspc_res * 1e9
|
|
33
|
+
fs, fe = s['fit_window_bins']
|
|
34
|
+
|
|
35
|
+
fig = plt.figure(figsize=(12, 7))
|
|
36
|
+
gs = gridspec.GridSpec(2, 3, height_ratios=[3, 1], hspace=0.08, wspace=0.35)
|
|
37
|
+
ax1 = fig.add_subplot(gs[0, :2])
|
|
38
|
+
ax2 = fig.add_subplot(gs[1, :2], sharex=ax1)
|
|
39
|
+
ax3 = fig.add_subplot(gs[0, 2])
|
|
40
|
+
ax4 = fig.add_subplot(gs[1, 2])
|
|
41
|
+
ax4.axis('off')
|
|
42
|
+
|
|
43
|
+
ax1.semilogy(t_ns, np.clip(decay, 1, None), '.', color='#aaa',
|
|
44
|
+
ms=2, rasterized=True, label='PTU data')
|
|
45
|
+
|
|
46
|
+
# IRF - scale to ~10% of decay peak for visibility on log axis.
|
|
47
|
+
# Mask bins below 0.1% of IRF peak so zeros don't pollute the log axis.
|
|
48
|
+
if irf_prompt is not None:
|
|
49
|
+
scale = decay.max() * 0.1 / irf_prompt.max()
|
|
50
|
+
irf_scaled = irf_prompt * scale
|
|
51
|
+
irf_mask = irf_scaled > irf_scaled.max() * 1e-3
|
|
52
|
+
t_irf_plot = t_ns[irf_mask]
|
|
53
|
+
v_irf_plot = irf_scaled[irf_mask]
|
|
54
|
+
ax1.semilogy(t_irf_plot, v_irf_plot,
|
|
55
|
+
color='#f4a261', lw=1.5, ls='-.', alpha=0.85,
|
|
56
|
+
label=f"IRF (×{scale:.1e})")
|
|
57
|
+
|
|
58
|
+
if xlsx is not None and xlsx.get('fit_t') is not None and xlsx.get('fit_c') is not None:
|
|
59
|
+
ax1.semilogy(xlsx['fit_t'], np.clip(xlsx['fit_c'], 1, None),
|
|
60
|
+
'b-', lw=1.1, alpha=0.55, label='FLIM microscope fit')
|
|
61
|
+
is_tail = s.get('fit_model') == 'tail'
|
|
62
|
+
ax1.semilogy(t_ns, np.clip(s['model'], 1, None), 'r-', lw=2,
|
|
63
|
+
label=f"{n_exp}-exp {'tail' if is_tail else 'reconv.'}")
|
|
64
|
+
ax1.set_xlim(0, min(t_ns[-1], 22))
|
|
65
|
+
ax1.set_ylabel('Counts')
|
|
66
|
+
ax1.legend(fontsize=8, loc='upper right')
|
|
67
|
+
ax1.set_title(f"Summed Decay - {n_exp}-exp "
|
|
68
|
+
f"{'tail fit (no IRF)' if is_tail else '| IRF: ' + strategy}",
|
|
69
|
+
fontweight='bold')
|
|
70
|
+
ax1.axvspan(s['fit_window_ns'][0], s['fit_window_ns'][1],
|
|
71
|
+
alpha=0.06, color='green')
|
|
72
|
+
plt.setp(ax1.get_xticklabels(), visible=False)
|
|
73
|
+
|
|
74
|
+
ax2.axhline(0, color='k', lw=0.8, ls='--')
|
|
75
|
+
ax2.fill_between(t_ns[fs:fe], np.clip(s['residuals'][fs:fe], -5, 5),
|
|
76
|
+
alpha=0.5, color='#457b9d')
|
|
77
|
+
ax2.set_ylim(-5, 5)
|
|
78
|
+
ax2.set_xlim(0, min(t_ns[-1], 22))
|
|
79
|
+
ax2.set_xlabel('Time (ns)')
|
|
80
|
+
ax2.set_ylabel('W. Residuals')
|
|
81
|
+
|
|
82
|
+
rv = np.clip(s['residuals'][fs:fe], -5, 5)
|
|
83
|
+
ax3.hist(rv, bins=60, color='#2a9d8f', edgecolor='none', alpha=0.85)
|
|
84
|
+
ax3.axvline(0, color='k', lw=0.8)
|
|
85
|
+
ax3.set_xlabel('Weighted residual')
|
|
86
|
+
ax3.set_ylabel('Frequency')
|
|
87
|
+
ax3.set_title(f"Residuals μ={rv.mean():.3f} σ={rv.std():.3f}")
|
|
88
|
+
|
|
89
|
+
p_val = s.get('p_val')
|
|
90
|
+
p_line = f"p = {p_val:.4f}" if p_val is not None else ""
|
|
91
|
+
lines = [f"χ²_r = {s['reduced_chi2_pearson']:.4f} (Pearson)",
|
|
92
|
+
f"χ²_r(tail) = {s['reduced_chi2_tail_pearson']:.4f} (Pearson)",
|
|
93
|
+
p_line,
|
|
94
|
+
f"bg = {s['bg_fit']:.1f} cts/bin",
|
|
95
|
+
f"τ_mean(int) = {s['tau_mean_int_ns']:.4f} ns",
|
|
96
|
+
f"τ_mean(amp) = {s['tau_mean_amp_ns']:.4f} ns",
|
|
97
|
+
(f"t0 = {s['t0_ns']:.4f} ns" if is_tail
|
|
98
|
+
else f"IRF FWHM(eff) = {s['irf_fwhm_eff_ns']:.4f} ns"), '']
|
|
99
|
+
if 'tau_centers_ns' in s:
|
|
100
|
+
width_label = 'σ' if s['dist_type'] == 'gaussian' else 'Γ'
|
|
101
|
+
for i, (tau_c, w, frac) in enumerate(
|
|
102
|
+
zip(s['tau_centers_ns'], s['widths_ns'], s['fractions'])):
|
|
103
|
+
lines.append(f"τ_c{i+1}={tau_c:.4f} ns {width_label}={w:.4f} ns f{i+1}={frac:.4f}")
|
|
104
|
+
else:
|
|
105
|
+
for i, (tau, frac) in enumerate(zip(s['taus_ns'], s['fractions'])):
|
|
106
|
+
lines.append(f"τ{i+1}={tau:.4f} ns f{i+1}={frac:.4f}")
|
|
107
|
+
ax4.text(0.05, 0.97, '\n'.join(lines), transform=ax4.transAxes,
|
|
108
|
+
va='top', fontsize=9, family='monospace',
|
|
109
|
+
bbox=dict(boxstyle='round,pad=0.4', fc='#f7f7f7', alpha=0.9))
|
|
110
|
+
|
|
111
|
+
plt.suptitle(f"FLIM {'Tail' if is_tail else 'Reconvolution'} Fit - "
|
|
112
|
+
f"FLIM microscope / PicoHarp",
|
|
113
|
+
fontsize=12, fontweight='bold')
|
|
114
|
+
out = f"{out_prefix}_summed_{n_exp}exp.png"
|
|
115
|
+
plt.savefig(out, dpi=150, bbox_inches='tight')
|
|
116
|
+
plt.close()
|
|
117
|
+
print(f" Saved: {out}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def plot_pixel_maps(maps, n_exp, out_prefix, binning=1):
|
|
121
|
+
fig, axes = plt.subplots(2, 3, figsize=(15, 9))
|
|
122
|
+
fig.patch.set_facecolor('#111')
|
|
123
|
+
|
|
124
|
+
def _show(ax, data, title, cmap='viridis', vmin=None, vmax=None, unit='ns'):
|
|
125
|
+
ax.set_facecolor('#111')
|
|
126
|
+
if data is None:
|
|
127
|
+
ax.set_visible(False); return
|
|
128
|
+
valid = data[np.isfinite(data) & (data > 0)]
|
|
129
|
+
if len(valid) == 0:
|
|
130
|
+
ax.set_visible(False); return
|
|
131
|
+
vlo = vmin if vmin is not None else np.percentile(valid, 2)
|
|
132
|
+
vhi = vmax if vmax is not None else np.percentile(valid, 98)
|
|
133
|
+
im = ax.imshow(data, cmap=cmap, vmin=vlo, vmax=vhi, interpolation='nearest')
|
|
134
|
+
cb = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
|
|
135
|
+
cb.set_label(unit, color='white')
|
|
136
|
+
cb.ax.yaxis.set_tick_params(color='white')
|
|
137
|
+
plt.setp(cb.ax.yaxis.get_ticklabels(), color='white')
|
|
138
|
+
ax.set_title(title, color='white', fontweight='bold')
|
|
139
|
+
ax.set_axis_off()
|
|
140
|
+
|
|
141
|
+
_show(axes[0, 0], maps['intensity'], 'Intensity', 'hot', unit='photons')
|
|
142
|
+
_show(axes[0, 1], maps['tau_mean_int'], 'τ_mean (int.-wt.)', FLIM_CMAP)
|
|
143
|
+
_show(axes[0, 2], maps['tau_mean_amp'], 'τ_mean (amp.-wt.)', FLIM_CMAP)
|
|
144
|
+
for i in range(min(n_exp, 3)):
|
|
145
|
+
_show(axes[1, i], maps.get(f"frac_{i+1}"), f"f{i+1}",
|
|
146
|
+
'viridis', vmin=0, vmax=1, unit='fraction')
|
|
147
|
+
|
|
148
|
+
plt.suptitle(f"FLIM Pixel Maps - {n_exp}-exp (τ fixed, α free) "
|
|
149
|
+
f"binning={binning}×{binning}",
|
|
150
|
+
color='white', fontsize=12, fontweight='bold')
|
|
151
|
+
plt.tight_layout()
|
|
152
|
+
out = f"{out_prefix}_pixelmaps_{n_exp}exp.png"
|
|
153
|
+
plt.savefig(out, dpi=150, bbox_inches='tight', facecolor='#111')
|
|
154
|
+
plt.close()
|
|
155
|
+
print(f" Saved: {out}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def plot_lifetime_histogram(maps, n_exp, out_prefix):
|
|
159
|
+
plt.rcParams.update(_EXPORT_RC)
|
|
160
|
+
tau = maps['tau_mean_int']
|
|
161
|
+
wt = maps['intensity']
|
|
162
|
+
ok = np.isfinite(tau) & (wt > 0)
|
|
163
|
+
if ok.sum() < 2:
|
|
164
|
+
return
|
|
165
|
+
tau_ok = tau[ok]
|
|
166
|
+
data_range = float(tau_ok.max() - tau_ok.min())
|
|
167
|
+
# Skip histogram if range is too narrow to bin meaningfully
|
|
168
|
+
if data_range < 1e-12:
|
|
169
|
+
print(f" Skipping lifetime histogram - τ range too narrow ({tau_ok[0]:.4f} ns)")
|
|
170
|
+
return
|
|
171
|
+
mu_w = np.average(tau_ok, weights=wt[ok])
|
|
172
|
+
n_bins = min(100, max(2, int(np.sqrt(ok.sum()))))
|
|
173
|
+
# Clamp bins so each bin spans at least ~1e-12
|
|
174
|
+
max_bins = max(1, int(data_range / 1e-12))
|
|
175
|
+
n_bins = min(n_bins, max_bins)
|
|
176
|
+
fig, ax = plt.subplots(figsize=(7, 4))
|
|
177
|
+
ax.hist(tau_ok, bins=n_bins, weights=wt[ok], color='#2a9d8f', alpha=0.85)
|
|
178
|
+
ax.axvline(mu_w, color='red', ls='--', lw=1.5,
|
|
179
|
+
label=f"Weighted mean = {mu_w:.3f} ns")
|
|
180
|
+
ax.set_xlabel('τ_mean (intensity-weighted) [ns]')
|
|
181
|
+
ax.set_ylabel('Photon-weighted frequency')
|
|
182
|
+
ax.set_title(f"Lifetime Distribution - {n_exp}-exp", fontweight='bold')
|
|
183
|
+
ax.legend()
|
|
184
|
+
ax.spines['top'].set_visible(False)
|
|
185
|
+
ax.spines['right'].set_visible(False)
|
|
186
|
+
plt.tight_layout()
|
|
187
|
+
out = f"{out_prefix}_lifetime_hist_{n_exp}exp.png"
|
|
188
|
+
plt.savefig(out, dpi=150, bbox_inches='tight')
|
|
189
|
+
plt.close()
|
|
190
|
+
print(f" Saved: {out}")
|
flimkit/utils/roi.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, List, Dict, Optional, Tuple
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
_COLORS = [
|
|
7
|
+
'#FF6B6B',
|
|
8
|
+
'#4ECDC4',
|
|
9
|
+
'#FFE66D',
|
|
10
|
+
'#95E1D3',
|
|
11
|
+
'#C7CEEA',
|
|
12
|
+
'#FF8C42',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
class RoiManager:
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.regions: List[Dict] = []
|
|
19
|
+
self._next_id = 0
|
|
20
|
+
self._selected_id: Optional[int] = None
|
|
21
|
+
|
|
22
|
+
def add_region(self, name: str, tool_type: str, coords: List[List[float]],
|
|
23
|
+
color_idx: Optional[int] = None) -> int:
|
|
24
|
+
if tool_type not in ('rect', 'ellipse', 'polygon', 'freehand'):
|
|
25
|
+
raise ValueError(f'Invalid tool_type: {tool_type}')
|
|
26
|
+
if not coords or len(coords) == 0:
|
|
27
|
+
raise ValueError('coords cannot be empty')
|
|
28
|
+
if color_idx is None:
|
|
29
|
+
color_idx = len(self.regions) % len(_COLORS)
|
|
30
|
+
region = {
|
|
31
|
+
'id': self._next_id,
|
|
32
|
+
'name': name,
|
|
33
|
+
'tool': tool_type,
|
|
34
|
+
'coords': [[float(x), float(y)] for x, y in coords],
|
|
35
|
+
'color_idx': int(color_idx),
|
|
36
|
+
}
|
|
37
|
+
self.regions.append(region)
|
|
38
|
+
self._next_id += 1
|
|
39
|
+
return region['id']
|
|
40
|
+
|
|
41
|
+
def remove_region(self, region_id: int) -> bool:
|
|
42
|
+
for i, r in enumerate(self.regions):
|
|
43
|
+
if r['id'] == region_id:
|
|
44
|
+
self.regions.pop(i)
|
|
45
|
+
if self._selected_id == region_id:
|
|
46
|
+
self._selected_id = None
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def get_region(self, region_id: int) -> Optional[Dict]:
|
|
51
|
+
for r in self.regions:
|
|
52
|
+
if r['id'] == region_id:
|
|
53
|
+
return r
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def update_region(self, region_id: int, **kwargs) -> bool:
|
|
57
|
+
for r in self.regions:
|
|
58
|
+
if r['id'] == region_id:
|
|
59
|
+
if 'coords' in kwargs:
|
|
60
|
+
r['coords'] = [[float(x), float(y)] for x, y in kwargs['coords']]
|
|
61
|
+
if 'name' in kwargs:
|
|
62
|
+
r['name'] = str(kwargs['name'])
|
|
63
|
+
if 'color_idx' in kwargs:
|
|
64
|
+
r['color_idx'] = int(kwargs['color_idx'])
|
|
65
|
+
if 'tool' in kwargs:
|
|
66
|
+
r['tool'] = str(kwargs['tool'])
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def select_region(self, region_id: Optional[int]) -> None:
|
|
71
|
+
self._selected_id = region_id
|
|
72
|
+
|
|
73
|
+
def get_selected_id(self) -> Optional[int]:
|
|
74
|
+
return self._selected_id
|
|
75
|
+
|
|
76
|
+
def get_all_regions(self) -> List[Dict]:
|
|
77
|
+
return self.regions
|
|
78
|
+
|
|
79
|
+
def clear_all(self) -> None:
|
|
80
|
+
self.regions = []
|
|
81
|
+
self._selected_id = None
|
|
82
|
+
self._next_id = 0
|
|
83
|
+
|
|
84
|
+
def to_json(self) -> str:
|
|
85
|
+
data = {
|
|
86
|
+
'regions': self.regions,
|
|
87
|
+
'next_id': self._next_id,
|
|
88
|
+
}
|
|
89
|
+
return json.dumps(data, default=str)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_json(cls, json_str: str) -> 'RoiManager':
|
|
93
|
+
manager = cls()
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(json_str)
|
|
96
|
+
manager.regions = data.get('regions', [])
|
|
97
|
+
manager._next_id = data.get('next_id', len(manager.regions))
|
|
98
|
+
except (json.JSONDecodeError, ValueError):
|
|
99
|
+
pass
|
|
100
|
+
return manager
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _geojson_coordinates(values: Any, minimum: int) -> List[List[float]]:
|
|
104
|
+
if not isinstance(values, list):
|
|
105
|
+
raise ValueError('GeoJSON coordinates must be a list')
|
|
106
|
+
coords = []
|
|
107
|
+
for point in values:
|
|
108
|
+
if not isinstance(point, (list, tuple)) or len(point) < 2:
|
|
109
|
+
raise ValueError('GeoJSON coordinates must contain [x, y] points')
|
|
110
|
+
x, y = float(point[0]), float(point[1])
|
|
111
|
+
if not np.isfinite(x) or not np.isfinite(y):
|
|
112
|
+
raise ValueError('GeoJSON coordinates must be finite')
|
|
113
|
+
coords.append([x, y])
|
|
114
|
+
if len(coords) < minimum:
|
|
115
|
+
raise ValueError(f'GeoJSON geometry requires at least {minimum} points')
|
|
116
|
+
return coords
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _outer_boundary(ring: List[List[float]]) -> Optional[List[List[float]]]:
|
|
120
|
+
from shapely.geometry import MultiPolygon, Polygon
|
|
121
|
+
polygon = Polygon(ring)
|
|
122
|
+
if polygon.is_valid:
|
|
123
|
+
return None
|
|
124
|
+
repaired = polygon.buffer(0)
|
|
125
|
+
if isinstance(repaired, MultiPolygon):
|
|
126
|
+
if not repaired.geoms:
|
|
127
|
+
return None
|
|
128
|
+
repaired = max(repaired.geoms, key=lambda part: part.area)
|
|
129
|
+
if repaired.is_empty or repaired.geom_type != 'Polygon':
|
|
130
|
+
return None
|
|
131
|
+
out = [[float(x), float(y)] for x, y in repaired.exterior.coords]
|
|
132
|
+
if len(out) < 4:
|
|
133
|
+
return None
|
|
134
|
+
if out[-1] != out[0]:
|
|
135
|
+
out.append(out[0][:])
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _region_feature(region: Dict) -> Dict:
|
|
140
|
+
tool = region['tool']
|
|
141
|
+
coords = [[float(x), float(y)] for x, y in region['coords']]
|
|
142
|
+
properties = {
|
|
143
|
+
'id': region.get('id'),
|
|
144
|
+
'name': region.get('name', ''),
|
|
145
|
+
'tool_type': tool,
|
|
146
|
+
'color_idx': region.get('color_idx', 0),
|
|
147
|
+
}
|
|
148
|
+
statistic_keys = (
|
|
149
|
+
'tau_median', 'tau_stdev', 'photon_count', 'photon_stdev',
|
|
150
|
+
)
|
|
151
|
+
statistics = {
|
|
152
|
+
key: region.get('statistics', {}).get(key)
|
|
153
|
+
for key in statistic_keys
|
|
154
|
+
if region.get('statistics', {}).get(key) is not None
|
|
155
|
+
}
|
|
156
|
+
properties['statistics'] = statistics
|
|
157
|
+
if tool == 'rect':
|
|
158
|
+
if len(coords) != 2:
|
|
159
|
+
raise ValueError('Rectangle regions require two corner points')
|
|
160
|
+
(x1, y1), (x2, y2) = coords
|
|
161
|
+
ring = [
|
|
162
|
+
[x1, y1], [x2, y1], [x2, y2], [x1, y2], [x1, y1],
|
|
163
|
+
]
|
|
164
|
+
properties['bounds'] = coords
|
|
165
|
+
elif tool == 'ellipse':
|
|
166
|
+
if len(coords) != 2:
|
|
167
|
+
raise ValueError('Ellipse regions require two corner points')
|
|
168
|
+
(x1, y1), (x2, y2) = coords
|
|
169
|
+
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
|
|
170
|
+
rx, ry = abs(x2 - x1) / 2, abs(y2 - y1) / 2
|
|
171
|
+
angles = np.linspace(0.0, 2.0 * np.pi, 64, endpoint=False)
|
|
172
|
+
ring = [
|
|
173
|
+
[float(cx + rx * np.cos(angle)),
|
|
174
|
+
float(cy + ry * np.sin(angle))]
|
|
175
|
+
for angle in angles
|
|
176
|
+
]
|
|
177
|
+
ring.append(ring[0].copy())
|
|
178
|
+
properties['bounds'] = coords
|
|
179
|
+
elif tool in ('polygon', 'freehand'):
|
|
180
|
+
if len(coords) < 3:
|
|
181
|
+
raise ValueError(f'{tool.capitalize()} regions require at least three points')
|
|
182
|
+
ring = coords.copy()
|
|
183
|
+
if ring[-1] != ring[0]:
|
|
184
|
+
ring.append(ring[0].copy())
|
|
185
|
+
outer = RoiManager._outer_boundary(ring)
|
|
186
|
+
if outer is not None:
|
|
187
|
+
ring = outer
|
|
188
|
+
properties['repaired'] = 'self-intersecting'
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError(f'Unsupported ROI tool: {tool}')
|
|
191
|
+
return {
|
|
192
|
+
'type': 'Feature',
|
|
193
|
+
'properties': properties,
|
|
194
|
+
'geometry': {'type': 'Polygon', 'coordinates': [ring]},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def to_geojson(self, region_ids: Optional[List[int]] = None) -> Dict:
|
|
198
|
+
if region_ids is None:
|
|
199
|
+
regions = self.regions
|
|
200
|
+
else:
|
|
201
|
+
wanted = set(region_ids)
|
|
202
|
+
regions = [region for region in self.regions if region['id'] in wanted]
|
|
203
|
+
found = {region['id'] for region in regions}
|
|
204
|
+
missing = wanted - found
|
|
205
|
+
if missing:
|
|
206
|
+
raise ValueError(f'Region IDs not found: {sorted(missing)}')
|
|
207
|
+
return {
|
|
208
|
+
'type': 'FeatureCollection',
|
|
209
|
+
'features': [self._region_feature(region) for region in regions],
|
|
210
|
+
'flimkit': {
|
|
211
|
+
'coordinate_system': 'image-pixel',
|
|
212
|
+
'axis_order': 'xy',
|
|
213
|
+
'origin': 'top-left',
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def _region_from_feature(cls, feature: Dict) -> Dict:
|
|
219
|
+
if not isinstance(feature, dict) or feature.get('type') != 'Feature':
|
|
220
|
+
raise ValueError('GeoJSON entries must be Features')
|
|
221
|
+
properties = feature.get('properties') or {}
|
|
222
|
+
geometry = feature.get('geometry') or {}
|
|
223
|
+
if not isinstance(properties, dict) or not isinstance(geometry, dict):
|
|
224
|
+
raise ValueError('GeoJSON Feature properties and geometry must be objects')
|
|
225
|
+
geometry_type = geometry.get('type')
|
|
226
|
+
raw_coordinates = geometry.get('coordinates')
|
|
227
|
+
tool = properties.get('tool_type')
|
|
228
|
+
if tool is None:
|
|
229
|
+
if geometry_type == 'Polygon':
|
|
230
|
+
tool = 'polygon'
|
|
231
|
+
elif geometry_type == 'LineString':
|
|
232
|
+
tool = 'freehand'
|
|
233
|
+
if tool not in ('rect', 'ellipse', 'polygon', 'freehand'):
|
|
234
|
+
raise ValueError(f'Unsupported GeoJSON geometry: {geometry_type}')
|
|
235
|
+
if geometry_type == 'Polygon':
|
|
236
|
+
if not isinstance(raw_coordinates, list) or not raw_coordinates:
|
|
237
|
+
raise ValueError('GeoJSON Polygon must contain an outer ring')
|
|
238
|
+
outer_ring = raw_coordinates[0]
|
|
239
|
+
if isinstance(outer_ring, list) and len(outer_ring) < 4:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
'GeoJSON Polygon outer rings require at least four '
|
|
242
|
+
'positions, including a repeated closing position',
|
|
243
|
+
)
|
|
244
|
+
ring = cls._geojson_coordinates(outer_ring, 4)
|
|
245
|
+
if ring[0] == ring[-1]:
|
|
246
|
+
ring = ring[:-1]
|
|
247
|
+
if tool in ('rect', 'ellipse'):
|
|
248
|
+
bounds = properties.get('bounds')
|
|
249
|
+
if bounds is not None:
|
|
250
|
+
coords = cls._geojson_coordinates(bounds, 2)
|
|
251
|
+
if len(coords) != 2:
|
|
252
|
+
raise ValueError('GeoJSON ROI bounds require two corner points')
|
|
253
|
+
else:
|
|
254
|
+
xs = [point[0] for point in ring]
|
|
255
|
+
ys = [point[1] for point in ring]
|
|
256
|
+
coords = [[min(xs), min(ys)], [max(xs), max(ys)]]
|
|
257
|
+
else:
|
|
258
|
+
coords = cls._geojson_coordinates(ring, 3)
|
|
259
|
+
elif geometry_type == 'LineString' and tool in ('polygon', 'freehand'):
|
|
260
|
+
coords = cls._geojson_coordinates(raw_coordinates, 3)
|
|
261
|
+
else:
|
|
262
|
+
raise ValueError(f'Unsupported GeoJSON geometry: {geometry_type}')
|
|
263
|
+
statistics = properties.get('statistics')
|
|
264
|
+
if isinstance(statistics, dict):
|
|
265
|
+
statistics = copy.deepcopy(statistics)
|
|
266
|
+
else:
|
|
267
|
+
statistics = {}
|
|
268
|
+
for key in ('tau_median', 'tau_stdev', 'photon_count', 'photon_stdev'):
|
|
269
|
+
if key not in statistics and properties.get(key) is not None:
|
|
270
|
+
statistics[key] = properties[key]
|
|
271
|
+
color_idx = properties.get('color_idx')
|
|
272
|
+
return {
|
|
273
|
+
'name': str(properties.get('name', 'imported-region')),
|
|
274
|
+
'tool': tool,
|
|
275
|
+
'coords': coords,
|
|
276
|
+
'color_idx': int(color_idx) if color_idx is not None else None,
|
|
277
|
+
'statistics': statistics,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
def add_geojson(self, payload: Dict, mode: str = 'append') -> List[int]:
|
|
281
|
+
if mode not in ('append', 'replace'):
|
|
282
|
+
raise ValueError('mode must be append or replace')
|
|
283
|
+
if not isinstance(payload, dict):
|
|
284
|
+
raise ValueError('GeoJSON payload must be an object')
|
|
285
|
+
if payload.get('type') == 'FeatureCollection':
|
|
286
|
+
features = payload.get('features')
|
|
287
|
+
if not isinstance(features, list):
|
|
288
|
+
raise ValueError('GeoJSON FeatureCollection features must be a list')
|
|
289
|
+
elif payload.get('type') == 'Feature':
|
|
290
|
+
features = [payload]
|
|
291
|
+
else:
|
|
292
|
+
raise ValueError('GeoJSON must be a Feature or FeatureCollection')
|
|
293
|
+
pending = [self._region_from_feature(feature) for feature in features]
|
|
294
|
+
if mode == 'replace':
|
|
295
|
+
self.clear_all()
|
|
296
|
+
added = []
|
|
297
|
+
for item in pending:
|
|
298
|
+
region_id = self.add_region(
|
|
299
|
+
item['name'], item['tool'], item['coords'], item['color_idx'],
|
|
300
|
+
)
|
|
301
|
+
region = self.get_region(region_id)
|
|
302
|
+
if region is not None and item['statistics']:
|
|
303
|
+
region['statistics'] = item['statistics']
|
|
304
|
+
added.append(region_id)
|
|
305
|
+
return added
|
|
306
|
+
|
|
307
|
+
def compute_region_mask(self, region_id: int, image_shape: Tuple[int, int]) -> Optional[np.ndarray]:
|
|
308
|
+
from matplotlib.path import Path as MplPath
|
|
309
|
+
region = self.get_region(region_id)
|
|
310
|
+
if region is None:
|
|
311
|
+
return None
|
|
312
|
+
height, width = image_shape
|
|
313
|
+
mask = np.zeros((height, width), dtype=bool)
|
|
314
|
+
coords = np.array(region['coords'], dtype=float)
|
|
315
|
+
if region['tool'] == 'rect':
|
|
316
|
+
if len(coords) >= 2:
|
|
317
|
+
x0, y0 = coords[0]
|
|
318
|
+
x1, y1 = coords[1]
|
|
319
|
+
x_min, x_max = int(min(x0, x1)), int(max(x0, x1))
|
|
320
|
+
y_min, y_max = int(min(y0, y1)), int(max(y0, y1))
|
|
321
|
+
mask[y_min:y_max+1, x_min:x_max+1] = True
|
|
322
|
+
elif region['tool'] == 'ellipse':
|
|
323
|
+
if len(coords) >= 2:
|
|
324
|
+
x0, y0 = coords[0]
|
|
325
|
+
x1, y1 = coords[1]
|
|
326
|
+
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
|
327
|
+
rx, ry = abs(x1 - x0) / 2, abs(y1 - y0) / 2
|
|
328
|
+
yy, xx = np.ogrid[:height, :width]
|
|
329
|
+
mask = ((xx - cx)**2 / (rx**2 + 1e-6) +
|
|
330
|
+
(yy - cy)**2 / (ry**2 + 1e-6)) <= 1
|
|
331
|
+
elif region['tool'] in ('polygon', 'freehand'):
|
|
332
|
+
if len(coords) >= 3:
|
|
333
|
+
path = MplPath(coords)
|
|
334
|
+
yy, xx = np.meshgrid(np.arange(height), np.arange(width), indexing='ij')
|
|
335
|
+
points = np.column_stack([xx.ravel(), yy.ravel()])
|
|
336
|
+
mask = path.contains_points(points).reshape((height, width))
|
|
337
|
+
return mask
|
|
338
|
+
|
|
339
|
+
def get_color(self, region_id: int) -> str:
|
|
340
|
+
region = self.get_region(region_id)
|
|
341
|
+
if region is None:
|
|
342
|
+
return '#999999'
|
|
343
|
+
color_idx = region.get('color_idx', 0) % len(_COLORS)
|
|
344
|
+
return _COLORS[color_idx]
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def get_color_palette() -> List[str]:
|
|
348
|
+
return _COLORS.copy()
|
|
349
|
+
|
|
350
|
+
def get_rectangle_patch(coords, edgecolor, facecolor='none', linewidth=2):
|
|
351
|
+
from matplotlib.patches import Rectangle
|
|
352
|
+
x0, y0 = coords[0]
|
|
353
|
+
x1, y1 = coords[1]
|
|
354
|
+
width = abs(x1 - x0)
|
|
355
|
+
height = abs(y1 - y0)
|
|
356
|
+
xy = (min(x0, x1), min(y0, y1))
|
|
357
|
+
return Rectangle(xy, width, height, edgecolor=edgecolor, facecolor=facecolor, linewidth=linewidth)
|
|
358
|
+
|
|
359
|
+
def get_ellipse_patch(coords, edgecolor, facecolor='none', linewidth=2):
|
|
360
|
+
from matplotlib.patches import Ellipse
|
|
361
|
+
x0, y0 = coords[0]
|
|
362
|
+
x1, y1 = coords[1]
|
|
363
|
+
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
|
364
|
+
width = abs(x1 - x0)
|
|
365
|
+
height = abs(y1 - y0)
|
|
366
|
+
return Ellipse((cx, cy), width, height, edgecolor=edgecolor, facecolor=facecolor, linewidth=linewidth)
|
|
367
|
+
|
|
368
|
+
def get_polygon_patch(coords, edgecolor, facecolor='none', linewidth=2):
|
|
369
|
+
from matplotlib.patches import Polygon
|
|
370
|
+
return Polygon(coords, edgecolor=edgecolor, facecolor=facecolor, linewidth=linewidth, closed=True)
|