venki101 commited on
Commit
1a12965
·
verified ·
1 Parent(s): 97b64b9

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. dataset/genuine/original_42_6.png +3 -0
  2. dataset/genuine/original_42_7.png +3 -0
  3. dataset/genuine/original_42_8.png +3 -0
  4. dataset/genuine/original_42_9.png +3 -0
  5. dataset/genuine/original_43_1.png +3 -0
  6. dataset/genuine/original_43_10.png +3 -0
  7. dataset/genuine/original_43_11.png +3 -0
  8. dataset/genuine/original_43_12.png +3 -0
  9. dataset/genuine/original_43_13.png +3 -0
  10. dataset/genuine/original_43_14.png +3 -0
  11. dataset/genuine/original_43_15.png +3 -0
  12. dataset/genuine/original_43_16.png +3 -0
  13. dataset/genuine/original_43_17.png +3 -0
  14. dataset/genuine/original_43_18.png +3 -0
  15. dataset/genuine/original_43_19.png +3 -0
  16. dataset/genuine/original_43_2.png +3 -0
  17. dataset/genuine/original_43_20.png +3 -0
  18. dataset/genuine/original_43_21.png +3 -0
  19. dataset/genuine/original_43_22.png +3 -0
  20. dataset/genuine/original_43_23.png +3 -0
  21. dataset/genuine/original_43_24.png +3 -0
  22. dataset/genuine/original_43_3.png +3 -0
  23. dataset/genuine/original_43_4.png +3 -0
  24. dataset/genuine/original_43_5.png +3 -0
  25. dataset/genuine/original_43_6.png +3 -0
  26. dataset/genuine/original_43_7.png +3 -0
  27. dataset/genuine/original_43_8.png +3 -0
  28. dataset/genuine/original_43_9.png +3 -0
  29. dataset/genuine/original_44_1.png +3 -0
  30. dataset/genuine/original_44_10.png +3 -0
  31. dataset/genuine/original_44_11.png +3 -0
  32. dataset/genuine/original_44_12.png +3 -0
  33. dataset/genuine/original_44_13.png +3 -0
  34. dataset/genuine/original_44_14.png +3 -0
  35. dataset/genuine/original_44_15.png +3 -0
  36. dataset/genuine/original_44_16.png +3 -0
  37. dataset/genuine/original_44_17.png +3 -0
  38. dataset/genuine/original_44_18.png +3 -0
  39. dataset/genuine/original_44_19.png +3 -0
  40. dataset/genuine/original_44_2.png +3 -0
  41. dataset/genuine/original_44_20.png +3 -0
  42. dataset/genuine/original_44_21.png +3 -0
  43. dataset/genuine/original_44_22.png +3 -0
  44. dataset/genuine/original_44_23.png +3 -0
  45. dataset/genuine/original_44_24.png +3 -0
  46. scripts/build_degradation_slides.py +306 -0
  47. scripts/degrade_cli.py +279 -0
  48. scripts/generate_before_after_assets.py +177 -0
  49. scripts/generate_case_assets.py +82 -0
  50. scripts/generate_degradation_gallery.py +78 -0
dataset/genuine/original_42_6.png ADDED

Git LFS Details

  • SHA256: 4c339d774a4d79131fe8c61a242250706725eacb7008600452f86fc15ed1bbc7
  • Pointer size: 130 Bytes
  • Size of remote file: 91.3 kB
dataset/genuine/original_42_7.png ADDED

Git LFS Details

  • SHA256: f1af23f88985a98caa9a25065f4bcf998e638c8b31a14bdd7f59599c1479e1a2
  • Pointer size: 130 Bytes
  • Size of remote file: 76.3 kB
dataset/genuine/original_42_8.png ADDED

Git LFS Details

  • SHA256: 8944f54c8e773b84b814b352661c029ef2a8ad89f31470029e543769c7081ec1
  • Pointer size: 131 Bytes
  • Size of remote file: 106 kB
dataset/genuine/original_42_9.png ADDED

Git LFS Details

  • SHA256: 730f4c7bafef9d369cf09e65485e92debd83f499d34cd791ae5dd1868e28f304
  • Pointer size: 130 Bytes
  • Size of remote file: 78.3 kB
dataset/genuine/original_43_1.png ADDED

Git LFS Details

  • SHA256: 6be95468b77fc516fc600797bbdcdf14dffbf2b9a77f3fe56ba21de8cfceb4b0
  • Pointer size: 130 Bytes
  • Size of remote file: 85.6 kB
dataset/genuine/original_43_10.png ADDED

Git LFS Details

  • SHA256: 687a1287f156b505b61deac61be26ba1777596df56262521a6bf6844254a935f
  • Pointer size: 130 Bytes
  • Size of remote file: 91.3 kB
dataset/genuine/original_43_11.png ADDED

Git LFS Details

  • SHA256: fda47e5eea0eb2c9d12c669a64ff8389ffb1041702849996a9935f044534bee0
  • Pointer size: 130 Bytes
  • Size of remote file: 94.1 kB
dataset/genuine/original_43_12.png ADDED

Git LFS Details

  • SHA256: 6f355c157cb0dbd7ece5a4f85a52e184a35457b3368c41c3bcd0381b2efd06c5
  • Pointer size: 130 Bytes
  • Size of remote file: 79.9 kB
dataset/genuine/original_43_13.png ADDED

Git LFS Details

  • SHA256: 822c6451724666e2f4cecb288eefbf93fd12dd3a20d097c724537ef0d2c94bb5
  • Pointer size: 130 Bytes
  • Size of remote file: 94.9 kB
dataset/genuine/original_43_14.png ADDED

Git LFS Details

  • SHA256: 15e1782b0f46c6ab5221a4df5730ff89beb0cbee127e3ab938c96ccf5f9c3ac9
  • Pointer size: 131 Bytes
  • Size of remote file: 103 kB
dataset/genuine/original_43_15.png ADDED

Git LFS Details

  • SHA256: fc8ff31795df68abf986b3c722f40c6caf5f49cecd7b274337f557e3743743ee
  • Pointer size: 130 Bytes
  • Size of remote file: 75.1 kB
dataset/genuine/original_43_16.png ADDED

Git LFS Details

  • SHA256: c45b1f13ef5ffc5746f12dd0137158326489f5de2bc31f8de7bf6257d2348b32
  • Pointer size: 130 Bytes
  • Size of remote file: 81.3 kB
dataset/genuine/original_43_17.png ADDED

Git LFS Details

  • SHA256: 1eba68ca30c4326f08f1b7e9db3f3c48b4b4db14aa8b86c0d4a4b8ce1eff6a59
  • Pointer size: 130 Bytes
  • Size of remote file: 78.9 kB
dataset/genuine/original_43_18.png ADDED

Git LFS Details

  • SHA256: 235cc3b65c08c6fe2db69a22f5174f3e80ac80dc1cf9ab28e0f36c269554be82
  • Pointer size: 131 Bytes
  • Size of remote file: 102 kB
dataset/genuine/original_43_19.png ADDED

Git LFS Details

  • SHA256: b92695b127f19cf27303c5426900395e6b63f6f8bdda9f171f953b14578bc4d3
  • Pointer size: 130 Bytes
  • Size of remote file: 77.4 kB
dataset/genuine/original_43_2.png ADDED

Git LFS Details

  • SHA256: b4e00df7a276d270e54fed709601a9c6ac605d898a53c6dab5b3e3fecbf6217e
  • Pointer size: 130 Bytes
  • Size of remote file: 82.7 kB
dataset/genuine/original_43_20.png ADDED

Git LFS Details

  • SHA256: c8fbee3df1cf6055ad1a819b6f20b6424b38343f1aa0f9bd24da3bbce86528d8
  • Pointer size: 130 Bytes
  • Size of remote file: 71.4 kB
dataset/genuine/original_43_21.png ADDED

Git LFS Details

  • SHA256: 5ed3dc9445788b39fc571fe56fcd2d3f0a76b7b5061dd7d8f518ed7ed8dc8783
  • Pointer size: 130 Bytes
  • Size of remote file: 84.8 kB
dataset/genuine/original_43_22.png ADDED

Git LFS Details

  • SHA256: 0160275c3fd8ebb4480b19e6023c6c93234dde53c8142b875d899bc8cd1acc24
  • Pointer size: 130 Bytes
  • Size of remote file: 89.7 kB
dataset/genuine/original_43_23.png ADDED

Git LFS Details

  • SHA256: ba36d5d45bc989b23fad613b037631e0c46f7e9e3996f704889557997a860ffe
  • Pointer size: 130 Bytes
  • Size of remote file: 82.1 kB
dataset/genuine/original_43_24.png ADDED

Git LFS Details

  • SHA256: 80d19b5e5c536496557ad272ddcaff0ac08ad202ef15d9671ab4c789e8de2f3e
  • Pointer size: 130 Bytes
  • Size of remote file: 98 kB
dataset/genuine/original_43_3.png ADDED

Git LFS Details

  • SHA256: 1162c6ceef3fd809a4c386c2f8e3e4a5d8354397b6728d711358c6a40b94ca1a
  • Pointer size: 130 Bytes
  • Size of remote file: 75.4 kB
dataset/genuine/original_43_4.png ADDED

Git LFS Details

  • SHA256: 4d4a13ca2323f005499b581f921a38ed871655f07ed3b5171852305b1fc9ade1
  • Pointer size: 130 Bytes
  • Size of remote file: 85.3 kB
dataset/genuine/original_43_5.png ADDED

Git LFS Details

  • SHA256: 98e03dc1d0e56650725d3179e11d7882e9971409c002df7ac9cd213b5b454c22
  • Pointer size: 130 Bytes
  • Size of remote file: 82 kB
dataset/genuine/original_43_6.png ADDED

Git LFS Details

  • SHA256: 7a6100535dfc3ea7f3c4107b934dc3cba108517ca0c8c00f0c860dcff5c2ea32
  • Pointer size: 130 Bytes
  • Size of remote file: 83 kB
dataset/genuine/original_43_7.png ADDED

Git LFS Details

  • SHA256: 954416dacea4257581ac2d1e584eedf26c94bab3349e6f9ea60a70f58c911b4d
  • Pointer size: 130 Bytes
  • Size of remote file: 66.4 kB
dataset/genuine/original_43_8.png ADDED

Git LFS Details

  • SHA256: 2c0331fc3c1fe85ecf09c64004c89dccfbde6ca183d0abc3c792f5da5e79babc
  • Pointer size: 130 Bytes
  • Size of remote file: 74.5 kB
dataset/genuine/original_43_9.png ADDED

Git LFS Details

  • SHA256: 37e35bd9f4a9bb2da17f5968bbe363c9eda79ae5ba622f439649f9f673764d7b
  • Pointer size: 130 Bytes
  • Size of remote file: 77.9 kB
dataset/genuine/original_44_1.png ADDED

Git LFS Details

  • SHA256: 0235a5e7677b2883a6a2bf890ff83ad01a73f7f08b841181fff099455545e769
  • Pointer size: 130 Bytes
  • Size of remote file: 65.4 kB
dataset/genuine/original_44_10.png ADDED

Git LFS Details

  • SHA256: 7fdce9d9fadbb09e79411962ec8c2a36ef110def0014ad7cbe2ad8b6ec642550
  • Pointer size: 130 Bytes
  • Size of remote file: 57 kB
dataset/genuine/original_44_11.png ADDED

Git LFS Details

  • SHA256: b4bc466a8f633b2880e8b6c0a432e7cb4946dc67c23edaed50688cf8f85294a9
  • Pointer size: 130 Bytes
  • Size of remote file: 60.9 kB
dataset/genuine/original_44_12.png ADDED

Git LFS Details

  • SHA256: d2e256c2523135f87e04d63f63b2655fbd746ec89eb9b82b7516a594bc5aa563
  • Pointer size: 130 Bytes
  • Size of remote file: 67.1 kB
dataset/genuine/original_44_13.png ADDED

Git LFS Details

  • SHA256: 816572178541f84ce4b78d2e7e097eb1eec9466601efff0b8e84c2c7496adb6d
  • Pointer size: 130 Bytes
  • Size of remote file: 52.6 kB
dataset/genuine/original_44_14.png ADDED

Git LFS Details

  • SHA256: c305feba125c25dd994570f3baefed1dcb02dfcc9cb273c946fafb6b0a794aad
  • Pointer size: 130 Bytes
  • Size of remote file: 43.9 kB
dataset/genuine/original_44_15.png ADDED

Git LFS Details

  • SHA256: 5d0b365076cd0e990471b896ec88793944aae0852027ba2cf8a91a038f9501fa
  • Pointer size: 130 Bytes
  • Size of remote file: 55.3 kB
dataset/genuine/original_44_16.png ADDED

Git LFS Details

  • SHA256: 792f991421ffabe12f60d8b4b61611b41a7f3925d0f48e565e21f69b3178c85a
  • Pointer size: 130 Bytes
  • Size of remote file: 48.3 kB
dataset/genuine/original_44_17.png ADDED

Git LFS Details

  • SHA256: ee485688d3d4a728feea82ecf38bfa348d81870e48154da501e076ab8c877cab
  • Pointer size: 130 Bytes
  • Size of remote file: 58.7 kB
dataset/genuine/original_44_18.png ADDED

Git LFS Details

  • SHA256: 63c8056e3ec6c71430a92ad4c87f7da89df9f124a4a33ddf73040f6c0f4acd3a
  • Pointer size: 130 Bytes
  • Size of remote file: 65 kB
dataset/genuine/original_44_19.png ADDED

Git LFS Details

  • SHA256: 490ee8dbe178536ec87940891cfccaf2555ea6232a9db06995eb09388a7d7267
  • Pointer size: 130 Bytes
  • Size of remote file: 63.8 kB
dataset/genuine/original_44_2.png ADDED

Git LFS Details

  • SHA256: 3f76edd19202caae86fc0869357162bb6924072945964cb8396c68dfbc7b220a
  • Pointer size: 130 Bytes
  • Size of remote file: 72.9 kB
dataset/genuine/original_44_20.png ADDED

Git LFS Details

  • SHA256: 0b1b4664c83ddac0945fb97b43624d79fb29cf62dd8a512a554e328b913df299
  • Pointer size: 130 Bytes
  • Size of remote file: 48.9 kB
dataset/genuine/original_44_21.png ADDED

Git LFS Details

  • SHA256: 1366e2f07576a0af3c69b4c956146d4043be8682405f3b152186b6c6e23c0113
  • Pointer size: 130 Bytes
  • Size of remote file: 60.6 kB
dataset/genuine/original_44_22.png ADDED

Git LFS Details

  • SHA256: 8dc22282b288de504e057db373ff68c74d06f31af81bd1303cabe2d660fcd4e2
  • Pointer size: 130 Bytes
  • Size of remote file: 65.7 kB
dataset/genuine/original_44_23.png ADDED

Git LFS Details

  • SHA256: fe9a370f4b1fc48f1135e4a5661ccb02b3c9cee513e6dde804c8bb16b31a1f01
  • Pointer size: 130 Bytes
  • Size of remote file: 69.3 kB
dataset/genuine/original_44_24.png ADDED

Git LFS Details

  • SHA256: 6ab6d09f72bc935b30fd5f6547c5101439d457ae3aed9f5ba37eba0cb487cbac
  • Pointer size: 130 Bytes
  • Size of remote file: 49.3 kB
scripts/build_degradation_slides.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Append a before/after slide per augmentation effect, plus normal- and
3
+ extreme-case multi-degradation slides, to Metrics_Thresholds_Formulas.pptx.
4
+
5
+ Reuses the assets written by generate_before_after_assets.py and
6
+ generate_case_assets.py (run those first). New slides replicate the deck's
7
+ existing visual language exactly (navy header bar, orange accent line, teal
8
+ section label, Calibri/Consolas body text, bottom-right page number) so they
9
+ read as part of the same deck rather than a bolt-on.
10
+
11
+ The new slides are inserted as a new section 8 ("Degradation gallery") and
12
+ section 9 ("Multi-degradation cases"), placed right before the closing "Key
13
+ takeaways" slide, and the Agenda slide (slide 2) is extended with the two new
14
+ entries. A one-time backup of the original file is kept alongside it.
15
+
16
+ Usage:
17
+ python scripts/build_degradation_slides.py
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import copy
23
+ import json
24
+ import shutil
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
29
+
30
+ from pptx import Presentation
31
+ from pptx.dml.color import RGBColor
32
+ from pptx.enum.shapes import MSO_SHAPE
33
+ from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
34
+ from pptx.util import Emu, Pt
35
+ from PIL import Image
36
+
37
+ from scripts.generate_before_after_assets import (
38
+ ALL_EFFECTS, DISPLAY_NAME, ZOOM_EFFECTS,
39
+ )
40
+ from scripts.generate_multiple_degradation_extreme_gallery import _fmt_effect
41
+
42
+ ROOT = Path(__file__).resolve().parents[1]
43
+ PPTX_PATH = ROOT / "Metrics_Thresholds_Formulas.pptx"
44
+ BACKUP_PATH = ROOT / "Metrics_Thresholds_Formulas.backup.pptx"
45
+ DEG_ROOT = ROOT / "degradation"
46
+
47
+ # ---------------------------------------------------------------- palette --
48
+ NAVY, TEAL, ORANGE = "0F2A43", "1B7A7A", "E07A2C"
49
+ LIGHT_TEAL, GRAY, LIGHT_BG, TEXT_DARK, WHITE = "9BC5C5", "6B767C", "F4F7F8", "222A30", "FFFFFF"
50
+
51
+
52
+ def rgb(hexstr: str) -> RGBColor:
53
+ return RGBColor.from_string(hexstr)
54
+
55
+
56
+ # ------------------------------------------------------------- geometry ---
57
+ SLIDE_W, SLIDE_H = Emu(12191695), Emu(6858000)
58
+ HEADER_H, ACCENT_H = Emu(1051560), Emu(38100)
59
+ TITLE_X = Emu(502920)
60
+ CONTENT_X, CONTENT_W = Emu(640080), Emu(10881360)
61
+ FOOTER_X, FOOTER_Y = Emu(11475720), Emu(6510528)
62
+ FOOTER_W, FOOTER_H = Emu(640080), Emu(274320)
63
+
64
+ LEFT_X, RIGHT_X = Emu(640080), Emu(6263640)
65
+ COL_W = Emu(5257800)
66
+ LABEL_Y, LABEL_H = Emu(1554480), Emu(365760)
67
+ IMG_Y = Emu(LABEL_Y + LABEL_H + Emu(91440))
68
+
69
+ CAPTION_Y = Emu(1150000)
70
+ CAPTION_H = Emu(320040)
71
+
72
+ # ------------------------------------------------------ formula captions --
73
+ FORMULAS = {
74
+ "rotation": "extra_deg = U(-1,1) × strength × 8.0",
75
+ "stroke_dilation": "ksize = round(1 + strength×5) [odd]",
76
+ "ink_fading": "mult = clip(1 − strength×(1−field)×0.9, .05, 1)",
77
+ "ink_bleed": "ksize = round(1+strength×4); halo floor = .35+.35×strength",
78
+ "motion_blur": "length = round(3 + strength×16)",
79
+ "gaussian_blur": "sigma = 0.2 + strength×2.2",
80
+ "perspective": "scale = 0.01 + strength×0.09",
81
+ "contrast": "contrast_limit = ±(strength×0.45)",
82
+ "gamma": "delta = round(strength×45); gamma_limit = (100−delta, 100+delta), lo≥40",
83
+ "coffee_stain": "n_blobs = 1+randint(0,1+round(strength×3)); opacity = .10+.30×strength",
84
+ "stain": "radius = (.06+.16×strength)×min(w,h); opacity scales with strength",
85
+ "shadow": "darkness = 0.15+0.55×strength; band/radius/fold-width scale with strength",
86
+ "crumple": "amplitude = strength×min(w,h)×0.015",
87
+ "discoloration": "tint strength scales with strength per tone",
88
+ "page_tilt": "angle = U(-1,1) × (1.5 + strength×6.5)",
89
+ "dust": "n_specks = (h×w)×0.00003×(1+strength×6)",
90
+ "scanner_streaks": "n_streaks = 1+int(strength×6)",
91
+ "scanner_noise": "GaussNoise std = 0.01 + strength×0.12",
92
+ "salt_pepper": "amount = 0.002 + strength×0.09",
93
+ "jpeg_compression": "quality = randint(min_quality, max_quality) — NOT driven by strength",
94
+ }
95
+
96
+
97
+ def short_list(names: list[str], limit: int = 8) -> str:
98
+ if len(names) <= limit:
99
+ return ", ".join(names) if names else "none"
100
+ return ", ".join(names[:limit]) + f", +{len(names) - limit} more"
101
+
102
+
103
+ # ----------------------------------------------------------- primitives ---
104
+ def add_rect(slide, x, y, w, h, color: str):
105
+ shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
106
+ shp.fill.solid()
107
+ shp.fill.fore_color.rgb = rgb(color)
108
+ shp.line.fill.background()
109
+ return shp
110
+
111
+
112
+ def add_text(
113
+ slide, x, y, w, h, text, size, color, *, bold=False, font="Calibri",
114
+ align=PP_ALIGN.LEFT, anchor=None, word_wrap=True,
115
+ ):
116
+ tb = slide.shapes.add_textbox(x, y, w, h)
117
+ tf = tb.text_frame
118
+ tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
119
+ tf.word_wrap = word_wrap
120
+ if anchor is not None:
121
+ tf.vertical_anchor = anchor
122
+ lines = text.split("\n")
123
+ for i, line in enumerate(lines):
124
+ p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
125
+ p.alignment = align
126
+ run = p.add_run()
127
+ run.text = line
128
+ run.font.size = Pt(size)
129
+ run.font.bold = bold
130
+ run.font.name = font
131
+ run.font.color.rgb = rgb(color)
132
+ return tb
133
+
134
+
135
+ def add_bg(slide):
136
+ add_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, WHITE)
137
+
138
+
139
+ def add_header(slide, section_label: str, title: str, page_num: int):
140
+ add_rect(slide, Emu(0), Emu(0), SLIDE_W, HEADER_H, NAVY)
141
+ add_rect(slide, Emu(0), HEADER_H, SLIDE_W, ACCENT_H, ORANGE)
142
+ add_text(slide, TITLE_X, Emu(128016), Emu(9144000), Emu(274320), section_label, 12, LIGHT_TEAL, bold=True)
143
+ add_text(slide, TITLE_X, Emu(384048), Emu(10515600), Emu(594360), title, 28, WHITE, bold=True)
144
+ add_text(
145
+ slide, FOOTER_X, FOOTER_Y, FOOTER_W, FOOTER_H, str(page_num), 11, GRAY,
146
+ align=PP_ALIGN.RIGHT,
147
+ )
148
+
149
+
150
+ def fit_picture(slide, img_path: Path, box_x, box_y, box_w, box_h):
151
+ iw, ih = Image.open(img_path).size
152
+ scale = min(box_w / iw, box_h / ih)
153
+ disp_w, disp_h = int(iw * scale), int(ih * scale)
154
+ left = int(box_x + (box_w - disp_w) / 2)
155
+ top = int(box_y + (box_h - disp_h) / 2)
156
+ slide.shapes.add_picture(str(img_path), left, top, width=disp_w, height=disp_h)
157
+
158
+
159
+ def add_panel_label(slide, x, color, text):
160
+ add_rect(slide, x, LABEL_Y, COL_W, LABEL_H, color)
161
+ add_text(
162
+ slide, x, LABEL_Y, COL_W, LABEL_H, text, 14, WHITE, bold=True,
163
+ align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, word_wrap=False,
164
+ )
165
+
166
+
167
+ # -------------------------------------------------------------- slides ----
168
+ def build_before_after_slide(prs, layout, effect: str, page_num: int, manifest: dict):
169
+ slide = prs.slides.add_slide(layout)
170
+ add_bg(slide)
171
+ add_header(slide, "8. DEGRADATION GALLERY", DISPLAY_NAME[effect], page_num)
172
+
173
+ rec = manifest[effect]
174
+ resolved = _fmt_effect(effect, {"strength": rec["strength"], "params": rec["params"] or {}})
175
+ caption = (
176
+ f"{FORMULAS[effect]} | resolved: {resolved} | "
177
+ f"difficulty after = {rec['difficulty_score']:.3f} ({rec['difficulty_tier']})"
178
+ )
179
+ add_text(slide, CONTENT_X, CAPTION_Y, CONTENT_W, CAPTION_H, caption, 12, GRAY, font="Consolas")
180
+
181
+ add_panel_label(slide, LEFT_X, TEAL, "BEFORE - clean render")
182
+ add_panel_label(slide, RIGHT_X, ORANGE, f"AFTER - {DISPLAY_NAME[effect]} (strength={rec['strength']:.2f})")
183
+
184
+ img_bottom = Emu(6100000)
185
+ img_h = Emu(img_bottom - IMG_Y)
186
+ before_name = "before_zoom.png" if effect in ZOOM_EFFECTS else "before.png"
187
+ after_name = "after_zoom.png" if effect in ZOOM_EFFECTS else "after.png"
188
+ fit_picture(slide, DEG_ROOT / effect / before_name, LEFT_X, IMG_Y, COL_W, img_h)
189
+ fit_picture(slide, DEG_ROOT / effect / after_name, RIGHT_X, IMG_Y, COL_W, img_h)
190
+ return slide
191
+
192
+
193
+ def build_case_slide(prs, layout, kind: str, page_num: int):
194
+ slide = prs.slides.add_slide(layout)
195
+ add_bg(slide)
196
+ title = "Normal-severity combinations" if kind == "normal" else "Extreme-severity combinations"
197
+ add_header(slide, "9. MULTI-DEGRADATION CASES", title, page_num)
198
+
199
+ if kind == "normal":
200
+ folder = ROOT / "multiple_degradation" / "case_assets"
201
+ subtitle = (
202
+ "configs/generate.yaml -- every effect independently rolls its own realistic "
203
+ "probability per sample (mixed, moderate combinations)"
204
+ )
205
+ color = TEAL
206
+ else:
207
+ folder = ROOT / "multiple_degradation_extreme" / "case_assets"
208
+ subtitle = (
209
+ "worst case -- every effect probability=0.85, strength 0.7-1.0, plus heavy "
210
+ "misplacement / faint+blurred ink (see scripts/generate_multiple_degradation_extreme_gallery.py)"
211
+ )
212
+ color = ORANGE
213
+
214
+ manifest = json.loads((folder / "case_manifest.json").read_text())
215
+ add_text(slide, CONTENT_X, CAPTION_Y, CONTENT_W, CAPTION_H, subtitle, 12, GRAY, font="Consolas")
216
+
217
+ img_bottom = Emu(5850000)
218
+ img_h = Emu(img_bottom - IMG_Y)
219
+ cap_y = Emu(img_bottom + Emu(45720))
220
+ cap_h = Emu(600000)
221
+
222
+ for i, x in enumerate((LEFT_X, RIGHT_X)):
223
+ key = f"case_{i + 1}"
224
+ rec = manifest[key]
225
+ add_panel_label(slide, x, color, f"Sample {i + 1} - {rec['n_applied']}/{rec['n_total']} effects applied")
226
+ fit_picture(slide, folder / f"{key}.png", x, IMG_Y, COL_W, img_h)
227
+ cap = (
228
+ f"difficulty = {rec['difficulty_score']:.3f} ({rec['difficulty_tier']}) | "
229
+ f"placement: rot={rec['placement']['rotation_deg']:.1f} deg "
230
+ f"scale={rec['placement']['scale_factor']:.2f} misplaced={rec['placement']['misplaced']}\n"
231
+ f"applied: {short_list(rec['applied_effects'])}"
232
+ )
233
+ add_text(slide, x, cap_y, COL_W, cap_h, cap, 10, TEXT_DARK, font="Consolas")
234
+ return slide
235
+
236
+
237
+ # ---------------------------------------------------------- agenda edit ---
238
+ def extend_agenda(prs):
239
+ slide = prs.slides[1]
240
+ target = None
241
+ for shape in slide.shapes:
242
+ if shape.name == "TextBox 7":
243
+ target = shape
244
+ break
245
+ if target is None:
246
+ print("WARNING: could not find agenda TextBox 7 -- skipping agenda update")
247
+ return
248
+
249
+ tf = target.text_frame
250
+ n_original = len(tf.paragraphs)
251
+ last_p = tf.paragraphs[-1]._p
252
+ new_texts = (
253
+ "8. Degradation gallery — before & after, all 20 effects",
254
+ "9. Multi-degradation cases — normal vs. extreme severity",
255
+ )
256
+ for text in new_texts:
257
+ new_p = copy.deepcopy(last_p)
258
+ run_elm = new_p.findall(".//{http://schemas.openxmlformats.org/drawingml/2006/main}t")
259
+ run_elm[0].text = text
260
+ last_p.addnext(new_p)
261
+ last_p = new_p
262
+
263
+ # Shape height doesn't clip a top-anchored textbox -- text simply overflows past
264
+ # it -- so what actually matters is total rendered text height staying inside the
265
+ # slide. 9 lines at the original 22pt ran ~24pt past the bottom edge, so shrink
266
+ # every run down to 20pt (measured to comfortably clear the slide bottom).
267
+ for para in tf.paragraphs:
268
+ for run in para.runs:
269
+ run.font.size = Pt(20)
270
+
271
+
272
+ # ---------------------------------------------------------------- main ----
273
+ def main():
274
+ if not BACKUP_PATH.exists():
275
+ shutil.copy2(PPTX_PATH, BACKUP_PATH)
276
+ print(f"Backed up original deck -> {BACKUP_PATH}")
277
+
278
+ manifest = json.loads((DEG_ROOT / "before_after_manifest.json").read_text())
279
+
280
+ prs = Presentation(str(PPTX_PATH))
281
+ layout = prs.slides[2].slide_layout # "Blank" layout used by all content slides
282
+
283
+ sld_id_lst = prs.slides._sldIdLst
284
+ closing_sld_id = sld_id_lst[-1] # "Key takeaways" slide, must stay last
285
+
286
+ extend_agenda(prs)
287
+
288
+ page_num = 19
289
+ for effect in ALL_EFFECTS:
290
+ build_before_after_slide(prs, layout, effect, page_num, manifest)
291
+ page_num += 1
292
+
293
+ build_case_slide(prs, layout, "normal", page_num)
294
+ page_num += 1
295
+ build_case_slide(prs, layout, "extreme", page_num)
296
+ page_num += 1
297
+
298
+ sld_id_lst.remove(closing_sld_id)
299
+ sld_id_lst.append(closing_sld_id)
300
+
301
+ prs.save(str(PPTX_PATH))
302
+ print(f"Saved {PPTX_PATH} -- {len(prs.slides.__iter__.__self__._sldIdLst)} slides total")
303
+
304
+
305
+ if __name__ == "__main__":
306
+ main()
scripts/degrade_cli.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Interactive terminal wizard for generating synthetic signed documents with
3
+ hand-picked degradation effects.
4
+
5
+ A friendlier front end over main.py's --set override system: arrow-key menus
6
+ and checkboxes instead of memorizing config dot-paths. Same pipeline underneath
7
+ (GeneratorContext), so output is identical to what main.py would produce with
8
+ the equivalent --set flags.
9
+
10
+ Usage:
11
+ python scripts/degrade_cli.py
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import sys
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
23
+
24
+ import questionary
25
+ from questionary import Choice, Separator
26
+ from rich.console import Console
27
+ from rich.panel import Panel
28
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
29
+ from rich.table import Table
30
+
31
+ from src.config.loader import load_config
32
+ from src.generator import GeneratorContext
33
+ from src.templates.template_loader import TemplateLoader
34
+ from src.utils.image_io import save_image_rgb
35
+ from src.visualization.visualizer import save_visualization
36
+ from scripts.generate_before_after_assets import ALL_EFFECTS, DISPLAY_NAME, DOCUMENT_EFFECTS, SIGNATURE_EFFECTS
37
+ from scripts.generate_multiple_degradation_extreme_gallery import build_overrides as extreme_overrides
38
+
39
+ ROOT = Path(__file__).resolve().parents[1]
40
+ console = Console()
41
+
42
+ # ---- palette, matched to Metrics_Thresholds_Formulas.pptx -----------------
43
+ NAVY, TEAL, ORANGE, GRAY = "#0F2A43", "#1B7A7A", "#E07A2C", "#6B767C"
44
+
45
+ QSTYLE = questionary.Style([
46
+ ("qmark", f"fg:{ORANGE} bold"),
47
+ ("question", "bold"),
48
+ ("answer", f"fg:{TEAL} bold"),
49
+ ("pointer", f"fg:{ORANGE} bold"),
50
+ ("highlighted", f"fg:{TEAL} bold"),
51
+ ("selected", f"fg:{TEAL}"),
52
+ ("instruction", f"fg:{GRAY}"),
53
+ ("separator", f"fg:{GRAY}"),
54
+ ])
55
+
56
+
57
+ def banner() -> None:
58
+ console.print(Panel.fit(
59
+ "[bold white]Synthetic Signed Document Generator[/bold white]\n"
60
+ f"[{TEAL}]pick degradations -> generate -> inspect[/{TEAL}]",
61
+ border_style=TEAL, padding=(1, 4),
62
+ ))
63
+
64
+
65
+ def discover_templates() -> list[tuple[str, str]]:
66
+ cfg = load_config()
67
+ loader = TemplateLoader(cfg.paths.image_dir, cfg.paths.annotations_dir)
68
+ loader.discover()
69
+ return sorted((tid, t.annotation.document_type) for tid, t in loader._templates.items())
70
+
71
+
72
+ def valid_float01(v: str) -> "bool | str":
73
+ try:
74
+ f = float(v)
75
+ except ValueError:
76
+ return "Enter a number between 0.0 and 1.0"
77
+ return True if 0.0 <= f <= 1.0 else "Must be between 0.0 and 1.0"
78
+
79
+
80
+ def valid_posint(v: str) -> "bool | str":
81
+ return True if v.isdigit() and int(v) > 0 else "Enter a positive whole number"
82
+
83
+
84
+ # ------------------------------------------------------------- questions ---
85
+ def ask_mode() -> str:
86
+ return questionary.select(
87
+ "How should degradations be applied?",
88
+ choices=[
89
+ Choice(title="Pick effects - isolated (ONLY the chosen effects fire, nothing else)", value="isolate"),
90
+ Choice(title="Pick effects - mixed (chosen effects forced on, others may also roll)", value="mixed"),
91
+ Choice(title="Preset - normal severity (realistic random mix, configs/generate.yaml)", value="normal"),
92
+ Choice(title="Preset - extreme severity (worst case, everything cranked)", value="extreme"),
93
+ ],
94
+ style=QSTYLE,
95
+ ).ask()
96
+
97
+
98
+ def ask_template() -> "str | None":
99
+ templates = discover_templates()
100
+ choices = [Choice(title="Random each sample", value=None)]
101
+ choices += [Choice(title=f"{tid} ({doc_type})", value=tid) for tid, doc_type in templates]
102
+ return questionary.select("Which document template?", choices=choices, style=QSTYLE).ask()
103
+
104
+
105
+ def ask_effects() -> list[str]:
106
+ choices = [Separator("-- signature-level --")]
107
+ choices += [Choice(title=DISPLAY_NAME[e], value=e) for e in SIGNATURE_EFFECTS]
108
+ choices += [Separator("-- document-level --")]
109
+ choices += [Choice(title=DISPLAY_NAME[e], value=e) for e in DOCUMENT_EFFECTS]
110
+ selected = questionary.checkbox(
111
+ "Pick degradations (space to toggle, enter to confirm)", choices=choices, style=QSTYLE,
112
+ ).ask()
113
+ if selected is None:
114
+ raise KeyboardInterrupt
115
+ if not selected:
116
+ console.print(f"[{ORANGE}]Pick at least one effect.[/{ORANGE}]")
117
+ return ask_effects()
118
+ return selected
119
+
120
+
121
+ def ask_strengths(effects: list[str]) -> dict[str, float]:
122
+ global_raw = questionary.text(
123
+ "Strength for all selected effects (0.0-1.0)", default="0.75",
124
+ validate=valid_float01, style=QSTYLE,
125
+ ).ask()
126
+ global_strength = float(global_raw)
127
+ strengths = {e: global_strength for e in effects}
128
+ if questionary.confirm("Customize strength per effect?", default=False, style=QSTYLE).ask():
129
+ for e in effects:
130
+ raw = questionary.text(
131
+ f" {DISPLAY_NAME[e]} strength", default=str(global_strength),
132
+ validate=valid_float01, style=QSTYLE,
133
+ ).ask()
134
+ strengths[e] = float(raw)
135
+ return strengths
136
+
137
+
138
+ def ask_count() -> int:
139
+ raw = questionary.text("How many samples?", default="3", validate=valid_posint, style=QSTYLE).ask()
140
+ return int(raw)
141
+
142
+
143
+ def ask_save_viz() -> bool:
144
+ return questionary.confirm(
145
+ "Also save the debug-panel visualization (crop / mask / metrics / difficulty)?",
146
+ default=True, style=QSTYLE,
147
+ ).ask()
148
+
149
+
150
+ def ask_output_dir() -> Path:
151
+ default = f"output/interactive_{datetime.now():%Y%m%d_%H%M%S}"
152
+ raw = questionary.text("Output folder", default=default, style=QSTYLE).ask()
153
+ return ROOT / raw
154
+
155
+
156
+ # ------------------------------------------------------------ overrides ----
157
+ def build_pick_overrides(mode: str, template: "str | None", effects: list[str], strengths: dict[str, float]) -> list[str]:
158
+ overrides = ["generation.run_validation=false"]
159
+ if template:
160
+ overrides.append(f"document.template={template}")
161
+
162
+ if mode == "isolate":
163
+ for name in ALL_EFFECTS:
164
+ on = name in effects
165
+ overrides.append(f"augmentation.{name}.enabled={'true' if on else 'false'}")
166
+ overrides.append(f"augmentation.{name}.probability={'1.0' if on else '0.0'}")
167
+ for name in effects:
168
+ overrides.append(f"augmentation.{name}.strength={strengths[name]}")
169
+ else: # mixed
170
+ for name in effects:
171
+ overrides.append(f"augmentation.{name}.enabled=true")
172
+ overrides.append(f"augmentation.{name}.probability=1.0")
173
+ overrides.append(f"augmentation.{name}.strength={strengths[name]}")
174
+ return overrides
175
+
176
+
177
+ # ---------------------------------------------------------------- report ---
178
+ def show_plan(mode: str, template, effects, strengths, count: int, out_dir: Path) -> None:
179
+ table = Table(title="Plan", title_style=f"bold {NAVY}", show_header=False, border_style=GRAY)
180
+ table.add_row("Mode", mode)
181
+ table.add_row("Template", template or "random per sample")
182
+ if effects:
183
+ table.add_row("Effects", ", ".join(f"{DISPLAY_NAME[e]} ({strengths[e]:.2f})" for e in effects))
184
+ table.add_row("Samples", str(count))
185
+ table.add_row("Output", str(out_dir))
186
+ console.print(table)
187
+
188
+
189
+ def show_results(results: list[tuple[str, float, str, list[str]]], out_dir: Path) -> None:
190
+ table = Table(title="Results", border_style=GRAY, header_style=f"bold white on {NAVY}")
191
+ table.add_column("Sample")
192
+ table.add_column("Difficulty")
193
+ table.add_column("Tier")
194
+ table.add_column("Effects applied")
195
+ tier_color = {"easy": "green", "medium": ORANGE, "hard": "red"}
196
+ for sid, score, tier, applied in results:
197
+ table.add_row(
198
+ sid, f"{score:.3f}", f"[{tier_color.get(tier, 'white')}]{tier}[/]",
199
+ ", ".join(applied) if applied else "[dim]none[/dim]",
200
+ )
201
+ console.print(table)
202
+ console.print(f"\n[{TEAL}]Wrote {len(results)} sample(s) -> {out_dir}[/{TEAL}]")
203
+
204
+
205
+ # ------------------------------------------------------------------ main ---
206
+ def main() -> int:
207
+ banner()
208
+ try:
209
+ mode = ask_mode()
210
+ if mode is None:
211
+ raise KeyboardInterrupt
212
+
213
+ template, effects, strengths = None, [], {}
214
+ if mode in ("isolate", "mixed"):
215
+ template = ask_template()
216
+ effects = ask_effects()
217
+ strengths = ask_strengths(effects)
218
+
219
+ count = ask_count()
220
+ save_viz = ask_save_viz()
221
+ out_dir = ask_output_dir()
222
+
223
+ show_plan(mode, template, effects, strengths, count, out_dir)
224
+ if not questionary.confirm("Generate now?", default=True, style=QSTYLE).ask():
225
+ console.print("Cancelled.")
226
+ return 0
227
+
228
+ if mode == "normal":
229
+ config = load_config(
230
+ config_path=ROOT / "configs" / "generate.yaml",
231
+ overrides=["document.template=bank_doc_05", "generation.run_validation=false"],
232
+ )
233
+ elif mode == "extreme":
234
+ config = load_config(overrides=extreme_overrides())
235
+ else:
236
+ config = load_config(overrides=build_pick_overrides(mode, template, effects, strengths))
237
+
238
+ out_dir.mkdir(parents=True, exist_ok=True)
239
+ ctx = GeneratorContext(config)
240
+ prefix, digits = config.generation.filename_prefix, config.generation.filename_digits
241
+
242
+ results = []
243
+ with Progress(
244
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
245
+ BarColumn(complete_style=TEAL), TextColumn("{task.completed}/{task.total}"),
246
+ TimeElapsedColumn(), console=console,
247
+ ) as progress:
248
+ task = progress.add_task("Generating", total=count)
249
+ for i in range(1, count + 1):
250
+ metadata, final_image, _ = ctx.run_sample(i)
251
+ sample_id = f"{prefix}_{i:0{digits}d}"
252
+
253
+ save_image_rgb(out_dir / f"{sample_id}.png", final_image)
254
+ ctx.metadata_exporter.export(metadata, out_dir / f"{sample_id}.json")
255
+ if save_viz:
256
+ save_visualization(out_dir / f"{sample_id}_viz.png", final_image, metadata)
257
+
258
+ applied = [n for n, r in metadata["augmentation"]["effects"].items() if r["applied"]]
259
+ results.append((sample_id, metadata["difficulty"]["score"], metadata["difficulty"]["tier"], applied))
260
+ progress.advance(task)
261
+
262
+ console.print()
263
+ show_results(results, out_dir)
264
+
265
+ if questionary.confirm("Open the output folder now?", default=True, style=QSTYLE).ask():
266
+ if os.name == "nt":
267
+ os.startfile(out_dir) # noqa: S606
268
+ else:
269
+ console.print(str(out_dir))
270
+
271
+ return 0
272
+
273
+ except KeyboardInterrupt:
274
+ console.print("\n[dim]Cancelled.[/dim]")
275
+ return 1
276
+
277
+
278
+ if __name__ == "__main__":
279
+ sys.exit(main())
scripts/generate_before_after_assets.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Generate paired before/after images for every augmentation effect, for slides.
3
+
4
+ For each of the 25 configurable effects, renders two samples that are
5
+ identical in every respect (same template, signature, placement, box) except
6
+ that one has every augmentation disabled ("before") and the other has just
7
+ that one effect forced on at a fixed strength ("after"). Because placement is
8
+ frozen and both runs share the same (seed, sample_index), the underlying
9
+ document/signature/box are pixel-identical up to the effect itself.
10
+
11
+ Signature-level effects are saved as a tight crop around the signature box
12
+ (where the effect is visible at slide scale); document-level effects are
13
+ saved as the full page. Also writes a JSON manifest with the resolved
14
+ strength/params/formula description used per effect, for slide captions.
15
+
16
+ Usage:
17
+ python scripts/generate_before_after_assets.py
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
27
+
28
+ from PIL import Image
29
+
30
+ from src.config.loader import load_config
31
+ from src.generator import GeneratorContext
32
+ from src.visualization.visualizer import _crop_region
33
+
34
+ SIGNATURE_EFFECTS = [
35
+ "rotation", "stroke_dilation",
36
+ "ink_fading", "ink_bleed", "motion_blur", "gaussian_blur",
37
+ ]
38
+ DOCUMENT_EFFECTS = [
39
+ "perspective", "contrast", "gamma", "coffee_stain", "stain",
40
+ "shadow", "crumple", "discoloration", "page_tilt", "dust", "scanner_streaks",
41
+ "scanner_noise", "salt_pepper", "jpeg_compression",
42
+ ]
43
+ ALL_EFFECTS = SIGNATURE_EFFECTS + DOCUMENT_EFFECTS
44
+
45
+ OUT_ROOT = Path(__file__).resolve().parents[1] / "degradation"
46
+ TEMPLATE_ID = "bank_doc_05"
47
+ FORCED_STRENGTH = 1.0 # max strength: matches the "range at strength=1.0" figures already on the formula slides
48
+ SAMPLE_INDEX = 2 # picked for a bolder/higher-ink-fill signature than index 1, more visible at slide scale
49
+ CROP_PAD = 45
50
+ CROP_UPSCALE = 2
51
+
52
+ # Purely tonal/textural document effects are nearly invisible at full-page slide
53
+ # scale, so also cut a fixed, upscaled body-text zoom for these (kept identical
54
+ # between before/after so the comparison is apples-to-apples). Structural effects
55
+ # (crumple, page_tilt, perspective, shadow, scanner_streaks) already read clearly
56
+ # on the full page and don't need one.
57
+ ZOOM_EFFECTS = {
58
+ "contrast", "gamma", "coffee_stain", "stain",
59
+ "discoloration", "dust", "scanner_noise", "salt_pepper", "jpeg_compression",
60
+ }
61
+ ZOOM_BOX = (40, 110, 460, 340) # (x1, y1, x2, y2) in full-page pixel space
62
+
63
+ DISPLAY_NAME = {
64
+ "rotation": "Rotation jitter",
65
+ "stroke_dilation": "Stroke dilation",
66
+ "ink_fading": "Ink fading",
67
+ "ink_bleed": "Ink bleed",
68
+ "motion_blur": "Motion blur",
69
+ "gaussian_blur": "Gaussian blur",
70
+ "perspective": "Perspective warp",
71
+ "contrast": "Contrast shift",
72
+ "gamma": "Gamma shift",
73
+ "coffee_stain": "Coffee stain",
74
+ "stain": "Water / paper stain",
75
+ "shadow": "Shadow",
76
+ "crumple": "Crumple / wrinkle",
77
+ "discoloration": "Discoloration",
78
+ "page_tilt": "Page tilt",
79
+ "dust": "Dust specks",
80
+ "scanner_streaks": "Scanner streaks",
81
+ "scanner_noise": "Scanner noise",
82
+ "salt_pepper": "Salt & pepper noise",
83
+ "jpeg_compression": "JPEG compression",
84
+ }
85
+
86
+
87
+ def base_overrides() -> list[str]:
88
+ return [
89
+ f"document.template={TEMPLATE_ID}",
90
+ "placement.rotation_deg.min=0", "placement.rotation_deg.max=0",
91
+ "placement.scale_factor.min=0.85", "placement.scale_factor.max=0.85",
92
+ "placement.x_offset=0", "placement.y_offset=0",
93
+ "placement.misplacement_probability=0.0",
94
+ "generation.run_validation=false",
95
+ ]
96
+
97
+
98
+ def all_disabled_overrides() -> list[str]:
99
+ overrides = base_overrides()
100
+ for name in ALL_EFFECTS:
101
+ overrides.append(f"augmentation.{name}.enabled=false")
102
+ overrides.append(f"augmentation.{name}.probability=0.0")
103
+ return overrides
104
+
105
+
106
+ def effect_overrides(target_effect: str) -> list[str]:
107
+ overrides = base_overrides()
108
+ for name in ALL_EFFECTS:
109
+ is_target = name == target_effect
110
+ overrides.append(f"augmentation.{name}.enabled={'true' if is_target else 'false'}")
111
+ overrides.append(f"augmentation.{name}.probability={'1.0' if is_target else '0.0'}")
112
+ overrides.append(f"augmentation.{target_effect}.strength={FORCED_STRENGTH}")
113
+ return overrides
114
+
115
+
116
+ def main() -> None:
117
+ OUT_ROOT.mkdir(parents=True, exist_ok=True)
118
+ manifest: dict[str, dict] = {}
119
+
120
+ baseline_config = load_config(overrides=all_disabled_overrides())
121
+ baseline_ctx = GeneratorContext(baseline_config)
122
+ baseline_meta, baseline_img, _ = baseline_ctx.run_sample(SAMPLE_INDEX)
123
+ box = baseline_meta["geometry"]["target_box"]
124
+
125
+ for effect in ALL_EFFECTS:
126
+ out_dir = OUT_ROOT / effect
127
+ out_dir.mkdir(parents=True, exist_ok=True)
128
+
129
+ config = load_config(overrides=effect_overrides(effect))
130
+ ctx = GeneratorContext(config)
131
+ meta, after_img, _ = ctx.run_sample(SAMPLE_INDEX)
132
+
133
+ before_img, after_img_final = baseline_img, after_img
134
+ if effect in SIGNATURE_EFFECTS:
135
+ before_out = _crop_region(before_img, box, pad=CROP_PAD)
136
+ after_out = _crop_region(after_img_final, box, pad=CROP_PAD)
137
+ before_pil = Image.fromarray(before_out)
138
+ after_pil = Image.fromarray(after_out)
139
+ before_pil = before_pil.resize(
140
+ (before_pil.width * CROP_UPSCALE, before_pil.height * CROP_UPSCALE), Image.LANCZOS,
141
+ )
142
+ after_pil = after_pil.resize(
143
+ (after_pil.width * CROP_UPSCALE, after_pil.height * CROP_UPSCALE), Image.LANCZOS,
144
+ )
145
+ else:
146
+ before_pil, after_pil = Image.fromarray(before_img), Image.fromarray(after_img_final)
147
+
148
+ before_pil.save(out_dir / "before.png")
149
+ after_pil.save(out_dir / "after.png")
150
+
151
+ if effect in ZOOM_EFFECTS:
152
+ x1, y1, x2, y2 = ZOOM_BOX
153
+ zb = Image.fromarray(before_img[y1:y2, x1:x2])
154
+ za = Image.fromarray(after_img_final[y1:y2, x1:x2])
155
+ zb = zb.resize((zb.width * CROP_UPSCALE, zb.height * CROP_UPSCALE), Image.LANCZOS)
156
+ za = za.resize((za.width * CROP_UPSCALE, za.height * CROP_UPSCALE), Image.LANCZOS)
157
+ zb.save(out_dir / "before_zoom.png")
158
+ za.save(out_dir / "after_zoom.png")
159
+
160
+ rec = meta["augmentation"]["effects"].get(effect, {})
161
+ manifest[effect] = {
162
+ "display_name": DISPLAY_NAME[effect],
163
+ "category": "signature" if effect in SIGNATURE_EFFECTS else "document",
164
+ "strength": rec.get("strength"),
165
+ "params": rec.get("params"),
166
+ "difficulty_score": meta["difficulty"]["score"],
167
+ "difficulty_tier": meta["difficulty"]["tier"],
168
+ }
169
+ print(f"[{effect}] before/after -> {out_dir} (strength={rec.get('strength')}, params={rec.get('params')})")
170
+
171
+ manifest_path = OUT_ROOT / "before_after_manifest.json"
172
+ manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
173
+ print(f"\nWrote manifest -> {manifest_path}")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
scripts/generate_case_assets.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Generate plain full-page images (no dashboard overlay) for normal vs. extreme
3
+ multi-degradation cases, for slides.
4
+
5
+ Reuses the same two configurations as scripts/generate_multiple_degradation_gallery.py
6
+ ("normal": configs/generate.yaml's realistic per-effect probabilities, unmodified)
7
+ and scripts/generate_multiple_degradation_extreme_gallery.py ("extreme": every
8
+ effect pushed to high probability/strength plus worst-case placement/rendering),
9
+ but writes the bare rendered page instead of the multi-panel debug figure, since
10
+ slides just need the document image plus a short caption.
11
+
12
+ Usage:
13
+ python scripts/generate_case_assets.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
23
+
24
+ from PIL import Image
25
+
26
+ from src.config.loader import load_config
27
+ from src.generator import GeneratorContext
28
+ from scripts.generate_multiple_degradation_extreme_gallery import build_overrides as extreme_overrides
29
+
30
+ ROOT = Path(__file__).resolve().parents[1]
31
+ TEMPLATE_ID = "bank_doc_05"
32
+ N_SAMPLES = 2
33
+
34
+
35
+ def _run_case(out_dir: Path, config_path, overrides: list[str]) -> None:
36
+ out_dir.mkdir(parents=True, exist_ok=True)
37
+ config = load_config(config_path=config_path, overrides=overrides)
38
+ ctx = GeneratorContext(config)
39
+
40
+ manifest = {}
41
+ for i in range(1, N_SAMPLES + 1):
42
+ meta, final_image, _ = ctx.run_sample(i)
43
+ Image.fromarray(final_image).save(out_dir / f"case_{i}.png")
44
+
45
+ effects = meta["augmentation"]["effects"]
46
+ applied = [n for n, r in effects.items() if r["applied"]]
47
+ manifest[f"case_{i}"] = {
48
+ "applied_effects": applied,
49
+ "n_applied": len(applied),
50
+ "n_total": len(effects),
51
+ "difficulty_score": meta["difficulty"]["score"],
52
+ "difficulty_tier": meta["difficulty"]["tier"],
53
+ "placement": {
54
+ "rotation_deg": meta["placement"]["rotation_deg"],
55
+ "scale_factor": meta["placement"]["scale_factor"],
56
+ "misplaced": meta["placement"]["misplaced"],
57
+ },
58
+ }
59
+ print(f"[{out_dir.parent.name}/case_{i}] {len(applied)}/{len(effects)} effects, "
60
+ f"difficulty={meta['difficulty']['score']:.3f} ({meta['difficulty']['tier']})")
61
+
62
+ (out_dir / "case_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
63
+
64
+
65
+ def main() -> None:
66
+ # Normal: configs/generate.yaml's realistic "varied batch" probabilities, unmodified.
67
+ _run_case(
68
+ ROOT / "multiple_degradation" / "case_assets",
69
+ ROOT / "configs" / "generate.yaml",
70
+ [f"document.template={TEMPLATE_ID}", "generation.run_validation=false"],
71
+ )
72
+ # Extreme: every effect pushed high, worst-case placement/rendering.
73
+ _run_case(
74
+ ROOT / "multiple_degradation_extreme" / "case_assets",
75
+ None,
76
+ extreme_overrides(),
77
+ )
78
+ print("\nDone.")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
scripts/generate_degradation_gallery.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Generate a labeled sample gallery of every augmentation effect in isolation.
3
+
4
+ For each of the 25 configurable augmentation families, forces that one effect
5
+ on (probability=1.0) and disables every other effect, so the visual result is
6
+ attributable to that effect alone. Placement is frozen (no rotation/scale/
7
+ misplacement jitter, fixed template) so samples across effects are directly
8
+ comparable. Writes 5 sample images per effect to degradation/<effect>/, each
9
+ a multi-panel figure (full page, signature crop, ink mask, coverage metrics,
10
+ difficulty breakdown) via the existing visualizer.
11
+
12
+ Usage:
13
+ python scripts/generate_degradation_gallery.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
22
+
23
+ from src.config.loader import load_config
24
+ from src.generator import GeneratorContext
25
+ from src.visualization.visualizer import save_visualization
26
+
27
+ SIGNATURE_EFFECTS = [
28
+ "rotation", "stroke_dilation",
29
+ "ink_fading", "ink_bleed", "motion_blur", "gaussian_blur",
30
+ ]
31
+ DOCUMENT_EFFECTS = [
32
+ "perspective", "contrast", "gamma", "coffee_stain", "stain",
33
+ "shadow", "crumple", "discoloration", "page_tilt", "dust", "scanner_streaks",
34
+ "scanner_noise", "salt_pepper", "jpeg_compression",
35
+ ]
36
+ ALL_EFFECTS = SIGNATURE_EFFECTS + DOCUMENT_EFFECTS
37
+
38
+ OUT_ROOT = Path(__file__).resolve().parents[1] / "degradation"
39
+ SAMPLES_PER_EFFECT = 5
40
+ TEMPLATE_ID = "bank_doc_05" # decent-sized signature box (255x35px)
41
+ FORCED_STRENGTH = 0.75
42
+
43
+
44
+ def build_overrides(target_effect: str) -> list[str]:
45
+ overrides = [
46
+ f"document.template={TEMPLATE_ID}",
47
+ "placement.rotation_deg.min=0", "placement.rotation_deg.max=0",
48
+ "placement.scale_factor.min=0.85", "placement.scale_factor.max=0.85",
49
+ "placement.x_offset=0", "placement.y_offset=0",
50
+ "placement.misplacement_probability=0.0",
51
+ "generation.run_validation=false",
52
+ ]
53
+ for name in ALL_EFFECTS:
54
+ is_target = name == target_effect
55
+ overrides.append(f"augmentation.{name}.enabled={'true' if is_target else 'false'}")
56
+ overrides.append(f"augmentation.{name}.probability={'1.0' if is_target else '0.0'}")
57
+ overrides.append(f"augmentation.{target_effect}.strength={FORCED_STRENGTH}")
58
+ return overrides
59
+
60
+
61
+ def main() -> None:
62
+ OUT_ROOT.mkdir(parents=True, exist_ok=True)
63
+ for effect in ALL_EFFECTS:
64
+ out_dir = OUT_ROOT / effect
65
+ out_dir.mkdir(parents=True, exist_ok=True)
66
+
67
+ config = load_config(overrides=build_overrides(effect))
68
+ ctx = GeneratorContext(config)
69
+
70
+ for i in range(1, SAMPLES_PER_EFFECT + 1):
71
+ metadata, final_image, _ = ctx.run_sample(i)
72
+ save_visualization(out_dir / f"sample_{i}.png", final_image, metadata)
73
+
74
+ print(f"[{effect}] wrote {SAMPLES_PER_EFFECT} samples -> {out_dir}")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()