Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files- README.md +44 -10
- app.py +702 -0
- requirements.txt +5 -0
README.md
CHANGED
|
@@ -1,10 +1,44 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Image File Compare
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.37.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# π Image File Compare
|
| 14 |
+
|
| 15 |
+
A web-based tool for comparing two images with threshold-based difference detection.
|
| 16 |
+
|
| 17 |
+
## Features
|
| 18 |
+
|
| 19 |
+
- **Multi-format support** β TIFF, FITS, PNG, JPG, BMP
|
| 20 |
+
- **Threshold control** β Adjustable slider to filter noise and focus on meaningful differences
|
| 21 |
+
- **Auto-scaling** β When image sizes differ, automatically scales one to match (configurable method)
|
| 22 |
+
- **Multiple scaling algorithms** β Nearest, Bilinear, Bicubic, Lanczos, Area
|
| 23 |
+
- **Difference visualization:**
|
| 24 |
+
- Actual difference image (with optional amplification)
|
| 25 |
+
- Binary mask (white = pixels exceeding threshold)
|
| 26 |
+
- Colored overlay (red = differences)
|
| 27 |
+
- Overlay blended on original image
|
| 28 |
+
- **Download outputs** β All result images downloadable as PNG or TIFF
|
| 29 |
+
- **Statistics** β Pixel count, percentage, max/mean difference values
|
| 30 |
+
|
| 31 |
+
## Usage
|
| 32 |
+
|
| 33 |
+
1. Upload two images (supports TIF, FITS, PNG, JPG, BMP)
|
| 34 |
+
2. Adjust the difference threshold in the sidebar
|
| 35 |
+
3. If sizes differ, choose a scaling method and which image to resize
|
| 36 |
+
4. View results: difference image, binary mask, colored overlay
|
| 37 |
+
5. Download any output using the download buttons at the bottom
|
| 38 |
+
|
| 39 |
+
## Running Locally
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
pip install -r requirements.txt
|
| 43 |
+
streamlit run app.py
|
| 44 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image File Compare Application
|
| 3 |
+
Supports TIFF, FITS and common image formats.
|
| 4 |
+
Provides threshold-based difference detection with scaling options.
|
| 5 |
+
Deployable on Hugging Face Spaces (Streamlit).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import io
|
| 9 |
+
import streamlit as st
|
| 10 |
+
import numpy as np
|
| 11 |
+
from PIL import Image
|
| 12 |
+
from astropy.io import fits
|
| 13 |
+
import cv2
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# --- Helper Functions ---
|
| 18 |
+
|
| 19 |
+
def load_image_from_upload(uploaded_file) -> np.ndarray:
|
| 20 |
+
"""Load an image from a Streamlit uploaded file object."""
|
| 21 |
+
name = uploaded_file.name.lower()
|
| 22 |
+
|
| 23 |
+
if name.endswith((".fits", ".fit", ".fts")):
|
| 24 |
+
return _load_fits_from_bytes(uploaded_file.getvalue())
|
| 25 |
+
elif name.endswith((".tif", ".tiff")):
|
| 26 |
+
return _load_tiff_from_bytes(uploaded_file.getvalue())
|
| 27 |
+
else:
|
| 28 |
+
img = Image.open(uploaded_file)
|
| 29 |
+
return np.array(img).astype(np.float64)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _load_fits_from_bytes(data_bytes: bytes) -> np.ndarray:
|
| 33 |
+
"""Load FITS from bytes."""
|
| 34 |
+
with fits.open(io.BytesIO(data_bytes)) as hdul:
|
| 35 |
+
for hdu in hdul:
|
| 36 |
+
if hdu.data is not None:
|
| 37 |
+
data = hdu.data.astype(np.float64)
|
| 38 |
+
return _normalize_fits_data(data)
|
| 39 |
+
raise ValueError("No image data found in FITS file")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _normalize_fits_data(data: np.ndarray) -> np.ndarray:
|
| 43 |
+
"""Normalize FITS data to 0-255 range."""
|
| 44 |
+
if data.ndim == 2:
|
| 45 |
+
dmin, dmax = np.nanmin(data), np.nanmax(data)
|
| 46 |
+
if dmax - dmin > 0:
|
| 47 |
+
data = (data - dmin) / (dmax - dmin) * 255.0
|
| 48 |
+
else:
|
| 49 |
+
data = np.zeros_like(data)
|
| 50 |
+
elif data.ndim == 3:
|
| 51 |
+
if data.shape[0] in (1, 3, 4):
|
| 52 |
+
data = np.moveaxis(data, 0, -1)
|
| 53 |
+
dmin, dmax = np.nanmin(data), np.nanmax(data)
|
| 54 |
+
if dmax - dmin > 0:
|
| 55 |
+
data = (data - dmin) / (dmax - dmin) * 255.0
|
| 56 |
+
else:
|
| 57 |
+
data = np.zeros_like(data)
|
| 58 |
+
return data
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _load_tiff_from_bytes(data_bytes: bytes) -> np.ndarray:
|
| 62 |
+
"""Load TIFF from bytes."""
|
| 63 |
+
img = Image.open(io.BytesIO(data_bytes))
|
| 64 |
+
data = np.array(img).astype(np.float64)
|
| 65 |
+
if data.max() > 255:
|
| 66 |
+
dmin, dmax = data.min(), data.max()
|
| 67 |
+
if dmax - dmin > 0:
|
| 68 |
+
data = (data - dmin) / (dmax - dmin) * 255.0
|
| 69 |
+
return data
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def align_images(img_ref: np.ndarray, img_to_align: np.ndarray,
|
| 73 |
+
method: str = "ECC (intensity-based)") -> tuple:
|
| 74 |
+
"""
|
| 75 |
+
Align img_to_align to img_ref to minimize differences.
|
| 76 |
+
Returns (aligned_image, info_dict).
|
| 77 |
+
|
| 78 |
+
Methods:
|
| 79 |
+
- "Feature-based (ORB)": Uses ORB keypoints + homography
|
| 80 |
+
- "ECC (intensity-based)": Uses Enhanced Correlation Coefficient for sub-pixel alignment
|
| 81 |
+
- "Phase correlation (translation only)": Handles pure translation/shift
|
| 82 |
+
"""
|
| 83 |
+
# Convert to grayscale uint8 for alignment computation
|
| 84 |
+
def to_gray_u8(img):
|
| 85 |
+
if img.ndim == 3:
|
| 86 |
+
gray = cv2.cvtColor(img.astype(np.uint8), cv2.COLOR_RGB2GRAY)
|
| 87 |
+
else:
|
| 88 |
+
gray = img.astype(np.uint8)
|
| 89 |
+
return gray
|
| 90 |
+
|
| 91 |
+
ref_gray = to_gray_u8(img_ref)
|
| 92 |
+
align_gray = to_gray_u8(img_to_align)
|
| 93 |
+
h, w = ref_gray.shape[:2]
|
| 94 |
+
|
| 95 |
+
info = {"method": method, "success": False, "details": ""}
|
| 96 |
+
|
| 97 |
+
if method == "Feature-based (ORB)":
|
| 98 |
+
aligned, info = _align_feature_based(img_to_align, ref_gray, align_gray, h, w, info)
|
| 99 |
+
elif method == "ECC (intensity-based)":
|
| 100 |
+
aligned, info = _align_ecc(img_to_align, ref_gray, align_gray, h, w, info)
|
| 101 |
+
elif method == "Phase correlation (translation only)":
|
| 102 |
+
aligned, info = _align_phase_correlation(img_to_align, ref_gray, align_gray, h, w, info)
|
| 103 |
+
else:
|
| 104 |
+
aligned = img_to_align
|
| 105 |
+
info["details"] = "Unknown method"
|
| 106 |
+
|
| 107 |
+
return aligned, info
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _align_feature_based(img_to_align, ref_gray, align_gray, h, w, info):
|
| 111 |
+
"""Align using ORB feature detection + homography."""
|
| 112 |
+
orb = cv2.ORB_create(nfeatures=5000)
|
| 113 |
+
kp1, des1 = orb.detectAndCompute(ref_gray, None)
|
| 114 |
+
kp2, des2 = orb.detectAndCompute(align_gray, None)
|
| 115 |
+
|
| 116 |
+
if des1 is None or des2 is None or len(des1) < 10 or len(des2) < 10:
|
| 117 |
+
info["details"] = "Not enough features detected"
|
| 118 |
+
return img_to_align, info
|
| 119 |
+
|
| 120 |
+
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
|
| 121 |
+
matches = bf.knnMatch(des2, des1, k=2)
|
| 122 |
+
|
| 123 |
+
# Lowe's ratio test
|
| 124 |
+
good_matches = []
|
| 125 |
+
for m_pair in matches:
|
| 126 |
+
if len(m_pair) == 2:
|
| 127 |
+
m, n = m_pair
|
| 128 |
+
if m.distance < 0.75 * n.distance:
|
| 129 |
+
good_matches.append(m)
|
| 130 |
+
|
| 131 |
+
if len(good_matches) < 10:
|
| 132 |
+
info["details"] = f"Only {len(good_matches)} good matches found (need >= 10)"
|
| 133 |
+
return img_to_align, info
|
| 134 |
+
|
| 135 |
+
src_pts = np.float32([kp2[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
|
| 136 |
+
dst_pts = np.float32([kp1[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
|
| 137 |
+
|
| 138 |
+
M, mask_inliers = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
|
| 139 |
+
|
| 140 |
+
if M is None:
|
| 141 |
+
info["details"] = "Homography estimation failed"
|
| 142 |
+
return img_to_align, info
|
| 143 |
+
|
| 144 |
+
inliers = int(mask_inliers.sum()) if mask_inliers is not None else 0
|
| 145 |
+
info["success"] = True
|
| 146 |
+
info["details"] = f"{len(good_matches)} matches, {inliers} inliers"
|
| 147 |
+
|
| 148 |
+
if img_to_align.ndim == 2:
|
| 149 |
+
aligned = cv2.warpPerspective(img_to_align, M, (w, h),
|
| 150 |
+
flags=cv2.INTER_LINEAR,
|
| 151 |
+
borderMode=cv2.BORDER_REFLECT)
|
| 152 |
+
else:
|
| 153 |
+
aligned = cv2.warpPerspective(img_to_align, M, (w, h),
|
| 154 |
+
flags=cv2.INTER_LINEAR,
|
| 155 |
+
borderMode=cv2.BORDER_REFLECT)
|
| 156 |
+
|
| 157 |
+
return aligned, info
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _align_ecc(img_to_align, ref_gray, align_gray, h, w, info):
|
| 161 |
+
"""Align using Enhanced Correlation Coefficient (ECC) β handles rotation + translation."""
|
| 162 |
+
# Use affine (6 DOF: rotation, translation, scale, shear)
|
| 163 |
+
warp_matrix = np.eye(2, 3, dtype=np.float32)
|
| 164 |
+
|
| 165 |
+
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 200, 1e-6)
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
# Downscale for initial estimate if images are large
|
| 169 |
+
scale = 1.0
|
| 170 |
+
if max(h, w) > 1000:
|
| 171 |
+
scale = 500.0 / max(h, w)
|
| 172 |
+
ref_small = cv2.resize(ref_gray, None, fx=scale, fy=scale)
|
| 173 |
+
align_small = cv2.resize(align_gray, None, fx=scale, fy=scale)
|
| 174 |
+
|
| 175 |
+
warp_small = np.eye(2, 3, dtype=np.float32)
|
| 176 |
+
_, warp_small = cv2.findTransformECC(
|
| 177 |
+
ref_small, align_small, warp_small, cv2.MOTION_AFFINE, criteria
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# Scale the translation back
|
| 181 |
+
warp_matrix = warp_small.copy()
|
| 182 |
+
warp_matrix[0, 2] /= scale
|
| 183 |
+
warp_matrix[1, 2] /= scale
|
| 184 |
+
|
| 185 |
+
# Refine at full resolution
|
| 186 |
+
_, warp_matrix = cv2.findTransformECC(
|
| 187 |
+
ref_gray, align_gray, warp_matrix, cv2.MOTION_AFFINE, criteria
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
info["success"] = True
|
| 191 |
+
# Extract rotation angle from the matrix
|
| 192 |
+
angle = np.degrees(np.arctan2(warp_matrix[1, 0], warp_matrix[0, 0]))
|
| 193 |
+
tx, ty = warp_matrix[0, 2], warp_matrix[1, 2]
|
| 194 |
+
info["details"] = f"Rotation: {angle:.3f}Β°, Translation: ({tx:.1f}, {ty:.1f}) px"
|
| 195 |
+
|
| 196 |
+
except cv2.error as e:
|
| 197 |
+
info["details"] = f"ECC failed to converge: {str(e)}"
|
| 198 |
+
return img_to_align, info
|
| 199 |
+
|
| 200 |
+
if img_to_align.ndim == 2:
|
| 201 |
+
aligned = cv2.warpAffine(img_to_align, warp_matrix, (w, h),
|
| 202 |
+
flags=cv2.INTER_LINEAR,
|
| 203 |
+
borderMode=cv2.BORDER_REFLECT)
|
| 204 |
+
else:
|
| 205 |
+
aligned = cv2.warpAffine(img_to_align, warp_matrix, (w, h),
|
| 206 |
+
flags=cv2.INTER_LINEAR,
|
| 207 |
+
borderMode=cv2.BORDER_REFLECT)
|
| 208 |
+
|
| 209 |
+
return aligned, info
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _align_phase_correlation(img_to_align, ref_gray, align_gray, h, w, info):
|
| 213 |
+
"""Align using phase correlation β handles pure translation/shift."""
|
| 214 |
+
ref_f = ref_gray.astype(np.float32)
|
| 215 |
+
align_f = align_gray.astype(np.float32)
|
| 216 |
+
|
| 217 |
+
shift, response = cv2.phaseCorrelate(ref_f, align_f)
|
| 218 |
+
|
| 219 |
+
tx, ty = shift # (x, y) shift
|
| 220 |
+
info["success"] = True
|
| 221 |
+
info["details"] = f"Translation: ({tx:.2f}, {ty:.2f}) px, confidence: {response:.4f}"
|
| 222 |
+
|
| 223 |
+
M = np.float32([[1, 0, tx], [0, 1, ty]])
|
| 224 |
+
|
| 225 |
+
if img_to_align.ndim == 2:
|
| 226 |
+
aligned = cv2.warpAffine(img_to_align, M, (w, h),
|
| 227 |
+
flags=cv2.INTER_LINEAR,
|
| 228 |
+
borderMode=cv2.BORDER_REFLECT)
|
| 229 |
+
else:
|
| 230 |
+
aligned = cv2.warpAffine(img_to_align, M, (w, h),
|
| 231 |
+
flags=cv2.INTER_LINEAR,
|
| 232 |
+
borderMode=cv2.BORDER_REFLECT)
|
| 233 |
+
|
| 234 |
+
return aligned, info
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def resize_image(image: np.ndarray, target_shape: tuple, method: str) -> np.ndarray:
|
| 238 |
+
"""Resize image to target shape using specified interpolation method."""
|
| 239 |
+
interpolation_methods = {
|
| 240 |
+
"Nearest": cv2.INTER_NEAREST,
|
| 241 |
+
"Bilinear": cv2.INTER_LINEAR,
|
| 242 |
+
"Bicubic": cv2.INTER_CUBIC,
|
| 243 |
+
"Lanczos": cv2.INTER_LANCZOS4,
|
| 244 |
+
"Area": cv2.INTER_AREA,
|
| 245 |
+
}
|
| 246 |
+
interp = interpolation_methods.get(method, cv2.INTER_LINEAR)
|
| 247 |
+
target_h, target_w = target_shape[:2]
|
| 248 |
+
resized = cv2.resize(image, (target_w, target_h), interpolation=interp)
|
| 249 |
+
return resized
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def compute_difference(img1: np.ndarray, img2: np.ndarray, threshold: float):
|
| 253 |
+
"""
|
| 254 |
+
Compute difference between two images.
|
| 255 |
+
Returns:
|
| 256 |
+
diff_image: absolute difference (float64)
|
| 257 |
+
mask_image: binary mask where diff > threshold (uint8, 0 or 255)
|
| 258 |
+
stats: dictionary with comparison statistics
|
| 259 |
+
"""
|
| 260 |
+
img1_proc = img1.copy()
|
| 261 |
+
img2_proc = img2.copy()
|
| 262 |
+
|
| 263 |
+
# Strip alpha channel if present
|
| 264 |
+
if img1_proc.ndim == 3 and img1_proc.shape[2] == 4:
|
| 265 |
+
img1_proc = img1_proc[:, :, :3]
|
| 266 |
+
if img2_proc.ndim == 3 and img2_proc.shape[2] == 4:
|
| 267 |
+
img2_proc = img2_proc[:, :, :3]
|
| 268 |
+
|
| 269 |
+
# If one is grayscale and other is color, convert both to grayscale
|
| 270 |
+
if img1_proc.ndim != img2_proc.ndim:
|
| 271 |
+
if img1_proc.ndim == 3:
|
| 272 |
+
img1_proc = cv2.cvtColor(img1_proc.astype(np.uint8), cv2.COLOR_RGB2GRAY).astype(np.float64)
|
| 273 |
+
if img2_proc.ndim == 3:
|
| 274 |
+
img2_proc = cv2.cvtColor(img2_proc.astype(np.uint8), cv2.COLOR_RGB2GRAY).astype(np.float64)
|
| 275 |
+
|
| 276 |
+
diff = np.abs(img1_proc - img2_proc)
|
| 277 |
+
|
| 278 |
+
# For multi-channel, take the max across channels for the mask
|
| 279 |
+
if diff.ndim == 3:
|
| 280 |
+
diff_for_mask = np.max(diff, axis=2)
|
| 281 |
+
else:
|
| 282 |
+
diff_for_mask = diff
|
| 283 |
+
|
| 284 |
+
mask = (diff_for_mask > threshold).astype(np.uint8) * 255
|
| 285 |
+
|
| 286 |
+
# Statistics
|
| 287 |
+
total_pixels = mask.shape[0] * mask.shape[1]
|
| 288 |
+
diff_pixels = int(np.count_nonzero(mask))
|
| 289 |
+
diff_percentage = (diff_pixels / total_pixels) * 100
|
| 290 |
+
|
| 291 |
+
stats = {
|
| 292 |
+
"total_pixels": total_pixels,
|
| 293 |
+
"different_pixels": diff_pixels,
|
| 294 |
+
"difference_percentage": diff_percentage,
|
| 295 |
+
"max_difference": float(np.max(diff)),
|
| 296 |
+
"mean_difference": float(np.mean(diff)),
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
return diff, mask, stats
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def to_display_image(data: np.ndarray) -> np.ndarray:
|
| 303 |
+
"""Convert array to displayable uint8 image."""
|
| 304 |
+
if data.ndim == 2:
|
| 305 |
+
dmin, dmax = data.min(), data.max()
|
| 306 |
+
if dmax - dmin > 0:
|
| 307 |
+
normalized = ((data - dmin) / (dmax - dmin) * 255).astype(np.uint8)
|
| 308 |
+
else:
|
| 309 |
+
normalized = np.zeros_like(data, dtype=np.uint8)
|
| 310 |
+
return normalized
|
| 311 |
+
else:
|
| 312 |
+
return np.clip(data, 0, 255).astype(np.uint8)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def create_colored_mask(mask: np.ndarray, diff: np.ndarray) -> np.ndarray:
|
| 316 |
+
"""Create a colored overlay showing differences.
|
| 317 |
+
Green = no difference, Red = difference detected."""
|
| 318 |
+
h, w = mask.shape[:2]
|
| 319 |
+
colored = np.zeros((h, w, 3), dtype=np.uint8)
|
| 320 |
+
colored[:, :, 1] = 100 # slight green background
|
| 321 |
+
|
| 322 |
+
if diff.ndim == 3:
|
| 323 |
+
diff_gray = np.max(diff, axis=2)
|
| 324 |
+
else:
|
| 325 |
+
diff_gray = diff
|
| 326 |
+
|
| 327 |
+
diff_normalized = np.clip(diff_gray / max(diff_gray.max(), 1) * 255, 0, 255).astype(np.uint8)
|
| 328 |
+
|
| 329 |
+
colored[mask > 0, 0] = 255
|
| 330 |
+
colored[mask > 0, 1] = 0
|
| 331 |
+
colored[mask > 0, 2] = diff_normalized[mask > 0]
|
| 332 |
+
|
| 333 |
+
return colored
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def image_to_png_bytes(img_array: np.ndarray) -> bytes:
|
| 337 |
+
"""Convert numpy array to PNG bytes for download."""
|
| 338 |
+
img = Image.fromarray(img_array)
|
| 339 |
+
buf = io.BytesIO()
|
| 340 |
+
img.save(buf, format="PNG")
|
| 341 |
+
return buf.getvalue()
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def image_to_tiff_bytes(img_array: np.ndarray) -> bytes:
|
| 345 |
+
"""Convert numpy array to TIFF bytes for download."""
|
| 346 |
+
img = Image.fromarray(img_array)
|
| 347 |
+
buf = io.BytesIO()
|
| 348 |
+
img.save(buf, format="TIFF")
|
| 349 |
+
return buf.getvalue()
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# --- Streamlit App ---
|
| 353 |
+
|
| 354 |
+
def main():
|
| 355 |
+
st.set_page_config(page_title="Image Compare Tool", layout="wide")
|
| 356 |
+
st.title("π Image File Compare")
|
| 357 |
+
st.markdown("Compare two images with threshold-based difference detection. "
|
| 358 |
+
"Supports **TIFF**, **FITS**, and common image formats.")
|
| 359 |
+
|
| 360 |
+
# --- Sidebar Controls ---
|
| 361 |
+
st.sidebar.header("βοΈ Settings")
|
| 362 |
+
|
| 363 |
+
threshold = st.sidebar.slider(
|
| 364 |
+
"Difference Threshold",
|
| 365 |
+
min_value=0.0,
|
| 366 |
+
max_value=255.0,
|
| 367 |
+
value=10.0,
|
| 368 |
+
step=0.5,
|
| 369 |
+
help="Pixel differences below this threshold are ignored."
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
scaling_method = st.sidebar.selectbox(
|
| 373 |
+
"Scaling Method (if sizes differ)",
|
| 374 |
+
["Bilinear", "Nearest", "Bicubic", "Lanczos", "Area"],
|
| 375 |
+
help="Interpolation method used when resizing images to match dimensions."
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
scale_target = st.sidebar.radio(
|
| 379 |
+
"Scale which image?",
|
| 380 |
+
["Scale Image 2 to match Image 1", "Scale Image 1 to match Image 2"],
|
| 381 |
+
help="Choose which image gets resized when dimensions differ."
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
st.sidebar.markdown("---")
|
| 385 |
+
st.sidebar.header("π Auto-Alignment")
|
| 386 |
+
|
| 387 |
+
enable_alignment = st.sidebar.checkbox(
|
| 388 |
+
"Enable auto-alignment",
|
| 389 |
+
value=False,
|
| 390 |
+
help="Automatically align Image 2 to Image 1 to compensate for rotation/translation before comparison."
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
alignment_method = "ECC (intensity-based)"
|
| 394 |
+
if enable_alignment:
|
| 395 |
+
alignment_method = st.sidebar.selectbox(
|
| 396 |
+
"Alignment method",
|
| 397 |
+
["ECC (intensity-based)", "Feature-based (ORB)", "Phase correlation (translation only)"],
|
| 398 |
+
help="ECC: best for small rotations/shifts (sub-pixel). "
|
| 399 |
+
"ORB: best for larger rotations with texture. "
|
| 400 |
+
"Phase correlation: translation/shift only."
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
st.sidebar.markdown("---")
|
| 404 |
+
st.sidebar.header("π Display Options")
|
| 405 |
+
|
| 406 |
+
show_enhanced_diff = st.sidebar.checkbox("Enhanced difference (amplified)", value=True,
|
| 407 |
+
help="Amplify small differences for better visibility.")
|
| 408 |
+
amplify_factor = 1.0
|
| 409 |
+
if show_enhanced_diff:
|
| 410 |
+
amplify_factor = st.sidebar.slider("Amplification factor", 1.0, 50.0, 10.0, 1.0)
|
| 411 |
+
|
| 412 |
+
st.sidebar.markdown("---")
|
| 413 |
+
st.sidebar.header("πΎ Download Format")
|
| 414 |
+
download_format = st.sidebar.selectbox("Output format", ["PNG", "TIFF"])
|
| 415 |
+
|
| 416 |
+
# --- Help & Credits ---
|
| 417 |
+
st.sidebar.markdown("---")
|
| 418 |
+
|
| 419 |
+
@st.dialog("π Help β Image File Compare", width="large")
|
| 420 |
+
def show_help():
|
| 421 |
+
st.markdown("""
|
| 422 |
+
## Overview
|
| 423 |
+
|
| 424 |
+
**Image File Compare** is a tool designed for **astronomical image comparison**. It helps astronomers and astrophotographers compare star field images captured by different telescopes, at different times, or with different processing pipelines.
|
| 425 |
+
|
| 426 |
+
By overlaying and differencing two images of the same region of sky, you can identify:
|
| 427 |
+
- **New or missing objects** (transients, variable stars, asteroids, novae)
|
| 428 |
+
- **Alignment/registration errors** between exposures
|
| 429 |
+
- **Processing artifacts** introduced by different reduction pipelines
|
| 430 |
+
- **Changes over time** in stellar fields (proper motion, variability)
|
| 431 |
+
|
| 432 |
+
---
|
| 433 |
+
|
| 434 |
+
## Parameters & Settings
|
| 435 |
+
|
| 436 |
+
### Difference Threshold (0β255)
|
| 437 |
+
Controls the sensitivity of the comparison. Pixel-level differences **below** this value are treated as "no change" and ignored.
|
| 438 |
+
- **Low threshold (0β5):** Very sensitive β shows noise-level differences, sensor read noise, and thermal artifacts.
|
| 439 |
+
- **Medium threshold (5β20):** Good default β filters out minor noise while catching real changes.
|
| 440 |
+
- **High threshold (20+):** Only shows large differences β useful for finding bright transients or major changes.
|
| 441 |
+
|
| 442 |
+
### Scaling Method
|
| 443 |
+
When two images have **different pixel dimensions** (e.g., different instruments or plate scales), one image must be resized to match. Choose the interpolation method:
|
| 444 |
+
- **Bilinear:** Good general-purpose method. Fast, smooth results.
|
| 445 |
+
- **Nearest:** No interpolation β preserves exact pixel values. Best for integer data.
|
| 446 |
+
- **Bicubic:** Smoother than bilinear. Slightly sharper results.
|
| 447 |
+
- **Lanczos:** Highest quality. Best for downsampling. Preserves fine detail.
|
| 448 |
+
- **Area:** Best for shrinking images. Uses pixel area relation.
|
| 449 |
+
|
| 450 |
+
### Scale Which Image?
|
| 451 |
+
Choose which image gets resized when dimensions differ. Typically you keep your **reference image** (Image 1) at its native resolution and scale Image 2 to match.
|
| 452 |
+
|
| 453 |
+
---
|
| 454 |
+
|
| 455 |
+
## Auto-Alignment
|
| 456 |
+
|
| 457 |
+
When images have rotational or translational offsets (common when comparing across different telescopes, mounts, or epochs), enable auto-alignment to register Image 2 to Image 1 before comparison.
|
| 458 |
+
|
| 459 |
+
### Alignment Methods
|
| 460 |
+
|
| 461 |
+
- **ECC (intensity-based):** Enhanced Correlation Coefficient method. Works by maximizing the correlation between pixel intensities. Best for **small rotations** (< 5Β°) and sub-pixel translations. Very accurate but can fail on large offsets.
|
| 462 |
+
|
| 463 |
+
- **Feature-based (ORB):** Detects keypoint features (stars, galaxies) in both images and matches them to compute a geometric transform (homography). Best for **larger rotations** and images with many point sources. Works well for star fields.
|
| 464 |
+
|
| 465 |
+
- **Phase correlation (translation only):** Computes the translational shift between images using Fourier analysis. Only corrects X/Y offset β does **not** handle rotation. Very fast, good for dithered exposures from the same telescope.
|
| 466 |
+
|
| 467 |
+
---
|
| 468 |
+
|
| 469 |
+
## Display Options
|
| 470 |
+
|
| 471 |
+
### Enhanced Difference (Amplified)
|
| 472 |
+
When enabled, the difference image is multiplied by an amplification factor to make subtle differences visible. Without this, a 1-count difference on a 0β255 scale would be nearly invisible.
|
| 473 |
+
|
| 474 |
+
### Amplification Factor (1β50Γ)
|
| 475 |
+
How much to boost the difference image. Higher values make faint differences more visible but can saturate bright differences.
|
| 476 |
+
|
| 477 |
+
---
|
| 478 |
+
|
| 479 |
+
## Download Format
|
| 480 |
+
Choose whether output images are saved as:
|
| 481 |
+
- **PNG:** Lossless, 8-bit per channel. Compatible with all viewers.
|
| 482 |
+
- **TIFF:** Lossless, widely used in astronomical imaging pipelines.
|
| 483 |
+
|
| 484 |
+
---
|
| 485 |
+
|
| 486 |
+
## Output Images
|
| 487 |
+
|
| 488 |
+
- **Actual Difference Image:** Absolute pixel-by-pixel difference between the two images (optionally amplified).
|
| 489 |
+
- **Binary Mask:** White pixels where the difference exceeds the threshold, black elsewhere. Useful for counting changed regions.
|
| 490 |
+
- **Colored Difference Overlay:** Red marks pixels exceeding the threshold, dark green shows matching regions. Intensity indicates the magnitude of difference.
|
| 491 |
+
- **Overlay on Original:** The colored overlay blended onto Image 1 at adjustable opacity, helping you localize differences in context.
|
| 492 |
+
""")
|
| 493 |
+
|
| 494 |
+
if st.sidebar.button("β Help", use_container_width=True):
|
| 495 |
+
show_help()
|
| 496 |
+
|
| 497 |
+
st.sidebar.markdown("---")
|
| 498 |
+
st.sidebar.markdown(
|
| 499 |
+
"<div style='text-align: center; color: gray; font-size: 0.85em;'>"
|
| 500 |
+
"Created by <b>Andy Kong</b>"
|
| 501 |
+
"</div>",
|
| 502 |
+
unsafe_allow_html=True,
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
# --- Image Input ---
|
| 506 |
+
st.header("π Upload Images")
|
| 507 |
+
|
| 508 |
+
col1, col2 = st.columns(2)
|
| 509 |
+
with col1:
|
| 510 |
+
file1 = st.file_uploader("Image 1", type=["tif", "tiff", "fits", "fit", "fts",
|
| 511 |
+
"png", "jpg", "jpeg", "bmp"])
|
| 512 |
+
with col2:
|
| 513 |
+
file2 = st.file_uploader("Image 2", type=["tif", "tiff", "fits", "fit", "fts",
|
| 514 |
+
"png", "jpg", "jpeg", "bmp"])
|
| 515 |
+
|
| 516 |
+
img1_data = None
|
| 517 |
+
img2_data = None
|
| 518 |
+
img1_name = ""
|
| 519 |
+
img2_name = ""
|
| 520 |
+
|
| 521 |
+
if file1 and file2:
|
| 522 |
+
img1_name = file1.name
|
| 523 |
+
img2_name = file2.name
|
| 524 |
+
try:
|
| 525 |
+
img1_data = load_image_from_upload(file1)
|
| 526 |
+
img2_data = load_image_from_upload(file2)
|
| 527 |
+
except Exception as e:
|
| 528 |
+
st.error(f"Error loading images: {e}")
|
| 529 |
+
|
| 530 |
+
# --- Comparison ---
|
| 531 |
+
if img1_data is not None and img2_data is not None:
|
| 532 |
+
st.markdown("---")
|
| 533 |
+
st.header("π Image Information")
|
| 534 |
+
|
| 535 |
+
col1, col2 = st.columns(2)
|
| 536 |
+
with col1:
|
| 537 |
+
st.markdown(f"**Image 1:** `{img1_name}`")
|
| 538 |
+
st.markdown(f"Shape: `{img1_data.shape}` | "
|
| 539 |
+
f"Range: [{img1_data.min():.1f}, {img1_data.max():.1f}]")
|
| 540 |
+
with col2:
|
| 541 |
+
st.markdown(f"**Image 2:** `{img2_name}`")
|
| 542 |
+
st.markdown(f"Shape: `{img2_data.shape}` | "
|
| 543 |
+
f"Range: [{img2_data.min():.1f}, {img2_data.max():.1f}]")
|
| 544 |
+
|
| 545 |
+
# Handle size mismatch
|
| 546 |
+
if img1_data.shape[:2] != img2_data.shape[:2]:
|
| 547 |
+
st.warning(f"β οΈ Image sizes differ! Image 1: {img1_data.shape[:2]}, "
|
| 548 |
+
f"Image 2: {img2_data.shape[:2]}. "
|
| 549 |
+
f"Applying **{scaling_method}** scaling.")
|
| 550 |
+
|
| 551 |
+
if "Image 2" in scale_target:
|
| 552 |
+
img2_data = resize_image(img2_data, img1_data.shape[:2], scaling_method)
|
| 553 |
+
else:
|
| 554 |
+
img1_data = resize_image(img1_data, img2_data.shape[:2], scaling_method)
|
| 555 |
+
|
| 556 |
+
# Handle channel mismatch
|
| 557 |
+
if img1_data.ndim != img2_data.ndim:
|
| 558 |
+
st.info("Channel mismatch detected. Converting both to grayscale for comparison.")
|
| 559 |
+
if img1_data.ndim == 3:
|
| 560 |
+
img1_data = cv2.cvtColor(img1_data.astype(np.uint8),
|
| 561 |
+
cv2.COLOR_RGB2GRAY).astype(np.float64)
|
| 562 |
+
if img2_data.ndim == 3:
|
| 563 |
+
img2_data = cv2.cvtColor(img2_data.astype(np.uint8),
|
| 564 |
+
cv2.COLOR_RGB2GRAY).astype(np.float64)
|
| 565 |
+
|
| 566 |
+
# Auto-alignment
|
| 567 |
+
if enable_alignment:
|
| 568 |
+
with st.spinner(f"Aligning images using {alignment_method}..."):
|
| 569 |
+
img2_data, align_info = align_images(img1_data, img2_data, alignment_method)
|
| 570 |
+
if align_info["success"]:
|
| 571 |
+
st.success(f"β
Alignment successful β {align_info['details']}")
|
| 572 |
+
else:
|
| 573 |
+
st.warning(f"β οΈ Alignment issue β {align_info['details']}")
|
| 574 |
+
|
| 575 |
+
# Compute difference
|
| 576 |
+
diff, mask, stats = compute_difference(img1_data, img2_data, threshold)
|
| 577 |
+
|
| 578 |
+
# --- Results ---
|
| 579 |
+
st.markdown("---")
|
| 580 |
+
st.header("π Comparison Results")
|
| 581 |
+
|
| 582 |
+
# Stats
|
| 583 |
+
stat_cols = st.columns(4)
|
| 584 |
+
stat_cols[0].metric("Different Pixels", f"{stats['different_pixels']:,}")
|
| 585 |
+
stat_cols[1].metric("Difference %", f"{stats['difference_percentage']:.2f}%")
|
| 586 |
+
stat_cols[2].metric("Max Difference", f"{stats['max_difference']:.1f}")
|
| 587 |
+
stat_cols[3].metric("Mean Difference", f"{stats['mean_difference']:.2f}")
|
| 588 |
+
|
| 589 |
+
# Determine download format
|
| 590 |
+
if download_format == "PNG":
|
| 591 |
+
ext = "png"
|
| 592 |
+
mime = "image/png"
|
| 593 |
+
convert_fn = image_to_png_bytes
|
| 594 |
+
else:
|
| 595 |
+
ext = "tiff"
|
| 596 |
+
mime = "image/tiff"
|
| 597 |
+
convert_fn = image_to_tiff_bytes
|
| 598 |
+
|
| 599 |
+
st.markdown("---")
|
| 600 |
+
|
| 601 |
+
# Display images
|
| 602 |
+
st.subheader("πΌοΈ Source Images")
|
| 603 |
+
col1, col2 = st.columns(2)
|
| 604 |
+
with col1:
|
| 605 |
+
st.markdown(f"**Image 1:** `{img1_name}`")
|
| 606 |
+
st.image(to_display_image(img1_data), use_container_width=True)
|
| 607 |
+
with col2:
|
| 608 |
+
st.markdown(f"**Image 2:** `{img2_name}`")
|
| 609 |
+
st.image(to_display_image(img2_data), use_container_width=True)
|
| 610 |
+
|
| 611 |
+
st.markdown("---")
|
| 612 |
+
|
| 613 |
+
# Prepare output images
|
| 614 |
+
diff_display = diff.copy()
|
| 615 |
+
if show_enhanced_diff:
|
| 616 |
+
diff_display = diff_display * amplify_factor
|
| 617 |
+
diff_display_uint8 = to_display_image(diff_display)
|
| 618 |
+
mask_display = mask # already uint8
|
| 619 |
+
|
| 620 |
+
# --- Difference Image with inline download ---
|
| 621 |
+
header_col, btn_col = st.columns([4, 1])
|
| 622 |
+
with header_col:
|
| 623 |
+
st.subheader("π Actual Difference Image")
|
| 624 |
+
with btn_col:
|
| 625 |
+
st.download_button(
|
| 626 |
+
label=f"β¬οΈ .{ext}",
|
| 627 |
+
data=convert_fn(diff_display_uint8),
|
| 628 |
+
file_name=f"difference.{ext}",
|
| 629 |
+
mime=mime,
|
| 630 |
+
key="dl_diff",
|
| 631 |
+
)
|
| 632 |
+
st.image(diff_display_uint8, use_container_width=True)
|
| 633 |
+
|
| 634 |
+
st.markdown("---")
|
| 635 |
+
|
| 636 |
+
# --- Binary Mask with inline download ---
|
| 637 |
+
header_col, btn_col = st.columns([4, 1])
|
| 638 |
+
with header_col:
|
| 639 |
+
st.subheader("π Binary Mask")
|
| 640 |
+
with btn_col:
|
| 641 |
+
st.download_button(
|
| 642 |
+
label=f"β¬οΈ .{ext}",
|
| 643 |
+
data=convert_fn(mask_display),
|
| 644 |
+
file_name=f"mask.{ext}",
|
| 645 |
+
mime=mime,
|
| 646 |
+
key="dl_mask",
|
| 647 |
+
)
|
| 648 |
+
st.image(mask_display, use_container_width=True,
|
| 649 |
+
caption="White = difference > threshold")
|
| 650 |
+
|
| 651 |
+
st.markdown("---")
|
| 652 |
+
|
| 653 |
+
# --- Colored Overlay with inline download ---
|
| 654 |
+
colored_mask = create_colored_mask(mask, diff)
|
| 655 |
+
header_col, btn_col = st.columns([4, 1])
|
| 656 |
+
with header_col:
|
| 657 |
+
st.subheader("π¨ Colored Difference Overlay")
|
| 658 |
+
with btn_col:
|
| 659 |
+
st.download_button(
|
| 660 |
+
label=f"β¬οΈ .{ext}",
|
| 661 |
+
data=convert_fn(colored_mask),
|
| 662 |
+
file_name=f"colored_overlay.{ext}",
|
| 663 |
+
mime=mime,
|
| 664 |
+
key="dl_colored",
|
| 665 |
+
)
|
| 666 |
+
st.image(colored_mask, use_container_width=True,
|
| 667 |
+
caption="Red = pixels exceeding threshold | Dark green = within threshold")
|
| 668 |
+
|
| 669 |
+
st.markdown("---")
|
| 670 |
+
|
| 671 |
+
# --- Overlay on Original with inline download ---
|
| 672 |
+
overlay_alpha = st.slider("Overlay opacity", 0.0, 1.0, 0.4, 0.05)
|
| 673 |
+
|
| 674 |
+
base_img = to_display_image(img1_data)
|
| 675 |
+
if base_img.ndim == 2:
|
| 676 |
+
base_img = cv2.cvtColor(base_img, cv2.COLOR_GRAY2RGB)
|
| 677 |
+
elif base_img.ndim == 3 and base_img.shape[2] == 4:
|
| 678 |
+
base_img = cv2.cvtColor(base_img, cv2.COLOR_RGBA2RGB)
|
| 679 |
+
|
| 680 |
+
overlay = cv2.addWeighted(base_img, 1 - overlay_alpha,
|
| 681 |
+
colored_mask, overlay_alpha, 0)
|
| 682 |
+
|
| 683 |
+
header_col, btn_col = st.columns([4, 1])
|
| 684 |
+
with header_col:
|
| 685 |
+
st.subheader("π· Overlay on Original")
|
| 686 |
+
with btn_col:
|
| 687 |
+
st.download_button(
|
| 688 |
+
label=f"β¬οΈ .{ext}",
|
| 689 |
+
data=convert_fn(overlay),
|
| 690 |
+
file_name=f"overlay_on_original.{ext}",
|
| 691 |
+
mime=mime,
|
| 692 |
+
key="dl_overlay",
|
| 693 |
+
)
|
| 694 |
+
st.image(overlay, use_container_width=True,
|
| 695 |
+
caption="Image 1 with difference overlay")
|
| 696 |
+
|
| 697 |
+
else:
|
| 698 |
+
st.info("π Please upload two images to compare.")
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
if __name__ == "__main__":
|
| 702 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit==1.37.0
|
| 2 |
+
numpy==1.26.4
|
| 3 |
+
Pillow==10.4.0
|
| 4 |
+
astropy==6.1.1
|
| 5 |
+
opencv-python-headless==4.10.0.84
|