diff --git a/.flake8 b/.flake8 index adaa55bb..2378f3cc 100644 --- a/.flake8 +++ b/.flake8 @@ -37,3 +37,4 @@ exclude = ignore, legacy, examples, + scripts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 79ddf030..7fb92b48 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -exclude: ^(docs|notebooks|ignore|/tests/artifacts|examples)/ +exclude: ^(docs|notebooks|ignore|/tests/artifacts|examples|scripts)/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.1.0 diff --git a/Makefile b/Makefile index 51805df4..e34538ad 100644 --- a/Makefile +++ b/Makefile @@ -10,12 +10,12 @@ uninstall: .PHONY: check-format check-format: - black --check -S -t py39 . + black --check -S -t py310 . isort --check . .PHONY: format format: - black -S -t py39 . + black -S -t py310 . isort . .PHONY: lint diff --git a/mantis/acquisition/acq_engine.py b/mantis/acquisition/acq_engine.py index 822fda24..ad9a53d0 100644 --- a/mantis/acquisition/acq_engine.py +++ b/mantis/acquisition/acq_engine.py @@ -807,8 +807,8 @@ def refocus_ls_path(self): # Define relative travel limits, in steps o3_z_stage = self.ls_acq.o3_stage target_z_position = o3_z_stage.true_position + o3_z_range - max_z_position = 500 # O3 is allowed to travel ~10 um towards O2 - min_z_position = -1000 # O3 is allowed to travel ~20 um away from O2 + max_z_position = 750 # O3 is allowed to travel ~15 um towards O2 + min_z_position = -1500 # O3 is allowed to travel ~30 um away from O2 if np.any(target_z_position > max_z_position) or np.any( target_z_position < min_z_position ): diff --git a/mantis/acquisition/scripts/github-markdown.css b/mantis/acquisition/scripts/github-markdown.css new file mode 100644 index 00000000..d32a3146 --- /dev/null +++ b/mantis/acquisition/scripts/github-markdown.css @@ -0,0 +1,1195 @@ +@media (prefers-color-scheme: dark) { + .markdown-body, + [data-theme="dark"] { + /*dark*/ + color-scheme: dark; + --color-prettylights-syntax-comment: #8b949e; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #c9d1d9; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #c9d1d9; + --color-prettylights-syntax-markup-bold: #c9d1d9; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #c9d1d9; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-brackethighlighter-angle: #8b949e; + --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-fg-default: #e6edf3; + --color-fg-muted: #848d97; + --color-fg-subtle: #6e7681; + --color-canvas-default: #0d1117; + --color-canvas-subtle: #161b22; + --color-border-default: #30363d; + --color-border-muted: #21262d; + --color-neutral-muted: rgba(110,118,129,0.4); + --color-accent-fg: #2f81f7; + --color-accent-emphasis: #1f6feb; + --color-success-fg: #3fb950; + --color-success-emphasis: #238636; + --color-attention-fg: #d29922; + --color-attention-emphasis: #9e6a03; + --color-attention-subtle: rgba(187,128,9,0.15); + --color-danger-fg: #f85149; + --color-danger-emphasis: #da3633; + --color-done-fg: #a371f7; + --color-done-emphasis: #8957e5; + } +} + +@media (prefers-color-scheme: light) { + .markdown-body, + [data-theme="light"] { + /*light*/ + color-scheme: light; + --color-prettylights-syntax-comment: #57606a; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #6639ba; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-fg-default: #1F2328; + --color-fg-muted: #656d76; + --color-fg-subtle: #6e7781; + --color-canvas-default: #ffffff; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210,18%,87%,1); + --color-neutral-muted: rgba(175,184,193,0.2); + --color-accent-fg: #0969da; + --color-accent-emphasis: #0969da; + --color-success-fg: #1a7f37; + --color-success-emphasis: #1f883d; + --color-attention-fg: #9a6700; + --color-attention-emphasis: #9a6700; + --color-attention-subtle: #fff8c5; + --color-danger-fg: #d1242f; + --color-danger-emphasis: #cf222e; + --color-done-fg: #8250df; + --color-done-emphasis: #8250df; + } +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: var(--color-fg-default); + background-color: var(--color-canvas-default); + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: var(--color-accent-fg); + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid var(--color-border-muted); +} + +.markdown-body mark { + background-color: var(--color-attention-subtle); + color: var(--color-fg-default); +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; + background-color: var(--color-canvas-default); +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em 40px; +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + border-bottom: 1px solid var(--color-border-muted); + height: .25em; + padding: 0; + margin: 24px 0; + background-color: var(--color-border-default); + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: var(--color-fg-subtle); + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body details:not([open])>*:not(summary) { + display: none !important; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + line-height: 10px; + color: var(--color-fg-default); + vertical-align: middle; + background-color: var(--color-canvas-subtle); + border: solid 1px var(--color-neutral-muted); + border-bottom-color: var(--color-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--color-neutral-muted); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid var(--color-border-muted); +} + +.markdown-body h3 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1em; +} + +.markdown-body h5 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .875em; +} + +.markdown-body h6 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .85em; + color: var(--color-fg-muted); +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: var(--color-fg-muted); + border-left: .25em solid var(--color-border-default); +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: var(--base-size-8, 8px) !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: var(--color-danger-fg); +} + +.markdown-body .anchor { + float: left; + padding-right: 4px; + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: var(--color-fg-default); + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div>ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: 16px; +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--color-border-default); +} + +.markdown-body table td>:last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + background-color: var(--color-canvas-default); + border-top: 1px solid var(--color-border-muted); +} + +.markdown-body table tr:nth-child(2n) { + background-color: var(--color-canvas-subtle); +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame>span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--color-border-default); +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--color-fg-default); +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right>span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--color-neutral-muted); + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: var(--color-fg-default); + background-color: var(--color-canvas-subtle); + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px 8px 9px; + text-align: right; + background: var(--color-canvas-default); + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--color-canvas-subtle); + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: var(--color-fg-muted); + border-top: 1px solid var(--color-border-default); +} + +.markdown-body .footnotes ol { + padding-left: 16px; +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: 16px; + margin-top: 16px; +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: -8px; + right: -8px; + bottom: -8px; + left: -24px; + pointer-events: none; + content: ""; + border: 2px solid var(--color-accent-emphasis); + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: var(--color-fg-default); +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: 4px; +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list { + position: relative; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: var(--base-size-8) var(--base-size-16); + margin-bottom: 16px; + color: inherit; + border-left: .25em solid var(--color-border-default); +} + +.markdown-body .markdown-alert>:first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert>:last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: var(--base-text-weight-medium, 500); + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: var(--color-accent-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: var(--color-accent-fg); +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: var(--color-done-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: var(--color-done-fg); +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: var(--color-attention-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: var(--color-attention-fg); +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: var(--color-success-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: var(--color-success-fg); +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: var(--color-danger-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: var(--color-danger-fg); +} diff --git a/mantis/acquisition/scripts/measure_psf.py b/mantis/acquisition/scripts/measure_psf.py new file mode 100644 index 00000000..5584f5be --- /dev/null +++ b/mantis/acquisition/scripts/measure_psf.py @@ -0,0 +1,289 @@ +# %% +import time + +from pathlib import Path + +import napari +import numpy as np +import torch + +from iohub.ngff_meta import TransformationMeta +from iohub.reader import open_ome_zarr +from pycromanager import Acquisition, Core, multi_d_acquisition_events + +from mantis.acquisition.microscope_operations import acquire_defocus_stack +from mantis.analysis.AnalysisSettings import CharacterizeSettings +from mantis.analysis.deskew import deskew_data, get_deskewed_data_shape +from mantis.cli.characterize_psf import _characterize_psf + +device = "cuda" if torch.cuda.is_available() else "cpu" +epi_bead_detection_settings = { + "block_size": (8, 8, 8), + "blur_kernel_size": 3, + "min_distance": 20, + "threshold_abs": 200.0, + "max_num_peaks": 500, + "exclude_border": (5, 5, 5), + "device": device, +} + +ls_bead_detection_settings = { + "block_size": (64, 64, 32), + "blur_kernel_size": 3, + "nms_distance": 32, + "min_distance": 50, + "threshold_abs": 200.0, + "max_num_peaks": 2000, + "exclude_border": (5, 10, 5), + "device": device, +} + +deskew_bead_detection_settings = { + "block_size": (64, 32, 16), + "blur_kernel_size": 3, + "nms_distance": 10, + "min_distance": 50, + "threshold_abs": 200.0, + "max_num_peaks": 500, + "exclude_border": (5, 5, 5), + "device": device, +} + + +def check_acquisition_directory(root_dir: Path, acq_name: str, suffix='', idx=1) -> Path: + acq_dir = root_dir / f'{acq_name}_{idx}{suffix}' + if acq_dir.exists(): + return check_acquisition_directory(root_dir, acq_name, suffix, idx + 1) + return acq_dir + + +mmc = Core() +step_per_um = None + +# mmc.set_property('Prime BSI Express', 'ExposeOutMode', 'Rolling Shutter') +# mmc.set_property('Oryx2', 'Line Selector', 'Line5') +# mmc.update_system_state_cache() +# mmc.set_property('Oryx2', 'Line Mode', 'Output') +# mmc.set_property('Oryx2', 'Line Source', 'ExposureActive') + +# %% +data_dir = Path(r'E:\2024_08_06_A549_TOMM20_SEC61') +date = '2024_08_07' +# dataset = f'{date}_RR_Straight_O3_scan' +dataset = f'{date}_epi_O1_benchmark' +# dataset = f'{date}_LS_Oryx_epi_illum' +# dataset = f'{date}_LS_Oryx_LS_illum' +# dataset = f'{date}_LS_benchmark' + +# epi settings +z_stage = 'PiezoStage:Q:35' +z_step = 0.2 # in um +z_range = (-2, 50) # in um +pixel_size = 2 * 3.45 / 100 # in um +# pixel_size = 3.45 / 55.7 # in um +axis_labels = ("Z", "Y", "X") + +# ls settings +# z_stage = 'AP Galvo' +# z_step = 0.205 # in um +# z_range = (-100, 85) # in um +# pixel_size = 0.116 # in um +# axis_labels = ("SCAN", "TILT", "COVERSLIP") + +# epi illumination rr detection settings +# z_stage = 'AP Galvo' +# # z_step = 0.205 # in um +# # z_range = (-85, 85) # in um +# z_step = 0.1 # in um, reduced range and smaller step size +# z_range = (-31, 49) # in um +# # pixel_size = 0.116 # in um +# pixel_size = 6.5 / 40 / 1.4 # in um, no binning +# axis_labels = ("SCAN", "TILT", "COVERSLIP") + +# ls straight settings +# from mantis.acquisition.microscope_operations import setup_kim101_stage +# z_stage = setup_kim101_stage('74000291') +# step_per_um = 35 # matches ~30 nm per step quoted in PIA13 specs +# z_start = 0 / step_per_um # in um +# z_end = 1000 / step_per_um +# z_step = 5 / step_per_um +# # z_end = 500 / step_per_um +# # z_step = 20 / step_per_um +# z_range = np.arange(z_start, z_end + z_step, z_step) # in um +# z_step /= 1.4 # count in 1.4x remote volume magnification +# pixel_size = 3.45 / 40 / 1.4 # in um, counting the 1.4x remote volume magnification +# axis_labels = ("Z", "Y", "X") + + +deskew = True +view = False +scale = (z_step, pixel_size, pixel_size) +data_path = data_dir / dataset + +camera = mmc.get_camera_device() +if isinstance(z_stage, str): + mmc.set_property('Core', 'Focus', z_stage) + z_pos = mmc.get_position(z_stage) + events = multi_d_acquisition_events( + z_start=z_pos + z_range[0], + z_end=z_pos + z_range[-1], + z_step=z_step, + ) + + if camera in ('Prime BSI Express', 'Oryx2') and z_stage == 'AP Galvo': + mmc.set_property('TS2_TTL1-8', 'Blanking', 'On') + mmc.set_property('TS2_DAC03', 'Sequence', 'On') + + mmc.set_auto_shutter(False) + mmc.set_shutter_open(True) + with Acquisition( + directory=str(data_dir), + name=dataset, + show_display=False, + ) as acq: + acq.acquire(events) + mmc.set_shutter_open(False) + mmc.set_auto_shutter(True) + mmc.set_position(z_stage, z_pos) + + if camera in ('Prime BSI Express', 'Oryx2') and z_stage == 'AP Galvo': + mmc.set_property('TS2_TTL1-8', 'Blanking', 'Off') + mmc.set_property('TS2_DAC03', 'Sequence', 'Off') + + ds = acq.get_dataset() + zyx_data = np.asarray(ds.as_array()) + channel_names = ['GFP'] + dataset = Path(ds.path).name + ds.close() + +else: + acq_dir = check_acquisition_directory(data_dir, dataset, suffix='.zarr') + dataset = acq_dir.stem + + mmc.set_auto_shutter(False) + mmc.set_shutter_open(True) + z_range_microsteps = (z_range * step_per_um).astype(int) + zyx_data = acquire_defocus_stack(mmc, z_stage, z_range_microsteps) + mmc.set_shutter_open(False) + mmc.set_auto_shutter(True) + + # save to zarr store + channel_names = ['GFP'] + with open_ome_zarr( + data_dir / (dataset + '.zarr'), + layout="hcs", + mode="w", + channel_names=channel_names, + ) as output_dataset: + pos = output_dataset.create_position('0', '0', '0') + pos.create_image( + name="0", + data=zyx_data[None, None, ...], + chunks=(1, 1, 50) + zyx_data.shape[1:], # may be bigger than 500 MB + ) + z_stage.close() + +raw = False +patch_size = (scale[0] * 15, scale[1] * 18, scale[2] * 18) +if axis_labels == ("SCAN", "TILT", "COVERSLIP"): + raw = True + patch_size = (scale[0] * 30, scale[1] * 36, scale[2] * 18) + +# %% Characterize peaks + +peaks = _characterize_psf( + zyx_data=zyx_data, + zyx_scale=scale, + settings=CharacterizeSettings( + **epi_bead_detection_settings, axis_labels=axis_labels, patch_size=patch_size + ), + output_report_path=data_dir / (dataset + '_psf_analysis'), + input_dataset_path=data_dir, + input_dataset_name=dataset, +) + +# %% Visualize in napari + +if view: + viewer = napari.Viewer() + viewer.add_image(zyx_data) + viewer.add_points( + peaks, name='peaks local max', size=12, symbol='ring', edge_color='yellow' + ) + +# %% Deskew data and analyze +output_zarr_path = data_dir / (dataset + '_deskewed.zarr') + +if raw and deskew: + # chunk data so that it fits in the GPU memory + # should not be necessary on the mantis GPU + num_chunks = 4 + chunked_data = np.split(zyx_data, num_chunks, axis=-1) + + deskew_settings = { + "ls_angle_deg": 30, + "px_to_scan_ratio": round(scale[-1] / scale[-3], 3), + "keep_overhang": True, + "average_n_slices": 3, + } + + deskewed_shape, deskewed_voxel_size = get_deskewed_data_shape( + raw_data_shape=zyx_data.shape, + pixel_size_um=scale[-1], + **deskew_settings, + ) + + print('Deskewing data...') + t1 = time.time() + deskewed_chunks = [] + for chunk in chunked_data: + deskewed_chunks.append( + deskew_data( + chunk, + device=device, + **deskew_settings, + ) + ) + + # concatenate arrays in reverse order + deskewed_data = np.concatenate(deskewed_chunks[::-1], axis=-2) + print(f'Tike to deskew data: {time.time() - t1}') + + # Characterize deskewed peaks + deskewed_peaks = _characterize_psf( + zyx_data=deskewed_data, + zyx_scale=deskewed_voxel_size, + settings=CharacterizeSettings( + **deskew_bead_detection_settings, + axis_labels=('Z', 'Y', 'X'), + ), + output_report_path=data_dir / (dataset + '_deskewed_psf_analysis'), + input_dataset_path=output_zarr_path, + input_dataset_name=dataset, + ) + + if view: + viewer2 = napari.Viewer() + viewer2.add_image(deskewed_data) + viewer2.add_points( + deskewed_peaks, name='peaks local max', size=12, symbol='ring', edge_color='yellow' + ) + + # Save to zarr store + transform = TransformationMeta( + type="scale", + scale=2 * (1,) + deskewed_voxel_size, + ) + + with open_ome_zarr( + output_zarr_path, layout="hcs", mode="w", channel_names=channel_names + ) as output_dataset: + pos = output_dataset.create_position('0', '0', '0') + pos.create_image( + name="0", + data=deskewed_data[None, None, ...], + chunks=(1, 1, 50) + deskewed_shape[1:], # may be bigger than 500 MB + transform=[transform], + ) + +# %% diff --git a/mantis/analysis/AnalysisSettings.py b/mantis/analysis/AnalysisSettings.py index 7413d140..4e1fad6b 100644 --- a/mantis/analysis/AnalysisSettings.py +++ b/mantis/analysis/AnalysisSettings.py @@ -3,6 +3,7 @@ from typing import Literal, Optional, Union import numpy as np +import torch from pydantic import BaseModel, Extra, NonNegativeInt, PositiveFloat, PositiveInt, validator @@ -76,6 +77,33 @@ def check_affine_transform(cls, v): return v +class PsfFromBeadsSettings(MyBaseModel): + axis0_patch_size: PositiveInt = 101 + axis1_patch_size: PositiveInt = 101 + axis2_patch_size: PositiveInt = 101 + + +class DeconvolveSettings(MyBaseModel): + regularization_strength: PositiveFloat = 0.001 + + +class CharacterizeSettings(MyBaseModel): + block_size: list[NonNegativeInt] = (64, 64, 32) + blur_kernel_size: NonNegativeInt = 3 + nms_distance: NonNegativeInt = 32 + min_distance: NonNegativeInt = 50 + threshold_abs: PositiveFloat = 200.0 + max_num_peaks: NonNegativeInt = 2000 + exclude_border: list[NonNegativeInt] = (5, 10, 5) + device: str = "cuda" + patch_size: tuple[PositiveFloat, PositiveFloat, PositiveFloat] | None = None + axis_labels: list[str] = ["AXIS0", "AXIS1", "AXIS2"] + + @validator("device") + def check_device(cls, v): + return "cuda" if torch.cuda.is_available() else "cpu" + + class ConcatenateSettings(MyBaseModel): concat_data_paths: list[str] time_indices: Union[int, list[int], Literal["all"]] = "all" diff --git a/mantis/analysis/analyze_psf.py b/mantis/analysis/analyze_psf.py new file mode 100644 index 00000000..1d1641c7 --- /dev/null +++ b/mantis/analysis/analyze_psf.py @@ -0,0 +1,596 @@ +import datetime +import importlib.resources as pkg_resources +import pickle +import shutil +import webbrowser + +from pathlib import Path +from typing import List + +import markdown +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import torch +import torch.nn.functional as F + +from napari_psf_analysis.psf_analysis.extract.BeadExtractor import BeadExtractor +from napari_psf_analysis.psf_analysis.image import Calibrated3DImage +from napari_psf_analysis.psf_analysis.psf import PSF +from numpy.typing import ArrayLike +from scipy.signal import peak_widths + +import mantis.acquisition.scripts + + +def _make_plots( + output_path: Path, + beads: List[ArrayLike], + df_gaussian_fit: pd.DataFrame, + df_1d_peak_width: pd.DataFrame, + scale: tuple, + axis_labels: tuple, + raw: bool = False, +): + plots_dir = output_path / 'plots' + plots_dir.mkdir(parents=True, exist_ok=True) + random_bead_number = sorted(np.random.choice(len(beads), 3)) + + bead_psf_slices_paths = plot_psf_slices( + plots_dir, + [beads[i] for i in random_bead_number], + scale, + axis_labels, + random_bead_number, + ) + + if raw: + plot_data_x = [df_1d_peak_width[col].values for col in ('x_mu', 'y_mu', 'z_mu')] + plot_data_y = [ + df_1d_peak_width[col].values for col in ('1d_x_fwhm', '1d_y_fwhm', '1d_z_fwhm') + ] + else: + plot_data_x = [df_gaussian_fit[col].values for col in ('x_mu', 'y_mu', 'z_mu')] + plot_data_y = [ + df_gaussian_fit[col].values for col in ('zyx_x_fwhm', 'zyx_y_fwhm', 'zyx_z_fwhm') + ] + + fwhm_vs_acq_axes_paths = plot_fwhm_vs_acq_axes( + plots_dir, + *plot_data_x, + *plot_data_y, + axis_labels, + ) + + psf_amp_paths = plot_psf_amp( + plots_dir, + df_gaussian_fit['x_mu'].values, + df_gaussian_fit['y_mu'].values, + df_gaussian_fit['z_mu'].values, + df_gaussian_fit['zyx_amp'].values, + axis_labels, + ) + + return (bead_psf_slices_paths, fwhm_vs_acq_axes_paths, psf_amp_paths) + + +def generate_report( + output_path: Path, + data_dir: Path, + dataset: str, + beads: List[ArrayLike], + peaks: ArrayLike, + df_gaussian_fit: pd.DataFrame, + df_1d_peak_width: pd.DataFrame, + scale: tuple, + axis_labels: tuple, +): + output_path.mkdir(exist_ok=True) + + num_beads = len(beads) + num_successful = len(df_gaussian_fit) + num_failed = num_beads - num_successful + + raw = False + if axis_labels == ("SCAN", "TILT", "COVERSLIP"): + raw = True + + # make plots + (bead_psf_slices_paths, fwhm_vs_acq_axes_paths, psf_amp_paths) = _make_plots( + output_path, beads, df_gaussian_fit, df_1d_peak_width, scale, axis_labels, raw=raw + ) + + # calculate statistics + fwhm_3d_mean = [ + df_gaussian_fit[col].mean() for col in ('zyx_z_fwhm', 'zyx_y_fwhm', 'zyx_x_fwhm') + ] + fwhm_3d_std = [ + df_gaussian_fit[col].std() for col in ('zyx_z_fwhm', 'zyx_y_fwhm', 'zyx_x_fwhm') + ] + fwhm_pc_mean = [ + df_gaussian_fit[col].mean() for col in ('zyx_pc3_fwhm', 'zyx_pc2_fwhm', 'zyx_pc1_fwhm') + ] + fwhm_1d_mean = [ + df_1d_peak_width[col].mean() for col in ('1d_z_fwhm', '1d_y_fwhm', '1d_x_fwhm') + ] + fwhm_1d_std = [ + df_1d_peak_width[col].std() for col in ('1d_z_fwhm', '1d_y_fwhm', '1d_x_fwhm') + ] + + # generate html report + html_report = _generate_html( + dataset, + data_dir, + scale, + (num_beads, num_successful, num_failed), + fwhm_1d_mean, + fwhm_1d_std, + fwhm_3d_mean, + fwhm_3d_std, + fwhm_pc_mean, + [str(_path.relative_to(output_path).as_posix()) for _path in bead_psf_slices_paths], + [str(_path.relative_to(output_path).as_posix()) for _path in fwhm_vs_acq_axes_paths], + [str(_path.relative_to(output_path).as_posix()) for _path in psf_amp_paths], + axis_labels, + ) + + # save html report and other results + with open(output_path / 'peaks.pkl', 'wb') as file: + pickle.dump(peaks, file) + + df_gaussian_fit.to_csv(output_path / 'psf_gaussian_fit.csv', index=False) + df_1d_peak_width.to_csv(output_path / 'psf_1d_peak_width.csv', index=False) + + with pkg_resources.path(mantis.acquisition.scripts, 'github-markdown.css') as css_path: + shutil.copy(css_path, output_path) + html_file_path = output_path / ('psf_analysis_report.html') + with open(html_file_path, 'w') as file: + file.write(html_report) + + # display html report + html_file_path = Path(html_file_path).absolute() + webbrowser.open(html_file_path.as_uri()) + + +def extract_beads( + zyx_data: ArrayLike, points: ArrayLike, scale: tuple, patch_size: tuple = None +): + if patch_size is None: + patch_size = (scale[0] * 15, scale[1] * 18, scale[2] * 18) + + # extract bead patches + bead_extractor = BeadExtractor( + image=Calibrated3DImage(data=zyx_data.astype(np.int32), spacing=scale), + patch_size=patch_size, + ) + beads = bead_extractor.extract_beads(points=points) + # remove bad beads + beads = [bead for bead in beads if bead.data.size > 0] + beads_data = [bead.data for bead in beads] + bead_offset = [bead.offset for bead in beads] + + return beads_data, bead_offset + + +def analyze_psf(zyx_patches: List[ArrayLike], bead_offsets: List[tuple], scale: tuple): + results = [] + for patch, offset in zip(zyx_patches, bead_offsets): + patch = np.clip(patch, 0, None) + bead = Calibrated3DImage(data=patch.astype(np.int32), spacing=scale, offset=offset) + psf = PSF(image=bead) + try: + psf.analyze() + summary_dict = psf.get_summary_dict() + except Exception: + summary_dict = {} + results.append(summary_dict) + + df_gaussian_fit = pd.DataFrame.from_records(results) + bead_offsets = np.asarray(bead_offsets) + + df_gaussian_fit['z_mu'] += bead_offsets[:, 0] * scale[0] + df_gaussian_fit['y_mu'] += bead_offsets[:, 1] * scale[1] + df_gaussian_fit['x_mu'] += bead_offsets[:, 2] * scale[2] + + df_1d_peak_width = pd.DataFrame( + [calculate_peak_widths(zyx_patch, scale) for zyx_patch in zyx_patches], + columns=(f'1d_{i}_fwhm' for i in ('z', 'y', 'x')), + ) + df_1d_peak_width = pd.concat( + (df_gaussian_fit[['z_mu', 'y_mu', 'x_mu']], df_1d_peak_width), axis=1 + ) + + # clean up dataframes + df_gaussian_fit = df_gaussian_fit.dropna() + df_1d_peak_width = df_1d_peak_width.loc[ + ~(df_1d_peak_width[['1d_z_fwhm', '1d_y_fwhm', '1d_x_fwhm']] == 0).any(axis=1) + ] + + return df_gaussian_fit, df_1d_peak_width + + +def calculate_peak_widths(zyx_data: ArrayLike, zyx_scale: tuple): + scale_Z, scale_Y, scale_X = zyx_scale + shape_Z, shape_Y, shape_X = zyx_data.shape + + try: + z_fwhm = peak_widths(zyx_data[:, shape_Y // 2, shape_X // 2], [shape_Z // 2])[0][0] + y_fwhm = peak_widths(zyx_data[shape_Z // 2, :, shape_X // 2], [shape_Y // 2])[0][0] + x_fwhm = peak_widths(zyx_data[shape_Z // 2, shape_Y // 2, :], [shape_X // 2])[0][0] + except Exception: + z_fwhm, y_fwhm, x_fwhm = (0.0, 0.0, 0.0) + + return z_fwhm * scale_Z, y_fwhm * scale_Y, x_fwhm * scale_X + + +def _adjust_fig(fig, ax): + for _ax in ax.flatten(): + _ax.set_xticks([]) + _ax.set_yticks([]) + + plt.tight_layout() + plt.subplots_adjust(wspace=0.5) + fig_size = fig.get_size_inches() + fig_size_scaling = 5 / fig_size[0] # set width to 5 inches + fig.set_figwidth(fig_size[0] * fig_size_scaling) + fig.set_figheight(fig_size[1] * fig_size_scaling) + + +def plot_psf_slices( + plots_dir: str, + beads: List[ArrayLike], + zyx_scale: tuple, + axis_labels: tuple, + bead_numbers: list, +): + num_beads = len(beads) + scale_Z, scale_Y, scale_X = zyx_scale + shape_Z, shape_Y, shape_X = beads[0].shape + cmap = 'viridis' + + bead_xy_psf_path = plots_dir / 'beads_xy_psf.png' + fig, ax = plt.subplots(1, num_beads) + for _ax, bead, bead_number in zip(ax, beads, bead_numbers): + _ax.imshow( + bead[shape_Z // 2, :, :], + cmap=cmap, + origin='lower', + aspect=scale_Y / scale_X, + vmin=0, + ) + _ax.set_xlabel(axis_labels[-1]) + _ax.set_ylabel(axis_labels[-2]) + _ax.set_title(f'Bead: {bead_number}') + _adjust_fig(fig, ax) + fig.set_figheight(2) + fig.savefig(bead_xy_psf_path) + + bead_xz_psf_path = plots_dir / 'beads_xz_psf.png' + fig, ax = plt.subplots(1, num_beads) + for _ax, bead in zip(ax, beads): + _ax.imshow( + bead[:, shape_Y // 2, :], cmap=cmap, origin='lower', aspect=scale_Z / scale_X + ) + _ax.set_xlabel(axis_labels[-1]) + _ax.set_ylabel(axis_labels[-3]) + _adjust_fig(fig, ax) + fig.savefig(bead_xz_psf_path) + + bead_yz_psf_path = plots_dir / 'beads_yz_psf.png' + fig, ax = plt.subplots(1, num_beads) + for _ax, bead in zip(ax, beads): + _ax.imshow( + bead[:, :, shape_X // 2], cmap=cmap, origin='lower', aspect=scale_Z / scale_Y + ) + _ax.set_xlabel(axis_labels[-2]) + _ax.set_ylabel(axis_labels[-3]) + _adjust_fig(fig, ax) + fig.savefig(bead_yz_psf_path) + + return bead_xy_psf_path, bead_xz_psf_path, bead_yz_psf_path + + +def plot_fwhm_vs_acq_axes(plots_dir: str, x, y, z, fwhm_x, fwhm_y, fwhm_z, axis_labels: tuple): + def plot_fwhm_vs_acq_axis(out_dir: str, x, fwhm_x, fwhm_y, fwhm_z, x_axis_label: str): + fig, ax = plt.subplots(1, 1) + artist1 = ax.plot(x, fwhm_x, 'o', x, fwhm_y, 'o') + ax.set_ylabel('{} and {} FWHM (um)'.format(*axis_labels[1:][::-1])) + ax.set_xlabel('{} position (um)'.format(x_axis_label)) + + ax2 = ax.twinx() + artist2 = ax2.plot(x, fwhm_z, 'o', color='green') + ax2.set_ylabel('{} FWHM (um)'.format(axis_labels[0]), color='green') + ax2.tick_params(axis='y', labelcolor='green') + plt.legend(artist1 + artist2, axis_labels[::-1]) + fig.savefig(out_dir) + + out_dirs = [plots_dir / f'fwhm_vs_{axis}.png' for axis in axis_labels] + for our_dir, x_axis, x_axis_label in zip(out_dirs, (z, y, x), axis_labels): + plot_fwhm_vs_acq_axis(our_dir, x_axis, fwhm_x, fwhm_y, fwhm_z, x_axis_label) + + return out_dirs + + +def plot_psf_amp(plots_dir: str, x, y, z, amp, axis_labels: tuple): + psf_amp_xy_path = plots_dir / 'psf_amp_xy.png' + fig, ax = plt.subplots(1, 1) + + sc = ax.scatter( + x, + y, + c=amp, + vmin=np.quantile(amp, 0.01), + vmax=np.quantile(amp, 0.99), + cmap='summer', + ) + ax.set_aspect('equal') + ax.set_xlabel(f'{axis_labels[-1]} (um)') + ax.set_ylabel(f'{axis_labels[-2]} (um)') + plt.colorbar(sc, label='Amplitude (a.u.)') + fig.savefig(psf_amp_xy_path) + + psf_amp_z_path = plots_dir / 'psf_amp_z.png' + fig, ax = plt.subplots(1, 1) + ax.scatter(z, amp) + ax.set_xlabel(f'{axis_labels[-3]} (um)') + ax.set_ylabel('Amplitude (a.u.)') + fig.savefig(psf_amp_z_path) + + return psf_amp_xy_path, psf_amp_z_path + + +def _generate_html( + dataset_name: str, + data_path: str, + dataset_scale: tuple, + num_beads_total_good_bad: tuple, + fwhm_1d_mean: tuple, + fwhm_1d_std: tuple, + fwhm_3d_mean: tuple, + fwhm_3d_std: tuple, + fwhm_pc_mean: tuple, + bead_psf_slices_paths: list, + fwhm_vs_acq_axes_paths: list, + psf_amp_paths: list, + axis_labels: tuple, +): + + # string indents need to be like that, otherwise this turns into a code block + report_str = f''' +# PSF Analysis + +## Overview + +### Dataset + +* Name: `{dataset_name}` +* Path: `{data_path}` +* Scale: {tuple(np.round(dataset_scale[::-1], 3))} um +* Date analyzed: {datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} + +### Number of beads + +* Detected: {num_beads_total_good_bad[0]} +* Analyzed: {num_beads_total_good_bad[1]} +* Skipped: {num_beads_total_good_bad[2]} + +### FWHM + +* **3D Gaussian fit** + - {axis_labels[-1]}: {fwhm_3d_mean[-1]:.3f} ± {fwhm_3d_std[0]:.3f} um + - {axis_labels[-2]}: {fwhm_3d_mean[-2]:.3f} ± {fwhm_3d_std[1]:.3f} um + - {axis_labels[-3]}: {fwhm_3d_mean[-3]:.3f} ± {fwhm_3d_std[2]:.3f} um +* 1D profile + - {axis_labels[-1]}: {fwhm_1d_mean[-1]:.3f} ± {fwhm_1d_std[0]:.3f} um + - {axis_labels[-2]}: {fwhm_1d_mean[-2]:.3f} ± {fwhm_1d_std[1]:.3f} um + - {axis_labels[-3]}: {fwhm_1d_mean[-3]:.3f} ± {fwhm_1d_std[2]:.3f} um +* 3D principal components + - {'{:.3f} um, {:.3f} um, {:.3f} um'.format(*fwhm_pc_mean)} + +## Representative bead PSF images +![beads xy psf]({bead_psf_slices_paths[0]}) +![beads xz psf]({bead_psf_slices_paths[1]}) +![beads yz psf]({bead_psf_slices_paths[2]}) + +## FWHM versus {axis_labels[0]} position +![fwhm vs z]({fwhm_vs_acq_axes_paths[0]} "fwhm vs z") + +## FWHM versus {axis_labels[1]} position +![fwhm vs z]({fwhm_vs_acq_axes_paths[1]} "fwhm vs y") + +## FWHM versus {axis_labels[2]} position +![fwhm vs z]({fwhm_vs_acq_axes_paths[2]} "fwhm vs x") + +## PSF amplitude versus {axis_labels[-1]}-{axis_labels[-2]} position +![psf amp xy]({psf_amp_paths[0]} "psf amp xy") + +## PSF amplitude versus {axis_labels[-3]} position +![psf amp z]({psf_amp_paths[1]} "psf amp z") +''' + + css_style = ''' + + + +''' + + head = f''' + + PSF Analysis: {dataset_name} + + ''' + + html = markdown.markdown(report_str) + formatted_html = f''' +{css_style} +{head} +
+{html} +
+'''.strip() + + return formatted_html + + +def detect_peaks( + zyx_data: np.ndarray, + block_size: int | tuple[int, int, int] = (8, 8, 8), + nms_distance: int = 3, + min_distance: int = 40, + threshold_abs: float = 200.0, + max_num_peaks: int = 500, + exclude_border: tuple[int, int, int] | None = None, + blur_kernel_size: int = 3, + device: str = "cpu", + verbose: bool = False, +): + """Detect peaks with local maxima. + This is an approximate torch implementation of `skimage.feature.peak_local_max`. + The algorithm works well with small kernel size, by default (8, 8, 8) which + generates a large number of peak candidates, and strict peak rejection criteria + - e.g. max_num_peaks=500, which selects top 500 brightest peaks and + threshold_abs=200.0, which selects peaks with intensity of at least 200 counts. + + Parameters + ---------- + zyx_data : np.ndarray + 3D image data + block_size : int | tuple[int, int, int], optional + block size to find approximate local maxima, by default (8, 8, 8) + nms_distance : int, optional + non-maximum suppression distance, by default 3 + distance is calculated assuming a Cartesian coordinate system + min_distance : int, optional + minimum distance between detections, + distance needs to be smaller than block size for efficiency, + by default 40 + threshold_abs : float, optional + lower bound of detected peak intensity, by default 200.0 + max_num_peaks : int, optional + max number of candidate detections to consider, by default 500 + exclude_border : tuple[int, int, int] | None, optional + width of borders to exclude, by default None + blur_kernel_size : int, optional + uniform kernel size to blur the image before detection + to avoid hot pixels, by default 3 + device : str, optional + compute device string for torch, + e.g. "cpu" (slow), "cuda" (single GPU) or "cuda:0" (0th GPU among multiple), + by default "cpu" + verbose : bool, optional + print number of peaks detected and rejected, by default False + + Returns + ------- + np.ndarray + 3D coordinates of detected peaks (N, 3) + + """ + zyx_shape = zyx_data.shape[-3:] + zyx_image = torch.from_numpy(zyx_data.astype(np.float32)[None, None]) + + if device != "cpu": + zyx_image = zyx_image.to(device) + + if blur_kernel_size: + if blur_kernel_size % 2 != 1: + raise ValueError(f"kernel_size={blur_kernel_size} must be an odd number") + # smooth image + # input and output variables need to be different for proper memory clearance + smooth_image = F.avg_pool3d( + input=zyx_image, + kernel_size=blur_kernel_size, + stride=1, + padding=blur_kernel_size // 2, + count_include_pad=False, + ) + + # detect peaks as local maxima + peak_value, peak_idx = ( + p.flatten().clone() + for p in F.max_pool3d( + smooth_image, + kernel_size=block_size, + stride=block_size, + padding=(block_size[0] // 2, block_size[1] // 2, block_size[2] // 2), + return_indices=True, + ) + ) + num_peaks = len(peak_idx) + + # select only top max_num_peaks brightest peaks + # peak_value (and peak_idx) are now sorted by brightness + peak_value, sort_mask = peak_value.topk(min(max_num_peaks, peak_value.nelement())) + peak_idx = peak_idx[sort_mask] + num_rejected_max_num_peaks = num_peaks - len(sort_mask) + + # select only peaks above intensity threshold + num_rejected_threshold_abs = 0 + if threshold_abs: + abs_mask = peak_value > threshold_abs + peak_value = peak_value[abs_mask] + peak_idx = peak_idx[abs_mask] + num_rejected_threshold_abs = sum(~abs_mask) + + # remove artifacts of multiple peaks detected at block boundaries + # requires torch>=2.2 + coords = torch.stack(torch.unravel_index(peak_idx, zyx_shape), -1) + fcoords = coords.float() + dist = torch.cdist(fcoords, fcoords) + dist_mask = torch.ones(len(coords), dtype=bool, device=device) + + nearby_peaks = torch.nonzero(torch.triu(dist < nms_distance, diagonal=1)) + dist_mask[nearby_peaks[:, 1]] = False # peak in second column is dimmer + num_rejected_nms_distance = sum(~dist_mask) + + # remove peaks withing min_distance of each other + num_rejected_min_distance = 0 + if min_distance: + _dist_mask = dist < min_distance + # exclude distances from nearby peaks rejected above + _dist_mask[nearby_peaks[:, 0], nearby_peaks[:, 1]] = False + dist_mask &= _dist_mask.sum(1) < 2 # Ziwen magic + num_rejected_min_distance = sum(~dist_mask) - num_rejected_nms_distance + coords = coords[dist_mask] + + # remove peaks near the border + num_rejected_exclude_border = 0 + match exclude_border: + case None: + pass + case (int(), int(), int()): + for dim, size in enumerate(exclude_border): + border_mask = (size < coords[:, dim]) & ( + coords[:, dim] < zyx_shape[dim] - size + ) + coords = coords[border_mask] + num_rejected_exclude_border += sum(~border_mask) + case _: + raise ValueError(f"invalid argument exclude_border={exclude_border}") + + num_peaks_returned = len(coords) + if verbose: + print(f'Number of peaks detected: {num_peaks}') + print(f'Number of peaks rejected by max_num_peaks: {num_rejected_max_num_peaks}') + print(f'Number of peaks rejected by threshold_abs: {num_rejected_threshold_abs}') + print(f'Number of peaks rejected by nms_distance: {num_rejected_nms_distance}') + print(f'Number of peaks rejected by min_distance: {num_rejected_min_distance}') + print(f'Number of peaks rejected by exclude_border: {num_rejected_exclude_border}') + print(f'Number of peaks returned: {num_peaks_returned}') + + del zyx_image, smooth_image + return coords.cpu().numpy() diff --git a/mantis/analysis/deskew.py b/mantis/analysis/deskew.py index 15c79084..e4e6494e 100644 --- a/mantis/analysis/deskew.py +++ b/mantis/analysis/deskew.py @@ -55,6 +55,40 @@ def _get_averaged_shape(deskewed_data_shape: tuple, average_window_width: int) - return averaged_shape +def _get_transform_matrix(ls_angle_deg: float, px_to_scan_ratio: float): + """ + Compute affine transformation matrix used to deskew data. + + Parameters + ---------- + ls_angle_deg : float + px_to_scan_ratio : float + keep_overhang : bool + + Returns + ------- + matrix : np.array + Affine transformation matrix. + """ + ct = np.cos(ls_angle_deg * np.pi / 180) + + matrix = np.array( + [ + [ + -px_to_scan_ratio * ct, + 0, + px_to_scan_ratio, + 0, + ], + [-1, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 0, 1], + ] + ) + + return matrix + + def get_deskewed_data_shape( raw_data_shape: tuple, ls_angle_deg: float, @@ -119,7 +153,7 @@ def deskew_data( keep_overhang: bool, average_n_slices: int = 1, device='cpu', -): +) -> np.ndarray: """Deskews fluorescence data from the mantis microscope Parameters ---------- @@ -150,20 +184,11 @@ def deskew_data( axis 2 is the X axis, the scanning axis """ # Prepare transforms - ct = np.cos(ls_angle_deg * np.pi / 180) - matrix = np.array( - [ - [ - -px_to_scan_ratio * ct, - 0, - px_to_scan_ratio, - 0, - ], - [-1, 0, 0, 0], - [0, -1, 0, 0], - [0, 0, 0, 1], - ] + matrix = _get_transform_matrix( + ls_angle_deg, + px_to_scan_ratio, ) + output_shape, _ = get_deskewed_data_shape( raw_data.shape, ls_angle_deg, px_to_scan_ratio, keep_overhang ) diff --git a/mantis/analysis/settings/example_register_settings.yml b/mantis/analysis/settings/example_register_settings.yml index b5ef5c76..954fdabf 100644 --- a/mantis/analysis/settings/example_register_settings.yml +++ b/mantis/analysis/settings/example_register_settings.yml @@ -1,4 +1,4 @@ -# Typical workflows use `estimate-registration` to generate these config files. +# Typical workflows use `estimate-registration` to generate these config files. source_channel_names: [Phase3D, Orientation, Retardance, Birefringence] target_channel_name: GFP # Single target channel. All others channels in target store are copied. affine_transform_zyx: # in pixel units, i.e. not scaled diff --git a/mantis/cli/characterize_psf.py b/mantis/cli/characterize_psf.py new file mode 100644 index 00000000..091415d3 --- /dev/null +++ b/mantis/cli/characterize_psf.py @@ -0,0 +1,113 @@ +import gc +import time +import warnings + +from pathlib import Path +from typing import List + +import click +import numpy as np +import torch + +from iohub.ngff import open_ome_zarr + +from mantis.analysis.AnalysisSettings import CharacterizeSettings +from mantis.analysis.analyze_psf import ( + analyze_psf, + detect_peaks, + extract_beads, + generate_report, +) +from mantis.cli.parsing import config_filepath, input_position_dirpaths, output_dirpath +from mantis.cli.utils import yaml_to_model + + +def _characterize_psf( + zyx_data: np.ndarray, + zyx_scale: tuple[float, float, float], + settings: CharacterizeSettings, + output_report_path: str, + input_dataset_path: str, + input_dataset_name: str, +): + settings_dict = settings.dict() + patch_size = settings_dict.pop("patch_size") + axis_labels = settings_dict.pop("axis_labels") + + click.echo("Detecting peaks...") + t1 = time.time() + peaks = detect_peaks( + zyx_data, + **settings_dict, + verbose=True, + ) + gc.collect() + torch.cuda.empty_cache() + t2 = time.time() + click.echo(f'Time to detect peaks: {t2-t1}') + + t1 = time.time() + beads, offsets = extract_beads( + zyx_data=zyx_data, + points=peaks, + scale=zyx_scale, + patch_size=patch_size, + ) + + click.echo("Analyzing PSFs...") + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + df_gaussian_fit, df_1d_peak_width = analyze_psf( + zyx_patches=beads, + bead_offsets=offsets, + scale=zyx_scale, + ) + t2 = time.time() + click.echo(f'Time to analyze PSFs: {t2-t1}') + + # Generate HTML report + generate_report( + output_report_path, + input_dataset_path, + input_dataset_name, + beads, + peaks, + df_gaussian_fit, + df_1d_peak_width, + zyx_scale, + axis_labels, + ) + + return peaks + + +@click.command() +@input_position_dirpaths() +@config_filepath() +@output_dirpath() +def characterize_psf( + input_position_dirpaths: List[str], + config_filepath: str, + output_dirpath: str, +): + """ + Characterize the point spread function (PSF) from bead images and output an html report + + >> mantis characterize-psf -i ./beads.zarr/*/*/* -c ./characterize_params.yml -o ./ + """ + if len(input_position_dirpaths) > 1: + warnings.warn("Only the first position will be characterized.") + + click.echo("Loading data...") + with open_ome_zarr(str(input_position_dirpaths[0]), mode="r") as input_dataset: + T, C, Z, Y, X = input_dataset.data.shape + zyx_data = input_dataset["0"][0, 0] + zyx_scale = input_dataset.scale[-3:] + + # Read settings + settings = yaml_to_model(config_filepath, CharacterizeSettings) + dataset_name = Path(input_position_dirpaths[0])[-4] + + _ = _characterize_psf( + zyx_data, zyx_scale, settings, output_dirpath, input_position_dirpaths[0], dataset_name + ) diff --git a/mantis/cli/deconvolve.py b/mantis/cli/deconvolve.py new file mode 100644 index 00000000..aa5a0c3d --- /dev/null +++ b/mantis/cli/deconvolve.py @@ -0,0 +1,125 @@ +from pathlib import Path +from typing import List + +import click +import numpy as np +import torch + +from iohub import open_ome_zarr +from waveorder.models.isotropic_fluorescent_thick_3d import apply_inverse_transfer_function + +from mantis.analysis.AnalysisSettings import DeconvolveSettings +from mantis.cli.parsing import ( + _str_to_path, + config_filepath, + input_position_dirpaths, + output_dirpath, +) +from mantis.cli.utils import create_empty_hcs_zarr, yaml_to_model + + +def apply_deconvolve_single_position( + input_position_dirpath: str, psf_dirpath: str, config_filepath: str, output_dirpath: Path +): + """ + Apply deconvolution to a single position + """ + # Read settings + settings = yaml_to_model(Path(config_filepath), DeconvolveSettings) + + # Load the data + input_dataset = open_ome_zarr(input_position_dirpath, mode="r") + output_dataset = open_ome_zarr(output_dirpath, mode="a") + T, C, Z, Y, X = input_dataset.data.shape + + # Load the PSF + with open_ome_zarr(psf_dirpath, mode="r") as psf_dataset: + position = psf_dataset["0/0/0"] + psf_data = position["0"][0, 0] + psf_scale = position.scale[-3:] + + click.echo("Padding PSF...") + zyx_padding = np.array((Z, Y, X)) - np.array(psf_data.shape) + pad_width = [(x // 2, x // 2) if x % 2 == 0 else (x // 2, x // 2 + 1) for x in zyx_padding] + padded_average_psf = np.pad( + psf_data, pad_width=pad_width, mode="constant", constant_values=0 + ) + + click.echo("Calculating transfer function...") + transfer_function = torch.abs(torch.fft.fftn(torch.tensor(padded_average_psf))) + transfer_function /= torch.max(transfer_function) + + zyx_scale = input_dataset.scale[-3:] + + # Check if scales match + if psf_scale != zyx_scale: + click.echo( + f"Warning: PSF scale {psf_scale} does not match data scale {zyx_scale}. " + "Consider resampling the PSF." + ) + + for t in range(1): # T): + for c in range(C): + zyx_data = input_dataset["0"][t, c] + + # Apply deconvolution + click.echo(f"Deconvolving channel {c}/{C-1}, time {t}/{T-1}") + zyx_data_deconvolved = apply_inverse_transfer_function( + torch.tensor(zyx_data), + torch.tensor(transfer_function), + 0, + regularization_strength=settings.regularization_strength, + ) + click.echo("Saving to output...") + output_dataset["0"][t, c] = zyx_data_deconvolved.numpy() + + input_dataset.close() + output_dataset.close() + + +@click.command() +@input_position_dirpaths() +@click.option( + "--psf-dirpath", + "-p", + required=True, + type=click.Path(exists=True, file_okay=False, dir_okay=True), + callback=_str_to_path, + help="Path to psf.zarr", +) +@config_filepath() +@output_dirpath() +def deconvolve( + input_position_dirpaths: List[str], + psf_dirpath: str, + config_filepath: str, + output_dirpath: str, +): + """ + Deconvolve across T and C axes using a PSF and a configuration file + + >> mantis deconvolve -i ./input.zarr/*/*/* -p ./psf.zarr -c ./deconvolve_params.yml -o ./output.zarr + """ + # Convert string paths to Path objects + output_dirpath = Path(output_dirpath) + config_filepath = Path(config_filepath) + + # Create output zarr store + click.echo("Creating empty output zarr...") + with open_ome_zarr(str(input_position_dirpaths[0]), mode="r") as input_dataset: + create_empty_hcs_zarr( + store_path=output_dirpath, + position_keys=[p.parts[-3:] for p in input_position_dirpaths], + channel_names=input_dataset.channel_names, + shape=input_dataset.data.shape, + scale=input_dataset.scale, + ) + + # Loop through positions + for input_position_dirpath in input_position_dirpaths: + apply_deconvolve_single_position( + input_position_dirpath, + psf_dirpath, + config_filepath, + output_dirpath / Path(*input_position_dirpath.parts[-3:]), + ) diff --git a/mantis/cli/estimate_psf.py b/mantis/cli/estimate_psf.py new file mode 100644 index 00000000..7c99b441 --- /dev/null +++ b/mantis/cli/estimate_psf.py @@ -0,0 +1,124 @@ +import gc +import time + +from pathlib import Path +from typing import List + +import click +import numpy as np +import torch + +from iohub.ngff import open_ome_zarr +from iohub.ngff_meta import TransformationMeta + +from mantis.analysis.AnalysisSettings import PsfFromBeadsSettings +from mantis.analysis.analyze_psf import detect_peaks, extract_beads +from mantis.cli.parsing import config_filepath, input_position_dirpaths, output_dirpath +from mantis.cli.utils import yaml_to_model + + +@click.command() +@input_position_dirpaths() +@config_filepath() +@output_dirpath() +def estimate_psf( + input_position_dirpaths: List[str], + config_filepath: str, + output_dirpath: str, +): + """ + Estimate the point spread function (PSF) from bead images + + >> mantis estimate-psf -i ./beads.zarr/*/*/* -c ./psf_params.yml -o ./psf.zarr + """ + # Convert string paths to Path objects + output_dirpath = Path(output_dirpath) + config_filepath = Path(config_filepath) + + # Load the first position + click.echo("Loading data...") + pzyx_data = [] + for input_position_dirpath in input_position_dirpaths: + with open_ome_zarr(str(input_position_dirpath), mode="r") as input_dataset: + T, C, Z, Y, X = input_dataset.data.shape + pzyx_data.append(input_dataset["0"][0, 0]) + zyx_scale = input_dataset.scale[-3:] + + try: + pzyx_data = np.array(pzyx_data) + except Exception: + raise "Concatenating position arrays failed." + + # Read settings + settings = yaml_to_model(config_filepath, PsfFromBeadsSettings) + patch_size = ( + settings.axis0_patch_size, + settings.axis1_patch_size, + settings.axis2_patch_size, + ) + + # Some of these settings can be moved to PsfFromBeadsSettings as needed + bead_detection_settings = { + "block_size": (64, 64, 32), + "blur_kernel_size": 3, + "nms_distance": 32, + "min_distance": 50, + "threshold_abs": 200.0, + "max_num_peaks": 2000, + "exclude_border": (5, 10, 5), + "device": "cuda" if torch.cuda.is_available() else "cpu", + } + + pbzyx_data = [] + for zyx_data in pzyx_data: + # Detect and extract bead patches + click.echo("Detecting beads...") + t1 = time.time() + peaks = detect_peaks( + zyx_data, + **bead_detection_settings, + verbose=True, + ) + gc.collect() + + torch.cuda.empty_cache() + t2 = time.time() + click.echo(f'Time to detect peaks: {t2-t1}') + + beads, _ = extract_beads( + zyx_data=zyx_data, + points=peaks, + scale=zyx_scale, + patch_size=tuple([a * b for a, b in zip(patch_size, zyx_scale)]), + ) + + # Filter PSFs with non-standard shapes + filtered_beads = [x for x in beads if x.shape == beads[0].shape] + bzyx_data = np.stack(filtered_beads) + pbzyx_data.append(bzyx_data) + + bzyx_data = np.concatenate(pbzyx_data) + click.echo(f"Total beads: {bzyx_data.shape[0]}") + + normalized_bzyx_data = ( + bzyx_data / np.max(bzyx_data, axis=(-3, -2, -1))[:, None, None, None] + ) + average_psf = np.mean(normalized_bzyx_data, axis=0) + + # Simple background subtraction and normalization + average_psf -= np.min(average_psf) + average_psf /= np.max(average_psf) + + # Save + with open_ome_zarr( + output_dirpath, layout="hcs", mode="w", channel_names=["PSF"] + ) as output_dataset: + pos = output_dataset.create_position("0", "0", "0") + array = pos.create_zeros( + name="0", + shape=2 * (1,) + average_psf.shape, + chunks=2 * (1,) + average_psf.shape, + dtype=np.float32, + transform=[TransformationMeta(type="scale", scale=2 * (1,) + tuple(zyx_scale))], + ) + array[0, 0] = average_psf diff --git a/mantis/cli/main.py b/mantis/cli/main.py index 8c59d2d2..71d8991c 100644 --- a/mantis/cli/main.py +++ b/mantis/cli/main.py @@ -1,9 +1,12 @@ import click +from mantis.cli.characterize_psf import characterize_psf from mantis.cli.concatenate import concatenate +from mantis.cli.deconvolve import deconvolve from mantis.cli.deskew import deskew from mantis.cli.estimate_bleaching import estimate_bleaching from mantis.cli.estimate_deskew import estimate_deskew +from mantis.cli.estimate_psf import estimate_psf from mantis.cli.estimate_registration import estimate_registration from mantis.cli.estimate_stabilization import estimate_stabilization from mantis.cli.estimate_stitch import estimate_stitch @@ -41,3 +44,6 @@ def cli(): cli.add_command(concatenate) cli.add_command(estimate_stabilization) cli.add_command(stabilize) +cli.add_command(estimate_psf) +cli.add_command(deconvolve) +cli.add_command(characterize_psf) diff --git a/mantis/tests/test_analysis/test_deskew.py b/mantis/tests/test_analysis/test_deskew.py index 28997685..6d588374 100644 --- a/mantis/tests/test_analysis/test_deskew.py +++ b/mantis/tests/test_analysis/test_deskew.py @@ -36,6 +36,7 @@ def test_deskew_data(): raw_data, ls_angle_deg, px_to_scan_ratio, keep_overhang, average_n_slices ) assert deskewed_data.shape[1] == 4 + assert deskewed_data[0, 0, 0] != 0 # indicates incorrect shifting assert ( deskewed_data.shape diff --git a/pyproject.toml b/pyproject.toml index c2dd3f3c..f94a53ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "ndtiff>=2.0", "nidaqmx", "numpy<2", + "markdown", "monai", "pandas~=2.1", "pycromanager==0.28.1", @@ -38,10 +39,12 @@ dependencies = [ "scipy", "slurmkit @ git+https://github.com/royerlab/slurmkit", "tifffile", + "torch>=2.3", "waveorder @ git+https://github.com/mehta-lab/waveorder", "largestinteriorrectangle", "antspyx", "pystackreg", + "napari-psf-analysis", ] @@ -73,7 +76,7 @@ version = { attr = "mantis.__version__" } [tool.black] line-length = 95 -target-version = ['py39', 'py310'] +target-version = ['py310'] include = '\.pyi?$' skip-string-normalization = true exclude = '''