SpandanM110 commited on
Commit
05b69f8
·
0 Parent(s):

DocSentry - bank document forensics with 4 tabs

Browse files
Files changed (42) hide show
  1. .gitattributes +6 -0
  2. .gitignore +36 -0
  3. .streamlit/config.toml +13 -0
  4. DATASETS.md +195 -0
  5. DEPLOY.md +133 -0
  6. PUSH.md +90 -0
  7. README.md +103 -0
  8. RUN_APP.md +117 -0
  9. app.py +522 -0
  10. audit_report.py +262 -0
  11. compliance.py +408 -0
  12. docsentry_master.ipynb +1505 -0
  13. forensics.py +542 -0
  14. models/forgery_rf.joblib +3 -0
  15. packages.txt +1 -0
  16. requirements.txt +1 -0
  17. sample_data/originals/agreement_000.png +3 -0
  18. sample_data/originals/agreement_001.png +3 -0
  19. sample_data/originals/agreement_002.png +3 -0
  20. sample_data/originals/agreement_003.png +3 -0
  21. sample_data/originals/land_000.png +3 -0
  22. sample_data/originals/land_001.png +3 -0
  23. sample_data/originals/land_002.png +3 -0
  24. sample_data/originals/land_003.png +3 -0
  25. sample_data/originals/statement_000.png +3 -0
  26. sample_data/originals/statement_001.png +3 -0
  27. sample_data/originals/statement_002.png +3 -0
  28. sample_data/originals/statement_003.png +3 -0
  29. sample_data/pdfs/agreement_000.pdf +3 -0
  30. sample_data/pdfs/agreement_000_tampered.pdf +3 -0
  31. sample_data/tampered/agreement_000_splice.png +3 -0
  32. sample_data/tampered/agreement_001_text_edit.png +3 -0
  33. sample_data/tampered/agreement_003_copy_move.png +3 -0
  34. sample_data/tampered/agreement_005_compression_after_edit.png +3 -0
  35. sample_data/tampered/land_000_splice.png +3 -0
  36. sample_data/tampered/land_001_text_edit.png +3 -0
  37. sample_data/tampered/land_003_compression_after_edit.png +3 -0
  38. sample_data/tampered/land_015_copy_move.png +3 -0
  39. sample_data/tampered/statement_000_copy_move.png +3 -0
  40. sample_data/tampered/statement_001_compression_after_edit.png +3 -0
  41. sample_data/tampered/statement_002_text_edit.png +3 -0
  42. sample_data/tampered/statement_003_splice.png +3 -0
.gitattributes ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
4
+ *.pdf filter=lfs diff=lfs merge=lfs -text
5
+ *.joblib filter=lfs diff=lfs merge=lfs -text
6
+ *.keras filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # venv / Python
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ .pytest_cache/
7
+
8
+ # Notebook execution outputs
9
+ .ipynb_checkpoints/
10
+
11
+ # Logs & big audit dumps
12
+ audit_log.csv
13
+ audit_log.json
14
+ reports/
15
+
16
+ # Heavy data folders - keep sample_data/ for the live demo
17
+ data/
18
+ cheque data/
19
+
20
+ # Trained large models (RF .joblib is OK to commit, CNN .keras is too big)
21
+ models/forgery_cnn.keras
22
+
23
+ # Outputs / temp
24
+ *.tmp
25
+ *.bak
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # IDE
32
+ .vscode/
33
+ .idea/
34
+
35
+ # Archive (the old notebooks we moved out)
36
+ archive/
.streamlit/config.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ primaryColor = "#1e3a8a"
3
+ backgroundColor = "#ffffff"
4
+ secondaryBackgroundColor = "#f1f5f9"
5
+ textColor = "#0f172a"
6
+ font = "sans serif"
7
+
8
+ [server]
9
+ maxUploadSize = 50
10
+ enableXsrfProtection = true
11
+
12
+ [browser]
13
+ gatherUsageStats = false
DATASETS.md ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Datasets for Document Anomaly / Forgery Detection
2
+
3
+ All datasets below are **free**. The only requirement is a free Kaggle account for the Kaggle-hosted ones. The notebook auto-picks up whatever you drop into the right folder — you do not need every dataset to get started.
4
+
5
+ ## Folder layout the notebook expects
6
+
7
+ ```
8
+ data/
9
+ ├── images/
10
+ │ ├── originals/ <-- genuine scans (.png/.jpg)
11
+ │ └── tampered/ <-- forged scans (.png/.jpg)
12
+ ├── pdfs/
13
+ │ ├── originals/ <-- genuine legal PDFs
14
+ │ └── tampered/ <-- forged legal PDFs
15
+ └── statements/ <-- bank statements, ITRs, receipts (any format)
16
+ ```
17
+
18
+ Run `validate_data_layout()` in the notebook to confirm everything is in place.
19
+
20
+ ---
21
+
22
+ ## 1. Image tampering datasets
23
+
24
+ ### CASIA v2 — Gold-standard image tampering benchmark
25
+ The most-used dataset for splicing/copy-move detection research. ~12k images (7k genuine + 5k tampered).
26
+
27
+ - **Source:** https://www.kaggle.com/datasets/divg07/casia-20-image-tampering-detection-dataset
28
+ - **Size:** ~2 GB
29
+ - **Download:**
30
+ ```bash
31
+ kaggle datasets download -d divg07/casia-20-image-tampering-detection-dataset \
32
+ -p data/images --unzip
33
+ ```
34
+ - **After download:** rename `Au/` → `originals/` and `Tp/` → `tampered/`
35
+
36
+ ### MICC-F220 — Classic copy-move benchmark
37
+ 220 images, perfect for testing copy-move detection.
38
+
39
+ - **Source:** http://lci.micc.unifi.it/labd/2015/01/copy-move-forgery-detection-and-localization/
40
+ - **Size:** ~50 MB
41
+ - **Download:** manual (form on the page)
42
+
43
+ ### CoMoFoD — Copy-move with ground-truth masks
44
+ 260 image sets with masks. Ideal for training a CNN with pixel-level supervision.
45
+
46
+ - **Source:** https://www.vcl.fer.hr/comofod/
47
+ - **Size:** ~1 GB
48
+ - **Download:** manual
49
+
50
+ ### Coverage — Genuine + tampered pairs
51
+ 100 pairs with similar-but-genuine objects (toughest case).
52
+
53
+ - **Source:** https://github.com/wenbihan/coverage
54
+ - **Size:** ~600 MB
55
+ - **Download:** `git clone https://github.com/wenbihan/coverage.git`
56
+
57
+ ### Columbia Uncompressed Image Splicing
58
+ 180 spliced + 180 authentic images, lossless.
59
+
60
+ - **Source:** https://www.ee.columbia.edu/ln/dvmm/downloads/AuthSplicedDataSet/AuthSplicedDataSet.htm
61
+ - **Size:** ~1 GB
62
+ - **Download:** manual (registration required, free)
63
+
64
+ ---
65
+
66
+ ## 2. Document / Legal PDF datasets
67
+
68
+ ### Tobacco-3482 — Real scanned legal docs
69
+ 3,482 real-world scanned legal documents (clean baseline of "genuine" docs).
70
+
71
+ - **Source:** https://www.kaggle.com/datasets/patrickaudriaz/tobacco3482jpg
72
+ - **Size:** ~200 MB
73
+ - **Download:**
74
+ ```bash
75
+ kaggle datasets download -d patrickaudriaz/tobacco3482jpg \
76
+ -p data/pdfs/originals --unzip
77
+ ```
78
+
79
+ ### ICDAR Find-It — Document forgery challenge
80
+ Official competition dataset for forged scientific documents.
81
+
82
+ - **Source:** https://findit.univ-lr.fr/
83
+ - **Size:** ~500 MB
84
+ - **Download:** manual (registration required, free)
85
+
86
+ ### DocVQA / RVL-CDIP — Real bank/govt docs
87
+ Massive dataset of real-world business documents.
88
+
89
+ - **Source:** https://www.docvqa.org/datasets and https://www.cs.cmu.edu/~aharley/rvl-cdip/
90
+ - **Size:** ~3 GB / 37 GB
91
+ - **Use case:** populate `originals/` with realistic genuine documents
92
+
93
+ ### FUNSD — Form understanding
94
+ 199 fully-annotated forms (good for layout-anomaly training).
95
+
96
+ - **Source:** https://guillaumejaume.github.io/FUNSD/
97
+ - **Size:** ~50 MB
98
+
99
+ ---
100
+
101
+ ## 3. Financial statements / receipts / cheques
102
+
103
+ ### Receipts Fraud Detection
104
+ 500+ tampered and genuine receipt images.
105
+
106
+ - **Source:** https://www.kaggle.com/datasets/trainingdatapro/receipts-fraud-detection-dataset
107
+ - **Size:** ~100 MB
108
+ - **Download:**
109
+ ```bash
110
+ kaggle datasets download -d trainingdatapro/receipts-fraud-detection-dataset \
111
+ -p data/statements --unzip
112
+ ```
113
+
114
+ ### Bank statements dataset
115
+ Realistic bank statement PDFs and images.
116
+
117
+ - **Source:** https://www.kaggle.com/datasets/dedeikhsandwisaputra/bank-statements-dataset
118
+ - **Size:** ~80 MB
119
+ - **Download:**
120
+ ```bash
121
+ kaggle datasets download -d dedeikhsandwisaputra/bank-statements-dataset \
122
+ -p data/statements --unzip
123
+ ```
124
+
125
+ ### IDRBT / Indian bank cheques
126
+ Cheque images (Indian banking context).
127
+
128
+ - **Source:** https://www.kaggle.com/datasets/arsh1207/bank-cheque-image-dataset
129
+ - **Size:** ~50 MB
130
+ - **Download:**
131
+ ```bash
132
+ kaggle datasets download -d arsh1207/bank-cheque-image-dataset \
133
+ -p data/statements --unzip
134
+ ```
135
+
136
+ ### SROIE — Scanned receipts
137
+ Receipt OCR + key-information extraction challenge.
138
+
139
+ - **Source:** https://rrc.cvc.uab.es/?ch=13
140
+ - **Size:** ~150 MB
141
+
142
+ ---
143
+
144
+ ## 4. Land records (India-specific)
145
+
146
+ There is no large public dataset for Indian land records — you have two practical options:
147
+
148
+ 1. **Synthesise.** The notebook already includes a `make_demo_pair()` function that generates realistic land-record images and tampered copies. You can extend this to produce hundreds of synthetic examples in minutes.
149
+ 2. **Use government open data.** Some state portals publish anonymised RoR (Record of Rights) samples — e.g.:
150
+ - Bhulekh portals (state-wise): https://bhulekh.gov.in/ (varies by state)
151
+ - DigiLocker sample certificates: https://www.digilocker.gov.in/
152
+ 3. **Use Tobacco-3482 or DocVQA as proxy** for general scanned-document forensics — the same forensic signals (ELA, copy-move, font mix) transfer directly.
153
+
154
+ ---
155
+
156
+ ## 5. Kaggle CLI setup (one-time, free)
157
+
158
+ ```bash
159
+ pip install kaggle
160
+ ```
161
+
162
+ 1. Sign up at https://www.kaggle.com
163
+ 2. Open https://www.kaggle.com/settings → **Create New API Token**
164
+ 3. A file `kaggle.json` will download. Place it at:
165
+ - Windows: `C:\Users\<you>\.kaggle\kaggle.json`
166
+ - Linux/Mac: `~/.kaggle/kaggle.json`
167
+ 4. On Linux/Mac: `chmod 600 ~/.kaggle/kaggle.json`
168
+
169
+ After that, all the `kaggle datasets download …` commands above will just work.
170
+
171
+ ---
172
+
173
+ ## 6. Minimum data needed to train
174
+
175
+ The Random Forest in Section 7.5 of the notebook will give meaningful results with:
176
+
177
+ - **~50 genuine + 50 tampered images** — workable baseline
178
+ - **~200 + 200** — good results, ROC-AUC typically 0.85+
179
+ - **~1000 + 1000** (e.g. full CASIA v2) — production-grade results
180
+
181
+ For the optional CNN in Section 7.6, target at least 200 images per class.
182
+
183
+ ---
184
+
185
+ ## 7. Quick-start recipe (fastest path to working demo)
186
+
187
+ 1. `pip install kaggle` and set up the API token (Section 5 above)
188
+ 2. Download CASIA v2:
189
+ ```bash
190
+ kaggle datasets download -d divg07/casia-20-image-tampering-detection-dataset \
191
+ -p data/images --unzip
192
+ ```
193
+ 3. Rename the extracted `Au` → `originals` and `Tp` → `tampered`
194
+ 4. Open `anomaly_detection_banking.ipynb` and run all cells
195
+ 5. Section 7.5 will train automatically on the data you just placed
DEPLOY.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploying DocSentry to Streamlit Community Cloud
2
+
3
+ Free, public URL like `https://docsentry-yourname.streamlit.app`. Judges click a link, see your app. ~10 minutes total.
4
+
5
+ ## What you need
6
+ - A free **GitHub** account (https://github.com)
7
+ - A free **Streamlit Community Cloud** account (https://share.streamlit.io)
8
+ - That's it — no credit card, no Docker, no AWS
9
+
10
+ ## Files already prepared for deployment
11
+
12
+ | File | Purpose |
13
+ |---|---|
14
+ | `requirements.txt` | Python packages |
15
+ | `packages.txt` | System packages (Tesseract OCR) — apt-installed on the cloud VM |
16
+ | `.streamlit/config.toml` | Theme: blue corporate colors |
17
+ | `.gitignore` | Excludes `.venv/`, `data/` (use `sample_data/` instead), checkpoints |
18
+ | `sample_data/` | 26 curated demo files (~21 MB) — small enough for GitHub |
19
+ | `app.py` | Auto-detects sample_data on startup; judges can pick a sample without uploading |
20
+
21
+ ## Step 1 — Create a GitHub repository (3 minutes)
22
+
23
+ 1. Go to https://github.com/new
24
+ 2. Repository name: `docsentry` (or whatever you want)
25
+ 3. Set to **Public** (required for free Streamlit Cloud tier)
26
+ 4. Do NOT initialize with README — we have our own
27
+ 5. Click **Create repository**
28
+ 6. Copy the URL shown — looks like `https://github.com/YourName/docsentry.git`
29
+
30
+ ## Step 2 — Push the project to GitHub
31
+
32
+ Open PowerShell in your project folder:
33
+
34
+ ```powershell
35
+ cd "C:\Users\HP\Desktop\Anomaly Based project"
36
+
37
+ # Install git for Windows if you don't have it: https://git-scm.com/download/win
38
+ git init
39
+ git add .
40
+ git commit -m "Initial commit: DocSentry document forensics demo"
41
+ git branch -M main
42
+ git remote add origin https://github.com/YourName/docsentry.git
43
+ git push -u origin main
44
+ ```
45
+
46
+ If git asks for credentials: use your GitHub username + a **personal access token** (not your password). Make a token at https://github.com/settings/tokens — give it `repo` scope.
47
+
48
+ After this, refresh your GitHub page. You should see all the files: `app.py`, `forensics.py`, `audit_report.py`, `requirements.txt`, `packages.txt`, `sample_data/`, etc.
49
+
50
+ ## Step 3 — Deploy on Streamlit Community Cloud (5 minutes)
51
+
52
+ 1. Go to https://share.streamlit.io
53
+ 2. Sign in with your GitHub account (one click — it's the same login)
54
+ 3. Click **Create app** (top right)
55
+ 4. Pick **Deploy from GitHub**
56
+ 5. Fill in:
57
+ - **Repository**: `YourName/docsentry`
58
+ - **Branch**: `main`
59
+ - **Main file path**: `app.py`
60
+ - **App URL** (custom): `docsentry-yourname` (or whatever's free)
61
+ 6. Click **Deploy**
62
+
63
+ Streamlit Cloud now:
64
+ 1. Reads `packages.txt` → installs Tesseract OCR on the VM
65
+ 2. Reads `requirements.txt` → pip-installs every dependency
66
+ 3. Loads `app.py` → starts the Streamlit server
67
+
68
+ This takes 3-5 minutes the first time. Watch the build log on the right side of the screen.
69
+
70
+ When you see **"Your app is live"**, click **Open app** — that's your public URL.
71
+
72
+ ## Step 4 — Test the live deployment
73
+
74
+ On the live URL:
75
+ 1. **Tab 1**: pick a sample from the dropdown (e.g. `tampered/land_005_copy_move.png`) → should show **HIGH** band + heatmaps
76
+ 2. **Tab 2**: upload two different files from `sample_data/originals/` → consistency check
77
+ 3. **Tab 3**: change folder to `sample_data` → batch audit
78
+
79
+ If anything errors, check the logs via the **Manage app** menu on the live page.
80
+
81
+ ## Step 5 — Share the link
82
+
83
+ You now have a public URL. Drop it in your pitch deck, on your GitHub README, on Devpost. Judges click → 30-second cold start → working demo, no install required.
84
+
85
+ ## Updating the live app
86
+
87
+ Push to GitHub → Streamlit Cloud auto-deploys in ~1 minute. No redeploy button needed.
88
+
89
+ ```powershell
90
+ git add .
91
+ git commit -m "Updated thresholds for higher precision"
92
+ git push
93
+ ```
94
+
95
+ The live URL stays the same.
96
+
97
+ ## Resource limits on the free tier
98
+
99
+ - **1 GB RAM** — enough for all forensic operations on documents up to ~2000x2000 pixels
100
+ - **CPU only** — no GPU. The Random Forest path runs fine; the CNN path (Section 7.6 in the notebook) won't fit here
101
+ - **App sleeps after ~7 days** of zero traffic — first visitor after that waits ~30 seconds for a cold start
102
+ - **Public access** — anyone with the URL can use it. Fine for hackathons. If you need it private, upgrade to Streamlit Teams ($25/mo) or self-host
103
+
104
+ ## Common issues
105
+
106
+ **"ModuleNotFoundError: forensics"**
107
+ Make sure `forensics.py` and `audit_report.py` are at the **repo root** (not in a subfolder). Streamlit Cloud runs `app.py` from the repo root and Python only finds modules in the same folder.
108
+
109
+ **"tesseract: not found" on the live app**
110
+ The `packages.txt` file might not have been committed. Run `git status` and `git add packages.txt`, then `git push`. Verify on GitHub that `packages.txt` exists in the root.
111
+
112
+ **Sample data dropdown is empty on live app**
113
+ The `.gitignore` excludes `data/` but not `sample_data/`. Check on GitHub that the `sample_data/` folder is visible. If it's missing, you might have a leftover `data/` exclusion. Remove `sample_data/` from `.gitignore` if so.
114
+
115
+ **Build times out**
116
+ Free tier build limit is ~15 minutes. If you exceed, trim `requirements.txt` (e.g. remove `seaborn`, `imagehash`, `exifread` if unused). They're nice-to-have but not core.
117
+
118
+ **`models/forgery_rf.joblib` missing**
119
+ Train it locally first (run Section 7.5 in the notebook) then commit + push. The app gracefully falls back to rule-based-only scoring if the model is missing.
120
+
121
+ **Memory error on large uploads**
122
+ Free tier has 1 GB RAM. Tell judges to use PNG/JPG under 5 MB or downscale large scans first. Or upgrade to Teams ($25/mo, 8 GB RAM) for the hackathon.
123
+
124
+ ## Alternative: Hugging Face Spaces (also free)
125
+
126
+ If Streamlit Cloud is slow on your day of demo, the same code deploys to Hugging Face Spaces with no changes:
127
+
128
+ 1. Create a Space at https://huggingface.co/new-space
129
+ 2. Pick **Streamlit** as the SDK
130
+ 3. Push to its git repo using your HF write token
131
+ 4. Done. Public URL like `huggingface.co/spaces/yourname/docsentry`
132
+
133
+ HF Spaces gives you 16 GB RAM free vs Streamlit's 1 GB, but the URL is uglier. Use whichever wins on the day.
PUSH.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Push to GitHub - One-time setup
2
+
3
+ Your repo: https://github.com/SpandanM110/Doc-Sentry.git
4
+
5
+ The `.gitignore` is configured to exclude the 826 MB `cheque data/` folder, the
6
+ ~250 MB `data/` folder, the `.venv`, and other heavy stuff. **Only ~400 KB of
7
+ code, docs, and 26 sample files will be pushed.**
8
+
9
+ ## Step 1 - Open PowerShell in your project folder
10
+
11
+ ```powershell
12
+ cd "C:\Users\HP\Desktop\Anomaly Based project"
13
+ ```
14
+
15
+ ## Step 2 - Initialize git + commit
16
+
17
+ Run these one by one (or paste all at once):
18
+
19
+ ```powershell
20
+ git init -b main
21
+ git config user.email "spandanmukherjeegithub@gmail.com"
22
+ git config user.name "Spandan"
23
+
24
+ git add .
25
+ git status # confirm only ~30 files staged (not the big data/ folders)
26
+ git commit -m "Initial commit: DocSentry - bank document forensics with 4 tabs"
27
+ ```
28
+
29
+ ## Step 3 - Connect to GitHub + push
30
+
31
+ ```powershell
32
+ git remote add origin https://github.com/SpandanM110/Doc-Sentry.git
33
+ git push -u origin main
34
+ ```
35
+
36
+ When prompted for credentials:
37
+ - **Username:** your GitHub username (`SpandanM110`)
38
+ - **Password:** a **Personal Access Token** (NOT your GitHub password)
39
+
40
+ If you don't have a PAT yet:
41
+ 1. Go to https://github.com/settings/tokens
42
+ 2. Click **Generate new token (classic)**
43
+ 3. Give it a name (e.g. "DocSentry repo")
44
+ 4. Tick the **repo** scope
45
+ 5. Click Generate, then **copy the token** (it's only shown once)
46
+ 6. Paste it as the password when git prompts
47
+
48
+ ## Step 4 - Verify
49
+
50
+ Open https://github.com/SpandanM110/Doc-Sentry in your browser.
51
+ You should see all your files: `app.py`, `forensics.py`, `compliance.py`,
52
+ `docsentry_master.ipynb`, the markdown docs, and the `sample_data/` folder.
53
+
54
+ ## Future pushes (after first time)
55
+
56
+ ```powershell
57
+ git add .
58
+ git commit -m "describe what you changed"
59
+ git push
60
+ ```
61
+
62
+ The push uses your cached credentials automatically after the first time.
63
+
64
+ ## If something goes wrong
65
+
66
+ **"Repository already exists" or other init errors**
67
+
68
+ ```powershell
69
+ # delete the .git folder and start over
70
+ rmdir /s /q .git
71
+ git init -b main
72
+ ```
73
+
74
+ **Files too large to push**
75
+
76
+ If you ever accidentally add a large file (> 100 MB):
77
+
78
+ ```powershell
79
+ git rm --cached path/to/big/file
80
+ echo path/to/big/file >> .gitignore
81
+ git add .gitignore
82
+ git commit -m "exclude large file"
83
+ ```
84
+
85
+ **Want to add the cheque data later?**
86
+
87
+ It's 826 MB - GitHub will reject it. Two options:
88
+ 1. Use Git LFS (`git lfs install` + `git lfs track "*.tif"`)
89
+ 2. Document the dataset source in DATASETS.md instead and have users
90
+ download it themselves (recommended)
README.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DocSentry - Document Forensics for Bank Underwriting
2
+
3
+ [![Built with Streamlit](https://img.shields.io/badge/Built%20with-Streamlit-FF4B4B.svg)](https://streamlit.io)
4
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ **Real-time anomaly detection for land records, legal documents, and financial statements during loan underwriting.**
8
+
9
+ > Detects tampering, edits, and forgery attempts in seconds. Generates explainable risk scores and underwriter-ready audit reports. 100% open-source, no paid APIs, no LLM calls. Runs on a laptop.
10
+
11
+ ## :rocket: Live demo
12
+
13
+ **Try it now:** [docsentry-yourname.streamlit.app](https://docsentry-yourname.streamlit.app) *(replace with your actual URL after deploying)*
14
+
15
+ Pick a tampered land record from the dropdown and watch the forensic verdict appear in real time.
16
+
17
+ ## :movie_camera: 3-minute demo flow
18
+
19
+ 1. **Single-document analysis** — drop a PNG/PDF, see risk band (LOW/MEDIUM/HIGH/CRITICAL), heatmap overlays, evidence bullets
20
+ 2. **Cross-document consistency** — upload 2-4 documents for the same applicant; system flags mismatches in name/DOB/address
21
+ 3. **Batch audit** — point at a folder of documents; get a sortable risk table and CSV export
22
+ 4. **Audit PDF** — one-click download of a bank-letterhead PDF report ready for the underwriting file
23
+
24
+ ## :package: What's included
25
+
26
+ | Component | What it does |
27
+ |---|---|
28
+ | `forensics.py` | Core detection engine: ELA, copy-move, noise inconsistency, EXIF audit, PDF structure analysis, OCR + text rules, Random Forest ML model loader, cross-document consistency check |
29
+ | `app.py` | Streamlit web app with 3 tabs and a sample picker for instant cloud demos |
30
+ | `audit_report.py` | ReportLab-based PDF report generator with bank letterhead, risk verdict, evidence, embedded heatmaps |
31
+ | `anomaly_detection_banking.ipynb` | Jupyter notebook walking through every detector + how to train your own classifier |
32
+ | `sample_data/` | 26 demo documents (12 genuine + 12 tampered + 2 PDFs) ready to play with |
33
+
34
+ ## :brain: How it works
35
+
36
+ ```
37
+ Document ─┬─► Image forensics (ELA, copy-move, noise, EXIF)
38
+ ├─► PDF structure (EOF markers, fonts, producer/creator)
39
+ ├─► OCR + text rules (date monotonicity, math, IFSC validation)
40
+ ├─► Trained RF model (learned forgery signal blend)
41
+ └─► Anomaly Scorer ─► Risk band + Insights + Audit JSON
42
+ ```
43
+
44
+ Each detector outputs a sub-score in [0, 1]. A weighted blend produces the final risk score, mapped to one of four bands. The trained Random Forest (when available) is blended 50/50 with the rule-based score. Every flag is explainable — no black-box "trust me, it's fraud."
45
+
46
+ ## :white_check_mark: Detection capabilities
47
+
48
+ - **Copy-move forgery** (duplicate region within image) — ORB keypoint matching
49
+ - **Image splicing** (region pasted from another source) — block-wise noise inconsistency
50
+ - **Text edits / amount tampering** — Error Level Analysis (ELA)
51
+ - **Photoshop / GIMP edits** — EXIF software-tag check
52
+ - **PDF incremental edits** — multi-`%%EOF` marker detection
53
+ - **PDF consumer-tool fingerprints** — iLovePDF, Smallpdf, PDFescape, Sejda producer flags
54
+ - **Inconsistent fonts** — embedded-font count anomaly
55
+ - **Date sequence violations** — monotonic check on extracted dates
56
+ - **Round-number anomalies** — suspicious mega-amounts
57
+ - **Missing IFSC with present account number** — bank-document validity rule
58
+ - **Cross-document identity mismatches** — name/DOB/address fuzzy match across 2+ docs
59
+
60
+ ## :wrench: Run locally
61
+
62
+ ```bash
63
+ git clone https://github.com/YourName/docsentry.git
64
+ cd docsentry
65
+ pip install -r requirements.txt
66
+ streamlit run app.py
67
+ ```
68
+
69
+ Opens at http://localhost:8501. Install Tesseract OCR separately for full text-rule support (https://github.com/UB-Mannheim/tesseract/wiki on Windows).
70
+
71
+ ## :cloud: Deploy your own (free)
72
+
73
+ Push to a public GitHub repo, then deploy on [Streamlit Community Cloud](https://share.streamlit.io). Detailed steps in [DEPLOY.md](DEPLOY.md). Takes ~10 minutes end to end.
74
+
75
+ ## :books: Documentation
76
+
77
+ - [DEPLOY.md](DEPLOY.md) — Streamlit Cloud deployment guide
78
+ - [RUN_APP.md](RUN_APP.md) — Local run guide + demo flow
79
+ - [DATASETS.md](DATASETS.md) — Public datasets (CASIA v2, MICC-F220, CoMoFoD, etc.) with download instructions
80
+ - [COLAB_QUICKSTART.md](COLAB_QUICKSTART.md) — Google Colab usage
81
+
82
+ ## :test_tube: Train your own model
83
+
84
+ Open `anomaly_detection_banking.ipynb`, drop your data into `data/images/originals/` and `data/images/tampered/`, run Section 7.5. The Random Forest auto-trains on whatever you put there. The app picks up the new `models/forgery_rf.joblib` automatically — no code changes.
85
+
86
+ For a CNN upgrade, flip `TRAIN_CNN = True` in Section 7.6 and run on Google Colab with a free T4 GPU.
87
+
88
+ ## :handshake: Tech stack
89
+
90
+ OpenCV - PIL/Pillow - scikit-image - scikit-learn - PyMuPDF - pdfplumber - pikepdf - pytesseract - Streamlit - ReportLab - matplotlib - PyTorch/TensorFlow (optional)
91
+
92
+ Everything pip-installable. No GPU required for the default pipeline.
93
+
94
+ ## :scroll: License
95
+
96
+ MIT. Use it, fork it, ship it. If you build something cool, send a screenshot.
97
+
98
+ ## :pray: Acknowledgements
99
+
100
+ - CASIA v2 image tampering dataset (Chinese Academy of Sciences)
101
+ - MICC-F220 copy-move benchmark (University of Florence)
102
+ - CoMoFoD dataset (University of Zagreb)
103
+ - Tobacco-3482 document corpus (University of Maryland)
RUN_APP.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Running the DocSentry Demo App
2
+
3
+ ## Quick start (3 commands)
4
+
5
+ ```powershell
6
+ # From: C:\Users\HP\Desktop\Anomaly Based project\
7
+
8
+ pip install -r requirements.txt
9
+ streamlit run app.py
10
+ ```
11
+
12
+ That's it. Streamlit will open the app at http://localhost:8501 in your browser.
13
+
14
+ ## Optional: install Tesseract OCR (for full text-rule checks)
15
+
16
+ 1. Download from https://github.com/UB-Mannheim/tesseract/wiki
17
+ 2. Run the `.exe` installer
18
+ 3. Check "Add Tesseract to system PATH" during install
19
+ 4. Restart your terminal, then `streamlit run app.py` again
20
+
21
+ The app works without Tesseract too — only the text/OCR-based checks are skipped.
22
+
23
+ ## What's in the app
24
+
25
+ **Tab 1 — Single-document analysis** *(your primary demo)*
26
+ - Drag-drop a PNG / JPG / PDF
27
+ - See risk band (LOW / MEDIUM / HIGH / CRITICAL) in big colored text
28
+ - Sub-score breakdown bar chart
29
+ - ELA heatmap, copy-move match visualization, noise inconsistency heatmap (image files)
30
+ - Producer / creator / fonts table (PDFs)
31
+ - Trained ML model verdict (if `models/forgery_rf.joblib` exists)
32
+ - Download audit JSON or formatted PDF report
33
+
34
+ **Tab 2 — Cross-document consistency check** *(the novel angle)*
35
+ - Upload 2–4 documents for the same applicant
36
+ - App extracts name, DOB, address, account, IFSC from each
37
+ - Field-by-field comparison table with green/yellow/red status
38
+ - Mismatch detector with similarity scores
39
+ - Download consistency report JSON
40
+
41
+ **Tab 3 — Batch audit**
42
+ - Point at a folder, scan every file in it
43
+ - Get risk band per file as a sortable table
44
+ - Download CSV for the underwriting team
45
+
46
+ ## Demo flow (for the pitch)
47
+
48
+ 1. **Open Tab 1**, drop in a clean `land_000.png` from `data/images/originals/` → "LOW" green band, no evidence
49
+ 2. **Drop in a `land_005_copy_move.png`** from `data/images/tampered/` → "HIGH" orange band, copy-move evidence
50
+ 3. **Click through the heatmap tabs** → judges see real visualizations, not just numbers
51
+ 4. **Click "Download audit PDF"** → a bank-letterhead report renders
52
+ 5. **Switch to Tab 2**, upload 2 different `agreement_*.png` files → "HIGH" mismatch because names differ
53
+ 6. **Switch to Tab 3**, point at `data/` → batch-process 250+ files in a few seconds
54
+
55
+ Total demo time: 3 minutes. Hands the judges something to download.
56
+
57
+ ## Project file layout
58
+
59
+ ```
60
+ C:\Users\HP\Desktop\Anomaly Based project\
61
+ ├── app.py <-- Streamlit UI (this app)
62
+ ├── forensics.py <-- Core detection module
63
+ ├── audit_report.py <-- PDF report generator
64
+ ├── requirements.txt <-- pip dependencies
65
+ ├── RUN_APP.md <-- this file
66
+ ├── DATASETS.md <-- where to get more training data
67
+ ├── COLAB_QUICKSTART.md <-- Colab usage guide
68
+ ├── anomaly_detection_banking.ipynb <-- local Jupyter notebook
69
+ ├── anomaly_detection_banking_COLAB.ipynb <-- Colab notebook
70
+ ├── data/
71
+ │ ├── images/originals/ 130 genuine docs
72
+ │ ├── images/tampered/ 130 tampered docs
73
+ │ ├── pdfs/originals/ 30 PDFs
74
+ │ ├── pdfs/tampered/ 30 tampered PDFs
75
+ │ └── statements/ 60 statements
76
+ └── models/
77
+ └── forgery_rf.joblib (created after running Section 7.5 in the notebook)
78
+ ```
79
+
80
+ ## Common issues
81
+
82
+ **"ModuleNotFoundError: No module named 'forensics'"**
83
+ You're not in the project folder. `cd "C:\Users\HP\Desktop\Anomaly Based project"` first, then `streamlit run app.py`.
84
+
85
+ **"streamlit: command not found"**
86
+ Streamlit didn't install. Re-run `pip install -r requirements.txt`. On Windows, you may need `python -m streamlit run app.py` instead.
87
+
88
+ **The "Download audit PDF" button shows a warning**
89
+ Make sure `reportlab` installed cleanly. Re-run `pip install reportlab`.
90
+
91
+ **Cross-doc tab says "ocr_skipped" for every field**
92
+ You don't have Tesseract installed. The forensic checks still work; only the cross-doc field extraction needs OCR. Install Tesseract (see above) to unlock that tab.
93
+
94
+ **The trained ML model section doesn't appear**
95
+ You haven't run Section 7.5 in the notebook yet. Open `anomaly_detection_banking.ipynb`, run all cells through Section 7.5; that creates `models/forgery_rf.joblib`. The Streamlit app picks it up automatically.
96
+
97
+ ## Architecture sketch
98
+
99
+ ```
100
+ ┌──────────────────────────────────────────────┐
101
+ │ app.py (Streamlit) │
102
+ │ Tab1: Single doc Tab2: Cross-doc │
103
+ │ Tab3: Batch audit Downloads: JSON + PDF │
104
+ └──────────────────┬───────────────────────────┘
105
+ │ imports
106
+ ┌──────────┴──────────┐
107
+ ▼ ▼
108
+ forensics.py audit_report.py
109
+ - ELA / copy-move - ReportLab PDF
110
+ - noise / EXIF - Heatmap embeds
111
+ - PDF audit - Bank letterhead
112
+ - OCR + text rules - Evidence list
113
+ - RF model load
114
+ - Cross-doc check
115
+ ```
116
+
117
+ All three files run on plain Python 3.10+, CPU-only, no paid APIs.
app.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py - Streamlit demo for real-time document anomaly detection
3
+
4
+ Run with:
5
+ streamlit run app.py
6
+
7
+ Tabs:
8
+ 1. Single-document analysis - drag-drop one file, see forensic verdict
9
+ 2. Cross-document check - upload 2+ docs, check identity consistency
10
+ 3. Batch audit - point at a folder, get an audit CSV
11
+ """
12
+
13
+ import io
14
+ import json
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ import streamlit as st
19
+ import numpy as np
20
+ import pandas as pd
21
+ import cv2
22
+ from PIL import Image
23
+ import matplotlib.pyplot as plt
24
+
25
+ import forensics
26
+ import compliance
27
+ from audit_report import build_pdf_report # Sprint 3 module
28
+
29
+ # -------------------------------------------------------------
30
+ # Page config + global CSS
31
+ # -------------------------------------------------------------
32
+ st.set_page_config(page_title="BankShield - Document Forensics",
33
+ page_icon=":lock:", layout="wide")
34
+
35
+ st.markdown("""
36
+ <style>
37
+ .big-risk {font-size: 48px; font-weight: 800; padding: 14px 28px;
38
+ border-radius: 12px; color: white; text-align: center;
39
+ letter-spacing: 1px;}
40
+ .low {background: #16a34a;}
41
+ .medium {background: #ca8a04;}
42
+ .high {background: #ea580c;}
43
+ .critical {background: #dc2626;}
44
+ .metric-card {background: #f8fafc; padding: 14px; border-radius: 8px;
45
+ border-left: 4px solid #2563eb;}
46
+ </style>
47
+ """, unsafe_allow_html=True)
48
+
49
+ # -------------------------------------------------------------
50
+ # Header
51
+ # -------------------------------------------------------------
52
+ st.title(":shield: BankShield - Document Forensics")
53
+ st.caption("Real-time anomaly detection for underwriting. "
54
+ "Land records | Legal documents | Financial statements.")
55
+
56
+ if not forensics.TESSERACT_OK:
57
+ st.warning("Tesseract OCR is not installed - text-rule checks will be skipped. "
58
+ "Install from https://github.com/UB-Mannheim/tesseract/wiki for full functionality.")
59
+
60
+ # -------------------------------------------------------------
61
+ # Helpers
62
+ # -------------------------------------------------------------
63
+ def risk_badge(band_str):
64
+ klass = band_str.lower()
65
+ st.markdown(f"<div class='big-risk {klass}'>{band_str}</div>",
66
+ unsafe_allow_html=True)
67
+
68
+
69
+ def save_uploaded(uploaded_file):
70
+ """Persist an uploaded file to a temp path; return Path."""
71
+ suffix = Path(uploaded_file.name).suffix
72
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
73
+ tmp.write(uploaded_file.getbuffer())
74
+ tmp.close()
75
+ return Path(tmp.name)
76
+
77
+
78
+ def sub_score_chart(sub_scores):
79
+ fig, ax = plt.subplots(figsize=(7, 3.2))
80
+ keys = list(sub_scores.keys())
81
+ vals = list(sub_scores.values())
82
+ colours = ["#16a34a" if v < 0.4 else "#ea580c" if v < 0.7 else "#dc2626"
83
+ for v in vals]
84
+ ax.barh(keys, vals, color=colours)
85
+ ax.set_xlim(0, 1)
86
+ ax.set_xlabel("score (0 = clean, 1 = suspicious)")
87
+ ax.set_title("Sub-score breakdown")
88
+ ax.invert_yaxis()
89
+ plt.tight_layout()
90
+ return fig
91
+
92
+
93
+ # -------------------------------------------------------------
94
+ # TABS
95
+ # -------------------------------------------------------------
96
+ tab1, tab2, tab3, tab4 = st.tabs([
97
+ ":mag: Single-document analysis",
98
+ ":busts_in_silhouette: Cross-document check",
99
+ ":file_folder: Batch audit",
100
+ ":scales: Compliance & Audit Pack",
101
+ ])
102
+
103
+ # =============================================================
104
+ # TAB 1 - Single document
105
+ # =============================================================
106
+ with tab1:
107
+ # Sample picker: lets cloud demos work without an upload
108
+ sample_dir = Path("sample_data")
109
+ sample_paths = []
110
+ if sample_dir.exists():
111
+ for sub in ("originals", "tampered", "pdfs"):
112
+ sample_paths.extend(sorted((sample_dir / sub).glob("*")))
113
+ sample_options = ["(upload your own)"] + [str(p.relative_to(sample_dir)) for p in sample_paths]
114
+
115
+ pick = st.selectbox("Try a sample document, or upload your own:", sample_options, key="sample_pick")
116
+
117
+ path = None
118
+ if pick != "(upload your own)":
119
+ path = sample_dir / pick
120
+ st.caption(f"Loaded sample: `{pick}`")
121
+ else:
122
+ uploaded = st.file_uploader(
123
+ "Upload a document (PNG / JPG / PDF)",
124
+ type=["png", "jpg", "jpeg", "pdf", "tif", "tiff"],
125
+ key="single",
126
+ )
127
+ if uploaded:
128
+ path = save_uploaded(uploaded)
129
+
130
+ if path is not None:
131
+ with st.spinner("Analyzing forensic signals..."):
132
+ report = forensics.analyse_document(path)
133
+
134
+ # --- top row: risk badge + action ---
135
+ c1, c2 = st.columns([1, 2])
136
+ with c1:
137
+ risk_badge(report["risk_band"])
138
+ st.metric("Risk score", f"{report['risk_score']:.3f}")
139
+ with c2:
140
+ st.markdown("**Recommended action**")
141
+ st.info(report["recommended_action"])
142
+ st.markdown("**Evidence**")
143
+ for e in report["evidence"]:
144
+ st.markdown(f"- {e}")
145
+
146
+ st.divider()
147
+
148
+ # --- detail row: image preview + sub-scores ---
149
+ left, right = st.columns([1, 1])
150
+ with left:
151
+ st.markdown("#### Document preview")
152
+ if report["type"] == "image":
153
+ st.image(str(path), use_container_width=True)
154
+ elif report["type"] == "pdf":
155
+ import fitz
156
+ with fitz.open(path) as d:
157
+ pix = d[0].get_pixmap(dpi=110)
158
+ img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
159
+ st.image(img, use_container_width=True)
160
+
161
+ with right:
162
+ st.markdown("#### Sub-score breakdown")
163
+ sub = report.get("sub_scores")
164
+ if sub:
165
+ st.pyplot(sub_score_chart(sub))
166
+
167
+ # --- forensic visualizations (images only) ---
168
+ if report["type"] == "image":
169
+ st.divider()
170
+ st.markdown("#### Forensic visualizations")
171
+ tabs_viz = st.tabs(["Error Level Analysis", "Copy-move matches", "Noise heatmap"])
172
+ with tabs_viz[0]:
173
+ ela_img, ela_s = forensics.error_level_analysis(path)
174
+ st.image(ela_img, caption=f"ELA score: {ela_s:.2f}",
175
+ use_container_width=True)
176
+ st.caption("Bright regions = compression-inconsistent areas (likely edited).")
177
+ with tabs_viz[1]:
178
+ viz, n, _ = forensics.copy_move_detect(path)
179
+ st.image(cv2.cvtColor(viz, cv2.COLOR_BGR2RGB),
180
+ caption=f"Copy-move matches: {n}",
181
+ use_container_width=True)
182
+ st.caption("Red lines connect duplicated regions within the image.")
183
+ with tabs_viz[2]:
184
+ heat, ratio = forensics.noise_inconsistency(path)
185
+ fig, ax = plt.subplots(figsize=(6, 4))
186
+ ax.imshow(heat, cmap="hot")
187
+ ax.set_title(f"Noise outlier ratio: {ratio:.2%}")
188
+ ax.axis("off")
189
+ st.pyplot(fig)
190
+ st.caption("Hotspots = local noise inconsistencies (splicing signal).")
191
+
192
+ # --- PDF-specific audit details ---
193
+ if report["type"] == "pdf":
194
+ st.divider()
195
+ st.markdown("#### PDF structural audit")
196
+ audit = report.get("pdf_audit", {})
197
+ fonts = report.get("font_audit", {})
198
+ cc1, cc2 = st.columns(2)
199
+ with cc1:
200
+ st.metric("EOF markers", audit.get("eof_markers", "-"))
201
+ st.metric("Pages", audit.get("pages", "-"))
202
+ st.markdown("**Metadata flags:**")
203
+ for f in audit.get("flags", []):
204
+ st.markdown(f"- {f}")
205
+ with cc2:
206
+ meta = audit.get("metadata", {}) or {}
207
+ st.markdown("**Producer:** " + str(meta.get("producer", "-")))
208
+ st.markdown("**Creator:** " + str(meta.get("creator", "-")))
209
+ st.markdown("**Fonts used:** " + ", ".join(fonts.get("fonts", []) or ["-"]))
210
+ st.markdown("**Font flags:**")
211
+ for f in fonts.get("flags", []):
212
+ st.markdown(f"- {f}")
213
+
214
+ # --- ML predictions (RF + CNN side-by-side if available) ---
215
+ has_rf = "ml_prediction" in report
216
+ has_cnn = "cnn_prediction" in report
217
+ if has_rf or has_cnn:
218
+ st.divider()
219
+ st.markdown("#### Trained model verdicts")
220
+ cols = st.columns(2 if (has_rf and has_cnn) else 1)
221
+ ci = 0
222
+ if has_rf:
223
+ ml = report["ml_prediction"]
224
+ with cols[ci]:
225
+ st.markdown("**Random Forest** (forensic features)")
226
+ cc1, cc2 = st.columns(2)
227
+ cc1.metric("Tamper probability", f"{ml['tamper_probability']:.1%}")
228
+ cc2.metric("Verdict", ml["verdict"])
229
+ ci += 1
230
+ if has_cnn:
231
+ cnn = report["cnn_prediction"]
232
+ with cols[ci]:
233
+ st.markdown("**CNN** (MobileNetV2 on CASIA v2)")
234
+ cc1, cc2 = st.columns(2)
235
+ cc1.metric("Tamper probability", f"{cnn['tamper_probability']:.1%}")
236
+ cc2.metric("Verdict", cnn["verdict"])
237
+ if cnn.get("val_auc"):
238
+ st.caption(f"Model val ROC-AUC: {cnn['val_auc']:.3f}")
239
+
240
+ # --- downloads ---
241
+ st.divider()
242
+ dl1, dl2 = st.columns(2)
243
+ with dl1:
244
+ st.download_button(
245
+ "Download audit JSON",
246
+ data=json.dumps(report, indent=2, default=str),
247
+ file_name=f"audit_{path.stem}.json",
248
+ mime="application/json",
249
+ )
250
+ with dl2:
251
+ try:
252
+ pdf_bytes = build_pdf_report(report, path)
253
+ st.download_button(
254
+ "Download audit PDF report",
255
+ data=pdf_bytes,
256
+ file_name=f"audit_report_{path.stem}.pdf",
257
+ mime="application/pdf",
258
+ )
259
+ except Exception as e:
260
+ st.warning(f"PDF report generation skipped: {e}")
261
+
262
+ # =============================================================
263
+ # TAB 2 - Cross-document consistency
264
+ # =============================================================
265
+ with tab2:
266
+ st.markdown("Upload 2 or more documents for the **same applicant** "
267
+ "(e.g. land record + bank statement + ID). The system will "
268
+ "extract identity fields and flag any mismatches.")
269
+
270
+ uploads = st.file_uploader(
271
+ "Upload 2-4 documents",
272
+ type=["png", "jpg", "jpeg", "pdf"],
273
+ accept_multiple_files=True,
274
+ key="multi",
275
+ )
276
+
277
+ if uploads and len(uploads) >= 2:
278
+ paths = [save_uploaded(u) for u in uploads]
279
+ with st.spinner("Extracting identity fields from each document..."):
280
+ result = forensics.cross_doc_consistency(paths)
281
+
282
+ # --- header ---
283
+ c1, c2 = st.columns([1, 2])
284
+ with c1:
285
+ risk_badge(result["consistency_band"])
286
+ st.metric("Consistency risk", f"{result['consistency_risk_score']:.3f}")
287
+ with c2:
288
+ st.metric("Mismatches", result["mismatches"])
289
+ st.metric("Likely mismatches", result["likely_mismatches"])
290
+
291
+ st.divider()
292
+ st.markdown("#### Field-by-field comparison")
293
+
294
+ # Build a comparison table
295
+ field_rows = []
296
+ files = [Path(p).name for p in paths]
297
+ for field, res in result["field_results"].items():
298
+ row = {"Field": field, "Status": res["status"],
299
+ "Similarity": res.get("similarity")}
300
+ for fn, val in zip(files, res["values"]):
301
+ row[fn] = val or "(not found)"
302
+ field_rows.append(row)
303
+ df = pd.DataFrame(field_rows)
304
+
305
+ def colour_status(val):
306
+ if val == "match": return "background-color: #dcfce7"
307
+ if val == "likely_match": return "background-color: #fef3c7"
308
+ if val == "mismatch": return "background-color: #fecaca"
309
+ return ""
310
+ st.dataframe(df.style.applymap(colour_status, subset=["Status"]),
311
+ use_container_width=True)
312
+
313
+ st.divider()
314
+ st.markdown("#### Per-document extracts")
315
+ for doc in result["documents"]:
316
+ with st.expander(Path(doc["file"]).name):
317
+ st.json(doc["fields"])
318
+
319
+ st.download_button(
320
+ "Download consistency report JSON",
321
+ data=json.dumps(result, indent=2, default=str),
322
+ file_name="cross_doc_consistency.json",
323
+ mime="application/json",
324
+ )
325
+ elif uploads:
326
+ st.info("Upload at least 2 documents to run the cross-check.")
327
+
328
+ # =============================================================
329
+ # TAB 3 - Batch audit
330
+ # =============================================================
331
+ with tab3:
332
+ st.markdown("Point at a folder on your machine to run a batch audit. "
333
+ "Produces a CSV with risk band per file.")
334
+ default = Path.cwd() / ("sample_data" if not (Path.cwd() / "data").exists() else "data")
335
+ folder = st.text_input("Folder path", value=str(default))
336
+ if st.button("Run batch audit"):
337
+ root = Path(folder)
338
+ if not root.exists():
339
+ st.error(f"Folder not found: {root}")
340
+ else:
341
+ results = []
342
+ files = [p for p in root.rglob("*")
343
+ if p.suffix.lower() in {".png", ".jpg", ".jpeg", ".pdf", ".tif"}]
344
+ if not files:
345
+ st.warning("No supported files found in folder.")
346
+ else:
347
+ progress = st.progress(0.0)
348
+ for i, p in enumerate(files):
349
+ try:
350
+ r = forensics.analyse_document(p)
351
+ results.append({
352
+ "file": str(p.relative_to(root)),
353
+ "type": r.get("type"),
354
+ "risk_score": r.get("risk_score"),
355
+ "risk_band": r.get("risk_band"),
356
+ "action": r.get("recommended_action"),
357
+ })
358
+ except Exception as e:
359
+ results.append({"file": str(p), "error": str(e)})
360
+ progress.progress((i + 1) / len(files))
361
+ df = pd.DataFrame(results)
362
+ st.success(f"Analysed {len(files)} files.")
363
+ st.dataframe(df, use_container_width=True)
364
+ csv = df.to_csv(index=False).encode("utf-8")
365
+ st.download_button("Download audit CSV", data=csv,
366
+ file_name="audit_log.csv", mime="text/csv")
367
+
368
+ # =============================================================
369
+ # TAB 4 - Compliance & Audit Pack (KYC + PII redaction + RBI report)
370
+ # =============================================================
371
+ with tab4:
372
+ st.markdown("**Three regulatory tools in one tab** - KYC field validation, "
373
+ "PII auto-redaction, and RBI-style compliance reports.")
374
+ sub_a, sub_b, sub_c = st.tabs([
375
+ ":id: KYC Field Validation",
376
+ ":lock: PII Auto-Redaction",
377
+ ":scroll: RBI Compliance Report",
378
+ ])
379
+
380
+ # -------- 4A: KYC validators (manual input) --------
381
+ with sub_a:
382
+ st.markdown("#### Validate KYC fields against RBI rules")
383
+ st.caption("IFSC: format + RBI bank-code list | PAN: format + entity-type "
384
+ "char | Aadhaar: 12-digit + UIDAI Verhoeff checksum.")
385
+
386
+ c1, c2, c3 = st.columns(3)
387
+ ifsc_in = c1.text_input("IFSC code", value="SBIN0001234")
388
+ pan_in = c2.text_input("PAN", value="ABCPQ1234F")
389
+ aad_in = c3.text_input("Aadhaar number (12 digits)", value="234567890124")
390
+
391
+ if st.button("Validate all", key="kyc_validate"):
392
+ r_ifsc = compliance.validate_ifsc(ifsc_in)
393
+ r_pan = compliance.validate_pan(pan_in)
394
+ r_aad = compliance.validate_aadhaar(aad_in)
395
+ for label, r in [("IFSC", r_ifsc), ("PAN", r_pan), ("Aadhaar", r_aad)]:
396
+ if r["ok"]:
397
+ st.success("**" + label + "**: VALID. " + " ".join(r["flags"]))
398
+ if label == "IFSC":
399
+ bn = r.get("bank_name", "-")
400
+ bc = r.get("branch_code", "-")
401
+ st.caption("Bank: " + bn + ", branch code: " + bc)
402
+ if label == "PAN":
403
+ et = r.get("entity_type", "-")
404
+ st.caption("Entity type: " + et)
405
+ if label == "Aadhaar":
406
+ mk = r.get("masked", "-")
407
+ st.caption("Masked: " + mk)
408
+ else:
409
+ st.error("**" + label + "**: INVALID. " + " | ".join(r["flags"]))
410
+
411
+ st.divider()
412
+ st.markdown("#### Or: extract & validate from a document")
413
+ kyc_file = st.file_uploader("Upload doc to scan for KYC fields",
414
+ type=["pdf", "png", "jpg"], key="kyc_doc")
415
+ if kyc_file:
416
+ kyc_path = save_uploaded(kyc_file)
417
+ with st.spinner("Extracting KYC fields..."):
418
+ fields, _ = compliance.extract_pii_fields(kyc_path)
419
+ n_ifsc = len(fields["ifsc"])
420
+ n_pan = len(fields["pan"])
421
+ n_aad = len(fields["aadhaar"])
422
+ n_acc = len(fields["accounts"])
423
+ st.markdown("**Found in document:** " + str(n_ifsc) + " IFSC, " +
424
+ str(n_pan) + " PAN, " + str(n_aad) + " Aadhaar candidates, " +
425
+ str(n_acc) + " account numbers")
426
+ # Validate unique IFSCs (first 5)
427
+ uniq_ifsc = list(set(fields["ifsc"]))[:5]
428
+ if uniq_ifsc:
429
+ st.markdown("**IFSC validation (first 5 unique):**")
430
+ rows = [compliance.validate_ifsc(c) for c in uniq_ifsc]
431
+ st.dataframe(pd.DataFrame(rows), use_container_width=True)
432
+ if fields["pan"]:
433
+ st.markdown("**PAN validation:**")
434
+ rows = [compliance.validate_pan(c) for c in fields["pan"][:5]]
435
+ st.dataframe(pd.DataFrame(rows), use_container_width=True)
436
+
437
+ # -------- 4B: PII redaction --------
438
+ with sub_b:
439
+ st.markdown("#### Auto-redact PII for safe sharing")
440
+ st.caption("Masks IFSC, PAN, Aadhaar, and account numbers. Use before "
441
+ "forwarding to external vendors / for DPDP Act compliance.")
442
+ rd_file = st.file_uploader("Upload document to redact",
443
+ type=["pdf", "png", "jpg"], key="rd")
444
+ if rd_file:
445
+ src_path = save_uploaded(rd_file)
446
+ if str(src_path).lower().endswith(".pdf"):
447
+ out_path = Path(tempfile.gettempdir()) / f"redacted_{src_path.stem}.pdf"
448
+ with st.spinner("Redacting PDF..."):
449
+ found = compliance.redact_pdf(str(src_path), str(out_path))
450
+ total = sum(len(v) for v in found.values())
451
+ st.success("Redacted " + str(total) + " PII items.")
452
+ summary = {k: len(v) for k, v in found.items()}
453
+ st.json(summary)
454
+ with open(out_path, "rb") as f:
455
+ st.download_button("Download redacted PDF", f.read(),
456
+ file_name=out_path.name, mime="application/pdf")
457
+ else:
458
+ # image - just OCR + redact text
459
+ fields, text = compliance.extract_pii_fields(src_path)
460
+ red_text, _ = compliance.redact_text(text)
461
+ st.markdown("**Original (OCR):**")
462
+ st.code(text[:600], language=None)
463
+ st.markdown("**Redacted:**")
464
+ st.code(red_text[:600], language=None)
465
+ st.download_button("Download redacted text", red_text,
466
+ file_name=f"redacted_{src_path.stem}.txt")
467
+
468
+ # -------- 4C: RBI compliance report --------
469
+ with sub_c:
470
+ st.markdown("#### Generate an RBI Master-Direction-style audit PDF")
471
+ st.caption("Runs full forensic analysis + KYC verification + RBI risk-treatment "
472
+ "recommendation, then produces a regulator-ready PDF.")
473
+ cr_file = st.file_uploader("Upload document for compliance audit",
474
+ type=["pdf", "png", "jpg"], key="cr")
475
+ if cr_file:
476
+ src_path = save_uploaded(cr_file)
477
+ with st.spinner("Running forensic analysis..."):
478
+ f_report = forensics.analyse_document(src_path)
479
+ with st.spinner("Validating KYC fields..."):
480
+ fields, _ = compliance.extract_pii_fields(src_path)
481
+ kyc_results = {}
482
+ if fields["ifsc"]:
483
+ kyc_results["ifsc"] = compliance.validate_ifsc(fields["ifsc"][0])
484
+ if fields["pan"]:
485
+ kyc_results["pan"] = compliance.validate_pan(fields["pan"][0])
486
+ if fields["aadhaar"]:
487
+ kyc_results["aadhaar"] = compliance.validate_aadhaar(fields["aadhaar"][0])
488
+
489
+ # Summary cards
490
+ cc1, cc2, cc3 = st.columns(3)
491
+ cc1.metric("Forensic risk", f_report.get("risk_band", "-"))
492
+ cc2.metric("KYC fields found",
493
+ sum(len(fields[k]) for k in ("ifsc", "pan", "aadhaar")))
494
+ cc3.metric("KYC checks passed",
495
+ sum(1 for r in kyc_results.values() if r.get("ok")))
496
+
497
+ # KYC table
498
+ if kyc_results:
499
+ rows = [{"Field": k.upper(), "Value": r.get("code", "-"),
500
+ "Status": "PASS" if r.get("ok") else "FAIL",
501
+ "Notes": "; ".join(r.get("flags", []))[:60]}
502
+ for k, r in kyc_results.items()]
503
+ st.dataframe(pd.DataFrame(rows), use_container_width=True)
504
+ else:
505
+ st.info("No KYC fields found in this document to validate.")
506
+
507
+ # Generate the report
508
+ with st.spinner("Building RBI compliance PDF..."):
509
+ pdf_bytes = compliance.build_compliance_report(
510
+ f_report, src_path, kyc_results)
511
+ st.success(f"Generated compliance audit ({len(pdf_bytes)//1024} KB)")
512
+ st.download_button("Download RBI Compliance Report (PDF)", pdf_bytes,
513
+ file_name=f"compliance_{src_path.stem}.pdf",
514
+ mime="application/pdf")
515
+
516
+
517
+ # -------------------------------------------------------------
518
+ # Footer
519
+ # -------------------------------------------------------------
520
+ st.divider()
521
+ st.caption("BankShield prototype - rule-based + trainable RF model - "
522
+ "100% open source, runs locally.")
audit_report.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ audit_report.py - generate a bank-letterhead-styled PDF audit report.
3
+
4
+ Used by app.py to produce the 'Download audit PDF' button.
5
+ Returns bytes so it can be streamed directly to the user without disk writes.
6
+ """
7
+
8
+ import io
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ from reportlab.lib.pagesizes import A4
13
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
14
+ from reportlab.lib.units import cm
15
+ from reportlab.lib import colors
16
+ from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,
17
+ TableStyle, Image as RLImage, PageBreak)
18
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT
19
+ import cv2
20
+ import numpy as np
21
+ from PIL import Image as PILImage
22
+
23
+ import forensics
24
+
25
+
26
+ BAND_COLOURS = {
27
+ "LOW": colors.HexColor("#16a34a"),
28
+ "MEDIUM": colors.HexColor("#ca8a04"),
29
+ "HIGH": colors.HexColor("#ea580c"),
30
+ "CRITICAL": colors.HexColor("#dc2626"),
31
+ }
32
+
33
+
34
+ def _styles():
35
+ s = getSampleStyleSheet()
36
+ s.add(ParagraphStyle(name="LetterheadBank", parent=s["Title"],
37
+ fontSize=22, textColor=colors.HexColor("#1e3a8a"),
38
+ spaceAfter=4))
39
+ s.add(ParagraphStyle(name="LetterheadSub", parent=s["Normal"],
40
+ fontSize=10, textColor=colors.grey,
41
+ spaceAfter=12, alignment=TA_CENTER))
42
+ s.add(ParagraphStyle(name="SectionH", parent=s["Heading2"],
43
+ fontSize=13, textColor=colors.HexColor("#1e3a8a"),
44
+ spaceAfter=6, spaceBefore=12))
45
+ s.add(ParagraphStyle(name="Evidence", parent=s["Normal"],
46
+ fontSize=10, leftIndent=12, bulletIndent=2,
47
+ spaceAfter=2))
48
+ s.add(ParagraphStyle(name="Mono", parent=s["Normal"],
49
+ fontName="Courier", fontSize=8,
50
+ textColor=colors.dimgray))
51
+ return s
52
+
53
+
54
+ def _pil_to_flowable(pil_img, max_width=15 * cm, max_height=8 * cm):
55
+ """Convert a PIL image to a sized RL Image flowable."""
56
+ buf = io.BytesIO()
57
+ pil_img.convert("RGB").save(buf, "PNG")
58
+ buf.seek(0)
59
+ img = RLImage(buf)
60
+ # scale preserving aspect ratio
61
+ iw, ih = pil_img.size
62
+ ratio = min(max_width / iw, max_height / ih)
63
+ img.drawWidth = iw * ratio
64
+ img.drawHeight = ih * ratio
65
+ return img
66
+
67
+
68
+ def _make_heatmap_image(heat, cmap="hot"):
69
+ import matplotlib.pyplot as plt
70
+ fig, ax = plt.subplots(figsize=(5, 3))
71
+ ax.imshow(heat, cmap=cmap)
72
+ ax.axis("off")
73
+ buf = io.BytesIO()
74
+ fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
75
+ plt.close(fig)
76
+ buf.seek(0)
77
+ return PILImage.open(buf)
78
+
79
+
80
+ def build_pdf_report(report, source_path):
81
+ """
82
+ report: the dict returned by forensics.analyse_document(...)
83
+ source_path: Path to the original document being analysed
84
+ Returns: bytes of the rendered PDF
85
+ """
86
+ source_path = Path(source_path)
87
+ s = _styles()
88
+ buf = io.BytesIO()
89
+ doc = SimpleDocTemplate(buf, pagesize=A4,
90
+ leftMargin=2 * cm, rightMargin=2 * cm,
91
+ topMargin=1.5 * cm, bottomMargin=1.5 * cm)
92
+ story = []
93
+
94
+ # ---- Letterhead ----
95
+ story.append(Paragraph("DOCSENTRY - DOCUMENT FORENSICS REPORT",
96
+ s["LetterheadBank"]))
97
+ story.append(Paragraph("Confidential - For Underwriting Use Only",
98
+ s["LetterheadSub"]))
99
+
100
+ # ---- Document metadata table ----
101
+ meta_data = [
102
+ ["Field", "Value"],
103
+ ["Document", source_path.name],
104
+ ["Type", report.get("type", "-")],
105
+ ["Analysed at", report.get("analysed_at", "-")[:19].replace("T", " ")],
106
+ ["SHA-256", report.get("sha256", "-")[:32] + "..."],
107
+ ]
108
+ t = Table(meta_data, colWidths=[4 * cm, 13 * cm])
109
+ t.setStyle(TableStyle([
110
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a8a")),
111
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
112
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
113
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
114
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
115
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1),
116
+ [colors.HexColor("#f8fafc"), colors.white]),
117
+ ]))
118
+ story.append(t)
119
+ story.append(Spacer(1, 0.6 * cm))
120
+
121
+ # ---- Risk verdict ----
122
+ band_str = report.get("risk_band", "UNKNOWN")
123
+ band_colour = BAND_COLOURS.get(band_str, colors.grey)
124
+ verdict_table = Table([
125
+ [Paragraph(f"<para alignment='center'><font size=22 color='white'>"
126
+ f"<b>{band_str}</b></font></para>", s["Normal"]),
127
+ Paragraph(f"<b>Risk score:</b> {report.get('risk_score', '-')}<br/>"
128
+ f"<b>Action:</b> {report.get('recommended_action', '-')}",
129
+ s["Normal"])],
130
+ ], colWidths=[5 * cm, 12 * cm])
131
+ verdict_table.setStyle(TableStyle([
132
+ ("BACKGROUND", (0, 0), (0, 0), band_colour),
133
+ ("BACKGROUND", (1, 0), (1, 0), colors.HexColor("#f1f5f9")),
134
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
135
+ ("BOX", (0, 0), (-1, -1), 0.4, colors.grey),
136
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
137
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
138
+ ("TOPPADDING", (0, 0), (-1, -1), 12),
139
+ ("BOTTOMPADDING",(0, 0), (-1, -1), 12),
140
+ ]))
141
+ story.append(verdict_table)
142
+ story.append(Spacer(1, 0.4 * cm))
143
+
144
+ # ---- Sub-score breakdown ----
145
+ story.append(Paragraph("Sub-score breakdown", s["SectionH"]))
146
+ sub_rows = [["Detector", "Score (0=clean, 1=suspicious)"]]
147
+ for k, v in (report.get("sub_scores") or {}).items():
148
+ bar = "#" * int(v * 30)
149
+ sub_rows.append([k, f"{v:.2f} {bar}"])
150
+ if len(sub_rows) > 1:
151
+ sub_t = Table(sub_rows, colWidths=[5 * cm, 12 * cm])
152
+ sub_t.setStyle(TableStyle([
153
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1e3a8a")),
154
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
155
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
156
+ ("FONTNAME", (1, 1), (1, -1), "Courier"),
157
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
158
+ ]))
159
+ story.append(sub_t)
160
+ story.append(Spacer(1, 0.4 * cm))
161
+
162
+ # ---- Evidence ----
163
+ story.append(Paragraph("Forensic evidence", s["SectionH"]))
164
+ for ev in report.get("evidence", []):
165
+ story.append(Paragraph(f"&bull; {ev}", s["Evidence"]))
166
+ story.append(Spacer(1, 0.3 * cm))
167
+
168
+ # ---- Image-specific: heatmaps ----
169
+ if report.get("type") == "image":
170
+ try:
171
+ story.append(PageBreak())
172
+ story.append(Paragraph("Forensic visualizations", s["SectionH"]))
173
+
174
+ # ELA
175
+ ela_img, ela_s = forensics.error_level_analysis(source_path)
176
+ story.append(Paragraph(f"<b>Error Level Analysis</b> (score: {ela_s:.2f})",
177
+ s["Normal"]))
178
+ story.append(_pil_to_flowable(ela_img))
179
+ story.append(Spacer(1, 0.3 * cm))
180
+
181
+ # Copy-move
182
+ viz, n_cm, _ = forensics.copy_move_detect(source_path)
183
+ if viz is not None:
184
+ story.append(Paragraph(f"<b>Copy-move matches:</b> {n_cm}",
185
+ s["Normal"]))
186
+ viz_rgb = cv2.cvtColor(viz, cv2.COLOR_BGR2RGB)
187
+ story.append(_pil_to_flowable(PILImage.fromarray(viz_rgb)))
188
+ story.append(Spacer(1, 0.3 * cm))
189
+
190
+ # Noise heatmap
191
+ heat, ratio = forensics.noise_inconsistency(source_path)
192
+ story.append(Paragraph(f"<b>Noise outlier ratio:</b> {ratio:.2%}",
193
+ s["Normal"]))
194
+ story.append(_pil_to_flowable(_make_heatmap_image(heat)))
195
+ except Exception as e:
196
+ story.append(Paragraph(f"Could not render heatmaps: {e}", s["Normal"]))
197
+
198
+ # ---- PDF-specific audit details ----
199
+ if report.get("type") == "pdf":
200
+ audit = report.get("pdf_audit", {})
201
+ fonts = report.get("font_audit", {})
202
+ story.append(Paragraph("PDF structural audit", s["SectionH"]))
203
+ meta = audit.get("metadata", {}) or {}
204
+ pdf_rows = [
205
+ ["Pages", str(audit.get("pages", "-"))],
206
+ ["EOF markers", str(audit.get("eof_markers", "-"))],
207
+ ["Producer", str(meta.get("producer", "-"))],
208
+ ["Creator", str(meta.get("creator", "-"))],
209
+ ["Fonts used", ", ".join(fonts.get("fonts", []) or ["-"])],
210
+ ]
211
+ pdf_t = Table(pdf_rows, colWidths=[5 * cm, 12 * cm])
212
+ pdf_t.setStyle(TableStyle([
213
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
214
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
215
+ ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f1f5f9")),
216
+ ]))
217
+ story.append(pdf_t)
218
+ story.append(Spacer(1, 0.3 * cm))
219
+ for f in audit.get("flags", []) + fonts.get("flags", []):
220
+ if f not in ("clean", "ok"):
221
+ story.append(Paragraph(f"&bull; {f}", s["Evidence"]))
222
+
223
+ # ---- ML model verdict (if present) ----
224
+ if "ml_prediction" in report:
225
+ ml = report["ml_prediction"]
226
+ story.append(Paragraph("Trained ML model verdict", s["SectionH"]))
227
+ ml_t = Table([
228
+ ["Tamper probability", f"{ml['tamper_probability']:.1%}"],
229
+ ["Verdict", ml["verdict"]],
230
+ ], colWidths=[5 * cm, 12 * cm])
231
+ ml_t.setStyle(TableStyle([
232
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
233
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
234
+ ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f1f5f9")),
235
+ ]))
236
+ story.append(ml_t)
237
+
238
+ # ---- Footer ----
239
+ story.append(Spacer(1, 0.6 * cm))
240
+ story.append(Paragraph(
241
+ "<i>This report was generated automatically by DocSentry. "
242
+ "Findings are based on forensic-signal heuristics and an explainable "
243
+ "Random Forest classifier. Manual verification is required for any "
244
+ "file in HIGH or CRITICAL bands.</i>", s["Mono"]))
245
+
246
+ doc.build(story)
247
+ buf.seek(0)
248
+ return buf.read()
249
+
250
+
251
+ if __name__ == "__main__":
252
+ # CLI smoke test
253
+ import sys
254
+ if len(sys.argv) < 2:
255
+ print("Usage: python audit_report.py <file>")
256
+ sys.exit(1)
257
+ src = Path(sys.argv[1])
258
+ report = forensics.analyse_document(src)
259
+ pdf_bytes = build_pdf_report(report, src)
260
+ out = src.with_suffix(".audit.pdf")
261
+ out.write_bytes(pdf_bytes)
262
+ print(f"Wrote {out} ({len(pdf_bytes)} bytes)")
compliance.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ compliance.py - Compliance & Audit Pack for DocSentry
3
+
4
+ Three capabilities:
5
+ 1. KYC validators - IFSC, PAN, Aadhaar format + checksum verification
6
+ 2. PII redaction - mask Aadhaar/PAN/account/IFSC in extracted text + new PDF
7
+ 3. Compliance report - RBI Master Direction style audit PDF
8
+
9
+ Imported by app.py Tab 4. None of this needs a network call; the validators
10
+ use RBI-published format rules.
11
+ """
12
+
13
+ import io
14
+ import re
15
+ from pathlib import Path
16
+ from datetime import datetime
17
+
18
+ import fitz # PyMuPDF for PDF rendering
19
+ from reportlab.lib.pagesizes import A4
20
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
21
+ from reportlab.lib.units import cm
22
+ from reportlab.lib import colors
23
+ from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,
24
+ TableStyle)
25
+
26
+ # ============================================================
27
+ # 1. KYC validators
28
+ # ============================================================
29
+
30
+ # RBI publishes the IFSC structure: 4-char bank code + '0' + 6-char branch code.
31
+ # Known major bank prefixes (subset of RBI's master list - ~150 banks).
32
+ RBI_BANK_CODES = {
33
+ "SBIN": "State Bank of India", "HDFC": "HDFC Bank",
34
+ "ICIC": "ICICI Bank", "UTIB": "Axis Bank",
35
+ "PUNB": "Punjab National Bank", "BARB": "Bank of Baroda",
36
+ "CNRB": "Canara Bank", "UBIN": "Union Bank of India",
37
+ "IOBA": "Indian Overseas Bank", "IBKL": "IDBI Bank",
38
+ "KKBK": "Kotak Mahindra Bank", "YESB": "Yes Bank",
39
+ "RATN": "RBL Bank", "INDB": "IndusInd Bank",
40
+ "IDIB": "Indian Bank", "MAHB": "Bank of Maharashtra",
41
+ "UCBA": "UCO Bank", "BKID": "Bank of India",
42
+ "ANDB": "Andhra Bank", "ALLA": "Allahabad Bank",
43
+ "CBIN": "Central Bank of India", "ORBC": "Oriental Bank of Commerce",
44
+ "SYNB": "Syndicate Bank", "VIJB": "Vijaya Bank",
45
+ "PSIB": "Punjab & Sind Bank", "FDRL": "Federal Bank",
46
+ "KARB": "Karnataka Bank", "SIBL": "South Indian Bank",
47
+ "TMBL": "Tamilnad Mercantile Bank", "CIUB": "City Union Bank",
48
+ "BDBL": "Bandhan Bank", "AUBL": "AU Small Finance Bank",
49
+ "IDFB": "IDFC First Bank", "ESFB": "Equitas Small Finance Bank",
50
+ "DCBL": "DCB Bank", "PROG": "Progressive National Bank",
51
+ }
52
+
53
+ IFSC_RE = re.compile(r"^[A-Z]{4}0[A-Z0-9]{6}$")
54
+
55
+
56
+ def validate_ifsc(code):
57
+ """Returns dict: ok, bank_name, flags."""
58
+ code = (code or "").upper().strip()
59
+ flags = []
60
+ if not code:
61
+ return {"ok": False, "code": "", "flags": ["empty"]}
62
+ if not IFSC_RE.match(code):
63
+ flags.append("invalid format (need AAAA0NNNNNN)")
64
+ return {"ok": False, "code": code, "flags": flags}
65
+ bank = code[:4]
66
+ if bank not in RBI_BANK_CODES:
67
+ flags.append(f"bank code '{bank}' not in known RBI master list")
68
+ bank_name = "unknown"
69
+ else:
70
+ bank_name = RBI_BANK_CODES[bank]
71
+ return {"ok": not flags, "code": code, "bank_code": bank,
72
+ "bank_name": bank_name, "branch_code": code[5:], "flags": flags or ["valid"]}
73
+
74
+
75
+ # --- PAN: AAAAA9999A; 4th char is entity type, 5th is surname initial ---
76
+ PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
77
+ PAN_ENTITY_TYPES = {
78
+ "P": "Individual", "F": "Firm", "C": "Company",
79
+ "A": "AOP", "T": "Trust", "B": "BOI",
80
+ "L": "LLP", "J": "Artificial Juridical Person",
81
+ "G": "Government", "H": "HUF",
82
+ }
83
+
84
+
85
+ def validate_pan(code):
86
+ code = (code or "").upper().strip()
87
+ flags = []
88
+ if not code:
89
+ return {"ok": False, "code": "", "flags": ["empty"]}
90
+ if not PAN_RE.match(code):
91
+ flags.append("invalid format (need AAAAA9999A)")
92
+ return {"ok": False, "code": code, "flags": flags}
93
+ entity = PAN_ENTITY_TYPES.get(code[3], "unknown entity type")
94
+ if entity == "unknown entity type":
95
+ flags.append(f"unrecognised 4th character '{code[3]}'")
96
+ return {"ok": not flags, "code": code, "entity_type": entity,
97
+ "flags": flags or ["valid"]}
98
+
99
+
100
+ # --- Aadhaar: 12 digits, last digit is Verhoeff check ---
101
+ # Verhoeff algorithm tables (UIDAI standard)
102
+ _VERHOEFF_D = [
103
+ [0,1,2,3,4,5,6,7,8,9], [1,2,3,4,0,6,7,8,9,5],
104
+ [2,3,4,0,1,7,8,9,5,6], [3,4,0,1,2,8,9,5,6,7],
105
+ [4,0,1,2,3,9,5,6,7,8], [5,9,8,7,6,0,4,3,2,1],
106
+ [6,5,9,8,7,1,0,4,3,2], [7,6,5,9,8,2,1,0,4,3],
107
+ [8,7,6,5,9,3,2,1,0,4], [9,8,7,6,5,4,3,2,1,0],
108
+ ]
109
+ _VERHOEFF_P = [
110
+ [0,1,2,3,4,5,6,7,8,9], [1,5,7,6,2,8,3,0,9,4],
111
+ [5,8,0,3,7,9,6,1,4,2], [8,9,1,6,0,4,3,5,2,7],
112
+ [9,4,5,3,1,2,6,8,7,0], [4,2,8,6,5,7,3,9,0,1],
113
+ [2,7,9,3,8,0,6,4,1,5], [7,0,4,6,9,1,3,2,5,8],
114
+ ]
115
+
116
+
117
+ def _verhoeff_check(digits):
118
+ c = 0
119
+ for i, d in enumerate(reversed(digits)):
120
+ c = _VERHOEFF_D[c][_VERHOEFF_P[i % 8][d]]
121
+ return c == 0
122
+
123
+
124
+ AADHAAR_RE = re.compile(r"^\d{12}$")
125
+
126
+
127
+ def validate_aadhaar(num):
128
+ num = re.sub(r"\s+", "", str(num or ""))
129
+ flags = []
130
+ if not num:
131
+ return {"ok": False, "code": "", "flags": ["empty"]}
132
+ if not AADHAAR_RE.match(num):
133
+ flags.append("invalid format (need 12 digits)")
134
+ return {"ok": False, "code": num, "flags": flags}
135
+ if num.startswith(("0", "1")):
136
+ flags.append("Aadhaar numbers cannot start with 0 or 1")
137
+ if not _verhoeff_check([int(d) for d in num]):
138
+ flags.append("Verhoeff checksum failed")
139
+ return {"ok": not flags, "code": num,
140
+ "masked": "XXXX-XXXX-" + num[-4:],
141
+ "flags": flags or ["valid (Verhoeff checksum passed)"]}
142
+
143
+
144
+ # ============================================================
145
+ # 2. PII redaction
146
+ # ============================================================
147
+
148
+ # Reuse the field regexes from forensics
149
+ ACC_RE = re.compile(r"\b\d{9,18}\b")
150
+ IFSC_ALL = re.compile(r"\b[A-Z]{4}0[A-Z0-9]{6}\b")
151
+ PAN_ALL = re.compile(r"\b[A-Z]{5}\d{4}[A-Z]\b")
152
+ AAD_ALL = re.compile(r"\b\d{4}\s?\d{4}\s?\d{4}\b")
153
+
154
+
155
+ def redact_text(text):
156
+ """Mask PII fields in arbitrary text. Returns redacted text + list of found PII."""
157
+ found = {"ifsc": [], "pan": [], "aadhaar": [], "account": []}
158
+
159
+ def _mask(s, last_n=4):
160
+ return "X" * (len(s) - last_n) + s[-last_n:]
161
+
162
+ for m in IFSC_ALL.findall(text):
163
+ found["ifsc"].append(m)
164
+ text = text.replace(m, _mask(m))
165
+ for m in PAN_ALL.findall(text):
166
+ found["pan"].append(m)
167
+ text = text.replace(m, _mask(m))
168
+ for m in AAD_ALL.findall(text):
169
+ found["aadhaar"].append(m)
170
+ text = text.replace(m, "XXXX-XXXX-" + m[-4:])
171
+ for m in ACC_RE.findall(text):
172
+ if len(m) >= 10 and m not in found["aadhaar"]:
173
+ found["account"].append(m)
174
+ text = text.replace(m, _mask(m))
175
+ return text, found
176
+
177
+
178
+ def redact_pdf(input_path, output_path):
179
+ """Create a redacted copy of a PDF with PII boxes filled in black."""
180
+ src = fitz.open(input_path)
181
+ out = fitz.open()
182
+ found_all = {"ifsc": [], "pan": [], "aadhaar": [], "account": []}
183
+
184
+ for page_num, page in enumerate(src):
185
+ text_dict = page.get_text("dict")
186
+ new_page = out.new_page(width=page.rect.width, height=page.rect.height)
187
+ new_page.show_pdf_page(new_page.rect, src, page_num)
188
+
189
+ # Search each PII pattern on the page and overlay black rectangles
190
+ for pattern, key in [(IFSC_ALL, "ifsc"), (PAN_ALL, "pan"),
191
+ (AAD_ALL, "aadhaar"), (ACC_RE, "account")]:
192
+ text = page.get_text()
193
+ for m in set(pattern.findall(text)):
194
+ if key == "account" and (len(m) < 10 or m in found_all["aadhaar"]):
195
+ continue
196
+ found_all[key].append(m)
197
+ # Find bounding boxes for this text on the page
198
+ rects = page.search_for(m)
199
+ for r in rects:
200
+ new_page.draw_rect(r, color=(0, 0, 0), fill=(0, 0, 0),
201
+ fill_opacity=1, overlay=True)
202
+
203
+ out.save(output_path)
204
+ out.close()
205
+ src.close()
206
+ return found_all
207
+
208
+
209
+ def extract_pii_fields(path):
210
+ """Convenience: pull all PII fields from a document (PDF or image)."""
211
+ if str(path).lower().endswith(".pdf"):
212
+ with fitz.open(path) as d:
213
+ text = "\n".join(p.get_text() for p in d)
214
+ else:
215
+ try:
216
+ import pytesseract
217
+ from PIL import Image
218
+ text = pytesseract.image_to_string(Image.open(path))
219
+ except Exception:
220
+ text = ""
221
+ fields = {
222
+ "ifsc": IFSC_ALL.findall(text),
223
+ "pan": PAN_ALL.findall(text),
224
+ "aadhaar": AAD_ALL.findall(text),
225
+ "accounts": [m for m in ACC_RE.findall(text) if len(m) >= 10],
226
+ }
227
+ fields["aadhaar"] = list(set(fields["aadhaar"]))
228
+ return fields, text
229
+
230
+
231
+ # ============================================================
232
+ # 3. RBI-style compliance report PDF
233
+ # ============================================================
234
+
235
+ def build_compliance_report(forensic_report, source_path, kyc_results=None):
236
+ """
237
+ Produces an RBI Master-Direction-style audit PDF with mandatory fields:
238
+ - Customer identification
239
+ - KYC verification status (per RBI KYC Master Direction 2016)
240
+ - Fraud-screening status
241
+ - Recommended risk weighting
242
+ - Auditor sign-off block
243
+ """
244
+ source_path = Path(source_path)
245
+ s = getSampleStyleSheet()
246
+ s.add(ParagraphStyle("RBITitle", parent=s["Title"], fontSize=18,
247
+ textColor=colors.HexColor("#0c4a6e")))
248
+ s.add(ParagraphStyle("RBISub", parent=s["Normal"], fontSize=10,
249
+ textColor=colors.dimgray, alignment=1))
250
+ s.add(ParagraphStyle("RBISection", parent=s["Heading2"], fontSize=12,
251
+ textColor=colors.HexColor("#0c4a6e"), spaceAfter=4))
252
+ s.add(ParagraphStyle("Mono", parent=s["Normal"], fontName="Courier",
253
+ fontSize=8, textColor=colors.dimgray))
254
+
255
+ buf = io.BytesIO()
256
+ doc = SimpleDocTemplate(buf, pagesize=A4,
257
+ leftMargin=2 * cm, rightMargin=2 * cm,
258
+ topMargin=1.5 * cm, bottomMargin=1.5 * cm)
259
+ story = []
260
+
261
+ # ----- Header -----
262
+ story.append(Paragraph("DOCSENTRY - REGULATORY COMPLIANCE AUDIT", s["RBITitle"]))
263
+ story.append(Paragraph("Pursuant to RBI Master Direction on KYC (2016, as amended) "
264
+ "and RBI Master Circular on Frauds (DBS.CO.CFMC.BC.No.1/23.04.001/2023-24)",
265
+ s["RBISub"]))
266
+ story.append(Spacer(1, 0.5 * cm))
267
+
268
+ # ----- Section 1: Document identification -----
269
+ story.append(Paragraph("1. Document Identification", s["RBISection"]))
270
+ ident = [
271
+ ["Field", "Value"],
272
+ ["Document filename", source_path.name],
273
+ ["Document type", forensic_report.get("type", "-")],
274
+ ["SHA-256 (full)", forensic_report.get("sha256", "-")],
275
+ ["Audit timestamp (UTC)", forensic_report.get("analysed_at", "-")],
276
+ ["Audit ID", "DS-" + forensic_report.get("sha256", "0" * 16)[:16].upper()],
277
+ ]
278
+ t = Table(ident, colWidths=[5 * cm, 12 * cm])
279
+ t.setStyle(TableStyle([
280
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0c4a6e")),
281
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
282
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
283
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
284
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
285
+ ]))
286
+ story.append(t)
287
+ story.append(Spacer(1, 0.4 * cm))
288
+
289
+ # ----- Section 2: KYC verification status -----
290
+ story.append(Paragraph("2. KYC Field Verification", s["RBISection"]))
291
+ if not kyc_results:
292
+ story.append(Paragraph("<i>No KYC fields supplied for verification.</i>", s["Normal"]))
293
+ else:
294
+ kyc_rows = [["Field", "Value", "Status", "Notes"]]
295
+ for kind, result in kyc_results.items():
296
+ status = "PASS" if result.get("ok") else "FAIL"
297
+ value = result.get("code", "-")
298
+ if kind == "aadhaar" and result.get("masked"):
299
+ value = result["masked"]
300
+ notes = "; ".join(result.get("flags", []))
301
+ kyc_rows.append([kind.upper(), value, status, notes])
302
+ kt = Table(kyc_rows, colWidths=[2.5 * cm, 4 * cm, 1.5 * cm, 9 * cm])
303
+ kt.setStyle(TableStyle([
304
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#0c4a6e")),
305
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
306
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
307
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
308
+ ]))
309
+ story.append(kt)
310
+ story.append(Spacer(1, 0.4 * cm))
311
+
312
+ # ----- Section 3: Fraud-screening status -----
313
+ story.append(Paragraph("3. Fraud / Tamper Screening", s["RBISection"]))
314
+ band = forensic_report.get("risk_band", "UNKNOWN")
315
+ score = forensic_report.get("risk_score", "-")
316
+ band_colour = {"LOW": colors.HexColor("#16a34a"),
317
+ "MEDIUM": colors.HexColor("#ca8a04"),
318
+ "HIGH": colors.HexColor("#ea580c"),
319
+ "CRITICAL": colors.HexColor("#dc2626")}.get(band, colors.grey)
320
+ fraud_box = Table([
321
+ [Paragraph(f"<para alignment='center'><font size=18 color='white'><b>{band}</b></font></para>",
322
+ s["Normal"]),
323
+ Paragraph(f"<b>Composite risk score:</b> {score}<br/>"
324
+ f"<b>Detection ensemble:</b> Rule-based + Random Forest"
325
+ f"{' + CNN (MobileNetV2)' if 'cnn_prediction' in forensic_report else ''}<br/>"
326
+ f"<b>Recommended action:</b> {forensic_report.get('recommended_action', '-')}",
327
+ s["Normal"])]
328
+ ], colWidths=[4 * cm, 13 * cm])
329
+ fraud_box.setStyle(TableStyle([
330
+ ("BACKGROUND", (0, 0), (0, 0), band_colour),
331
+ ("BACKGROUND", (1, 0), (1, 0), colors.HexColor("#f0f9ff")),
332
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
333
+ ("TOPPADDING", (0, 0), (-1, -1), 10),
334
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
335
+ ("LEFTPADDING", (0, 0), (-1, -1), 12),
336
+ ("RIGHTPADDING", (0, 0), (-1, -1), 12),
337
+ ]))
338
+ story.append(fraud_box)
339
+ story.append(Spacer(1, 0.3 * cm))
340
+ story.append(Paragraph("Detected anomalies:", s["Normal"]))
341
+ for ev in forensic_report.get("evidence", []):
342
+ story.append(Paragraph("&bull; " + ev, s["Normal"]))
343
+ story.append(Spacer(1, 0.4 * cm))
344
+
345
+ # ----- Section 4: Recommended risk weighting -----
346
+ story.append(Paragraph("4. Recommended Risk Treatment (per RBI guidelines)",
347
+ s["RBISection"]))
348
+ rw_map = {"LOW": ("Standard onboarding", "Risk weight 75% (retail)"),
349
+ "MEDIUM": ("Request additional verification", "Risk weight 100%"),
350
+ "HIGH": ("Mandatory manual review by fraud-risk team",
351
+ "Risk weight 150% pending verification"),
352
+ "CRITICAL": ("Decline / escalate to compliance",
353
+ "Reject application; report under Suspicious Transaction Report (STR) workflow")}
354
+ treatment, risk_weight = rw_map.get(band, ("-", "-"))
355
+ rw_table = Table([
356
+ ["Recommended treatment", treatment],
357
+ ["Suggested risk weight", risk_weight],
358
+ ["Audit retention period", "10 years (per RBI Master Direction on KYC, Para 67)"],
359
+ ], colWidths=[5 * cm, 12 * cm])
360
+ rw_table.setStyle(TableStyle([
361
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
362
+ ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f0f9ff")),
363
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
364
+ ]))
365
+ story.append(rw_table)
366
+ story.append(Spacer(1, 0.4 * cm))
367
+
368
+ # ----- Section 5: Auditor sign-off -----
369
+ story.append(Paragraph("5. Auditor Sign-Off", s["RBISection"]))
370
+ signoff = Table([
371
+ ["Generated by", "DocSentry Automated Audit Engine v1.0"],
372
+ ["Reviewer name", "_______________________________"],
373
+ ["Designation", "_______________________________"],
374
+ ["Date", "_______________________________"],
375
+ ["Signature", "_______________________________"],
376
+ ], colWidths=[5 * cm, 12 * cm])
377
+ signoff.setStyle(TableStyle([
378
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
379
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
380
+ ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f0f9ff")),
381
+ ]))
382
+ story.append(signoff)
383
+ story.append(Spacer(1, 0.4 * cm))
384
+
385
+ # ----- Disclaimer -----
386
+ story.append(Paragraph(
387
+ "<i>This document is an automated compliance report. The recommendations "
388
+ "herein are advisory and do not substitute statutory verification or "
389
+ "manual review by an authorised compliance officer. All audit records "
390
+ "must be retained per RBI Master Direction requirements.</i>", s["Mono"]))
391
+
392
+ doc.build(story)
393
+ buf.seek(0)
394
+ return buf.read()
395
+
396
+
397
+ if __name__ == "__main__":
398
+ # Quick smoke tests
399
+ print("IFSC SBIN0123456 :", validate_ifsc("SBIN0123456"))
400
+ print("IFSC FAKE0000000 :", validate_ifsc("FAKE0000000"))
401
+ print("PAN ABCDE1234F :", validate_pan("ABCDE1234F"))
402
+ print("PAN bad :", validate_pan("ABCD12345"))
403
+ print("Aadhaar valid :", validate_aadhaar("234567890124")) # not real
404
+ print("Aadhaar starts 0 :", validate_aadhaar("012345678901"))
405
+ text = "Name: RAMESH. Account: 78439336112. IFSC: SBIN0001234. PAN: ABCDE1234F"
406
+ redacted, found = redact_text(text)
407
+ print("Redacted:", redacted)
408
+ print("Found:", found)
docsentry_master.ipynb ADDED
@@ -0,0 +1,1505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# DocSentry - Master Notebook\n",
8
+ "\n",
9
+ "**Single source of truth.** Everything lives here: detectors, training,\n",
10
+ "evaluation, cross-doc check, PDF report generator, AND a cell that exports\n",
11
+ "the supporting `.py` files for the Streamlit demo.\n",
12
+ "\n",
13
+ "**Use case:** real-time document anomaly detection for bank underwriting.\n",
14
+ "Land records, legal documents, financial statements.\n",
15
+ "\n",
16
+ "**Pipeline:**\n",
17
+ "```\n",
18
+ " Document -> Image forensics (ELA, copy-move, noise, EXIF)\n",
19
+ " -> PDF structure (EOF count, fonts, producer)\n",
20
+ " -> OCR + text rules (date monotonicity, math, IFSC)\n",
21
+ " -> Random Forest (forensic feature blend)\n",
22
+ " -> CNN (MobileNetV2 on CASIA v2)\n",
23
+ " -> Risk band + Insights + Audit JSON + PDF report\n",
24
+ "```\n",
25
+ "\n",
26
+ "**100% open-source, no paid APIs, no LLM calls.** Runs on a laptop CPU.\n",
27
+ "GPU only required for the optional CNN training section.\n"
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "markdown",
32
+ "metadata": {},
33
+ "source": [
34
+ "## 0. Environment auto-detection\n",
35
+ "\n",
36
+ "Detects whether you are on Colab or local; auto-installs deps if Colab.\n"
37
+ ]
38
+ },
39
+ {
40
+ "cell_type": "code",
41
+ "execution_count": null,
42
+ "metadata": {},
43
+ "outputs": [],
44
+ "source": [
45
+ "import sys, os, platform\n",
46
+ "IS_COLAB = 'google.colab' in sys.modules\n",
47
+ "IS_WINDOWS = platform.system() == 'Windows'\n",
48
+ "print('Colab:', IS_COLAB, ' Windows:', IS_WINDOWS)\n",
49
+ "\n",
50
+ "# One-shot install (skip if you already pip-installed locally)\n",
51
+ "if IS_COLAB:\n",
52
+ " !apt-get -qq install -y tesseract-ocr\n",
53
+ " %pip install --quiet \\\n",
54
+ " numpy pandas matplotlib seaborn scikit-image scikit-learn joblib \\\n",
55
+ " opencv-python-headless pillow pytesseract pdfplumber pymupdf pikepdf \\\n",
56
+ " imagehash exifread python-dateutil kaggle reportlab\n",
57
+ "print('Setup complete.')\n"
58
+ ]
59
+ },
60
+ {
61
+ "cell_type": "code",
62
+ "execution_count": null,
63
+ "metadata": {},
64
+ "outputs": [],
65
+ "source": [
66
+ "import io, json, re, math, hashlib, shutil, tempfile, warnings\n",
67
+ "from pathlib import Path\n",
68
+ "from datetime import datetime\n",
69
+ "from collections import Counter\n",
70
+ "\n",
71
+ "import numpy as np\n",
72
+ "import pandas as pd\n",
73
+ "import matplotlib.pyplot as plt\n",
74
+ "from PIL import Image, ImageChops, ImageEnhance, ImageDraw, ImageFont, ImageFilter\n",
75
+ "import cv2\n",
76
+ "import fitz # PyMuPDF\n",
77
+ "import pdfplumber\n",
78
+ "import pikepdf\n",
79
+ "import pytesseract\n",
80
+ "\n",
81
+ "warnings.filterwarnings('ignore')\n",
82
+ "plt.rcParams['figure.figsize'] = (10, 6)\n",
83
+ "\n",
84
+ "# Auto-detect Tesseract on Windows / Mac / Linux\n",
85
+ "TESSERACT_OK = False\n",
86
+ "for c in [shutil.which('tesseract'),\n",
87
+ " r'C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe',\n",
88
+ " r'C:\\\\Program Files (x86)\\\\Tesseract-OCR\\\\tesseract.exe',\n",
89
+ " os.path.expanduser(r'~\\\\AppData\\\\Local\\\\Programs\\\\Tesseract-OCR\\\\tesseract.exe')]:\n",
90
+ " if c and os.path.isfile(c):\n",
91
+ " pytesseract.pytesseract.tesseract_cmd = c\n",
92
+ " TESSERACT_OK = True\n",
93
+ " print('Tesseract:', c)\n",
94
+ " break\n",
95
+ "if not TESSERACT_OK:\n",
96
+ " print('Tesseract not found. OCR-based checks will be skipped.')\n",
97
+ " print('Windows install: https://github.com/UB-Mannheim/tesseract/wiki')\n"
98
+ ]
99
+ },
100
+ {
101
+ "cell_type": "markdown",
102
+ "metadata": {},
103
+ "source": [
104
+ "## 1. Datasets\n",
105
+ "\n",
106
+ "Folder layout the notebook expects:\n",
107
+ "```\n",
108
+ "data/\n",
109
+ " images/originals/ <-- genuine scans\n",
110
+ " images/tampered/ <-- forged scans\n",
111
+ " pdfs/originals/\n",
112
+ " pdfs/tampered/\n",
113
+ " statements/\n",
114
+ "```\n",
115
+ "\n",
116
+ "Three ways to populate `data/`:\n",
117
+ "1. **Synthetic generator** (next cell) - 130 docs each, no downloads, runs in ~3 min\n",
118
+ "2. **Kaggle CASIA v2** - the 12k-image industry benchmark (see cell 1.3)\n",
119
+ "3. **Manual datasets** - MICC-F220, CoMoFoD, ICDAR Find-It, Tobacco-3482 (see DATASETS.md)\n"
120
+ ]
121
+ },
122
+ {
123
+ "cell_type": "code",
124
+ "execution_count": null,
125
+ "metadata": {},
126
+ "outputs": [],
127
+ "source": [
128
+ "DATA = Path('data')\n",
129
+ "for sub in ['images/originals', 'images/tampered',\n",
130
+ " 'pdfs/originals', 'pdfs/tampered', 'statements']:\n",
131
+ " (DATA / sub).mkdir(parents=True, exist_ok=True)\n",
132
+ "print('Folders ready under', DATA.resolve())\n"
133
+ ]
134
+ },
135
+ {
136
+ "cell_type": "markdown",
137
+ "metadata": {},
138
+ "source": [
139
+ "### 1.1 Synthetic banking-document generator\n",
140
+ "\n",
141
+ "Produces realistic land records, loan agreements, and bank statements.\n",
142
+ "Tampering variants: copy-move, text-edit, splice, compression-after-edit.\n",
143
+ "Resumable - skips existing files.\n"
144
+ ]
145
+ },
146
+ {
147
+ "cell_type": "code",
148
+ "execution_count": null,
149
+ "metadata": {},
150
+ "outputs": [],
151
+ "source": [
152
+ "import random\n",
153
+ "random.seed(42); np.random.seed(42)\n",
154
+ "\n",
155
+ "FIRST = ['RAMESH','SURESH','AMIT','PRIYA','ANITA','VIKAS','POOJA','RAHUL',\n",
156
+ " 'DEEPAK','SUNITA','ARJUN','MEENA','KIRAN','NEHA','SANJAY','GEETA']\n",
157
+ "LAST = ['KUMAR','SHARMA','VERMA','SINGH','GUPTA','PATEL','REDDY','RAO',\n",
158
+ " 'NAIR','JOSHI','MEHTA','AGGARWAL','BANERJEE','MISHRA']\n",
159
+ "VILLAGES = ['NARAYANPUR','RAMGARH','BHIWANI','KISHANGARH','SITAPUR','JAGADHRI']\n",
160
+ "BANKS = ['State Bank of India','HDFC Bank','ICICI Bank','Axis Bank',\n",
161
+ " 'Punjab National Bank','Bank of Baroda','Canara Bank']\n",
162
+ "IFSC_PFX = ['SBIN','HDFC','ICIC','UTIB','PUNB','BARB','CNRB']\n",
163
+ "\n",
164
+ "def rand_name(): return f'{random.choice(FIRST)} {random.choice(LAST)}'\n",
165
+ "def rand_date(): return f'{random.randint(1,28):02d}-{random.randint(1,12):02d}-{random.randint(2018,2024)}'\n",
166
+ "def rand_amount(low=100000, high=10000000): return (random.randint(low,high)//1000)*1000\n",
167
+ "def rand_account():return ''.join(str(random.randint(0,9)) for _ in range(random.randint(11,14)))\n",
168
+ "def rand_ifsc(): return f\"{random.choice(IFSC_PFX)}0{''.join(random.choice('0123456789ABCDEF') for _ in range(6))}\"\n",
169
+ "def fmt_inr(a):\n",
170
+ " s = str(a)[::-1]; parts=[s[:3]]; s=s[3:]\n",
171
+ " while s: parts.append(s[:2]); s=s[2:]\n",
172
+ " return 'Rs ' + ','.join(parts)[::-1]\n",
173
+ "\n",
174
+ "def get_fonts():\n",
175
+ " for p in ['/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',\n",
176
+ " 'DejaVuSans.ttf', 'arial.ttf']:\n",
177
+ " try:\n",
178
+ " return (ImageFont.truetype(p,22), ImageFont.truetype(p,16),\n",
179
+ " ImageFont.truetype(p,14))\n",
180
+ " except OSError: continue\n",
181
+ " f = ImageFont.load_default(); return f,f,f\n",
182
+ "BIG, MID, SMALL = get_fonts()\n",
183
+ "print('Helpers ready.')\n"
184
+ ]
185
+ },
186
+ {
187
+ "cell_type": "code",
188
+ "execution_count": null,
189
+ "metadata": {},
190
+ "outputs": [],
191
+ "source": [
192
+ "def make_land_record():\n",
193
+ " img = Image.new('RGB', (900,600), 'white'); d = ImageDraw.Draw(img)\n",
194
+ " d.rectangle([20,20,880,580], outline='black', width=2)\n",
195
+ " fields = dict(survey=f'{random.randint(50,300)}/{random.randint(1,9)}',\n",
196
+ " owner=rand_name(), area=f'{random.uniform(0.1,5):.2f} hectares',\n",
197
+ " village=random.choice(VILLAGES), date=rand_date(),\n",
198
+ " amount=fmt_inr(rand_amount()))\n",
199
+ " d.text((40,40),'GOVERNMENT OF INDIA - LAND RECORD',font=BIG,fill='black')\n",
200
+ " for i,(k,v) in enumerate([('Survey No', fields['survey']),\n",
201
+ " ('Owner', fields['owner']),\n",
202
+ " ('Area', fields['area']),\n",
203
+ " ('Village', fields['village']),\n",
204
+ " ('Date', fields['date']),\n",
205
+ " ('Stamp value', fields['amount'])]):\n",
206
+ " d.text((40, 90+i*30), f'{k:11s}: {v}', font=MID, fill='black')\n",
207
+ " sx,sy = random.randint(520,600), random.randint(370,410)\n",
208
+ " d.rectangle([sx,sy,sx+250,sy+140], outline='black', width=1)\n",
209
+ " d.text((sx+30,sy+30),'OFFICIAL SEAL',font=MID,fill='black')\n",
210
+ " d.text((sx+30,sy+60),'Tehsildar / Patwari',font=MID,fill='black')\n",
211
+ " fields['seal_box']=(sx,sy,sx+250,sy+140)\n",
212
+ " fields['amount_pos']=(170,256,380,285)\n",
213
+ " return img, fields\n",
214
+ "\n",
215
+ "def make_loan_agreement():\n",
216
+ " img = Image.new('RGB',(900,700),'white'); d = ImageDraw.Draw(img)\n",
217
+ " d.rectangle([20,20,880,680], outline='black', width=2)\n",
218
+ " fields = dict(borrower=rand_name(), principal=fmt_inr(rand_amount(500000,8000000)),\n",
219
+ " tenure=f'{random.choice([36,60,84,120,180])} months',\n",
220
+ " rate=f'{random.uniform(6.5,12.5):.2f}% p.a.',\n",
221
+ " date=rand_date(), bank=random.choice(BANKS),\n",
222
+ " account=rand_account(), ifsc=rand_ifsc())\n",
223
+ " d.text((40,40),'LOAN AGREEMENT',font=BIG,fill='black')\n",
224
+ " for i,(k,v) in enumerate([('Borrower',fields['borrower']),\n",
225
+ " ('Principal',fields['principal']),\n",
226
+ " ('Tenure',fields['tenure']),\n",
227
+ " ('Rate',fields['rate']),\n",
228
+ " ('Date',fields['date']),\n",
229
+ " ('Bank',fields['bank']),\n",
230
+ " ('A/c No',fields['account']),\n",
231
+ " ('IFSC',fields['ifsc'])]):\n",
232
+ " d.text((40,110+i*35), f'{k:11s}: {v}', font=MID, fill='black')\n",
233
+ " sx,sy=560,520\n",
234
+ " d.rectangle([sx,sy,sx+260,sy+120], outline='black', width=1)\n",
235
+ " d.text((sx+20,sy+20),'AUTHORISED SIGNATORY',font=SMALL,fill='black')\n",
236
+ " fields['sig_box']=(sx,sy,sx+260,sy+120)\n",
237
+ " fields['principal_pos']=(170,141,380,170)\n",
238
+ " return img, fields\n",
239
+ "\n",
240
+ "def make_bank_statement():\n",
241
+ " img = Image.new('RGB',(900,800),'white'); d = ImageDraw.Draw(img)\n",
242
+ " d.rectangle([20,20,880,780], outline='black', width=2)\n",
243
+ " d.text((40,40), random.choice(BANKS).upper(), font=BIG, fill='black')\n",
244
+ " d.text((40,80), f'Account Holder : {rand_name()}', font=MID, fill='black')\n",
245
+ " d.text((40,110), f'Account No : {rand_account()}', font=MID, fill='black')\n",
246
+ " d.text((40,140), f'IFSC : {rand_ifsc()}', font=MID, fill='black')\n",
247
+ " d.line([(40,215),(860,215)], fill='black', width=1)\n",
248
+ " d.text((50,220),'Date',font=MID,fill='black')\n",
249
+ " d.text((180,220),'Narration',font=MID,fill='black')\n",
250
+ " d.text((500,220),'Debit',font=MID,fill='black')\n",
251
+ " d.text((620,220),'Credit',font=MID,fill='black')\n",
252
+ " d.line([(40,250),(860,250)], fill='black', width=1)\n",
253
+ " bal=random.randint(20000,200000); y=260\n",
254
+ " for _ in range(random.randint(8,14)):\n",
255
+ " date=f'{random.randint(1,28):02d}-04-2024'\n",
256
+ " narr=random.choice(['UPI Transfer','ATM Withdrawal','Salary Credit','Cheque Deposit','EMI Debit'])\n",
257
+ " is_cr=random.random()>0.55; amt=random.randint(1000,50000)\n",
258
+ " bal = bal+amt if is_cr else bal-amt\n",
259
+ " d.text((50,y),date,font=SMALL,fill='black')\n",
260
+ " d.text((180,y),narr,font=SMALL,fill='black')\n",
261
+ " d.text((500,y),'' if is_cr else f'{amt:,}',font=SMALL,fill='black')\n",
262
+ " d.text((620,y),f'{amt:,}' if is_cr else '',font=SMALL,fill='black')\n",
263
+ " y += 28\n",
264
+ " return img, {}\n"
265
+ ]
266
+ },
267
+ {
268
+ "cell_type": "code",
269
+ "execution_count": null,
270
+ "metadata": {},
271
+ "outputs": [],
272
+ "source": [
273
+ "def tamper_copy_move(img, fields, kind):\n",
274
+ " arr = np.array(img)\n",
275
+ " box = fields.get('seal_box') or fields.get('sig_box') or (600,400,850,540)\n",
276
+ " x0,y0,x1,y1 = box\n",
277
+ " patch = arr[y0:y1, x0:x1].copy()\n",
278
+ " tx = max(40, x0 - 300); ty = y0\n",
279
+ " if tx + (x1-x0) < arr.shape[1] and ty + (y1-y0) < arr.shape[0]:\n",
280
+ " arr[ty:ty+(y1-y0), tx:tx+(x1-x0)] = patch\n",
281
+ " return Image.fromarray(arr)\n",
282
+ "\n",
283
+ "def tamper_text_edit(img, fields, kind):\n",
284
+ " pos = fields.get('amount_pos') or fields.get('principal_pos') or (600,80,800,110)\n",
285
+ " d = ImageDraw.Draw(img)\n",
286
+ " d.rectangle(pos, fill='white')\n",
287
+ " d.text((pos[0]+5, pos[1]+5), fmt_inr(rand_amount(1000000,50000000)),\n",
288
+ " font=MID, fill='black')\n",
289
+ " return img\n",
290
+ "\n",
291
+ "def tamper_splice(img, fields, kind, donor):\n",
292
+ " arr=np.array(img); donor_arr=np.array(donor.resize(img.size))\n",
293
+ " h,w=arr.shape[:2]; bh,bw=80,200\n",
294
+ " x=random.randint(40,w-bw-40); y=random.randint(300,h-bh-40)\n",
295
+ " arr[y:y+bh, x:x+bw] = donor_arr[y:y+bh, x:x+bw]\n",
296
+ " return Image.fromarray(arr)\n",
297
+ "\n",
298
+ "def tamper_compression(img):\n",
299
+ " buf=io.BytesIO(); img.save(buf,'JPEG',quality=random.randint(35,60)); buf.seek(0)\n",
300
+ " return Image.open(buf).convert('RGB')\n",
301
+ "\n",
302
+ "def add_scan_noise(img):\n",
303
+ " arr=np.array(img).astype(np.float32)\n",
304
+ " arr=np.clip(arr+np.random.normal(0,3,arr.shape),0,255).astype(np.uint8)\n",
305
+ " return Image.fromarray(arr)\n"
306
+ ]
307
+ },
308
+ {
309
+ "cell_type": "code",
310
+ "execution_count": null,
311
+ "metadata": {},
312
+ "outputs": [],
313
+ "source": [
314
+ "GENERATORS = {'land': make_land_record, 'agreement': make_loan_agreement,\n",
315
+ " 'statement': make_bank_statement}\n",
316
+ "COUNTS = {'land': 30, 'agreement': 20, 'statement': 15} # adjust as needed\n",
317
+ "\n",
318
+ "def generate_dataset(counts=COUNTS):\n",
319
+ " print('Generating genuine documents...')\n",
320
+ " genuine = []\n",
321
+ " for kind, n in counts.items():\n",
322
+ " for i in range(n):\n",
323
+ " p = DATA/'images/originals'/f'{kind}_{i:03d}.png'\n",
324
+ " if p.exists():\n",
325
+ " try: img=Image.open(p).convert('RGB')\n",
326
+ " except: p.unlink(missing_ok=True); img,fields=GENERATORS[kind]()\n",
327
+ " else: _,fields=GENERATORS[kind]()\n",
328
+ " else:\n",
329
+ " img,fields = GENERATORS[kind]()\n",
330
+ " img = add_scan_noise(img); img.save(p)\n",
331
+ " genuine.append((kind, img.copy(), fields))\n",
332
+ " print(f' {sum(counts.values())} originals on disk.')\n",
333
+ " print('Generating tampered documents...')\n",
334
+ " tampers = ['copy_move','text_edit','splice','compression_after_edit']\n",
335
+ " nt = 0\n",
336
+ " for kind, n in counts.items():\n",
337
+ " for i in range(n):\n",
338
+ " if list((DATA/'images/tampered').glob(f'{kind}_{i:03d}_*.png')):\n",
339
+ " continue\n",
340
+ " img,fields = GENERATORS[kind]()\n",
341
+ " t = random.choice(tampers)\n",
342
+ " if t=='copy_move': out = tamper_copy_move(img, fields, kind)\n",
343
+ " elif t=='text_edit': out = tamper_text_edit(img, fields, kind)\n",
344
+ " elif t=='splice': out = tamper_splice(img, fields, kind, random.choice(genuine)[1])\n",
345
+ " else: out = tamper_compression(tamper_text_edit(img, fields, kind))\n",
346
+ " out = add_scan_noise(out)\n",
347
+ " out.save(DATA/'images/tampered'/f'{kind}_{i:03d}_{t}.png')\n",
348
+ " nt += 1\n",
349
+ " print(f' {nt} new tampered images written.')\n",
350
+ "\n",
351
+ "# Run generator (idempotent - skip cells you already ran)\n",
352
+ "generate_dataset()\n",
353
+ "\n",
354
+ "# Show one genuine + one tampered\n",
355
+ "samples = sorted((DATA/'images/originals').glob('land_*.png'))[:1]\n",
356
+ "tsamples = sorted((DATA/'images/tampered').glob('land_*.png'))[:1]\n",
357
+ "if samples and tsamples:\n",
358
+ " fig, ax = plt.subplots(1,2, figsize=(14,5))\n",
359
+ " ax[0].imshow(Image.open(samples[0])); ax[0].set_title('Genuine'); ax[0].axis('off')\n",
360
+ " ax[1].imshow(Image.open(tsamples[0])); ax[1].set_title(f'Tampered ({tsamples[0].name.split(\"_\",2)[2]})'); ax[1].axis('off')\n",
361
+ " plt.show()\n"
362
+ ]
363
+ },
364
+ {
365
+ "cell_type": "markdown",
366
+ "metadata": {},
367
+ "source": [
368
+ "### 1.2 Demo PDFs (for the PDF forensic detectors)\n"
369
+ ]
370
+ },
371
+ {
372
+ "cell_type": "code",
373
+ "execution_count": null,
374
+ "metadata": {},
375
+ "outputs": [],
376
+ "source": [
377
+ "def make_demo_pdfs(n=15):\n",
378
+ " for i in range(n):\n",
379
+ " op = DATA/'pdfs/originals'/f'agreement_{i:03d}.pdf'\n",
380
+ " tp = DATA/'pdfs/tampered'/f'agreement_{i:03d}_tampered.pdf'\n",
381
+ " if op.exists() and tp.exists(): continue\n",
382
+ " doc = fitz.open(); page = doc.new_page()\n",
383
+ " text = (f'LOAN AGREEMENT\\n\\nBorrower : {rand_name()}\\n'\n",
384
+ " f'Principal: {fmt_inr(rand_amount())}\\nTenure : 60 months\\n'\n",
385
+ " f'Rate : 8.5% p.a.\\nDate : {rand_date()}')\n",
386
+ " page.insert_text((72,72), text, fontsize=14)\n",
387
+ " doc.set_metadata({'producer':'PyMuPDF','creator':'PyMuPDF'})\n",
388
+ " doc.save(op); doc.close()\n",
389
+ " doc = fitz.open(op); page = doc[0]\n",
390
+ " page.draw_rect(fitz.Rect(150,102,360,122), color=(1,1,1), fill=(1,1,1))\n",
391
+ " page.insert_text((150,118), fmt_inr(rand_amount(10000000,90000000)),\n",
392
+ " fontsize=14, fontname='helv')\n",
393
+ " doc.set_metadata({'producer':random.choice(['iLovePDF','Smallpdf','PDFescape','Sejda']),\n",
394
+ " 'creator':'PyMuPDF'})\n",
395
+ " doc.save(tp, deflate=True); doc.close()\n",
396
+ "make_demo_pdfs()\n",
397
+ "print('PDFs ready.')\n"
398
+ ]
399
+ },
400
+ {
401
+ "cell_type": "markdown",
402
+ "metadata": {},
403
+ "source": [
404
+ "### 1.3 (Optional) Kaggle CASIA v2 download\n",
405
+ "\n",
406
+ "Adds 12,000 real tampered/genuine images on top of your synthetic ones.\n",
407
+ "Requires `kaggle.json` (https://www.kaggle.com/settings -> Create New API Token).\n"
408
+ ]
409
+ },
410
+ {
411
+ "cell_type": "code",
412
+ "execution_count": null,
413
+ "metadata": {},
414
+ "outputs": [],
415
+ "source": [
416
+ "USE_CASIA = False # flip True after placing kaggle.json\n",
417
+ "\n",
418
+ "if USE_CASIA:\n",
419
+ " if IS_COLAB:\n",
420
+ " from google.colab import files\n",
421
+ " if not os.path.exists('/root/.kaggle/kaggle.json'):\n",
422
+ " up = files.upload() # browse-and-select\n",
423
+ " os.makedirs('/root/.kaggle', exist_ok=True)\n",
424
+ " for n in up:\n",
425
+ " if n.endswith('.json'):\n",
426
+ " shutil.copy(n, '/root/.kaggle/kaggle.json')\n",
427
+ " os.chmod('/root/.kaggle/kaggle.json', 0o600)\n",
428
+ " break\n",
429
+ " !kaggle datasets download -d divg07/casia-20-image-tampering-detection-dataset \\\n",
430
+ " -p data/images --unzip --force\n",
431
+ " # rename Au/Tp -> originals/tampered\n",
432
+ " for src, dst in [('Au','originals'),('Tp','tampered')]:\n",
433
+ " for cand in [f'data/images/{src}', f'data/images/CASIA2/{src}']:\n",
434
+ " if os.path.isdir(cand) and not os.path.isdir(f'data/images/{dst}'):\n",
435
+ " shutil.move(cand, f'data/images/{dst}'); break\n",
436
+ " print('CASIA v2 ready.')\n",
437
+ "else:\n",
438
+ " print('USE_CASIA = False - skipping. Synthetic data only.')\n"
439
+ ]
440
+ },
441
+ {
442
+ "cell_type": "markdown",
443
+ "metadata": {},
444
+ "source": [
445
+ "## 2. Image forensics detectors\n",
446
+ "\n",
447
+ "Four classical techniques, none requiring training:\n",
448
+ "- **Error Level Analysis** - re-save at known JPEG quality; tampered regions diverge\n",
449
+ "- **Copy-move** - ORB keypoint matching finds duplicated regions\n",
450
+ "- **Noise inconsistency** - per-block Laplacian variance for splice detection\n",
451
+ "- **EXIF sanity** - missing metadata, photo-editor fingerprints, time mismatches\n"
452
+ ]
453
+ },
454
+ {
455
+ "cell_type": "code",
456
+ "execution_count": null,
457
+ "metadata": {},
458
+ "outputs": [],
459
+ "source": [
460
+ "def error_level_analysis(path, quality=90, scale=15):\n",
461
+ " orig = Image.open(path).convert('RGB')\n",
462
+ " buf = io.BytesIO(); orig.save(buf,'JPEG',quality=quality); buf.seek(0)\n",
463
+ " resaved = Image.open(buf)\n",
464
+ " diff = ImageChops.difference(orig, resaved)\n",
465
+ " max_diff = max([e[1] for e in diff.getextrema()]) or 1\n",
466
+ " ela = ImageEnhance.Brightness(diff).enhance(scale * 255 / max_diff)\n",
467
+ " return ela, float(np.array(diff).mean())\n",
468
+ "\n",
469
+ "def copy_move_detect(path, min_dist=40, max_matches=80):\n",
470
+ " img = cv2.imread(str(path))\n",
471
+ " if img is None: return None, 0, []\n",
472
+ " gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n",
473
+ " orb = cv2.ORB_create(nfeatures=2000)\n",
474
+ " kp, des = orb.detectAndCompute(gray, None)\n",
475
+ " if des is None or len(kp)<10: return img, 0, []\n",
476
+ " matches = cv2.BFMatcher(cv2.NORM_HAMMING).knnMatch(des, des, k=10)\n",
477
+ " good = []\n",
478
+ " for ml in matches:\n",
479
+ " for m in ml[1:]:\n",
480
+ " p1,p2 = kp[m.queryIdx].pt, kp[m.trainIdx].pt\n",
481
+ " d = math.hypot(p1[0]-p2[0], p1[1]-p2[1])\n",
482
+ " if d > min_dist and m.distance < 40: good.append((p1,p2,d))\n",
483
+ " good = good[:max_matches]\n",
484
+ " out = img.copy()\n",
485
+ " for p1,p2,_ in good:\n",
486
+ " cv2.line(out, tuple(map(int,p1)), tuple(map(int,p2)), (0,0,255), 1)\n",
487
+ " cv2.circle(out, tuple(map(int,p1)), 3, (0,255,0), -1)\n",
488
+ " cv2.circle(out, tuple(map(int,p2)), 3, (0,255,0), -1)\n",
489
+ " return out, len(good), good\n",
490
+ "\n",
491
+ "def noise_inconsistency(path, block=32):\n",
492
+ " img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)\n",
493
+ " if img is None: return np.zeros((1,1)), 0.0\n",
494
+ " H,W = img.shape; Hc,Wc = (H//block)*block, (W//block)*block\n",
495
+ " if Hc==0 or Wc==0: return np.zeros((1,1)), 0.0\n",
496
+ " img = img[:Hc,:Wc]\n",
497
+ " lap = cv2.Laplacian(img, cv2.CV_64F)\n",
498
+ " blocks = lap.reshape(Hc//block,block,Wc//block,block).transpose(0,2,1,3).reshape(-1,block*block)\n",
499
+ " var = blocks.var(axis=1)\n",
500
+ " z = (var - var.mean()) / (var.std() + 1e-9)\n",
501
+ " return np.abs(z).reshape(Hc//block, Wc//block), float((np.abs(z)>2.5).sum()/max(1,len(z)))\n",
502
+ "\n",
503
+ "def exif_sanity(path):\n",
504
+ " try: exif = Image.open(path).getexif()\n",
505
+ " except Exception: return ['cannot read image']\n",
506
+ " if not exif: return ['no EXIF metadata (re-saved or stripped)']\n",
507
+ " tags = {Image.ExifTags.TAGS.get(k,k):v for k,v in exif.items()}\n",
508
+ " flags = []; sw = str(tags.get('Software','')).lower()\n",
509
+ " for bad in ['photoshop','gimp','paint','snapseed','picsart']:\n",
510
+ " if bad in sw: flags.append('edited with '+bad)\n",
511
+ " if 'DateTimeOriginal' in tags and 'DateTime' in tags:\n",
512
+ " if tags['DateTimeOriginal'] != tags['DateTime']:\n",
513
+ " flags.append('modified-time differs from original-time')\n",
514
+ " return flags or ['exif clean']\n",
515
+ "\n",
516
+ "print('Image forensics ready.')\n"
517
+ ]
518
+ },
519
+ {
520
+ "cell_type": "markdown",
521
+ "metadata": {},
522
+ "source": [
523
+ "### 2.1 Visual smoke test\n"
524
+ ]
525
+ },
526
+ {
527
+ "cell_type": "code",
528
+ "execution_count": null,
529
+ "metadata": {},
530
+ "outputs": [],
531
+ "source": [
532
+ "originals = sorted((DATA/'images/originals').glob('land_*.png'))[:1]\n",
533
+ "tampered = sorted((DATA/'images/tampered').glob('land_*.png'))[:1]\n",
534
+ "\n",
535
+ "for label, path in [('Genuine', originals[0]), ('Tampered', tampered[0])]:\n",
536
+ " fig, ax = plt.subplots(1, 3, figsize=(16, 4))\n",
537
+ " ax[0].imshow(Image.open(path)); ax[0].set_title(f'{label} - source'); ax[0].axis('off')\n",
538
+ " ela, s = error_level_analysis(path)\n",
539
+ " ax[1].imshow(ela); ax[1].set_title(f'ELA (score={s:.2f})'); ax[1].axis('off')\n",
540
+ " viz, n, _ = copy_move_detect(path)\n",
541
+ " ax[2].imshow(cv2.cvtColor(viz, cv2.COLOR_BGR2RGB))\n",
542
+ " ax[2].set_title(f'Copy-move ({n} matches)'); ax[2].axis('off')\n",
543
+ " plt.show()\n",
544
+ " print(f'{label}: EXIF -> {exif_sanity(path)}')\n"
545
+ ]
546
+ },
547
+ {
548
+ "cell_type": "markdown",
549
+ "metadata": {},
550
+ "source": [
551
+ "## 3. PDF forensics detectors\n"
552
+ ]
553
+ },
554
+ {
555
+ "cell_type": "code",
556
+ "execution_count": null,
557
+ "metadata": {},
558
+ "outputs": [],
559
+ "source": [
560
+ "def pdf_structural_audit(path):\n",
561
+ " raw = Path(path).read_bytes(); eofs = raw.count(b'%%EOF')\n",
562
+ " with fitz.open(path) as d:\n",
563
+ " info = d.metadata or {}; n_pages = d.page_count\n",
564
+ " flags = []\n",
565
+ " if eofs > 1: flags.append(f'{eofs} EOF markers (incremental updates)')\n",
566
+ " prod = (info.get('producer') or '').lower()\n",
567
+ " crt = (info.get('creator') or '').lower()\n",
568
+ " if prod and crt and prod != crt:\n",
569
+ " flags.append(f'producer/creator differ: {prod} vs {crt}')\n",
570
+ " for t in ['ilovepdf','smallpdf','pdfescape','sejda','foxit phantom']:\n",
571
+ " if t in prod or t in crt: flags.append('edited via consumer tool: '+t)\n",
572
+ " return {'pages':n_pages, 'eof_markers':eofs, 'metadata':info,\n",
573
+ " 'flags': flags or ['clean']}\n",
574
+ "\n",
575
+ "def pdf_font_audit(path):\n",
576
+ " fonts = []\n",
577
+ " with fitz.open(path) as d:\n",
578
+ " for page in d: fonts.append({f[3] for f in page.get_fonts()})\n",
579
+ " allf = set().union(*fonts) if fonts else set()\n",
580
+ " return {'fonts': sorted(allf),\n",
581
+ " 'flags': ['unusually high font count: '+str(len(allf))] if len(allf)>4 else ['ok']}\n",
582
+ "\n",
583
+ "import pprint\n",
584
+ "for label, p in [('Genuine', DATA/'pdfs/originals/agreement_000.pdf'),\n",
585
+ " ('Tampered', DATA/'pdfs/tampered/agreement_000_tampered.pdf')]:\n",
586
+ " if p.exists():\n",
587
+ " print(f'\\n=== {label} ==='); pprint.pp(pdf_structural_audit(p))\n",
588
+ " print('Fonts:', pdf_font_audit(p))\n"
589
+ ]
590
+ },
591
+ {
592
+ "cell_type": "markdown",
593
+ "metadata": {},
594
+ "source": [
595
+ "## 4. OCR + text-level rules\n",
596
+ "\n",
597
+ "Date monotonicity, amount sanity, IFSC format, account-without-IFSC,\n",
598
+ "round-number anomalies. Skipped gracefully if Tesseract isn't installed.\n"
599
+ ]
600
+ },
601
+ {
602
+ "cell_type": "code",
603
+ "execution_count": null,
604
+ "metadata": {},
605
+ "outputs": [],
606
+ "source": [
607
+ "AMT_RE = re.compile(r'(?<![A-Za-z])[-]?\\d{1,3}(?:,\\d{2,3})*(?:\\.\\d{1,2})?')\n",
608
+ "DATE_RE = re.compile(r'(\\d{1,2}[-/]\\d{1,2}[-/]\\d{2,4})')\n",
609
+ "IFSC_RE = re.compile(r'\\b[A-Z]{4}0[A-Z0-9]{6}\\b')\n",
610
+ "ACC_RE = re.compile(r'\\b\\d{9,18}\\b')\n",
611
+ "\n",
612
+ "def ocr_text(path):\n",
613
+ " if not TESSERACT_OK: return ''\n",
614
+ " try: return pytesseract.image_to_string(Image.open(path))\n",
615
+ " except Exception: return ''\n",
616
+ "\n",
617
+ "def parse_amounts(text):\n",
618
+ " out=[]\n",
619
+ " for m in AMT_RE.findall(text):\n",
620
+ " try: out.append(float(m.replace(',', '')))\n",
621
+ " except ValueError: pass\n",
622
+ " return out\n",
623
+ "\n",
624
+ "def text_rule_checks(text):\n",
625
+ " if not text:\n",
626
+ " return {'n_dates':0,'n_amounts':0,'n_ifsc':0,'n_accounts':0,'flags':['ocr_skipped']}\n",
627
+ " flags = []\n",
628
+ " dates = DATE_RE.findall(text); ifsc = IFSC_RE.findall(text)\n",
629
+ " accs = ACC_RE.findall(text); amts = parse_amounts(text)\n",
630
+ " if dates:\n",
631
+ " try:\n",
632
+ " from dateutil import parser as dp\n",
633
+ " ds = [dp.parse(d, dayfirst=True) for d in dates]\n",
634
+ " if any(ds[i] > ds[i+1] for i in range(len(ds)-1)):\n",
635
+ " flags.append('dates not monotonic')\n",
636
+ " except Exception: flags.append('unparseable dates')\n",
637
+ " if amts:\n",
638
+ " big = [a for a in amts if a >= 100000 and a % 100000 == 0]\n",
639
+ " if len(big) > 3: flags.append(f'{len(big)} suspiciously round large amounts')\n",
640
+ " if accs and not ifsc: flags.append('account number present but no IFSC')\n",
641
+ " return {'n_dates':len(dates), 'n_amounts':len(amts),\n",
642
+ " 'n_ifsc':len(ifsc), 'n_accounts':len(accs),\n",
643
+ " 'flags': flags or ['ok']}\n",
644
+ "\n",
645
+ "print('OCR + text rules ready.')\n"
646
+ ]
647
+ },
648
+ {
649
+ "cell_type": "markdown",
650
+ "metadata": {},
651
+ "source": [
652
+ "## 5. Anomaly scoring + risk band + insights\n"
653
+ ]
654
+ },
655
+ {
656
+ "cell_type": "code",
657
+ "execution_count": null,
658
+ "metadata": {},
659
+ "outputs": [],
660
+ "source": [
661
+ "WEIGHTS = {'ela':0.20, 'copy_move':0.25, 'noise':0.15, 'exif':0.10,\n",
662
+ " 'pdf_struct':0.15, 'text_rules':0.10, 'math':0.05}\n",
663
+ "\n",
664
+ "def band(s):\n",
665
+ " return ('LOW' if s<0.25 else 'MEDIUM' if s<0.50 else\n",
666
+ " 'HIGH' if s<0.75 else 'CRITICAL')\n",
667
+ "\n",
668
+ "INSIGHT_RULES = [\n",
669
+ " ('copy_move',0.4,'Possible copy-paste forgery: repeated visual region. Inspect seal/signature area.'),\n",
670
+ " ('ela', 0.4,'Compression artefacts inconsistent with a single-source scan. Likely re-saved after edits.'),\n",
671
+ " ('noise', 0.4,'Localised noise inconsistency - common in image splicing.'),\n",
672
+ " ('exif', 0.4,'Image metadata indicates edits in a photo-editor or stripped EXIF.'),\n",
673
+ " ('pdf_struct',0.4,'PDF structural anomalies (incremental edits or consumer-tool fingerprint).'),\n",
674
+ "]\n",
675
+ "ACTIONS = {'LOW':'Proceed with standard underwriting.',\n",
676
+ " 'MEDIUM':'Request additional verification documents.',\n",
677
+ " 'HIGH':'Escalate to fraud-risk team; manual review mandatory.',\n",
678
+ " 'CRITICAL':'Block file; trigger investigation workflow.'}\n",
679
+ "\n",
680
+ "def score_image(path):\n",
681
+ " _, ela_s = error_level_analysis(path)\n",
682
+ " _, ncm,_ = copy_move_detect(path)\n",
683
+ " _, nr = noise_inconsistency(path)\n",
684
+ " ef = exif_sanity(path)\n",
685
+ " sub = {'ela':min(ela_s/25.0,1.0),\n",
686
+ " 'copy_move':min(ncm/50.0,1.0),\n",
687
+ " 'noise':min(nr*4,1.0),\n",
688
+ " 'exif':0.0 if ef==['exif clean'] else 0.6}\n",
689
+ " return sum(WEIGHTS[k]*v for k,v in sub.items()), sub, ef\n",
690
+ "\n",
691
+ "def generate_insights(score, sub, extra=None):\n",
692
+ " bullets = [m for k,t,m in INSIGHT_RULES if sub.get(k,0) >= t]\n",
693
+ " if extra:\n",
694
+ " bullets += ['Flag: '+str(f) for f in extra if f not in ('exif clean','ok','clean')]\n",
695
+ " if not bullets: bullets = ['No anomaly indicators above threshold.']\n",
696
+ " return {'risk_score':round(score,3), 'risk_band':band(score),\n",
697
+ " 'recommended_action':ACTIONS[band(score)], 'evidence':bullets}\n",
698
+ "\n",
699
+ "for label, path in [('Genuine', sorted((DATA/'images/originals').glob('land_*.png'))[0]),\n",
700
+ " ('Tampered', sorted((DATA/'images/tampered').glob('land_*.png'))[0])]:\n",
701
+ " s, sub, ef = score_image(path)\n",
702
+ " print(f'{label:9s} score={s:.3f} band={band(s)} sub={sub} exif={ef}')\n"
703
+ ]
704
+ },
705
+ {
706
+ "cell_type": "markdown",
707
+ "metadata": {},
708
+ "source": [
709
+ "## 6. Random Forest training (auto-trains on data/images/)\n",
710
+ "\n",
711
+ "Extracts forensic features per image; trains a Random Forest;\n",
712
+ "saves to `models/forgery_rf.joblib`. Loaded automatically by the pipeline.\n"
713
+ ]
714
+ },
715
+ {
716
+ "cell_type": "code",
717
+ "execution_count": null,
718
+ "metadata": {},
719
+ "outputs": [],
720
+ "source": [
721
+ "from skimage.feature import graycomatrix, graycoprops\n",
722
+ "from sklearn.ensemble import RandomForestClassifier\n",
723
+ "from sklearn.model_selection import train_test_split\n",
724
+ "from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score\n",
725
+ "import joblib\n",
726
+ "\n",
727
+ "def extract_features(path):\n",
728
+ " f = {}\n",
729
+ " _, f['ela_mean'] = error_level_analysis(path)\n",
730
+ " _, f['copy_move_matches'], _ = copy_move_detect(path)\n",
731
+ " _, f['noise_outlier_ratio'] = noise_inconsistency(path)\n",
732
+ " f['exif_clean'] = int(exif_sanity(path) == ['exif clean'])\n",
733
+ " g = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)\n",
734
+ " gs = cv2.resize(g, (256,256))\n",
735
+ " glcm = graycomatrix(gs, [1], [0], 256, symmetric=True, normed=True)\n",
736
+ " f['glcm_contrast'] = float(graycoprops(glcm,'contrast')[0,0])\n",
737
+ " f['glcm_homogeneity'] = float(graycoprops(glcm,'homogeneity')[0,0])\n",
738
+ " f['glcm_energy'] = float(graycoprops(glcm,'energy')[0,0])\n",
739
+ " f['glcm_correlation'] = float(graycoprops(glcm,'correlation')[0,0])\n",
740
+ " c = cv2.imread(str(path))\n",
741
+ " if c is not None:\n",
742
+ " for i,ch in enumerate(['b','g','r']):\n",
743
+ " h = cv2.calcHist([c],[i],None,[32],[0,256]).flatten()\n",
744
+ " h = h/(h.sum()+1e-9)\n",
745
+ " f['hist_'+ch+'_entropy'] = float(-(h*np.log2(h+1e-9)).sum())\n",
746
+ " return f\n",
747
+ "\n",
748
+ "def build_training_table(root=DATA/'images'):\n",
749
+ " rows = []\n",
750
+ " for label, sub in [(0,'originals'), (1,'tampered')]:\n",
751
+ " for p in (root/sub).rglob('*'):\n",
752
+ " if p.suffix.lower() in {'.png','.jpg','.jpeg'}:\n",
753
+ " try:\n",
754
+ " f = extract_features(p)\n",
755
+ " f['label']=label; rows.append(f)\n",
756
+ " except Exception as e:\n",
757
+ " print('skip', p.name, '->', e)\n",
758
+ " return pd.DataFrame(rows)\n",
759
+ "\n",
760
+ "train_df = build_training_table()\n",
761
+ "print(f'Training rows: {len(train_df)} Classes: {train_df[\"label\"].value_counts().to_dict() if len(train_df) else \"none\"}')\n"
762
+ ]
763
+ },
764
+ {
765
+ "cell_type": "code",
766
+ "execution_count": null,
767
+ "metadata": {},
768
+ "outputs": [],
769
+ "source": [
770
+ "MODEL_PATH = Path('models/forgery_rf.joblib')\n",
771
+ "MODEL_PATH.parent.mkdir(exist_ok=True)\n",
772
+ "\n",
773
+ "if len(train_df) < 10:\n",
774
+ " print('Not enough real data - skipping training. Run section 1.1 first.')\n",
775
+ "else:\n",
776
+ " FEATURES = [c for c in train_df.columns if c != 'label']\n",
777
+ " X = train_df[FEATURES]; y = train_df['label']\n",
778
+ " Xtr,Xte,ytr,yte = train_test_split(X,y, test_size=0.25, random_state=42, stratify=y)\n",
779
+ " clf = RandomForestClassifier(n_estimators=300, max_depth=10,\n",
780
+ " class_weight='balanced', random_state=42, n_jobs=-1)\n",
781
+ " clf.fit(Xtr, ytr)\n",
782
+ " pred = clf.predict(Xte); prob = clf.predict_proba(Xte)[:,1]\n",
783
+ " print(classification_report(yte, pred, target_names=['genuine','tampered']))\n",
784
+ " print('Confusion:'); print(confusion_matrix(yte, pred))\n",
785
+ " try: print('ROC-AUC:', round(roc_auc_score(yte, prob), 3))\n",
786
+ " except Exception: pass\n",
787
+ " joblib.dump({'model':clf, 'features':FEATURES}, MODEL_PATH)\n",
788
+ " # Feature importance plot\n",
789
+ " imp = pd.Series(clf.feature_importances_, index=FEATURES).sort_values()\n",
790
+ " plt.figure(figsize=(8,5))\n",
791
+ " imp.plot.barh(color='steelblue'); plt.title('Forensic feature importance')\n",
792
+ " plt.tight_layout(); plt.show()\n",
793
+ " print(f'Model saved: {MODEL_PATH.resolve()}')\n"
794
+ ]
795
+ },
796
+ {
797
+ "cell_type": "code",
798
+ "execution_count": null,
799
+ "metadata": {},
800
+ "outputs": [],
801
+ "source": [
802
+ "def predict_with_model(path, model_path=MODEL_PATH):\n",
803
+ " if not Path(model_path).exists(): return None\n",
804
+ " b = joblib.load(model_path)\n",
805
+ " f = extract_features(path)\n",
806
+ " p = b['model'].predict_proba(pd.DataFrame([f])[b['features']])[0,1]\n",
807
+ " return {'tamper_probability':round(float(p),3),\n",
808
+ " 'verdict':'TAMPERED' if p>=0.5 else 'GENUINE'}\n",
809
+ "\n",
810
+ "if MODEL_PATH.exists():\n",
811
+ " print('Genuine :', predict_with_model(sorted((DATA/'images/originals').glob('land_*.png'))[0]))\n",
812
+ " print('Tampered:', predict_with_model(sorted((DATA/'images/tampered').glob('land_*.png'))[0]))\n"
813
+ ]
814
+ },
815
+ {
816
+ "cell_type": "markdown",
817
+ "metadata": {},
818
+ "source": [
819
+ "## 7. (Optional) CNN training on real CASIA v2\n",
820
+ "\n",
821
+ "Flip `TRAIN_CNN = True` once you have ~200+ real images per class.\n",
822
+ "Trains MobileNetV2 in two phases (head only, then unfreezed top layers).\n",
823
+ "Saves to `models/forgery_cnn.keras` + `forgery_cnn.meta.json`.\n",
824
+ "Runs in ~25 min on Colab T4 GPU. Skip if running on a CPU laptop.\n"
825
+ ]
826
+ },
827
+ {
828
+ "cell_type": "code",
829
+ "execution_count": null,
830
+ "metadata": {},
831
+ "outputs": [],
832
+ "source": [
833
+ "TRAIN_CNN = False\n",
834
+ "\n",
835
+ "if TRAIN_CNN:\n",
836
+ " if IS_COLAB: %pip install --quiet tensorflow\n",
837
+ " import tensorflow as tf\n",
838
+ " from tensorflow.keras import layers, Model\n",
839
+ " print('TF:', tf.__version__, ' GPU:', tf.config.list_physical_devices('GPU'))\n",
840
+ " IMG, BATCH = 224, 16\n",
841
+ " train_ds = tf.keras.utils.image_dataset_from_directory(\n",
842
+ " 'data/images', validation_split=0.2, subset='training', seed=42,\n",
843
+ " image_size=(IMG,IMG), batch_size=BATCH, label_mode='binary')\n",
844
+ " val_ds = tf.keras.utils.image_dataset_from_directory(\n",
845
+ " 'data/images', validation_split=0.2, subset='validation', seed=42,\n",
846
+ " image_size=(IMG,IMG), batch_size=BATCH, label_mode='binary')\n",
847
+ " CLASS_NAMES = train_ds.class_names\n",
848
+ " augment = tf.keras.Sequential([\n",
849
+ " layers.RandomFlip('horizontal'), layers.RandomRotation(0.04),\n",
850
+ " layers.RandomBrightness(0.15), layers.RandomContrast(0.15)])\n",
851
+ " train_ds = train_ds.map(lambda x,y: (augment(x,training=True), y)).prefetch(tf.data.AUTOTUNE)\n",
852
+ " val_ds = val_ds.cache().prefetch(tf.data.AUTOTUNE)\n",
853
+ " base = tf.keras.applications.MobileNetV2(input_shape=(IMG,IMG,3),\n",
854
+ " include_top=False, weights='imagenet')\n",
855
+ " base.trainable = False\n",
856
+ " inp = layers.Input(shape=(IMG,IMG,3))\n",
857
+ " x = tf.keras.applications.mobilenet_v2.preprocess_input(inp)\n",
858
+ " x = base(x, training=False)\n",
859
+ " x = layers.GlobalAveragePooling2D()(x)\n",
860
+ " x = layers.Dropout(0.35)(x)\n",
861
+ " x = layers.Dense(256, activation='relu')(x)\n",
862
+ " x = layers.Dropout(0.25)(x)\n",
863
+ " out = layers.Dense(1, activation='sigmoid')(x)\n",
864
+ " cnn = Model(inp, out)\n",
865
+ " cnn.compile(optimizer=tf.keras.optimizers.Adam(1e-3),\n",
866
+ " loss='binary_crossentropy',\n",
867
+ " metrics=['accuracy', tf.keras.metrics.AUC(name='auc')])\n",
868
+ " print('Phase 1: head-only training...')\n",
869
+ " cnn.fit(train_ds, validation_data=val_ds, epochs=5)\n",
870
+ " print('Phase 2: fine-tuning top of backbone...')\n",
871
+ " base.trainable = True\n",
872
+ " for l in base.layers[:-40]: l.trainable = False\n",
873
+ " cnn.compile(optimizer=tf.keras.optimizers.Adam(1e-5),\n",
874
+ " loss='binary_crossentropy',\n",
875
+ " metrics=['accuracy', tf.keras.metrics.AUC(name='auc')])\n",
876
+ " cnn.fit(train_ds, validation_data=val_ds, epochs=5)\n",
877
+ " loss, acc, auc = cnn.evaluate(val_ds, verbose=0)\n",
878
+ " print(f'Val loss {loss:.3f} acc {acc:.3f} AUC {auc:.3f}')\n",
879
+ " Path('models').mkdir(exist_ok=True)\n",
880
+ " cnn.save('models/forgery_cnn.keras')\n",
881
+ " json.dump({'class_names':CLASS_NAMES, 'image_size':IMG, 'val_auc':float(auc),\n",
882
+ " 'val_accuracy':float(acc)},\n",
883
+ " open('models/forgery_cnn.meta.json','w'), indent=2)\n",
884
+ " print('Saved: models/forgery_cnn.keras')\n",
885
+ "else:\n",
886
+ " print('TRAIN_CNN = False - skipping. Flip True + Colab GPU runtime to train.')\n"
887
+ ]
888
+ },
889
+ {
890
+ "cell_type": "code",
891
+ "execution_count": null,
892
+ "metadata": {},
893
+ "outputs": [],
894
+ "source": [
895
+ "CNN_MODEL_PATH = Path('models/forgery_cnn.keras')\n",
896
+ "CNN_META_PATH = Path('models/forgery_cnn.meta.json')\n",
897
+ "_CNN = {'model':None, 'meta':None, 'tried':False}\n",
898
+ "\n",
899
+ "def _load_cnn():\n",
900
+ " if _CNN['tried']: return _CNN['model'], _CNN['meta']\n",
901
+ " _CNN['tried'] = True\n",
902
+ " if not CNN_MODEL_PATH.exists(): return None, None\n",
903
+ " try:\n",
904
+ " import tensorflow as tf\n",
905
+ " _CNN['model'] = tf.keras.models.load_model(CNN_MODEL_PATH)\n",
906
+ " _CNN['meta'] = (json.loads(CNN_META_PATH.read_text())\n",
907
+ " if CNN_META_PATH.exists() else {'image_size':224})\n",
908
+ " except Exception as e: print('CNN load failed:', e)\n",
909
+ " return _CNN['model'], _CNN['meta']\n",
910
+ "\n",
911
+ "def predict_with_cnn(path):\n",
912
+ " m, meta = _load_cnn()\n",
913
+ " if m is None: return None\n",
914
+ " sz = meta.get('image_size', 224)\n",
915
+ " arr = np.array(Image.open(path).convert('RGB').resize((sz,sz)))[None].astype(np.float32)\n",
916
+ " p = float(m.predict(arr, verbose=0)[0,0])\n",
917
+ " return {'tamper_probability':round(p,3),\n",
918
+ " 'verdict':'TAMPERED' if p>=0.5 else 'GENUINE',\n",
919
+ " 'model':'MobileNetV2 (CASIA v2 fine-tuned)',\n",
920
+ " 'val_auc': meta.get('val_auc')}\n",
921
+ "\n",
922
+ "print('CNN inference ready (uses model when present).')\n"
923
+ ]
924
+ },
925
+ {
926
+ "cell_type": "markdown",
927
+ "metadata": {},
928
+ "source": [
929
+ "## 8. End-to-end pipeline\n",
930
+ "\n",
931
+ "Single `analyse_document(path)` call that:\n",
932
+ "- Detects type (image vs PDF)\n",
933
+ "- Runs all relevant detectors\n",
934
+ "- Blends RF + CNN predictions if their models exist\n",
935
+ "- Returns a complete audit dict\n"
936
+ ]
937
+ },
938
+ {
939
+ "cell_type": "code",
940
+ "execution_count": null,
941
+ "metadata": {},
942
+ "outputs": [],
943
+ "source": [
944
+ "def analyse_document(path):\n",
945
+ " path = Path(path); ext = path.suffix.lower()\n",
946
+ " r = {'file':str(path),\n",
947
+ " 'analysed_at':datetime.utcnow().isoformat()+'Z',\n",
948
+ " 'sha256':hashlib.sha256(path.read_bytes()).hexdigest()}\n",
949
+ " if ext in ('.png','.jpg','.jpeg','.tif','.tiff','.bmp'):\n",
950
+ " r['type'] = 'image'\n",
951
+ " s, sub, ef = score_image(path)\n",
952
+ " try:\n",
953
+ " tr = text_rule_checks(ocr_text(path))\n",
954
+ " sub['text_rules'] = 0.0 if tr['flags']==['ok'] else 0.5\n",
955
+ " s = sum(WEIGHTS.get(k,0)*v for k,v in sub.items())\n",
956
+ " except Exception as e: tr = {'error':str(e)}\n",
957
+ " try:\n",
958
+ " ml = predict_with_model(path)\n",
959
+ " if ml is not None:\n",
960
+ " s = 0.5*s + 0.5*ml['tamper_probability']\n",
961
+ " r['ml_prediction'] = ml\n",
962
+ " except Exception as e: r['ml_error'] = str(e)\n",
963
+ " try:\n",
964
+ " cnn = predict_with_cnn(path)\n",
965
+ " if cnn is not None:\n",
966
+ " w = max(0.4, min(0.7, (cnn.get('val_auc') or 0.85)))\n",
967
+ " s = (1-w)*s + w*cnn['tamper_probability']\n",
968
+ " r['cnn_prediction'] = cnn\n",
969
+ " except Exception as e: r['cnn_error'] = str(e)\n",
970
+ " r.update({'sub_scores':sub, 'exif_flags':ef, 'text_rules':tr,\n",
971
+ " **generate_insights(s, sub, ef+tr.get('flags',[]))})\n",
972
+ " elif ext == '.pdf':\n",
973
+ " r['type'] = 'pdf'\n",
974
+ " audit = pdf_structural_audit(path); fonts = pdf_font_audit(path)\n",
975
+ " sub = {'pdf_struct':0.8 if audit['flags']!=['clean'] else 0.1,\n",
976
+ " 'text_rules':0.6 if fonts['flags']!=['ok'] else 0.1}\n",
977
+ " s = sum(WEIGHTS.get(k,0)*v for k,v in sub.items())\n",
978
+ " r.update({'sub_scores':sub, 'pdf_audit':audit, 'font_audit':fonts,\n",
979
+ " **generate_insights(s, sub, audit['flags']+fonts['flags'])})\n",
980
+ " else: r['type']='unsupported'; r['error']='extension '+ext\n",
981
+ " return r\n",
982
+ "\n",
983
+ "# Demo on 4 files\n",
984
+ "for p in [sorted((DATA/'images/originals').glob('land_*.png'))[0],\n",
985
+ " sorted((DATA/'images/tampered').glob('land_*.png'))[0],\n",
986
+ " DATA/'pdfs/originals/agreement_000.pdf',\n",
987
+ " DATA/'pdfs/tampered/agreement_000_tampered.pdf']:\n",
988
+ " if p.exists():\n",
989
+ " r = analyse_document(p)\n",
990
+ " print(f'\\n--- {p.name} ---')\n",
991
+ " print(f\" band: {r['risk_band']} score: {r['risk_score']} action: {r['recommended_action']}\")\n",
992
+ " for e in r['evidence']: print(' *', e)\n"
993
+ ]
994
+ },
995
+ {
996
+ "cell_type": "markdown",
997
+ "metadata": {},
998
+ "source": [
999
+ "## 9. Cross-document consistency check\n",
1000
+ "\n",
1001
+ "Upload 2+ docs for the same applicant; system extracts identity fields\n",
1002
+ "and flags mismatches in name, DOB, address, account, IFSC.\n"
1003
+ ]
1004
+ },
1005
+ {
1006
+ "cell_type": "code",
1007
+ "execution_count": null,
1008
+ "metadata": {},
1009
+ "outputs": [],
1010
+ "source": [
1011
+ "NAME_RE = re.compile(r'(?:Name|Owner|Borrower|Holder|Account Holder)\\s*[:\\-]\\s*([A-Z][A-Z\\s.]{2,40})', re.I)\n",
1012
+ "DOB_RE = re.compile(r'(?:DOB|Date of Birth|Born)\\s*[:\\-]\\s*(\\d{1,2}[-/]\\d{1,2}[-/]\\d{2,4})', re.I)\n",
1013
+ "ADDR_RE = re.compile(r'(?:Address|Village|Residence)\\s*[:\\-]\\s*([A-Z0-9][A-Z0-9\\s,.\\-/]{3,80})', re.I)\n",
1014
+ "\n",
1015
+ "def _norm(s): return re.sub(r'\\s+', ' ', (s or '').strip().upper())\n",
1016
+ "\n",
1017
+ "def extract_identity_fields(path):\n",
1018
+ " if str(path).lower().endswith('.pdf'):\n",
1019
+ " with fitz.open(path) as d: text = '\\n'.join(p.get_text() for p in d)\n",
1020
+ " else: text = ocr_text(path)\n",
1021
+ " f = {k:None for k in ('name','dob','address','account','ifsc')}; f['amounts']=[]\n",
1022
+ " if not text: return f, text\n",
1023
+ " for k, rx in [('name',NAME_RE),('dob',DOB_RE),('address',ADDR_RE)]:\n",
1024
+ " m = rx.search(text)\n",
1025
+ " if m: f[k] = _norm(m.group(1))\n",
1026
+ " accs = ACC_RE.findall(text); ifsc = IFSC_RE.findall(text)\n",
1027
+ " if accs: f['account'] = accs[0]\n",
1028
+ " if ifsc: f['ifsc'] = ifsc[0]\n",
1029
+ " f['amounts'] = parse_amounts(text)\n",
1030
+ " return f, text\n",
1031
+ "\n",
1032
+ "def cross_doc_consistency(paths):\n",
1033
+ " if len(paths) < 2: return {'error':'need >=2 documents'}\n",
1034
+ " extracts = [{'file':str(p), 'fields':extract_identity_fields(p)[0]} for p in paths]\n",
1035
+ " field_results = {}\n",
1036
+ " from difflib import SequenceMatcher\n",
1037
+ " for field in ['name','dob','address','account','ifsc']:\n",
1038
+ " vals = [e['fields'].get(field) for e in extracts]\n",
1039
+ " present = [v for v in vals if v]\n",
1040
+ " if len(present) < 2:\n",
1041
+ " field_results[field] = {'status':'insufficient_data','values':vals,'similarity':None}\n",
1042
+ " continue\n",
1043
+ " sims = [SequenceMatcher(None, a, b).ratio()\n",
1044
+ " for i,a in enumerate(present) for b in present[i+1:]]\n",
1045
+ " ms = min(sims)\n",
1046
+ " status = 'match' if ms>=0.95 else 'likely_match' if ms>=0.75 else 'mismatch'\n",
1047
+ " field_results[field] = {'status':status,'values':vals,'similarity':round(ms,3)}\n",
1048
+ " mm = sum(1 for r in field_results.values() if r['status']=='mismatch')\n",
1049
+ " lm = sum(1 for r in field_results.values() if r['status']=='likely_match')\n",
1050
+ " rs = min(1.0, mm*0.5 + lm*0.2)\n",
1051
+ " return {'documents':extracts, 'field_results':field_results,\n",
1052
+ " 'mismatches':mm, 'likely_mismatches':lm,\n",
1053
+ " 'consistency_risk_score':round(rs,3), 'consistency_band':band(rs)}\n",
1054
+ "\n",
1055
+ "files = [sorted((DATA/'images/originals').glob('land_*.png'))[0],\n",
1056
+ " sorted((DATA/'images/originals').glob('agreement_*.png'))[0]]\n",
1057
+ "r = cross_doc_consistency(files)\n",
1058
+ "print('Band:', r['consistency_band'], ' Mismatches:', r['mismatches'])\n",
1059
+ "for f, v in r['field_results'].items():\n",
1060
+ " print(f' {f:10s} {v[\"status\"]:20s} sim={v[\"similarity\"]}')\n"
1061
+ ]
1062
+ },
1063
+ {
1064
+ "cell_type": "markdown",
1065
+ "metadata": {},
1066
+ "source": [
1067
+ "## 10. Underwriter dashboard + batch audit\n"
1068
+ ]
1069
+ },
1070
+ {
1071
+ "cell_type": "code",
1072
+ "execution_count": null,
1073
+ "metadata": {},
1074
+ "outputs": [],
1075
+ "source": [
1076
+ "def render_dashboard(report):\n",
1077
+ " fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
1078
+ " sub = report.get('sub_scores', {})\n",
1079
+ " if sub:\n",
1080
+ " keys = list(sub.keys()); vals = list(sub.values())\n",
1081
+ " bars = axes[0].barh(keys, vals)\n",
1082
+ " for b,v in zip(bars, vals):\n",
1083
+ " b.set_color('green' if v<0.4 else 'orange' if v<0.7 else 'red')\n",
1084
+ " axes[0].set_xlim(0,1); axes[0].set_title('Sub-scores')\n",
1085
+ " axes[1].axis('off')\n",
1086
+ " cmap = {'LOW':'green','MEDIUM':'gold','HIGH':'orange','CRITICAL':'red'}\n",
1087
+ " risk = report.get('risk_band','N/A')\n",
1088
+ " axes[1].text(0.05, 0.85, f'RISK: {risk}', fontsize=22,\n",
1089
+ " color=cmap.get(risk,'black'), weight='bold')\n",
1090
+ " axes[1].text(0.05, 0.70, f'Score: {report.get(\"risk_score\",\"-\")}', fontsize=14)\n",
1091
+ " axes[1].text(0.05, 0.60, f'Action: {report.get(\"recommended_action\",\"-\")}', fontsize=11)\n",
1092
+ " y = 0.45\n",
1093
+ " for e in report.get('evidence', []):\n",
1094
+ " axes[1].text(0.05, y, '- '+e, fontsize=10, wrap=True); y -= 0.07\n",
1095
+ " plt.tight_layout(); plt.show()\n",
1096
+ "\n",
1097
+ "render_dashboard(analyse_document(sorted((DATA/'images/tampered').glob('land_*.png'))[0]))\n"
1098
+ ]
1099
+ },
1100
+ {
1101
+ "cell_type": "code",
1102
+ "execution_count": null,
1103
+ "metadata": {},
1104
+ "outputs": [],
1105
+ "source": [
1106
+ "def batch_analyse(folder, out_csv='audit_log.csv'):\n",
1107
+ " folder = Path(folder); reports = []\n",
1108
+ " for p in folder.rglob('*'):\n",
1109
+ " if p.suffix.lower() in {'.png','.jpg','.jpeg','.pdf'}:\n",
1110
+ " try: reports.append(analyse_document(p))\n",
1111
+ " except Exception as e: reports.append({'file':str(p),'error':str(e)})\n",
1112
+ " df = pd.DataFrame([{'file':r.get('file'), 'type':r.get('type'),\n",
1113
+ " 'risk_score':r.get('risk_score'),\n",
1114
+ " 'risk_band':r.get('risk_band'),\n",
1115
+ " 'action':r.get('recommended_action')} for r in reports])\n",
1116
+ " df.to_csv(out_csv, index=False)\n",
1117
+ " return df\n",
1118
+ "\n",
1119
+ "audit = batch_analyse(DATA)\n",
1120
+ "audit.head(10)\n"
1121
+ ]
1122
+ },
1123
+ {
1124
+ "cell_type": "markdown",
1125
+ "metadata": {},
1126
+ "source": [
1127
+ "## 11. PDF audit report generator\n",
1128
+ "\n",
1129
+ "Bank-letterhead PDF with risk verdict, evidence, embedded heatmaps.\n",
1130
+ "Uses ReportLab.\n"
1131
+ ]
1132
+ },
1133
+ {
1134
+ "cell_type": "code",
1135
+ "execution_count": null,
1136
+ "metadata": {},
1137
+ "outputs": [],
1138
+ "source": [
1139
+ "%pip install --quiet reportlab\n",
1140
+ "from reportlab.lib.pagesizes import A4\n",
1141
+ "from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle\n",
1142
+ "from reportlab.lib.units import cm\n",
1143
+ "from reportlab.lib import colors\n",
1144
+ "from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,\n",
1145
+ " TableStyle, Image as RLImage, PageBreak)\n",
1146
+ "from reportlab.lib.enums import TA_CENTER\n",
1147
+ "\n",
1148
+ "BAND_C = {'LOW':colors.HexColor('#16a34a'),'MEDIUM':colors.HexColor('#ca8a04'),\n",
1149
+ " 'HIGH':colors.HexColor('#ea580c'),'CRITICAL':colors.HexColor('#dc2626')}\n",
1150
+ "\n",
1151
+ "def build_pdf_report(report, source_path):\n",
1152
+ " source_path = Path(source_path); s = getSampleStyleSheet()\n",
1153
+ " s.add(ParagraphStyle('Title2', parent=s['Title'], fontSize=22,\n",
1154
+ " textColor=colors.HexColor('#1e3a8a')))\n",
1155
+ " s.add(ParagraphStyle('Mono', parent=s['Normal'], fontName='Courier',\n",
1156
+ " fontSize=8, textColor=colors.dimgray))\n",
1157
+ " buf = io.BytesIO()\n",
1158
+ " doc = SimpleDocTemplate(buf, pagesize=A4,\n",
1159
+ " leftMargin=2*cm, rightMargin=2*cm,\n",
1160
+ " topMargin=1.5*cm, bottomMargin=1.5*cm)\n",
1161
+ " story = [\n",
1162
+ " Paragraph('DOCSENTRY - DOCUMENT FORENSICS REPORT', s['Title2']),\n",
1163
+ " Paragraph('<para alignment=\"center\"><font color=\"grey\">Confidential - For Underwriting Use Only</font></para>', s['Normal']),\n",
1164
+ " Spacer(1, 0.4*cm),\n",
1165
+ " ]\n",
1166
+ " # metadata table\n",
1167
+ " meta = [['Field','Value'],\n",
1168
+ " ['Document', source_path.name],\n",
1169
+ " ['Type', report.get('type','-')],\n",
1170
+ " ['Analysed at', report.get('analysed_at','-')[:19].replace('T',' ')],\n",
1171
+ " ['SHA-256', report.get('sha256','-')[:32]+'...']]\n",
1172
+ " t = Table(meta, colWidths=[4*cm, 13*cm])\n",
1173
+ " t.setStyle(TableStyle([\n",
1174
+ " ('BACKGROUND',(0,0),(-1,0), colors.HexColor('#1e3a8a')),\n",
1175
+ " ('TEXTCOLOR',(0,0),(-1,0), colors.white),\n",
1176
+ " ('GRID',(0,0),(-1,-1), 0.4, colors.grey),\n",
1177
+ " ('FONTSIZE',(0,0),(-1,-1), 9)]))\n",
1178
+ " story += [t, Spacer(1, 0.4*cm)]\n",
1179
+ " # verdict box\n",
1180
+ " band_str = report.get('risk_band','UNKNOWN')\n",
1181
+ " bc = BAND_C.get(band_str, colors.grey)\n",
1182
+ " vt = Table([[Paragraph(f'<para alignment=\"center\"><font size=22 color=\"white\"><b>{band_str}</b></font></para>', s['Normal']),\n",
1183
+ " Paragraph(f'<b>Risk score:</b> {report.get(\"risk_score\",\"-\")}<br/>'\n",
1184
+ " f'<b>Action:</b> {report.get(\"recommended_action\",\"-\")}', s['Normal'])]],\n",
1185
+ " colWidths=[5*cm, 12*cm])\n",
1186
+ " vt.setStyle(TableStyle([\n",
1187
+ " ('BACKGROUND',(0,0),(0,0), bc),\n",
1188
+ " ('BACKGROUND',(1,0),(1,0), colors.HexColor('#f1f5f9')),\n",
1189
+ " ('VALIGN',(0,0),(-1,-1),'MIDDLE'),\n",
1190
+ " ('TOPPADDING',(0,0),(-1,-1), 12),\n",
1191
+ " ('BOTTOMPADDING',(0,0),(-1,-1), 12),\n",
1192
+ " ('LEFTPADDING',(0,0),(-1,-1), 12),\n",
1193
+ " ('RIGHTPADDING',(0,0),(-1,-1), 12)]))\n",
1194
+ " story.append(vt)\n",
1195
+ " story.append(Spacer(1, 0.4*cm))\n",
1196
+ " # evidence\n",
1197
+ " story.append(Paragraph('<b>Forensic evidence</b>', s['Heading3']))\n",
1198
+ " for e in report.get('evidence', []):\n",
1199
+ " story.append(Paragraph('&bull; '+e, s['Normal']))\n",
1200
+ " story.append(Spacer(1, 0.3*cm))\n",
1201
+ " story.append(Paragraph('<i>Generated by DocSentry. Heuristic + ML ensemble. '\n",
1202
+ " 'Manual review required for HIGH/CRITICAL.</i>', s['Mono']))\n",
1203
+ " doc.build(story)\n",
1204
+ " buf.seek(0); return buf.read()\n",
1205
+ "\n",
1206
+ "# Generate a sample report\n",
1207
+ "sample = sorted((DATA/'images/tampered').glob('land_*.png'))[0]\n",
1208
+ "r = analyse_document(sample)\n",
1209
+ "pdf_bytes = build_pdf_report(r, sample)\n",
1210
+ "Path('reports').mkdir(exist_ok=True)\n",
1211
+ "(Path('reports')/f'audit_{sample.stem}.pdf').write_bytes(pdf_bytes)\n",
1212
+ "print(f'Wrote: reports/audit_{sample.stem}.pdf ({len(pdf_bytes)} bytes)')\n"
1213
+ ]
1214
+ },
1215
+ {
1216
+ "cell_type": "markdown",
1217
+ "metadata": {},
1218
+ "source": [
1219
+ "## 12. Export Streamlit demo files (forensics.py, app.py, audit_report.py)\n",
1220
+ "\n",
1221
+ "**This is the cell that links the notebook to the live web demo.**\n",
1222
+ "Run it once. It writes 3 files at the repo root with the same logic\n",
1223
+ "as above. After that:\n",
1224
+ "```\n",
1225
+ "streamlit run app.py\n",
1226
+ "```\n",
1227
+ "\n",
1228
+ "Re-run this cell any time you change the detector logic and want the app\n",
1229
+ "to pick up your changes. The cell is idempotent.\n"
1230
+ ]
1231
+ },
1232
+ {
1233
+ "cell_type": "code",
1234
+ "execution_count": null,
1235
+ "metadata": {},
1236
+ "outputs": [],
1237
+ "source": [
1238
+ "# Section 12 - Generate forensics.py from in-notebook functions\n",
1239
+ "import inspect\n",
1240
+ "\n",
1241
+ "FORENSICS_HEADER = \"\"\"# Auto-generated from docsentry_master.ipynb. Edit notebook, not this file.\n",
1242
+ "import os, io, re, math, json, hashlib, shutil, warnings\n",
1243
+ "from pathlib import Path\n",
1244
+ "from datetime import datetime\n",
1245
+ "import numpy as np, pandas as pd\n",
1246
+ "from PIL import Image, ImageChops, ImageEnhance\n",
1247
+ "import cv2, fitz, pytesseract, joblib\n",
1248
+ "from skimage.feature import graycomatrix, graycoprops\n",
1249
+ "from difflib import SequenceMatcher\n",
1250
+ "warnings.filterwarnings('ignore')\n",
1251
+ "\n",
1252
+ "TESSERACT_OK = False\n",
1253
+ "for _c in [shutil.which('tesseract'),\n",
1254
+ " r'C:\\\\Program Files\\\\Tesseract-OCR\\\\tesseract.exe',\n",
1255
+ " r'C:\\\\Program Files (x86)\\\\Tesseract-OCR\\\\tesseract.exe',\n",
1256
+ " os.path.expanduser(r'~\\\\AppData\\\\Local\\\\Programs\\\\Tesseract-OCR\\\\tesseract.exe')]:\n",
1257
+ " if _c and os.path.isfile(_c):\n",
1258
+ " pytesseract.pytesseract.tesseract_cmd = _c\n",
1259
+ " TESSERACT_OK = True; break\n",
1260
+ "\n",
1261
+ "AMT_RE = re.compile(r'(?<![A-Za-z])[-]?\\\\d{1,3}(?:,\\\\d{2,3})*(?:\\\\.\\\\d{1,2})?')\n",
1262
+ "DATE_RE = re.compile(r'(\\\\d{1,2}[-/]\\\\d{1,2}[-/]\\\\d{2,4})')\n",
1263
+ "IFSC_RE = re.compile(r'\\\\b[A-Z]{4}0[A-Z0-9]{6}\\\\b')\n",
1264
+ "ACC_RE = re.compile(r'\\\\b\\\\d{9,18}\\\\b')\n",
1265
+ "NAME_RE = re.compile(r'(?:Name|Owner|Borrower|Holder|Account Holder)\\\\s*[:\\\\-]\\\\s*([A-Z][A-Z\\\\s.]{2,40})', re.IGNORECASE)\n",
1266
+ "DOB_RE = re.compile(r'(?:DOB|Date of Birth|Born)\\\\s*[:\\\\-]\\\\s*(\\\\d{1,2}[-/]\\\\d{1,2}[-/]\\\\d{2,4})', re.IGNORECASE)\n",
1267
+ "ADDR_RE = re.compile(r'(?:Address|Village|Residence)\\\\s*[:\\\\-]\\\\s*([A-Z0-9][A-Z0-9\\\\s,.\\\\-/]{3,80})', re.IGNORECASE)\n",
1268
+ "\n",
1269
+ "MODEL_PATH = Path('models/forgery_rf.joblib')\n",
1270
+ "CNN_MODEL_PATH = Path('models/forgery_cnn.keras')\n",
1271
+ "CNN_META_PATH = Path('models/forgery_cnn.meta.json')\n",
1272
+ "_CNN = {'model': None, 'meta': None, 'tried': False}\n",
1273
+ "\"\"\"\n",
1274
+ "\n",
1275
+ "FORENSICS_HEADER += f'WEIGHTS = {WEIGHTS!r}\\n'\n",
1276
+ "FORENSICS_HEADER += f'INSIGHT_RULES = {INSIGHT_RULES!r}\\n'\n",
1277
+ "FORENSICS_HEADER += f'ACTIONS = {ACTIONS!r}\\n\\n'\n",
1278
+ "\n",
1279
+ "FUNCS = [error_level_analysis, copy_move_detect, noise_inconsistency,\n",
1280
+ " exif_sanity, pdf_structural_audit, pdf_font_audit,\n",
1281
+ " ocr_text, parse_amounts, text_rule_checks,\n",
1282
+ " band, score_image, generate_insights,\n",
1283
+ " extract_features, predict_with_model,\n",
1284
+ " _load_cnn, predict_with_cnn,\n",
1285
+ " extract_identity_fields, cross_doc_consistency,\n",
1286
+ " analyse_document]\n",
1287
+ "body = ''\n",
1288
+ "for fn in FUNCS:\n",
1289
+ " body += inspect.getsource(fn) + '\\n'\n",
1290
+ "\n",
1291
+ "Path('forensics.py').write_text(FORENSICS_HEADER + body)\n",
1292
+ "print('Wrote forensics.py')\n",
1293
+ "import ast\n",
1294
+ "ast.parse(Path('forensics.py').read_text())\n",
1295
+ "print('forensics.py syntax OK')\n"
1296
+ ]
1297
+ },
1298
+ {
1299
+ "cell_type": "code",
1300
+ "execution_count": null,
1301
+ "metadata": {},
1302
+ "outputs": [],
1303
+ "source": [
1304
+ "APP_PY = '''\"\"\"app.py - Streamlit demo (auto-generated from docsentry_master.ipynb)\"\"\"\n",
1305
+ "import io, json, tempfile\n",
1306
+ "from pathlib import Path\n",
1307
+ "import streamlit as st\n",
1308
+ "import numpy as np, pandas as pd, cv2\n",
1309
+ "from PIL import Image\n",
1310
+ "import matplotlib.pyplot as plt\n",
1311
+ "import forensics\n",
1312
+ "from audit_report import build_pdf_report\n",
1313
+ "\n",
1314
+ "st.set_page_config(page_title=\"DocSentry\", page_icon=\":lock:\", layout=\"wide\")\n",
1315
+ "st.markdown(\"\"\"<style>.big-risk{font-size:48px;font-weight:800;padding:14px 28px;\n",
1316
+ " border-radius:12px;color:white;text-align:center}.low{background:#16a34a}\n",
1317
+ " .medium{background:#ca8a04}.high{background:#ea580c}.critical{background:#dc2626}\n",
1318
+ " </style>\"\"\", unsafe_allow_html=True)\n",
1319
+ "st.title(\":shield: DocSentry - Document Forensics\")\n",
1320
+ "st.caption(\"Real-time anomaly detection for underwriting.\")\n",
1321
+ "if not forensics.TESSERACT_OK:\n",
1322
+ " st.warning(\"Tesseract not installed - text-rule checks skipped.\")\n",
1323
+ "\n",
1324
+ "def risk_badge(b): st.markdown(f\"<div class=\\'big-risk {b.lower()}\\'>{b}</div>\", unsafe_allow_html=True)\n",
1325
+ "def save(u):\n",
1326
+ " t = tempfile.NamedTemporaryFile(delete=False, suffix=Path(u.name).suffix)\n",
1327
+ " t.write(u.getbuffer()); t.close(); return Path(t.name)\n",
1328
+ "\n",
1329
+ "tab1, tab2, tab3 = st.tabs([\":mag: Single doc\", \":busts_in_silhouette: Cross-doc\", \":file_folder: Batch\"])\n",
1330
+ "\n",
1331
+ "with tab1:\n",
1332
+ " sd = Path(\"sample_data\")\n",
1333
+ " samples = [p for sub in (\"originals\",\"tampered\",\"pdfs\") for p in sorted((sd/sub).glob(\"*\")) if sd.exists()]\n",
1334
+ " opts = [\"(upload)\"] + [str(p.relative_to(sd)) for p in samples]\n",
1335
+ " pick = st.selectbox(\"Try a sample, or upload:\", opts)\n",
1336
+ " path = None\n",
1337
+ " if pick != \"(upload)\": path = sd / pick\n",
1338
+ " else:\n",
1339
+ " u = st.file_uploader(\"Upload\", type=[\"png\",\"jpg\",\"jpeg\",\"pdf\"])\n",
1340
+ " if u: path = save(u)\n",
1341
+ " if path:\n",
1342
+ " r = forensics.analyse_document(path)\n",
1343
+ " c1, c2 = st.columns([1,2])\n",
1344
+ " with c1: risk_badge(r[\"risk_band\"]); st.metric(\"Score\", f'{r[\"risk_score\"]:.3f}')\n",
1345
+ " with c2:\n",
1346
+ " st.info(r[\"recommended_action\"])\n",
1347
+ " for e in r[\"evidence\"]: st.markdown(\"- \" + e)\n",
1348
+ " st.image(str(path), use_container_width=True) if r[\"type\"]==\"image\" else None\n",
1349
+ " if r[\"type\"] == \"image\":\n",
1350
+ " ela, _ = forensics.error_level_analysis(path)\n",
1351
+ " viz, n, _ = forensics.copy_move_detect(path)\n",
1352
+ " t1, t2 = st.tabs([\"ELA\", f\"Copy-move ({n})\"])\n",
1353
+ " with t1: st.image(ela)\n",
1354
+ " with t2: st.image(cv2.cvtColor(viz, cv2.COLOR_BGR2RGB))\n",
1355
+ " if \"ml_prediction\" in r:\n",
1356
+ " ml = r[\"ml_prediction\"]; st.metric(\"RF verdict\", f\"{ml[\\'tamper_probability\\']:.1%}\")\n",
1357
+ " if \"cnn_prediction\" in r:\n",
1358
+ " cnn = r[\"cnn_prediction\"]; st.metric(\"CNN verdict\", f\"{cnn[\\'tamper_probability\\']:.1%}\")\n",
1359
+ " st.download_button(\"Audit JSON\", json.dumps(r, indent=2, default=str),\n",
1360
+ " file_name=f\"audit_{path.stem}.json\")\n",
1361
+ " try:\n",
1362
+ " pdf = build_pdf_report(r, path)\n",
1363
+ " st.download_button(\"Audit PDF\", pdf, file_name=f\"audit_{path.stem}.pdf\")\n",
1364
+ " except Exception as e: st.caption(f\"PDF report: {e}\")\n",
1365
+ "\n",
1366
+ "with tab2:\n",
1367
+ " ups = st.file_uploader(\"Upload 2+ docs\", type=[\"png\",\"jpg\",\"pdf\"], accept_multiple_files=True)\n",
1368
+ " if ups and len(ups) >= 2:\n",
1369
+ " paths = [save(u) for u in ups]\n",
1370
+ " r = forensics.cross_doc_consistency(paths)\n",
1371
+ " risk_badge(r[\"consistency_band\"])\n",
1372
+ " st.metric(\"Mismatches\", r[\"mismatches\"])\n",
1373
+ " rows = []\n",
1374
+ " for f, v in r[\"field_results\"].items():\n",
1375
+ " rows.append({\"Field\":f, \"Status\":v[\"status\"], \"Similarity\":v[\"similarity\"]})\n",
1376
+ " st.dataframe(pd.DataFrame(rows), use_container_width=True)\n",
1377
+ "\n",
1378
+ "with tab3:\n",
1379
+ " default = Path.cwd() / (\"sample_data\" if not (Path.cwd()/\"data\").exists() else \"data\")\n",
1380
+ " folder = st.text_input(\"Folder\", value=str(default))\n",
1381
+ " if st.button(\"Audit\"):\n",
1382
+ " root = Path(folder); reports = []\n",
1383
+ " for p in root.rglob(\"*\"):\n",
1384
+ " if p.suffix.lower() in {\".png\",\".jpg\",\".jpeg\",\".pdf\"}:\n",
1385
+ " try: reports.append(forensics.analyse_document(p))\n",
1386
+ " except Exception as e: reports.append({\"file\":str(p),\"error\":str(e)})\n",
1387
+ " df = pd.DataFrame([{\"file\":r.get(\"file\"), \"band\":r.get(\"risk_band\"),\n",
1388
+ " \"score\":r.get(\"risk_score\")} for r in reports])\n",
1389
+ " st.dataframe(df, use_container_width=True)\n",
1390
+ " st.download_button(\"CSV\", df.to_csv(index=False), file_name=\"audit_log.csv\")\n",
1391
+ "'''\n",
1392
+ "Path('app.py').write_text(APP_PY)\n",
1393
+ "print('Wrote app.py')\n"
1394
+ ]
1395
+ },
1396
+ {
1397
+ "cell_type": "code",
1398
+ "execution_count": null,
1399
+ "metadata": {},
1400
+ "outputs": [],
1401
+ "source": [
1402
+ "AUDIT_PY = '''\"\"\"audit_report.py - PDF report (auto-generated from notebook)\"\"\"\n",
1403
+ "import io\n",
1404
+ "from pathlib import Path\n",
1405
+ "from reportlab.lib.pagesizes import A4\n",
1406
+ "from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle\n",
1407
+ "from reportlab.lib.units import cm\n",
1408
+ "from reportlab.lib import colors\n",
1409
+ "from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle\n",
1410
+ "\n",
1411
+ "BAND_C = {\"LOW\":colors.HexColor(\"#16a34a\"),\"MEDIUM\":colors.HexColor(\"#ca8a04\"),\n",
1412
+ " \"HIGH\":colors.HexColor(\"#ea580c\"),\"CRITICAL\":colors.HexColor(\"#dc2626\")}\n",
1413
+ "\n",
1414
+ "def build_pdf_report(report, source_path):\n",
1415
+ " source_path = Path(source_path); s = getSampleStyleSheet()\n",
1416
+ " s.add(ParagraphStyle(\"Title2\", parent=s[\"Title\"], fontSize=22,\n",
1417
+ " textColor=colors.HexColor(\"#1e3a8a\")))\n",
1418
+ " buf = io.BytesIO()\n",
1419
+ " doc = SimpleDocTemplate(buf, pagesize=A4,\n",
1420
+ " leftMargin=2*cm, rightMargin=2*cm,\n",
1421
+ " topMargin=1.5*cm, bottomMargin=1.5*cm)\n",
1422
+ " story = [Paragraph(\"DOCSENTRY - DOCUMENT FORENSICS REPORT\", s[\"Title2\"]),\n",
1423
+ " Spacer(1, 0.3*cm)]\n",
1424
+ " meta = [[\"Document\", source_path.name],\n",
1425
+ " [\"Type\", report.get(\"type\",\"-\")],\n",
1426
+ " [\"Analysed at\", report.get(\"analysed_at\",\"-\")[:19].replace(\"T\",\" \")],\n",
1427
+ " [\"SHA-256\", report.get(\"sha256\",\"-\")[:32]+\"...\"]]\n",
1428
+ " t = Table(meta, colWidths=[4*cm, 13*cm])\n",
1429
+ " t.setStyle(TableStyle([(\"GRID\",(0,0),(-1,-1),0.4,colors.grey),(\"FONTSIZE\",(0,0),(-1,-1),9)]))\n",
1430
+ " story += [t, Spacer(1, 0.4*cm)]\n",
1431
+ " band_str = report.get(\"risk_band\",\"UNKNOWN\")\n",
1432
+ " bc = BAND_C.get(band_str, colors.grey)\n",
1433
+ " vt = Table([[Paragraph(f\\'<para alignment=\"center\"><font size=22 color=\"white\"><b>{band_str}</b></font></para>\\', s[\"Normal\"]),\n",
1434
+ " Paragraph(f\\'<b>Risk score:</b> {report.get(\"risk_score\",\"-\")}<br/><b>Action:</b> {report.get(\"recommended_action\",\"-\")}\\', s[\"Normal\"])]],\n",
1435
+ " colWidths=[5*cm, 12*cm])\n",
1436
+ " vt.setStyle(TableStyle([(\"BACKGROUND\",(0,0),(0,0), bc),\n",
1437
+ " (\"BACKGROUND\",(1,0),(1,0), colors.HexColor(\"#f1f5f9\")),\n",
1438
+ " (\"VALIGN\",(0,0),(-1,-1),\"MIDDLE\"),\n",
1439
+ " (\"TOPPADDING\",(0,0),(-1,-1),12),(\"BOTTOMPADDING\",(0,0),(-1,-1),12)]))\n",
1440
+ " story.append(vt); story.append(Spacer(1, 0.4*cm))\n",
1441
+ " story.append(Paragraph(\"<b>Forensic evidence</b>\", s[\"Heading3\"]))\n",
1442
+ " for e in report.get(\"evidence\",[]): story.append(Paragraph(\"&bull; \"+e, s[\"Normal\"]))\n",
1443
+ " doc.build(story)\n",
1444
+ " buf.seek(0); return buf.read()\n",
1445
+ "'''\n",
1446
+ "Path('audit_report.py').write_text(AUDIT_PY)\n",
1447
+ "# requirements + packages.txt for Streamlit Cloud\n",
1448
+ "Path('requirements.txt').write_text('\\\\n'.join([\n",
1449
+ " 'numpy','pandas','matplotlib','scikit-image','scikit-learn','joblib',\n",
1450
+ " 'opencv-python-headless','Pillow','pytesseract','pdfplumber','pymupdf',\n",
1451
+ " 'pikepdf','python-dateutil','streamlit','reportlab','tensorflow-cpu']))\n",
1452
+ "Path('packages.txt').write_text('tesseract-ocr\\\\nlibtesseract-dev\\\\n')\n",
1453
+ "print('Wrote audit_report.py, requirements.txt, packages.txt')\n",
1454
+ "print()\n",
1455
+ "print('Streamlit demo files are ready. Launch with:')\n",
1456
+ "print(' streamlit run app.py')\n"
1457
+ ]
1458
+ },
1459
+ {
1460
+ "cell_type": "markdown",
1461
+ "metadata": {},
1462
+ "source": [
1463
+ "## 13. Launch the live app\n",
1464
+ "\n",
1465
+ "After running section 12, the supporting files exist at the repo root.\n",
1466
+ "Open a terminal in this folder and run:\n",
1467
+ "\n",
1468
+ "```\n",
1469
+ "streamlit run app.py\n",
1470
+ "```\n",
1471
+ "\n",
1472
+ "Or to deploy to Streamlit Community Cloud (free public URL):\n",
1473
+ "1. Push this folder to a public GitHub repo\n",
1474
+ "2. Connect at https://share.streamlit.io\n",
1475
+ "3. Pick the repo, main file `app.py`, click Deploy\n",
1476
+ "\n",
1477
+ "## 14. Where to go next\n",
1478
+ "\n",
1479
+ "- **Train CNN on real CASIA v2** - section 7, flip `TRAIN_CNN=True` on Colab GPU\n",
1480
+ "- **Add signature verification** - Siamese network for borrower signatures\n",
1481
+ "- **Wrap as FastAPI** - turn `analyse_document` into an HTTP endpoint\n",
1482
+ "- **Grad-CAM overlays** - show which pixels the CNN flagged\n",
1483
+ "\n",
1484
+ "**Everything in this notebook is free, runs CPU-only by default, and demos end-to-end without any paid API call.**\n"
1485
+ ]
1486
+ }
1487
+ ],
1488
+ "metadata": {
1489
+ "kernelspec": {
1490
+ "display_name": "Python 3",
1491
+ "language": "python",
1492
+ "name": "python3"
1493
+ },
1494
+ "language_info": {
1495
+ "name": "python",
1496
+ "version": "3.10"
1497
+ },
1498
+ "colab": {
1499
+ "provenance": [],
1500
+ "toc_visible": true
1501
+ }
1502
+ },
1503
+ "nbformat": 4,
1504
+ "nbformat_minor": 5
1505
+ }
forensics.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ forensics.py - Document forensics core module
3
+
4
+ Reusable analysis functions extracted from anomaly_detection_banking.ipynb.
5
+ Imported by app.py (Streamlit) and the notebook.
6
+
7
+ Public API:
8
+ analyse_document(path) - end-to-end pipeline
9
+ score_image(path) - image-only forensic score
10
+ error_level_analysis(path) - ELA image + score
11
+ copy_move_detect(path) - copy-move heatmap + match count
12
+ noise_inconsistency(path) - noise heatmap + outlier ratio
13
+ exif_sanity(path) - metadata flags
14
+ pdf_structural_audit(path) - EOF count + producer/creator
15
+ pdf_font_audit(path) - font count + flags
16
+ ocr_text(path) - OCR (no-op if Tesseract missing)
17
+ text_rule_checks(text) - date/amount/IFSC sanity
18
+ extract_features(path) - feature vector for ML model
19
+ predict_with_model(path) - run trained Random Forest if present
20
+ generate_insights(score, sub, flags) - rule-based bullets
21
+ band(score) - score -> LOW/MEDIUM/HIGH/CRITICAL
22
+ """
23
+
24
+ import os
25
+ import io
26
+ import re
27
+ import math
28
+ import json
29
+ import hashlib
30
+ import shutil
31
+ import warnings
32
+ from pathlib import Path
33
+ from datetime import datetime
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+ from PIL import Image, ImageChops, ImageEnhance
38
+ import cv2
39
+ import fitz # PyMuPDF
40
+ import pytesseract
41
+
42
+ warnings.filterwarnings("ignore")
43
+
44
+ # -------------------------------------------------------------
45
+ # Tesseract auto-detect (Windows-friendly)
46
+ # -------------------------------------------------------------
47
+ TESSERACT_OK = False
48
+ for _c in [
49
+ shutil.which("tesseract"),
50
+ r"C:\Program Files\Tesseract-OCR\tesseract.exe",
51
+ r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
52
+ os.path.expanduser(r"~\AppData\Local\Programs\Tesseract-OCR\tesseract.exe"),
53
+ ]:
54
+ if _c and os.path.isfile(_c):
55
+ pytesseract.pytesseract.tesseract_cmd = _c
56
+ TESSERACT_OK = True
57
+ break
58
+
59
+ # -------------------------------------------------------------
60
+ # Image forensics
61
+ # -------------------------------------------------------------
62
+ def error_level_analysis(path, quality=90, scale=15):
63
+ orig = Image.open(path).convert("RGB")
64
+ buf = io.BytesIO()
65
+ orig.save(buf, "JPEG", quality=quality)
66
+ buf.seek(0)
67
+ resaved = Image.open(buf)
68
+ diff = ImageChops.difference(orig, resaved)
69
+ extrema = diff.getextrema()
70
+ max_diff = max([e[1] for e in extrema]) or 1
71
+ ela = ImageEnhance.Brightness(diff).enhance(scale * 255 / max_diff)
72
+ score = float(np.array(diff).mean())
73
+ return ela, score
74
+
75
+
76
+ def copy_move_detect(path, min_dist=40, max_matches=80):
77
+ img = cv2.imread(str(path))
78
+ if img is None:
79
+ return None, 0, []
80
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
81
+ orb = cv2.ORB_create(nfeatures=2000)
82
+ kp, des = orb.detectAndCompute(gray, None)
83
+ if des is None or len(kp) < 10:
84
+ return img, 0, []
85
+ bf = cv2.BFMatcher(cv2.NORM_HAMMING)
86
+ matches = bf.knnMatch(des, des, k=10)
87
+ good = []
88
+ for m_list in matches:
89
+ for m in m_list[1:]:
90
+ p1 = kp[m.queryIdx].pt
91
+ p2 = kp[m.trainIdx].pt
92
+ d = math.hypot(p1[0] - p2[0], p1[1] - p2[1])
93
+ if d > min_dist and m.distance < 40:
94
+ good.append((p1, p2, d))
95
+ good = good[:max_matches]
96
+ out = img.copy()
97
+ for p1, p2, _ in good:
98
+ cv2.line(out, tuple(map(int, p1)), tuple(map(int, p2)), (0, 0, 255), 1)
99
+ cv2.circle(out, tuple(map(int, p1)), 3, (0, 255, 0), -1)
100
+ cv2.circle(out, tuple(map(int, p2)), 3, (0, 255, 0), -1)
101
+ return out, len(good), good
102
+
103
+
104
+ def noise_inconsistency(path, block=32):
105
+ img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
106
+ if img is None:
107
+ return np.zeros((1, 1)), 0.0
108
+ H, W = img.shape
109
+ Hc, Wc = (H // block) * block, (W // block) * block
110
+ if Hc == 0 or Wc == 0:
111
+ return np.zeros((1, 1)), 0.0
112
+ img = img[:Hc, :Wc]
113
+ lap = cv2.Laplacian(img, cv2.CV_64F)
114
+ lap_blocks = (lap.reshape(Hc // block, block, Wc // block, block)
115
+ .transpose(0, 2, 1, 3)
116
+ .reshape(-1, block * block))
117
+ var = lap_blocks.var(axis=1)
118
+ z = (var - var.mean()) / (var.std() + 1e-9)
119
+ suspicious = (np.abs(z) > 2.5).sum() / max(1, len(z))
120
+ heat = np.abs(z).reshape(Hc // block, Wc // block)
121
+ return heat, float(suspicious)
122
+
123
+
124
+ def exif_sanity(path):
125
+ try:
126
+ img = Image.open(path)
127
+ exif = img.getexif()
128
+ except Exception:
129
+ return ["cannot read image"]
130
+ if not exif:
131
+ return ["no EXIF metadata (re-saved or stripped)"]
132
+ tags = {Image.ExifTags.TAGS.get(k, k): v for k, v in exif.items()}
133
+ flags = []
134
+ sw = str(tags.get("Software", "")).lower()
135
+ for bad in ["photoshop", "gimp", "paint", "snapseed", "picsart"]:
136
+ if bad in sw:
137
+ flags.append("edited with " + bad)
138
+ if "DateTimeOriginal" in tags and "DateTime" in tags:
139
+ if tags["DateTimeOriginal"] != tags["DateTime"]:
140
+ flags.append("modified-time differs from original-time")
141
+ return flags or ["exif clean"]
142
+
143
+
144
+ # -------------------------------------------------------------
145
+ # PDF forensics
146
+ # -------------------------------------------------------------
147
+ def pdf_structural_audit(path):
148
+ raw = Path(path).read_bytes()
149
+ eofs = raw.count(b"%%EOF")
150
+ with fitz.open(path) as d:
151
+ info = d.metadata or {}
152
+ n_pages = d.page_count
153
+ flags = []
154
+ if eofs > 1:
155
+ flags.append(f"{eofs} EOF markers (incremental updates)")
156
+ prod = (info.get("producer") or "").lower()
157
+ crt = (info.get("creator") or "").lower()
158
+ if prod and crt and prod != crt:
159
+ flags.append(f"producer/creator differ: {prod} vs {crt}")
160
+ for t in ["ilovepdf", "smallpdf", "pdfescape", "sejda", "foxit phantom"]:
161
+ if t in prod or t in crt:
162
+ flags.append("edited via consumer tool: " + t)
163
+ return {"pages": n_pages, "eof_markers": eofs,
164
+ "metadata": info, "flags": flags or ["clean"]}
165
+
166
+
167
+ def pdf_font_audit(path):
168
+ fonts_per_page = []
169
+ with fitz.open(path) as d:
170
+ for page in d:
171
+ fonts_per_page.append({f[3] for f in page.get_fonts()})
172
+ all_fonts = set().union(*fonts_per_page) if fonts_per_page else set()
173
+ flags = []
174
+ if len(all_fonts) > 4:
175
+ flags.append("unusually high font count: " + str(len(all_fonts)))
176
+ return {"fonts": sorted(all_fonts), "flags": flags or ["ok"]}
177
+
178
+
179
+ # -------------------------------------------------------------
180
+ # OCR + text rules
181
+ # -------------------------------------------------------------
182
+ AMT_RE = re.compile(r"(?<![A-Za-z])[-]?\d{1,3}(?:,\d{2,3})*(?:\.\d{1,2})?")
183
+ DATE_RE = re.compile(r"(\d{1,2}[-/]\d{1,2}[-/]\d{2,4})")
184
+ IFSC_RE = re.compile(r"\b[A-Z]{4}0[A-Z0-9]{6}\b")
185
+ ACC_RE = re.compile(r"\b\d{9,18}\b")
186
+
187
+
188
+ def ocr_text(path):
189
+ if not TESSERACT_OK:
190
+ return ""
191
+ try:
192
+ return pytesseract.image_to_string(Image.open(path))
193
+ except Exception:
194
+ return ""
195
+
196
+
197
+ def parse_amounts(text):
198
+ vals = []
199
+ for m in AMT_RE.findall(text):
200
+ try:
201
+ vals.append(float(m.replace(",", "")))
202
+ except ValueError:
203
+ pass
204
+ return vals
205
+
206
+
207
+ def text_rule_checks(text):
208
+ if not text:
209
+ return {"n_dates": 0, "n_amounts": 0, "n_ifsc": 0,
210
+ "n_accounts": 0, "flags": ["ocr_skipped"]}
211
+ flags = []
212
+ dates = DATE_RE.findall(text)
213
+ ifsc = IFSC_RE.findall(text)
214
+ accs = ACC_RE.findall(text)
215
+ amts = parse_amounts(text)
216
+ if dates:
217
+ try:
218
+ from dateutil import parser as dp
219
+ ds = [dp.parse(d, dayfirst=True) for d in dates]
220
+ if any(ds[i] > ds[i + 1] for i in range(len(ds) - 1)):
221
+ flags.append("dates not monotonic")
222
+ except Exception:
223
+ flags.append("unparseable dates")
224
+ if amts:
225
+ big_round = [a for a in amts if a >= 100000 and a % 100000 == 0]
226
+ if len(big_round) > 3:
227
+ flags.append(f"{len(big_round)} suspiciously round large amounts")
228
+ if accs and not ifsc:
229
+ flags.append("account number present but no IFSC")
230
+ return {"n_dates": len(dates), "n_amounts": len(amts),
231
+ "n_ifsc": len(ifsc), "n_accounts": len(accs),
232
+ "flags": flags or ["ok"]}
233
+
234
+
235
+ # -------------------------------------------------------------
236
+ # Scoring & insights
237
+ # -------------------------------------------------------------
238
+ WEIGHTS = {"ela": 0.20, "copy_move": 0.25, "noise": 0.15, "exif": 0.10,
239
+ "pdf_struct": 0.15, "text_rules": 0.10, "math": 0.05}
240
+
241
+ INSIGHT_RULES = [
242
+ ("copy_move", 0.4, "Possible copy-paste forgery: repeated visual region. Inspect seal/signature area."),
243
+ ("ela", 0.4, "Compression artefacts inconsistent with a single-source scan. Likely re-saved after edits."),
244
+ ("noise", 0.4, "Localised noise inconsistency - common in image splicing."),
245
+ ("exif", 0.4, "Image metadata indicates edits in a photo-editor or stripped EXIF."),
246
+ ("pdf_struct", 0.4, "PDF structural anomalies detected (incremental edits / consumer-tool fingerprint)."),
247
+ ]
248
+
249
+ ACTIONS = {
250
+ "LOW": "Proceed with standard underwriting.",
251
+ "MEDIUM": "Request additional verification documents.",
252
+ "HIGH": "Escalate to fraud-risk team; manual review mandatory.",
253
+ "CRITICAL": "Block file; trigger investigation workflow.",
254
+ }
255
+
256
+
257
+ def band(score):
258
+ if score < 0.25: return "LOW"
259
+ if score < 0.50: return "MEDIUM"
260
+ if score < 0.75: return "HIGH"
261
+ return "CRITICAL"
262
+
263
+
264
+ def score_image(path):
265
+ _, ela_s = error_level_analysis(path)
266
+ _, n_cm, _ = copy_move_detect(path)
267
+ _, noise_r = noise_inconsistency(path)
268
+ exif_flags = exif_sanity(path)
269
+ sub = {"ela": min(ela_s / 25.0, 1.0),
270
+ "copy_move": min(n_cm / 50.0, 1.0),
271
+ "noise": min(noise_r * 4, 1.0),
272
+ "exif": 0.0 if exif_flags == ["exif clean"] else 0.6}
273
+ total = sum(WEIGHTS[k] * v for k, v in sub.items())
274
+ return total, sub, exif_flags
275
+
276
+
277
+ def generate_insights(score, sub_scores, extra_flags=None):
278
+ bullets = []
279
+ for key, thresh, msg in INSIGHT_RULES:
280
+ if sub_scores.get(key, 0) >= thresh:
281
+ bullets.append(msg)
282
+ if extra_flags:
283
+ for f in extra_flags:
284
+ if f not in ("exif clean", "ok", "clean"):
285
+ bullets.append("Flag: " + str(f))
286
+ if not bullets:
287
+ bullets.append("No anomaly indicators above threshold.")
288
+ return {"risk_score": round(score, 3),
289
+ "risk_band": band(score),
290
+ "recommended_action": ACTIONS[band(score)],
291
+ "evidence": bullets}
292
+
293
+
294
+ # -------------------------------------------------------------
295
+ # ML feature extraction + prediction
296
+ # -------------------------------------------------------------
297
+ MODEL_PATH = Path("models/forgery_rf.joblib")
298
+ CNN_MODEL_PATH = Path("models/forgery_cnn.keras")
299
+ CNN_META_PATH = Path("models/forgery_cnn.meta.json")
300
+
301
+ _CNN_CACHE = {"model": None, "meta": None, "tried": False}
302
+
303
+
304
+ def _load_cnn():
305
+ """Lazy-load the CNN model only when first needed (avoids TF import cost)."""
306
+ if _CNN_CACHE["tried"]:
307
+ return _CNN_CACHE["model"], _CNN_CACHE["meta"]
308
+ _CNN_CACHE["tried"] = True
309
+ if not CNN_MODEL_PATH.exists():
310
+ return None, None
311
+ try:
312
+ import tensorflow as tf # local import - heavy
313
+ _CNN_CACHE["model"] = tf.keras.models.load_model(CNN_MODEL_PATH)
314
+ if CNN_META_PATH.exists():
315
+ _CNN_CACHE["meta"] = json.loads(CNN_META_PATH.read_text())
316
+ else:
317
+ _CNN_CACHE["meta"] = {"image_size": 224, "class_names": ["originals", "tampered"]}
318
+ except Exception as e:
319
+ print("CNN load failed:", e)
320
+ return _CNN_CACHE["model"], _CNN_CACHE["meta"]
321
+
322
+
323
+ def predict_with_cnn(path):
324
+ """Run the trained CNN if forgery_cnn.keras exists. Returns dict or None."""
325
+ model, meta = _load_cnn()
326
+ if model is None:
327
+ return None
328
+ img_size = meta.get("image_size", 224)
329
+ img = Image.open(path).convert("RGB").resize((img_size, img_size))
330
+ arr = np.array(img)[None, ...].astype(np.float32)
331
+ prob = float(model.predict(arr, verbose=0)[0, 0])
332
+ return {
333
+ "tamper_probability": round(prob, 3),
334
+ "verdict": "TAMPERED" if prob >= 0.5 else "GENUINE",
335
+ "model": "MobileNetV2 (CASIA v2 fine-tuned)",
336
+ "val_auc": (meta or {}).get("val_auc"),
337
+ }
338
+
339
+
340
+
341
+
342
+ def extract_features(path):
343
+ from skimage.feature import graycomatrix, graycoprops
344
+ feats = {}
345
+ _, ela_score = error_level_analysis(path)
346
+ feats["ela_mean"] = ela_score
347
+ _, cm_count, _ = copy_move_detect(path)
348
+ feats["copy_move_matches"] = cm_count
349
+ _, noise_ratio = noise_inconsistency(path)
350
+ feats["noise_outlier_ratio"] = noise_ratio
351
+ feats["exif_clean"] = int(exif_sanity(path) == ["exif clean"])
352
+ img = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
353
+ img_s = cv2.resize(img, (256, 256))
354
+ glcm = graycomatrix(img_s, [1], [0], 256, symmetric=True, normed=True)
355
+ feats["glcm_contrast"] = float(graycoprops(glcm, "contrast")[0, 0])
356
+ feats["glcm_homogeneity"] = float(graycoprops(glcm, "homogeneity")[0, 0])
357
+ feats["glcm_energy"] = float(graycoprops(glcm, "energy")[0, 0])
358
+ feats["glcm_correlation"] = float(graycoprops(glcm, "correlation")[0, 0])
359
+ col = cv2.imread(str(path))
360
+ if col is not None:
361
+ for i, ch in enumerate(["b", "g", "r"]):
362
+ hist = cv2.calcHist([col], [i], None, [32], [0, 256]).flatten()
363
+ hist = hist / (hist.sum() + 1e-9)
364
+ feats["hist_" + ch + "_entropy"] = float(-(hist * np.log2(hist + 1e-9)).sum())
365
+ return feats
366
+
367
+
368
+ def predict_with_model(path, model_path=MODEL_PATH):
369
+ import joblib
370
+ if not Path(model_path).exists():
371
+ return None
372
+ bundle = joblib.load(model_path)
373
+ feats = extract_features(path)
374
+ x = pd.DataFrame([feats])[bundle["features"]]
375
+ p = bundle["model"].predict_proba(x)[0, 1]
376
+ return {"file": str(path), "tamper_probability": round(float(p), 3),
377
+ "verdict": "TAMPERED" if p >= 0.5 else "GENUINE",
378
+ "features": feats}
379
+
380
+
381
+ # -------------------------------------------------------------
382
+ # End-to-end pipeline
383
+ # -------------------------------------------------------------
384
+ def analyse_document(path):
385
+ path = Path(path)
386
+ ext = path.suffix.lower()
387
+ report = {"file": str(path),
388
+ "analysed_at": datetime.utcnow().isoformat() + "Z",
389
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
390
+
391
+ if ext in (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"):
392
+ report["type"] = "image"
393
+ s, sub, flags = score_image(path)
394
+ try:
395
+ txt = ocr_text(path)
396
+ text_rules = text_rule_checks(txt)
397
+ sub["text_rules"] = 0.0 if text_rules["flags"] == ["ok"] else 0.5
398
+ s = sum(WEIGHTS.get(k, 0) * v for k, v in sub.items())
399
+ except Exception as e:
400
+ text_rules = {"error": str(e)}
401
+ # Blend in RF prediction if model exists
402
+ try:
403
+ ml = predict_with_model(path)
404
+ if ml is not None:
405
+ s = 0.5 * s + 0.5 * ml["tamper_probability"]
406
+ report["ml_prediction"] = ml
407
+ except Exception as e:
408
+ report["ml_error"] = str(e)
409
+ # Blend in CNN prediction if model exists (weight rises with val_auc)
410
+ try:
411
+ cnn = predict_with_cnn(path)
412
+ if cnn is not None:
413
+ # If CNN AUC is known and high, give it more weight than rule-score
414
+ w = max(0.4, min(0.7, (cnn.get("val_auc") or 0.85)))
415
+ s = (1 - w) * s + w * cnn["tamper_probability"]
416
+ report["cnn_prediction"] = cnn
417
+ except Exception as e:
418
+ report["cnn_error"] = str(e)
419
+ insights = generate_insights(s, sub, flags + text_rules.get("flags", []))
420
+ report.update({"sub_scores": sub, "exif_flags": flags,
421
+ "text_rules": text_rules, **insights})
422
+
423
+ elif ext == ".pdf":
424
+ report["type"] = "pdf"
425
+ audit = pdf_structural_audit(path)
426
+ fonts = pdf_font_audit(path)
427
+ sub = {"pdf_struct": 0.8 if audit["flags"] != ["clean"] else 0.1,
428
+ "text_rules": 0.6 if fonts["flags"] != ["ok"] else 0.1}
429
+ s = sum(WEIGHTS.get(k, 0) * v for k, v in sub.items())
430
+ insights = generate_insights(s, sub, audit["flags"] + fonts["flags"])
431
+ report.update({"sub_scores": sub, "pdf_audit": audit,
432
+ "font_audit": fonts, **insights})
433
+ else:
434
+ report["type"] = "unsupported"
435
+ report["error"] = "extension " + ext + " not handled"
436
+ return report
437
+
438
+
439
+ # -------------------------------------------------------------
440
+ # Cross-document consistency (Sprint 2)
441
+ # -------------------------------------------------------------
442
+ NAME_RE = re.compile(r"(?:Name|Owner|Borrower|Holder|Account Holder)\s*[:\-]\s*([A-Z][A-Z\s.]{2,40})", re.IGNORECASE)
443
+ DOB_RE = re.compile(r"(?:DOB|Date of Birth|Born)\s*[:\-]\s*(\d{1,2}[-/]\d{1,2}[-/]\d{2,4})", re.IGNORECASE)
444
+ ADDR_RE = re.compile(r"(?:Address|Village|Residence)\s*[:\-]\s*([A-Z0-9][A-Z0-9\s,.\-/]{3,80})", re.IGNORECASE)
445
+
446
+
447
+ def _norm(s):
448
+ return re.sub(r"\s+", " ", (s or "").strip().upper())
449
+
450
+
451
+ def extract_identity_fields(path):
452
+ """Pull name, DOB, address, account, IFSC from any document via OCR."""
453
+ if str(path).lower().endswith(".pdf"):
454
+ with fitz.open(path) as d:
455
+ text = "\n".join(page.get_text() for page in d)
456
+ else:
457
+ text = ocr_text(path)
458
+ fields = {
459
+ "name": None, "dob": None, "address": None,
460
+ "account": None, "ifsc": None, "amounts": [],
461
+ }
462
+ if not text:
463
+ return fields, text
464
+ m = NAME_RE.search(text)
465
+ if m: fields["name"] = _norm(m.group(1))
466
+ m = DOB_RE.search(text)
467
+ if m: fields["dob"] = _norm(m.group(1))
468
+ m = ADDR_RE.search(text)
469
+ if m: fields["address"] = _norm(m.group(1))
470
+ accs = ACC_RE.findall(text)
471
+ if accs: fields["account"] = accs[0]
472
+ ifsc = IFSC_RE.findall(text)
473
+ if ifsc: fields["ifsc"] = ifsc[0]
474
+ fields["amounts"] = parse_amounts(text)
475
+ return fields, text
476
+
477
+
478
+ def _similarity(a, b):
479
+ """Simple ratio-based string similarity."""
480
+ if not a or not b:
481
+ return 0.0
482
+ from difflib import SequenceMatcher
483
+ return SequenceMatcher(None, a, b).ratio()
484
+
485
+
486
+ def cross_doc_consistency(file_paths):
487
+ """Compare identity fields across 2+ documents. Return per-field verdict."""
488
+ if len(file_paths) < 2:
489
+ return {"error": "need at least 2 documents"}
490
+ extracts = []
491
+ for p in file_paths:
492
+ fields, _ = extract_identity_fields(p)
493
+ extracts.append({"file": str(p), "fields": fields})
494
+ # Compare each field across docs
495
+ field_results = {}
496
+ for field in ["name", "dob", "address", "account", "ifsc"]:
497
+ values = [e["fields"].get(field) for e in extracts]
498
+ present = [v for v in values if v]
499
+ if len(present) < 2:
500
+ field_results[field] = {
501
+ "status": "insufficient_data",
502
+ "values": values,
503
+ "similarity": None,
504
+ }
505
+ continue
506
+ # All-pairs similarity
507
+ sims = []
508
+ for i in range(len(present)):
509
+ for j in range(i + 1, len(present)):
510
+ sims.append(_similarity(present[i], present[j]))
511
+ min_sim = min(sims)
512
+ if min_sim >= 0.95:
513
+ status = "match"
514
+ elif min_sim >= 0.75:
515
+ status = "likely_match"
516
+ else:
517
+ status = "mismatch"
518
+ field_results[field] = {
519
+ "status": status,
520
+ "values": values,
521
+ "similarity": round(min_sim, 3),
522
+ }
523
+ # Aggregate risk
524
+ mismatches = sum(1 for r in field_results.values() if r["status"] == "mismatch")
525
+ likely = sum(1 for r in field_results.values() if r["status"] == "likely_match")
526
+ risk_score = min(1.0, mismatches * 0.5 + likely * 0.2)
527
+ return {
528
+ "documents": extracts,
529
+ "field_results": field_results,
530
+ "mismatches": mismatches,
531
+ "likely_mismatches": likely,
532
+ "consistency_risk_score": round(risk_score, 3),
533
+ "consistency_band": band(risk_score),
534
+ }
535
+
536
+
537
+ if __name__ == "__main__":
538
+ import sys
539
+ if len(sys.argv) < 2:
540
+ print("Usage: python forensics.py <file>")
541
+ sys.exit(1)
542
+ print(json.dumps(analyse_document(sys.argv[1]), indent=2, default=str))
models/forgery_rf.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4f3d5924665984784844d47cae20b79373439173253289921a6608c76a0cd346
3
+ size 243698
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ tesseract-ocr\nlibtesseract-dev\n
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ numpy\npandas\nmatplotlib\nscikit-image\nscikit-learn\njoblib\nopencv-python-headless\nPillow\npytesseract\npdfplumber\npymupdf\npikepdf\npython-dateutil\nstreamlit\nreportlab\ntensorflow-cpu
sample_data/originals/agreement_000.png ADDED

Git LFS Details

  • SHA256: 26496810e5a1db82b08bc14e4474454da12e5b078499d8f2d7d753d797e3ae89
  • Pointer size: 131 Bytes
  • Size of remote file: 867 kB
sample_data/originals/agreement_001.png ADDED

Git LFS Details

  • SHA256: 39a75886465612ca46d5b215f8fc75caf547964abb2c7f11addc65f43019142e
  • Pointer size: 131 Bytes
  • Size of remote file: 869 kB
sample_data/originals/agreement_002.png ADDED

Git LFS Details

  • SHA256: 0ecabee4a5163d7668df9762aa9662869ea47083ec2806d78f693e3f8e5f74bd
  • Pointer size: 131 Bytes
  • Size of remote file: 867 kB
sample_data/originals/agreement_003.png ADDED

Git LFS Details

  • SHA256: fefd35f1ef35801e0b341d4c859cecbc336b4938019fc5856735841c4636ca3e
  • Pointer size: 131 Bytes
  • Size of remote file: 869 kB
sample_data/originals/land_000.png ADDED

Git LFS Details

  • SHA256: 4c5854504393302498d540bb6ef75c3cc80bfc7ea3c5070f70accb8f9707a482
  • Pointer size: 131 Bytes
  • Size of remote file: 766 kB
sample_data/originals/land_001.png ADDED

Git LFS Details

  • SHA256: 54898de9e54a02332b4ce2b0b38dde047ce55aac6736d80921029164119b83b9
  • Pointer size: 131 Bytes
  • Size of remote file: 747 kB
sample_data/originals/land_002.png ADDED

Git LFS Details

  • SHA256: 82fd6da6f322b43f346093c94b58e2a2981607cd8bb27d1646d2c40c770fcd2b
  • Pointer size: 131 Bytes
  • Size of remote file: 747 kB
sample_data/originals/land_003.png ADDED

Git LFS Details

  • SHA256: 397368b6ff7a2bf0af7a7c53fae92cbe57669d14f9082b266e2474dcf0d5ce44
  • Pointer size: 131 Bytes
  • Size of remote file: 766 kB
sample_data/originals/statement_000.png ADDED

Git LFS Details

  • SHA256: a3ec901b2421c461d6671b04836d39cc2250f306c579934c3add54e8ebfd752a
  • Pointer size: 132 Bytes
  • Size of remote file: 1.04 MB
sample_data/originals/statement_001.png ADDED

Git LFS Details

  • SHA256: 310811324dada6bbf5f747e34158a2bf8f654296e43176b0a543a5a81ecdd70e
  • Pointer size: 132 Bytes
  • Size of remote file: 1.03 MB
sample_data/originals/statement_002.png ADDED

Git LFS Details

  • SHA256: aee8870225ab52ae910ecf62ee23b8b95180713fed5f16596faa710e59d35ba8
  • Pointer size: 132 Bytes
  • Size of remote file: 1.03 MB
sample_data/originals/statement_003.png ADDED

Git LFS Details

  • SHA256: 648dffea7bac606606c00a619027d61da81ad44ff92245705978bd5c8d6c7636
  • Pointer size: 132 Bytes
  • Size of remote file: 1.03 MB
sample_data/pdfs/agreement_000.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2e3cc121f8054d518169c7cf1ed65f37420586301ae89b4fdd5535df61ed8e04
3
+ size 1051
sample_data/pdfs/agreement_000_tampered.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:abb944a9428840c93416b2cdaa85ee58851e523c6f67df6b3f1e0d3d0dc6c2f3
3
+ size 1362
sample_data/tampered/agreement_000_splice.png ADDED

Git LFS Details

  • SHA256: f27fc1b1dcbd8b722e83a1d52e4e94e5472f987516bba18245e9490c50de3795
  • Pointer size: 131 Bytes
  • Size of remote file: 927 kB
sample_data/tampered/agreement_001_text_edit.png ADDED

Git LFS Details

  • SHA256: 89348fb91f3ea0efc19cea01404437cfc17fe975419d3dcbaaffaf7e9a0cb784
  • Pointer size: 131 Bytes
  • Size of remote file: 923 kB
sample_data/tampered/agreement_003_copy_move.png ADDED

Git LFS Details

  • SHA256: 6cbaf7ede00aed4de4a37ed1cd2c407a9680c9b38d33965541e5dec783520dc5
  • Pointer size: 131 Bytes
  • Size of remote file: 927 kB
sample_data/tampered/agreement_005_compression_after_edit.png ADDED

Git LFS Details

  • SHA256: 461d446b21872998c6779289a5f0fd208026d7dbfaa41e928008064810d935ee
  • Pointer size: 132 Bytes
  • Size of remote file: 1 MB
sample_data/tampered/land_000_splice.png ADDED

Git LFS Details

  • SHA256: 8524f9c342f514d39e8d456fe74037cfa489a5051850f3c38901d258a1d9489c
  • Pointer size: 131 Bytes
  • Size of remote file: 796 kB
sample_data/tampered/land_001_text_edit.png ADDED

Git LFS Details

  • SHA256: 80cb210b5ff5ec2adf66e370c9b6f3f037c987088e6cbd14a6d4c4b81c00e4ed
  • Pointer size: 131 Bytes
  • Size of remote file: 793 kB
sample_data/tampered/land_003_compression_after_edit.png ADDED

Git LFS Details

  • SHA256: 36f3d6c72e3b1beb2cad51920c5a5e6d51a766cd085911567e7ccb3113622df2
  • Pointer size: 131 Bytes
  • Size of remote file: 822 kB
sample_data/tampered/land_015_copy_move.png ADDED

Git LFS Details

  • SHA256: 108d215975b6f6b9eeda911885a0d45931cae3d7bbedccdbc4da37623f8a1ce0
  • Pointer size: 131 Bytes
  • Size of remote file: 794 kB
sample_data/tampered/statement_000_copy_move.png ADDED

Git LFS Details

  • SHA256: 0ed3ab40bb4b7626bbf3ba9e774d4588c64f43a8aca74fcbb8a2af0d4d0709c5
  • Pointer size: 132 Bytes
  • Size of remote file: 1.08 MB
sample_data/tampered/statement_001_compression_after_edit.png ADDED

Git LFS Details

  • SHA256: 7eae8655a6343e144d5d7f0acbf4ff33e1c4af74fcb0e27dbe90d7775fcc279d
  • Pointer size: 132 Bytes
  • Size of remote file: 1.14 MB
sample_data/tampered/statement_002_text_edit.png ADDED

Git LFS Details

  • SHA256: dba51027d831344bdfcfb85a79f7642a007d949873ba9ebbda27a3b7ff0e34cf
  • Pointer size: 132 Bytes
  • Size of remote file: 1.09 MB
sample_data/tampered/statement_003_splice.png ADDED

Git LFS Details

  • SHA256: 78fcac1c4af537cc1c8ca8e357eb2747ba0bcd3e112ef0efc42d63c228170bf1
  • Pointer size: 132 Bytes
  • Size of remote file: 1.09 MB