AK51 commited on
Commit
b5341f3
Β·
verified Β·
1 Parent(s): 65574e5

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +44 -10
  2. app.py +702 -0
  3. requirements.txt +5 -0
README.md CHANGED
@@ -1,10 +1,44 @@
1
- ---
2
- title: FileCompare
3
- emoji: 😻
4
- colorFrom: yellow
5
- colorTo: green
6
- sdk: static
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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