Venket / scripts /build_degradation_slides.py
venki101's picture
Add files using upload-large-folder tool
1a12965 verified
Raw History Blame Contribute Delete
12.2 kB
#!/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()