qchapp commited on
Commit
9aa89dd
·
verified ·
1 Parent(s): 19bc1e0

Sync from GitHub Actions (skip png)

Browse files
Files changed (6) hide show
  1. .gitignore +166 -0
  2. README.md +103 -2
  3. app.py +281 -141
  4. core/registration.py +361 -0
  5. core/utils.py +5 -5
  6. requirements.txt +4 -4
.gitignore ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ env/
126
+ venv/
127
+ ENV/
128
+ env.bak/
129
+ venv.bak/
130
+
131
+ # Spyder project settings
132
+ .spyderproject
133
+ .spyproject
134
+
135
+ # Rope project settings
136
+ .ropeproject
137
+
138
+ # mkdocs documentation
139
+ /site
140
+
141
+ # mypy
142
+ .mypy_cache/
143
+ .dmypy.json
144
+ dmypy.json
145
+
146
+ # Pyre type checker
147
+ .pyre/
148
+
149
+ # pytype static type analyzer
150
+ .pytype/
151
+
152
+ # Cython debug symbols
153
+ cython_debug/
154
+
155
+ # PyCharm
156
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
159
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
+ #.idea/
161
+
162
+ .gradio/
163
+ flagged/
164
+ else/
165
+ data/
166
+ .devcontainer/
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🧠
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
7
- sdk_version: 5.25.1
8
  app_file: app.py
9
  pinned: false
10
  tags:
@@ -12,12 +12,13 @@ tags:
12
  - registration
13
  - pystackreg
14
  ---
 
15
  # 🧠 Stack Image Registration Web App
16
  A web-based application for image stack registration powered by **Gradio** and **pystackreg**.
17
  This tool allows users to align and stabilize multi-frame TIFF images using a variety of transformation models.
18
 
19
  <p align="center">
20
- <img src="images/app.png" height="500">
21
  </p>
22
 
23
  ---
@@ -122,6 +123,106 @@ https://huggingface.co/spaces/qchapp/pystackreg-app?file_url_1=https://github.co
122
 
123
  ---
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  ### 📚 Credits
126
 
127
  - **App Author**: [Quentin Chappuis](https://github.com/qchapp)
 
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 5.49.1
8
  app_file: app.py
9
  pinned: false
10
  tags:
 
12
  - registration
13
  - pystackreg
14
  ---
15
+
16
  # 🧠 Stack Image Registration Web App
17
  A web-based application for image stack registration powered by **Gradio** and **pystackreg**.
18
  This tool allows users to align and stabilize multi-frame TIFF images using a variety of transformation models.
19
 
20
  <p align="center">
21
+ <img src="https://raw.githubusercontent.com/qchapp/pystackreg-app/master/images/app.png" height="500">
22
  </p>
23
 
24
  ---
 
123
 
124
  ---
125
 
126
+ ## 🤖 MCP Server
127
+
128
+ This app doubles as a **Model Context Protocol (MCP) server**, exposing the three core registration workflows as callable MCP tools that any MCP-compatible client (e.g. Claude Desktop, GitHub Copilot in VS Code) can invoke programmatically.
129
+
130
+ ### Running the app as an MCP server
131
+
132
+ ```sh
133
+ python app.py
134
+ ```
135
+
136
+ The human-facing Gradio UI is available at [http://localhost:7860](http://localhost:7860) as usual.
137
+ The MCP endpoint is available at:
138
+
139
+ - **MCP server**: `http://localhost:7860/gradio_api/mcp/sse`
140
+ - **MCP schema**: `http://localhost:7860/gradio_api/mcp/schema`
141
+
142
+ ### Available MCP tools
143
+
144
+ #### 1. `align_stack_to_reference`
145
+ Align every frame in a TIFF stack to a chosen reference frame (intra-stack alignment).
146
+
147
+ | Argument | Type | Default | Description |
148
+ |---|---|---|---|
149
+ | `stack_file` | `str` | — | Path to the input TIFF stack |
150
+ | `reference_index` | `int` | `0` | Zero-based index of the reference frame inside the stack |
151
+ | `mode` | `str` | `"RIGID_BODY"` | Transformation mode (see below) |
152
+ | `external_reference_file` | `str \| None` | `None` | Optional path to an external reference TIFF stack |
153
+ | `external_reference_index` | `int` | `0` | Frame index inside the external reference stack |
154
+
155
+ **Returns**: path to the aligned output TIFF file.
156
+
157
+ **Example arguments:**
158
+ ```json
159
+ {
160
+ "stack_file": "/data/pc12-unreg.tif",
161
+ "reference_index": 0,
162
+ "mode": "RIGID_BODY"
163
+ }
164
+ ```
165
+
166
+ ---
167
+
168
+ #### 2. `align_stack_to_stack`
169
+ Align every frame in a moving TIFF stack to the first frame of a reference TIFF stack.
170
+
171
+ | Argument | Type | Default | Description |
172
+ |---|---|---|---|
173
+ | `reference_stack_file` | `str` | — | Path to the reference TIFF stack |
174
+ | `moving_stack_file` | `str` | — | Path to the moving TIFF stack |
175
+ | `mode` | `str` | `"RIGID_BODY"` | Transformation mode (see below) |
176
+
177
+ **Returns**: path to the aligned output TIFF file.
178
+
179
+ **Example arguments:**
180
+ ```json
181
+ {
182
+ "reference_stack_file": "/data/pc12-unreg.tif",
183
+ "moving_stack_file": "/data/pc12-reg-translation.tif",
184
+ "mode": "TRANSLATION"
185
+ }
186
+ ```
187
+
188
+ ---
189
+
190
+ #### 3. `align_frame_to_frame`
191
+ Align a single moving frame to a reference frame within the same TIFF stack.
192
+
193
+ | Argument | Type | Default | Description |
194
+ |---|---|---|---|
195
+ | `stack_file` | `str` | — | Path to the TIFF stack containing both frames |
196
+ | `reference_index` | `int` | — | Zero-based index of the reference frame |
197
+ | `moving_index` | `int` | — | Zero-based index of the frame to align |
198
+ | `mode` | `str` | `"RIGID_BODY"` | Transformation mode (see below) |
199
+
200
+ **Returns**: path to the aligned single-frame output TIFF file.
201
+
202
+ **Example arguments:**
203
+ ```json
204
+ {
205
+ "stack_file": "/data/pc12-unreg.tif",
206
+ "reference_index": 0,
207
+ "moving_index": 5,
208
+ "mode": "AFFINE"
209
+ }
210
+ ```
211
+
212
+ ---
213
+
214
+ ### Supported transformation modes
215
+
216
+ | Mode | Description |
217
+ |---|---|
218
+ | `TRANSLATION` | Translation only (x/y shift) |
219
+ | `RIGID_BODY` | Translation + rotation (default) |
220
+ | `SCALED_ROTATION` | Translation + rotation + uniform scaling |
221
+ | `AFFINE` | Full affine transformation |
222
+ | `BILINEAR` | Bilinear (non-linear) transformation |
223
+
224
+ ---
225
+
226
  ### 📚 Credits
227
 
228
  - **App Author**: [Quentin Chappuis](https://github.com/qchapp)
app.py CHANGED
@@ -1,116 +1,162 @@
1
  import gradio as gr
2
- import numpy as np
3
  from PIL import Image
4
- from pystackreg import StackReg
5
- import imageio.v2 as iio
6
  import tifffile
7
  import tempfile
8
- import urllib.request
9
  import os
 
10
 
11
  from core.utils import (
12
- get_sr_mode, normalize_stack, upscale, load_stack,
13
- _is_demo_url, _demo_path_for_url,
14
  _start_cleaner, citation_markdown, documentation_markdown
15
  )
 
 
 
 
 
 
 
 
16
 
17
  _start_cleaner()
18
 
19
- # Globals
20
- original_frames, aligned_frames = [], []
21
- ref_frames, mov_frames, reg_frames = [], [], []
22
- custom_stack, reg_result = [], None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- # Reset functions
25
  def reset_intra_stack():
26
- global original_frames, aligned_frames
27
- original_frames, aligned_frames = [], []
28
  return [None, gr.update(value=0, minimum=0, maximum=0), None, gr.update(value=0, minimum=0, maximum=0),
29
- None, gr.update(value=0, minimum=0, maximum=0), None, gr.update(value=0, minimum=0, maximum=0), None]
 
30
 
31
  def reset_reference_based():
32
- global ref_frames, mov_frames, reg_frames
33
- ref_frames, mov_frames, reg_frames = [], [], []
34
  return [None, None, None, gr.update(value=0, minimum=0, maximum=0),
35
- None, gr.update(value=0, minimum=0, maximum=0), None]
 
36
 
37
  def reset_frame_to_frame():
38
- global custom_stack, reg_result
39
- custom_stack, reg_result = [], None
40
  return [None, gr.update(value=0, minimum=0, maximum=0),
41
  gr.update(value=0, minimum=0, maximum=0), None, None]
42
 
43
- # Registration logic
44
  def intra_stack_align(f, ref_idx, ext_file, ext_idx, mode):
45
- global original_frames, aligned_frames
46
- stack = load_stack(f)
47
- original_frames = [Image.fromarray(fr) for fr in stack]
48
- sr = StackReg(get_sr_mode(mode))
49
-
50
- ref = load_stack(ext_file)[ext_idx] if ext_file else stack[ref_idx]
51
- aligned = [sr.register_transform(ref, fr) for fr in stack]
52
- aligned = normalize_stack(np.stack(aligned))
53
- aligned_frames = [upscale(Image.fromarray(fr)) for fr in aligned]
54
-
55
- path = tempfile.NamedTemporaryFile(suffix=".tif", delete=False).name
56
- tifffile.imwrite(path, aligned, photometric='minisblack')
57
-
 
 
 
 
 
 
58
  return (
59
- original_frames[0], gr.update(value=0, maximum=len(original_frames)-1),
60
- aligned_frames[0], gr.update(value=0, maximum=len(aligned_frames)-1), path
 
61
  )
62
 
63
  def reference_align(ref_file, mov_file, mode):
64
- global ref_frames, mov_frames, reg_frames
 
 
 
 
 
 
65
  ref_stack = load_stack(ref_file)
66
  mov_stack = load_stack(mov_file)
67
- ref_frames = [Image.fromarray(f) for f in ref_stack]
68
- mov_frames = [Image.fromarray(f) for f in mov_stack]
 
 
69
 
70
- sr = StackReg(get_sr_mode(mode))
71
- aligned = [sr.register_transform(ref_stack[0], f) for f in mov_stack]
72
- aligned = normalize_stack(np.stack(aligned))
73
- reg_frames = [upscale(Image.fromarray(f)) for f in aligned]
74
 
75
- path = tempfile.NamedTemporaryFile(suffix=".tif", delete=False).name
76
- tifffile.imwrite(path, aligned, photometric='minisblack')
77
- return ref_frames[0], gr.update(value=0, maximum=len(ref_frames)-1), \
78
- reg_frames[0], gr.update(value=0, maximum=len(reg_frames)-1), path
 
 
 
 
79
 
80
  def frame_to_frame_align(file, ref_idx, mov_idx, mode):
81
- global custom_stack, reg_result
82
- stack = load_stack(file)
83
- custom_stack = [Image.fromarray(f) for f in stack]
84
-
85
- sr = StackReg(get_sr_mode(mode))
86
- aligned = sr.register_transform(stack[ref_idx], stack[mov_idx])
87
- aligned = normalize_stack(np.stack([aligned]))[0]
88
- reg_result = Image.fromarray(aligned)
89
-
90
- path = tempfile.NamedTemporaryFile(suffix=".tif", delete=False).name
91
- tifffile.imwrite(path, aligned[np.newaxis, ...], photometric='minisblack')
92
- return reg_result, path
93
-
94
- # URL loading
95
- def load_from_url(request: gr.Request):
96
- params = request.query_params
97
- if "file_url" in params:
98
- try:
99
- url = params["file_url"]
100
- if _is_demo_url(url):
101
- tmp_path = _demo_path_for_url(url)
102
- if not os.path.exists(tmp_path):
103
- urllib.request.urlretrieve(url, tmp_path)
104
- else:
105
- tmp_path = tempfile.mktemp(suffix=".tif") # goes to WORK_DIR
106
- urllib.request.urlretrieve(url, tmp_path)
107
- return [gr.update(value=tmp_path)]
108
- except Exception as e:
109
- print(f"[URL load failed] {e}")
110
- return [None]
 
 
 
 
 
111
 
112
  # Interface
113
  with gr.Blocks() as demo:
 
 
 
 
 
 
 
 
114
  gr.Markdown("# 🧠 Pystackreg Web Application")
115
  gr.Markdown(citation_markdown)
116
 
@@ -131,7 +177,7 @@ with gr.Blocks() as demo:
131
  )
132
 
133
  with gr.Row():
134
- ref_slider = gr.Slider(label="Reference Frame (from uploaded stack)", minimum=0, maximum=0, value=0, step=1, visible=True)
135
  ext_ref_file = gr.File(label="Upload External Reference Stack (.tif)", visible=False)
136
  ext_ref_slider = gr.Slider(label="Reference Frame (from external stack)", minimum=0, maximum=0, value=0, step=1, visible=False)
137
 
@@ -142,13 +188,15 @@ with gr.Blocks() as demo:
142
  gr.update(visible=v)
143
  ),
144
  use_ext_ref,
145
- [ref_slider, ext_ref_file, ext_ref_slider]
 
146
  )
147
 
148
  ext_ref_file.change(
149
- lambda f: gr.update(value=0, maximum=len(iio.mimread(f)) - 1) if f else gr.update(value=0, maximum=0),
150
  ext_ref_file,
151
- ext_ref_slider
 
152
  )
153
 
154
  with gr.Row():
@@ -156,7 +204,7 @@ with gr.Blocks() as demo:
156
  mode_dropdown = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"],
157
  value="RIGID_BODY", visible=False, label="Transformation Mode")
158
 
159
- show_adv.change(lambda v: gr.update(visible=v), show_adv, mode_dropdown)
160
  run_btn = gr.Button("▶️ Align Stack")
161
 
162
  with gr.Row():
@@ -170,28 +218,37 @@ with gr.Blocks() as demo:
170
  download = gr.File(label="Download")
171
 
172
  file_input.change(
173
- lambda f: gr.update(value=0, maximum=len(iio.mimread(f)) - 1) if f else gr.update(value=0, maximum=0),
174
  file_input,
175
- ref_slider
 
176
  )
177
 
178
  run_btn.click(
179
  intra_stack_align,
180
- [file_input, ref_slider, ext_ref_file, ext_ref_slider, mode_dropdown],
181
- [original_image, original_slider, aligned_image, aligned_slider, download]
 
 
182
  )
183
 
184
- original_slider.change(lambda i: original_frames[i] if 0 <= i < len(original_frames) else None,
185
- original_slider, original_image)
186
- aligned_slider.change(lambda i: aligned_frames[i] if 0 <= i < len(aligned_frames) else None,
187
- aligned_slider, aligned_image)
 
 
 
 
188
 
189
  gr.Button("🔄 Reset Tab").click(
190
  reset_intra_stack,
191
  outputs=[
192
- file_input, ref_slider, ext_ref_file, ext_ref_slider,
193
- original_image, original_slider, aligned_image, aligned_slider, download
194
- ]
 
 
195
  )
196
 
197
  with gr.Tab("🎯 Stack-Based Alignment"):
@@ -215,7 +272,7 @@ with gr.Blocks() as demo:
215
  mode_dropdown_ref = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"],
216
  value="RIGID_BODY", visible=False, label="Transformation Mode")
217
 
218
- show_adv_ref.change(lambda v: gr.update(visible=v), show_adv_ref, mode_dropdown_ref)
219
  ref_btn = gr.Button("▶️ Register")
220
 
221
  with gr.Row():
@@ -223,19 +280,32 @@ with gr.Blocks() as demo:
223
  reg_image = gr.Image(label="Registered Frame")
224
 
225
  with gr.Row():
226
- ref_slider = gr.Slider(label="Browse Ref", minimum=0, maximum=0, value=0, step=1)
227
  reg_slider = gr.Slider(label="Browse Reg", minimum=0, maximum=0, value=0, step=1)
228
 
229
  download_ref = gr.File(label="Download")
230
 
231
- ref_btn.click(reference_align, [ref_input, mov_input, mode_dropdown_ref],
232
- [ref_image, ref_slider, reg_image, reg_slider, download_ref])
233
- ref_slider.change(lambda i: ref_frames[i] if 0 <= i < len(ref_frames) else None, ref_slider, ref_image)
234
- reg_slider.change(lambda i: reg_frames[i] if 0 <= i < len(reg_frames) else None, reg_slider, reg_image)
 
 
 
 
 
 
 
 
 
 
 
235
 
236
  gr.Button("🔄 Reset Tab").click(
237
  reset_reference_based,
238
- outputs=[ref_input, mov_input, ref_image, ref_slider, reg_image, reg_slider, download_ref]
 
 
239
  )
240
 
241
  with gr.Tab("🧩 Frame-to-Frame Alignment"):
@@ -258,28 +328,118 @@ with gr.Blocks() as demo:
258
  mode_dropdown_ftf = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"],
259
  value="RIGID_BODY", visible=False, label="Transformation Mode")
260
 
261
- show_adv_ftf.change(lambda v: gr.update(visible=v), show_adv_ftf, mode_dropdown_ftf)
262
  frame_btn = gr.Button("▶️ Register Frame")
263
  frame_output = gr.Image(label="Registered Output")
264
  download_ftf = gr.File(label="Download")
265
 
266
  frame_file.change(
267
- lambda f: [gr.update(value=0, maximum=len(iio.mimread(f)) - 1)] * 2 if f else [gr.update(value=0, maximum=0)] * 2,
268
- frame_file, [ref_idx, mov_idx]
 
269
  )
270
 
271
- frame_btn.click(frame_to_frame_align,
272
- [frame_file, ref_idx, mov_idx, mode_dropdown_ftf],
273
- [frame_output, download_ftf])
 
 
 
274
 
275
  gr.Button("🔄 Reset Tab").click(
276
  reset_frame_to_frame,
277
- outputs=[frame_file, ref_idx, mov_idx, frame_output, download_ftf]
 
278
  )
279
 
280
- @demo.load(
281
- outputs=[file_input, ref_slider, frame_file, ref_idx, mov_idx, ref_input, mov_input]
282
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  def load_from_query(request: gr.Request):
284
  params = request.query_params
285
  results = [None] * 7 # 7 outputs
@@ -287,19 +447,12 @@ with gr.Blocks() as demo:
287
  # One-stack file case (for ref-based + frame-to-frame)
288
  if "file_url" in params:
289
  try:
290
- url = params["file_url"]
291
- if _is_demo_url(url):
292
- tmp_path = _demo_path_for_url(url)
293
- if not os.path.exists(tmp_path):
294
- urllib.request.urlretrieve(url, tmp_path)
295
- else:
296
- tmp_path = tempfile.mktemp(suffix=".tif") # goes to WORK_DIR
297
- urllib.request.urlretrieve(url, tmp_path)
298
- stack = iio.mimread(tmp_path)
299
- max_frame = len(stack) - 1
300
 
301
  results[0] = tmp_path # file_input
302
- results[1] = gr.update(value=0, maximum=max_frame) # ref_slider
303
  results[2] = tmp_path # frame_file
304
  results[3] = gr.update(value=0, maximum=max_frame) # ref_idx
305
  results[4] = gr.update(value=1 if max_frame >= 1 else 0, maximum=max_frame) # mov_idx
@@ -310,32 +463,19 @@ with gr.Blocks() as demo:
310
  # Two-stack file case (for stack-based alignment)
311
  if "file_url_1" in params and "file_url_2" in params:
312
  try:
313
- u1, u2 = params["file_url_1"], params["file_url_2"]
314
-
315
- if _is_demo_url(u1):
316
- tmp_path_1 = _demo_path_for_url(u1)
317
- if not os.path.exists(tmp_path_1):
318
- urllib.request.urlretrieve(u1, tmp_path_1)
319
- else:
320
- tmp_path_1 = tempfile.mktemp(suffix=".tif") # goes to WORK_DIR
321
- urllib.request.urlretrieve(u1, tmp_path_1)
322
-
323
- if _is_demo_url(u2):
324
- tmp_path_2 = _demo_path_for_url(u2)
325
- if not os.path.exists(tmp_path_2):
326
- urllib.request.urlretrieve(u2, tmp_path_2)
327
- else:
328
- tmp_path_2 = tempfile.mktemp(suffix=".tif") # goes to WORK_DIR
329
- urllib.request.urlretrieve(u2, tmp_path_2)
330
-
331
- results[5] = tmp_path_1 # ref_input
332
- results[6] = tmp_path_2 # mov_input
333
  except Exception as e:
334
  print(f"[Error loading file_url_1 or file_url_2] {e}")
335
 
336
  return results
337
 
 
 
 
 
 
338
 
339
 
340
  if __name__ == "__main__":
341
- demo.launch()
 
1
  import gradio as gr
 
2
  from PIL import Image
 
 
3
  import tifffile
4
  import tempfile
 
5
  import os
6
+ from typing import Optional
7
 
8
  from core.utils import (
9
+ WORK_DIR, DEMO_DIR, upscale, load_stack,
 
10
  _start_cleaner, citation_markdown, documentation_markdown
11
  )
12
+ from core.registration import (
13
+ align_stack_to_reference,
14
+ align_stack_to_stack,
15
+ align_frame_to_frame,
16
+ _run_align_to_reference,
17
+ _run_align_to_stack,
18
+ _resolve_path,
19
+ )
20
 
21
  _start_cleaner()
22
 
23
+ def _stage_for_backend(src: str) -> str:
24
+ """Return a sandbox-safe path for *src*.
25
+
26
+ Files already in WORK_DIR or DEMO_DIR are returned as-is. Files that
27
+ Gradio placed in its own upload staging area are hard-linked into WORK_DIR
28
+ (zero-copy on the same filesystem) so the backend sandbox accepts them.
29
+ Falls back to a regular copy if the hard link fails (cross-device).
30
+ """
31
+ import shutil
32
+ real = os.path.realpath(src)
33
+ sandboxes = (os.path.realpath(WORK_DIR), os.path.realpath(DEMO_DIR))
34
+ if any(real == s or real.startswith(s + os.sep) for s in sandboxes):
35
+ return src
36
+ suffix = os.path.splitext(src)[-1] or ".tif"
37
+ fd, dst = tempfile.mkstemp(suffix=suffix, dir=WORK_DIR)
38
+ os.close(fd)
39
+ os.unlink(dst) # remove placeholder so os.link can create the entry
40
+ try:
41
+ os.link(src, dst)
42
+ except OSError:
43
+ shutil.copy2(src, dst)
44
+ return dst
45
 
 
46
  def reset_intra_stack():
 
 
47
  return [None, gr.update(value=0, minimum=0, maximum=0), None, gr.update(value=0, minimum=0, maximum=0),
48
+ None, gr.update(value=0, minimum=0, maximum=0), None, gr.update(value=0, minimum=0, maximum=0), None,
49
+ None, None]
50
 
51
  def reset_reference_based():
 
 
52
  return [None, None, None, gr.update(value=0, minimum=0, maximum=0),
53
+ None, gr.update(value=0, minimum=0, maximum=0), None,
54
+ None, None]
55
 
56
  def reset_frame_to_frame():
 
 
57
  return [None, gr.update(value=0, minimum=0, maximum=0),
58
  gr.update(value=0, minimum=0, maximum=0), None, None]
59
 
60
+ # Registration logic — UI wrappers that call the pure backend functions
61
  def intra_stack_align(f, ref_idx, ext_file, ext_idx, mode):
62
+ if not f:
63
+ raise gr.Error("Please upload a TIFF stack before running alignment.")
64
+ f = _stage_for_backend(f)
65
+ # Load and normalise once — reused for both the UI preview and registration
66
+ orig_stack = load_stack(f)
67
+ fd, orig_path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR)
68
+ os.close(fd)
69
+ tifffile.imwrite(orig_path, orig_stack, photometric="minisblack")
70
+ n_orig = len(orig_stack)
71
+
72
+ if ext_file:
73
+ ref_frame = load_stack(_stage_for_backend(ext_file))[int(ext_idx)]
74
+ else:
75
+ ref_frame = orig_stack[int(ref_idx)]
76
+ aligned = _run_align_to_reference(orig_stack, ref_frame, mode)
77
+
78
+ fd, path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR)
79
+ os.close(fd)
80
+ tifffile.imwrite(path, aligned, photometric="minisblack")
81
  return (
82
+ Image.fromarray(orig_stack[0]), gr.update(value=0, maximum=n_orig - 1),
83
+ upscale(Image.fromarray(aligned[0])), gr.update(value=0, maximum=len(aligned) - 1), path,
84
+ orig_path, path,
85
  )
86
 
87
  def reference_align(ref_file, mov_file, mode):
88
+ if not ref_file:
89
+ raise gr.Error("Please upload a reference stack.")
90
+ if not mov_file:
91
+ raise gr.Error("Please upload a moving stack.")
92
+ ref_file = _stage_for_backend(ref_file)
93
+ mov_file = _stage_for_backend(mov_file)
94
+ # Load both stacks once — reference reused for preview and registration
95
  ref_stack = load_stack(ref_file)
96
  mov_stack = load_stack(mov_file)
97
+ fd, ref_path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR)
98
+ os.close(fd)
99
+ tifffile.imwrite(ref_path, ref_stack, photometric="minisblack")
100
+ n_ref = len(ref_stack)
101
 
102
+ aligned = _run_align_to_stack(ref_stack, mov_stack, mode)
 
 
 
103
 
104
+ fd, path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR)
105
+ os.close(fd)
106
+ tifffile.imwrite(path, aligned, photometric="minisblack")
107
+ return (
108
+ Image.fromarray(ref_stack[0]), gr.update(value=0, maximum=n_ref - 1),
109
+ upscale(Image.fromarray(aligned[0])), gr.update(value=0, maximum=len(aligned) - 1), path,
110
+ ref_path, path,
111
+ )
112
 
113
  def frame_to_frame_align(file, ref_idx, mov_idx, mode):
114
+ if not file:
115
+ raise gr.Error("Please upload a TIFF stack before running alignment.")
116
+ file = _stage_for_backend(file)
117
+ # Delegate to pure backend
118
+ path = align_frame_to_frame(
119
+ stack_file=file,
120
+ reference_index=int(ref_idx),
121
+ moving_index=int(mov_idx),
122
+ mode=mode,
123
+ )
124
+
125
+ # Load aligned frame for UI preview (already normalised — read raw to avoid double-normalisation)
126
+ result_stack = tifffile.imread(path)
127
+ return Image.fromarray(result_stack[0]), path
128
+
129
+ def _read_frame(path, idx, scale=False):
130
+ """Read a single frame from a TIFF file by index, return a PIL Image."""
131
+ if not path:
132
+ return None
133
+ try:
134
+ frame = tifffile.imread(path, key=int(idx))
135
+ img = Image.fromarray(frame)
136
+ return upscale(img) if scale else img
137
+ except Exception:
138
+ return None
139
+
140
+ def _count_frames(path: str) -> int:
141
+ """Return the number of frames in a TIFF file without loading pixel data."""
142
+ if not path or not os.path.exists(path):
143
+ return 0
144
+ try:
145
+ with tifffile.TiffFile(path) as tf:
146
+ return len(tf.pages)
147
+ except Exception as exc:
148
+ raise gr.Error("Unable to read the uploaded TIFF file. Please upload a valid, non-corrupt TIFF stack.") from exc
149
 
150
  # Interface
151
  with gr.Blocks() as demo:
152
+ # Per-session state — one independent copy per connected user.
153
+ # Avoids the shared-globals race condition where concurrent users
154
+ # would overwrite each other's frame lists and see wrong previews.
155
+ original_path_state = gr.State(None)
156
+ aligned_path_state = gr.State(None)
157
+ ref_path_state = gr.State(None)
158
+ reg_path_state = gr.State(None)
159
+
160
  gr.Markdown("# 🧠 Pystackreg Web Application")
161
  gr.Markdown(citation_markdown)
162
 
 
177
  )
178
 
179
  with gr.Row():
180
+ reference_frame_slider = gr.Slider(label="Reference Frame (from uploaded stack)", minimum=0, maximum=0, value=0, step=1, visible=True)
181
  ext_ref_file = gr.File(label="Upload External Reference Stack (.tif)", visible=False)
182
  ext_ref_slider = gr.Slider(label="Reference Frame (from external stack)", minimum=0, maximum=0, value=0, step=1, visible=False)
183
 
 
188
  gr.update(visible=v)
189
  ),
190
  use_ext_ref,
191
+ [reference_frame_slider, ext_ref_file, ext_ref_slider],
192
+ show_api=False,
193
  )
194
 
195
  ext_ref_file.change(
196
+ lambda f: gr.update(value=0, maximum=_count_frames(f) - 1) if f else gr.update(value=0, maximum=0),
197
  ext_ref_file,
198
+ ext_ref_slider,
199
+ show_api=False,
200
  )
201
 
202
  with gr.Row():
 
204
  mode_dropdown = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"],
205
  value="RIGID_BODY", visible=False, label="Transformation Mode")
206
 
207
+ show_adv.change(lambda v: gr.update(visible=v), show_adv, mode_dropdown, show_api=False)
208
  run_btn = gr.Button("▶️ Align Stack")
209
 
210
  with gr.Row():
 
218
  download = gr.File(label="Download")
219
 
220
  file_input.change(
221
+ lambda f: gr.update(value=0, maximum=_count_frames(f) - 1) if f else gr.update(value=0, maximum=0),
222
  file_input,
223
+ reference_frame_slider,
224
+ show_api=False,
225
  )
226
 
227
  run_btn.click(
228
  intra_stack_align,
229
+ [file_input, reference_frame_slider, ext_ref_file, ext_ref_slider, mode_dropdown],
230
+ [original_image, original_slider, aligned_image, aligned_slider, download,
231
+ original_path_state, aligned_path_state],
232
+ show_api=False,
233
  )
234
 
235
+ original_slider.change(
236
+ lambda i, path: _read_frame(path, i, scale=False),
237
+ [original_slider, original_path_state], original_image, show_api=False,
238
+ )
239
+ aligned_slider.change(
240
+ lambda i, path: _read_frame(path, i, scale=True),
241
+ [aligned_slider, aligned_path_state], aligned_image, show_api=False,
242
+ )
243
 
244
  gr.Button("🔄 Reset Tab").click(
245
  reset_intra_stack,
246
  outputs=[
247
+ file_input, reference_frame_slider, ext_ref_file, ext_ref_slider,
248
+ original_image, original_slider, aligned_image, aligned_slider, download,
249
+ original_path_state, aligned_path_state,
250
+ ],
251
+ show_api=False,
252
  )
253
 
254
  with gr.Tab("🎯 Stack-Based Alignment"):
 
272
  mode_dropdown_ref = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"],
273
  value="RIGID_BODY", visible=False, label="Transformation Mode")
274
 
275
+ show_adv_ref.change(lambda v: gr.update(visible=v), show_adv_ref, mode_dropdown_ref, show_api=False)
276
  ref_btn = gr.Button("▶️ Register")
277
 
278
  with gr.Row():
 
280
  reg_image = gr.Image(label="Registered Frame")
281
 
282
  with gr.Row():
283
+ stack_ref_browse_slider = gr.Slider(label="Browse Ref", minimum=0, maximum=0, value=0, step=1)
284
  reg_slider = gr.Slider(label="Browse Reg", minimum=0, maximum=0, value=0, step=1)
285
 
286
  download_ref = gr.File(label="Download")
287
 
288
+ ref_btn.click(
289
+ reference_align,
290
+ [ref_input, mov_input, mode_dropdown_ref],
291
+ [ref_image, stack_ref_browse_slider, reg_image, reg_slider, download_ref,
292
+ ref_path_state, reg_path_state],
293
+ show_api=False,
294
+ )
295
+ stack_ref_browse_slider.change(
296
+ lambda i, path: _read_frame(path, i, scale=False),
297
+ [stack_ref_browse_slider, ref_path_state], ref_image, show_api=False,
298
+ )
299
+ reg_slider.change(
300
+ lambda i, path: _read_frame(path, i, scale=True),
301
+ [reg_slider, reg_path_state], reg_image, show_api=False,
302
+ )
303
 
304
  gr.Button("🔄 Reset Tab").click(
305
  reset_reference_based,
306
+ outputs=[ref_input, mov_input, ref_image, stack_ref_browse_slider, reg_image, reg_slider, download_ref,
307
+ ref_path_state, reg_path_state],
308
+ show_api=False,
309
  )
310
 
311
  with gr.Tab("🧩 Frame-to-Frame Alignment"):
 
328
  mode_dropdown_ftf = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"],
329
  value="RIGID_BODY", visible=False, label="Transformation Mode")
330
 
331
+ show_adv_ftf.change(lambda v: gr.update(visible=v), show_adv_ftf, mode_dropdown_ftf, show_api=False)
332
  frame_btn = gr.Button("▶️ Register Frame")
333
  frame_output = gr.Image(label="Registered Output")
334
  download_ftf = gr.File(label="Download")
335
 
336
  frame_file.change(
337
+ lambda f: [gr.update(value=0, maximum=_count_frames(f) - 1)] * 2 if f else [gr.update(value=0, maximum=0)] * 2,
338
+ frame_file, [ref_idx, mov_idx],
339
+ show_api=False,
340
  )
341
 
342
+ frame_btn.click(
343
+ frame_to_frame_align,
344
+ [frame_file, ref_idx, mov_idx, mode_dropdown_ftf],
345
+ [frame_output, download_ftf],
346
+ show_api=False,
347
+ )
348
 
349
  gr.Button("🔄 Reset Tab").click(
350
  reset_frame_to_frame,
351
+ outputs=[frame_file, ref_idx, mov_idx, frame_output, download_ftf],
352
+ show_api=False,
353
  )
354
 
355
+ # ---------------------------------------------------------------------------
356
+ # MCP / API-only endpoints thin wrappers that return the output file path
357
+ # as a plain string so Gradio/MCP can serve it correctly.
358
+ # ---------------------------------------------------------------------------
359
+ def _mcp_align_stack_to_reference(
360
+ stack_file: str,
361
+ reference_index: int = 0,
362
+ mode: str = "RIGID_BODY",
363
+ external_reference_file: Optional[str] = None,
364
+ external_reference_index: int = 0,
365
+ ) -> str:
366
+ """Align every frame in a TIFF stack to a chosen reference frame.
367
+
368
+ Each frame in stack_file is registered to the selected reference frame
369
+ using the chosen transformation model. The reference frame can come from
370
+ the same stack or from a separate external TIFF stack.
371
+
372
+ Args:
373
+ stack_file: Path or HTTP/HTTPS URL to the input TIFF stack.
374
+ reference_index: Zero-based index of the reference frame inside
375
+ stack_file (ignored when external_reference_file is provided).
376
+ Default is 0.
377
+ mode: Transformation model. One of: TRANSLATION, RIGID_BODY,
378
+ SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY.
379
+ external_reference_file: Optional path or URL to an external TIFF
380
+ stack from which the reference frame is taken.
381
+ external_reference_index: Zero-based index of the reference frame
382
+ inside external_reference_file. Default is 0.
383
+
384
+ Returns:
385
+ The aligned output TIFF file.
386
+ """
387
+ out = align_stack_to_reference(
388
+ stack_file, reference_index, mode,
389
+ external_reference_file, external_reference_index,
390
+ )
391
+ return out
392
+
393
+ def _mcp_align_stack_to_stack(
394
+ reference_stack_file: str,
395
+ moving_stack_file: str,
396
+ mode: str = "RIGID_BODY",
397
+ ) -> str:
398
+ """Align every frame in a moving TIFF stack to the first frame of a reference stack.
399
+
400
+ Args:
401
+ reference_stack_file: Path or HTTP/HTTPS URL to the reference TIFF
402
+ stack. Its first frame is used as the alignment target.
403
+ moving_stack_file: Path or HTTP/HTTPS URL to the moving TIFF stack
404
+ to align.
405
+ mode: Transformation model. One of: TRANSLATION, RIGID_BODY,
406
+ SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY.
407
+
408
+ Returns:
409
+ The aligned output TIFF file.
410
+ """
411
+ out = align_stack_to_stack(reference_stack_file, moving_stack_file, mode)
412
+ return out
413
+
414
+ def _mcp_align_frame_to_frame(
415
+ stack_file: str,
416
+ reference_index: int,
417
+ moving_index: int,
418
+ mode: str = "RIGID_BODY",
419
+ ) -> str:
420
+ """Align a single moving frame to a reference frame within the same TIFF stack.
421
+
422
+ Args:
423
+ stack_file: Path or HTTP/HTTPS URL to the TIFF stack containing both
424
+ frames.
425
+ reference_index: Zero-based index of the reference frame.
426
+ moving_index: Zero-based index of the frame to align.
427
+ mode: Transformation model. One of: TRANSLATION, RIGID_BODY,
428
+ SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY.
429
+
430
+ Returns:
431
+ The aligned single-frame output TIFF file.
432
+ """
433
+ out = align_frame_to_frame(stack_file, reference_index, moving_index, mode)
434
+ return out
435
+
436
+ gr.api(fn=_mcp_align_stack_to_reference, api_name="align_stack_to_reference")
437
+ gr.api(fn=_mcp_align_stack_to_stack, api_name="align_stack_to_stack")
438
+ gr.api(fn=_mcp_align_frame_to_frame, api_name="align_frame_to_frame")
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Page-load handler (UI only — not an MCP tool)
442
+ # ---------------------------------------------------------------------------
443
  def load_from_query(request: gr.Request):
444
  params = request.query_params
445
  results = [None] * 7 # 7 outputs
 
447
  # One-stack file case (for ref-based + frame-to-frame)
448
  if "file_url" in params:
449
  try:
450
+ tmp_path = _resolve_path(params["file_url"], "file_url")
451
+ with tifffile.TiffFile(tmp_path) as tf:
452
+ max_frame = len(tf.pages) - 1
 
 
 
 
 
 
 
453
 
454
  results[0] = tmp_path # file_input
455
+ results[1] = gr.update(value=0, maximum=max_frame) # reference_frame_slider
456
  results[2] = tmp_path # frame_file
457
  results[3] = gr.update(value=0, maximum=max_frame) # ref_idx
458
  results[4] = gr.update(value=1 if max_frame >= 1 else 0, maximum=max_frame) # mov_idx
 
463
  # Two-stack file case (for stack-based alignment)
464
  if "file_url_1" in params and "file_url_2" in params:
465
  try:
466
+ results[5] = _resolve_path(params["file_url_1"], "file_url_1") # ref_input
467
+ results[6] = _resolve_path(params["file_url_2"], "file_url_2") # mov_input
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  except Exception as e:
469
  print(f"[Error loading file_url_1 or file_url_2] {e}")
470
 
471
  return results
472
 
473
+ demo.load(
474
+ load_from_query,
475
+ outputs=[file_input, reference_frame_slider, frame_file, ref_idx, mov_idx, ref_input, mov_input],
476
+ show_api=False,
477
+ )
478
 
479
 
480
  if __name__ == "__main__":
481
+ demo.launch(mcp_server=True)
core/registration.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pure backend registration functions for pystackreg-app.
3
+
4
+ These functions implement the three image-registration workflows as
5
+ file-based, MCP-friendly operations. They take TIFF file paths as inputs
6
+ and return the path to the output TIFF file. No Gradio objects are returned.
7
+
8
+ Gradio MCP uses function names, type hints, and docstrings to build MCP
9
+ tool schemas, so all three are kept clear and complete.
10
+ """
11
+
12
+ import ipaddress
13
+ import os
14
+ import socket
15
+ import tempfile
16
+ import urllib.parse
17
+ import urllib.request
18
+ from typing import Optional
19
+
20
+ import numpy as np
21
+ import tifffile
22
+ from pystackreg import StackReg
23
+
24
+ from core.utils import WORK_DIR, DEMO_DIR, get_sr_mode, load_stack, normalize_stack
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Validation helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ VALID_MODES = {"TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"}
31
+
32
+ # Maximum size allowed for HTTP downloads (prevents resource-exhaustion attacks).
33
+ _MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024 # 500 MB
34
+ _DOWNLOAD_TIMEOUT = 30 # seconds
35
+
36
+ # Valid TIFF magic byte sequences (little/big-endian classic and BigTIFF).
37
+ _TIFF_MAGIC = (
38
+ b"II\x2A\x00", # little-endian TIFF
39
+ b"MM\x00\x2A", # big-endian TIFF
40
+ b"II\x2B\x00", # little-endian BigTIFF
41
+ b"MM\x00\x2B", # big-endian BigTIFF
42
+ )
43
+
44
+
45
+ def _block_private_url(url: str) -> None:
46
+ """Raise ValueError if *url* resolves to a private, loopback, link-local,
47
+ reserved, or multicast address (SSRF protection)."""
48
+ parsed = urllib.parse.urlparse(url)
49
+ host = parsed.hostname
50
+ if not host:
51
+ raise ValueError(f"Could not parse host from URL: {url!r}")
52
+ try:
53
+ infos = socket.getaddrinfo(host, None)
54
+ except socket.gaierror as exc:
55
+ raise ValueError(f"Could not resolve host '{host}': {exc}") from exc
56
+ for info in infos:
57
+ addr_str = info[4][0]
58
+ try:
59
+ addr = ipaddress.ip_address(addr_str)
60
+ except ValueError:
61
+ continue
62
+ if (
63
+ addr.is_private
64
+ or addr.is_loopback
65
+ or addr.is_link_local
66
+ or addr.is_reserved
67
+ or addr.is_multicast
68
+ or addr.is_unspecified
69
+ ):
70
+ raise ValueError(
71
+ f"Requests to private or internal addresses are not allowed "
72
+ f"('{host}' resolved to {addr})."
73
+ )
74
+
75
+
76
+ class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
77
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
78
+ _block_private_url(newurl)
79
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
80
+
81
+
82
+ _SAFE_URL_OPENER = urllib.request.build_opener(_SafeRedirectHandler)
83
+
84
+
85
+ def _download_tiff_to_work_dir(url: str, label: str) -> str:
86
+ """Download *url* to a temp file in WORK_DIR, validating TIFF magic and size.
87
+
88
+ Uses chunked streaming so that the full file is never held in memory.
89
+ Raises ValueError on SSRF, size-limit, or magic-byte failures.
90
+ Cleans up the temp file before raising on any error.
91
+ """
92
+ _block_private_url(url)
93
+
94
+ fd, local_path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR)
95
+ os.close(fd)
96
+
97
+ total = 0
98
+ first4 = b""
99
+
100
+ try:
101
+ with _SAFE_URL_OPENER.open(url, timeout=_DOWNLOAD_TIMEOUT) as resp, open(local_path, "wb") as f:
102
+ _block_private_url(resp.geturl())
103
+ while True:
104
+ chunk = resp.read(1024 * 1024)
105
+ if not chunk:
106
+ break
107
+
108
+ if len(first4) < 4:
109
+ first4 = (first4 + chunk[: 4 - len(first4)])[:4]
110
+ if len(first4) == 4 and not any(first4.startswith(magic) for magic in _TIFF_MAGIC):
111
+ raise ValueError(f"{label} does not appear to be a valid TIFF file.")
112
+
113
+ total += len(chunk)
114
+ if total > _MAX_DOWNLOAD_BYTES:
115
+ raise ValueError(
116
+ f"{label} exceeds the maximum allowed download size of "
117
+ f"{_MAX_DOWNLOAD_BYTES // (1024 * 1024)} MB."
118
+ )
119
+
120
+ f.write(chunk)
121
+
122
+ if total == 0:
123
+ raise ValueError(f"{label} is empty.")
124
+
125
+ if len(first4) < 4 or not any(first4.startswith(magic) for magic in _TIFF_MAGIC):
126
+ raise ValueError(f"{label} does not appear to be a valid TIFF file.")
127
+
128
+ return local_path
129
+
130
+ except Exception:
131
+ try:
132
+ os.unlink(local_path)
133
+ except FileNotFoundError:
134
+ pass
135
+ raise
136
+
137
+
138
+ def _resolve_path(path_or_url: str, label: str = "file") -> str:
139
+ """Return a local, sandbox-safe path for *path_or_url*.
140
+
141
+ - If the value starts with ``http://`` or ``https://``, the file is
142
+ downloaded to WORK_DIR and the local path is returned. This is the
143
+ intended flow for MCP clients, which cannot upload files directly.
144
+ - Otherwise the value is treated as a local path and must resolve within
145
+ the app sandbox enforced by _require_file(): WORK_DIR (for outputs from
146
+ previous tool calls) or DEMO_DIR (for cached demo files).
147
+ """
148
+ if path_or_url.startswith(("http://", "https://")):
149
+ return _download_tiff_to_work_dir(path_or_url, label)
150
+ _require_file(path_or_url, label)
151
+ return path_or_url
152
+
153
+
154
+ def _validate_mode(mode: str) -> None:
155
+ """Raise ValueError if *mode* is not a supported transformation mode."""
156
+ if mode not in VALID_MODES:
157
+ raise ValueError(
158
+ f"Invalid transformation mode '{mode}'. "
159
+ f"Must be one of: {', '.join(sorted(VALID_MODES))}."
160
+ )
161
+
162
+
163
+ def _validate_index(idx: int, stack_len: int, name: str = "frame index") -> None:
164
+ """Raise IndexError if *idx* is outside [0, stack_len)."""
165
+ if not (0 <= idx < stack_len):
166
+ raise IndexError(
167
+ f"{name} {idx} is out of range for a stack with {stack_len} frame(s) "
168
+ f"(valid range: 0 to {stack_len - 1})."
169
+ )
170
+
171
+
172
+ def _require_file(path: str, label: str = "file") -> None:
173
+ """Raise an error if *path* does not exist or is outside the app's sandbox.
174
+
175
+ Allowed locations are WORK_DIR (outputs from previous tool calls, enabling
176
+ tool chaining) and DEMO_DIR (cached demo files). Symlinks are resolved
177
+ first to prevent traversal attacks.
178
+
179
+ Remote MCP clients should pass HTTP/HTTPS URLs; _resolve_path() downloads
180
+ them to WORK_DIR automatically.
181
+ """
182
+ real = os.path.realpath(path)
183
+ sandboxes = (os.path.realpath(WORK_DIR), os.path.realpath(DEMO_DIR))
184
+ if not any(real == s or real.startswith(s + os.sep) for s in sandboxes):
185
+ raise ValueError(
186
+ f"{label} must be an HTTP/HTTPS URL or a path returned by a previous "
187
+ "tool call. Pass a URL and it will be downloaded automatically."
188
+ )
189
+ if not os.path.isfile(path):
190
+ raise FileNotFoundError(f"{label} not found: {path}")
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Private computation helpers (array-in / array-out, no file I/O)
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def _run_align_to_reference(
198
+ stack: np.ndarray, ref_frame: np.ndarray, mode: str
199
+ ) -> np.ndarray:
200
+ """Register every frame in *stack* against *ref_frame*. Returns normalised uint8 array."""
201
+ sr = StackReg(get_sr_mode(mode))
202
+ return normalize_stack(np.stack([sr.register_transform(ref_frame, fr) for fr in stack]))
203
+
204
+
205
+ def _run_align_to_stack(
206
+ ref_stack: np.ndarray, mov_stack: np.ndarray, mode: str
207
+ ) -> np.ndarray:
208
+ """Register every frame in *mov_stack* against the first frame of *ref_stack*.
209
+ Returns normalised uint8 array."""
210
+ sr = StackReg(get_sr_mode(mode))
211
+ return normalize_stack(np.stack([sr.register_transform(ref_stack[0], fr) for fr in mov_stack]))
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Public backend functions (exposed as MCP tools)
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def align_stack_to_reference(
220
+ stack_file: str,
221
+ reference_index: int = 0,
222
+ mode: str = "RIGID_BODY",
223
+ external_reference_file: Optional[str] = None,
224
+ external_reference_index: int = 0,
225
+ ) -> str:
226
+ """
227
+ Align every frame in a TIFF stack to a chosen reference frame (intra-stack alignment).
228
+
229
+ Each frame in *stack_file* is registered to the selected reference frame using
230
+ the chosen transformation model. The reference frame can come from the same
231
+ stack or from a separate external TIFF stack.
232
+
233
+ Args:
234
+ stack_file: Path to the input TIFF stack whose frames will be aligned.
235
+ reference_index: Zero-based index of the reference frame inside
236
+ *stack_file* (ignored when *external_reference_file* is provided).
237
+ Default is 0.
238
+ mode: Transformation model for registration. One of: TRANSLATION,
239
+ RIGID_BODY, SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY.
240
+ external_reference_file: Optional path to an external TIFF stack from
241
+ which the reference frame is taken. When provided, *reference_index*
242
+ is ignored and *external_reference_index* is used instead.
243
+ external_reference_index: Zero-based index of the reference frame inside
244
+ *external_reference_file*. Default is 0.
245
+
246
+ Returns:
247
+ Path to the aligned output TIFF file (same number of frames as input).
248
+
249
+ Raises:
250
+ FileNotFoundError: If *stack_file* or *external_reference_file* does not
251
+ exist on disk.
252
+ IndexError: If *reference_index* or *external_reference_index* is out of
253
+ range for the corresponding stack.
254
+ ValueError: If *mode* is not one of the supported transformation modes.
255
+ """
256
+ _validate_mode(mode)
257
+ stack_file = _resolve_path(stack_file, "stack_file")
258
+
259
+ stack = load_stack(stack_file)
260
+
261
+ if external_reference_file is not None:
262
+ external_reference_file = _resolve_path(external_reference_file, "external_reference_file")
263
+ ext_stack = load_stack(external_reference_file)
264
+ _validate_index(external_reference_index, len(ext_stack), "external_reference_index")
265
+ ref_frame = ext_stack[external_reference_index]
266
+ else:
267
+ _validate_index(reference_index, len(stack), "reference_index")
268
+ ref_frame = stack[reference_index]
269
+
270
+ aligned = _run_align_to_reference(stack, ref_frame, mode)
271
+
272
+ fd, out_path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR)
273
+ os.close(fd)
274
+ tifffile.imwrite(out_path, aligned, photometric="minisblack")
275
+ return out_path
276
+
277
+
278
+ def align_stack_to_stack(
279
+ reference_stack_file: str,
280
+ moving_stack_file: str,
281
+ mode: str = "RIGID_BODY",
282
+ ) -> str:
283
+ """
284
+ Align every frame in a moving TIFF stack to the first frame of a reference TIFF stack.
285
+
286
+ All frames in *moving_stack_file* are registered against the first frame of
287
+ *reference_stack_file* using the specified transformation model.
288
+
289
+ Args:
290
+ reference_stack_file: Path to the reference TIFF stack. Its first frame
291
+ is used as the alignment target for all frames in the moving stack.
292
+ moving_stack_file: Path to the moving TIFF stack to align.
293
+ mode: Transformation model for registration. One of: TRANSLATION,
294
+ RIGID_BODY, SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY.
295
+
296
+ Returns:
297
+ Path to the aligned output TIFF file (same number of frames as the
298
+ moving stack).
299
+
300
+ Raises:
301
+ FileNotFoundError: If *reference_stack_file* or *moving_stack_file* does
302
+ not exist on disk.
303
+ ValueError: If *mode* is not one of the supported transformation modes.
304
+ """
305
+ _validate_mode(mode)
306
+ reference_stack_file = _resolve_path(reference_stack_file, "reference_stack_file")
307
+ moving_stack_file = _resolve_path(moving_stack_file, "moving_stack_file")
308
+
309
+ ref_stack = load_stack(reference_stack_file)
310
+ mov_stack = load_stack(moving_stack_file)
311
+
312
+ aligned = _run_align_to_stack(ref_stack, mov_stack, mode)
313
+
314
+ fd, out_path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR)
315
+ os.close(fd)
316
+ tifffile.imwrite(out_path, aligned, photometric="minisblack")
317
+ return out_path
318
+
319
+
320
+ def align_frame_to_frame(
321
+ stack_file: str,
322
+ reference_index: int,
323
+ moving_index: int,
324
+ mode: str = "RIGID_BODY",
325
+ ) -> str:
326
+ """
327
+ Align a single moving frame to a reference frame within the same TIFF stack.
328
+
329
+ Registers the frame at *moving_index* against the frame at *reference_index*
330
+ inside *stack_file* and writes the result as a single-frame TIFF.
331
+
332
+ Args:
333
+ stack_file: Path to the TIFF stack that contains both frames.
334
+ reference_index: Zero-based index of the reference frame within the stack.
335
+ moving_index: Zero-based index of the frame to align (the moving frame).
336
+ mode: Transformation model for registration. One of: TRANSLATION,
337
+ RIGID_BODY, SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY.
338
+
339
+ Returns:
340
+ Path to the aligned output TIFF file (single-frame TIFF).
341
+
342
+ Raises:
343
+ FileNotFoundError: If *stack_file* does not exist on disk.
344
+ IndexError: If *reference_index* or *moving_index* is out of range for
345
+ the stack.
346
+ ValueError: If *mode* is not one of the supported transformation modes.
347
+ """
348
+ _validate_mode(mode)
349
+ stack_file = _resolve_path(stack_file, "stack_file")
350
+
351
+ stack = load_stack(stack_file)
352
+ _validate_index(reference_index, len(stack), "reference_index")
353
+ _validate_index(moving_index, len(stack), "moving_index")
354
+
355
+ sr = StackReg(get_sr_mode(mode))
356
+ aligned = normalize_stack(np.stack([sr.register_transform(stack[reference_index], stack[moving_index])]))[0]
357
+
358
+ fd, out_path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR)
359
+ os.close(fd)
360
+ tifffile.imwrite(out_path, aligned[np.newaxis, ...], photometric="minisblack")
361
+ return out_path
core/utils.py CHANGED
@@ -22,7 +22,10 @@ def normalize_stack(stack):
22
  f = frame.astype(np.float32)
23
  low, high = np.percentile(f, (1, 99))
24
  f = np.clip(f, low, high)
25
- f = (f - f.min()) / (np.ptp(f) + 1e-8) * 255 if np.ptp(f) > 0 else np.zeros_like(f)
 
 
 
26
  norm_stack.append(f.astype(np.uint8))
27
  return np.stack(norm_stack)
28
 
@@ -45,9 +48,6 @@ DEMO_DIR = os.path.join(APP_TMP_ROOT, "demo") # persistent demo cache
45
  os.makedirs(WORK_DIR, exist_ok=True)
46
  os.makedirs(DEMO_DIR, exist_ok=True)
47
 
48
- # Direct all tempfile.* calls to WORK_DIR so cleanup is safe
49
- tempfile.tempdir = WORK_DIR
50
-
51
  TTL_SECONDS = 30 * 60 # 30 minutes
52
 
53
  def _cleanup_old_files(folder, older_than_seconds):
@@ -152,6 +152,6 @@ documentation_markdown = """
152
 
153
  ### 🧠 Credits
154
 
155
- App developed by **Quentin Chapuis**
156
  Library: [`pystackreg`](https://github.com/glichtner/pystackreg) by **Georg Lichtenberg**
157
  """
 
22
  f = frame.astype(np.float32)
23
  low, high = np.percentile(f, (1, 99))
24
  f = np.clip(f, low, high)
25
+ fmin = f.min()
26
+ fmax = f.max()
27
+ rng = fmax - fmin
28
+ f = (f - fmin) / (rng + 1e-8) * 255 if rng > 0 else np.zeros_like(f)
29
  norm_stack.append(f.astype(np.uint8))
30
  return np.stack(norm_stack)
31
 
 
48
  os.makedirs(WORK_DIR, exist_ok=True)
49
  os.makedirs(DEMO_DIR, exist_ok=True)
50
 
 
 
 
51
  TTL_SECONDS = 30 * 60 # 30 minutes
52
 
53
  def _cleanup_old_files(folder, older_than_seconds):
 
152
 
153
  ### 🧠 Credits
154
 
155
+ App developed by **Quentin Chappuis**
156
  Library: [`pystackreg`](https://github.com/glichtner/pystackreg) by **Georg Lichtenberg**
157
  """
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- gradio==5.25.1
2
- pystackreg
3
- tifffile
4
- imageio
 
1
+ gradio[mcp]==5.49.1
2
+ pystackreg==0.2.8
3
+ tifffile==2025.3.30
4
+ imageio==2.37.0