harsh-dev commited on
Commit
3ba5b62
·
unverified ·
1 Parent(s): 7339b80

Add application file

Browse files
Files changed (15) hide show
  1. .env.example +12 -0
  2. .gitignore +18 -0
  3. api.py +154 -0
  4. app.py +128 -0
  5. auth.py +87 -0
  6. create_user.py +16 -0
  7. describe.py +168 -0
  8. diff.py +347 -0
  9. diff_ai copy.py +622 -0
  10. diff_ai.py +640 -0
  11. requirements-render.txt +9 -0
  12. requirements.txt +16 -0
  13. sam2_t.pt +3 -0
  14. test_describe.py +56 -0
  15. 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
+ }