Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,72 @@
# TIFviewer-processor
# TIFviewer-processor

A frontend-backend separated tool for viewing and processing **16-bit grayscale TIFF** images.

## Stack

| Layer | Technology |
|----------|-----------------------------------------|
| Backend | Python · FastAPI · tifffile · NumPy · scikit-image · scipy |
| Frontend | Vue 3 · Vite · Chart.js · vuedraggable |

## Features

* Upload 16-bit TIF/TIFF files
* Grayscale histogram with hover tooltip (Value / Distribution)
* Composable enhancement pipeline (drag-to-reorder, add/remove)
* Histogram Equalization
* Local Contrast Normalization (Sigma, Epsilon, Output Gain sliders)
* Image viewer with scroll-to-zoom and drag-to-pan
* Toolbar with zoom in/out/fit and annotation-mode placeholders

## Quick start

### Backend

```bash
cd backend
pip install -r requirements.txt
python main.py # starts on http://localhost:8000
```

### Frontend

```bash
cd frontend
npm install
npm run dev # starts on http://localhost:5173
```

Open **http://localhost:5173** in your browser. The Vite dev-server proxies
all `/api/*` requests to the FastAPI backend automatically.

## API reference

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/upload` | Upload a `.tif`/`.tiff` file; returns `file_id` |
| `GET` | `/api/image/{file_id}` | Render processed PNG (query: `enhancements`, `min_val`, `max_val`) |
| `GET` | `/api/histogram/{file_id}` | 256-bin histogram JSON |
| `POST` | `/api/process` | Apply pipeline; returns base64 PNG + histogram |

## Project layout

```
backend/
main.py FastAPI application
requirements.txt
processors/
histogram_eq.py Histogram equalization
local_contrast_norm.py Local contrast normalization
frontend/
src/
App.vue Root component / layout
components/
Histogram.vue Chart.js histogram panel
EnhancementPanel.vue Pipeline list (drag-and-drop)
ParameterPanel.vue Slider controls
SliderRow.vue Single slider with value display
ImageViewer.vue Pan / zoom image display
Toolbar.vue File open + zoom + annotation toolbar
vite.config.js Proxies /api → localhost:8000
```
6 changes: 6 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*.egg-info/
.env
.venv/
venv/
150 changes: 150 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import io
import json
import uuid
import base64
from typing import Any, Optional

import numpy as np
import tifffile
from PIL import Image
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel

from processors import PROCESSORS

app = FastAPI(title="TIF Viewer API")

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# In-memory store: file_id -> numpy array (float32, range 0-65535).
# Replace with a persistent cache (e.g. Redis / disk) for production use.
image_store: dict[str, np.ndarray] = {}


# ── Helpers ───────────────────────────────────────────────────────────────────

def _apply_pipeline(image: np.ndarray, enhancements: list[dict]) -> np.ndarray:
"""Apply a list of {type, params} dicts sequentially."""
result = image.copy()
for enh in enhancements:
enh_type = enh.get("type")
params = enh.get("params") or {}
processor = PROCESSORS.get(enh_type)
if processor is None:
raise HTTPException(status_code=400, detail=f"Unknown enhancement: {enh_type!r}")
result = processor(result, **params)
return result


def _to_png_bytes(
image: np.ndarray,
min_val: Optional[float],
max_val: Optional[float],
) -> bytes:
"""Normalise to 8-bit and encode as PNG."""
img = image.astype(np.float32)
lo = float(min_val) if min_val is not None else float(img.min())
hi = float(max_val) if max_val is not None else float(img.max())
if hi == lo:
hi = lo + 1.0
img_8 = ((img - lo) / (hi - lo) * 255.0).clip(0, 255).astype(np.uint8)
buf = io.BytesIO()
Image.fromarray(img_8, mode="L").save(buf, format="PNG")
return buf.getvalue()


def _compute_histogram(image: np.ndarray) -> dict:
"""256-bin histogram over [0, 65535]."""
counts, edges = np.histogram(image, bins=256, range=(0, 65535))
bins = ((edges[:-1] + edges[1:]) / 2.0).astype(int).tolist()
return {"bins": bins, "counts": counts.tolist()}


# ── Endpoints ─────────────────────────────────────────────────────────────────

@app.get("/")
def root():
return {"status": "ok", "message": "TIF Viewer API"}


@app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)):
if not file.filename.lower().endswith((".tif", ".tiff")):
raise HTTPException(status_code=400, detail="Only .tif / .tiff files are accepted")

data = await file.read()
try:
img = tifffile.imread(io.BytesIO(data))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Failed to read TIFF: {exc}")

# Flatten to 2-D grayscale
if img.ndim == 3:
img = img[..., 0] if img.shape[2] == 1 else img.mean(axis=2)
elif img.ndim != 2:
raise HTTPException(status_code=400, detail="Unsupported TIFF dimensionality")

img = img.astype(np.float32)

file_id = str(uuid.uuid4())
image_store[file_id] = img
return {"file_id": file_id, "width": int(img.shape[1]), "height": int(img.shape[0])}


@app.get("/api/image/{file_id}")
def get_image(
file_id: str,
enhancements: Optional[str] = None,
min_val: Optional[float] = None,
max_val: Optional[float] = None,
):
if file_id not in image_store:
raise HTTPException(status_code=404, detail="File not found")

img = image_store[file_id]

if enhancements:
try:
enh_list = json.loads(enhancements)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid enhancements JSON")
img = _apply_pipeline(img, enh_list)

return Response(content=_to_png_bytes(img, min_val, max_val), media_type="image/png")


@app.get("/api/histogram/{file_id}")
def get_histogram(file_id: str):
if file_id not in image_store:
raise HTTPException(status_code=404, detail="File not found")
return JSONResponse(_compute_histogram(image_store[file_id]))


class ProcessRequest(BaseModel):
file_id: str
enhancements: list[dict[str, Any]] = []
min_val: Optional[float] = None
max_val: Optional[float] = None


@app.post("/api/process")
def process_image(req: ProcessRequest):
if req.file_id not in image_store:
raise HTTPException(status_code=404, detail="File not found")

img = _apply_pipeline(image_store[req.file_id], req.enhancements)
b64 = base64.b64encode(_to_png_bytes(img, req.min_val, req.max_val)).decode()
return {"image": b64, "histogram": _compute_histogram(img)}


if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
9 changes: 9 additions & 0 deletions backend/processors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from processors.histogram_eq import apply as _hist_eq
from processors.local_contrast_norm import apply as _lcn

PROCESSORS = {
"histogram_equalization": _hist_eq,
"local_contrast_normalization": _lcn,
}

__all__ = ["PROCESSORS"]
14 changes: 14 additions & 0 deletions backend/processors/histogram_eq.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import numpy as np
from skimage.exposure import equalize_hist


def apply(image: np.ndarray) -> np.ndarray:
"""Histogram equalization on a 16-bit grayscale image.

The image is normalised to [0, 1] before equalisation, then scaled back
to the uint16 range so that downstream processors continue to work on a
consistent data type.
"""
img_float = image.astype(np.float32) / 65535.0
equalised = equalize_hist(img_float) # returns float in [0, 1]
return (equalised * 65535.0).astype(np.float32)
41 changes: 41 additions & 0 deletions backend/processors/local_contrast_norm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import numpy as np
from scipy.ndimage import gaussian_filter


def apply(
image: np.ndarray,
sigma: float = 25.54,
epsilon: float = 0.08,
output_gain: float = 1.0,
) -> np.ndarray:
"""Local contrast normalization.

For each pixel p:
p_norm = (p - local_mean) / (local_std + epsilon)

Parameters
----------
image: Input 2-D array (uint16 or float32).
sigma: Gaussian blur radius for computing local statistics.
epsilon: Stability constant added to the local standard deviation.
output_gain: Multiplicative gain applied to the normalised image before
rescaling back to the uint16 range.
"""
img = image.astype(np.float32)

local_mean = gaussian_filter(img, sigma=sigma)
local_mean_sq = gaussian_filter(img ** 2, sigma=sigma)
local_var = np.maximum(local_mean_sq - local_mean ** 2, 0.0)
local_std = np.sqrt(local_var)

normalised = (img - local_mean) / (local_std + epsilon)
normalised *= output_gain

# Rescale to [0, 65535] for consistency with the rest of the pipeline
lo, hi = normalised.min(), normalised.max()
if hi > lo:
normalised = (normalised - lo) / (hi - lo) * 65535.0
else:
normalised = np.zeros_like(normalised)

return normalised.astype(np.float32)
8 changes: 8 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
python-multipart>=0.0.9
numpy>=1.26.0
tifffile>=2024.2.12
Pillow>=10.3.0
scikit-image>=0.22.0
scipy>=1.13.0
24 changes: 24 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
3 changes: 3 additions & 0 deletions frontend/.vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
5 changes: 5 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Vue 3 + Vite

This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.

Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
13 changes: 13 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Loading