Spaces:
Sleeping
Sleeping
Commit ·
05b69f8
0
Parent(s):
DocSentry - bank document forensics with 4 tabs
Browse files- .gitattributes +6 -0
- .gitignore +36 -0
- .streamlit/config.toml +13 -0
- DATASETS.md +195 -0
- DEPLOY.md +133 -0
- PUSH.md +90 -0
- README.md +103 -0
- RUN_APP.md +117 -0
- app.py +522 -0
- audit_report.py +262 -0
- compliance.py +408 -0
- docsentry_master.ipynb +1505 -0
- forensics.py +542 -0
- models/forgery_rf.joblib +3 -0
- packages.txt +1 -0
- requirements.txt +1 -0
- sample_data/originals/agreement_000.png +3 -0
- sample_data/originals/agreement_001.png +3 -0
- sample_data/originals/agreement_002.png +3 -0
- sample_data/originals/agreement_003.png +3 -0
- sample_data/originals/land_000.png +3 -0
- sample_data/originals/land_001.png +3 -0
- sample_data/originals/land_002.png +3 -0
- sample_data/originals/land_003.png +3 -0
- sample_data/originals/statement_000.png +3 -0
- sample_data/originals/statement_001.png +3 -0
- sample_data/originals/statement_002.png +3 -0
- sample_data/originals/statement_003.png +3 -0
- sample_data/pdfs/agreement_000.pdf +3 -0
- sample_data/pdfs/agreement_000_tampered.pdf +3 -0
- sample_data/tampered/agreement_000_splice.png +3 -0
- sample_data/tampered/agreement_001_text_edit.png +3 -0
- sample_data/tampered/agreement_003_copy_move.png +3 -0
- sample_data/tampered/agreement_005_compression_after_edit.png +3 -0
- sample_data/tampered/land_000_splice.png +3 -0
- sample_data/tampered/land_001_text_edit.png +3 -0
- sample_data/tampered/land_003_compression_after_edit.png +3 -0
- sample_data/tampered/land_015_copy_move.png +3 -0
- sample_data/tampered/statement_000_copy_move.png +3 -0
- sample_data/tampered/statement_001_compression_after_edit.png +3 -0
- sample_data/tampered/statement_002_text_edit.png +3 -0
- 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 |
+
[](https://streamlit.io)
|
| 4 |
+
[](https://www.python.org)
|
| 5 |
+
[](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"• {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"• {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("• " + 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('• '+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(\"• \"+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
|
sample_data/originals/agreement_001.png
ADDED
|
Git LFS Details
|
sample_data/originals/agreement_002.png
ADDED
|
Git LFS Details
|
sample_data/originals/agreement_003.png
ADDED
|
Git LFS Details
|
sample_data/originals/land_000.png
ADDED
|
Git LFS Details
|
sample_data/originals/land_001.png
ADDED
|
Git LFS Details
|
sample_data/originals/land_002.png
ADDED
|
Git LFS Details
|
sample_data/originals/land_003.png
ADDED
|
Git LFS Details
|
sample_data/originals/statement_000.png
ADDED
|
Git LFS Details
|
sample_data/originals/statement_001.png
ADDED
|
Git LFS Details
|
sample_data/originals/statement_002.png
ADDED
|
Git LFS Details
|
sample_data/originals/statement_003.png
ADDED
|
Git LFS Details
|
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
|
sample_data/tampered/agreement_001_text_edit.png
ADDED
|
Git LFS Details
|
sample_data/tampered/agreement_003_copy_move.png
ADDED
|
Git LFS Details
|
sample_data/tampered/agreement_005_compression_after_edit.png
ADDED
|
Git LFS Details
|
sample_data/tampered/land_000_splice.png
ADDED
|
Git LFS Details
|
sample_data/tampered/land_001_text_edit.png
ADDED
|
Git LFS Details
|
sample_data/tampered/land_003_compression_after_edit.png
ADDED
|
Git LFS Details
|
sample_data/tampered/land_015_copy_move.png
ADDED
|
Git LFS Details
|
sample_data/tampered/statement_000_copy_move.png
ADDED
|
Git LFS Details
|
sample_data/tampered/statement_001_compression_after_edit.png
ADDED
|
Git LFS Details
|
sample_data/tampered/statement_002_text_edit.png
ADDED
|
Git LFS Details
|
sample_data/tampered/statement_003_splice.png
ADDED
|
Git LFS Details
|