Compare commits

...

2 commits

Author SHA1 Message Date
bagusindrayana
083cf23dbf fix frame not render correctly in bad apple 2026-03-20 10:54:02 +08:00
bagusindrayana
91f96952e3 bad apple example 2026-03-19 22:53:40 +08:00
4 changed files with 292 additions and 0 deletions

131
convert_bad_apple.py Normal file
View file

@ -0,0 +1,131 @@
import cv2
import numpy as np
import json
import argparse
import sys
import math
def distance(p1, p2):
return math.hypot(p1[0] - p2[0], p1[1] - p2[1])
def sort_points_nearest_neighbor(points):
"""
Mengurutkan titik-titik agar menjadi satu garis yang menyambung (TSP approximation).
Sangat krusial untuk rendering tipe oscilloscope/XY agar garis tidak melompat-lompat berantakan.
"""
if not points:
return []
unvisited = points.copy()
current = unvisited.pop(0)
sorted_points = [current]
while unvisited:
nearest_idx = 0
min_dist = distance(current, unvisited[0])
for i in range(1, len(unvisited)):
dist = distance(current, unvisited[i])
if dist < min_dist:
min_dist = dist
nearest_idx = i
current = unvisited.pop(nearest_idx)
sorted_points.append(current)
return sorted_points
def main():
parser = argparse.ArgumentParser(description="Convert black & white video to XY JSON for Waveform Chart")
parser.add_argument("input", help="Path to input video (e.g. bad_apple.mp4)")
parser.add_argument("output", help="Path to output JSON (e.g. bad_apple_frames.json)")
parser.add_argument("--fps", type=int, default=15, help="Target FPS to extract (lower is easier on browser memory)")
parser.add_argument("--max-points", type=int, default=400, help="Max points per frame (lower = faster render, higher = detailed)")
args = parser.parse_args()
cap = cv2.VideoCapture(args.input)
if not cap.isOpened():
print(f"Error: Could not open video {args.input}")
sys.exit(1)
video_fps = cap.get(cv2.CAP_PROP_FPS)
if video_fps <= 0:
video_fps = 30
frame_skip = max(1, int(video_fps / args.fps))
frames_data = []
frame_count = 0
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"Processing video: {width}x{height} @ {video_fps}fps")
print(f"Targeting: {args.fps}fps, Max {args.max_points} points/frame")
while True:
ret, frame = cap.read()
if not ret:
break
if frame_count % frame_skip == 0:
# 1. Convert to graysale
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 2. Thresholding (Pastikan benar-benar hitam/putih murni)
_, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
# 3. Cari garis tepi (Contours)
# Menggunakan RETR_LIST agar lubang (seperti apel hitam di background putih) ikut terbaca
contours, _ = cv2.findContours(thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
frame_points = []
screen_area = width * height
for contour in contours:
# Abaikan batas kotak frame layar penuh saat warnanya terbalik
if cv2.contourArea(contour) < screen_area * 0.95:
for point in contour:
x, y = point[0]
frame_points.append((x, y))
# 4. Batasi jumlah titik agar web tidak hang (Downsampling)
if len(frame_points) > args.max_points:
step = len(frame_points) / args.max_points
frame_points = [frame_points[int(i * step)] for i in range(args.max_points)]
# 5. Sortir titik-titik (Algoritma Jalur Terpendek)
# supaya "benang" yang digambar di Svelte tidak ruwet/melompat jauh
if len(frame_points) > 0:
frame_points = sort_points_nearest_neighbor(frame_points)
# 6. Normalisasi ke rentang nx, ny (-1.0 sampai 1.0)
normalized_points = []
for (x, y) in frame_points:
nx = (x / width) * 2.0 - 1.0
# Koordinat Canvas Y itu dari Atas(0) ke Bawah(Height).
# Sumbu Math/Grafik Bawah(-1) ke Atas(1). Di versi terbaru chart,
# Y positif ny sudah menuju ke bawah, jadi tidak perlu di-invert lagi.
ny = (y / height) * 2.0 - 1.0
normalized_points.append({"nx": round(nx, 4), "ny": round(ny, 4)})
# Hanya simpan frame jika ada point-nya
frames_data.append(normalized_points)
if len(frames_data) % 100 == 0:
print(f"Processed {len(frames_data)} target frames...")
frame_count += 1
cap.release()
print(f"Saving {len(frames_data)} frames to {args.output}...")
with open(args.output, 'w') as f:
# Tulis JSON serapat mungkin (tanpa indent) agar file lebih kecil
json.dump(frames_data, f, separators=(',', ':'))
print(f"Done! The file size is ready for consumption by WaveformChart.svelte.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import WaveformChart from "$lib/components/WaveformChart.svelte";
let frames: any[] = [];
let currentFrameIndex = 0;
let isPlaying = false;
let isLoading = true;
let interval: ReturnType<typeof setInterval>;
// Props for WaveformChart
let waveformChart: any;
let psychoPoints: { nx: number; ny: number }[] = [];
// Target FPS used during extraction
const TARGET_FPS = 15;
const MS_PER_FRAME = 1000 / TARGET_FPS;
onMount(async () => {
try {
const response = await fetch("/bad_apple_frames_v4.json");
if (response.ok) {
frames = await response.json();
isLoading = false;
// Set initial frame
if (frames.length > 0) {
psychoPoints = frames[0];
}
} else {
console.error("Failed to fetch frames:", response.status);
}
} catch (error) {
console.error("Failed to load Bad Apple frames:", error);
}
});
function togglePlay() {
if (isPlaying) {
isPlaying = false;
if (interval) clearInterval(interval);
} else {
if (frames.length === 0) return;
isPlaying = true;
// Restart if at end
if (currentFrameIndex >= frames.length - 1) {
currentFrameIndex = 0;
}
interval = setInterval(() => {
if (currentFrameIndex < frames.length) {
psychoPoints = frames[currentFrameIndex];
currentFrameIndex++;
} else {
isPlaying = false;
clearInterval(interval);
}
}, MS_PER_FRAME);
}
}
onDestroy(() => {
if (interval) clearInterval(interval);
});
</script>
<svelte:head>
<title>Bad Apple!! - Oscilloscope Mode</title>
</svelte:head>
<div
class="w-full min-h-screen bg-black flex flex-col items-center justify-center p-4 lg:p-8 font-mono relative"
>
<div class="flex flex-col items-center gap-6 w-full">
<div class="w-full max-w-7xl flex flex-col bordered p-1 grow gap-3">
<div
class="relative w-full h-[75vh] border-b-4 border-l-4 bg-black overflow-hidden flex flex-col items-center"
style="border-bottom-color: #fa0; border-left-color: #fa0;"
>
<!-- Top Left -->
<div
class="absolute right-2 lg:right-0 lg:top-6 left-4 lg:left-24 pointer-events-none z-5 max-w-100 flex flex-col justify-center items-end lg:items-start gap-1"
>
<div
class="rounded-sm bordered text-3xl bg-black/60 shadow-lg h-10 text-center flex justify-center items-center px-1"
>
<div class="font-bold md:text-3xl uppercase ews-title">
PSYCHOGRAPHIC DISPLAY
</div>
</div>
<div
class="font-bold mt-1 tracking-widest text-sm md:text-xl ews-title"
>
Phase 4 Link
</div>
</div>
<!-- Top Right -->
<div
class="absolute bottom-16 lg:bottom-auto top-auto lg:top-18 md:top-4 right-2 lg:right-16 pointer-events-none flex flex-col items-end z-5"
>
<div
class="rounded-lg p-1 inline-block bg-black/60 shadow-lg"
>
<div class="bordered px-3 py-1 flex flex-col text-3xl">
<div
class="font-bold text-[10px] md:text-sm tracking-widest leading-none mb-1 ews-title"
>
STATION:
</div>
<div
class="font-bold text-2xl md:text-4xl tracking-widest leading-none text-right ews-title"
>
BAD APPLE
</div>
</div>
</div>
</div>
<WaveformChart
bind:this={waveformChart}
waveformData={[]}
timeWindowMs={20000}
selectedTimezone={0}
isDemoPsychoMode={true}
{psychoPoints}
backgroundColor="#000000"
gridColor="#33cc55"
axesColor="#fa0"
waveformColor="#ff9900"
psychoWaveformColor="#ff9900"
emptyTextColor="#fa0"
/>
</div>
</div>
<div class="flex gap-4 mt-2">
<button
class="px-10 py-3 bg-white text-black font-bold uppercase tracking-widest text-lg rounded hover:bg-gray-200 transition-all shadow-[0_0_15px_rgba(255,255,255,0.3)] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
on:click={togglePlay}
disabled={isLoading}
>
{isPlaying ? "PAUSE" : "PLAY VIDEO"}
</button>
<button
class="px-6 py-3 bg-transparent border-2 border-white/50 text-white font-bold uppercase tracking-widest text-lg rounded hover:bg-white hover:text-black transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
on:click={() => {
currentFrameIndex = 0;
if (frames.length > 0) psychoPoints = frames[0];
if (isPlaying) togglePlay();
}}
disabled={isLoading}
>
RESTART
</button>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long