Folder structure

image of folder structure

README.md

How to run it in the container

To run development

docker compose \
    -f docker-compose.yml \
    -f docker-compose.dev.yml \
    up

To run production:

docker compose down
docker compose build --no-cache
docker compose up -d

The difference between dev and production version is that the app is not in the volume

To run witout a docker compose

docker run --rm \
    -v "$(pwd):/app" \
    -w /app \
    zeiss-positions \
    python script.py \
        examples/B_01_old_refs.pos \
        examples/B_02_new_refs.pos \
        examples/B_03_old_positions.pos

docker-compose.yml

services:
  zeiss-positions:
    build: .
    restart: unless-stopped
    ports:
      - "5000:5000"
    command: python app.py

docker-compose.dev.yml

services:
  zeiss-positions:
    volumes:
      - .:/app

Dockerfile

FROM python:3.13.9-slim@sha256:ed5ef34cda36cfa2f3a340f07cac7e7814f91c7f3c411f6d3562323a866c5c66
WORKDIR /app

# Create virtual environment
RUN python -m venv /venv

# Use it from now on
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN mkdir jobs uploads outputs
COPY . .
CMD ["python", "script.py"]

NOTE: Adding the @sha256:… binds it to the most definded version of python as possible.

I add it after the fact it was build, the information is hiddein in:

docker images # to get the name of the image 
docker inspect <image-name> # search tehere for the sha 

app.py

from flask import Flask
from flask import render_template
from flask import request
from flask import send_file

import os
import uuid
import sys
import subprocess

app = Flask(__name__)

@app.route("/")
def home():

    return render_template(
        "convert.html"
    )

@app.route("/convert", methods=["POST"])
def transform():

    old_refs = request.files["old_refs"]
    new_refs = request.files["new_refs"]
    positions = request.files["positions"]

    job = uuid.uuid4().hex
    job_dir = os.path.join("jobs", job)
    os.makedirs(job_dir)
    output_path = os.path.join(job_dir, "converted.pos")
    
    # Destination filenames
    old_refs_path = os.path.join(job_dir, "old_refs.pos")
    new_refs_path = os.path.join(job_dir, "new_refs.pos")
    positions_path = os.path.join(job_dir, "old_positions.pos")
    
        # Save uploaded files
    old_refs.save(old_refs_path)
    new_refs.save(new_refs_path)
    positions.save(positions_path)

    subprocess.run(
    [
        "python",
        "script.py",
        old_refs_path,
        new_refs_path,
        positions_path,
    ],
    check=True,
)
       # Return the converted file
    return send_file(
        output_path,
        as_attachment=True,
        download_name="converted.pos"
    )

app.run(
    host="0.0.0.0",
    port=5000,
    debug=True
)

requirments.txt

blinker==1.9.0  
click==8.4.2  
Flask==3.1.3
ImageIO==2.37.3
itsdangerous==2.2.0
Jinja2==3.1.6
lazy-loader==0.5
MarkupSafe==3.0.3
networkx==3.6.1
numpy==2.5.0
packaging==26.2
pillow==12.3.0
scikit-image==0.26.0
scipy==1.18.0
tifffile==2026.6.1
Werkzeug==3.1.8

NOTE:
This was obtained after the application was working using:

pip freeze > requirements.txt

script.py

#!/usr/bin/env python3

import re
import sys
import numpy as np
from skimage.transform import estimate_transform
from pathlib import Path
from datetime import datetime


# ------------------------------------------------------------
# Read coordinates from a Zeiss .pos file
# ------------------------------------------------------------

def read_positions(filename):
    """
    Returns an (N,3) numpy array with columns X,Y,Z.
    """

    with open(filename, encoding="latin1") as f:
        text = f.read()

    # Ignore whatever unit follows the number (µm, µm, etc.)
    pattern = re.compile(
        r"X\s*=\s*([-+]?\d+(?:\.\d+)?).*?"
        r"Y\s*=\s*([-+]?\d+(?:\.\d+)?).*?"
        r"Z\s*=\s*([-+]?\d+(?:\.\d+)?)",
        re.DOTALL,
    )

    positions = np.array(
        [[float(x), float(y), float(z)]
         for x, y, z in pattern.findall(text)],
        dtype=float,
    )

    return positions


# ------------------------------------------------------------
# Write transformed coordinates back into a template file
# ------------------------------------------------------------

def write_positions(template_file, output_file, positions):
    """
    Copy template_file while replacing only X,Y,Z values.
    """
    with open(template_file, encoding="latin1") as f:
        text = f.read()

    values = iter(positions)

    pattern = re.compile(
        r"(X\s*=\s*)([-+]?\d+(?:\.\d+)?)(.*?)"
        r"(Y\s*=\s*)([-+]?\d+(?:\.\d+)?)(.*?)"
        r"(Z\s*=\s*)([-+]?\d+(?:\.\d+)?)",
        re.DOTALL,
    )

    def replace(match):
        try:
            x, y, z = next(values)
        except StopIteration:
            raise ValueError(
                "Template contains more positions than supplied."
            )

        return (
            f"{match.group(1)}{x:.3f}"
            f"{match.group(3)}"
            f"{match.group(4)}{y:.3f}"
            f"{match.group(6)}"
            f"{match.group(7)}{z:.3f}"
        )

    new_text = pattern.sub(replace, text)

    try:
        next(values)
        raise ValueError(
            "More coordinates supplied than template contains."
        )
    except StopIteration:
        pass

    with open(output_file, "w", encoding="latin1", newline="\r\n") as f:
        f.write(new_text)
    #with open(output_file, "w", encoding="latin1") as f:
     #   f.write(new_text)


# ------------------------------------------------------------
# Main
# ------------------------------------------------------------

if len(sys.argv) != 4:
    print(
        "Usage:\n"
        "    python script.py old_refs.pos new_refs.pos old_positions.pos"
    )
    sys.exit(1)


old_refs = read_positions(sys.argv[1])
new_refs = read_positions(sys.argv[2])
old_positions = read_positions(sys.argv[3])

print("Old reference points")
print(old_refs)

print("\nNew reference points")
print(new_refs)

print("\nOld positions")
print(old_positions)

if old_refs.shape != new_refs.shape:
    raise ValueError("Reference files contain different numbers of points.")

if old_refs.shape[0] < 2:
    raise ValueError("Need at least two reference points.")

# Compute XY transformation only
tform = estimate_transform(
    "euclidean",
    old_refs[:, :2],
    new_refs[:, :2],
)

print("\nEstimated transform")
print("Rotation (deg):", np.degrees(tform.rotation))
print("Translation:", tform.translation)

# Transform XY coordinates
new_positions = old_positions.copy()
new_positions[:, :2] = tform(old_positions[:, :2])

print("\nTransformed positions")
print(new_positions)

# Directory containing old_positions.pos
output_dir = Path(sys.argv[3]).parent

# Timestamp like 02-07_15-42-31
#timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")

output_file = output_dir / f"converted.pos"

write_positions(
    sys.argv[3],
    output_file,
    new_positions,
)

print(f"\nSaved {output_file}")

templates folder

<!DOCTYPE html>
<html>
<head>
<title>Zeiss Position Transformer</title>
</head>

<body>
<h1>Zeiss Position Transformer</h1>

<hr>
<form
    action="/convert"
    method="POST"
    enctype="multipart/form-data">
<p>Old reference positions</p>
<input
    type="file"
    name="old_refs"
    required>
<br><br>
<p>New reference positions</p>
<input
    type="file"
    name="new_refs"
    required>
<br><br>
<p>Positions to transform</p>
<input
    type="file"
    name="positions"
    required>
<br><br>

<hr>
<button
    type="submit">
Transform
</button>

</form>
</body>
</html>