Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- dataset/genuine/original_42_6.png +3 -0
- dataset/genuine/original_42_7.png +3 -0
- dataset/genuine/original_42_8.png +3 -0
- dataset/genuine/original_42_9.png +3 -0
- dataset/genuine/original_43_1.png +3 -0
- dataset/genuine/original_43_10.png +3 -0
- dataset/genuine/original_43_11.png +3 -0
- dataset/genuine/original_43_12.png +3 -0
- dataset/genuine/original_43_13.png +3 -0
- dataset/genuine/original_43_14.png +3 -0
- dataset/genuine/original_43_15.png +3 -0
- dataset/genuine/original_43_16.png +3 -0
- dataset/genuine/original_43_17.png +3 -0
- dataset/genuine/original_43_18.png +3 -0
- dataset/genuine/original_43_19.png +3 -0
- dataset/genuine/original_43_2.png +3 -0
- dataset/genuine/original_43_20.png +3 -0
- dataset/genuine/original_43_21.png +3 -0
- dataset/genuine/original_43_22.png +3 -0
- dataset/genuine/original_43_23.png +3 -0
- dataset/genuine/original_43_24.png +3 -0
- dataset/genuine/original_43_3.png +3 -0
- dataset/genuine/original_43_4.png +3 -0
- dataset/genuine/original_43_5.png +3 -0
- dataset/genuine/original_43_6.png +3 -0
- dataset/genuine/original_43_7.png +3 -0
- dataset/genuine/original_43_8.png +3 -0
- dataset/genuine/original_43_9.png +3 -0
- dataset/genuine/original_44_1.png +3 -0
- dataset/genuine/original_44_10.png +3 -0
- dataset/genuine/original_44_11.png +3 -0
- dataset/genuine/original_44_12.png +3 -0
- dataset/genuine/original_44_13.png +3 -0
- dataset/genuine/original_44_14.png +3 -0
- dataset/genuine/original_44_15.png +3 -0
- dataset/genuine/original_44_16.png +3 -0
- dataset/genuine/original_44_17.png +3 -0
- dataset/genuine/original_44_18.png +3 -0
- dataset/genuine/original_44_19.png +3 -0
- dataset/genuine/original_44_2.png +3 -0
- dataset/genuine/original_44_20.png +3 -0
- dataset/genuine/original_44_21.png +3 -0
- dataset/genuine/original_44_22.png +3 -0
- dataset/genuine/original_44_23.png +3 -0
- dataset/genuine/original_44_24.png +3 -0
- scripts/build_degradation_slides.py +306 -0
- scripts/degrade_cli.py +279 -0
- scripts/generate_before_after_assets.py +177 -0
- scripts/generate_case_assets.py +82 -0
- scripts/generate_degradation_gallery.py +78 -0
dataset/genuine/original_42_6.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_42_7.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_42_8.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_42_9.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_1.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_10.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_11.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_12.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_13.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_14.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_15.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_16.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_17.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_18.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_19.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_2.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_20.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_21.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_22.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_23.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_24.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_3.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_4.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_5.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_6.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_7.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_8.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_43_9.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_1.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_10.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_11.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_12.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_13.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_14.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_15.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_16.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_17.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_18.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_19.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_2.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_20.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_21.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_22.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_23.png
ADDED
|
Git LFS Details
|
dataset/genuine/original_44_24.png
ADDED
|
Git LFS Details
|
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()
|