#!/usr/bin/env python """Append a before/after slide per augmentation effect, plus normal- and extreme-case multi-degradation slides, to Metrics_Thresholds_Formulas.pptx. Reuses the assets written by generate_before_after_assets.py and generate_case_assets.py (run those first). New slides replicate the deck's existing visual language exactly (navy header bar, orange accent line, teal section label, Calibri/Consolas body text, bottom-right page number) so they read as part of the same deck rather than a bolt-on. The new slides are inserted as a new section 8 ("Degradation gallery") and section 9 ("Multi-degradation cases"), placed right before the closing "Key takeaways" slide, and the Agenda slide (slide 2) is extended with the two new entries. A one-time backup of the original file is kept alongside it. Usage: python scripts/build_degradation_slides.py """ from __future__ import annotations import copy import json import shutil import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_SHAPE from pptx.enum.text import MSO_ANCHOR, PP_ALIGN from pptx.util import Emu, Pt from PIL import Image from scripts.generate_before_after_assets import ( ALL_EFFECTS, DISPLAY_NAME, ZOOM_EFFECTS, ) from scripts.generate_multiple_degradation_extreme_gallery import _fmt_effect ROOT = Path(__file__).resolve().parents[1] PPTX_PATH = ROOT / "Metrics_Thresholds_Formulas.pptx" BACKUP_PATH = ROOT / "Metrics_Thresholds_Formulas.backup.pptx" DEG_ROOT = ROOT / "degradation" # ---------------------------------------------------------------- palette -- NAVY, TEAL, ORANGE = "0F2A43", "1B7A7A", "E07A2C" LIGHT_TEAL, GRAY, LIGHT_BG, TEXT_DARK, WHITE = "9BC5C5", "6B767C", "F4F7F8", "222A30", "FFFFFF" def rgb(hexstr: str) -> RGBColor: return RGBColor.from_string(hexstr) # ------------------------------------------------------------- geometry --- SLIDE_W, SLIDE_H = Emu(12191695), Emu(6858000) HEADER_H, ACCENT_H = Emu(1051560), Emu(38100) TITLE_X = Emu(502920) CONTENT_X, CONTENT_W = Emu(640080), Emu(10881360) FOOTER_X, FOOTER_Y = Emu(11475720), Emu(6510528) FOOTER_W, FOOTER_H = Emu(640080), Emu(274320) LEFT_X, RIGHT_X = Emu(640080), Emu(6263640) COL_W = Emu(5257800) LABEL_Y, LABEL_H = Emu(1554480), Emu(365760) IMG_Y = Emu(LABEL_Y + LABEL_H + Emu(91440)) CAPTION_Y = Emu(1150000) CAPTION_H = Emu(320040) # ------------------------------------------------------ formula captions -- FORMULAS = { "rotation": "extra_deg = U(-1,1) × strength × 8.0", "stroke_dilation": "ksize = round(1 + strength×5) [odd]", "ink_fading": "mult = clip(1 − strength×(1−field)×0.9, .05, 1)", "ink_bleed": "ksize = round(1+strength×4); halo floor = .35+.35×strength", "motion_blur": "length = round(3 + strength×16)", "gaussian_blur": "sigma = 0.2 + strength×2.2", "perspective": "scale = 0.01 + strength×0.09", "contrast": "contrast_limit = ±(strength×0.45)", "gamma": "delta = round(strength×45); gamma_limit = (100−delta, 100+delta), lo≥40", "coffee_stain": "n_blobs = 1+randint(0,1+round(strength×3)); opacity = .10+.30×strength", "stain": "radius = (.06+.16×strength)×min(w,h); opacity scales with strength", "shadow": "darkness = 0.15+0.55×strength; band/radius/fold-width scale with strength", "crumple": "amplitude = strength×min(w,h)×0.015", "discoloration": "tint strength scales with strength per tone", "page_tilt": "angle = U(-1,1) × (1.5 + strength×6.5)", "dust": "n_specks = (h×w)×0.00003×(1+strength×6)", "scanner_streaks": "n_streaks = 1+int(strength×6)", "scanner_noise": "GaussNoise std = 0.01 + strength×0.12", "salt_pepper": "amount = 0.002 + strength×0.09", "jpeg_compression": "quality = randint(min_quality, max_quality) — NOT driven by strength", } def short_list(names: list[str], limit: int = 8) -> str: if len(names) <= limit: return ", ".join(names) if names else "none" return ", ".join(names[:limit]) + f", +{len(names) - limit} more" # ----------------------------------------------------------- primitives --- def add_rect(slide, x, y, w, h, color: str): shp = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) shp.fill.solid() shp.fill.fore_color.rgb = rgb(color) shp.line.fill.background() return shp def add_text( slide, x, y, w, h, text, size, color, *, bold=False, font="Calibri", align=PP_ALIGN.LEFT, anchor=None, word_wrap=True, ): tb = slide.shapes.add_textbox(x, y, w, h) tf = tb.text_frame tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0 tf.word_wrap = word_wrap if anchor is not None: tf.vertical_anchor = anchor lines = text.split("\n") for i, line in enumerate(lines): p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() p.alignment = align run = p.add_run() run.text = line run.font.size = Pt(size) run.font.bold = bold run.font.name = font run.font.color.rgb = rgb(color) return tb def add_bg(slide): add_rect(slide, Emu(0), Emu(0), SLIDE_W, SLIDE_H, WHITE) def add_header(slide, section_label: str, title: str, page_num: int): add_rect(slide, Emu(0), Emu(0), SLIDE_W, HEADER_H, NAVY) add_rect(slide, Emu(0), HEADER_H, SLIDE_W, ACCENT_H, ORANGE) add_text(slide, TITLE_X, Emu(128016), Emu(9144000), Emu(274320), section_label, 12, LIGHT_TEAL, bold=True) add_text(slide, TITLE_X, Emu(384048), Emu(10515600), Emu(594360), title, 28, WHITE, bold=True) add_text( slide, FOOTER_X, FOOTER_Y, FOOTER_W, FOOTER_H, str(page_num), 11, GRAY, align=PP_ALIGN.RIGHT, ) def fit_picture(slide, img_path: Path, box_x, box_y, box_w, box_h): iw, ih = Image.open(img_path).size scale = min(box_w / iw, box_h / ih) disp_w, disp_h = int(iw * scale), int(ih * scale) left = int(box_x + (box_w - disp_w) / 2) top = int(box_y + (box_h - disp_h) / 2) slide.shapes.add_picture(str(img_path), left, top, width=disp_w, height=disp_h) def add_panel_label(slide, x, color, text): add_rect(slide, x, LABEL_Y, COL_W, LABEL_H, color) add_text( slide, x, LABEL_Y, COL_W, LABEL_H, text, 14, WHITE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, word_wrap=False, ) # -------------------------------------------------------------- slides ---- def build_before_after_slide(prs, layout, effect: str, page_num: int, manifest: dict): slide = prs.slides.add_slide(layout) add_bg(slide) add_header(slide, "8. DEGRADATION GALLERY", DISPLAY_NAME[effect], page_num) rec = manifest[effect] resolved = _fmt_effect(effect, {"strength": rec["strength"], "params": rec["params"] or {}}) caption = ( f"{FORMULAS[effect]} | resolved: {resolved} | " f"difficulty after = {rec['difficulty_score']:.3f} ({rec['difficulty_tier']})" ) add_text(slide, CONTENT_X, CAPTION_Y, CONTENT_W, CAPTION_H, caption, 12, GRAY, font="Consolas") add_panel_label(slide, LEFT_X, TEAL, "BEFORE - clean render") add_panel_label(slide, RIGHT_X, ORANGE, f"AFTER - {DISPLAY_NAME[effect]} (strength={rec['strength']:.2f})") img_bottom = Emu(6100000) img_h = Emu(img_bottom - IMG_Y) before_name = "before_zoom.png" if effect in ZOOM_EFFECTS else "before.png" after_name = "after_zoom.png" if effect in ZOOM_EFFECTS else "after.png" fit_picture(slide, DEG_ROOT / effect / before_name, LEFT_X, IMG_Y, COL_W, img_h) fit_picture(slide, DEG_ROOT / effect / after_name, RIGHT_X, IMG_Y, COL_W, img_h) return slide def build_case_slide(prs, layout, kind: str, page_num: int): slide = prs.slides.add_slide(layout) add_bg(slide) title = "Normal-severity combinations" if kind == "normal" else "Extreme-severity combinations" add_header(slide, "9. MULTI-DEGRADATION CASES", title, page_num) if kind == "normal": folder = ROOT / "multiple_degradation" / "case_assets" subtitle = ( "configs/generate.yaml -- every effect independently rolls its own realistic " "probability per sample (mixed, moderate combinations)" ) color = TEAL else: folder = ROOT / "multiple_degradation_extreme" / "case_assets" subtitle = ( "worst case -- every effect probability=0.85, strength 0.7-1.0, plus heavy " "misplacement / faint+blurred ink (see scripts/generate_multiple_degradation_extreme_gallery.py)" ) color = ORANGE manifest = json.loads((folder / "case_manifest.json").read_text()) add_text(slide, CONTENT_X, CAPTION_Y, CONTENT_W, CAPTION_H, subtitle, 12, GRAY, font="Consolas") img_bottom = Emu(5850000) img_h = Emu(img_bottom - IMG_Y) cap_y = Emu(img_bottom + Emu(45720)) cap_h = Emu(600000) for i, x in enumerate((LEFT_X, RIGHT_X)): key = f"case_{i + 1}" rec = manifest[key] add_panel_label(slide, x, color, f"Sample {i + 1} - {rec['n_applied']}/{rec['n_total']} effects applied") fit_picture(slide, folder / f"{key}.png", x, IMG_Y, COL_W, img_h) cap = ( f"difficulty = {rec['difficulty_score']:.3f} ({rec['difficulty_tier']}) | " f"placement: rot={rec['placement']['rotation_deg']:.1f} deg " f"scale={rec['placement']['scale_factor']:.2f} misplaced={rec['placement']['misplaced']}\n" f"applied: {short_list(rec['applied_effects'])}" ) add_text(slide, x, cap_y, COL_W, cap_h, cap, 10, TEXT_DARK, font="Consolas") return slide # ---------------------------------------------------------- agenda edit --- def extend_agenda(prs): slide = prs.slides[1] target = None for shape in slide.shapes: if shape.name == "TextBox 7": target = shape break if target is None: print("WARNING: could not find agenda TextBox 7 -- skipping agenda update") return tf = target.text_frame n_original = len(tf.paragraphs) last_p = tf.paragraphs[-1]._p new_texts = ( "8. Degradation gallery — before & after, all 20 effects", "9. Multi-degradation cases — normal vs. extreme severity", ) for text in new_texts: new_p = copy.deepcopy(last_p) run_elm = new_p.findall(".//{http://schemas.openxmlformats.org/drawingml/2006/main}t") run_elm[0].text = text last_p.addnext(new_p) last_p = new_p # Shape height doesn't clip a top-anchored textbox -- text simply overflows past # it -- so what actually matters is total rendered text height staying inside the # slide. 9 lines at the original 22pt ran ~24pt past the bottom edge, so shrink # every run down to 20pt (measured to comfortably clear the slide bottom). for para in tf.paragraphs: for run in para.runs: run.font.size = Pt(20) # ---------------------------------------------------------------- main ---- def main(): if not BACKUP_PATH.exists(): shutil.copy2(PPTX_PATH, BACKUP_PATH) print(f"Backed up original deck -> {BACKUP_PATH}") manifest = json.loads((DEG_ROOT / "before_after_manifest.json").read_text()) prs = Presentation(str(PPTX_PATH)) layout = prs.slides[2].slide_layout # "Blank" layout used by all content slides sld_id_lst = prs.slides._sldIdLst closing_sld_id = sld_id_lst[-1] # "Key takeaways" slide, must stay last extend_agenda(prs) page_num = 19 for effect in ALL_EFFECTS: build_before_after_slide(prs, layout, effect, page_num, manifest) page_num += 1 build_case_slide(prs, layout, "normal", page_num) page_num += 1 build_case_slide(prs, layout, "extreme", page_num) page_num += 1 sld_id_lst.remove(closing_sld_id) sld_id_lst.append(closing_sld_id) prs.save(str(PPTX_PATH)) print(f"Saved {PPTX_PATH} -- {len(prs.slides.__iter__.__self__._sldIdLst)} slides total") if __name__ == "__main__": main()