import gradio as gr from PIL import Image import tifffile import tempfile import os from typing import Optional, Literal from core.utils import ( WORK_DIR, DEMO_DIR, upscale, load_stack, _start_cleaner, citation_markdown, documentation_markdown ) from core.registration import ( align_stack_to_reference, align_stack_to_stack, align_frame_to_frame, _run_align_to_reference, _run_align_to_stack, _resolve_path, ) TransformationMode = Literal[ "TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR", ] _start_cleaner() def _stage_for_backend(src: str) -> str: """Return a sandbox-safe path for *src*. Files already in WORK_DIR or DEMO_DIR are returned as-is. Files that Gradio placed in its own upload staging area are hard-linked into WORK_DIR (zero-copy on the same filesystem) so the backend sandbox accepts them. Falls back to a regular copy if the hard link fails (cross-device). """ import shutil real = os.path.realpath(src) sandboxes = (os.path.realpath(WORK_DIR), os.path.realpath(DEMO_DIR)) if any(real == s or real.startswith(s + os.sep) for s in sandboxes): return src suffix = os.path.splitext(src)[-1] or ".tif" fd, dst = tempfile.mkstemp(suffix=suffix, dir=WORK_DIR) os.close(fd) os.unlink(dst) # remove placeholder so os.link can create the entry try: os.link(src, dst) except OSError: shutil.copy2(src, dst) return dst def reset_intra_stack(): return [None, gr.update(value=0, minimum=0, maximum=0), None, gr.update(value=0, minimum=0, maximum=0), None, gr.update(value=0, minimum=0, maximum=0), None, gr.update(value=0, minimum=0, maximum=0), None, None, None] def reset_reference_based(): return [None, None, None, gr.update(value=0, minimum=0, maximum=0), None, gr.update(value=0, minimum=0, maximum=0), None, None, None] def reset_frame_to_frame(): return [None, gr.update(value=0, minimum=0, maximum=0), gr.update(value=0, minimum=0, maximum=0), None, None] # Registration logic — UI wrappers that call the pure backend functions def intra_stack_align(f, ref_idx, ext_file, ext_idx, mode): if not f: raise gr.Error("Please upload a TIFF stack before running alignment.") f = _stage_for_backend(f) # Load and normalise once — reused for both the UI preview and registration orig_stack = load_stack(f) fd, orig_path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR) os.close(fd) tifffile.imwrite(orig_path, orig_stack, photometric="minisblack") n_orig = len(orig_stack) if ext_file: ref_frame = load_stack(_stage_for_backend(ext_file))[int(ext_idx)] else: ref_frame = orig_stack[int(ref_idx)] aligned = _run_align_to_reference(orig_stack, ref_frame, mode) fd, path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR) os.close(fd) tifffile.imwrite(path, aligned, photometric="minisblack") return ( Image.fromarray(orig_stack[0]), gr.update(value=0, maximum=n_orig - 1), upscale(Image.fromarray(aligned[0])), gr.update(value=0, maximum=len(aligned) - 1), path, orig_path, path, ) def reference_align(ref_file, mov_file, mode): if not ref_file: raise gr.Error("Please upload a reference stack.") if not mov_file: raise gr.Error("Please upload a moving stack.") ref_file = _stage_for_backend(ref_file) mov_file = _stage_for_backend(mov_file) # Load both stacks once — reference reused for preview and registration ref_stack = load_stack(ref_file) mov_stack = load_stack(mov_file) fd, ref_path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR) os.close(fd) tifffile.imwrite(ref_path, ref_stack, photometric="minisblack") n_ref = len(ref_stack) aligned = _run_align_to_stack(ref_stack, mov_stack, mode) fd, path = tempfile.mkstemp(suffix=".tif", dir=WORK_DIR) os.close(fd) tifffile.imwrite(path, aligned, photometric="minisblack") return ( Image.fromarray(ref_stack[0]), gr.update(value=0, maximum=n_ref - 1), upscale(Image.fromarray(aligned[0])), gr.update(value=0, maximum=len(aligned) - 1), path, ref_path, path, ) def frame_to_frame_align(file, ref_idx, mov_idx, mode): if not file: raise gr.Error("Please upload a TIFF stack before running alignment.") file = _stage_for_backend(file) # Delegate to pure backend path = align_frame_to_frame( stack_file=file, reference_index=int(ref_idx), moving_index=int(mov_idx), mode=mode, ) # Load aligned frame for UI preview (already normalised — read raw to avoid double-normalisation) result_stack = tifffile.imread(path) return Image.fromarray(result_stack[0]), path def _read_frame(path, idx, scale=False): """Read a single frame from a TIFF file by index, return a PIL Image.""" if not path: return None try: frame = tifffile.imread(path, key=int(idx)) img = Image.fromarray(frame) return upscale(img) if scale else img except Exception: return None def _count_frames(path: str) -> int: """Return the number of frames in a TIFF file without loading pixel data.""" if not path or not os.path.exists(path): return 0 try: with tifffile.TiffFile(path) as tf: return len(tf.pages) except Exception as exc: raise gr.Error("Unable to read the uploaded TIFF file. Please upload a valid, non-corrupt TIFF stack.") from exc # Interface with gr.Blocks() as demo: # Per-session state — one independent copy per connected user. # Avoids the shared-globals race condition where concurrent users # would overwrite each other's frame lists and see wrong previews. original_path_state = gr.State(None) aligned_path_state = gr.State(None) ref_path_state = gr.State(None) reg_path_state = gr.State(None) gr.Markdown("# 🧠 Pystackreg Web Application") gr.Markdown(citation_markdown) with gr.Accordion("📘 How to Use This App (Click to Expand)", open=False): gr.Markdown(documentation_markdown) with gr.Tab("📚 Reference-Based Alignment"): with gr.Row(): file_input = gr.File(label="Upload Stack to Register") use_ext_ref = gr.Checkbox(label="Use external reference stack") gr.Examples( examples=[ ["https://github.com/glichtner/pystackreg/raw/master/examples/data/pc12-unreg.tif"] ], inputs=[file_input], label="Try with Example TIFF" ) with gr.Row(): reference_frame_slider = gr.Slider(label="Reference Frame (from uploaded stack)", minimum=0, maximum=0, value=0, step=1, visible=True) ext_ref_file = gr.File(label="Upload External Reference Stack (.tif)", visible=False) ext_ref_slider = gr.Slider(label="Reference Frame (from external stack)", minimum=0, maximum=0, value=0, step=1, visible=False) use_ext_ref.change( lambda v: ( gr.update(visible=not v), gr.update(visible=v), gr.update(visible=v) ), use_ext_ref, [reference_frame_slider, ext_ref_file, ext_ref_slider], show_api=False, ) ext_ref_file.change( lambda f: gr.update(value=0, maximum=_count_frames(f) - 1) if f else gr.update(value=0, maximum=0), ext_ref_file, ext_ref_slider, show_api=False, ) with gr.Row(): show_adv = gr.Checkbox(label="Show Advanced Settings", value=False) mode_dropdown = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"], value="RIGID_BODY", visible=False, label="Transformation Mode") show_adv.change(lambda v: gr.update(visible=v), show_adv, mode_dropdown, show_api=False) run_btn = gr.Button("▶️ Align Stack") with gr.Row(): original_image = gr.Image(label="Original Frame") aligned_image = gr.Image(label="Aligned Frame") with gr.Row(): original_slider = gr.Slider(label="Browse Original Stack", minimum=0, maximum=0, value=0, step=1) aligned_slider = gr.Slider(label="Browse Aligned Stack", minimum=0, maximum=0, value=0, step=1) download = gr.File(label="Download") file_input.change( lambda f: gr.update(value=0, maximum=_count_frames(f) - 1) if f else gr.update(value=0, maximum=0), file_input, reference_frame_slider, show_api=False, ) run_btn.click( intra_stack_align, [file_input, reference_frame_slider, ext_ref_file, ext_ref_slider, mode_dropdown], [original_image, original_slider, aligned_image, aligned_slider, download, original_path_state, aligned_path_state], show_api=False, ) original_slider.change( lambda i, path: _read_frame(path, i, scale=False), [original_slider, original_path_state], original_image, show_api=False, ) aligned_slider.change( lambda i, path: _read_frame(path, i, scale=True), [aligned_slider, aligned_path_state], aligned_image, show_api=False, ) gr.Button("🔄 Reset Tab").click( reset_intra_stack, outputs=[ file_input, reference_frame_slider, ext_ref_file, ext_ref_slider, original_image, original_slider, aligned_image, aligned_slider, download, original_path_state, aligned_path_state, ], show_api=False, ) with gr.Tab("🎯 Stack-Based Alignment"): with gr.Row(): ref_input = gr.File(label="Reference Stack") mov_input = gr.File(label="Moving Stack") gr.Examples( examples=[ [ "https://github.com/glichtner/pystackreg/raw/master/examples/data/pc12-unreg.tif", "https://github.com/glichtner/pystackreg/raw/master/examples/data/pc12-reg-translation.tif" ] ], inputs=[ref_input, mov_input], label="Try with Example Stacks" ) with gr.Row(): show_adv_ref = gr.Checkbox(label="Show Advanced Settings", value=False) mode_dropdown_ref = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"], value="RIGID_BODY", visible=False, label="Transformation Mode") show_adv_ref.change(lambda v: gr.update(visible=v), show_adv_ref, mode_dropdown_ref, show_api=False) ref_btn = gr.Button("▶️ Register") with gr.Row(): ref_image = gr.Image(label="Reference Frame") reg_image = gr.Image(label="Registered Frame") with gr.Row(): stack_ref_browse_slider = gr.Slider(label="Browse Ref", minimum=0, maximum=0, value=0, step=1) reg_slider = gr.Slider(label="Browse Reg", minimum=0, maximum=0, value=0, step=1) download_ref = gr.File(label="Download") ref_btn.click( reference_align, [ref_input, mov_input, mode_dropdown_ref], [ref_image, stack_ref_browse_slider, reg_image, reg_slider, download_ref, ref_path_state, reg_path_state], show_api=False, ) stack_ref_browse_slider.change( lambda i, path: _read_frame(path, i, scale=False), [stack_ref_browse_slider, ref_path_state], ref_image, show_api=False, ) reg_slider.change( lambda i, path: _read_frame(path, i, scale=True), [reg_slider, reg_path_state], reg_image, show_api=False, ) gr.Button("🔄 Reset Tab").click( reset_reference_based, outputs=[ref_input, mov_input, ref_image, stack_ref_browse_slider, reg_image, reg_slider, download_ref, ref_path_state, reg_path_state], show_api=False, ) with gr.Tab("🧩 Frame-to-Frame Alignment"): with gr.Row(): frame_file = gr.File(label="Upload Stack") ref_idx = gr.Slider(label="Reference Frame", minimum=0, maximum=0, value=0, step=1) mov_idx = gr.Slider(label="Moving Frame", minimum=0, maximum=0, value=0, step=1) gr.Examples( examples=[ ["https://github.com/glichtner/pystackreg/raw/master/examples/data/pc12-unreg.tif"] ], inputs=[frame_file], label="Try with Example Stack" ) with gr.Row(): show_adv_ftf = gr.Checkbox(label="Show Advanced Settings", value=False) mode_dropdown_ftf = gr.Dropdown(["TRANSLATION", "RIGID_BODY", "SCALED_ROTATION", "AFFINE", "BILINEAR"], value="RIGID_BODY", visible=False, label="Transformation Mode") show_adv_ftf.change(lambda v: gr.update(visible=v), show_adv_ftf, mode_dropdown_ftf, show_api=False) frame_btn = gr.Button("▶️ Register Frame") frame_output = gr.Image(label="Registered Output") download_ftf = gr.File(label="Download") frame_file.change( lambda f: [gr.update(value=0, maximum=_count_frames(f) - 1)] * 2 if f else [gr.update(value=0, maximum=0)] * 2, frame_file, [ref_idx, mov_idx], show_api=False, ) frame_btn.click( frame_to_frame_align, [frame_file, ref_idx, mov_idx, mode_dropdown_ftf], [frame_output, download_ftf], show_api=False, ) gr.Button("🔄 Reset Tab").click( reset_frame_to_frame, outputs=[frame_file, ref_idx, mov_idx, frame_output, download_ftf], show_api=False, ) # --------------------------------------------------------------------------- # MCP / API-only endpoints — thin wrappers that return the output file # as a Gradio FileData so Gradio/MCP can serve it correctly. # --------------------------------------------------------------------------- def _as_mcp_file(path: str) -> gr.FileData: """Wrap a generated local file so Gradio exposes it as a served file.""" return gr.FileData( path=path, orig_name=os.path.basename(path), mime_type="image/tiff", size=os.path.getsize(path), ) def _mcp_align_stack_to_reference( stack_file: str, reference_index: int = 0, mode: TransformationMode = "RIGID_BODY", external_reference_file: Optional[str] = None, external_reference_index: int = 0, ) -> gr.FileData: """Align every frame in a TIFF stack to a chosen reference frame. Each frame in stack_file is registered to the selected reference frame using the chosen transformation model. The reference frame can come from the same stack or from a separate external TIFF stack. Args: stack_file: Path or HTTP/HTTPS URL to the input TIFF stack. reference_index: Zero-based index of the reference frame inside stack_file (ignored when external_reference_file is provided). Default is 0. mode: Transformation model. One of: TRANSLATION, RIGID_BODY, SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY. external_reference_file: Optional path or URL to an external TIFF stack from which the reference frame is taken. external_reference_index: Zero-based index of the reference frame inside external_reference_file. Default is 0. Returns: The aligned output TIFF file. """ out = align_stack_to_reference( stack_file, reference_index, mode, external_reference_file, external_reference_index, ) return _as_mcp_file(out) def _mcp_align_stack_to_stack( reference_stack_file: str, moving_stack_file: str, mode: TransformationMode = "RIGID_BODY", ) -> gr.FileData: """Align every frame in a moving TIFF stack to the first frame of a reference stack. Args: reference_stack_file: Path or HTTP/HTTPS URL to the reference TIFF stack. Its first frame is used as the alignment target. moving_stack_file: Path or HTTP/HTTPS URL to the moving TIFF stack to align. mode: Transformation model. One of: TRANSLATION, RIGID_BODY, SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY. Returns: The aligned output TIFF file. """ out = align_stack_to_stack(reference_stack_file, moving_stack_file, mode) return _as_mcp_file(out) def _mcp_align_frame_to_frame( stack_file: str, reference_index: int, moving_index: int, mode: TransformationMode = "RIGID_BODY", ) -> gr.FileData: """Align a single moving frame to a reference frame within the same TIFF stack. Args: stack_file: Path or HTTP/HTTPS URL to the TIFF stack containing both frames. reference_index: Zero-based index of the reference frame. moving_index: Zero-based index of the frame to align. mode: Transformation model. One of: TRANSLATION, RIGID_BODY, SCALED_ROTATION, AFFINE, BILINEAR. Default is RIGID_BODY. Returns: The aligned single-frame output TIFF file. """ out = align_frame_to_frame(stack_file, reference_index, moving_index, mode) return _as_mcp_file(out) gr.api(fn=_mcp_align_stack_to_reference, api_name="align_stack_to_reference") gr.api(fn=_mcp_align_stack_to_stack, api_name="align_stack_to_stack") gr.api(fn=_mcp_align_frame_to_frame, api_name="align_frame_to_frame") # --------------------------------------------------------------------------- # Page-load handler (UI only — not an MCP tool) # --------------------------------------------------------------------------- def load_from_query(request: gr.Request): params = request.query_params results = [None] * 7 # 7 outputs # One-stack file case (for ref-based + frame-to-frame) if "file_url" in params: try: tmp_path = _resolve_path(params["file_url"], "file_url") with tifffile.TiffFile(tmp_path) as tf: max_frame = len(tf.pages) - 1 results[0] = tmp_path # file_input results[1] = gr.update(value=0, maximum=max_frame) # reference_frame_slider results[2] = tmp_path # frame_file results[3] = gr.update(value=0, maximum=max_frame) # ref_idx results[4] = gr.update(value=1 if max_frame >= 1 else 0, maximum=max_frame) # mov_idx except Exception as e: print(f"[Error loading file_url] {e}") # Two-stack file case (for stack-based alignment) if "file_url_1" in params and "file_url_2" in params: try: results[5] = _resolve_path(params["file_url_1"], "file_url_1") # ref_input results[6] = _resolve_path(params["file_url_2"], "file_url_2") # mov_input except Exception as e: print(f"[Error loading file_url_1 or file_url_2] {e}") return results demo.load( load_from_query, outputs=[file_input, reference_frame_slider, frame_file, ref_idx, mov_idx, ref_input, mov_input], show_api=False, ) if __name__ == "__main__": demo.launch(mcp_server=True)