Add unified_pipeline: merged signature-verification synthetic document generator (part 15)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +71 -0
- unified_pipeline/README.md +844 -0
- unified_pipeline/data/raw/signet/train/060_forg/01_0111060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/01_0121060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/01_0126060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/02_0111060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/02_0121060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/02_0126060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/03_0111060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/03_0121060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/03_0126060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/04_0111060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/04_0121060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/060_forg/04_0126060.PNG +3 -0
- unified_pipeline/data/raw/signet/train/061/01_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/02_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/03_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/04_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/05_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/06_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/07_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/08_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/09_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/10_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/11_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061/12_061.png +3 -0
- unified_pipeline/data/raw/signet/train/061_forg/01_0102061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/01_0112061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/01_0206061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/02_0102061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/02_0112061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/02_0206061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/03_0102061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/03_0112061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/03_0206061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/04_0102061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/04_0112061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/061_forg/04_0206061.PNG +0 -0
- unified_pipeline/data/raw/signet/train/062/01_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/02_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/03_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/04_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/05_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/06_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/07_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/08_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/09_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/10_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/11_062.png +3 -0
- unified_pipeline/data/raw/signet/train/062/12_062.png +3 -0
.gitattributes
CHANGED
|
@@ -4974,3 +4974,74 @@ unified_pipeline/data/raw/signet/train/059_forg/03_0104059.PNG filter=lfs diff=l
|
|
| 4974 |
unified_pipeline/data/raw/signet/train/059_forg/03_0125059.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4975 |
unified_pipeline/data/raw/signet/train/059_forg/04_0104059.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4976 |
unified_pipeline/data/raw/signet/train/059_forg/04_0125059.PNG filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4974 |
unified_pipeline/data/raw/signet/train/059_forg/03_0125059.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4975 |
unified_pipeline/data/raw/signet/train/059_forg/04_0104059.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4976 |
unified_pipeline/data/raw/signet/train/059_forg/04_0125059.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4977 |
+
unified_pipeline/data/raw/signet/train/060_forg/01_0111060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4978 |
+
unified_pipeline/data/raw/signet/train/060_forg/01_0121060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4979 |
+
unified_pipeline/data/raw/signet/train/060_forg/01_0126060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4980 |
+
unified_pipeline/data/raw/signet/train/060_forg/02_0111060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4981 |
+
unified_pipeline/data/raw/signet/train/060_forg/02_0121060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4982 |
+
unified_pipeline/data/raw/signet/train/060_forg/02_0126060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4983 |
+
unified_pipeline/data/raw/signet/train/060_forg/03_0111060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4984 |
+
unified_pipeline/data/raw/signet/train/060_forg/03_0121060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4985 |
+
unified_pipeline/data/raw/signet/train/060_forg/03_0126060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4986 |
+
unified_pipeline/data/raw/signet/train/060_forg/04_0111060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4987 |
+
unified_pipeline/data/raw/signet/train/060_forg/04_0121060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4988 |
+
unified_pipeline/data/raw/signet/train/060_forg/04_0126060.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4989 |
+
unified_pipeline/data/raw/signet/train/062_forg/01_0116062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4990 |
+
unified_pipeline/data/raw/signet/train/062_forg/01_0201062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4991 |
+
unified_pipeline/data/raw/signet/train/062_forg/02_0116062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4992 |
+
unified_pipeline/data/raw/signet/train/062_forg/02_0201062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4993 |
+
unified_pipeline/data/raw/signet/train/062_forg/03_0109062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4994 |
+
unified_pipeline/data/raw/signet/train/062_forg/03_0116062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4995 |
+
unified_pipeline/data/raw/signet/train/062_forg/03_0201062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4996 |
+
unified_pipeline/data/raw/signet/train/062_forg/04_0109062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4997 |
+
unified_pipeline/data/raw/signet/train/062_forg/04_0116062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4998 |
+
unified_pipeline/data/raw/signet/train/062_forg/04_0201062.PNG filter=lfs diff=lfs merge=lfs -text
|
| 4999 |
+
unified_pipeline/data/raw/signet/train/063_forg/01_0104063.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5000 |
+
unified_pipeline/data/raw/signet/train/063_forg/01_0119063.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5001 |
+
unified_pipeline/data/raw/signet/train/063_forg/02_0104063.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5002 |
+
unified_pipeline/data/raw/signet/train/063_forg/02_0119063.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5003 |
+
unified_pipeline/data/raw/signet/train/063_forg/03_0104063.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5004 |
+
unified_pipeline/data/raw/signet/train/063_forg/04_0104063.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5005 |
+
unified_pipeline/data/raw/signet/train/064_forg/01_0105064.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5006 |
+
unified_pipeline/data/raw/signet/train/064_forg/01_0203064.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5007 |
+
unified_pipeline/data/raw/signet/train/064_forg/02_0105064.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5008 |
+
unified_pipeline/data/raw/signet/train/064_forg/02_0203064.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5009 |
+
unified_pipeline/data/raw/signet/train/064_forg/03_0105064.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5010 |
+
unified_pipeline/data/raw/signet/train/064_forg/03_0203064.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5011 |
+
unified_pipeline/data/raw/signet/train/064_forg/04_0105064.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5012 |
+
unified_pipeline/data/raw/signet/train/064_forg/04_0203064.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5013 |
+
unified_pipeline/data/raw/signet/train/065_forg/01_0118065.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5014 |
+
unified_pipeline/data/raw/signet/train/065_forg/01_0206065.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5015 |
+
unified_pipeline/data/raw/signet/train/065_forg/02_0118065.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5016 |
+
unified_pipeline/data/raw/signet/train/065_forg/03_0118065.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5017 |
+
unified_pipeline/data/raw/signet/train/065_forg/03_0206065.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5018 |
+
unified_pipeline/data/raw/signet/train/065_forg/04_0118065.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5019 |
+
unified_pipeline/data/raw/signet/train/065_forg/04_0206065.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5020 |
+
unified_pipeline/data/raw/signet/train/066_forg/01_0212066.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5021 |
+
unified_pipeline/data/raw/signet/train/066_forg/02_0212066.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5022 |
+
unified_pipeline/data/raw/signet/train/066_forg/03_0101066.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5023 |
+
unified_pipeline/data/raw/signet/train/066_forg/03_0212066.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5024 |
+
unified_pipeline/data/raw/signet/train/066_forg/04_0212066.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5025 |
+
unified_pipeline/data/raw/signet/train/067_forg/01_0205067.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5026 |
+
unified_pipeline/data/raw/signet/train/067_forg/01_0212067.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5027 |
+
unified_pipeline/data/raw/signet/train/067_forg/02_0205067.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5028 |
+
unified_pipeline/data/raw/signet/train/067_forg/02_0212067.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5029 |
+
unified_pipeline/data/raw/signet/train/067_forg/03_0205067.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5030 |
+
unified_pipeline/data/raw/signet/train/067_forg/03_0212067.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5031 |
+
unified_pipeline/data/raw/signet/train/067_forg/04_0205067.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5032 |
+
unified_pipeline/data/raw/signet/train/067_forg/04_0212067.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5033 |
+
unified_pipeline/data/raw/signet/train/068_forg/03_0113068.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5034 |
+
unified_pipeline/data/raw/signet/train/068_forg/04_0113068.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5035 |
+
unified_pipeline/data/raw/signet/train/069_forg/01_0106069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5036 |
+
unified_pipeline/data/raw/signet/train/069_forg/01_0108069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5037 |
+
unified_pipeline/data/raw/signet/train/069_forg/01_0111069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5038 |
+
unified_pipeline/data/raw/signet/train/069_forg/02_0106069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5039 |
+
unified_pipeline/data/raw/signet/train/069_forg/02_0108069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5040 |
+
unified_pipeline/data/raw/signet/train/069_forg/02_0111069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5041 |
+
unified_pipeline/data/raw/signet/train/069_forg/03_0106069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5042 |
+
unified_pipeline/data/raw/signet/train/069_forg/03_0108069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5043 |
+
unified_pipeline/data/raw/signet/train/069_forg/03_0111069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5044 |
+
unified_pipeline/data/raw/signet/train/069_forg/04_0106069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5045 |
+
unified_pipeline/data/raw/signet/train/069_forg/04_0108069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5046 |
+
unified_pipeline/data/raw/signet/train/069_forg/04_0111069.PNG filter=lfs diff=lfs merge=lfs -text
|
| 5047 |
+
unified_pipeline/docs/unified_pipeline_Metrics_Thresholds_Formulas.pptx filter=lfs diff=lfs merge=lfs -text
|
unified_pipeline/README.md
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# unified_pipeline
|
| 2 |
+
|
| 3 |
+
A single, self-contained synthetic signed-bank-document generator, built
|
| 4 |
+
against a spec deck (`Metrics_Thresholds_Formulas` /
|
| 5 |
+
`Synthetic_Test_Data_Combined.pptx`). It combines two template families --
|
| 6 |
+
9 real scanned bank-document photographs with hand-measured signature-box
|
| 7 |
+
annotations, and 15 procedurally (matplotlib-)drawn documents with
|
| 8 |
+
cosmetic-field realism (SWIFT/NRIC/UEN/amounts) -- behind one config-driven
|
| 9 |
+
(Pydantic + YAML) generation engine, a 12-case-type reject-code taxonomy,
|
| 10 |
+
a shared signature dataset pool (CEDAR, SigNet, BHSig260 Hindi/Bengali), a
|
| 11 |
+
richer Albumentations-backed augmentation set, and a 119-test pytest suite.
|
| 12 |
+
|
| 13 |
+
**Start here if you're reviewing this:** `data/showcase_dataset/` -- 24
|
| 14 |
+
pre-generated cases covering all 12 case types, each rendered once on a
|
| 15 |
+
scanned template and once on a procedural template, with
|
| 16 |
+
`SHOWCASE_INDEX.csv` as a one-page overview, a `before_after/` folder
|
| 17 |
+
holding every case's metrics preview grouped together (see "Before/after
|
| 18 |
+
degradation preview" below), and `reference_signers_index.json` mapping
|
| 19 |
+
every name/role that appears in the dataset back to their signer identity
|
| 20 |
+
and reference signature image(s) (see "Reference-signer index" below). No
|
| 21 |
+
need to run anything to see what the pipeline produces.
|
| 22 |
+
`docs/unified_pipeline_Metrics_Thresholds_Formulas.pptx` walks through the
|
| 23 |
+
same metrics/thresholds/formulas with worked examples and images.
|
| 24 |
+
|
| 25 |
+
## This repo is fully self-contained
|
| 26 |
+
|
| 27 |
+
**Nothing here points outside this folder.** Unzip it anywhere and
|
| 28 |
+
`python main.py` / `pytest` just work -- no sibling checkouts, no external
|
| 29 |
+
dataset downloads. See "Datasets" below for exactly what's bundled and what
|
| 30 |
+
was left out (and why).
|
| 31 |
+
|
| 32 |
+
## Design notes
|
| 33 |
+
|
| 34 |
+
A few structural choices worth calling out up front:
|
| 35 |
+
|
| 36 |
+
- **Per-case output folders** (`case_NNN.../`) hold the rendered document,
|
| 37 |
+
the exact reference signature used (copied in, not just referenced by
|
| 38 |
+
path), and the ground truth together -- a flat `sample_NNNNNN.png/.json`
|
| 39 |
+
layout only makes sense at ML-training scale where nobody opens
|
| 40 |
+
individual cases; for a reviewed dataset, self-contained per-case folders
|
| 41 |
+
are the better fit.
|
| 42 |
+
- **Ground-truth schema** is deep and fully populated (geometry/placement/
|
| 43 |
+
rendering/augmentation audit trail), with top-level convenience fields
|
| 44 |
+
(`case_id`, `expected_verdict`, `expected_reject_code`) layered on top --
|
| 45 |
+
so a reader doesn't need to know the nested `verification.verdict` shape
|
| 46 |
+
to answer "what should a verifier conclude here."
|
| 47 |
+
- **Case generation** runs off one data table (`CASE_TABLE`) plus one
|
| 48 |
+
generic engine, rather than 12 separate hardcoded functions duplicating
|
| 49 |
+
the same logic.
|
| 50 |
+
- **Config system** is Pydantic + YAML (validated, typed) -- catches bad
|
| 51 |
+
values before a long batch run fails partway through.
|
| 52 |
+
- **Templates** are two genuinely complementary families (9 real scanned
|
| 53 |
+
photos, 15 programmatically-drawn documents) kept separate but unified
|
| 54 |
+
under one id space. `template_provider: both` is the **default** (see
|
| 55 |
+
"Template providers" below), so a plain `python main.py` shows both.
|
| 56 |
+
- **Difficulty formula** is identical across both template families --
|
| 57 |
+
same weights/tiers/normalization; only the *reference constants* differ
|
| 58 |
+
(see "Difficulty calibration profiles"), and that's a config choice, not
|
| 59 |
+
a code fork.
|
| 60 |
+
- **Difficulty CLI** takes a specific numeric target (`--target-difficulty
|
| 61 |
+
0.55`), not a coarse tier, since the score itself is already continuous.
|
| 62 |
+
See "Difficulty-score targeting" below.
|
| 63 |
+
|
| 64 |
+
## Datasets ("use all the dataset")
|
| 65 |
+
|
| 66 |
+
`SignatureDataset` (`src/signatures/signature_dataset.py`) indexes every
|
| 67 |
+
source into one pool under `data/raw/`, each record tagged with
|
| 68 |
+
`source_type` (genuine/forged) and `script` (latin/hindi/bengali):
|
| 69 |
+
|
| 70 |
+
| Source | Path | Signer group prefix | Bundled | Notes |
|
| 71 |
+
|---|---|---|---|---|
|
| 72 |
+
| CEDAR | `data/raw/cedar/{genuine,forged}` | `signer_NNNN` | Full (1320 + 1320) | Used for the scanned templates. |
|
| 73 |
+
| SigNet | `data/raw/signet/{train,test}` | `signet_NNNN` | Full (2149 files) | Primary Latin-script source. |
|
| 74 |
+
| BHSig260-Hindi | `data/raw/bhsig260_hindi` | `bhsig260_hindi_NNNN` | Capped 8 genuine + 8 forged / writer, 160 writers (2560 files; full archive is ~8600 files / 2.3GB) | |
|
| 75 |
+
| BHSig260-Bengali | `data/raw/bhsig260_bengali` | `bhsig260_bengali_NNNN` | Capped 8+8 / writer, 100 writers (1600 files; full archive is ~5400 files / 1.5GB) | |
|
| 76 |
+
| GPDS | -- | `gpds_NNNN` | **Not bundled** | Never wired into the live generation path (present in the original dataset archive, never validated). `SignatureDataset` still supports it (`gpds_dir` config) if you have the archive and want to point at it. |
|
| 77 |
+
|
| 78 |
+
**Dropped: an 11-file named-signer demo overlay** (`dataset/hindi_bengali/`
|
| 79 |
+
+ `metadata.csv`, mapping a handful of files to hand-picked names like
|
| 80 |
+
"Priya Sharma"). It added no signature diversity beyond what BHSig260
|
| 81 |
+
already provides at ~260x the scale (260 writers vs. 7) -- its only effect
|
| 82 |
+
was a few ground-truth JSONs showing a real name instead of an
|
| 83 |
+
auto-generated one. `SignatureDataset`'s `metadata.csv` support itself is
|
| 84 |
+
still fully functional generic code (see `test_metadata_csv_overrides_signer_identity_and_script`
|
| 85 |
+
in `tests/test_discovery.py`), just not exercised by bundled data anymore.
|
| 86 |
+
|
| 87 |
+
Total bundled dataset: **~1.7GB**. Any source can be omitted by pointing
|
| 88 |
+
its config path at a location that doesn't exist -- `SignatureDataset`
|
| 89 |
+
skips missing sources gracefully rather than failing (this is how GPDS
|
| 90 |
+
support stays in the code without being bundled).
|
| 91 |
+
|
| 92 |
+
## Output structure
|
| 93 |
+
|
| 94 |
+
Every generated case gets its own folder:
|
| 95 |
+
|
| 96 |
+
```
|
| 97 |
+
output/case_000001/
|
| 98 |
+
case_000001_document.png # the rendered document
|
| 99 |
+
case_000001_reference_signature.png # copy of the exact signature image used
|
| 100 |
+
case_000001_companion_signature.png # (case types that officially test a 2nd signer)
|
| 101 |
+
case_000001_<box_id>_signature.png # (any OTHER box on a multi-box template -- see below)
|
| 102 |
+
case_000001_visualization.png # (--save-visualization: geometry/metrics overlay)
|
| 103 |
+
case_000001_ground_truth.json # full metadata
|
| 104 |
+
output/before_after/
|
| 105 |
+
case_000001_before_after.png # (--save-before-after: clean vs. final side by side)
|
| 106 |
+
case_000002_before_after.png # grouped in one folder, not scattered per-case --
|
| 107 |
+
... # easier to flip through in a row
|
| 108 |
+
output/dataset_summary.csv # one row per case: template/type/verdict/difficulty
|
| 109 |
+
output/generation_summary.json # batch totals
|
| 110 |
+
output/resolved_config.yaml # the fully-resolved config that produced this run
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
Case-type batches (`quality.case_type` set) get `_<type>` appended to the
|
| 114 |
+
folder name, e.g. `case_000001_3b/`.
|
| 115 |
+
|
| 116 |
+
**Every signature box on a multi-box template gets signed, always.** If a
|
| 117 |
+
template has more than one signature box but the sample's case type doesn't
|
| 118 |
+
officially test the extra one (e.g. `1a`/`2a`/generic samples on a 2-box
|
| 119 |
+
template), that box still gets filled with its own correct, genuine
|
| 120 |
+
signature rather than being left blank with no explanation -- tracked in
|
| 121 |
+
`metadata["additional_signatures"]`. This never overrides a case type's own
|
| 122 |
+
deliberate blank/wrong target (a `2a` case's target box still correctly
|
| 123 |
+
stays blank with `NO_SIGNATURE`; only its *unrelated* second box gets the
|
| 124 |
+
extra fill). See "Multi-signature-box logic, audited" below.
|
| 125 |
+
|
| 126 |
+
## Template providers ("works with both template approaches")
|
| 127 |
+
|
| 128 |
+
Set via `paths.template_provider` in config or `--set paths.template_provider=...`:
|
| 129 |
+
|
| 130 |
+
- `both` (**default**) -- both pools merged into one id space (`bank_doc_*` + `proc_doc_*`), so a plain `python main.py` shows both template families.
|
| 131 |
+
- `scanned` -- the 9 real scanned bank-document images only.
|
| 132 |
+
- `procedural` -- the 15 matplotlib-drawn documents only (`proc_doc_01`..`proc_doc_15`).
|
| 133 |
+
|
| 134 |
+
`paths.excluded_template_ids` (default `[]`) can pull any scanned template
|
| 135 |
+
out of the pool without touching the source image file -- exclusion is
|
| 136 |
+
id-stable (doesn't renumber/shift any other template's id).
|
| 137 |
+
|
| 138 |
+
Every ground-truth JSON records `template_kind` (`"scanned"` / `"procedural"`)
|
| 139 |
+
at the top level regardless of mode.
|
| 140 |
+
|
| 141 |
+
Both families live under one `templates/` folder:
|
| 142 |
+
|
| 143 |
+
```
|
| 144 |
+
templates/
|
| 145 |
+
scanned/
|
| 146 |
+
guarantee/ bank_doc_01.png, bank_doc_02.png, bank_doc_03.png
|
| 147 |
+
loan_forms/ bank_doc_04.png, bank_doc_05.png, bank_doc_06.png
|
| 148 |
+
mandate/ bank_doc_07.png, bank_doc_08.png, bank_doc_09.png
|
| 149 |
+
procedural/ proc_doc_01.png .. proc_doc_15.png
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
`templates/scanned/` holds the 9 real scanned photographs (`paths.image_dir`);
|
| 153 |
+
`templates/procedural/` holds the 15 procedurally-drawn documents, rendered
|
| 154 |
+
once and cached as PNGs the same way a scanned template is a fixed image
|
| 155 |
+
(`paths.cache_dir`) -- delete it and it's regenerated automatically on the
|
| 156 |
+
next run, from the drawing code in `src/templates/procedural_template_provider.py`.
|
| 157 |
+
|
| 158 |
+
## Difficulty calibration profiles
|
| 159 |
+
|
| 160 |
+
Real signature ink occupies a different proportion of the page on the two
|
| 161 |
+
template families, so procedural samples use different smallness/faintness/
|
| 162 |
+
low_texture reference constants (`smallness_reference_area_ratio=0.003`,
|
| 163 |
+
`faintness_dark_ref=165`, `faintness_light_ref=220`,
|
| 164 |
+
`low_texture_reference_stddev=46` -- see
|
| 165 |
+
`src/config/schema.py::DifficultyWeights`) than scanned samples, which keep
|
| 166 |
+
the spec deck's literal generic values (`0.04` / `90` / `210` / `60`) --
|
| 167 |
+
still an accurate fit for scanned templates' own measured population.
|
| 168 |
+
|
| 169 |
+
`faintness_dark_ref` and `low_texture_reference_stddev` were refit
|
| 170 |
+
2026-08-11 against a fresh 60-sample natural draw of real procedural output.
|
| 171 |
+
The prior `faintness_dark_ref` (184) sat *above* the real median
|
| 172 |
+
`mean_dark_intensity` (180.6), which floored `faintness` to exactly `0.000`
|
| 173 |
+
for every sample measured -- a 20%-weighted component contributing nothing,
|
| 174 |
+
discriminating nothing, for the whole procedural family. Re-anchored to the
|
| 175 |
+
real 10th percentile (~164, rounded to 165): median sample faintness is now
|
| 176 |
+
~0.29, only the darkest ~13% floor to 0, spread runs to ~0.79 for the
|
| 177 |
+
lightest observed ink. `low_texture_reference_stddev` had never been split
|
| 178 |
+
from the deck-literal default at all, despite the same underlying asymmetry
|
| 179 |
+
existing there too (procedural's vector-rendered ink is naturally less
|
| 180 |
+
textured than scanned's real-photograph ink) -- anchored to the real 90th
|
| 181 |
+
percentile `gray_stddev` (~46.5, rounded to 46), the same treatment already
|
| 182 |
+
given to `smallness_reference_area_ratio`. Verified: re-measured 60 fresh
|
| 183 |
+
procedural samples post-fix -- faintness floors to 0 for only 8/60 (was
|
| 184 |
+
60/60), low_texture floors to 0 for only 7/60; full 119-test suite still
|
| 185 |
+
passes.
|
| 186 |
+
|
| 187 |
+
This is applied automatically, **per sample**, by `GeneratorContext`
|
| 188 |
+
(`src/generator.py::_difficulty_analyzer_for()`), keyed on each sample's own
|
| 189 |
+
`template_kind()` -- not a config profile you have to remember to select.
|
| 190 |
+
Previously this only applied if the whole run used
|
| 191 |
+
`--config configs/procedural_templates.yaml`, which meant `template_provider:
|
| 192 |
+
both` (or the plain CLI default) silently scored every procedural sample
|
| 193 |
+
against the deck-literal, too-aggressive constants regardless. Fixed: each
|
| 194 |
+
sample now gets the constants that actually fit its template family, even
|
| 195 |
+
within a mixed `both` batch.
|
| 196 |
+
|
| 197 |
+
If you explicitly set any of the 3 reference constants yourself -- a CLI
|
| 198 |
+
`--set difficulty.smallness_reference_area_ratio=...` override, or a whole
|
| 199 |
+
profile like `configs/procedural_templates.yaml` that sets them itself --
|
| 200 |
+
that choice is respected as-is and applied to every sample regardless of
|
| 201 |
+
template kind; the automatic per-kind default only fills in fields you
|
| 202 |
+
didn't already decide for yourself
|
| 203 |
+
(`DifficultyWeights.model_fields_set` is how this is detected).
|
| 204 |
+
|
| 205 |
+
## Placement calibration
|
| 206 |
+
|
| 207 |
+
Signatures are also pasted at a different scale per template family, same
|
| 208 |
+
per-sample-keyed pattern as difficulty calibration above
|
| 209 |
+
(`GeneratorContext._placement_engine_for()`):
|
| 210 |
+
|
| 211 |
+
- **`min_signature_height_px`**: `24px -> 28px` (shared default -- see
|
| 212 |
+
`src/config/schema.py::PlacementConfig`, moved off a `placement_engine.py`
|
| 213 |
+
module constant so it's config-overridable like every other placement
|
| 214 |
+
knob). Measured 2026-08-11 across 20 real placements per candidate into a
|
| 215 |
+
genuine 18px "sign here" line: 24px -> 15.1% fully-opaque ink, 28px ->
|
| 216 |
+
16.8%, with a modest overflow cost (median 4% at 28px vs. 0% at 24px);
|
| 217 |
+
32px was tested and rejected (20%+ median overflow -- escalates much
|
| 218 |
+
faster than the legibility gain).
|
| 219 |
+
- **`placement.scale_factor`**: `1.15` (procedural only; scanned keeps the
|
| 220 |
+
shipped `{min: 0.78, max: 0.95}` range unchanged). Procedural's boxes
|
| 221 |
+
(200x80px) have real headroom scanned's cramped real "sign here" lines
|
| 222 |
+
don't -- 1.1 already gave a meaningful legibility gain (opaque-ink
|
| 223 |
+
fraction 0.238 -> 0.280) with zero overflow; 1.3 starts trading further
|
| 224 |
+
gains for real overflow (6-7%). Scanned was tested too (0.9 through 1.2)
|
| 225 |
+
and showed **no change at all** in that range -- `min_signature_height_px`
|
| 226 |
+
is the actual binding constraint there, not `scale_factor`, so bumping
|
| 227 |
+
scanned's scale_factor would have been a no-op.
|
| 228 |
+
|
| 229 |
+
**A real bug surfaced while implementing this**: the difficulty-calibration
|
| 230 |
+
pattern detects genuine user overrides via `model_fields_set`, which works
|
| 231 |
+
because `configs/default.yaml` never explicitly sets the difficulty
|
| 232 |
+
reference constants. It does explicitly set `placement.scale_factor`
|
| 233 |
+
though (to its own baseline range) -- so applying the same pattern
|
| 234 |
+
unmodified would have silently defeated this recalibration on every single
|
| 235 |
+
run, with or without a real override. Fixed by comparing the resolved
|
| 236 |
+
value against the known shipped baseline directly instead of relying on
|
| 237 |
+
field-presence tracking.
|
| 238 |
+
|
| 239 |
+
Pasting bigger also organically fixed a gap flagged in the earlier
|
| 240 |
+
recalibration work: `smallness_reference_area_ratio=0.003` previously sat
|
| 241 |
+
above even the best 10% of real procedural samples (floor ~0.4, never
|
| 242 |
+
reaching 0). Re-measured post-placement-fix: real median
|
| 243 |
+
`signature_area_ratio` moved 0.00095 -> 0.00180, and `smallness` now
|
| 244 |
+
resolves with genuine spread on its own (8/50 samples reach 0, median
|
| 245 |
+
0.398) -- no further change to the reference constant was needed.
|
| 246 |
+
|
| 247 |
+
## Difficulty-score targeting
|
| 248 |
+
|
| 249 |
+
```bash
|
| 250 |
+
python main.py --count 5 --target-difficulty 0.55
|
| 251 |
+
python main.py --count 5 --target-difficulty 0.55 --difficulty-tolerance 0.03 --difficulty-max-attempts 8
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
A retry-loop (`case_generation.py::generate_case_at_difficulty()`) built on
|
| 255 |
+
this pipeline's own CLI conventions (argparse, `--set key=value`):
|
| 256 |
+
since the difficulty score is already a continuous `[0, 1]` value (not a
|
| 257 |
+
bucket), you ask for a specific number (`0.55`), not a tier. Retries (`--difficulty-max-attempts`, default 5) with fresh but
|
| 258 |
+
still fully deterministic randomness (`seed_manager.python_rng` derived
|
| 259 |
+
from `f"{sample_index}_difficulty_retry{attempt}"`) until the score lands
|
| 260 |
+
within `--difficulty-tolerance` (default 0.05) of the target; if no
|
| 261 |
+
attempt matches, the closest one is kept and `ground_truth.json`'s
|
| 262 |
+
`difficulty_targeting.matched` is `false` so this is never silently
|
| 263 |
+
misreported. See `GeneratorContext.run_sample_targeting_difficulty()`.
|
| 264 |
+
|
| 265 |
+
## Before/after degradation preview
|
| 266 |
+
|
| 267 |
+
```bash
|
| 268 |
+
python main.py --count 5 --save-before-after
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
Builds a clean-vs-final side-by-side comparison combined with a metrics
|
| 272 |
+
overlay into one image. Writes
|
| 273 |
+
`before_after/<case_id>_before_after.png` per sample -- grouped in one
|
| 274 |
+
shared folder rather than scattered one per case folder, so every sample's
|
| 275 |
+
preview can be flipped through in a row without opening each case folder
|
| 276 |
+
individually:
|
| 277 |
+
|
| 278 |
+
- **BEFORE (left)** -- the clean render (signature placed, before any
|
| 279 |
+
document-level augmentation), annotated with the "what was actually
|
| 280 |
+
measured" overlay: the target box (red), the actual
|
| 281 |
+
signature bbox (yellow), its detected ink polygon (green), and any
|
| 282 |
+
cosmetic field boxes (cyan = actual, orange = target cell), plus a
|
| 283 |
+
legend, a **difficulty breakdown** (`value x weight = contribution` per
|
| 284 |
+
component, e.g. `misplacement: 0.464 x 0.35 = 0.162`), and an
|
| 285 |
+
**augmentation-applied** panel listing every effect this sample rolled --
|
| 286 |
+
ON with its strength, or off.
|
| 287 |
+
- **AFTER (right)** -- the final look, labeled with a one-line summary of
|
| 288 |
+
which effects actually fired (`"AFTER - 2 effect(s): contrast,
|
| 289 |
+
scanner_noise"`).
|
| 290 |
+
|
| 291 |
+
Every case in `data/showcase_dataset/` has one of these pre-generated.
|
| 292 |
+
Distinct from the existing `--save-visualization` (a single matplotlib-based
|
| 293 |
+
multi-panel figure -- crop, ink mask, bar chart -- on the FINAL image only,
|
| 294 |
+
no before/after comparison); the two can be used together. See
|
| 295 |
+
`src/visualization/before_after_preview.py`.
|
| 296 |
+
|
| 297 |
+
## Cosmetic (non-scored) fields
|
| 298 |
+
|
| 299 |
+
`document.enable_cosmetic_fields: true` (**on by default**) fills in
|
| 300 |
+
name/company-name and, on procedural templates, the fuller SWIFT/NRIC/UEN/
|
| 301 |
+
amount set too -- never read by `DifficultyAnalyzer` or any scored metric.
|
| 302 |
+
|
| 303 |
+
- **Procedural templates**: full field set at exact coordinates (known
|
| 304 |
+
from the drawing spec) -- see `src/cosmetics/cosmetic_fields.py`.
|
| 305 |
+
- **Scanned templates**: a best-effort name/company-name field on 7 of the
|
| 306 |
+
9 templates, found via the same CV line-detection technique already used
|
| 307 |
+
for signature-box auto-detection (search a hand-picked region for the
|
| 308 |
+
blank line/table-cell, draw text against it) -- see
|
| 309 |
+
`src/cosmetics/scanned_cosmetic_fields.py`. `bank_doc_01`/`bank_doc_02`
|
| 310 |
+
are stock photos with fabricated content already baked into the image
|
| 311 |
+
(bracketed `[PLACEHOLDER ...]` text, or an already-fully-filled specimen
|
| 312 |
+
letter) and are left as-is -- there's no genuine blank field to fill
|
| 313 |
+
without inpainting over existing print.
|
| 314 |
+
|
| 315 |
+
## Template exclusion (mechanism exists, nothing excluded by default)
|
| 316 |
+
|
| 317 |
+
`paths.excluded_template_ids` (config list, default `[]`) can pull a
|
| 318 |
+
scanned template out of the pool without touching the source image file --
|
| 319 |
+
exclusion is id-stable (`TemplateLoader(excluded_ids=...)`), so pulling one
|
| 320 |
+
id never renumbers/shifts any other template's id. Every discovered
|
| 321 |
+
template is included by default.
|
| 322 |
+
|
| 323 |
+
## Scanned cosmetic fields: field semantics, not just position
|
| 324 |
+
|
| 325 |
+
`bank_doc_09`'s "Company Name" table cell (under `ENTITY DETAILS`, next to
|
| 326 |
+
`Registration Number`) was pasting the *signer's own personal name* into
|
| 327 |
+
it (e.g. "Nancy Ivanov" under a column literally headed "Company Name") --
|
| 328 |
+
the position was correct, but the wrong *kind* of text. Every other
|
| 329 |
+
scanned field really is a personal-name field ("Print Name:", "Name of
|
| 330 |
+
Account Holder:", "Mr./Ms. ___") and correctly gets the signer's name;
|
| 331 |
+
`company_name` is the one exception and now generates an actual company
|
| 332 |
+
name (`generate_company_name()`, seeded, same generator procedural
|
| 333 |
+
templates already used) instead. See `src/cosmetics/scanned_cosmetic_fields.py`'s
|
| 334 |
+
`_GENERATED_FIELD_TEXT` dispatch table.
|
| 335 |
+
|
| 336 |
+
### Cosmetic-field coordinates are pre-measured and cached, like signature boxes
|
| 337 |
+
|
| 338 |
+
`signature_boxes` in `annotations/<template_id>.json` have always been
|
| 339 |
+
hand-measured once and just read at generation time -- no per-sample image
|
| 340 |
+
analysis. Cosmetic-field coordinates (where the "Name"/"Company Name" text
|
| 341 |
+
gets drawn on a scanned template) now work exactly the same way, via a new
|
| 342 |
+
`cosmetic_fields` array on the same annotation file:
|
| 343 |
+
|
| 344 |
+
```json
|
| 345 |
+
"cosmetic_fields": [
|
| 346 |
+
{"field": "company_name", "mode": "below_line", "line_x": 74, "line_y": 164, "line_w": 303}
|
| 347 |
+
]
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
**How each coordinate is measured** (`scripts/detect_cosmetic_field_coordinates.py`):
|
| 351 |
+
|
| 352 |
+
1. A generous *search region* is hand-picked per field, once, by eye (a
|
| 353 |
+
`(x0, y0, x1, y1)` rectangle expressed as fractions of the page's
|
| 354 |
+
width/height -- see `FIELD_REGION_RECIPES` in that script). This step
|
| 355 |
+
only needs to be roughly right, not precise.
|
| 356 |
+
2. Within that region, the *same* classical-CV horizontal-line detector
|
| 357 |
+
already used to auto-detect signature boxes when no manual annotation
|
| 358 |
+
exists (`src/templates/box_detector.py`: binarize, morphological
|
| 359 |
+
opening with a wide horizontal kernel to isolate long thin dark runs,
|
| 360 |
+
merge nearby fragments) finds the actual blank underline or table
|
| 361 |
+
row-divider pixel-exactly. Deterministic given the same image, so it
|
| 362 |
+
only ever needs to run once per template.
|
| 363 |
+
3. The result (`field`, `above_line`/`below_line` mode, and the detected
|
| 364 |
+
line's exact `x`/`y`/`width`) is written into the template's
|
| 365 |
+
`annotations/<id>.json`, alongside `signature_boxes`.
|
| 366 |
+
|
| 367 |
+
Run it manually (`python scripts/detect_cosmetic_field_coordinates.py
|
| 368 |
+
[--force]`) after adding a new scanned template or if a template image
|
| 369 |
+
changes -- it is **not** run as part of normal generation. At generation
|
| 370 |
+
time, `fill_scanned_cosmetic_fields()` (`src/cosmetics/scanned_cosmetic_fields.py`)
|
| 371 |
+
just reads `template.annotation.pages[N].cosmetic_fields` directly, the
|
| 372 |
+
same way signature-box placement already reads `signature_boxes` -- one
|
| 373 |
+
measurement, reused on every sample, instead of re-running CV detection
|
| 374 |
+
per call.
|
| 375 |
+
|
| 376 |
+
### One "Print Name:" field per signatory, not one per template
|
| 377 |
+
|
| 378 |
+
`bank_doc_03`, `04`, `05`, `06`, and `08` each have **two** signature
|
| 379 |
+
boxes, and (confirmed by visually cropping every candidate line in each
|
| 380 |
+
template) each box's signatory has their *own* "Print Name:"-style line --
|
| 381 |
+
not one field shared by both parties. The original single-field-per-template
|
| 382 |
+
recipe only ever measured one of the two, so the second signatory's name
|
| 383 |
+
was always left blank regardless of what filled the first. Each field now
|
| 384 |
+
carries an optional `box_id`, tying it to the `signature_boxes[].id` whose
|
| 385 |
+
signer's name belongs on that line:
|
| 386 |
+
|
| 387 |
+
```json
|
| 388 |
+
"cosmetic_fields": [
|
| 389 |
+
{"field": "name_sig1", "mode": "above_line", "line_x": 606, "line_y": 464, "line_w": 136, "box_id": "sig1"},
|
| 390 |
+
{"field": "name_sig2", "mode": "above_line", "line_x": 828, "line_y": 464, "line_w": 136, "box_id": "sig2"}
|
| 391 |
+
]
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
Fields with no `box_id` (e.g. `bank_doc_09`'s `company_name`) are
|
| 395 |
+
entity-level, not tied to one signer, and are unaffected.
|
| 396 |
+
|
| 397 |
+
Because a companion/additional signer's identity usually isn't resolved
|
| 398 |
+
until *after* the primary box's cosmetic fields are already filled (see
|
| 399 |
+
`_run_case_sample`/`_run_generic_sample` in `src/generator.py`),
|
| 400 |
+
`_maybe_fill_cosmetic_fields()` only ever fills fields with no `box_id` or
|
| 401 |
+
tied to the box it's given; a second pass, `_fill_box_cosmetic_field()`,
|
| 402 |
+
fills the companion/extra box's own field once that signer's display name
|
| 403 |
+
is known -- so both parties' names land on their own lines, matching
|
| 404 |
+
whichever signature actually got pasted into their box.
|
| 405 |
+
|
| 406 |
+
While narrowing `bank_doc_03`'s search region to find its second field, a
|
| 407 |
+
related pre-existing bug surfaced: its *first* field had been landing on
|
| 408 |
+
the "By:" line instead of "Print Name:" (both lines sit in the same
|
| 409 |
+
generous search region, and `_find_line_in_region()`'s "pick the widest
|
| 410 |
+
line" heuristic happened to prefer "By:"). Fixed by narrowing that
|
| 411 |
+
template's region to the exact "Print Name:" line's y-band.
|
| 412 |
+
|
| 413 |
+
## Legible ink on short signature lines
|
| 414 |
+
|
| 415 |
+
A real "sign here" line on a scanned bank form is often only ~15-25px tall
|
| 416 |
+
at this pipeline's working resolution (e.g. `bank_doc_08`'s "Signature of
|
| 417 |
+
Bank Manager:" line is 22px tall) -- placing a signature stamp from a
|
| 418 |
+
typical few-hundred-pixel-tall dataset source crop into a box that short
|
| 419 |
+
forces a large downscale, sometimes 10-20x. `cv2.resize`'s `INTER_AREA`
|
| 420 |
+
area-averages a multi-pixel-wide source stroke down into a sub-pixel-wide
|
| 421 |
+
destination stroke, which is the geometrically correct way to downscale --
|
| 422 |
+
but it also dilutes that stroke's alpha well below fully opaque across most
|
| 423 |
+
of its footprint. A signature that was solidly inked in its source crop
|
| 424 |
+
could come out mostly under 30-40% opacity: a faint gray smudge rather than
|
| 425 |
+
a smaller-but-still-dark signature (real signatures squeezed onto a short
|
| 426 |
+
line stay dark, just compact).
|
| 427 |
+
|
| 428 |
+
`PlacementEngine.place()` (`src/placement/placement_engine.py`) now applies
|
| 429 |
+
a gamma correction to the placed alpha channel after any real downscale --
|
| 430 |
+
every partially-transparent pixel gets boosted non-linearly (alpha^gamma,
|
| 431 |
+
gamma<1), which is what actually restores legibility (a plain linear
|
| 432 |
+
rescale matching the *source's own* percentile was tried first and wasn't
|
| 433 |
+
enough: even the source crop's own mid-tone pixels are already somewhat
|
| 434 |
+
soft/anti-aliased from the original scan). Gamma self-scales with
|
| 435 |
+
`final_scale` (the actual resize factor) -- a sample that barely shrank
|
| 436 |
+
gets ~no correction (gamma near 1, alpha^1 = alpha); one squeezed 10-20x
|
| 437 |
+
onto a short line gets the strongest correction (gamma floor 0.18).
|
| 438 |
+
Applied to alpha only, never RGB, so ink *color* is unaffected -- this
|
| 439 |
+
makes thin/faint strokes fully opaque sooner, not darker in hue. Regression
|
| 440 |
+
test: `test_aggressive_downscale_onto_a_short_box_keeps_ink_dark` in
|
| 441 |
+
`tests/test_placement_engine.py`.
|
| 442 |
+
|
| 443 |
+
## Post-render ink detection: not fooled by the template's own printed content
|
| 444 |
+
|
| 445 |
+
`GeometryAnalyzer` (`src/analysis/geometry_analyzer.py`) redetects each
|
| 446 |
+
sample's signature from the rendered pixels alone (deliberately -- a real
|
| 447 |
+
verifier only ever sees the final scanned document, not ground-truth
|
| 448 |
+
placement coordinates). Four contamination sources were found and fixed
|
| 449 |
+
across three review passes -- the first two via a user-reported case
|
| 450 |
+
(`bank_doc_08`'s Bank Manager signature, a genuine PASS, whose metrics
|
| 451 |
+
preview showed a bounding box/polygon spanning almost the entire printed
|
| 452 |
+
line instead of just the ink), the third via a broader anomaly scan across
|
| 453 |
+
the whole showcase dataset after a follow-up "the metrics still look kinda
|
| 454 |
+
off" report, the fourth via the interactive UI's degradation lever
|
| 455 |
+
surfacing a sample that visually looked heavily degraded yet scored "easy":
|
| 456 |
+
|
| 457 |
+
1. **The printed "sign here" rule line itself.** A pasted signature very
|
| 458 |
+
often sits directly on top of, and touches, that line -- 8-connectivity
|
| 459 |
+
then merges the line and the ink into ONE component, whose bounding box
|
| 460 |
+
spans the line's full width. `_strip_printed_rule_line_pixels()` runs a
|
| 461 |
+
*narrow, absolute-width* (20px, not proportional to the crop -- a
|
| 462 |
+
signature crossing the middle of the line splits it into two shorter
|
| 463 |
+
sub-segments, and a proportional kernel could exceed either half's
|
| 464 |
+
length) horizontal morphological opening to isolate straight
|
| 465 |
+
single-row runs, then only subtracts ones whose overall connected-component
|
| 466 |
+
height stays <=3px (so a genuinely tall block of ink -- thick
|
| 467 |
+
handwriting, or a filled test fixture -- survives; only truly thin,
|
| 468 |
+
line-like structures get erased).
|
| 469 |
+
2. **Printed field labels near the box** (e.g. "Bank Manager:"). A tight
|
| 470 |
+
detection pass expands slightly past the annotated box edge, and on a
|
| 471 |
+
short box that expansion can reach into adjacent label text. Fixed by
|
| 472 |
+
optionally diffing against the clean, unsigned template image
|
| 473 |
+
(`analyze(..., baseline_image_rgb=...)`) -- pixels already dark in the
|
| 474 |
+
baseline (permanent printed content) are never counted as new ink. The
|
| 475 |
+
baseline is known in advance the same way a real production pipeline
|
| 476 |
+
knows its own form layouts, not this run's specific signature
|
| 477 |
+
placement, so this isn't using ground truth the way a real verifier
|
| 478 |
+
couldn't. Originally skipped automatically when the sample underwent a
|
| 479 |
+
page-level geometric transform (`perspective`/`page_tilt`/`crumple`)
|
| 480 |
+
that would misalign a pixel-for-pixel diff -- see contamination source
|
| 481 |
+
4 below for why that gap itself needed fixing.
|
| 482 |
+
|
| 483 |
+
Net effect on the reported case: `box_coverage_pct` dropped from a
|
| 484 |
+
contamination-inflated 56% to an accurate ~10%, and the detected polygon
|
| 485 |
+
now tightly traces just the actual ink.
|
| 486 |
+
|
| 487 |
+
3. **A printed caption below a deliberately BLANK target box** (2a/5a case
|
| 488 |
+
types -- `_BLANK_CASE_TYPES`). The widened retry pass (`analyze()`'s
|
| 489 |
+
second, more generous search) exists to chase a signature that was
|
| 490 |
+
deliberately misplaced far from its own box -- but a 2a/5a target box
|
| 491 |
+
has no signature at all, so there's nothing to chase, and on a sample
|
| 492 |
+
where `baseline_image_rgb` had to be omitted (a geometric transform was
|
| 493 |
+
applied, so a pixel diff would misalign), the widened search had no
|
| 494 |
+
baseline to exclude already-printed content with. Caught a printed
|
| 495 |
+
role-caption ("1. SIGNATURE: AUTHORIZED REPRESENTATIVE *") sitting just
|
| 496 |
+
below the blank box and reported it as a wildly-misplaced signature
|
| 497 |
+
(`distance_from_box_center_px` ~90-130px, `margin_utilization` >2).
|
| 498 |
+
Fixed with `analyze(..., expect_blank=True)`, passed whenever the
|
| 499 |
+
caller already knows from ground truth that this target has no
|
| 500 |
+
signature (`target_rgba is None` in `_run_case_sample`) -- skips the
|
| 501 |
+
widened retry entirely, so a genuinely blank box just reports
|
| 502 |
+
`detected: false` and zeroed metrics, exactly as it should.
|
| 503 |
+
|
| 504 |
+
4. **Whole blocks of printed text, whenever `perspective`/`page_tilt`/
|
| 505 |
+
`crumple` actually fired.** Found via the interactive UI's degradation
|
| 506 |
+
lever: a heavily rotated/noisy "Explore from clean" preview scored
|
| 507 |
+
16% ("easy") despite looking obviously harder to read than the clean
|
| 508 |
+
original (48%, "medium"). Root cause: contamination source 2's
|
| 509 |
+
baseline-diff exclusion was skipped entirely whenever a geometric
|
| 510 |
+
transform fired (a rotated page no longer lines up pixel-for-pixel with
|
| 511 |
+
the un-rotated baseline template), so the ONLY defense against printed
|
| 512 |
+
content near the box was gone -- and `_strip_printed_rule_line_pixels()`
|
| 513 |
+
only catches thin rule *lines*, not printed paragraph *text*. On one
|
| 514 |
+
measured sample, a `page_tilt` of well under 1 degree was enough for a
|
| 515 |
+
nearby paragraph heading to rotate into the search box's expanded
|
| 516 |
+
region and get counted wholesale as "ink": detected pixels jumped from
|
| 517 |
+
545 (the real signature) to 13,993, `mean_dark_intensity` dropped from
|
| 518 |
+
175 to 107 (consistent with picking up dense black print), and the
|
| 519 |
+
artificially large, well-centered "blob" read as LESS misplaced and
|
| 520 |
+
LESS small than the real, small, genuine signature -- the opposite of
|
| 521 |
+
what a harder-to-read page should score. Fixed at the source instead of
|
| 522 |
+
working around it: `AugmentationEngine.apply_to_document()` now accepts
|
| 523 |
+
an optional `baseline_image`, and whenever perspective/page_tilt/crumple
|
| 524 |
+
actually apply, the identical transform (same warp matrix / same
|
| 525 |
+
`map_x`,`map_y` remap field) is also applied to the baseline companion
|
| 526 |
+
image, so it stays pixel-aligned with the final document and baseline
|
| 527 |
+
exclusion keeps working instead of being skipped. Verified in isolation:
|
| 528 |
+
`page_tilt` alone at maximum strength now detects 552 ink pixels (vs.
|
| 529 |
+
545 with no tilt at all) -- effectively fixed.
|
| 530 |
+
|
| 531 |
+
**A separate, smaller contamination source was found (not fixed) while
|
| 532 |
+
verifying #4 above:** forcing `scanner_noise`/`salt_pepper`/`dust` on
|
| 533 |
+
simultaneously with everything else -- as the lever's "Explore from
|
| 534 |
+
clean (all effects)" mode deliberately does -- inflates detected ink
|
| 535 |
+
pixels through a *different* mechanism: the geometry pipeline scores a
|
| 536 |
+
signature using the convex hull/bounding box of *every* surviving "ink"
|
| 537 |
+
pixel, so even a few scattered noise specks near opposite corners of the
|
| 538 |
+
search box balloon that box to span nearly the whole region, regardless
|
| 539 |
+
of how little total dark area they cover (measured: ink pixel *count*
|
| 540 |
+
only roughly doubled, but IoU-with-box jumped from 0.11 to 0.82). A
|
| 541 |
+
median-blur denoise pre-pass was prototyped as a fix and rejected: a
|
| 542 |
+
kernel strong enough to meaningfully suppress noise at extreme,
|
| 543 |
+
all-effects-maxed strength (5x5) also cut a genuinely clean sample's
|
| 544 |
+
real ink-pixel count by ~60% (403 -> 157) -- real pen strokes are only a
|
| 545 |
+
few pixels wide, the same order of magnitude as the noise being
|
| 546 |
+
filtered, so the fix would trade real detection accuracy for a mostly
|
| 547 |
+
synthetic problem. A shape/cluster-aware alternative (discard ink
|
| 548 |
+
components far from the "main" cluster) was also rejected: it directly
|
| 549 |
+
conflicts with this same section's existing, deliberate design (the
|
| 550 |
+
hull is meant to *bridge* a genuine flourish far from the main strokes,
|
| 551 |
+
not discard it) and would need real testing against genuine faint/thin
|
| 552 |
+
signatures to avoid new false negatives -- a bigger investment than
|
| 553 |
+
warranted, since at realistic (non-maxed) strengths the effect is mild
|
| 554 |
+
(measured: only ~8% ink-pixel inflation at strength 0.3, vs. 25x for the
|
| 555 |
+
page_tilt bug above). Left as a documented limitation of the "Explore
|
| 556 |
+
from clean (all effects)" lever mode specifically -- which stacks all 14
|
| 557 |
+
effects simultaneously, something real generation never does (each
|
| 558 |
+
effect fires independently/probabilistically) -- rather than changing
|
| 559 |
+
detection logic; the lever now shows an explicit warning above 25% in
|
| 560 |
+
that mode instead of a silently-misleading score.
|
| 561 |
+
|
| 562 |
+
## Placement floor: 28px, not the deck's 16px -- resolved by measurement, not forced
|
| 563 |
+
|
| 564 |
+
A full line-by-line audit against `Metrics_Thresholds_Formulas.pptx`'s
|
| 565 |
+
"Threshold reference sheet" slide found every constant in this pipeline
|
| 566 |
+
matches exactly except one: `min_signature_height_px`
|
| 567 |
+
(`src/config/schema.py::PlacementConfig`, formerly a
|
| 568 |
+
`placement_engine.py` module constant) is 24px-then-28px in code vs. the
|
| 569 |
+
deck's 16px. Rather than silently override it to match the deck, or leave
|
| 570 |
+
it unexplained, this was resolved empirically: a real signature was placed
|
| 571 |
+
into a genuine short 18px "sign here" line (the deck's own "real lines are
|
| 572 |
+
12-20px tall" range) at each floor value and the result measured directly.
|
| 573 |
+
|
| 574 |
+
| Floor | Placed height | Fully-opaque ink (alpha > 200/255) |
|
| 575 |
+
|---|---|---|
|
| 576 |
+
| 16px (deck) | 16px | 12.3% |
|
| 577 |
+
| 24px (first pass) | 22px | 15.1% |
|
| 578 |
+
| 28px (current, see "Placement calibration" above) | -- | 16.8% |
|
| 579 |
+
|
| 580 |
+
Each step up measurably preserves more solid, legible ink -- `cv2.INTER_AREA`
|
| 581 |
+
dilutes a multi-pixel source stroke's alpha the smaller the downscale
|
| 582 |
+
target gets, and 16px sits deepest in that dilution zone (the same artifact
|
| 583 |
+
already described in this file's placement-scaling comments). 32px was
|
| 584 |
+
also tested (see "Placement calibration" above) and rejected -- overflow
|
| 585 |
+
past 28px escalates much faster than the legibility gain. The constant's
|
| 586 |
+
own definition cites this measurement directly, the same way
|
| 587 |
+
`DifficultyWeights`' smallness/faintness constants document their own
|
| 588 |
+
deliberate deviation from the deck's generic values.
|
| 589 |
+
|
| 590 |
+
This was one finding from a full line-by-line audit (every formula/
|
| 591 |
+
threshold/case-type in the deck checked against this codebase) -- 52 of 53
|
| 592 |
+
checks matched exactly; this was the one documented deviation, resolved by
|
| 593 |
+
measurement rather than left silently unexplained.
|
| 594 |
+
|
| 595 |
+
## The ink polygon on the preview tints the real ink, not the hull
|
| 596 |
+
|
| 597 |
+
Several rendering styles were tried this session, in order, each fixing
|
| 598 |
+
the previous one's complaint:
|
| 599 |
+
|
| 600 |
+
1. **Hull, outline only** (the original design). A convex hull "bridges"
|
| 601 |
+
gaps between separate ink blobs (letters, a flourish far from the main
|
| 602 |
+
strokes) with straight lines -- the bridging edges visibly don't hug
|
| 603 |
+
the ink wherever they cross a gap. Reported: "the polygon doesn't even
|
| 604 |
+
cover the signature."
|
| 605 |
+
2. **Hull, translucent fill.** Fixed #1 (the ink is always inside the
|
| 606 |
+
tinted region), but a filled hull shades in every gap it bridges too.
|
| 607 |
+
Reported, correctly: "shouldn't [it] only cover surround the signature
|
| 608 |
+
not whitespaces."
|
| 609 |
+
3. **Hull outline + `cv2.approxPolyDP` simplification** (a vertex-count
|
| 610 |
+
reduction on the hull polygon). Still visibly failed to "hug" a
|
| 611 |
+
wide/gappy signature on direct re-inspection, since it's still
|
| 612 |
+
fundamentally the same convex hull -- a known tradeoff of hull-based
|
| 613 |
+
rendering that just isn't obvious on a small, compact signature.
|
| 614 |
+
4. **`display_contours`, translucent fill** (current, final). A
|
| 615 |
+
purely-visual field on `GeometryResult`: one real boundary contour per
|
| 616 |
+
disjoint ink blob, via `cv2.findContours` on the exact mask used for
|
| 617 |
+
scoring (mildly dilated first, on a copy, just to merge one letter's
|
| 618 |
+
own stroke fragments into a single blob -- nowhere near enough to
|
| 619 |
+
bridge a real gap between separate letters). Filling each blob's own
|
| 620 |
+
contour tints only actual ink, never the whitespace between strokes,
|
| 621 |
+
and -- unlike an *outline* of these same contours (tried and rejected
|
| 622 |
+
earlier: for a dense cursive/Devanagari signature it visually resembled
|
| 623 |
+
a second overlaid signature) -- a fill has no separate line art to be
|
| 624 |
+
confused with handwriting.
|
| 625 |
+
|
| 626 |
+
`signature_polygon_px`/`convex_hull_px` in `ground_truth.json` remain the
|
| 627 |
+
convex hull (with the `approxPolyDP` simplification from step 3 kept, as a
|
| 628 |
+
minor genuine improvement to the scoring polygon's vertex count) -- every
|
| 629 |
+
coverage metric (`box_coverage_pct`/IoU/`containment_pct`/`margin_utilization`)
|
| 630 |
+
still comes from the hull, unchanged. `display_contours` is additive and
|
| 631 |
+
explicitly documented as "never used in scoring." Both
|
| 632 |
+
`before_after_preview.py` and `visualizer.py` (`--save-visualization`)
|
| 633 |
+
fill `display_contours` when present, falling back to filling the hull for
|
| 634 |
+
any geometry dict that predates this field. This was a deliberate choice,
|
| 635 |
+
made after comparing several rendering styles directly -- prioritizing an
|
| 636 |
+
accurate, non-misleading preview over hull-only visual simplicity.
|
| 637 |
+
|
| 638 |
+
## Metrics preview shows every signed box, not just the scored one
|
| 639 |
+
|
| 640 |
+
A multi-box template's companion/additional-signature boxes are genuinely
|
| 641 |
+
rendered with real ink but aren't part of a sample's *scored* target
|
| 642 |
+
(geometry/difficulty stay keyed to the one target box the rest of the
|
| 643 |
+
pipeline scores) -- previously the before/after metrics preview only ever
|
| 644 |
+
overlaid that one box, leaving a second signature elsewhere on the page
|
| 645 |
+
unexplained. `compute_extra_signature_overlays()` (`src/generator.py`)
|
| 646 |
+
re-runs `GeometryAnalyzer` (view-only, never for scoring) on every other
|
| 647 |
+
signed box for the preview, and `save_before_after_preview()`'s
|
| 648 |
+
`extra_boxes` param draws them in a distinct purple/pink palette, labeled
|
| 649 |
+
by role (e.g. "Account Holder (other box)") instead of full re-scoring.
|
| 650 |
+
|
| 651 |
+
## Reference-signer index
|
| 652 |
+
|
| 653 |
+
`scripts/generate_showcase_dataset.py` also writes
|
| 654 |
+
`data/showcase_dataset/reference_signers_index.json` -- every distinct
|
| 655 |
+
name/role that appears anywhere in the dataset (as a primary signer,
|
| 656 |
+
companion, or additional/other-box signer), with which signer identity
|
| 657 |
+
backs it and where its ink actually comes from:
|
| 658 |
+
|
| 659 |
+
```json
|
| 660 |
+
{
|
| 661 |
+
"signer_group": "signet_0009",
|
| 662 |
+
"display_name": "John Thompson",
|
| 663 |
+
"script": "latin",
|
| 664 |
+
"roles": ["Bank Manager"],
|
| 665 |
+
"appears_in": [{"case_id": "case_001_1a_scanned", "role": "Bank Manager", "as": "primary", "template": "bank_doc_08"}],
|
| 666 |
+
"reference_images": ["data/raw/signet/train/009/009_24.PNG"]
|
| 667 |
+
}
|
| 668 |
+
```
|
| 669 |
+
|
| 670 |
+
`signer_group` is the pipeline's internal, deterministic identity key
|
| 671 |
+
(`src/verification/registry.py::SignerIdentityRegistry`) -- `display_name`
|
| 672 |
+
is the human-readable name derived from it, stable across every case that
|
| 673 |
+
identity appears in. `appears_in` distinguishes *how* that identity was
|
| 674 |
+
used in each case (`"primary"` = the case's own target signer, `"companion"`
|
| 675 |
+
= the officially-tested second signer for `1c`/`4a`/`4b`/`5a`/`5b`,
|
| 676 |
+
`"additional"` = an unrelated-but-still-genuine extra box fill -- see
|
| 677 |
+
"Every signature box on a multi-box template gets signed" above).
|
| 678 |
+
`reference_images` collects every distinct source image path used for that
|
| 679 |
+
identity across the whole dataset (not just one case), each already copied
|
| 680 |
+
into its case folder too (`*_reference_signature.png` /
|
| 681 |
+
`*_companion_signature.png` / `*_<box_id>_signature.png`) so nothing here
|
| 682 |
+
depends on `data/raw/` actually being present to inspect a specific case.
|
| 683 |
+
|
| 684 |
+
## Multi-signature-box logic, audited (and a real gap fixed)
|
| 685 |
+
|
| 686 |
+
Every case type that needs two signature boxes (`1c`, `4a`, `4b`, `5a`,
|
| 687 |
+
`5b` -- see `CASE_TABLE`'s `fill_companion` flag) was verified end-to-end
|
| 688 |
+
across both scanned and procedural 2-box templates: template selection
|
| 689 |
+
correctly restricts to templates with >=2 boxes, the companion box is
|
| 690 |
+
always filled with its own correct authorized signer, and the target box
|
| 691 |
+
gets exactly what its case type specifies (`1c`: also correct; `4a`: the
|
| 692 |
+
*companion's* signer, in the wrong box, same form; `4b`: a signer
|
| 693 |
+
authorized on a *different* template entirely; `5a`: left blank; `5b`: an
|
| 694 |
+
unrelated wrong signer) -- confirmed both from the ground-truth JSON
|
| 695 |
+
(`expected_signer_group` vs `actual_signer_group` vs the companion's
|
| 696 |
+
group) and from rendered images.
|
| 697 |
+
|
| 698 |
+
**Gap found and fixed:** case types that *don't* officially test a second
|
| 699 |
+
signer (`1a`, `1b`, `1d`, `2a`, `2b`, `3a`, `3b` -- `fill_companion=False`)
|
| 700 |
+
and plain generic (no `quality.case_type` at all) samples were leaving any
|
| 701 |
+
*extra* box on a multi-box template completely blank, with no verdict or
|
| 702 |
+
reject_code explaining why -- it just looked like a broken render. Fixed
|
| 703 |
+
in both `_run_generic_sample` and `_run_case_sample`
|
| 704 |
+
(`src/generator.py`): any signature box beyond the one the sample is
|
| 705 |
+
actually scored/tested against now gets filled with its own correct
|
| 706 |
+
authorized signer too, recorded under the new `additional_signatures`
|
| 707 |
+
metadata field (list of `{box_id, role, signature_id, signer_group,
|
| 708 |
+
source_type, script, ref_path}`) and copied into the case folder as
|
| 709 |
+
`..._<box_id>_signature.png`. This only ever *adds* a properly-signed box
|
| 710 |
+
-- it never overrides a case type's own deliberate blank/wrong-signer
|
| 711 |
+
target (verified: a `2a` case's target box is still correctly blank with
|
| 712 |
+
`NO_SIGNATURE`, while its unrelated second box is now properly signed
|
| 713 |
+
instead of mysteriously empty).
|
| 714 |
+
|
| 715 |
+
One real annotation bug found and fixed in the process: `bank_doc_09`'s
|
| 716 |
+
bundled signature-box annotation (`annotations/bank_doc_09.json`,
|
| 717 |
+
inherited as-is from an earlier revision) placed both boxes 27px too
|
| 718 |
+
high -- inside the paragraph text above the "First/Second Authorised
|
| 719 |
+
Signatory" line instead of on it. Found by rendering a sample and looking
|
| 720 |
+
at where the ink actually landed (not visible from the annotation JSON
|
| 721 |
+
alone), confirmed against the template's real line position via the same
|
| 722 |
+
CV line-detector `scanned_cosmetic_fields.py` uses, and corrected.
|
| 723 |
+
|
| 724 |
+
## A bug worth knowing about (fixed): reference signature images
|
| 725 |
+
|
| 726 |
+
An earlier version of the per-case output copied the reference signature
|
| 727 |
+
file with `shutil.copy2` under a `*_reference_signature.png` name. For
|
| 728 |
+
CEDAR/SigNet sources (already `.png`) that's fine; for **BHSig260 sources
|
| 729 |
+
(`.tif` files)** it silently produced a file containing real TIFF bytes
|
| 730 |
+
under a `.png` name -- any standards-compliant image viewer correctly
|
| 731 |
+
refuses to open it. Fixed by decoding with Pillow and re-saving as a real
|
| 732 |
+
PNG (`src/generator.py::_export_reference_signature`) -- every reference/
|
| 733 |
+
companion signature image is now guaranteed to actually be what its
|
| 734 |
+
extension claims. Regression test: `tests/test_scanned_cosmetic_fields.py::test_export_reference_signature_produces_a_real_png_from_a_tiff_source`.
|
| 735 |
+
|
| 736 |
+
## Usage
|
| 737 |
+
|
| 738 |
+
```bash
|
| 739 |
+
pip install -r requirements.txt
|
| 740 |
+
|
| 741 |
+
# The flagship demo -- all 12 case types x both template families, 24 cases
|
| 742 |
+
python scripts/generate_showcase_dataset.py
|
| 743 |
+
# (already generated once into data/showcase_dataset/ -- rerun to regenerate)
|
| 744 |
+
|
| 745 |
+
# Default: both template pools, deck-literal difficulty constants
|
| 746 |
+
python main.py --count 20 --seed 1
|
| 747 |
+
|
| 748 |
+
# Procedural only, recalibrated difficulty constants
|
| 749 |
+
python main.py --config configs/procedural_templates.yaml --count 20 --seed 1
|
| 750 |
+
|
| 751 |
+
# Scanned only
|
| 752 |
+
python main.py --set paths.template_provider=scanned --count 20 --seed 1
|
| 753 |
+
|
| 754 |
+
# A specific case type (see src/verification/case_types.py for all 12)
|
| 755 |
+
python main.py --config configs/procedural_templates.yaml --set quality.case_type=3b --count 10
|
| 756 |
+
|
| 757 |
+
# Request a specific difficulty score, with a before/after preview
|
| 758 |
+
python main.py --count 5 --target-difficulty 0.55 --save-before-after
|
| 759 |
+
```
|
| 760 |
+
|
| 761 |
+
## Interactive UI
|
| 762 |
+
|
| 763 |
+
```bash
|
| 764 |
+
streamlit run streamlit_app.py
|
| 765 |
+
```
|
| 766 |
+
|
| 767 |
+
A local browser UI (`streamlit_app.py`) for one-off, customizable document
|
| 768 |
+
creation -- pick template provider/id (picking a specific template shows a
|
| 769 |
+
live preview of its blank form, with its document type and signature-box
|
| 770 |
+
roles, right below the dropdown, before you even generate anything), case
|
| 771 |
+
type (with its own description/verdict shown inline), a natural or
|
| 772 |
+
targeted difficulty (as a plain percentage, not a raw `0.0-1.0` score), a
|
| 773 |
+
**difficulty calibration** toggle (see below), signer name/role overrides,
|
| 774 |
+
cosmetic fields on/off, and a seed, then generate and immediately see the
|
| 775 |
+
rendered document, verdict, and a plain difficulty percentage (the
|
| 776 |
+
easy/medium/hard tier is kept in the ground-truth data for case-type
|
| 777 |
+
validation, but isn't the headline UI number anymore -- a caption under
|
| 778 |
+
the percentage shows it for anyone who wants it), and (organized into tabs
|
| 779 |
+
to keep the page scannable):
|
| 780 |
+
- **Difficulty & degradation** -- what actually built the difficulty score
|
| 781 |
+
(a labeled progress bar per weighted component --
|
| 782 |
+
misplacement/whitespace/faintness/smallness/low texture, each with a
|
| 783 |
+
one-line plain-English explanation) and which degradation effects
|
| 784 |
+
actually, randomly fired for this specific sample (not just which were
|
| 785 |
+
possible). Difficulty calibration is now automatic per sample (see
|
| 786 |
+
"Difficulty calibration profiles" above) -- procedural samples get
|
| 787 |
+
`smallness_reference_area_ratio=0.003` / `faintness_dark_ref/light_ref=
|
| 788 |
+
165/220` / `low_texture_reference_stddev=46` and scanned samples keep the
|
| 789 |
+
deck-literal `0.04` / `90` / `210` / `60`, without needing to opt in. The
|
| 790 |
+
sidebar's **"Force deck-literal reference for ALL templates"** checkbox
|
| 791 |
+
(off by default) is only there to deliberately turn this OFF -- forcing
|
| 792 |
+
every sample, including procedural ones, back onto the deck-literal
|
| 793 |
+
constants that peg smallness/faintness near their ceiling for almost any
|
| 794 |
+
real signature (real signature area is only ~0.2-1.5% of the page vs. the
|
| 795 |
+
deck's 4% "normal"), e.g. to compare
|
| 796 |
+
against pre-recalibration scores.
|
| 797 |
+
- **Interactive lever** -- drag a slider to watch the same clean render
|
| 798 |
+
pass through progressively stronger document degradation live, without
|
| 799 |
+
re-running the whole pipeline. Three modes: *match this document*
|
| 800 |
+
(default) scales only the effects that actually fired, together, from
|
| 801 |
+
0% (clean) to 100% (this document's real per-effect strengths -- so it
|
| 802 |
+
closely matches the Before/After tab, modulo the lever using its own
|
| 803 |
+
seed key for random-variant choices like stain shape/shadow corner);
|
| 804 |
+
*adjust individually* gives one slider per document-level effect,
|
| 805 |
+
defaulted to what actually fired (0% if it didn't), so you can push any
|
| 806 |
+
single effect harder/weaker/on independently of the rest; *explore from
|
| 807 |
+
clean* ignores what actually fired entirely and forces all 14 effects on
|
| 808 |
+
together at one shared strength, starting from clean -- a generic
|
| 809 |
+
worst-case stress-test (this is what the original version of the lever
|
| 810 |
+
always did, kept as its own mode). Both the clean and degraded preview
|
| 811 |
+
now show a live `difficulty_score`, freshly re-detected from the
|
| 812 |
+
pictured image with the same `GeometryAnalyzer`/`DifficultyAnalyzer` the
|
| 813 |
+
real pipeline uses -- it updates as you drag, but is computed
|
| 814 |
+
independently of (and can legitimately differ from) the "Difficulty"
|
| 815 |
+
badge at the top of the page, which reflects the actual generated
|
| 816 |
+
document. The lever only covers the 14 whole-page effects
|
| 817 |
+
(`apply_to_document()`) -- the 6 signature-only effects (rotation,
|
| 818 |
+
stroke thickness, ink bleed/fading, blur) run earlier, on the signature
|
| 819 |
+
snippet alone before it's pasted onto the page, so they're already baked
|
| 820 |
+
into "Clean" as plain pixels and can't be toggled here; a warning banner
|
| 821 |
+
surfaces exactly which of them fired (if any) for the current sample, so
|
| 822 |
+
the gap is visible rather than silent.
|
| 823 |
+
- **Before / After (real)** -- the saved before/after preview PNG, with a
|
| 824 |
+
note that BEFORE's colored boxes are a measurement diagnostic
|
| 825 |
+
(target/detected/cosmetic-field boxes) and AFTER deliberately has no
|
| 826 |
+
overlay, since it's meant to show what a real reviewer/scanner would see.
|
| 827 |
+
- **Ground truth & export** -- the full ground-truth JSON, the equivalent
|
| 828 |
+
`python main.py --set ...` CLI command (every control is a thin wrapper
|
| 829 |
+
over a config override the CLI already accepts, so anything built in the
|
| 830 |
+
UI is exactly reproducible from the command line), and "Save this case",
|
| 831 |
+
which writes the document/ground-truth/reference-signature into
|
| 832 |
+
`data/ui_generated/<name>/`, in the same per-case-folder shape as
|
| 833 |
+
`output/`/`data/showcase_dataset/`.
|
| 834 |
+
|
| 835 |
+
## Tests
|
| 836 |
+
|
| 837 |
+
```bash
|
| 838 |
+
python -m pytest -q # 119 passed, ~2-3 min
|
| 839 |
+
```
|
| 840 |
+
|
| 841 |
+
Covers formula correctness, placement, augmentation, validation, all 12
|
| 842 |
+
case types, end-to-end reproducibility, `ProceduralTemplateProvider`,
|
| 843 |
+
`CompositeTemplateProvider`, the additive signature sources, and the two
|
| 844 |
+
difficulty-calibration profiles.
|
unified_pipeline/data/raw/signet/train/060_forg/01_0111060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/01_0121060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/01_0126060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/02_0111060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/02_0121060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/02_0126060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/03_0111060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/03_0121060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/03_0126060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/04_0111060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/04_0121060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/060_forg/04_0126060.PNG
ADDED
|
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/01_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/02_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/03_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/04_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/05_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/06_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/07_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/08_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/09_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/10_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/11_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061/12_061.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/061_forg/01_0102061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/01_0112061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/01_0206061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/02_0102061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/02_0112061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/02_0206061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/03_0102061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/03_0112061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/03_0206061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/04_0102061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/04_0112061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/061_forg/04_0206061.PNG
ADDED
|
|
unified_pipeline/data/raw/signet/train/062/01_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/02_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/03_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/04_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/05_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/06_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/07_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/08_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/09_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/10_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/11_062.png
ADDED
|
Git LFS Details
|
unified_pipeline/data/raw/signet/train/062/12_062.png
ADDED
|
Git LFS Details
|