Spaces:
Sleeping
Sleeping
Add application file
Browse files- .env.example +12 -0
- .gitignore +18 -0
- api.py +154 -0
- app.py +128 -0
- auth.py +87 -0
- create_user.py +16 -0
- describe.py +168 -0
- diff.py +347 -0
- diff_ai copy.py +622 -0
- diff_ai.py +640 -0
- requirements-render.txt +9 -0
- requirements.txt +16 -0
- sam2_t.pt +3 -0
- test_describe.py +56 -0
- users.json +5 -0
.env.example
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Keys
|
| 2 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 3 |
+
|
| 4 |
+
# Network Configuration (optional, defaults to 5050)
|
| 5 |
+
PORT=5050
|
| 6 |
+
|
| 7 |
+
# CORS Configuration (comma-separated list of allowed frontend domains)
|
| 8 |
+
# e.g., DIFFLENS_ORIGINS=https://your-username.github.io,http://localhost:5000
|
| 9 |
+
DIFFLENS_ORIGINS=http://localhost:5000
|
| 10 |
+
|
| 11 |
+
# Override for Public asset URLs (optional, defaults to auto-detecting the request host)
|
| 12 |
+
# DIFFLENS_PUBLIC_URL=https://your-huggingface-space.hf.space
|
.gitignore
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local environment and secrets
|
| 2 |
+
.env
|
| 3 |
+
.secret_key
|
| 4 |
+
|
| 5 |
+
# Python virtual environments
|
| 6 |
+
.venv/
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
ENV/
|
| 10 |
+
|
| 11 |
+
# Python cache files
|
| 12 |
+
__pycache__/
|
| 13 |
+
*.pyc
|
| 14 |
+
*.pyo
|
| 15 |
+
*.pyd
|
| 16 |
+
|
| 17 |
+
# Local uploads & temporary folders
|
| 18 |
+
uploads/
|
api.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
from flask import Flask, request, send_from_directory, jsonify, session
|
| 8 |
+
from flask_cors import CORS
|
| 9 |
+
from diff import compare_images
|
| 10 |
+
from diff_ai import compare_images_ai
|
| 11 |
+
from describe import describe_change
|
| 12 |
+
from auth import (
|
| 13 |
+
authenticate, login_required, get_secret_key, current_user,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
app = Flask(__name__)
|
| 17 |
+
app.config["SECRET_KEY"] = get_secret_key()
|
| 18 |
+
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
| 19 |
+
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
| 20 |
+
|
| 21 |
+
# Allow the frontend origin to send cookies. For local dev we list the dev
|
| 22 |
+
# origins; in production set DIFFLENS_ORIGINS env var to your domain.
|
| 23 |
+
_origins_env = os.environ.get("DIFFLENS_ORIGINS")
|
| 24 |
+
if _origins_env:
|
| 25 |
+
_origins = [o.strip() for o in _origins_env.split(",") if o.strip()]
|
| 26 |
+
else:
|
| 27 |
+
_origins = [
|
| 28 |
+
"http://localhost:8000", "http://127.0.0.1:8000",
|
| 29 |
+
"http://localhost:5000", "http://127.0.0.1:5000",
|
| 30 |
+
"http://localhost:3000", "http://127.0.0.1:3000"
|
| 31 |
+
]
|
| 32 |
+
CORS(app, resources={r"/*": {"origins": _origins}}, supports_credentials=True)
|
| 33 |
+
|
| 34 |
+
# Use /tmp/uploads on Linux/production environments (e.g. Hugging Face Spaces)
|
| 35 |
+
if os.name != "nt" or os.environ.get("HF_HUB_DISABLE_SYMLINKS_WARNING"):
|
| 36 |
+
UPLOAD_FOLDER = "/tmp/uploads"
|
| 37 |
+
else:
|
| 38 |
+
UPLOAD_FOLDER = "../uploads"
|
| 39 |
+
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def cleanup_old_files(folder, max_age_seconds=900):
|
| 43 |
+
"""Clean up files in the given folder that are older than max_age_seconds."""
|
| 44 |
+
try:
|
| 45 |
+
now = time.time()
|
| 46 |
+
for filename in os.listdir(folder):
|
| 47 |
+
filepath = os.path.join(folder, filename)
|
| 48 |
+
if os.path.isfile(filepath):
|
| 49 |
+
stat = os.stat(filepath)
|
| 50 |
+
if now - stat.st_mtime > max_age_seconds:
|
| 51 |
+
try:
|
| 52 |
+
os.remove(filepath)
|
| 53 |
+
except Exception as e:
|
| 54 |
+
print(f"[app] Failed to remove {filename}: {e}")
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"[app] Cleanup failed: {e}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@app.route("/")
|
| 60 |
+
def home():
|
| 61 |
+
return "Backend running"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------- AUTH ----------------
|
| 65 |
+
|
| 66 |
+
@app.route("/auth/login", methods=["POST", "OPTIONS"])
|
| 67 |
+
def login():
|
| 68 |
+
if request.method == "OPTIONS":
|
| 69 |
+
return ("", 204)
|
| 70 |
+
data = request.get_json(silent=True) or {}
|
| 71 |
+
username = (data.get("username") or "").strip()
|
| 72 |
+
password = data.get("password") or ""
|
| 73 |
+
if not username or not password:
|
| 74 |
+
return jsonify({"error": "missing credentials"}), 400
|
| 75 |
+
if not authenticate(username, password):
|
| 76 |
+
return jsonify({"error": "invalid credentials"}), 401
|
| 77 |
+
session["user"] = username
|
| 78 |
+
session.permanent = True
|
| 79 |
+
return jsonify({"user": username})
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@app.route("/auth/logout", methods=["POST", "OPTIONS"])
|
| 83 |
+
def logout():
|
| 84 |
+
if request.method == "OPTIONS":
|
| 85 |
+
return ("", 204)
|
| 86 |
+
session.clear()
|
| 87 |
+
return jsonify({"ok": True})
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.route("/auth/me", methods=["GET", "OPTIONS"])
|
| 91 |
+
def me():
|
| 92 |
+
if request.method == "OPTIONS":
|
| 93 |
+
return ("", 204)
|
| 94 |
+
return jsonify({"user": current_user()})
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------- DIFF ----------------
|
| 98 |
+
|
| 99 |
+
@app.route("/compare", methods=["POST", "OPTIONS"])
|
| 100 |
+
def compare():
|
| 101 |
+
if request.method == "OPTIONS":
|
| 102 |
+
return jsonify({"status": "ok"}), 200
|
| 103 |
+
|
| 104 |
+
# Clean up old temp uploads
|
| 105 |
+
cleanup_old_files(UPLOAD_FOLDER)
|
| 106 |
+
|
| 107 |
+
if "image1" not in request.files or "image2" not in request.files:
|
| 108 |
+
return jsonify({"error": "Missing images"}), 400
|
| 109 |
+
|
| 110 |
+
img1 = request.files["image1"]
|
| 111 |
+
img2 = request.files["image2"]
|
| 112 |
+
|
| 113 |
+
# Use UUID to prevent image name collisions from concurrent users
|
| 114 |
+
req_id = str(uuid.uuid4())
|
| 115 |
+
path1 = os.path.join(UPLOAD_FOLDER, f"{req_id}_img1.jpg")
|
| 116 |
+
path2 = os.path.join(UPLOAD_FOLDER, f"{req_id}_img2.jpg")
|
| 117 |
+
img1.save(path1)
|
| 118 |
+
img2.save(path2)
|
| 119 |
+
|
| 120 |
+
output_path = os.path.join(UPLOAD_FOLDER, f"{req_id}_result.png")
|
| 121 |
+
heatmap_path = os.path.join(UPLOAD_FOLDER, f"{req_id}_heatmap.png")
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
result_data = compare_images_ai(path1, path2, output_path, heatmap_path)
|
| 125 |
+
except Exception as e:
|
| 126 |
+
print(f"[app] AI pipeline failed ({e}); falling back to classical")
|
| 127 |
+
result_data = compare_images(path1, path2, output_path, heatmap_path)
|
| 128 |
+
result_data["pipeline"] = "classical"
|
| 129 |
+
|
| 130 |
+
description = describe_change(path1, path2, result_data)
|
| 131 |
+
|
| 132 |
+
# Construct the base URL dynamically so Hugging Face Space works without hardcoded domains
|
| 133 |
+
public_base = os.environ.get("DIFFLENS_PUBLIC_URL")
|
| 134 |
+
if not public_base:
|
| 135 |
+
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
| 136 |
+
public_base = f"{proto}://{request.host}"
|
| 137 |
+
public_base = public_base.rstrip("/")
|
| 138 |
+
|
| 139 |
+
return jsonify({
|
| 140 |
+
"result": f"{public_base}/uploads/{req_id}_result.png",
|
| 141 |
+
"heatmap": f"{public_base}/uploads/{req_id}_heatmap.png",
|
| 142 |
+
"metrics": result_data,
|
| 143 |
+
"description": description,
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@app.route('/uploads/<filename>')
|
| 148 |
+
def uploaded_file(filename):
|
| 149 |
+
return send_from_directory(UPLOAD_FOLDER, filename)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
port = int(os.environ.get("PORT", 5050))
|
| 154 |
+
app.run(debug=True, host="0.0.0.0", port=port)
|
app.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import tempfile
|
| 4 |
+
import cv2
|
| 5 |
+
import dotenv
|
| 6 |
+
|
| 7 |
+
# Load environment variables (e.g. GROQ_API_KEY)
|
| 8 |
+
dotenv.load_dotenv()
|
| 9 |
+
|
| 10 |
+
# Add app.py directory and backend/ directory to system path so imports work in all layouts
|
| 11 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 12 |
+
sys.path.append(current_dir)
|
| 13 |
+
sys.path.append(os.path.join(current_dir, "backend"))
|
| 14 |
+
|
| 15 |
+
from diff_ai import compare_images_ai
|
| 16 |
+
from diff import compare_images
|
| 17 |
+
from describe import describe_change
|
| 18 |
+
|
| 19 |
+
import gradio as gr
|
| 20 |
+
|
| 21 |
+
def gradio_compare(img1, img2):
|
| 22 |
+
if img1 is None or img2 is None:
|
| 23 |
+
return None, None, "Please upload both images.", {}
|
| 24 |
+
|
| 25 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 26 |
+
path1 = os.path.join(tmpdir, "img1.jpg")
|
| 27 |
+
path2 = os.path.join(tmpdir, "img2.jpg")
|
| 28 |
+
output_path = os.path.join(tmpdir, "result.png")
|
| 29 |
+
heatmap_path = os.path.join(tmpdir, "heatmap.png")
|
| 30 |
+
|
| 31 |
+
# Convert RGB inputs (Gradio) to BGR (OpenCV)
|
| 32 |
+
img1_bgr = cv2.cvtColor(img1, cv2.COLOR_RGB2BGR)
|
| 33 |
+
img2_bgr = cv2.cvtColor(img2, cv2.COLOR_RGB2BGR)
|
| 34 |
+
|
| 35 |
+
# Save images
|
| 36 |
+
cv2.imwrite(path1, img1_bgr)
|
| 37 |
+
cv2.imwrite(path2, img2_bgr)
|
| 38 |
+
|
| 39 |
+
# Run comparison pipeline
|
| 40 |
+
pipeline_used = "AI Pipeline"
|
| 41 |
+
try:
|
| 42 |
+
result_data = compare_images_ai(path1, path2, output_path, heatmap_path)
|
| 43 |
+
except Exception as e:
|
| 44 |
+
print(f"[Gradio] AI pipeline failed ({e}); falling back to classical")
|
| 45 |
+
result_data = compare_images(path1, path2, output_path, heatmap_path)
|
| 46 |
+
result_data["pipeline"] = "classical"
|
| 47 |
+
pipeline_used = "Classical Pipeline"
|
| 48 |
+
|
| 49 |
+
# Generate description
|
| 50 |
+
try:
|
| 51 |
+
description = describe_change(path1, path2, result_data)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
description = f"Error generating description: {e}"
|
| 54 |
+
|
| 55 |
+
# Load output images (convert BGR back to RGB for Gradio display)
|
| 56 |
+
result_img = None
|
| 57 |
+
heatmap_img = None
|
| 58 |
+
|
| 59 |
+
if os.path.exists(output_path):
|
| 60 |
+
result_img = cv2.cvtColor(cv2.imread(output_path), cv2.COLOR_BGR2RGB)
|
| 61 |
+
if os.path.exists(heatmap_path):
|
| 62 |
+
heatmap_img = cv2.cvtColor(cv2.imread(heatmap_path), cv2.COLOR_BGR2RGB)
|
| 63 |
+
|
| 64 |
+
# Format metrics dictionary
|
| 65 |
+
metrics = {
|
| 66 |
+
"Pipeline Used": pipeline_used,
|
| 67 |
+
"SSIM Score (Similarity)": f"{result_data.get('ssim_score', 0):.4f}" if isinstance(result_data.get('ssim_score'), (int, float)) else "N/A",
|
| 68 |
+
"LPIPS Distance (Perceptual)": f"{result_data.get('lpips_max', 0):.4f}" if isinstance(result_data.get('lpips_max'), (int, float)) else "N/A",
|
| 69 |
+
"Detected Object Changes": result_data.get("object_changes", "0"),
|
| 70 |
+
"Detected Crack Count": result_data.get("crack_count", "0"),
|
| 71 |
+
"Change Severity": str(result_data.get("severity", "none")).upper(),
|
| 72 |
+
"Alignment Failed": "Yes" if result_data.get("alignment_failed") else "No"
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
return result_img, heatmap_img, description, metrics
|
| 76 |
+
|
| 77 |
+
# UI Theme and Layout Design
|
| 78 |
+
theme = gr.themes.Soft(
|
| 79 |
+
primary_hue="indigo",
|
| 80 |
+
secondary_hue="slate",
|
| 81 |
+
neutral_hue="slate"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
css_styling = """
|
| 85 |
+
.container {
|
| 86 |
+
max-width: 1200px;
|
| 87 |
+
margin: 0 auto;
|
| 88 |
+
}
|
| 89 |
+
.header {
|
| 90 |
+
text-align: center;
|
| 91 |
+
margin-bottom: 2rem;
|
| 92 |
+
}
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
with gr.Blocks() as demo:
|
| 96 |
+
gr.Markdown(
|
| 97 |
+
"""
|
| 98 |
+
# 🔍 DiffLens AI — Visual Comparison & Change Analysis
|
| 99 |
+
Compare **Before** and **After** images of structures, scenes, or objects. The system aligns the images, detects change zones, runs segmentation (SAM2) and semantic filtering (DINOv2), and writes a detailed natural language explanation of changes.
|
| 100 |
+
""",
|
| 101 |
+
elem_classes="header"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
with gr.Row():
|
| 105 |
+
with gr.Column(scale=1):
|
| 106 |
+
gr.Markdown("### Input Images")
|
| 107 |
+
img1_input = gr.Image(label="Before Image", type="numpy")
|
| 108 |
+
img2_input = gr.Image(label="After Image", type="numpy")
|
| 109 |
+
submit_btn = gr.Button("Compare & Analyze", variant="primary", size="lg")
|
| 110 |
+
|
| 111 |
+
with gr.Column(scale=1):
|
| 112 |
+
gr.Markdown("### Results")
|
| 113 |
+
with gr.Tabs():
|
| 114 |
+
with gr.TabItem("Visual Change Detection"):
|
| 115 |
+
result_img_output = gr.Image(label="Change Boundaries (Bbox & Segments)")
|
| 116 |
+
heatmap_img_output = gr.Image(label="Difference Heatmap")
|
| 117 |
+
with gr.TabItem("AI Interpretation & Metrics"):
|
| 118 |
+
desc_output = gr.Textbox(label="AI Explanation of What Changed", lines=6)
|
| 119 |
+
metrics_output = gr.JSON(label="Quantitative Comparison Metrics")
|
| 120 |
+
|
| 121 |
+
submit_btn.click(
|
| 122 |
+
fn=gradio_compare,
|
| 123 |
+
inputs=[img1_input, img2_input],
|
| 124 |
+
outputs=[result_img_output, heatmap_img_output, desc_output, metrics_output]
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
demo.launch(theme=theme, css=css_styling)
|
auth.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simple username + password authentication for DiffLens.
|
| 3 |
+
|
| 4 |
+
User credentials are stored in users.json next to this file:
|
| 5 |
+
{"users": {"alice": "$2b$12$..."}}
|
| 6 |
+
|
| 7 |
+
The hash is bcrypt. Sessions use Flask's signed cookie (SECRET_KEY must be
|
| 8 |
+
set in the environment for production).
|
| 9 |
+
"""
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import secrets
|
| 13 |
+
from functools import wraps
|
| 14 |
+
|
| 15 |
+
import bcrypt
|
| 16 |
+
from flask import session, jsonify, request
|
| 17 |
+
|
| 18 |
+
USERS_FILE = os.path.join(os.path.dirname(__file__), "users.json")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _load_users():
|
| 22 |
+
if not os.path.exists(USERS_FILE):
|
| 23 |
+
return {}
|
| 24 |
+
with open(USERS_FILE, "r") as f:
|
| 25 |
+
data = json.load(f) or {}
|
| 26 |
+
return data.get("users", {})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _save_users(users):
|
| 30 |
+
with open(USERS_FILE, "w") as f:
|
| 31 |
+
json.dump({"users": users}, f, indent=2)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def hash_password(plain):
|
| 35 |
+
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt(12)).decode("ascii")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def verify_password(plain, hashed):
|
| 39 |
+
try:
|
| 40 |
+
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
| 41 |
+
except Exception:
|
| 42 |
+
return False
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def create_user(username, password):
|
| 46 |
+
users = _load_users()
|
| 47 |
+
users[username] = hash_password(password)
|
| 48 |
+
_save_users(users)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def authenticate(username, password):
|
| 52 |
+
users = _load_users()
|
| 53 |
+
h = users.get(username)
|
| 54 |
+
if not h:
|
| 55 |
+
return False
|
| 56 |
+
return verify_password(password, h)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def get_secret_key():
|
| 60 |
+
"""Returns the Flask SECRET_KEY. Reads from env, falls back to a file
|
| 61 |
+
that's auto-generated on first run so the key persists across restarts."""
|
| 62 |
+
key = os.environ.get("DIFFLENS_SECRET_KEY")
|
| 63 |
+
if key:
|
| 64 |
+
return key
|
| 65 |
+
path = os.path.join(os.path.dirname(__file__), ".secret_key")
|
| 66 |
+
if os.path.exists(path):
|
| 67 |
+
with open(path, "r") as f:
|
| 68 |
+
return f.read().strip()
|
| 69 |
+
key = secrets.token_urlsafe(48)
|
| 70 |
+
with open(path, "w") as f:
|
| 71 |
+
f.write(key)
|
| 72 |
+
os.chmod(path, 0o600)
|
| 73 |
+
return key
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def login_required(view):
|
| 77 |
+
"""Decorator that returns 401 for unauthenticated requests."""
|
| 78 |
+
@wraps(view)
|
| 79 |
+
def wrapper(*args, **kwargs):
|
| 80 |
+
if not session.get("user"):
|
| 81 |
+
return jsonify({"error": "unauthorized"}), 401
|
| 82 |
+
return view(*args, **kwargs)
|
| 83 |
+
return wrapper
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def current_user():
|
| 87 |
+
return session.get("user")
|
create_user.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CLI: create or update a DiffLens user.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python3 create_user.py <username> <password>
|
| 6 |
+
"""
|
| 7 |
+
import sys
|
| 8 |
+
from auth import create_user
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
if len(sys.argv) != 3:
|
| 12 |
+
print("Usage: python3 create_user.py <username> <password>")
|
| 13 |
+
sys.exit(1)
|
| 14 |
+
username, password = sys.argv[1], sys.argv[2]
|
| 15 |
+
create_user(username, password)
|
| 16 |
+
print(f"User '{username}' created/updated.")
|
describe.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VLM-based change description via Ollama (Llama 3.2 Vision).
|
| 3 |
+
|
| 4 |
+
Llama 3.2 Vision (mllama) only accepts ONE image per request, so we
|
| 5 |
+
concatenate the before and after images into a single side-by-side
|
| 6 |
+
composite with labels and send that.
|
| 7 |
+
|
| 8 |
+
Requires:
|
| 9 |
+
- Ollama running locally on port 11434
|
| 10 |
+
- llama3.2-vision model pulled (`ollama pull llama3.2-vision`)
|
| 11 |
+
"""
|
| 12 |
+
import base64
|
| 13 |
+
import io
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import urllib.request
|
| 17 |
+
import urllib.error
|
| 18 |
+
|
| 19 |
+
import cv2
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
OLLAMA_URL = "http://localhost:11434/api/generate"
|
| 23 |
+
OLLAMA_MODEL = "llama3.2-vision:latest"
|
| 24 |
+
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 25 |
+
GROQ_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
|
| 26 |
+
TIMEOUT_SECONDS = 180
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _make_side_by_side(img1_path, img2_path, max_height=384):
|
| 30 |
+
"""Build a single composite image with BEFORE | AFTER labels.
|
| 31 |
+
Returns base64-encoded PNG, or None on failure."""
|
| 32 |
+
a = cv2.imread(img1_path)
|
| 33 |
+
b = cv2.imread(img2_path)
|
| 34 |
+
if a is None or b is None:
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
# Normalize heights
|
| 38 |
+
def resize_to_height(img, h):
|
| 39 |
+
scale = h / img.shape[0]
|
| 40 |
+
return cv2.resize(img, (int(round(img.shape[1] * scale)), h),
|
| 41 |
+
interpolation=cv2.INTER_AREA)
|
| 42 |
+
|
| 43 |
+
a = resize_to_height(a, max_height)
|
| 44 |
+
b = resize_to_height(b, max_height)
|
| 45 |
+
|
| 46 |
+
# Header strip with labels
|
| 47 |
+
header_h = 50
|
| 48 |
+
total_w = a.shape[1] + b.shape[1] + 20 # 20px gap between
|
| 49 |
+
header = np.full((header_h, total_w, 3), 30, dtype=np.uint8)
|
| 50 |
+
cv2.putText(header, "BEFORE", (a.shape[1] // 2 - 70, 35),
|
| 51 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 2)
|
| 52 |
+
cv2.putText(header, "AFTER", (a.shape[1] + 20 + b.shape[1] // 2 - 60, 35),
|
| 53 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 2)
|
| 54 |
+
|
| 55 |
+
# Glue panels: a, gap, b
|
| 56 |
+
gap = np.full((max_height, 20, 3), 30, dtype=np.uint8)
|
| 57 |
+
body = np.hstack([a, gap, b])
|
| 58 |
+
composite = np.vstack([header, body])
|
| 59 |
+
|
| 60 |
+
ok, buf = cv2.imencode(".png", composite)
|
| 61 |
+
if not ok:
|
| 62 |
+
return None
|
| 63 |
+
return base64.b64encode(buf.tobytes()).decode("ascii")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def describe_change(img1_path, img2_path, metrics):
|
| 67 |
+
"""Returns a plain-English description string, or None on failure.
|
| 68 |
+
Uses Groq API if GROQ_API_KEY is in env, else falls back to local Ollama."""
|
| 69 |
+
groq_api_key = os.environ.get("GROQ_API_KEY")
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
composite_b64 = _make_side_by_side(img1_path, img2_path)
|
| 73 |
+
if composite_b64 is None:
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
prompt_lines = [
|
| 77 |
+
"This single image contains TWO photos placed side by side.",
|
| 78 |
+
"The LEFT photo is labeled BEFORE. The RIGHT photo is labeled AFTER.",
|
| 79 |
+
"Describe ONLY what physically changed from BEFORE to AFTER in 1-3 short sentences.",
|
| 80 |
+
"Be specific about objects (color, shape, position).",
|
| 81 |
+
"If nothing meaningful changed, say 'No visible changes.'",
|
| 82 |
+
"Do not list things that are the same in both photos.",
|
| 83 |
+
]
|
| 84 |
+
if metrics:
|
| 85 |
+
added = metrics.get("added")
|
| 86 |
+
removed = metrics.get("removed")
|
| 87 |
+
if added is not None or removed is not None:
|
| 88 |
+
prompt_lines.append(
|
| 89 |
+
f"\nDetector hint: added={added}, removed={removed}."
|
| 90 |
+
)
|
| 91 |
+
prompt = "\n".join(prompt_lines)
|
| 92 |
+
|
| 93 |
+
if groq_api_key:
|
| 94 |
+
# --- Use Groq API ---
|
| 95 |
+
payload = {
|
| 96 |
+
"model": GROQ_MODEL,
|
| 97 |
+
"messages": [
|
| 98 |
+
{
|
| 99 |
+
"role": "user",
|
| 100 |
+
"content": [
|
| 101 |
+
{"type": "text", "text": prompt},
|
| 102 |
+
{
|
| 103 |
+
"type": "image_url",
|
| 104 |
+
"image_url": {
|
| 105 |
+
"url": f"data:image/png;base64,{composite_b64}"
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
]
|
| 109 |
+
}
|
| 110 |
+
],
|
| 111 |
+
"temperature": 0.2,
|
| 112 |
+
"max_tokens": 120
|
| 113 |
+
}
|
| 114 |
+
req = urllib.request.Request(
|
| 115 |
+
GROQ_URL,
|
| 116 |
+
data=json.dumps(payload).encode("utf-8"),
|
| 117 |
+
headers={
|
| 118 |
+
"Content-Type": "application/json",
|
| 119 |
+
"Authorization": f"Bearer {groq_api_key}",
|
| 120 |
+
"User-Agent": "Mozilla/5.0"
|
| 121 |
+
},
|
| 122 |
+
)
|
| 123 |
+
with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as resp:
|
| 124 |
+
body = json.loads(resp.read().decode("utf-8"))
|
| 125 |
+
text = (body["choices"][0]["message"]["content"] or "").strip()
|
| 126 |
+
print("[describe] generated description using Groq API")
|
| 127 |
+
return text or None
|
| 128 |
+
else:
|
| 129 |
+
# --- Fallback to local Ollama ---
|
| 130 |
+
payload = {
|
| 131 |
+
"model": OLLAMA_MODEL,
|
| 132 |
+
"prompt": prompt,
|
| 133 |
+
"images": [composite_b64],
|
| 134 |
+
"stream": False,
|
| 135 |
+
"options": {
|
| 136 |
+
"temperature": 0.2,
|
| 137 |
+
"num_predict": 120,
|
| 138 |
+
"num_ctx": 2048,
|
| 139 |
+
},
|
| 140 |
+
"keep_alive": "10m",
|
| 141 |
+
}
|
| 142 |
+
req = urllib.request.Request(
|
| 143 |
+
OLLAMA_URL,
|
| 144 |
+
data=json.dumps(payload).encode("utf-8"),
|
| 145 |
+
headers={"Content-Type": "application/json"},
|
| 146 |
+
)
|
| 147 |
+
with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as resp:
|
| 148 |
+
body = json.loads(resp.read().decode("utf-8"))
|
| 149 |
+
text = (body.get("response") or "").strip()
|
| 150 |
+
print("[describe] generated description using local Ollama")
|
| 151 |
+
return text or None
|
| 152 |
+
|
| 153 |
+
except urllib.error.HTTPError as e:
|
| 154 |
+
try:
|
| 155 |
+
err_body = e.read().decode("utf-8")
|
| 156 |
+
print(f"[describe] Groq API HTTPError {e.code}: {err_body}")
|
| 157 |
+
except Exception:
|
| 158 |
+
print(f"[describe] Groq API HTTPError {e}: (could not read body)")
|
| 159 |
+
return None
|
| 160 |
+
except urllib.error.URLError as e:
|
| 161 |
+
if groq_api_key:
|
| 162 |
+
print(f"[describe] Groq API error/unreachable: {e}")
|
| 163 |
+
else:
|
| 164 |
+
print(f"[describe] Ollama unreachable: {e}")
|
| 165 |
+
return None
|
| 166 |
+
except Exception as e:
|
| 167 |
+
print(f"[describe] error: {e}")
|
| 168 |
+
return None
|
diff.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
from skimage.metrics import structural_similarity as ssim
|
| 4 |
+
|
| 5 |
+
# -----------------------------
|
| 6 |
+
# LPIPS — perceptual similarity (lazy-loaded so first request pays the cost)
|
| 7 |
+
# -----------------------------
|
| 8 |
+
_lpips_model = None
|
| 9 |
+
_lpips_torch = None
|
| 10 |
+
|
| 11 |
+
def _get_lpips():
|
| 12 |
+
"""Lazy-load LPIPS + torch on first use. Returns (model, torch) or (None, None)
|
| 13 |
+
if the dependencies aren't installed — pipeline still works without it."""
|
| 14 |
+
global _lpips_model, _lpips_torch
|
| 15 |
+
if _lpips_model is not None:
|
| 16 |
+
return _lpips_model, _lpips_torch
|
| 17 |
+
try:
|
| 18 |
+
import torch
|
| 19 |
+
import lpips
|
| 20 |
+
_lpips_torch = torch
|
| 21 |
+
# 'alex' is the fastest backbone; 'vgg' is more accurate but slower.
|
| 22 |
+
_lpips_model = lpips.LPIPS(net='alex', verbose=False).eval()
|
| 23 |
+
except Exception:
|
| 24 |
+
_lpips_model = False # sentinel: tried and failed
|
| 25 |
+
_lpips_torch = None
|
| 26 |
+
return _lpips_model, _lpips_torch
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def lpips_distance(patch1_bgr, patch2_bgr):
|
| 30 |
+
"""Returns LPIPS perceptual distance in [0, ~1]. Higher = more different.
|
| 31 |
+
Returns None if LPIPS isn't available."""
|
| 32 |
+
model, torch = _get_lpips()
|
| 33 |
+
if not model:
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
def to_tensor(bgr):
|
| 37 |
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 38 |
+
# LPIPS expects 64x64+ float in [-1, 1], shape (1,3,H,W)
|
| 39 |
+
rgb = cv2.resize(rgb, (128, 128))
|
| 40 |
+
t = torch.from_numpy(rgb).float().permute(2, 0, 1) / 127.5 - 1.0
|
| 41 |
+
return t.unsqueeze(0)
|
| 42 |
+
|
| 43 |
+
with torch.no_grad():
|
| 44 |
+
d = model(to_tensor(patch1_bgr), to_tensor(patch2_bgr))
|
| 45 |
+
return float(d.item())
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# -----------------------------
|
| 49 |
+
# ALIGNMENT (ORB homography + ECC sub-pixel refinement)
|
| 50 |
+
# -----------------------------
|
| 51 |
+
def align_images(img1, img2):
|
| 52 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
|
| 53 |
+
gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
|
| 54 |
+
|
| 55 |
+
orb = cv2.ORB_create(3000)
|
| 56 |
+
kp1, des1 = orb.detectAndCompute(gray1, None)
|
| 57 |
+
kp2, des2 = orb.detectAndCompute(gray2, None)
|
| 58 |
+
|
| 59 |
+
aligned = img2 # fallback
|
| 60 |
+
|
| 61 |
+
if des1 is not None and des2 is not None:
|
| 62 |
+
matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
|
| 63 |
+
matches = matcher.match(des1, des2)
|
| 64 |
+
|
| 65 |
+
if len(matches) >= 10:
|
| 66 |
+
matches = sorted(matches, key=lambda x: x.distance)[:50]
|
| 67 |
+
pts1 = np.float32([kp1[m.queryIdx].pt for m in matches])
|
| 68 |
+
pts2 = np.float32([kp2[m.trainIdx].pt for m in matches])
|
| 69 |
+
matrix, _ = cv2.findHomography(pts2, pts1, cv2.RANSAC)
|
| 70 |
+
|
| 71 |
+
if matrix is not None:
|
| 72 |
+
aligned = cv2.warpPerspective(
|
| 73 |
+
img2, matrix, (img1.shape[1], img1.shape[0])
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# ---- ECC refinement, self-validating ----
|
| 77 |
+
# Only keep the ECC result if it actually IMPROVED alignment
|
| 78 |
+
# (measured by SSIM of the aligned images). Otherwise discard.
|
| 79 |
+
try:
|
| 80 |
+
g1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
|
| 81 |
+
g2_before = cv2.cvtColor(aligned, cv2.COLOR_BGR2GRAY)
|
| 82 |
+
before_score, _ = ssim(g1, g2_before, full=True)
|
| 83 |
+
|
| 84 |
+
warp = np.eye(3, 3, dtype=np.float32)
|
| 85 |
+
criteria = (
|
| 86 |
+
cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,
|
| 87 |
+
100,
|
| 88 |
+
1e-5,
|
| 89 |
+
)
|
| 90 |
+
_, warp = cv2.findTransformECC(
|
| 91 |
+
g1, g2_before, warp, cv2.MOTION_HOMOGRAPHY, criteria, None, 5
|
| 92 |
+
)
|
| 93 |
+
candidate = cv2.warpPerspective(
|
| 94 |
+
aligned,
|
| 95 |
+
warp,
|
| 96 |
+
(img1.shape[1], img1.shape[0]),
|
| 97 |
+
flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP,
|
| 98 |
+
)
|
| 99 |
+
g2_after = cv2.cvtColor(candidate, cv2.COLOR_BGR2GRAY)
|
| 100 |
+
after_score, _ = ssim(g1, g2_after, full=True)
|
| 101 |
+
|
| 102 |
+
if after_score > before_score:
|
| 103 |
+
aligned = candidate
|
| 104 |
+
except cv2.error:
|
| 105 |
+
# ECC can fail to converge — keep ORB-only result in that case
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
return aligned
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# -----------------------------
|
| 112 |
+
# SSIM-BASED STRUCTURAL DIFF
|
| 113 |
+
# -----------------------------
|
| 114 |
+
def ssim_diff(gray1, gray2):
|
| 115 |
+
"""Returns a uint8 'difference' map where bright = structurally different."""
|
| 116 |
+
score, diff_map = ssim(gray1, gray2, full=True)
|
| 117 |
+
# ssim returns [-1, 1] where 1 == identical; invert + scale to 0..255
|
| 118 |
+
diff_map = (1.0 - diff_map) * 255.0
|
| 119 |
+
return score, diff_map.astype(np.uint8)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# -----------------------------
|
| 123 |
+
# CRACK DETECTION (IMPROVED)
|
| 124 |
+
# -----------------------------
|
| 125 |
+
def detect_cracks(gray_img):
|
| 126 |
+
enhanced = cv2.equalizeHist(gray_img)
|
| 127 |
+
|
| 128 |
+
edges = cv2.Canny(enhanced, 40, 120)
|
| 129 |
+
|
| 130 |
+
kernel = np.ones((3,3), np.uint8)
|
| 131 |
+
edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations=1)
|
| 132 |
+
|
| 133 |
+
return edges
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# -----------------------------
|
| 137 |
+
# MAIN FUNCTION
|
| 138 |
+
# -----------------------------
|
| 139 |
+
def compare_images(img1_path, img2_path, output_path, heatmap_path):
|
| 140 |
+
|
| 141 |
+
img1 = cv2.imread(img1_path)
|
| 142 |
+
img2 = cv2.imread(img2_path)
|
| 143 |
+
|
| 144 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 145 |
+
|
| 146 |
+
# 🔥 ALIGN
|
| 147 |
+
aligned_img2 = align_images(img1, img2)
|
| 148 |
+
|
| 149 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
|
| 150 |
+
gray2 = cv2.cvtColor(aligned_img2, cv2.COLOR_BGR2GRAY)
|
| 151 |
+
|
| 152 |
+
# Moderate blur — enough to absorb pixel noise / alignment jitter, but
|
| 153 |
+
# small enough that handwritten letters and other tiny changes survive.
|
| 154 |
+
blur1 = cv2.GaussianBlur(gray1, (5, 5), 0)
|
| 155 |
+
blur2 = cv2.GaussianBlur(gray2, (5, 5), 0)
|
| 156 |
+
|
| 157 |
+
# -----------------------------
|
| 158 |
+
# DIFF — SSIM with a large window is the workhorse here.
|
| 159 |
+
# A big win=21 window means a single new pixel doesn't trigger a hit;
|
| 160 |
+
# only regions where the *local structure* genuinely changed do.
|
| 161 |
+
# -----------------------------
|
| 162 |
+
ssim_score, struct_diff = ssim_diff(blur1, blur2)
|
| 163 |
+
|
| 164 |
+
diff = struct_diff
|
| 165 |
+
heatmap = cv2.applyColorMap(diff, cv2.COLORMAP_JET)
|
| 166 |
+
|
| 167 |
+
# -----------------------------
|
| 168 |
+
# OBJECT DETECTION — shape-aware filtering
|
| 169 |
+
# -----------------------------
|
| 170 |
+
_, thresh = cv2.threshold(diff, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 171 |
+
|
| 172 |
+
# OPEN first: erodes thin alignment-edge ribbons away entirely.
|
| 173 |
+
# Then CLOSE: reconnects fragments of the real new object.
|
| 174 |
+
# Smaller OPEN kernel preserves small changes (handwritten letters, marks).
|
| 175 |
+
# Median blur first — single best speckle killer for textured surfaces
|
| 176 |
+
# (wood grain, carpet, asphalt) where SSIM picks up tiny texture jitter.
|
| 177 |
+
thresh = cv2.medianBlur(thresh, 7)
|
| 178 |
+
|
| 179 |
+
open_k = np.ones((7, 7), np.uint8)
|
| 180 |
+
close_k = np.ones((15, 15), np.uint8)
|
| 181 |
+
thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, open_k, iterations=2)
|
| 182 |
+
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_k, iterations=2)
|
| 183 |
+
|
| 184 |
+
result = aligned_img2.copy()
|
| 185 |
+
img_area = gray1.shape[0] * gray1.shape[1]
|
| 186 |
+
|
| 187 |
+
# ---- Global alignment-failure guard ----
|
| 188 |
+
# If the diff covers a huge portion of the image, alignment is broken
|
| 189 |
+
# (camera moved/rotated too much between shots). Bail out cleanly
|
| 190 |
+
# instead of drawing one giant useless box.
|
| 191 |
+
diff_ratio = cv2.countNonZero(thresh) / img_area
|
| 192 |
+
alignment_failed = diff_ratio > 0.70
|
| 193 |
+
|
| 194 |
+
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 195 |
+
if alignment_failed:
|
| 196 |
+
contours = [] # skip detection entirely
|
| 197 |
+
# Allow tiny changes (single letters, marks). LPIPS verification below
|
| 198 |
+
# is what keeps false positives out at this size.
|
| 199 |
+
min_area = max(400, img_area * 0.0002) # ~0.02% of image
|
| 200 |
+
|
| 201 |
+
# Score each contour and only keep "blob-shaped" ones (real objects),
|
| 202 |
+
# not "ribbon-shaped" ones (edge jitter).
|
| 203 |
+
candidates = []
|
| 204 |
+
for cnt in contours:
|
| 205 |
+
area = cv2.contourArea(cnt)
|
| 206 |
+
if area < min_area:
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
x, y, w, h = cv2.boundingRect(cnt)
|
| 210 |
+
|
| 211 |
+
# Solidity = contour area / convex hull area. Real objects ≈ 0.6+.
|
| 212 |
+
# Thin ribbons along edges have very low solidity.
|
| 213 |
+
hull_area = cv2.contourArea(cv2.convexHull(cnt))
|
| 214 |
+
solidity = area / hull_area if hull_area > 0 else 0
|
| 215 |
+
|
| 216 |
+
# Extent = contour area / bounding-box area. Slivers ≈ low extent.
|
| 217 |
+
extent = area / (w * h) if w * h > 0 else 0
|
| 218 |
+
|
| 219 |
+
# Aspect ratio sanity: skip extreme slivers
|
| 220 |
+
aspect = max(w, h) / max(1, min(w, h))
|
| 221 |
+
|
| 222 |
+
# LARGE-BLOB BYPASS: any region covering >2% of the image is
|
| 223 |
+
# almost certainly a real change (added/removed object). Trust it
|
| 224 |
+
# unconditionally — don't apply shape filters that could reject
|
| 225 |
+
# irregular shapes (e.g. paper partially occluded by a plant).
|
| 226 |
+
is_large = area > img_area * 0.02
|
| 227 |
+
|
| 228 |
+
# Relaxed thresholds — small handwritten marks can be irregular.
|
| 229 |
+
# LPIPS does the final perceptual gate.
|
| 230 |
+
if not is_large and (solidity < 0.30 or extent < 0.18 or aspect > 12):
|
| 231 |
+
continue
|
| 232 |
+
|
| 233 |
+
candidates.append((area, x, y, w, h))
|
| 234 |
+
|
| 235 |
+
# Keep only the top few biggest blobs — the "real" changes.
|
| 236 |
+
candidates.sort(reverse=True)
|
| 237 |
+
candidates = candidates[:15] # raise cap; LPIPS will prune below
|
| 238 |
+
|
| 239 |
+
# -----------------------------
|
| 240 |
+
# LPIPS PERCEPTUAL VERIFICATION
|
| 241 |
+
# For each surviving candidate region, check that the patch in img2 is
|
| 242 |
+
# *perceptually* different from the same region in img1. This kills the
|
| 243 |
+
# last layer of false positives (lighting shifts, residual jitter on
|
| 244 |
+
# textured surfaces) that survived the SSIM + shape filter.
|
| 245 |
+
# -----------------------------
|
| 246 |
+
LPIPS_THRESHOLD = 0.15 # tuned: <0.10 = ~identical, >0.20 = clearly different
|
| 247 |
+
verified = []
|
| 248 |
+
lpips_scores = []
|
| 249 |
+
|
| 250 |
+
for area, x, y, w, h in candidates:
|
| 251 |
+
# Pad the crop slightly so LPIPS sees context, not just the object edge
|
| 252 |
+
pad = 10
|
| 253 |
+
x0 = max(0, x - pad)
|
| 254 |
+
y0 = max(0, y - pad)
|
| 255 |
+
x1 = min(img1.shape[1], x + w + pad)
|
| 256 |
+
y1 = min(img1.shape[0], y + h + pad)
|
| 257 |
+
|
| 258 |
+
patch1 = img1[y0:y1, x0:x1]
|
| 259 |
+
patch2 = aligned_img2[y0:y1, x0:x1]
|
| 260 |
+
if patch1.size == 0 or patch2.size == 0:
|
| 261 |
+
continue
|
| 262 |
+
|
| 263 |
+
d = lpips_distance(patch1, patch2)
|
| 264 |
+
|
| 265 |
+
# If LPIPS is unavailable (None), trust the upstream pipeline and keep.
|
| 266 |
+
# If available, only keep candidates that are perceptually different.
|
| 267 |
+
if d is None or d >= LPIPS_THRESHOLD:
|
| 268 |
+
verified.append((area, x, y, w, h))
|
| 269 |
+
if d is not None:
|
| 270 |
+
lpips_scores.append(d)
|
| 271 |
+
|
| 272 |
+
object_changes = len(verified)
|
| 273 |
+
|
| 274 |
+
for _, x, y, w, h in verified:
|
| 275 |
+
cv2.rectangle(result, (x, y), (x + w, y + h), (0, 0, 255), 3)
|
| 276 |
+
|
| 277 |
+
# -----------------------------
|
| 278 |
+
# CRACK DETECTION (ONLY IN CHANGED AREA)
|
| 279 |
+
# -----------------------------
|
| 280 |
+
cracks = detect_cracks(gray2)
|
| 281 |
+
|
| 282 |
+
# 🔥 FIX 4 APPLIED
|
| 283 |
+
cracks = cv2.bitwise_and(cracks, cracks, mask=thresh)
|
| 284 |
+
|
| 285 |
+
crack_contours, _ = cv2.findContours(cracks, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 286 |
+
|
| 287 |
+
crack_count = 0
|
| 288 |
+
total_crack_area = 0
|
| 289 |
+
max_crack_area = 0
|
| 290 |
+
|
| 291 |
+
# Only run crack analysis if NO new objects were detected. When a real
|
| 292 |
+
# object appears, every edge of it would otherwise be flagged as a "crack".
|
| 293 |
+
if object_changes == 0:
|
| 294 |
+
for cnt in crack_contours:
|
| 295 |
+
area = cv2.contourArea(cnt)
|
| 296 |
+
|
| 297 |
+
if 80 < area < 1500:
|
| 298 |
+
x, y, w, h = cv2.boundingRect(cnt)
|
| 299 |
+
|
| 300 |
+
if w < 60 and h < 60:
|
| 301 |
+
crack_count += 1
|
| 302 |
+
total_crack_area += area
|
| 303 |
+
max_crack_area = max(max_crack_area, area)
|
| 304 |
+
|
| 305 |
+
cv2.rectangle(result, (x, y), (x+w, y+h), (255,0,0), 2)
|
| 306 |
+
|
| 307 |
+
# -----------------------------
|
| 308 |
+
# SEVERITY SCORING
|
| 309 |
+
# >=1 real object change → HIGH. Otherwise → NONE.
|
| 310 |
+
# -----------------------------
|
| 311 |
+
if object_changes >= 1 or crack_count >= 1:
|
| 312 |
+
severity = "HIGH"
|
| 313 |
+
else:
|
| 314 |
+
severity = "NONE"
|
| 315 |
+
|
| 316 |
+
# -----------------------------
|
| 317 |
+
# DRAW LABEL
|
| 318 |
+
# -----------------------------
|
| 319 |
+
color = (0,255,0)
|
| 320 |
+
|
| 321 |
+
if severity == "MEDIUM":
|
| 322 |
+
color = (0,255,255)
|
| 323 |
+
elif severity == "HIGH":
|
| 324 |
+
color = (0,0,255)
|
| 325 |
+
|
| 326 |
+
label = f"Severity: {severity}"
|
| 327 |
+
if alignment_failed:
|
| 328 |
+
label = "ALIGNMENT FAILED — retake from same angle"
|
| 329 |
+
color = (0, 165, 255)
|
| 330 |
+
cv2.putText(result, label, (20, 40),
|
| 331 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1, color, 3)
|
| 332 |
+
|
| 333 |
+
# Save outputs
|
| 334 |
+
cv2.imwrite(output_path, result)
|
| 335 |
+
cv2.imwrite(heatmap_path, heatmap)
|
| 336 |
+
|
| 337 |
+
return {
|
| 338 |
+
"crack_count": crack_count,
|
| 339 |
+
"total_crack_area": int(total_crack_area),
|
| 340 |
+
"max_crack_area": int(max_crack_area),
|
| 341 |
+
"object_changes": object_changes,
|
| 342 |
+
"ssim_score": float(ssim_score),
|
| 343 |
+
"lpips_max": float(max(lpips_scores)) if lpips_scores else None,
|
| 344 |
+
"lpips_available": lpips_scores != [] or _get_lpips()[0] is not False,
|
| 345 |
+
"alignment_failed": bool(alignment_failed),
|
| 346 |
+
"severity": severity
|
| 347 |
+
}
|
diff_ai copy.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI-first image comparison pipeline (change-region-driven).
|
| 3 |
+
|
| 4 |
+
Strategy (different from naive segment-everything-then-match):
|
| 5 |
+
1. LoFTR (kornia) aligns img2 onto img1
|
| 6 |
+
2. SSIM produces a binary "change mask" — where pixels actually differ
|
| 7 |
+
3. Connected components on the change mask give us discrete change regions
|
| 8 |
+
4. For each region:
|
| 9 |
+
a. Crop a context patch from both images
|
| 10 |
+
b. DINOv2 verifies perceptual difference (kills shadow/lighting noise)
|
| 11 |
+
c. SAM2 in PROMPT mode (point prompt at the region centroid) returns
|
| 12 |
+
a tight object mask in img2
|
| 13 |
+
d. Edge-density heuristic classifies as ADDED vs REMOVED
|
| 14 |
+
5. REMOVED and ADDED objects are matched against each other to detect
|
| 15 |
+
MOVED objects, using a globally-greedy bipartite match over DINOv2
|
| 16 |
+
similarity (see _match_moved_objects)
|
| 17 |
+
6. Draw color-coded bounding boxes from the SAM masks
|
| 18 |
+
|
| 19 |
+
Why this is better than the previous attempt:
|
| 20 |
+
- No SAM auto-mask-generation (10x faster — only prompted on change regions)
|
| 21 |
+
- No cross-image DINO matching (no phantom added/removed pairs)
|
| 22 |
+
- Change mask is the source of truth; SAM and DINO act as refinement layers
|
| 23 |
+
- Pipeline degrades gracefully: if SAM/DINO fail, the change-region bboxes
|
| 24 |
+
are still usable as fallback output
|
| 25 |
+
|
| 26 |
+
All models are lazy-loaded. Falls back to classical pipeline (diff.py) if any
|
| 27 |
+
required model is unavailable — see app.py for the wiring.
|
| 28 |
+
"""
|
| 29 |
+
import cv2
|
| 30 |
+
import numpy as np
|
| 31 |
+
from skimage.metrics import structural_similarity as ssim
|
| 32 |
+
|
| 33 |
+
# =====================================================================
|
| 34 |
+
# CONFIG / CONSTANTS
|
| 35 |
+
# =====================================================================
|
| 36 |
+
|
| 37 |
+
# Drawing colors (BGR)
|
| 38 |
+
COLOR_ADDED = (0, 200, 0)
|
| 39 |
+
COLOR_REMOVED = (0, 0, 220)
|
| 40 |
+
COLOR_MOVED = (255, 255, 0) # cyan in BGR (drawn on both from & to boxes)
|
| 41 |
+
|
| 42 |
+
LOFTR_LONG_EDGE = 640
|
| 43 |
+
MAX_LONG_EDGE = 960
|
| 44 |
+
|
| 45 |
+
DINO_SIM_THRESHOLD = 0.92 # same-location patches: >= this => unchanged, drop
|
| 46 |
+
MOVED_SIM_THRESHOLD = 0.75 # cross-location patches: >= this => same object, moved
|
| 47 |
+
EDGE_DENSITY_THRESHOLD = 1.0
|
| 48 |
+
|
| 49 |
+
REGION_PAD = 12 # padding when cropping a change region for verification
|
| 50 |
+
MOVE_MATCH_PAD = 12 # padding when cropping removed/added objects for move-matching
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# =====================================================================
|
| 54 |
+
# LAZY MODEL LOADERS
|
| 55 |
+
# =====================================================================
|
| 56 |
+
|
| 57 |
+
_loftr = None
|
| 58 |
+
|
| 59 |
+
def _get_loftr():
|
| 60 |
+
"""Lazy-load kornia LoFTR (transformer dense matcher)."""
|
| 61 |
+
global _loftr
|
| 62 |
+
if _loftr is not None:
|
| 63 |
+
return _loftr
|
| 64 |
+
try:
|
| 65 |
+
import torch
|
| 66 |
+
import kornia.feature as KF
|
| 67 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 68 |
+
matcher = KF.LoFTR(pretrained="outdoor").eval().to(device)
|
| 69 |
+
_loftr = {"matcher": matcher, "torch": torch, "device": device}
|
| 70 |
+
print("[diff_ai] LoFTR loaded")
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"[diff_ai] LoFTR unavailable: {e}")
|
| 73 |
+
_loftr = False
|
| 74 |
+
return _loftr
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
_sam = None
|
| 78 |
+
|
| 79 |
+
def _get_sam():
|
| 80 |
+
"""Lazy-load ultralytics SAM2 (used in prompted mode, not auto-mask)."""
|
| 81 |
+
global _sam
|
| 82 |
+
if _sam is not None:
|
| 83 |
+
return _sam
|
| 84 |
+
try:
|
| 85 |
+
from ultralytics import SAM
|
| 86 |
+
_sam = SAM("sam2_t.pt") # ~150MB, auto-downloads
|
| 87 |
+
print("[diff_ai] SAM2 loaded")
|
| 88 |
+
except Exception as e:
|
| 89 |
+
print(f"[diff_ai] SAM2 unavailable: {e}")
|
| 90 |
+
_sam = False
|
| 91 |
+
return _sam
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
_dinov2 = None
|
| 95 |
+
|
| 96 |
+
def _get_dinov2():
|
| 97 |
+
"""Lazy-load DINOv2 via HuggingFace transformers (Python 3.9 compatible)."""
|
| 98 |
+
global _dinov2
|
| 99 |
+
if _dinov2 is not None:
|
| 100 |
+
return _dinov2
|
| 101 |
+
try:
|
| 102 |
+
import torch
|
| 103 |
+
from transformers import AutoModel, AutoImageProcessor
|
| 104 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 105 |
+
processor = AutoImageProcessor.from_pretrained(
|
| 106 |
+
"facebook/dinov2-small", use_fast=True
|
| 107 |
+
)
|
| 108 |
+
model = AutoModel.from_pretrained("facebook/dinov2-small").eval().to(device)
|
| 109 |
+
_dinov2 = {
|
| 110 |
+
"model": model, "processor": processor,
|
| 111 |
+
"torch": torch, "device": device,
|
| 112 |
+
}
|
| 113 |
+
print("[diff_ai] DINOv2 loaded")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
print(f"[diff_ai] DINOv2 unavailable: {e}")
|
| 116 |
+
_dinov2 = False
|
| 117 |
+
return _dinov2
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# =====================================================================
|
| 121 |
+
# STAGE 1 — LoFTR ALIGNMENT
|
| 122 |
+
# =====================================================================
|
| 123 |
+
|
| 124 |
+
def _to_loftr_tensor(bgr, torch_mod, device):
|
| 125 |
+
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
| 126 |
+
h, w = gray.shape
|
| 127 |
+
scale = LOFTR_LONG_EDGE / max(h, w)
|
| 128 |
+
nh, nw = int(round(h * scale)), int(round(w * scale))
|
| 129 |
+
nh = max(8, nh - nh % 8)
|
| 130 |
+
nw = max(8, nw - nw % 8)
|
| 131 |
+
resized = cv2.resize(gray, (nw, nh))
|
| 132 |
+
t = torch_mod.from_numpy(resized).float()[None, None] / 255.0
|
| 133 |
+
return t.to(device), scale
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def align_loftr(img1_bgr, img2_bgr):
|
| 137 |
+
"""Warp img2 onto img1 using LoFTR matches + RANSAC homography.
|
| 138 |
+
|
| 139 |
+
Returns (aligned_bgr, valid_mask) on success, or (None, None) on
|
| 140 |
+
failure. valid_mask is a uint8 mask (255 = valid, 0 = invalid) the
|
| 141 |
+
same size as img1, marking which pixels of `aligned_bgr` actually
|
| 142 |
+
came from img2 vs. the black border warpPerspective fills in when
|
| 143 |
+
the two images aren't framed identically. Callers should restrict
|
| 144 |
+
any pixel-level comparison to valid_mask == 255, otherwise that
|
| 145 |
+
black border gets treated as a huge fake "removed" region."""
|
| 146 |
+
bundle = _get_loftr()
|
| 147 |
+
if not bundle:
|
| 148 |
+
return None, None
|
| 149 |
+
matcher = bundle["matcher"]
|
| 150 |
+
torch = bundle["torch"]
|
| 151 |
+
device = bundle["device"]
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
t1, s1 = _to_loftr_tensor(img1_bgr, torch, device)
|
| 155 |
+
t2, s2 = _to_loftr_tensor(img2_bgr, torch, device)
|
| 156 |
+
with torch.no_grad():
|
| 157 |
+
corr = matcher({"image0": t1, "image1": t2})
|
| 158 |
+
|
| 159 |
+
kp1 = corr["keypoints0"].cpu().numpy()
|
| 160 |
+
kp2 = corr["keypoints1"].cpu().numpy()
|
| 161 |
+
confidence = corr["confidence"].cpu().numpy()
|
| 162 |
+
|
| 163 |
+
mask = confidence > 0.5
|
| 164 |
+
kp1 = kp1[mask] / s1
|
| 165 |
+
kp2 = kp2[mask] / s2
|
| 166 |
+
|
| 167 |
+
if len(kp1) < 10:
|
| 168 |
+
print(f"[diff_ai] LoFTR: only {len(kp1)} confident matches")
|
| 169 |
+
return None, None
|
| 170 |
+
|
| 171 |
+
H, _ = cv2.findHomography(kp2, kp1, cv2.RANSAC, 5.0)
|
| 172 |
+
if H is None:
|
| 173 |
+
return None, None
|
| 174 |
+
|
| 175 |
+
out_size = (img1_bgr.shape[1], img1_bgr.shape[0])
|
| 176 |
+
aligned = cv2.warpPerspective(img2_bgr, H, out_size)
|
| 177 |
+
|
| 178 |
+
# Warp an all-white mask through the same H to find which pixels
|
| 179 |
+
# of `aligned` are real img2 content vs. the black fill border.
|
| 180 |
+
valid = np.full(img2_bgr.shape[:2], 255, dtype=np.uint8)
|
| 181 |
+
valid_mask = cv2.warpPerspective(valid, H, out_size)
|
| 182 |
+
# Erode a bit so interpolation-blurred edge pixels near the
|
| 183 |
+
# border aren't counted as valid either.
|
| 184 |
+
valid_mask = cv2.erode(valid_mask, np.ones((9, 9), np.uint8))
|
| 185 |
+
|
| 186 |
+
return aligned, valid_mask
|
| 187 |
+
except Exception as e:
|
| 188 |
+
print(f"[diff_ai] LoFTR alignment error: {e}")
|
| 189 |
+
return None, None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# =====================================================================
|
| 193 |
+
# STAGE 2 — CHANGE REGION EXTRACTION (SSIM + connected components)
|
| 194 |
+
# =====================================================================
|
| 195 |
+
|
| 196 |
+
def extract_change_regions(img1_bgr, aligned_bgr, valid_mask=None):
|
| 197 |
+
"""Returns (heatmap_bgr, list of {bbox, area, centroid}, binmask,
|
| 198 |
+
alignment_failed). Each region is a connected blob of pixels that
|
| 199 |
+
significantly differ.
|
| 200 |
+
|
| 201 |
+
valid_mask (optional): uint8 mask from align_loftr marking which
|
| 202 |
+
pixels of aligned_bgr are real warped content vs. warpPerspective's
|
| 203 |
+
black fill border. When provided, the border is excluded from the
|
| 204 |
+
change mask entirely so it can never be picked up as a fake
|
| 205 |
+
"removed" region, and low valid coverage also counts toward
|
| 206 |
+
alignment_failed."""
|
| 207 |
+
g1 = cv2.cvtColor(img1_bgr, cv2.COLOR_BGR2GRAY)
|
| 208 |
+
g2 = cv2.cvtColor(aligned_bgr, cv2.COLOR_BGR2GRAY)
|
| 209 |
+
g1 = cv2.GaussianBlur(g1, (5, 5), 0)
|
| 210 |
+
g2 = cv2.GaussianBlur(g2, (5, 5), 0)
|
| 211 |
+
|
| 212 |
+
_, diff = ssim(g1, g2, full=True)
|
| 213 |
+
diff_u8 = np.clip((1.0 - diff) * 255.0, 0, 255).astype(np.uint8)
|
| 214 |
+
heatmap = cv2.applyColorMap(diff_u8, cv2.COLORMAP_JET)
|
| 215 |
+
|
| 216 |
+
# Otsu binarize + clean noise
|
| 217 |
+
_, binmask = cv2.threshold(diff_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 218 |
+
binmask = cv2.medianBlur(binmask, 7)
|
| 219 |
+
open_k = np.ones((5, 5), np.uint8)
|
| 220 |
+
close_k = np.ones((15, 15), np.uint8)
|
| 221 |
+
binmask = cv2.morphologyEx(binmask, cv2.MORPH_OPEN, open_k, iterations=1)
|
| 222 |
+
binmask = cv2.morphologyEx(binmask, cv2.MORPH_CLOSE, close_k, iterations=2)
|
| 223 |
+
|
| 224 |
+
img_area = img1_bgr.shape[0] * img1_bgr.shape[1]
|
| 225 |
+
|
| 226 |
+
if valid_mask is not None:
|
| 227 |
+
# Never let the warpPerspective border be treated as a change
|
| 228 |
+
binmask = cv2.bitwise_and(binmask, valid_mask)
|
| 229 |
+
# If a large chunk of the frame has no real img2 coverage at all,
|
| 230 |
+
# the homography barely overlaps the frame — treat as a failure
|
| 231 |
+
# the same way a too-large diff_ratio would be.
|
| 232 |
+
coverage_ratio = cv2.countNonZero(valid_mask) / img_area
|
| 233 |
+
if coverage_ratio < 0.5:
|
| 234 |
+
print(f"[diff_ai] low valid coverage after warp ({coverage_ratio:.2f}) — "
|
| 235 |
+
f"treating as alignment failure")
|
| 236 |
+
return heatmap, [], binmask, True
|
| 237 |
+
|
| 238 |
+
# Global alignment-failure guard
|
| 239 |
+
diff_ratio = cv2.countNonZero(binmask) / img_area
|
| 240 |
+
alignment_failed = diff_ratio > 0.55
|
| 241 |
+
|
| 242 |
+
# Connected components → individual change regions
|
| 243 |
+
n_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
| 244 |
+
binmask, connectivity=8
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
min_area = max(400, int(img_area * 0.0005)) # >= 0.05% of image
|
| 248 |
+
max_area = int(img_area * 0.6) # < 60% (skip background)
|
| 249 |
+
|
| 250 |
+
regions = []
|
| 251 |
+
if not alignment_failed:
|
| 252 |
+
for i in range(1, n_labels): # skip label 0 (background)
|
| 253 |
+
x = int(stats[i, cv2.CC_STAT_LEFT])
|
| 254 |
+
y = int(stats[i, cv2.CC_STAT_TOP])
|
| 255 |
+
w = int(stats[i, cv2.CC_STAT_WIDTH])
|
| 256 |
+
h = int(stats[i, cv2.CC_STAT_HEIGHT])
|
| 257 |
+
area = int(stats[i, cv2.CC_STAT_AREA])
|
| 258 |
+
if area < min_area or area > max_area:
|
| 259 |
+
continue
|
| 260 |
+
cx = float(centroids[i, 0])
|
| 261 |
+
cy = float(centroids[i, 1])
|
| 262 |
+
regions.append({
|
| 263 |
+
"bbox": (x, y, w, h),
|
| 264 |
+
"area": area,
|
| 265 |
+
"centroid": (cx, cy),
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
return heatmap, regions, binmask, alignment_failed
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# =====================================================================
|
| 272 |
+
# STAGE 3 — DINOv2 PERCEPTUAL VERIFICATION
|
| 273 |
+
# =====================================================================
|
| 274 |
+
|
| 275 |
+
def dinov2_similarity(patch1_bgr, patch2_bgr):
|
| 276 |
+
"""Returns cosine similarity in [0, 1] between two patches.
|
| 277 |
+
Higher = more perceptually similar. Used to drop change regions caused
|
| 278 |
+
by lighting/shadow rather than real object changes, and to match
|
| 279 |
+
removed/added objects for MOVED detection."""
|
| 280 |
+
bundle = _get_dinov2()
|
| 281 |
+
if not bundle:
|
| 282 |
+
return None
|
| 283 |
+
model = bundle["model"]
|
| 284 |
+
processor = bundle["processor"]
|
| 285 |
+
torch = bundle["torch"]
|
| 286 |
+
device = bundle["device"]
|
| 287 |
+
|
| 288 |
+
if patch1_bgr.size == 0 or patch2_bgr.size == 0:
|
| 289 |
+
return None
|
| 290 |
+
try:
|
| 291 |
+
rgb1 = cv2.cvtColor(patch1_bgr, cv2.COLOR_BGR2RGB)
|
| 292 |
+
rgb2 = cv2.cvtColor(patch2_bgr, cv2.COLOR_BGR2RGB)
|
| 293 |
+
inputs = processor(images=[rgb1, rgb2], return_tensors="pt").to(device)
|
| 294 |
+
with torch.no_grad():
|
| 295 |
+
out = model(**inputs)
|
| 296 |
+
feats = out.last_hidden_state[:, 0] # CLS token, (2, D)
|
| 297 |
+
feats = feats / feats.norm(dim=1, keepdim=True)
|
| 298 |
+
sim = float((feats[0] * feats[1]).sum().item())
|
| 299 |
+
return sim
|
| 300 |
+
except Exception as e:
|
| 301 |
+
print(f"[diff_ai] DINOv2 sim error: {e}")
|
| 302 |
+
return None
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# =====================================================================
|
| 306 |
+
# STAGE 4 — SAM2 PROMPTED SEGMENTATION
|
| 307 |
+
# =====================================================================
|
| 308 |
+
|
| 309 |
+
def _sam_prompt_single(img_bgr, point_xy):
|
| 310 |
+
"""Run SAM2 with one point prompt. Returns binary uint8 mask or None."""
|
| 311 |
+
sam = _get_sam()
|
| 312 |
+
if not sam:
|
| 313 |
+
return None
|
| 314 |
+
try:
|
| 315 |
+
results = sam(
|
| 316 |
+
img_bgr,
|
| 317 |
+
points=[[float(point_xy[0]), float(point_xy[1])]],
|
| 318 |
+
labels=[1],
|
| 319 |
+
verbose=False,
|
| 320 |
+
)
|
| 321 |
+
if not results:
|
| 322 |
+
return None
|
| 323 |
+
r = results[0]
|
| 324 |
+
if r.masks is None or len(r.masks.data) == 0:
|
| 325 |
+
return None
|
| 326 |
+
masks_np = r.masks.data.cpu().numpy()
|
| 327 |
+
areas = [int((m > 0.5).sum()) for m in masks_np]
|
| 328 |
+
if not areas:
|
| 329 |
+
return None
|
| 330 |
+
best_idx = int(np.argmax(areas))
|
| 331 |
+
return (masks_np[best_idx] > 0.5).astype(np.uint8)
|
| 332 |
+
except Exception:
|
| 333 |
+
return None
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def sam_mask_at_point(img_bgr, point_xy, fallback_bbox=None):
|
| 337 |
+
"""Run SAM2 with a single positive point prompt. Returns the largest
|
| 338 |
+
returned mask as a binary uint8 array, or None on failure.
|
| 339 |
+
|
| 340 |
+
If the centroid prompt produces an empty/noisy mask and fallback_bbox
|
| 341 |
+
is provided, tries additional points across the region and picks the
|
| 342 |
+
largest mask (multi-point fallback for edge cases where the centroid
|
| 343 |
+
lands on background — e.g. ring-shaped change regions)."""
|
| 344 |
+
mask = _sam_prompt_single(img_bgr, point_xy)
|
| 345 |
+
if mask is not None and int(mask.sum()) > 100:
|
| 346 |
+
return mask
|
| 347 |
+
|
| 348 |
+
if fallback_bbox is not None:
|
| 349 |
+
x, y, w, h = fallback_bbox
|
| 350 |
+
margin = 0.25
|
| 351 |
+
candidates = [
|
| 352 |
+
(x + w * margin, y + h * margin),
|
| 353 |
+
(x + w * (1.0 - margin), y + h * margin),
|
| 354 |
+
(x + w * margin, y + h * (1.0 - margin)),
|
| 355 |
+
(x + w * (1.0 - margin), y + h * (1.0 - margin)),
|
| 356 |
+
(x + w * 0.5, y + h * 0.5),
|
| 357 |
+
]
|
| 358 |
+
best_mask = None
|
| 359 |
+
best_area = 0
|
| 360 |
+
for px, py in candidates:
|
| 361 |
+
m = _sam_prompt_single(img_bgr, (px, py))
|
| 362 |
+
if m is not None:
|
| 363 |
+
a = int(m.sum())
|
| 364 |
+
if a > best_area:
|
| 365 |
+
best_area = a
|
| 366 |
+
best_mask = m
|
| 367 |
+
if best_mask is not None:
|
| 368 |
+
return best_mask
|
| 369 |
+
|
| 370 |
+
return mask
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def bbox_from_mask(mask):
|
| 374 |
+
"""Tight bbox (x, y, w, h) around a binary mask, or None if empty."""
|
| 375 |
+
ys, xs = np.where(mask > 0)
|
| 376 |
+
if len(ys) == 0:
|
| 377 |
+
return None
|
| 378 |
+
x0, x1 = int(xs.min()), int(xs.max())
|
| 379 |
+
y0, y1 = int(ys.min()), int(ys.max())
|
| 380 |
+
return (x0, y0, x1 - x0 + 1, y1 - y0 + 1)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
# =====================================================================
|
| 384 |
+
# STAGE 5 — ADDED vs REMOVED CLASSIFICATION
|
| 385 |
+
# =====================================================================
|
| 386 |
+
|
| 387 |
+
def classify_added_or_removed(patch1_bgr, patch2_bgr,
|
| 388 |
+
edge_threshold=EDGE_DENSITY_THRESHOLD):
|
| 389 |
+
"""Edge-density heuristic. The image with more edges in this region
|
| 390 |
+
is the one that has the 'object'.
|
| 391 |
+
Returns 'added' (img2 has more), 'removed' (img1 has more),
|
| 392 |
+
or None if both patches have near-zero edge variance (noise)."""
|
| 393 |
+
g1 = cv2.cvtColor(patch1_bgr, cv2.COLOR_BGR2GRAY)
|
| 394 |
+
g2 = cv2.cvtColor(patch2_bgr, cv2.COLOR_BGR2GRAY)
|
| 395 |
+
e1 = float(cv2.Laplacian(g1, cv2.CV_64F).var())
|
| 396 |
+
e2 = float(cv2.Laplacian(g2, cv2.CV_64F).var())
|
| 397 |
+
if e1 < edge_threshold and e2 < edge_threshold:
|
| 398 |
+
return None
|
| 399 |
+
return "added" if e2 >= e1 else "removed"
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
# =====================================================================
|
| 403 |
+
# STAGE 6 — MOVED-OBJECT MATCHING (removed <-> added)
|
| 404 |
+
# =====================================================================
|
| 405 |
+
|
| 406 |
+
def _crop_patch(img_bgr, bbox, pad):
|
| 407 |
+
"""Crop bbox from img_bgr with padding, clipped to image bounds."""
|
| 408 |
+
x, y, w, h = bbox
|
| 409 |
+
x0 = max(0, x - pad)
|
| 410 |
+
y0 = max(0, y - pad)
|
| 411 |
+
x1 = min(img_bgr.shape[1], x + w + pad)
|
| 412 |
+
y1 = min(img_bgr.shape[0], y + h + pad)
|
| 413 |
+
return img_bgr[y0:y1, x0:x1]
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _bboxes_overlap(bbox_a, bbox_b):
|
| 417 |
+
"""AABB intersection test. Overlapping removed/added regions are
|
| 418 |
+
treated as an in-place change rather than a move, so they're never
|
| 419 |
+
considered as a MOVED pair."""
|
| 420 |
+
ax, ay, aw, ah = bbox_a
|
| 421 |
+
bx, by, bw, bh = bbox_b
|
| 422 |
+
return ax < bx + bw and ax + aw > bx and ay < by + bh and ay + ah > by
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def _match_moved_objects(removed_objects, added_objects, img1_bgr, aligned_bgr,
|
| 426 |
+
pad=MOVE_MATCH_PAD, sim_threshold=MOVED_SIM_THRESHOLD):
|
| 427 |
+
"""Match REMOVED objects against ADDED objects to find MOVED objects.
|
| 428 |
+
|
| 429 |
+
Fix vs. the previous implementation: the old version iterated
|
| 430 |
+
`removed_objects` in (arbitrary, scan-order-derived) list order and let
|
| 431 |
+
each removed object greedily grab its own single best-matching added
|
| 432 |
+
object before moving to the next. That's order-dependent — an earlier
|
| 433 |
+
removed object could claim a mediocre match and starve a later removed
|
| 434 |
+
object of the added object it should have matched to, and swapping the
|
| 435 |
+
order of `removed_objects` could change the result.
|
| 436 |
+
|
| 437 |
+
This version computes the *entire* removed x added similarity matrix
|
| 438 |
+
up front (skipping overlapping pairs), then commits pairs in
|
| 439 |
+
descending similarity order — the single best candidate pair anywhere
|
| 440 |
+
in the matrix is matched first, then the next best remaining pair, and
|
| 441 |
+
so on. This is order-independent and much closer to an optimal
|
| 442 |
+
bipartite matching than the old per-item greedy approach.
|
| 443 |
+
|
| 444 |
+
Returns (moved_objects, surviving_removed, surviving_added).
|
| 445 |
+
"""
|
| 446 |
+
if not removed_objects or not added_objects:
|
| 447 |
+
return [], list(removed_objects), list(added_objects)
|
| 448 |
+
|
| 449 |
+
candidates = [] # (similarity, removed_idx, added_idx)
|
| 450 |
+
for ri, rem in enumerate(removed_objects):
|
| 451 |
+
rem_patch = _crop_patch(img1_bgr, rem["bbox"], pad)
|
| 452 |
+
for ai, add in enumerate(added_objects):
|
| 453 |
+
if _bboxes_overlap(rem["bbox"], add["bbox"]):
|
| 454 |
+
continue
|
| 455 |
+
add_patch = _crop_patch(aligned_bgr, add["bbox"], pad)
|
| 456 |
+
sim = dinov2_similarity(rem_patch, add_patch)
|
| 457 |
+
if sim is not None and sim >= sim_threshold:
|
| 458 |
+
candidates.append((sim, ri, ai))
|
| 459 |
+
|
| 460 |
+
# Best pairs first, order-independent
|
| 461 |
+
candidates.sort(key=lambda c: c[0], reverse=True)
|
| 462 |
+
|
| 463 |
+
matched_removed = set()
|
| 464 |
+
matched_added = set()
|
| 465 |
+
moved_objects = []
|
| 466 |
+
for sim, ri, ai in candidates:
|
| 467 |
+
if ri in matched_removed or ai in matched_added:
|
| 468 |
+
continue
|
| 469 |
+
matched_removed.add(ri)
|
| 470 |
+
matched_added.add(ai)
|
| 471 |
+
moved_objects.append({
|
| 472 |
+
"from": removed_objects[ri],
|
| 473 |
+
"to": added_objects[ai],
|
| 474 |
+
"similarity": sim,
|
| 475 |
+
})
|
| 476 |
+
print(f" MOVED: from {removed_objects[ri]['centroid']} "
|
| 477 |
+
f"-> to {added_objects[ai]['centroid']} sim={sim:.3f}")
|
| 478 |
+
|
| 479 |
+
surviving_removed = [r for i, r in enumerate(removed_objects) if i not in matched_removed]
|
| 480 |
+
surviving_added = [a for i, a in enumerate(added_objects) if i not in matched_added]
|
| 481 |
+
|
| 482 |
+
for i, rem in enumerate(removed_objects):
|
| 483 |
+
if i not in matched_removed:
|
| 484 |
+
print(f" REMOVED (unmatched) @{rem['centroid']}")
|
| 485 |
+
|
| 486 |
+
return moved_objects, surviving_removed, surviving_added
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
# =====================================================================
|
| 490 |
+
# DRAWING
|
| 491 |
+
# =====================================================================
|
| 492 |
+
|
| 493 |
+
def _draw_box(img, bbox, color, label):
|
| 494 |
+
x, y, w, h = bbox
|
| 495 |
+
cv2.rectangle(img, (x, y), (x + w, y + h), color, 3)
|
| 496 |
+
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
| 497 |
+
cv2.rectangle(img, (x, y - th - 8), (x + tw + 8, y), color, -1)
|
| 498 |
+
cv2.putText(img, label, (x + 4, y - 4),
|
| 499 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
# =====================================================================
|
| 503 |
+
# MAIN ENTRY
|
| 504 |
+
# =====================================================================
|
| 505 |
+
|
| 506 |
+
def compare_images_ai(img1_path, img2_path, output_path, heatmap_path):
|
| 507 |
+
"""End-to-end change-region-driven AI pipeline. Raises RuntimeError
|
| 508 |
+
if any required model is unavailable."""
|
| 509 |
+
if not _get_loftr() or not _get_sam() or not _get_dinov2():
|
| 510 |
+
raise RuntimeError("AI pipeline unavailable — required model missing")
|
| 511 |
+
|
| 512 |
+
img1 = cv2.imread(img1_path)
|
| 513 |
+
img2 = cv2.imread(img2_path)
|
| 514 |
+
if img1 is None or img2 is None:
|
| 515 |
+
raise RuntimeError("Failed to read input images")
|
| 516 |
+
|
| 517 |
+
# Normalize sizes + downscale to keep models fast
|
| 518 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 519 |
+
|
| 520 |
+
h0, w0 = img1.shape[:2]
|
| 521 |
+
if max(h0, w0) > MAX_LONG_EDGE:
|
| 522 |
+
s = MAX_LONG_EDGE / max(h0, w0)
|
| 523 |
+
new_size = (int(w0 * s), int(h0 * s))
|
| 524 |
+
img1 = cv2.resize(img1, new_size, interpolation=cv2.INTER_AREA)
|
| 525 |
+
img2 = cv2.resize(img2, new_size, interpolation=cv2.INTER_AREA)
|
| 526 |
+
|
| 527 |
+
# 1) LoFTR alignment
|
| 528 |
+
aligned, valid_mask = align_loftr(img1, img2)
|
| 529 |
+
if aligned is None:
|
| 530 |
+
raise RuntimeError("LoFTR alignment failed")
|
| 531 |
+
|
| 532 |
+
# 2) Change region extraction (SSIM + CC), excluding the warp border
|
| 533 |
+
heatmap, regions, _, alignment_failed = extract_change_regions(img1, aligned, valid_mask)
|
| 534 |
+
if alignment_failed:
|
| 535 |
+
regions = []
|
| 536 |
+
print(f"[diff_ai] {len(regions)} change region(s) before verification")
|
| 537 |
+
|
| 538 |
+
# 3) For each region: DINOv2 verify + SAM2 prompted segmentation
|
| 539 |
+
added_objects = []
|
| 540 |
+
removed_objects = []
|
| 541 |
+
|
| 542 |
+
for r in regions:
|
| 543 |
+
x, y, w, h = r["bbox"]
|
| 544 |
+
x0 = max(0, x - REGION_PAD)
|
| 545 |
+
y0 = max(0, y - REGION_PAD)
|
| 546 |
+
x1 = min(img1.shape[1], x + w + REGION_PAD)
|
| 547 |
+
y1 = min(img1.shape[0], y + h + REGION_PAD)
|
| 548 |
+
patch1 = img1[y0:y1, x0:x1]
|
| 549 |
+
patch2 = aligned[y0:y1, x0:x1]
|
| 550 |
+
|
| 551 |
+
# 3a) DINOv2 perceptual gate
|
| 552 |
+
sim = dinov2_similarity(patch1, patch2)
|
| 553 |
+
if sim is not None and sim >= DINO_SIM_THRESHOLD:
|
| 554 |
+
print(f" region @{r['centroid']}: DINOv2 sim={sim:.3f} — perceptually same, dropping")
|
| 555 |
+
continue
|
| 556 |
+
|
| 557 |
+
# 3b) Classify as added vs removed via edge density
|
| 558 |
+
label = classify_added_or_removed(patch1, patch2)
|
| 559 |
+
if label is None:
|
| 560 |
+
print(f" region @{r['centroid']}: edge variance too low on both sides, skipping")
|
| 561 |
+
continue
|
| 562 |
+
|
| 563 |
+
# 3c) SAM2 prompted at the centroid for a clean object mask
|
| 564 |
+
target_img = aligned if label == "added" else img1
|
| 565 |
+
mask = sam_mask_at_point(target_img, r["centroid"], r["bbox"])
|
| 566 |
+
tight_bbox = bbox_from_mask(mask) if mask is not None else r["bbox"]
|
| 567 |
+
|
| 568 |
+
entry = {
|
| 569 |
+
"bbox": tight_bbox,
|
| 570 |
+
"centroid": r["centroid"],
|
| 571 |
+
"dino_sim": sim,
|
| 572 |
+
"mask": mask,
|
| 573 |
+
"source": target_img,
|
| 574 |
+
}
|
| 575 |
+
if label == "added":
|
| 576 |
+
added_objects.append(entry)
|
| 577 |
+
else:
|
| 578 |
+
removed_objects.append(entry)
|
| 579 |
+
|
| 580 |
+
print(f" region @{r['centroid']}: sim={sim} -> {label}")
|
| 581 |
+
|
| 582 |
+
# 4) MOVED object detection: globally-greedy match REMOVED <-> ADDED
|
| 583 |
+
moved_objects, removed_objects, added_objects = _match_moved_objects(
|
| 584 |
+
removed_objects, added_objects, img1, aligned
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
n_moved = len(moved_objects)
|
| 588 |
+
n_added = len(added_objects)
|
| 589 |
+
n_removed = len(removed_objects)
|
| 590 |
+
n_total = n_added + n_removed + n_moved
|
| 591 |
+
severity = "HIGH" if n_total > 0 else "NONE"
|
| 592 |
+
|
| 593 |
+
# 5) Draw boxes
|
| 594 |
+
result_img = aligned.copy()
|
| 595 |
+
|
| 596 |
+
for a in added_objects:
|
| 597 |
+
_draw_box(result_img, a["bbox"], COLOR_ADDED, "ADDED")
|
| 598 |
+
for r in removed_objects:
|
| 599 |
+
_draw_box(result_img, r["bbox"], COLOR_REMOVED, "REMOVED")
|
| 600 |
+
for m in moved_objects:
|
| 601 |
+
_draw_box(result_img, m["from"]["bbox"], COLOR_MOVED, "MOVED")
|
| 602 |
+
_draw_box(result_img, m["to"]["bbox"], COLOR_MOVED, "MOVED")
|
| 603 |
+
|
| 604 |
+
# Draw severity label in top-left
|
| 605 |
+
label = f"{severity} +{n_added} -{n_removed} ~{n_moved}"
|
| 606 |
+
cv2.putText(result_img, label, (20, 42),
|
| 607 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 5)
|
| 608 |
+
cv2.putText(result_img, label, (20, 42),
|
| 609 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
|
| 610 |
+
|
| 611 |
+
cv2.imwrite(output_path, result_img)
|
| 612 |
+
cv2.imwrite(heatmap_path, heatmap)
|
| 613 |
+
|
| 614 |
+
return {
|
| 615 |
+
"pipeline": "ai",
|
| 616 |
+
"added": n_added,
|
| 617 |
+
"removed": n_removed,
|
| 618 |
+
"moved": n_moved,
|
| 619 |
+
"object_changes": n_total,
|
| 620 |
+
"severity": severity,
|
| 621 |
+
"alignment_failed": alignment_failed,
|
| 622 |
+
}
|
diff_ai.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI-first image comparison pipeline (change-region-driven).
|
| 3 |
+
|
| 4 |
+
Strategy (different from naive segment-everything-then-match):
|
| 5 |
+
1. LoFTR (kornia) aligns img2 onto img1
|
| 6 |
+
2. SSIM produces a binary "change mask" — where pixels actually differ
|
| 7 |
+
3. Connected components on the change mask give us discrete change regions
|
| 8 |
+
4. For each region:
|
| 9 |
+
a. Crop a context patch from both images
|
| 10 |
+
b. DINOv2 verifies perceptual difference (kills shadow/lighting noise)
|
| 11 |
+
c. SAM2 in PROMPT mode (point prompt at the region centroid) returns
|
| 12 |
+
a tight object mask in img2
|
| 13 |
+
d. Edge-density heuristic classifies as ADDED vs REMOVED
|
| 14 |
+
5. Draw color-coded bounding boxes / masks (falling back to the raw change
|
| 15 |
+
bbox if SAM couldn't produce a mask, so a single failed SAM call never
|
| 16 |
+
causes a real change to vanish from the output)
|
| 17 |
+
|
| 18 |
+
Why this is better than the previous attempt:
|
| 19 |
+
- No SAM auto-mask-generation (10x faster — only prompted on change regions)
|
| 20 |
+
- No cross-image DINO matching (no phantom added/removed pairs)
|
| 21 |
+
- Change mask is the source of truth; SAM and DINO act as refinement layers
|
| 22 |
+
- Pipeline degrades gracefully: if SAM/DINO fail, the change-region bboxes
|
| 23 |
+
are still drawn as fallback output (not silently dropped)
|
| 24 |
+
- DINOv2 embeddings are batched: the perceptual gate embeds all regions in
|
| 25 |
+
one forward pass, and moved-object matching embeds each patch once and
|
| 26 |
+
compares via a similarity matrix instead of one forward pass per pair
|
| 27 |
+
|
| 28 |
+
All models are lazy-loaded (thread-safely). Falls back to classical pipeline
|
| 29 |
+
(diff.py) if any required model is unavailable — see app.py for the wiring.
|
| 30 |
+
"""
|
| 31 |
+
import threading
|
| 32 |
+
|
| 33 |
+
import cv2
|
| 34 |
+
import numpy as np
|
| 35 |
+
from skimage.metrics import structural_similarity as ssim
|
| 36 |
+
|
| 37 |
+
# =====================================================================
|
| 38 |
+
# TUNABLE CONSTANTS
|
| 39 |
+
# =====================================================================
|
| 40 |
+
|
| 41 |
+
# Drawing colors (BGR)
|
| 42 |
+
COLOR_ADDED = (0, 200, 0)
|
| 43 |
+
COLOR_REMOVED = (0, 0, 220)
|
| 44 |
+
COLOR_MOVED = (255, 255, 0) # cyan in BGR (drawn on both from & to boxes)
|
| 45 |
+
|
| 46 |
+
LOFTR_LONG_EDGE = 640 # working resolution for LoFTR matching
|
| 47 |
+
LOFTR_MIN_MATCHES = 10 # below this, alignment is considered failed
|
| 48 |
+
LOFTR_CONF_THRESH = 0.5
|
| 49 |
+
|
| 50 |
+
MAX_LONG_EDGE = 960 # cap on working image size for the pipeline
|
| 51 |
+
|
| 52 |
+
CHANGE_MIN_AREA_FRAC = 0.0005 # region must be >= this fraction of image area
|
| 53 |
+
CHANGE_MAX_AREA_FRAC = 0.6 # region must be < this fraction (skip background)
|
| 54 |
+
ALIGNMENT_FAIL_DIFF_RATIO = 0.55 # if more of the image "changed" than this, alignment likely failed
|
| 55 |
+
|
| 56 |
+
DINO_SIM_THRESHOLD = 0.85 # >= this means perceptually identical → drop region
|
| 57 |
+
MOVED_SIM_THRESHOLD = 0.75 # >= this cosine sim links a removed/added pair as "moved"
|
| 58 |
+
REGION_PAD = 12 # px padding around a region when cropping context patches
|
| 59 |
+
|
| 60 |
+
EDGE_VARIANCE_THRESHOLD = 1.0 # Laplacian-variance floor for added/removed classification
|
| 61 |
+
|
| 62 |
+
# SAM mask is considered too small/noisy below this fraction of the prompt
|
| 63 |
+
# image's area (relative, so it scales with input resolution).
|
| 64 |
+
SAM_MIN_MASK_AREA_FRAC = 0.0005
|
| 65 |
+
|
| 66 |
+
_MODEL_LOCK = threading.Lock()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# =====================================================================
|
| 70 |
+
# LAZY MODEL LOADERS (thread-safe)
|
| 71 |
+
# =====================================================================
|
| 72 |
+
|
| 73 |
+
_loftr = None
|
| 74 |
+
|
| 75 |
+
def _get_loftr():
|
| 76 |
+
"""Lazy-load kornia LoFTR (transformer dense matcher)."""
|
| 77 |
+
global _loftr
|
| 78 |
+
if _loftr is not None:
|
| 79 |
+
return _loftr
|
| 80 |
+
with _MODEL_LOCK:
|
| 81 |
+
if _loftr is not None:
|
| 82 |
+
return _loftr
|
| 83 |
+
try:
|
| 84 |
+
import torch
|
| 85 |
+
import kornia.feature as KF
|
| 86 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 87 |
+
matcher = KF.LoFTR(pretrained="outdoor").eval().to(device)
|
| 88 |
+
_loftr = {"matcher": matcher, "torch": torch, "device": device}
|
| 89 |
+
print("[diff_ai] LoFTR loaded")
|
| 90 |
+
except Exception as e:
|
| 91 |
+
print(f"[diff_ai] LoFTR unavailable: {e}")
|
| 92 |
+
_loftr = False
|
| 93 |
+
return _loftr
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
_sam = None
|
| 97 |
+
|
| 98 |
+
def _get_sam():
|
| 99 |
+
"""Lazy-load ultralytics SAM2 (used in prompted mode, not auto-mask)."""
|
| 100 |
+
global _sam
|
| 101 |
+
if _sam is not None:
|
| 102 |
+
return _sam
|
| 103 |
+
with _MODEL_LOCK:
|
| 104 |
+
if _sam is not None:
|
| 105 |
+
return _sam
|
| 106 |
+
try:
|
| 107 |
+
import torch
|
| 108 |
+
from ultralytics import SAM
|
| 109 |
+
model = SAM("sam2_t.pt") # ~150MB, auto-downloads
|
| 110 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 111 |
+
try:
|
| 112 |
+
model.to(device)
|
| 113 |
+
except Exception:
|
| 114 |
+
pass # some ultralytics versions take device per-call instead
|
| 115 |
+
_sam = {"model": model, "device": device}
|
| 116 |
+
print(f"[diff_ai] SAM2 loaded on {device}")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
print(f"[diff_ai] SAM2 unavailable: {e}")
|
| 119 |
+
_sam = False
|
| 120 |
+
return _sam
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
_dinov2 = None
|
| 124 |
+
|
| 125 |
+
def _get_dinov2():
|
| 126 |
+
"""Lazy-load DINOv2 via HuggingFace transformers (Python 3.9 compatible)."""
|
| 127 |
+
global _dinov2
|
| 128 |
+
if _dinov2 is not None:
|
| 129 |
+
return _dinov2
|
| 130 |
+
with _MODEL_LOCK:
|
| 131 |
+
if _dinov2 is not None:
|
| 132 |
+
return _dinov2
|
| 133 |
+
try:
|
| 134 |
+
import torch
|
| 135 |
+
from transformers import AutoModel, AutoImageProcessor
|
| 136 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 137 |
+
processor = AutoImageProcessor.from_pretrained(
|
| 138 |
+
"facebook/dinov2-small", use_fast=True
|
| 139 |
+
)
|
| 140 |
+
model = AutoModel.from_pretrained("facebook/dinov2-small").eval().to(device)
|
| 141 |
+
_dinov2 = {
|
| 142 |
+
"model": model, "processor": processor,
|
| 143 |
+
"torch": torch, "device": device,
|
| 144 |
+
}
|
| 145 |
+
print("[diff_ai] DINOv2 loaded")
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"[diff_ai] DINOv2 unavailable: {e}")
|
| 148 |
+
_dinov2 = False
|
| 149 |
+
return _dinov2
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# =====================================================================
|
| 153 |
+
# STAGE 1 — LoFTR ALIGNMENT
|
| 154 |
+
# =====================================================================
|
| 155 |
+
|
| 156 |
+
def _to_loftr_tensor(bgr, torch_mod, device):
|
| 157 |
+
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
| 158 |
+
h, w = gray.shape
|
| 159 |
+
scale = LOFTR_LONG_EDGE / max(h, w)
|
| 160 |
+
nh, nw = int(round(h * scale)), int(round(w * scale))
|
| 161 |
+
# Round down to a multiple of 8 (LoFTR requirement) without ever hitting 0.
|
| 162 |
+
nh = max(8, nh - nh % 8)
|
| 163 |
+
nw = max(8, nw - nw % 8)
|
| 164 |
+
resized = cv2.resize(gray, (nw, nh))
|
| 165 |
+
t = torch_mod.from_numpy(resized).float()[None, None] / 255.0
|
| 166 |
+
return t.to(device), (nw / w, nh / h) # per-axis scale, resize isn't isotropic-safe
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def align_loftr(img1_bgr, img2_bgr):
|
| 170 |
+
"""Warp img2 onto img1 using LoFTR matches + RANSAC homography."""
|
| 171 |
+
bundle = _get_loftr()
|
| 172 |
+
if not bundle:
|
| 173 |
+
return None
|
| 174 |
+
matcher = bundle["matcher"]
|
| 175 |
+
torch = bundle["torch"]
|
| 176 |
+
device = bundle["device"]
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
t1, (sx1, sy1) = _to_loftr_tensor(img1_bgr, torch, device)
|
| 180 |
+
t2, (sx2, sy2) = _to_loftr_tensor(img2_bgr, torch, device)
|
| 181 |
+
with torch.no_grad():
|
| 182 |
+
corr = matcher({"image0": t1, "image1": t2})
|
| 183 |
+
|
| 184 |
+
kp1 = corr["keypoints0"].cpu().numpy()
|
| 185 |
+
kp2 = corr["keypoints1"].cpu().numpy()
|
| 186 |
+
confidence = corr["confidence"].cpu().numpy()
|
| 187 |
+
|
| 188 |
+
mask = confidence > LOFTR_CONF_THRESH
|
| 189 |
+
kp1 = kp1[mask] / np.array([sx1, sy1])
|
| 190 |
+
kp2 = kp2[mask] / np.array([sx2, sy2])
|
| 191 |
+
|
| 192 |
+
if len(kp1) < LOFTR_MIN_MATCHES:
|
| 193 |
+
print(f"[diff_ai] LoFTR: only {len(kp1)} confident matches")
|
| 194 |
+
return None
|
| 195 |
+
|
| 196 |
+
H, _ = cv2.findHomography(kp2, kp1, cv2.RANSAC, 5.0)
|
| 197 |
+
if H is None:
|
| 198 |
+
return None
|
| 199 |
+
|
| 200 |
+
return cv2.warpPerspective(
|
| 201 |
+
img2_bgr, H, (img1_bgr.shape[1], img1_bgr.shape[0])
|
| 202 |
+
)
|
| 203 |
+
except Exception as e:
|
| 204 |
+
print(f"[diff_ai] LoFTR alignment error: {e}")
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# =====================================================================
|
| 209 |
+
# STAGE 2 — CHANGE REGION EXTRACTION (SSIM + connected components)
|
| 210 |
+
# =====================================================================
|
| 211 |
+
|
| 212 |
+
def extract_change_regions(img1_bgr, aligned_bgr):
|
| 213 |
+
"""Returns (heatmap_bgr, list of {bbox, area, centroid}, binmask, alignment_failed).
|
| 214 |
+
Each region is a connected blob of pixels that significantly differ."""
|
| 215 |
+
g1 = cv2.cvtColor(img1_bgr, cv2.COLOR_BGR2GRAY)
|
| 216 |
+
g2 = cv2.cvtColor(aligned_bgr, cv2.COLOR_BGR2GRAY)
|
| 217 |
+
g1 = cv2.GaussianBlur(g1, (5, 5), 0)
|
| 218 |
+
g2 = cv2.GaussianBlur(g2, (5, 5), 0)
|
| 219 |
+
|
| 220 |
+
_, diff = ssim(g1, g2, full=True)
|
| 221 |
+
diff_u8 = np.clip((1.0 - diff) * 255.0, 0, 255).astype(np.uint8)
|
| 222 |
+
heatmap = cv2.applyColorMap(diff_u8, cv2.COLORMAP_JET)
|
| 223 |
+
|
| 224 |
+
# Otsu binarize + clean noise
|
| 225 |
+
_, binmask = cv2.threshold(diff_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 226 |
+
binmask = cv2.medianBlur(binmask, 7)
|
| 227 |
+
open_k = np.ones((5, 5), np.uint8)
|
| 228 |
+
close_k = np.ones((15, 15), np.uint8)
|
| 229 |
+
binmask = cv2.morphologyEx(binmask, cv2.MORPH_OPEN, open_k, iterations=1)
|
| 230 |
+
binmask = cv2.morphologyEx(binmask, cv2.MORPH_CLOSE, close_k, iterations=2)
|
| 231 |
+
|
| 232 |
+
# Global alignment-failure guard
|
| 233 |
+
img_area = img1_bgr.shape[0] * img1_bgr.shape[1]
|
| 234 |
+
diff_ratio = cv2.countNonZero(binmask) / img_area
|
| 235 |
+
alignment_failed = diff_ratio > ALIGNMENT_FAIL_DIFF_RATIO
|
| 236 |
+
|
| 237 |
+
# Connected components → individual change regions
|
| 238 |
+
n_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
| 239 |
+
binmask, connectivity=8
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
min_area = max(400, int(img_area * CHANGE_MIN_AREA_FRAC))
|
| 243 |
+
max_area = int(img_area * CHANGE_MAX_AREA_FRAC)
|
| 244 |
+
|
| 245 |
+
regions = []
|
| 246 |
+
if not alignment_failed:
|
| 247 |
+
for i in range(1, n_labels): # skip label 0 (background)
|
| 248 |
+
x = int(stats[i, cv2.CC_STAT_LEFT])
|
| 249 |
+
y = int(stats[i, cv2.CC_STAT_TOP])
|
| 250 |
+
w = int(stats[i, cv2.CC_STAT_WIDTH])
|
| 251 |
+
h = int(stats[i, cv2.CC_STAT_HEIGHT])
|
| 252 |
+
area = int(stats[i, cv2.CC_STAT_AREA])
|
| 253 |
+
if area < min_area or area > max_area:
|
| 254 |
+
continue
|
| 255 |
+
cx = float(centroids[i, 0])
|
| 256 |
+
cy = float(centroids[i, 1])
|
| 257 |
+
regions.append({
|
| 258 |
+
"bbox": (x, y, w, h),
|
| 259 |
+
"area": area,
|
| 260 |
+
"centroid": (cx, cy),
|
| 261 |
+
})
|
| 262 |
+
|
| 263 |
+
return heatmap, regions, binmask, alignment_failed
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# =====================================================================
|
| 267 |
+
# STAGE 3 — DINOv2 PERCEPTUAL VERIFICATION (batched)
|
| 268 |
+
# =====================================================================
|
| 269 |
+
|
| 270 |
+
def dinov2_embed_batch(patches_bgr):
|
| 271 |
+
"""Embed a list of BGR patches in a single forward pass.
|
| 272 |
+
Returns an (N, D) L2-normalized numpy array, or None if the model is
|
| 273 |
+
unavailable or every patch is empty. Empty patches get an all-zero
|
| 274 |
+
embedding (guaranteed cosine sim of 0 with anything)."""
|
| 275 |
+
bundle = _get_dinov2()
|
| 276 |
+
if not bundle:
|
| 277 |
+
return None
|
| 278 |
+
model, processor = bundle["model"], bundle["processor"]
|
| 279 |
+
torch, device = bundle["torch"], bundle["device"]
|
| 280 |
+
|
| 281 |
+
valid_idx = [i for i, p in enumerate(patches_bgr) if p is not None and p.size > 0]
|
| 282 |
+
if not valid_idx:
|
| 283 |
+
return None
|
| 284 |
+
|
| 285 |
+
try:
|
| 286 |
+
rgb_imgs = [cv2.cvtColor(patches_bgr[i], cv2.COLOR_BGR2RGB) for i in valid_idx]
|
| 287 |
+
inputs = processor(images=rgb_imgs, return_tensors="pt").to(device)
|
| 288 |
+
with torch.no_grad():
|
| 289 |
+
out = model(**inputs)
|
| 290 |
+
feats = out.last_hidden_state[:, 0] # CLS token, (n_valid, D)
|
| 291 |
+
feats = feats / feats.norm(dim=1, keepdim=True)
|
| 292 |
+
feats = feats.cpu().numpy()
|
| 293 |
+
except Exception as e:
|
| 294 |
+
print(f"[diff_ai] DINOv2 batch embed error: {e}")
|
| 295 |
+
return None
|
| 296 |
+
|
| 297 |
+
dim = feats.shape[1]
|
| 298 |
+
result = np.zeros((len(patches_bgr), dim), dtype=feats.dtype)
|
| 299 |
+
for slot, i in enumerate(valid_idx):
|
| 300 |
+
result[i] = feats[slot]
|
| 301 |
+
return result
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def dinov2_similarity(patch1_bgr, patch2_bgr):
|
| 305 |
+
"""Cosine similarity in [-1, 1] between two patches (single-pair
|
| 306 |
+
convenience wrapper around the batched embedder). Higher = more
|
| 307 |
+
perceptually similar."""
|
| 308 |
+
embs = dinov2_embed_batch([patch1_bgr, patch2_bgr])
|
| 309 |
+
if embs is None:
|
| 310 |
+
return None
|
| 311 |
+
if not np.any(embs[0]) or not np.any(embs[1]):
|
| 312 |
+
return None
|
| 313 |
+
return float(np.dot(embs[0], embs[1]))
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# =====================================================================
|
| 317 |
+
# STAGE 4 — SAM2 PROMPTED SEGMENTATION
|
| 318 |
+
# =====================================================================
|
| 319 |
+
|
| 320 |
+
def _sam_prompt_single(img_bgr, point_xy):
|
| 321 |
+
"""Run SAM2 with one point prompt. Returns binary uint8 mask or None."""
|
| 322 |
+
bundle = _get_sam()
|
| 323 |
+
if not bundle:
|
| 324 |
+
return None
|
| 325 |
+
sam = bundle["model"]
|
| 326 |
+
try:
|
| 327 |
+
results = sam(
|
| 328 |
+
img_bgr,
|
| 329 |
+
points=[[float(point_xy[0]), float(point_xy[1])]],
|
| 330 |
+
labels=[1],
|
| 331 |
+
verbose=False,
|
| 332 |
+
)
|
| 333 |
+
if not results:
|
| 334 |
+
return None
|
| 335 |
+
r = results[0]
|
| 336 |
+
if r.masks is None or len(r.masks.data) == 0:
|
| 337 |
+
return None
|
| 338 |
+
masks_np = r.masks.data.cpu().numpy()
|
| 339 |
+
areas = [int((m > 0.5).sum()) for m in masks_np]
|
| 340 |
+
if not areas:
|
| 341 |
+
return None
|
| 342 |
+
best_idx = int(np.argmax(areas))
|
| 343 |
+
return (masks_np[best_idx] > 0.5).astype(np.uint8)
|
| 344 |
+
except Exception as e:
|
| 345 |
+
print(f"[diff_ai] SAM2 prompt error @{point_xy}: {e}")
|
| 346 |
+
return None
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def sam_mask_at_point(img_bgr, point_xy, fallback_bbox=None):
|
| 350 |
+
"""Run SAM2 with a single positive point prompt. Returns the largest
|
| 351 |
+
returned mask as a binary uint8 array, or None on failure.
|
| 352 |
+
|
| 353 |
+
If the centroid prompt produces an empty/noisy mask and fallback_bbox
|
| 354 |
+
is provided, tries additional points across the region and picks the
|
| 355 |
+
largest mask (multi-point fallback for edge cases where the centroid
|
| 356 |
+
lands on background — e.g. ring-shaped change regions)."""
|
| 357 |
+
img_area = img_bgr.shape[0] * img_bgr.shape[1]
|
| 358 |
+
min_mask_area = max(50, int(img_area * SAM_MIN_MASK_AREA_FRAC))
|
| 359 |
+
|
| 360 |
+
mask = _sam_prompt_single(img_bgr, point_xy)
|
| 361 |
+
if mask is not None and int(mask.sum()) > min_mask_area:
|
| 362 |
+
return mask
|
| 363 |
+
|
| 364 |
+
if fallback_bbox is not None:
|
| 365 |
+
x, y, w, h = fallback_bbox
|
| 366 |
+
margin = 0.25
|
| 367 |
+
candidates = [
|
| 368 |
+
(x + w * margin, y + h * margin),
|
| 369 |
+
(x + w * (1.0 - margin), y + h * margin),
|
| 370 |
+
(x + w * margin, y + h * (1.0 - margin)),
|
| 371 |
+
(x + w * (1.0 - margin), y + h * (1.0 - margin)),
|
| 372 |
+
(x + w * 0.5, y + h * 0.5),
|
| 373 |
+
]
|
| 374 |
+
best_mask = None
|
| 375 |
+
best_area = 0
|
| 376 |
+
for px, py in candidates:
|
| 377 |
+
m = _sam_prompt_single(img_bgr, (px, py))
|
| 378 |
+
if m is not None:
|
| 379 |
+
a = int(m.sum())
|
| 380 |
+
if a > best_area:
|
| 381 |
+
best_area = a
|
| 382 |
+
best_mask = m
|
| 383 |
+
if best_mask is not None:
|
| 384 |
+
return best_mask
|
| 385 |
+
|
| 386 |
+
return mask # possibly None, possibly small — caller falls back to bbox
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def bbox_from_mask(mask):
|
| 390 |
+
"""Tight bbox (x, y, w, h) around a binary mask, or None if empty."""
|
| 391 |
+
if mask is None:
|
| 392 |
+
return None
|
| 393 |
+
ys, xs = np.where(mask > 0)
|
| 394 |
+
if len(ys) == 0:
|
| 395 |
+
return None
|
| 396 |
+
x0, x1 = int(xs.min()), int(xs.max())
|
| 397 |
+
y0, y1 = int(ys.min()), int(ys.max())
|
| 398 |
+
return (x0, y0, x1 - x0 + 1, y1 - y0 + 1)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
# =====================================================================
|
| 402 |
+
# STAGE 5 — ADDED vs REMOVED CLASSIFICATION
|
| 403 |
+
# =====================================================================
|
| 404 |
+
|
| 405 |
+
def classify_added_or_removed(patch1_bgr, patch2_bgr,
|
| 406 |
+
edge_threshold=EDGE_VARIANCE_THRESHOLD):
|
| 407 |
+
"""Edge-density heuristic. The image with more edges in this region
|
| 408 |
+
is the one that has the 'object'.
|
| 409 |
+
Returns 'added' (img2 has more), 'removed' (img1 has more),
|
| 410 |
+
or None if both patches have near-zero edge variance (noise)."""
|
| 411 |
+
g1 = cv2.cvtColor(patch1_bgr, cv2.COLOR_BGR2GRAY)
|
| 412 |
+
g2 = cv2.cvtColor(patch2_bgr, cv2.COLOR_BGR2GRAY)
|
| 413 |
+
e1 = float(cv2.Laplacian(g1, cv2.CV_64F).var())
|
| 414 |
+
e2 = float(cv2.Laplacian(g2, cv2.CV_64F).var())
|
| 415 |
+
if e1 < edge_threshold and e2 < edge_threshold:
|
| 416 |
+
return None
|
| 417 |
+
return "added" if e2 >= e1 else "removed"
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
# =====================================================================
|
| 421 |
+
# DRAWING
|
| 422 |
+
# =====================================================================
|
| 423 |
+
|
| 424 |
+
def _draw_box(img, bbox, color, label=None):
|
| 425 |
+
x, y, w, h = bbox
|
| 426 |
+
cv2.rectangle(img, (x, y), (x + w, y + h), color, 3)
|
| 427 |
+
if label:
|
| 428 |
+
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
| 429 |
+
ty = max(th + 8, y)
|
| 430 |
+
cv2.rectangle(img, (x, ty - th - 8), (x + tw + 8, ty), color, -1)
|
| 431 |
+
cv2.putText(img, label, (x + 4, ty - 4),
|
| 432 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def _composite_object(base, object_img, mask, bbox, color):
|
| 436 |
+
"""Paste object pixels from object_img onto a darkened base using mask,
|
| 437 |
+
with a colored outline. If mask is missing/empty (SAM failed or was
|
| 438 |
+
unavailable), fall back to drawing the raw change-region bbox so the
|
| 439 |
+
change is never silently dropped from the output. Modifies base in place."""
|
| 440 |
+
if mask is not None and int(mask.sum()) > 0:
|
| 441 |
+
mask_bin = mask.astype(bool)
|
| 442 |
+
base[mask_bin] = object_img[mask_bin]
|
| 443 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 444 |
+
cv2.drawContours(base, contours, -1, color, 2)
|
| 445 |
+
elif bbox is not None:
|
| 446 |
+
x, y, w, h = bbox
|
| 447 |
+
base[y:y + h, x:x + w] = object_img[y:y + h, x:x + w]
|
| 448 |
+
_draw_box(base, bbox, color)
|
| 449 |
+
return base
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# =====================================================================
|
| 453 |
+
# MAIN ENTRY
|
| 454 |
+
# =====================================================================
|
| 455 |
+
|
| 456 |
+
def compare_images_ai(img1_path, img2_path, output_path, heatmap_path):
|
| 457 |
+
"""End-to-end change-region-driven AI pipeline. Raises RuntimeError
|
| 458 |
+
if any required model is unavailable."""
|
| 459 |
+
if not _get_loftr() or not _get_sam() or not _get_dinov2():
|
| 460 |
+
raise RuntimeError("AI pipeline unavailable — required model missing")
|
| 461 |
+
|
| 462 |
+
img1 = cv2.imread(img1_path)
|
| 463 |
+
img2 = cv2.imread(img2_path)
|
| 464 |
+
if img1 is None or img2 is None:
|
| 465 |
+
raise RuntimeError("Failed to read input images")
|
| 466 |
+
|
| 467 |
+
if img1.shape[:2] != img2.shape[:2]:
|
| 468 |
+
print(f"[diff_ai] warning: input sizes differ {img1.shape[:2]} vs "
|
| 469 |
+
f"{img2.shape[:2]}; resizing img2 to match img1 (may distort)")
|
| 470 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 471 |
+
|
| 472 |
+
h0, w0 = img1.shape[:2]
|
| 473 |
+
if max(h0, w0) > MAX_LONG_EDGE:
|
| 474 |
+
s = MAX_LONG_EDGE / max(h0, w0)
|
| 475 |
+
new_size = (int(w0 * s), int(h0 * s))
|
| 476 |
+
img1 = cv2.resize(img1, new_size, interpolation=cv2.INTER_AREA)
|
| 477 |
+
img2 = cv2.resize(img2, new_size, interpolation=cv2.INTER_AREA)
|
| 478 |
+
|
| 479 |
+
# 1) LoFTR alignment
|
| 480 |
+
aligned = align_loftr(img1, img2)
|
| 481 |
+
if aligned is None:
|
| 482 |
+
raise RuntimeError("LoFTR alignment failed")
|
| 483 |
+
|
| 484 |
+
# 2) Change region extraction (SSIM + CC)
|
| 485 |
+
heatmap, regions, _, alignment_failed = extract_change_regions(img1, aligned)
|
| 486 |
+
if alignment_failed:
|
| 487 |
+
regions = []
|
| 488 |
+
print(f"[diff_ai] {len(regions)} change region(s) before verification")
|
| 489 |
+
|
| 490 |
+
# 3) Batched DINOv2 perceptual gate across all regions at once
|
| 491 |
+
patches1, patches2 = [], []
|
| 492 |
+
for r in regions:
|
| 493 |
+
x, y, w, h = r["bbox"]
|
| 494 |
+
x0 = max(0, x - REGION_PAD); y0 = max(0, y - REGION_PAD)
|
| 495 |
+
x1 = min(img1.shape[1], x + w + REGION_PAD)
|
| 496 |
+
y1 = min(img1.shape[0], y + h + REGION_PAD)
|
| 497 |
+
r["patch_bounds"] = (x0, y0, x1, y1)
|
| 498 |
+
patches1.append(img1[y0:y1, x0:x1])
|
| 499 |
+
patches2.append(aligned[y0:y1, x0:x1])
|
| 500 |
+
|
| 501 |
+
emb1 = dinov2_embed_batch(patches1)
|
| 502 |
+
emb2 = dinov2_embed_batch(patches2)
|
| 503 |
+
|
| 504 |
+
added_objects = []
|
| 505 |
+
removed_objects = []
|
| 506 |
+
|
| 507 |
+
for i, r in enumerate(regions):
|
| 508 |
+
x0, y0, x1, y1 = r["patch_bounds"]
|
| 509 |
+
patch1, patch2 = patches1[i], patches2[i]
|
| 510 |
+
|
| 511 |
+
# 3a) DINOv2 perceptual gate
|
| 512 |
+
sim = None
|
| 513 |
+
if emb1 is not None and emb2 is not None and np.any(emb1[i]) and np.any(emb2[i]):
|
| 514 |
+
sim = float(np.dot(emb1[i], emb2[i]))
|
| 515 |
+
if sim is not None and sim >= DINO_SIM_THRESHOLD:
|
| 516 |
+
print(f" region @{r['centroid']}: DINOv2 sim={sim:.3f} — perceptually same, dropping")
|
| 517 |
+
continue
|
| 518 |
+
|
| 519 |
+
# 3b) Classify as added vs removed via edge density
|
| 520 |
+
label = classify_added_or_removed(patch1, patch2)
|
| 521 |
+
if label is None:
|
| 522 |
+
print(f" region @{r['centroid']}: edge variance too low on both sides, skipping")
|
| 523 |
+
continue
|
| 524 |
+
|
| 525 |
+
# 3c) SAM2 prompted at the centroid for a clean object mask
|
| 526 |
+
target_img = aligned if label == "added" else img1
|
| 527 |
+
mask = sam_mask_at_point(target_img, r["centroid"], r["bbox"])
|
| 528 |
+
tight_bbox = bbox_from_mask(mask) or r["bbox"]
|
| 529 |
+
|
| 530 |
+
entry = {
|
| 531 |
+
"bbox": tight_bbox,
|
| 532 |
+
"centroid": r["centroid"],
|
| 533 |
+
"dino_sim": sim,
|
| 534 |
+
"mask": mask,
|
| 535 |
+
"source": target_img,
|
| 536 |
+
}
|
| 537 |
+
if label == "added":
|
| 538 |
+
added_objects.append(entry)
|
| 539 |
+
else:
|
| 540 |
+
removed_objects.append(entry)
|
| 541 |
+
|
| 542 |
+
print(f" region @{r['centroid']}: sim={sim} -> {label}"
|
| 543 |
+
f"{' (no SAM mask, using bbox fallback)' if mask is None else ''}")
|
| 544 |
+
|
| 545 |
+
# 4) MOVED object detection: match REMOVED <-> ADDED pairs.
|
| 546 |
+
# Embed every candidate patch once, then compare via a similarity matrix
|
| 547 |
+
# instead of one DINOv2 forward pass per (removed, added) pair.
|
| 548 |
+
rem_patches, add_patches = [], []
|
| 549 |
+
for rem in removed_objects:
|
| 550 |
+
rx, ry, rw, rh = rem["bbox"]
|
| 551 |
+
r_x0 = max(0, rx - REGION_PAD); r_y0 = max(0, ry - REGION_PAD)
|
| 552 |
+
r_x1 = min(img1.shape[1], rx + rw + REGION_PAD)
|
| 553 |
+
r_y1 = min(img1.shape[0], ry + rh + REGION_PAD)
|
| 554 |
+
rem_patches.append(img1[r_y0:r_y1, r_x0:r_x1])
|
| 555 |
+
for add in added_objects:
|
| 556 |
+
ax, ay, aw, ah = add["bbox"]
|
| 557 |
+
a_x0 = max(0, ax - REGION_PAD); a_y0 = max(0, ay - REGION_PAD)
|
| 558 |
+
a_x1 = min(aligned.shape[1], ax + aw + REGION_PAD)
|
| 559 |
+
a_y1 = min(aligned.shape[0], ay + ah + REGION_PAD)
|
| 560 |
+
add_patches.append(aligned[a_y0:a_y1, a_x0:a_x1])
|
| 561 |
+
|
| 562 |
+
rem_emb = dinov2_embed_batch(rem_patches) if rem_patches else None
|
| 563 |
+
add_emb = dinov2_embed_batch(add_patches) if add_patches else None
|
| 564 |
+
|
| 565 |
+
moved_objects = []
|
| 566 |
+
surviving_removed = []
|
| 567 |
+
remaining_added = list(range(len(added_objects)))
|
| 568 |
+
|
| 569 |
+
if rem_emb is not None and add_emb is not None and len(remaining_added) > 0:
|
| 570 |
+
sim_matrix = rem_emb @ add_emb.T # (R, A), both rows L2-normalized
|
| 571 |
+
else:
|
| 572 |
+
sim_matrix = None
|
| 573 |
+
|
| 574 |
+
for ri, rem in enumerate(removed_objects):
|
| 575 |
+
rx, ry, rw, rh = rem["bbox"]
|
| 576 |
+
best_sim, best_j = -1.0, None
|
| 577 |
+
if sim_matrix is not None:
|
| 578 |
+
for j in remaining_added:
|
| 579 |
+
add = added_objects[j]
|
| 580 |
+
ax, ay, aw, ah = add["bbox"]
|
| 581 |
+
# Skip if bboxes overlap (different objects at same position)
|
| 582 |
+
if rx < ax + aw and rx + rw > ax and ry < ay + ah and ry + rh > ay:
|
| 583 |
+
continue
|
| 584 |
+
sim = float(sim_matrix[ri, j])
|
| 585 |
+
if sim > best_sim:
|
| 586 |
+
best_sim, best_j = sim, j
|
| 587 |
+
|
| 588 |
+
if best_j is not None and best_sim >= MOVED_SIM_THRESHOLD:
|
| 589 |
+
add = added_objects[best_j]
|
| 590 |
+
moved_objects.append({"from": rem, "to": add, "similarity": best_sim})
|
| 591 |
+
remaining_added.remove(best_j)
|
| 592 |
+
print(f" MOVED: from {rem['centroid']} -> to {add['centroid']} sim={best_sim:.3f}")
|
| 593 |
+
else:
|
| 594 |
+
surviving_removed.append(rem)
|
| 595 |
+
if best_j is not None:
|
| 596 |
+
print(f" REMOVED (unmatched) @{rem['centroid']}: best sim={best_sim:.3f} < {MOVED_SIM_THRESHOLD}")
|
| 597 |
+
else:
|
| 598 |
+
print(f" REMOVED (unmatched) @{rem['centroid']}: no non-overlapping ADDED found")
|
| 599 |
+
|
| 600 |
+
removed_objects = surviving_removed
|
| 601 |
+
added_objects = [added_objects[j] for j in remaining_added]
|
| 602 |
+
|
| 603 |
+
n_moved = len(moved_objects)
|
| 604 |
+
n_added = len(added_objects)
|
| 605 |
+
n_removed = len(removed_objects)
|
| 606 |
+
n_total = n_added + n_removed + n_moved
|
| 607 |
+
severity = "HIGH" if n_total > 0 else "NONE"
|
| 608 |
+
|
| 609 |
+
# 5) Render: dark-tint background + highlight changed objects
|
| 610 |
+
DARK_FACTOR = 0.3
|
| 611 |
+
result_img = cv2.multiply(aligned, np.array([DARK_FACTOR] * 3, dtype=np.float64))
|
| 612 |
+
result_img = np.clip(result_img, 0, 255).astype(np.uint8)
|
| 613 |
+
|
| 614 |
+
for a in added_objects:
|
| 615 |
+
_composite_object(result_img, aligned, a["mask"], a["bbox"], COLOR_ADDED)
|
| 616 |
+
for r in removed_objects:
|
| 617 |
+
_composite_object(result_img, r["source"], r["mask"], r["bbox"], COLOR_REMOVED)
|
| 618 |
+
for m in moved_objects:
|
| 619 |
+
_composite_object(result_img, m["from"]["source"], m["from"]["mask"], m["from"]["bbox"], COLOR_MOVED)
|
| 620 |
+
_composite_object(result_img, aligned, m["to"]["mask"], m["to"]["bbox"], COLOR_MOVED)
|
| 621 |
+
|
| 622 |
+
# Draw severity label in top-left
|
| 623 |
+
label = f"{severity} +{n_added} -{n_removed} ~{n_moved}"
|
| 624 |
+
cv2.putText(result_img, label, (20, 42),
|
| 625 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 255, 255), 5)
|
| 626 |
+
cv2.putText(result_img, label, (20, 42),
|
| 627 |
+
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
|
| 628 |
+
|
| 629 |
+
cv2.imwrite(output_path, result_img)
|
| 630 |
+
cv2.imwrite(heatmap_path, heatmap)
|
| 631 |
+
|
| 632 |
+
return {
|
| 633 |
+
"pipeline": "ai",
|
| 634 |
+
"added": n_added,
|
| 635 |
+
"removed": n_removed,
|
| 636 |
+
"moved": n_moved,
|
| 637 |
+
"object_changes": n_total,
|
| 638 |
+
"severity": severity,
|
| 639 |
+
"alignment_failed": alignment_failed,
|
| 640 |
+
}
|
requirements-render.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
flask-cors
|
| 3 |
+
gunicorn
|
| 4 |
+
bcrypt
|
| 5 |
+
opencv-python-headless
|
| 6 |
+
numpy<2
|
| 7 |
+
scikit-image
|
| 8 |
+
scipy
|
| 9 |
+
python-dotenv
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
flask-cors
|
| 3 |
+
gunicorn
|
| 4 |
+
bcrypt
|
| 5 |
+
opencv-python==4.9.0.80
|
| 6 |
+
numpy<2
|
| 7 |
+
scikit-image
|
| 8 |
+
scipy
|
| 9 |
+
torch
|
| 10 |
+
torchvision
|
| 11 |
+
lpips
|
| 12 |
+
kornia
|
| 13 |
+
ultralytics
|
| 14 |
+
transformers
|
| 15 |
+
python-dotenv
|
| 16 |
+
gradio
|
sam2_t.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:94375f988270836169320bd901960c67b5770e8bef3867d70102f01a8b5ca501
|
| 3 |
+
size 78064050
|
test_describe.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
# Load variables from .env file
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
# Import the describe_change function
|
| 9 |
+
from describe import describe_change
|
| 10 |
+
|
| 11 |
+
def main():
|
| 12 |
+
# Allow specifying filenames as command line arguments or use defaults
|
| 13 |
+
img1 = sys.argv[1] if len(sys.argv) > 1 else "../uploads/img1.jpg"
|
| 14 |
+
img2 = sys.argv[2] if len(sys.argv) > 2 else "../uploads/img2.jpg"
|
| 15 |
+
|
| 16 |
+
print(f"Testing describe_change with:")
|
| 17 |
+
print(f" Before image: {img1}")
|
| 18 |
+
print(f" After image: {img2}")
|
| 19 |
+
|
| 20 |
+
# Check if files exist
|
| 21 |
+
if not os.path.exists(img1):
|
| 22 |
+
print(f"Error: Before image '{img1}' does not exist.")
|
| 23 |
+
print("Please place test images or pass paths: python test_describe.py path/to/before.jpg path/to/after.jpg")
|
| 24 |
+
return
|
| 25 |
+
if not os.path.exists(img2):
|
| 26 |
+
print(f"Error: After image '{img2}' does not exist.")
|
| 27 |
+
print("Please place test images or pass paths: python test_describe.py path/to/before.jpg path/to/after.jpg")
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
# Check for API key or local setup
|
| 31 |
+
groq_api_key = os.environ.get("GROQ_API_KEY")
|
| 32 |
+
if groq_api_key:
|
| 33 |
+
print(f"Using Groq API for generation (Key starts with: {groq_api_key[:10]}...)")
|
| 34 |
+
else:
|
| 35 |
+
print("Using local Ollama fallback (GROQ_API_KEY not set).")
|
| 36 |
+
|
| 37 |
+
# Sample metrics detector hint
|
| 38 |
+
metrics = {
|
| 39 |
+
"added": 1,
|
| 40 |
+
"removed": 0
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
print("\nGenerating description...")
|
| 44 |
+
try:
|
| 45 |
+
desc = describe_change(img1, img2, metrics)
|
| 46 |
+
print("\n--- Result ---")
|
| 47 |
+
if desc:
|
| 48 |
+
print(desc)
|
| 49 |
+
else:
|
| 50 |
+
print("Failed to generate description (returned None).")
|
| 51 |
+
print("--------------")
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"Error running description: {e}")
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
main()
|
users.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"users": {
|
| 3 |
+
"username": "$2b$12$BFqw0a4oqR.Iky53Udd.TuA1TfBHWaIDNM7flIj8E5tJ6vnN4H5ny"
|
| 4 |
+
}
|
| 5 |
+
}
|