diff --git a/README.md b/README.md index 0fb7052..06e574a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # JEKA AOPS +

+ JEKA AOPS — animirani prikaz audio-optičkog sustava prometne sigurnosti +

+ **JEKA AOPS — Audio-optički sustav prometne sigurnosti** Mobilni istraživačko-razvojni projekt za snimanje i kasniju analizu prometnih scena pomoću kamere, mikrofona i umjetne inteligencije na pametnom telefonu. diff --git a/docs/assets/jeka-aops-readme-base.png b/docs/assets/jeka-aops-readme-base.png new file mode 100644 index 0000000..fda7bf8 Binary files /dev/null and b/docs/assets/jeka-aops-readme-base.png differ diff --git a/docs/assets/jeka-aops-readme.gif b/docs/assets/jeka-aops-readme.gif new file mode 100644 index 0000000..f72582b Binary files /dev/null and b/docs/assets/jeka-aops-readme.gif differ diff --git a/tools/create_readme_animation.py b/tools/create_readme_animation.py new file mode 100644 index 0000000..02a4bd2 --- /dev/null +++ b/tools/create_readme_animation.py @@ -0,0 +1,153 @@ +"""Build the lightweight animated JEKA AOPS banner used by the README.""" + +from __future__ import annotations + +import math +from pathlib import Path + +from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "docs" / "assets" / "jeka-aops-readme-base.png" +OUTPUT = ROOT / "docs" / "assets" / "jeka-aops-readme.gif" +SIZE = (1000, 500) +FRAMES = 24 + + +def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: + windows_fonts = Path("C:/Windows/Fonts") + candidates = ( + [windows_fonts / "segoeuib.ttf", windows_fonts / "arialbd.ttf"] + if bold + else [windows_fonts / "segoeui.ttf", windows_fonts / "arial.ttf"] + ) + for candidate in candidates: + if candidate.exists(): + return ImageFont.truetype(str(candidate), size) + return ImageFont.load_default() + + +def glow_line(layer: Image.Image, points, color, width: int = 2) -> None: + glow = Image.new("RGBA", SIZE, (0, 0, 0, 0)) + draw = ImageDraw.Draw(glow) + draw.line(points, fill=(*color, 90), width=width * 5, joint="curve") + glow = glow.filter(ImageFilter.GaussianBlur(width * 2)) + layer.alpha_composite(glow) + ImageDraw.Draw(layer).line(points, fill=(*color, 235), width=width, joint="curve") + + +def build_frame(base: Image.Image, index: int) -> Image.Image: + phase = index / FRAMES + pulse = (math.sin(phase * math.tau) + 1) / 2 + frame = ImageEnhance.Brightness(base).enhance(0.97 + pulse * 0.05).convert("RGBA") + + # Preserve readable negative space for deterministic typography. + shade = Image.new("RGBA", SIZE, (0, 0, 0, 0)) + shade_draw = ImageDraw.Draw(shade) + for x in range(430, SIZE[0]): + alpha = int(25 + 115 * ((x - 430) / (SIZE[0] - 430))) + shade_draw.line((x, 0, x, SIZE[1]), fill=(1, 7, 20, alpha)) + frame.alpha_composite(shade) + + effects = Image.new("RGBA", SIZE, (0, 0, 0, 0)) + draw = ImageDraw.Draw(effects) + + # Sensor pulse around the emblem. + center = (267, 219) + for ring in range(3): + progress = (phase + ring / 3) % 1 + radius = 92 + progress * 155 + alpha = int(150 * (1 - progress)) + bbox = ( + center[0] - radius, + center[1] - radius, + center[0] + radius, + center[1] + radius, + ) + draw.arc(bbox, 198, 342, fill=(0, 190, 255, alpha), width=3) + draw.arc(bbox, 18, 162, fill=(0, 190, 255, alpha // 2), width=2) + + # Scanning line sweeps across the road and sensor field. + scan_x = int(85 + ((phase * 1.25) % 1) * 395) + scan_glow = Image.new("RGBA", SIZE, (0, 0, 0, 0)) + scan_draw = ImageDraw.Draw(scan_glow) + scan_draw.line((scan_x, 70, scan_x, 432), fill=(0, 210, 255, 105), width=15) + scan_glow = scan_glow.filter(ImageFilter.GaussianBlur(9)) + effects.alpha_composite(scan_glow) + draw = ImageDraw.Draw(effects) + draw.line((scan_x, 82, scan_x, 420), fill=(97, 230, 255, 185), width=2) + + # Animated audio/data waveform on the right. + wave_points = [] + for x in range(560, 952, 5): + envelope = math.sin((x - 560) / 392 * math.pi) ** 0.7 + y = 337 + math.sin((x / 52) + phase * math.tau * 1.4) * (12 + 24 * pulse) * envelope + wave_points.append((x, int(y))) + glow_line(effects, wave_points, (0, 191, 255), 2) + + # Small live indicator and tracking brackets. + status_alpha = int(125 + pulse * 130) + draw.ellipse((909, 92, 919, 102), fill=(255, 170, 22, status_alpha)) + bx, by, bw, bh = 798, 205, 62, 45 + corner = 13 + for points in ( + [(bx, by + corner), (bx, by), (bx + corner, by)], + [(bx + bw - corner, by), (bx + bw, by), (bx + bw, by + corner)], + [(bx, by + bh - corner), (bx, by + bh), (bx + corner, by + bh)], + [(bx + bw - corner, by + bh), (bx + bw, by + bh), (bx + bw, by + bh - corner)], + ): + draw.line(points, fill=(0, 190, 255, 130 + int(80 * pulse)), width=2) + + frame.alpha_composite(effects) + + # Exact text is rendered locally to avoid generative spelling errors. + text_layer = Image.new("RGBA", SIZE, (0, 0, 0, 0)) + td = ImageDraw.Draw(text_layer) + title_font = font(64, bold=True) + subtitle_font = font(18, bold=True) + detail_font = font(16) + td.text((548, 105), "JEKA", font=title_font, fill=(245, 251, 255, 255), stroke_width=1) + title_width = td.textbbox((0, 0), "JEKA", font=title_font)[2] + td.text((560 + title_width, 105), "AOPS", font=title_font, fill=(0, 190, 255, 255), stroke_width=1) + td.text( + (552, 188), + "AUDIO-OPTIČKI SUSTAV PROMETNE SIGURNOSTI", + font=subtitle_font, + fill=(153, 215, 244, 245), + ) + td.rounded_rectangle((552, 251, 921, 294), radius=10, outline=(0, 176, 235, 145), width=2) + td.text( + (573, 262), + "SNIMI • ANALIZIRAJ • RAZVIJAJ OTVORENO", + font=detail_font, + fill=(229, 243, 252, 245), + ) + frame.alpha_composite(text_layer) + return frame.convert("RGB") + + +def main() -> None: + base = Image.open(SOURCE).convert("RGB").resize(SIZE, Image.Resampling.LANCZOS) + rendered = [build_frame(base, index) for index in range(FRAMES)] + + # One shared palette keeps the GIF compact and prevents color flicker. + palette_source = rendered[0].quantize(colors=112, method=Image.Quantize.MEDIANCUT) + frames = [ + image.quantize(palette=palette_source, dither=Image.Dither.NONE) + for image in rendered + ] + frames[0].save( + OUTPUT, + save_all=True, + append_images=frames[1:], + duration=85, + loop=0, + optimize=True, + disposal=2, + ) + print(f"Saved {OUTPUT} ({OUTPUT.stat().st_size / 1024 / 1024:.2f} MB)") + + +if __name__ == "__main__": + main()