imghost11 commited on
Commit
f3b85bf
·
verified ·
1 Parent(s): e3ac443

Create ablation.py

Browse files
Files changed (1) hide show
  1. ablation.py +1076 -0
ablation.py ADDED
@@ -0,0 +1,1076 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # imgnet_ablation_viewer.py
2
+ # IMGNet Ablation + Comparison Viewer
3
+ # Kiri : Upload 2 foto + score metrics (IMG Sign, AMP, Chain, Cosine)
4
+ # Kanan: Compare (overlay/sign/chain/embedding) & Ablation (baca-ulang sliding window)
5
+ # Ablation: bandingkan HASIL BACA ULANG sliding window (asli vs terdampak-occlusion)
6
+ # → tampilkan blok/window mana saja yang berubah, bukan cuma skor akhir
7
+
8
+ import tkinter as tk
9
+ from tkinter import filedialog
10
+ from PIL import Image, ImageTk, ImageDraw
11
+ import numpy as np
12
+ import threading
13
+ from collections import Counter
14
+
15
+ # ── CONFIG ─────────────────────────────────────────────────
16
+ CKPT_PATH = r"C:\PythonProj\img_bnn\checkpoints_sw357_conv10_imgsign\SW357_conv10_imgsign\best_model_epoch39_plateau.pth"
17
+ WINDOW_SIZE = 11
18
+ THRESHOLD = 8
19
+ EMB_DIM = 1024
20
+ IMG_SIZE = 112
21
+ NEUTRAL_LEN = 29
22
+ REWARD_RATE = 0.3
23
+ PUNISH_RATE = 1.0
24
+ MASK_COLOR = 128
25
+ SOFT_BETA = 5.0 # sharpness tanh agreement per-dimensi (soft sign+magnitude)
26
+ SOFT_GATE_SHARPNESS= 15.0 # sharpness sigmoid gate di sekitar THRESHOLD (landai)
27
+
28
+ BG= "#0a0e1a"; CARD= "#111827"; BORDER="#1e293b"
29
+ BLUE= "#6366f1"; GREEN= "#10b981"; ORANGE="#f59e0b"
30
+ PURPLE="#a855f7"; TEAL= "#14b8a6"; RED= "#ef4444"
31
+ YELLOW="#fbbf24"; WHITE= "#ffffff"; SUB= "#64748b"; TEXT="#e2e8f0"
32
+
33
+ REGION_COLS = [
34
+ "#60a5fa","#34d399","#f59e0b","#f472b6","#a78bfa",
35
+ "#fb923c","#22d3ee","#e879f9","#facc15","#6ee7b7"
36
+ ]
37
+ REGIONS = {
38
+ "Mata Kiri" : (25, 20, 50, 55),
39
+ "Mata Kanan" : (25, 57, 50, 90),
40
+ "Semua Mata" : (18, 12, 52, 100),
41
+ "Hidung" : (50, 35, 75, 77),
42
+ "Mulut" : (72, 25, 95, 87),
43
+ "Dahi" : (4, 18, 27, 94),
44
+ "Rahang Kiri" : (72, 3, 112, 45),
45
+ "Rahang Kanan": (72, 67, 112, 109),
46
+ "Bagian Atas" : (0, 0, 56, 112),
47
+ "Bagian Bawah": (56, 0, 112, 112),
48
+ }
49
+ CUSTOM_KEY = "Custom (6 Titik)"
50
+
51
+ # ── MODEL LOAD ─────────────────────────────────────────────
52
+ try:
53
+ import torch, torch.nn as nn, torch.nn.functional as F
54
+ TORCH_OK = True
55
+ except: TORCH_OK = False
56
+
57
+ try:
58
+ from facenet_pytorch import MTCNN
59
+ _mtcnn = MTCNN(image_size=112, keep_all=False, post_process=False,
60
+ device="cuda" if (TORCH_OK and torch.cuda.is_available()) else "cpu")
61
+ MTCNN_OK = True
62
+ except: _mtcnn = None; MTCNN_OK = False
63
+
64
+ _model = None; _device = "cpu"
65
+
66
+ if TORCH_OK:
67
+ class _SW(nn.Module):
68
+ def __init__(self):
69
+ super().__init__()
70
+ self.fc = nn.Sequential(nn.Linear(240,64),nn.ReLU(True),nn.Linear(64,32))
71
+ def forward(self, x):
72
+ B,C,H,W=x.shape; diffs=[]
73
+ for ws in [3,5,7]:
74
+ p=ws//2; xp=F.pad(x,[p,p,p,p],mode='reflect')
75
+ pat=xp.unfold(2,ws,1).unfold(3,ws,1)
76
+ d=x.unsqueeze(-1).unsqueeze(-1)-pat
77
+ m=torch.ones(ws,ws,dtype=torch.bool,device=x.device); m[ws//2,ws//2]=False
78
+ diffs.append(d[:,:,:,:,m])
79
+ d=torch.cat(diffs,-1); B,C,H,W,N=d.shape
80
+ o=self.fc(d.permute(0,2,3,1,4).reshape(B*H*W,C*N))
81
+ return o.reshape(B,H,W,-1).permute(0,3,1,2)
82
+
83
+ class _Net(nn.Module):
84
+ def __init__(self):
85
+ super().__init__()
86
+ self.sw1=_SW(); self.bn1=nn.BatchNorm2d(32)
87
+ self.conv2 =nn.Conv2d(32, 64,3,stride=1,padding=1,bias=False); self.bn2 =nn.BatchNorm2d(64)
88
+ self.conv3 =nn.Conv2d(64, 64,3,stride=2,padding=1,bias=False); self.bn3 =nn.BatchNorm2d(64)
89
+ self.conv4 =nn.Conv2d(64,128,3,stride=1,padding=1,bias=False); self.bn4 =nn.BatchNorm2d(128)
90
+ self.conv5 =nn.Conv2d(128,128,3,stride=1,padding=1,bias=False); self.bn5 =nn.BatchNorm2d(128)
91
+ self.conv6 =nn.Conv2d(128,128,3,stride=2,padding=1,bias=False); self.bn6 =nn.BatchNorm2d(128)
92
+ self.conv7 =nn.Conv2d(128,256,3,stride=1,padding=1,bias=False); self.bn7 =nn.BatchNorm2d(256)
93
+ self.conv8 =nn.Conv2d(256,256,3,stride=1,padding=1,bias=False); self.bn8 =nn.BatchNorm2d(256)
94
+ self.conv9 =nn.Conv2d(256,256,3,stride=2,padding=1,bias=False); self.bn9 =nn.BatchNorm2d(256)
95
+ self.conv10=nn.Conv2d(256,256,3,stride=1,padding=1,bias=False); self.bn10=nn.BatchNorm2d(256)
96
+ self.gap=nn.AdaptiveAvgPool2d(1); self.fc=nn.Linear(256,1024); self.bn=nn.BatchNorm1d(1024)
97
+ def forward(self,x):
98
+ x=F.relu(self.bn1(self.sw1(x)))
99
+ for i in range(2,11):
100
+ x=F.relu(getattr(self,f'bn{i}')(getattr(self,f'conv{i}')(x)))
101
+ return self.bn(self.fc(self.gap(x).view(x.size(0),-1)))
102
+
103
+ import os
104
+ if os.path.exists(CKPT_PATH):
105
+ try:
106
+ _device="cuda" if torch.cuda.is_available() else "cpu"
107
+ _model=_Net().to(_device)
108
+ st=torch.load(CKPT_PATH,map_location="cpu",weights_only=False)
109
+ if isinstance(st,dict) and "model" in st: st=st["model"]
110
+ _model.load_state_dict(st); _model.eval()
111
+ print(f"✓ IMGNet loaded device={_device}")
112
+ except Exception as e: print(f"✗ {e}"); _model=None
113
+
114
+
115
+ # ── UTILS ──────────────────────────────────────────────────
116
+ def get_emb(arr):
117
+ if _model and TORCH_OK:
118
+ try:
119
+ t=torch.from_numpy(arr.astype(np.float32)/255.0).permute(2,0,1).unsqueeze(0).to(_device)
120
+ with torch.no_grad(): return _model(t).squeeze(0).cpu().numpy()
121
+ except: pass
122
+ np.random.seed(int(arr.sum())%2**31)
123
+ e=np.random.randn(EMB_DIM).astype(np.float32)
124
+ return e/(np.linalg.norm(e)+1e-8)
125
+
126
+ def load_face(path):
127
+ img=Image.open(path).convert("RGB")
128
+ if MTCNN_OK and _mtcnn:
129
+ try:
130
+ face=_mtcnn(img)
131
+ if face is not None:
132
+ return np.clip(face.permute(1,2,0).numpy(),0,255).astype(np.uint8)
133
+ except: pass
134
+ return np.array(img.resize((IMG_SIZE,IMG_SIZE),Image.BILINEAR))
135
+
136
+ def occlude(arr, name):
137
+ out=arr.copy(); r1,c1,r2,c2=REGIONS[name]
138
+ out[r1:r2,c1:c2]=MASK_COLOR; return out
139
+
140
+ def polygon_to_mask(points, size=IMG_SIZE):
141
+ """points: list of (x,y) dalam ruang 112x112 (x=kolom, y=baris).
142
+ Return boolean mask HxW — True = di dalam polygon."""
143
+ img=Image.new('L',(size,size),0)
144
+ ImageDraw.Draw(img).polygon(points,fill=255)
145
+ return np.array(img)>0
146
+
147
+ def occlude_mask(arr, mask):
148
+ if mask is None: return arr
149
+ out=arr.copy(); out[mask]=MASK_COLOR; return out
150
+
151
+ def img_sign_score(e1,e2):
152
+ n=len(e1)-WINDOW_SIZE+1
153
+ return sum(1 for i in range(n)
154
+ if sum(1 for j in range(WINDOW_SIZE)
155
+ if (e1[i+j]>=0)==(e2[i+j]>=0))>=THRESHOLD)/max(n,1)
156
+
157
+ def amp_score(e1,e2):
158
+ n=len(e1)-WINDOW_SIZE+1; tot=0.0
159
+ for i in range(n):
160
+ w1,w2=e1[i:i+WINDOW_SIZE],e2[i:i+WINDOW_SIZE]
161
+ s1=np.where(w1>=0,1,-1).astype(np.int8); s2=np.where(w2>=0,1,-1).astype(np.int8)
162
+ if int(np.sum(s1==s2))>=THRESHOLD:
163
+ a1,a2=np.mean(np.abs(w1)),np.mean(np.abs(w2))
164
+ tot+=max(0.0,1-abs(a1-a2)/max(a1,a2,1e-6))
165
+ return tot/max(n,1)
166
+
167
+ def chain_score(e1,e2):
168
+ n=len(e1)-WINDOW_SIZE+1
169
+ flags=[int(np.sum(np.where(e1[i:i+WINDOW_SIZE]>=0,1,-1).astype(np.int8)==
170
+ np.where(e2[i:i+WINDOW_SIZE]>=0,1,-1).astype(np.int8)))>=THRESHOLD
171
+ for i in range(n)]
172
+ total=sum(flags); sg=total/max(n,1)
173
+ nc=0; ic=False
174
+ for f in flags:
175
+ if f and not ic: nc+=1; ic=True
176
+ elif not f: ic=False
177
+ if nc==0 or total==0: return 0.0,0,0.0
178
+ ac=total/nc; diff=ac-NEUTRAL_LEN
179
+ score=sg+(REWARD_RATE*diff if diff>=0 else PUNISH_RATE*diff)/100
180
+ return float(np.clip(score,0,1)),nc,ac
181
+
182
+ def cosine(e1,e2):
183
+ return float(np.dot(e1,e2)/(np.linalg.norm(e1)*np.linalg.norm(e2)+1e-8))
184
+
185
+ def lerp_color(t, c1="#ef4444", c2="#10b981"):
186
+ r1,g1,b1=int(c1[1:3],16),int(c1[3:5],16),int(c1[5:7],16)
187
+ r2,g2,b2=int(c2[1:3],16),int(c2[3:5],16),int(c2[5:7],16)
188
+ r=int(r1+(r2-r1)*t); g=int(g1+(g2-g1)*t); b=int(b1+(b2-b1)*t)
189
+ return f"#{r:02x}{g:02x}{b:02x}"
190
+
191
+ # ── ABLATION: BACA ULANG SLIDING WINDOW ────────────────────
192
+ def window_sign_match(e1, e2):
193
+ """Baca ulang tiap sliding window (window_size=11, threshold=8) antara e1 dan e2.
194
+ Return array boolean per window: True = window MASIH cocok (>=THRESHOLD bit
195
+ searah), False = window ini BERUBAH akibat oklusi.
196
+ e1 = embedding asli, e2 = embedding foto yang sudah di-occlude."""
197
+ n=len(e1)-WINDOW_SIZE+1
198
+ out=np.zeros(n,dtype=bool)
199
+ for i in range(n):
200
+ s1=np.where(e1[i:i+WINDOW_SIZE]>=0,1,-1).astype(np.int8)
201
+ s2=np.where(e2[i:i+WINDOW_SIZE]>=0,1,-1).astype(np.int8)
202
+ out[i]=int(np.sum(s1==s2))>=THRESHOLD
203
+ return out
204
+
205
+ def soft_window_match(e1, e2, beta=SOFT_BETA, gate_sharpness=SOFT_GATE_SHARPNESS):
206
+ """Versi soft/differentiable dari window_sign_match + amp ratio (numpy port,
207
+ tanpa torch), berdasarkan:
208
+ agreement = (tanh(beta * E1 * E2) + 1) / 2 # per-dimensi
209
+ soft_match = sliding-window-sum(agreement) # analog count 0..WINDOW_SIZE
210
+ gate = sigmoid(gate_sharpness*(soft_match - THRESHOLD + 0.5))
211
+ amp1/amp2 = sliding-window-mean(|E1|), sliding-window-mean(|E2|)
212
+ Tidak ada cliff seperti window_sign_match: 'agreement' juga mempertimbangkan
213
+ magnitude, dan 'gate' transisi mulus di sekitar THRESHOLD alih-alih lompat.
214
+ Return (gate, amp_ratio, combined) — semua array sepanjang n_win, TANPA
215
+ di-gating oleh hasil hard-threshold (beda dari window_amp_sim)."""
216
+ def window_sum(arr, w):
217
+ c=np.cumsum(np.insert(arr,0,0.0))
218
+ return c[w:]-c[:-w]
219
+ prod=e1.astype(np.float64)*e2.astype(np.float64)
220
+ agreement=(np.tanh(beta*prod)+1)/2
221
+ soft_match=window_sum(agreement, WINDOW_SIZE)
222
+ gate=1.0/(1.0+np.exp(-gate_sharpness*(soft_match-THRESHOLD+0.5)))
223
+ amp1=window_sum(np.abs(e1.astype(np.float64)), WINDOW_SIZE)/WINDOW_SIZE
224
+ amp2=window_sum(np.abs(e2.astype(np.float64)), WINDOW_SIZE)/WINDOW_SIZE
225
+ amp_ratio=np.clip(1.0-np.abs(amp1-amp2)/np.maximum(np.maximum(amp1,amp2),1e-6),0.0,1.0)
226
+ combined=gate*amp_ratio
227
+ return gate, amp_ratio, combined
228
+
229
+ def changed_segments(blocks):
230
+ """Kelompokkan window yang BERUBAH (False) menjadi segmen kontinu.
231
+ Return list of (start_idx, length) — dipakai untuk breakdown, bukan cuma
232
+ angka akhir."""
233
+ segs=[]; i=0; n=len(blocks)
234
+ while i<n:
235
+ if not blocks[i]:
236
+ j=i
237
+ while j<n and not blocks[j]: j+=1
238
+ segs.append((i,j-i)); i=j
239
+ else:
240
+ i+=1
241
+ return segs
242
+
243
+
244
+ # ============================================================
245
+ # APP
246
+ # ============================================================
247
+ class App(tk.Tk):
248
+ def __init__(self):
249
+ super().__init__()
250
+ self.title("IMGNet — Ablation + Comparison Viewer")
251
+ self.geometry("1600x900")
252
+ self.configure(bg=BG)
253
+ self.resizable(True,True)
254
+
255
+ # State
256
+ self.arr1=None; self.arr2=None
257
+ self.e1=None; self.e2=None
258
+ self.dc1={}; self.dc2={} # cache per region: {"emb":..., "blocks":...}
259
+ self.sel=tk.StringVar(value="Semua Mata")
260
+ self.mode=tk.StringVar(value="compare") # compare / ablation
261
+ self.custom_points={1:[],2:[]} # titik polygon TERPISAH per foto, max 6 tiap foto
262
+ self.custom_mask={1:None,2:None} # mask hasil polygon per foto
263
+ self.custom_active_photo=1 # foto mana yg lagi digambar di kanvas custom
264
+
265
+ self._build()
266
+
267
+ # ── BUILD ─────────────────────────────────────────────
268
+ def _build(self):
269
+ top=tk.Frame(self,bg=BG); top.pack(fill="x",padx=12,pady=(8,4))
270
+ tk.Label(top,text="IMGNet · Ablation + Comparison Viewer",
271
+ font=("Courier",13,"bold"),bg=BG,fg=TEXT).pack(side="left")
272
+ st=f"Model={'✓' if _model else '✗ dummy'} MTCNN={'✓' if MTCNN_OK else '✗'}"
273
+ tk.Label(top,text=st,font=("Courier",9),bg=BG,fg=SUB).pack(side="right")
274
+
275
+ main=tk.Frame(self,bg=BG); main.pack(fill="both",expand=True,padx=8,pady=4)
276
+ main.grid_columnconfigure(0,weight=0,minsize=280)
277
+ main.grid_columnconfigure(1,weight=1)
278
+ main.grid_rowconfigure(0,weight=1)
279
+
280
+ self._build_left(main)
281
+ self._build_right(main)
282
+
283
+ def _build_left(self,parent):
284
+ lf_container=tk.Frame(parent,bg=CARD,highlightthickness=1,
285
+ highlightbackground=BORDER,width=280)
286
+ lf_container.grid(row=0,column=0,sticky="nsew",padx=(0,8))
287
+ lf_container.grid_propagate(False)
288
+
289
+ # Panel kiri dibungkus Canvas+Scrollbar supaya SEMUA kontrol (termasuk
290
+ # region Custom & checkbox tampilan) selalu bisa dicapai walau window
291
+ # dipersempit / kontennya makin panjang — sebelumnya item di bawah
292
+ # bisa "hilang" (terpotong) tanpa cara buat mengaksesnya.
293
+ lf_canvas=tk.Canvas(lf_container,bg=CARD,highlightthickness=0)
294
+ lf_scroll=tk.Scrollbar(lf_container,orient="vertical",command=lf_canvas.yview)
295
+ lf_canvas.configure(yscrollcommand=lf_scroll.set)
296
+ lf_canvas.pack(side="left",fill="both",expand=True)
297
+ lf_scroll.pack(side="right",fill="y")
298
+
299
+ lf=tk.Frame(lf_canvas,bg=CARD) # frame konten asli — semua widget di bawah nempel di sini
300
+ lf_window=lf_canvas.create_window((0,0),window=lf,anchor="nw")
301
+
302
+ def _on_lf_configure(event):
303
+ lf_canvas.configure(scrollregion=lf_canvas.bbox("all"))
304
+ lf.bind("<Configure>",_on_lf_configure)
305
+
306
+ def _on_canvas_configure(event):
307
+ lf_canvas.itemconfig(lf_window,width=event.width)
308
+ lf_canvas.bind("<Configure>",_on_canvas_configure)
309
+
310
+ def _on_mousewheel(event):
311
+ lf_canvas.yview_scroll(int(-1*(event.delta/120)),"units")
312
+ def _bind_wheel(_): lf_canvas.bind_all("<MouseWheel>",_on_mousewheel)
313
+ def _unbind_wheel(_): lf_canvas.unbind_all("<MouseWheel>")
314
+ lf_canvas.bind("<Enter>",_bind_wheel)
315
+ lf_canvas.bind("<Leave>",_unbind_wheel)
316
+
317
+ # Upload buttons
318
+ tk.Label(lf,text="FOTO",font=("Courier",10,"bold"),bg=CARD,fg=BLUE).pack(pady=(8,4))
319
+ btn_f=tk.Frame(lf,bg=CARD); btn_f.pack(fill="x",padx=8)
320
+ tk.Button(btn_f,text="Upload Foto 1",command=self.upload1,
321
+ bg=BLUE,fg=WHITE,font=("Courier",9,"bold"),
322
+ relief="flat",pady=4,cursor="hand2").pack(side="left",expand=True,fill="x",padx=2)
323
+ tk.Button(btn_f,text="Upload Foto 2",command=self.upload2,
324
+ bg=GREEN,fg=WHITE,font=("Courier",9,"bold"),
325
+ relief="flat",pady=4,cursor="hand2").pack(side="left",expand=True,fill="x",padx=2)
326
+
327
+ # Preview row
328
+ prev=tk.Frame(lf,bg=CARD); prev.pack(fill="x",padx=6,pady=4)
329
+ prev.grid_columnconfigure(0,weight=1); prev.grid_columnconfigure(1,weight=1)
330
+ for col,label,color,attr in [(0,"Foto 1",BLUE,"c1"),(1,"Foto 2",GREEN,"c2")]:
331
+ f=tk.Frame(prev,bg=CARD); f.grid(row=0,column=col,padx=2)
332
+ tk.Label(f,text=label,font=("Courier",8,"bold"),bg=CARD,fg=color).pack()
333
+ c=tk.Canvas(f,width=120,height=120,bg="#050810",
334
+ highlightthickness=1,highlightbackground=BORDER); c.pack()
335
+ setattr(self,attr,c)
336
+
337
+ # Mode tabs
338
+ tk.Label(lf,text="MODE",font=("Courier",9,"bold"),bg=CARD,fg=SUB).pack(pady=(6,2))
339
+ mf=tk.Frame(lf,bg=CARD); mf.pack(fill="x",padx=8)
340
+ for val,label,col in [("compare","COMPARE",TEAL),("ablation","ABLATION",PURPLE)]:
341
+ tk.Radiobutton(mf,text=label,variable=self.mode,value=val,
342
+ bg=CARD,fg=col,selectcolor=CARD,
343
+ font=("Courier",9,"bold"),
344
+ command=self._refresh).pack(side="left",padx=6)
345
+
346
+ # Indikator region aktif — selalu kelihatan di atas (nggak ikut ke-scroll)
347
+ # biar nggak ambigu region mana yang benar-benar dipilih.
348
+ self.region_active_lbl=tk.Label(lf,text="Region aktif: —",
349
+ font=("Courier",8,"bold"),bg="#0a0e1a",fg=YELLOW,
350
+ anchor="w",padx=6,pady=3)
351
+ self.region_active_lbl.pack(fill="x",padx=8,pady=(2,4))
352
+
353
+ # Metrics (compare mode)
354
+ self.metric_f=tk.Frame(lf,bg=CARD); self.metric_f.pack(fill="x",padx=8,pady=4)
355
+ self.m_sign =self._mbox(self.metric_f,"IMG SIGN",GREEN)
356
+ self.m_amp =self._mbox(self.metric_f,"AMP IMG", ORANGE)
357
+ self.m_chain =self._mbox(self.metric_f,"CHAIN", TEAL)
358
+ self.m_cos =self._mbox(self.metric_f,"COSINE", PURPLE)
359
+
360
+ # Verdict
361
+ self.verdict_lbl=tk.Label(lf,text="—",
362
+ font=("Courier",18,"bold"),bg=CARD,fg=SUB,
363
+ pady=6,highlightthickness=2,highlightbackground=BORDER)
364
+ self.verdict_lbl.pack(fill="x",padx=8,pady=4)
365
+
366
+ # Chain detail
367
+ self.chain_lbl=tk.Label(lf,text="",font=("Courier",8),bg=CARD,fg=SUB,
368
+ justify="left",wraplength=250)
369
+ self.chain_lbl.pack(padx=10)
370
+
371
+ # Ablation region selector
372
+ tk.Label(lf,text="REGION OKLUASI",font=("Courier",9,"bold"),
373
+ bg=CARD,fg=ORANGE).pack(pady=(10,2))
374
+ for i,name in enumerate(REGIONS):
375
+ col=REGION_COLS[i%len(REGION_COLS)]
376
+ tk.Radiobutton(lf,text=name,variable=self.sel,value=name,
377
+ bg=CARD,fg=TEXT,selectcolor=CARD,
378
+ font=("Courier",9),
379
+ command=self._on_region).pack(anchor="w",padx=16,pady=1)
380
+ tk.Radiobutton(lf,text=CUSTOM_KEY,variable=self.sel,value=CUSTOM_KEY,
381
+ bg=CARD,fg=PURPLE,selectcolor=CARD,
382
+ font=("Courier",9,"bold"),
383
+ command=self._on_region).pack(anchor="w",padx=16,pady=(4,1))
384
+
385
+ # ── Ablation display toggles ────────────────────────
386
+ tk.Label(lf,text="TAMPILAN BACA-ULANG WINDOW",font=("Courier",9,"bold"),
387
+ bg=CARD,fg=YELLOW).pack(pady=(10,2))
388
+ self.v_show_asli = tk.BooleanVar(value=True) # garis biru = embedding asli
389
+ self.v_show_terdampak = tk.BooleanVar(value=True) # garis merah = embedding ter-occlude
390
+ self.v_show_blocks = tk.BooleanVar(value=True) # bar blok hijau/merah per window
391
+ self.v_show_delta = tk.BooleanVar(value=True) # sinyal delta besar (spike)
392
+
393
+ tk.Checkbutton(lf,text="Garis Asli (Biru)",variable=self.v_show_asli,
394
+ bg=CARD,fg=BLUE,selectcolor=CARD,activebackground=CARD,
395
+ activeforeground=BLUE,font=("Courier",9,"bold"),
396
+ command=self._on_region).pack(anchor="w",padx=16,pady=1)
397
+ tk.Checkbutton(lf,text="Garis Terdampak (Merah)",variable=self.v_show_terdampak,
398
+ bg=CARD,fg=RED,selectcolor=CARD,activebackground=CARD,
399
+ activeforeground=RED,font=("Courier",9,"bold"),
400
+ command=self._on_region).pack(anchor="w",padx=16,pady=1)
401
+ tk.Checkbutton(lf,text="Bar Blok Berubah (Hijau/Merah)",variable=self.v_show_blocks,
402
+ bg=CARD,fg=YELLOW,selectcolor=CARD,activebackground=CARD,
403
+ activeforeground=YELLOW,font=("Courier",9,"bold"),
404
+ command=self._on_region).pack(anchor="w",padx=16,pady=1)
405
+ tk.Checkbutton(lf,text="Tandai Delta Besar (Spike)",variable=self.v_show_delta,
406
+ bg=CARD,fg=ORANGE,selectcolor=CARD,activebackground=CARD,
407
+ activeforeground=ORANGE,font=("Courier",9,"bold"),
408
+ command=self._on_region).pack(anchor="w",padx=16,pady=1)
409
+
410
+ # Ablation breakdown (bukan cuma skor akhir)
411
+ self.abl_lbl=tk.Label(lf,text="",font=("Courier",8),bg=CARD,fg=TEXT,
412
+ justify="left",wraplength=250)
413
+ self.abl_lbl.pack(padx=10,pady=6)
414
+
415
+ def _mbox(self,parent,label,color):
416
+ f=tk.Frame(parent,bg="#0a0e1a",highlightthickness=1,highlightbackground=BORDER)
417
+ f.pack(side="left",expand=True,fill="both",padx=2,pady=2)
418
+ tk.Label(f,text=label,font=("Courier",6,"bold"),bg="#0a0e1a",fg=color).pack(pady=(4,0))
419
+ lbl=tk.Label(f,text="—",font=("Courier",13,"bold"),bg="#0a0e1a",fg=color)
420
+ lbl.pack(pady=(0,4)); return lbl
421
+
422
+ def _build_right(self,parent):
423
+ rf=tk.Frame(parent,bg=CARD,highlightthickness=1,highlightbackground=BORDER)
424
+ rf.grid(row=0,column=1,sticky="nsew")
425
+
426
+ # ── COMPARE MODE canvases ──────────────────────────
427
+ self.cmp_frame=tk.Frame(rf,bg=CARD); self.cmp_frame.pack(fill="both",expand=True)
428
+
429
+ tk.Label(self.cmp_frame,
430
+ text="Embedding: Biru=Foto1 Hijau=Foto2 — window aktif di-highlight",
431
+ font=("Courier",9,"bold"),bg=CARD,fg=TEAL).pack(pady=(6,1))
432
+ self.c_emb=tk.Canvas(self.cmp_frame,bg="#050810",height=180,
433
+ highlightthickness=1,highlightbackground=BORDER)
434
+ self.c_emb.pack(fill="x",padx=8,pady=2)
435
+
436
+ tk.Label(self.cmp_frame,
437
+ text="Sign match per window (hijau=match ≥8/11, merah=tidak)",
438
+ font=("Courier",9,"bold"),bg=CARD,fg=GREEN).pack(pady=(4,1))
439
+ self.c_sign=tk.Canvas(self.cmp_frame,bg="#050810",height=80,
440
+ highlightthickness=1,highlightbackground=BORDER)
441
+ self.c_sign.pack(fill="x",padx=8,pady=2)
442
+
443
+ tk.Label(self.cmp_frame,
444
+ text="Chain pattern (rantai match kontinu — panjang rantai = kualitas kecocokan)",
445
+ font=("Courier",9,"bold"),bg=CARD,fg=TEAL).pack(pady=(4,1))
446
+ self.c_chain=tk.Canvas(self.cmp_frame,bg="#050810",height=70,
447
+ highlightthickness=1,highlightbackground=BORDER)
448
+ self.c_chain.pack(fill="x",padx=8,pady=2)
449
+
450
+ # Embedding bars foto 1 dan 2
451
+ emb_row=tk.Frame(self.cmp_frame,bg=CARD)
452
+ emb_row.pack(fill="x",padx=8,pady=(4,2))
453
+ emb_row.grid_columnconfigure(0,weight=1)
454
+ emb_row.grid_columnconfigure(1,weight=1)
455
+
456
+ lf1=tk.Frame(emb_row,bg=CARD); lf1.grid(row=0,column=0,sticky="ew",padx=(0,4))
457
+ tk.Label(lf1,text="Embedding Foto 1 (1024D)",font=("Courier",8,"bold"),
458
+ bg=CARD,fg=BLUE).pack()
459
+ self.c_emb1=tk.Canvas(lf1,bg="#050810",height=60,
460
+ highlightthickness=1,highlightbackground=BORDER)
461
+ self.c_emb1.pack(fill="x")
462
+
463
+ lf2=tk.Frame(emb_row,bg=CARD); lf2.grid(row=0,column=1,sticky="ew",padx=(4,0))
464
+ tk.Label(lf2,text="Embedding Foto 2 (1024D)",font=("Courier",8,"bold"),
465
+ bg=CARD,fg=GREEN).pack()
466
+ self.c_emb2=tk.Canvas(lf2,bg="#050810",height=60,
467
+ highlightthickness=1,highlightbackground=BORDER)
468
+ self.c_emb2.pack(fill="x")
469
+
470
+ # ── ABLATION MODE canvases ─────────────────────────
471
+ self.abl_frame=tk.Frame(rf,bg=CARD); # tidak di-pack dulu
472
+
473
+ # Kanvas gambar polygon custom (6 titik) — hanya di-pack saat
474
+ # region terpilih == CUSTOM_KEY, disisipkan di atas panel hasil
475
+ self.custom_draw_frame=tk.Frame(self.abl_frame,bg=CARD)
476
+ tk.Label(self.custom_draw_frame,
477
+ text="OKLUSI CUSTOM — klik 6 titik di foto (urut, otomatis nutup jadi polygon). "
478
+ "Foto1 & Foto2 digambar TERPISAH — pilih tab di bawah.",
479
+ font=("Courier",9,"bold"),bg=CARD,fg=PURPLE).pack(pady=(6,2))
480
+ toggle_row=tk.Frame(self.custom_draw_frame,bg=CARD); toggle_row.pack(pady=(0,4))
481
+ self.btn_custom_p1=tk.Button(toggle_row,text="✎ Gambar Foto 1",
482
+ command=lambda:self._switch_custom_photo(1),
483
+ font=("Courier",9,"bold"),relief="flat",padx=10,cursor="hand2")
484
+ self.btn_custom_p1.pack(side="left",padx=4)
485
+ self.btn_custom_p2=tk.Button(toggle_row,text="✎ Gambar Foto 2",
486
+ command=lambda:self._switch_custom_photo(2),
487
+ font=("Courier",9,"bold"),relief="flat",padx=10,cursor="hand2")
488
+ self.btn_custom_p2.pack(side="left",padx=4)
489
+ draw_row=tk.Frame(self.custom_draw_frame,bg=CARD); draw_row.pack()
490
+ self.custom_scale=3
491
+ cs=IMG_SIZE*self.custom_scale
492
+ self.c_custom_draw=tk.Canvas(draw_row,width=cs,height=cs,bg="#050810",
493
+ highlightthickness=1,highlightbackground=BORDER,
494
+ cursor="crosshair")
495
+ self.c_custom_draw.pack(padx=4,pady=2)
496
+ self.c_custom_draw.bind("<Button-1>", self._on_custom_click)
497
+ btn_row=tk.Frame(self.custom_draw_frame,bg=CARD); btn_row.pack(pady=(2,6))
498
+ tk.Button(btn_row,text="Reset Titik",command=self._reset_custom_points,
499
+ bg=RED,fg=WHITE,font=("Courier",9,"bold"),relief="flat",
500
+ padx=8,cursor="hand2").pack(side="left",padx=4)
501
+ self.btn_apply_custom=tk.Button(btn_row,text="Terapkan Oklusi",
502
+ command=self._apply_custom_occlusion,
503
+ bg=PURPLE,fg=WHITE,font=("Courier",9,"bold"),relief="flat",
504
+ padx=8,cursor="hand2",state="disabled")
505
+ self.btn_apply_custom.pack(side="left",padx=4)
506
+ self.custom_status_lbl=tk.Label(btn_row,text="0/6 titik",
507
+ font=("Courier",9,"bold"),bg=CARD,fg=SUB)
508
+ self.custom_status_lbl.pack(side="left",padx=8)
509
+ self._update_custom_toggle_style()
510
+
511
+ self.abl_d1_title=tk.Label(self.abl_frame,
512
+ text="Foto1 — Baca Ulang Sliding Window: Asli vs Terdampak",
513
+ font=("Courier",9,"bold"),bg=CARD,fg=WHITE)
514
+ self.abl_d1_title.pack(pady=(6,1))
515
+ self.c_d1=tk.Canvas(self.abl_frame,bg="#050810",height=90,
516
+ highlightthickness=1,highlightbackground=BORDER)
517
+ self.c_d1.pack(fill="x",padx=8,pady=2)
518
+ self.c_blk1=tk.Canvas(self.abl_frame,bg="#050810",height=50,
519
+ highlightthickness=1,highlightbackground=BORDER)
520
+ self.c_blk1.pack(fill="x",padx=8,pady=(0,6))
521
+
522
+ self.abl_d2_title=tk.Label(self.abl_frame,
523
+ text="Foto2 — Baca Ulang Sliding Window: Asli vs Terdampak",
524
+ font=("Courier",9,"bold"),bg=CARD,fg=WHITE)
525
+ self.abl_d2_title.pack(pady=(4,1))
526
+ self.c_d2=tk.Canvas(self.abl_frame,bg="#050810",height=90,
527
+ highlightthickness=1,highlightbackground=BORDER)
528
+ self.c_d2.pack(fill="x",padx=8,pady=2)
529
+ self.c_blk2=tk.Canvas(self.abl_frame,bg="#050810",height=50,
530
+ highlightthickness=1,highlightbackground=BORDER)
531
+ self.c_blk2.pack(fill="x",padx=8,pady=(0,6))
532
+
533
+ tk.Label(self.abl_frame,
534
+ text="Perbandingan region — % window yang berubah kalau region ini di-occlude (Biru=Foto1 Hijau=Foto2)",
535
+ font=("Courier",9,"bold"),bg=CARD,fg=WHITE).pack(pady=(4,1))
536
+ self.c_multi=tk.Canvas(self.abl_frame,bg="#050810",height=150,
537
+ highlightthickness=1,highlightbackground=BORDER)
538
+ self.c_multi.pack(fill="x",padx=8,pady=(2,6))
539
+
540
+ tk.Label(self.abl_frame,
541
+ text="Konsistensi blok berubah: Foto1 vs Foto2 pada region terpilih — blok merah di lokasi SAMA = region ini konsisten di-encode walau beda orang",
542
+ font=("Courier",9,"bold"),bg=CARD,fg=WHITE).pack(pady=(4,1))
543
+ self.c_consist=tk.Canvas(self.abl_frame,bg="#050810",height=110,
544
+ highlightthickness=1,highlightbackground=BORDER)
545
+ self.c_consist.pack(fill="x",padx=8,pady=(2,6))
546
+
547
+ # Bind resize agar grafik re-draw saat ukuran berubah
548
+ for cv in [self.c_emb,self.c_sign,self.c_chain,
549
+ self.c_emb1,self.c_emb2,
550
+ self.c_d1,self.c_d2,self.c_blk1,self.c_blk2,
551
+ self.c_multi,self.c_consist]:
552
+ cv.bind("<Configure>", lambda e: self.after(50, self._redraw_all))
553
+
554
+ # Default: show compare
555
+ self.cmp_frame.pack(fill="both",expand=True)
556
+
557
+ # ── UPLOAD ────────────────────────────────────────────
558
+ def _redraw_all(self):
559
+ """Re-draw semua grafik yang aktif setelah window resize"""
560
+ if self.mode.get()=="compare":
561
+ self._draw_compare()
562
+ else:
563
+ self._on_region()
564
+ self._draw_multi()
565
+
566
+ def _get_w(self, canvas, fallback=1200):
567
+ """Get actual canvas width after rendering"""
568
+ w = canvas.winfo_width()
569
+ return w if w > 10 else fallback
570
+
571
+ def upload1(self):
572
+ path=filedialog.askopenfilename(filetypes=[("Image","*.jpg *.jpeg *.png *.bmp")])
573
+ if not path: return
574
+ self.arr1=load_face(path); self.e1=get_emb(self.arr1)
575
+ self._show(self.arr1,self.c1,120)
576
+ self.dc1={}
577
+ threading.Thread(target=self._precompute,args=(1,),daemon=True).start()
578
+ self._refresh()
579
+
580
+ def upload2(self):
581
+ path=filedialog.askopenfilename(filetypes=[("Image","*.jpg *.jpeg *.png *.bmp")])
582
+ if not path: return
583
+ self.arr2=load_face(path); self.e2=get_emb(self.arr2)
584
+ self._show(self.arr2,self.c2,120)
585
+ self.dc2={}
586
+ threading.Thread(target=self._precompute,args=(2,),daemon=True).start()
587
+ self._refresh()
588
+
589
+ def _show(self,arr,canvas,size,region=None):
590
+ img=Image.fromarray(arr.astype(np.uint8)).resize((size,size),Image.NEAREST)
591
+ if region:
592
+ draw=ImageDraw.Draw(img); r1,c1,r2,c2=REGIONS[region]
593
+ sc=size/IMG_SIZE
594
+ draw.rectangle([c1*sc,r1*sc,c2*sc,r2*sc],outline=ORANGE,width=2)
595
+ tk_img=ImageTk.PhotoImage(img)
596
+ canvas.delete("all"); canvas.create_image(0,0,anchor="nw",image=tk_img)
597
+ canvas.image=tk_img
598
+
599
+ def _precompute(self,which):
600
+ """Untuk tiap region: occlude foto, hitung ulang embedding, lalu BACA ULANG
601
+ sliding window (window_sign_match) antara embedding asli vs embedding
602
+ ter-occlude. Hasilnya array boolean per window (blocks), bukan kurva delta."""
603
+ arr=self.arr1 if which==1 else self.arr2
604
+ emb=self.e1 if which==1 else self.e2
605
+ dc =self.dc1 if which==1 else self.dc2
606
+ for name in REGIONS:
607
+ occ=occlude(arr,name); eo=get_emb(occ)
608
+ blocks=window_sign_match(emb,eo) # True=window tetap, False=window berubah
609
+ dc[name]={"emb":eo,"blocks":blocks}
610
+ if self.custom_mask.get(which) is not None:
611
+ self._compute_custom_for(which)
612
+ self.after(0,self._draw_multi)
613
+ self.after(0,self._on_region)
614
+
615
+ # ── REFRESH MODE ─────────────────────────────────────
616
+ def _refresh(self):
617
+ mode=self.mode.get()
618
+ if mode=="compare":
619
+ self.abl_frame.pack_forget()
620
+ self.cmp_frame.pack(fill="both",expand=True)
621
+ self._draw_compare()
622
+ else:
623
+ self.cmp_frame.pack_forget()
624
+ self.abl_frame.pack(fill="both",expand=True)
625
+ self._on_region()
626
+
627
+ # ── COMPARE DRAWS ─────────────────────────────────────
628
+ def _draw_compare(self):
629
+ if self.e1 is None or self.e2 is None: return
630
+ e1,e2=self.e1,self.e2
631
+
632
+ # Scores
633
+ sg=img_sign_score(e1,e2); ap=amp_score(e1,e2)
634
+ cs,nc,ac=chain_score(e1,e2); co=cosine(e1,e2)
635
+ self.m_sign.config(text=f"{sg:.3f}")
636
+ self.m_amp.config(text=f"{ap:.3f}")
637
+ self.m_chain.config(text=f"{cs:.3f}")
638
+ self.m_cos.config(text=f"{co:.3f}")
639
+ self.chain_lbl.config(
640
+ text=f"Chains: {nc} AvgLen: {ac:.1f} (neutral={NEUTRAL_LEN})")
641
+
642
+ thr=0.79; npass=sum([sg>=thr,ap>=thr,cs>=thr])
643
+ if npass>=2:
644
+ self.verdict_lbl.config(text="✅ MATCH",fg=WHITE,bg="#064e3b",
645
+ highlightbackground=GREEN)
646
+ elif npass==1:
647
+ self.verdict_lbl.config(text="⚠️ UNCERTAIN",fg=WHITE,bg="#78350f",
648
+ highlightbackground=ORANGE)
649
+ else:
650
+ self.verdict_lbl.config(text="❌ DIFFERENT",fg=WHITE,bg="#450a0a",
651
+ highlightbackground=RED)
652
+
653
+ # Embedding bar
654
+ self._draw_emb_bars()
655
+ self._draw_sign_pattern()
656
+ self._draw_chain_pattern()
657
+ self._draw_emb_single(self.c_emb1,self.e1,BLUE,"Foto 1")
658
+ self._draw_emb_single(self.c_emb2,self.e2,GREEN,"Foto 2")
659
+
660
+ def _draw_emb_bars(self):
661
+ c=self.c_emb; c.delete("all")
662
+ W=self._get_w(c); H=180
663
+ n=len(self.e1); bw=W/n; mid=H//2
664
+ c.create_line(0,mid,W,mid,fill=BORDER,width=1,dash=(3,2))
665
+ for i in range(n):
666
+ x0=i*bw; x1=x0+bw-0.3
667
+ v1=float(self.e1[i]); h1=abs(v1)*(mid-4)
668
+ if v1>=0: c.create_rectangle(x0,mid-h1,x1,mid,fill=BLUE,outline="")
669
+ else: c.create_rectangle(x0,mid,x1,mid+h1,fill=BLUE,outline="")
670
+ v2=float(self.e2[i]); h2=abs(v2)*(mid-4)*0.7
671
+ if v2>=0: c.create_rectangle(x0,mid-h2,x1,mid,fill=GREEN,outline="",stipple="gray25")
672
+ else: c.create_rectangle(x0,mid,x1,mid+h2,fill=GREEN,outline="",stipple="gray25")
673
+ c.create_text(4,4,anchor="nw",
674
+ text="Biru=Foto1 Hijau=Foto2 (stipple) — dimensi yang searah = sign cocok",
675
+ font=("Courier",7),fill=SUB)
676
+
677
+ def _draw_emb_single(self,canvas,emb,color,label):
678
+ """Grafik embedding individual (1024D) untuk satu foto — dipakai di kedua canvas bawah."""
679
+ canvas.delete("all")
680
+ if emb is None:
681
+ W=self._get_w(canvas); H=60
682
+ canvas.create_text(W//2,H//2,text=f"Upload {label}...",
683
+ font=("Courier",9),fill=SUB)
684
+ return
685
+ W=self._get_w(canvas); H=60
686
+ n=len(emb); bw=W/n; mid=H//2
687
+ canvas.create_line(0,mid,W,mid,fill=BORDER,width=1,dash=(3,2))
688
+ for i in range(n):
689
+ x0=i*bw; x1=x0+bw-0.3
690
+ v=float(emb[i]); h=abs(v)*(mid-3)
691
+ if v>=0: canvas.create_rectangle(x0,mid-h,x1,mid,fill=color,outline="")
692
+ else: canvas.create_rectangle(x0,mid,x1,mid+h,fill=color,outline="")
693
+ canvas.create_text(4,2,anchor="nw",
694
+ text=f"{label} min={emb.min():.2f} max={emb.max():.2f} mean={emb.mean():.3f}",
695
+ font=("Courier",7),fill=SUB)
696
+
697
+ def _draw_sign_pattern(self):
698
+ c=self.c_sign; c.delete("all")
699
+ W=self._get_w(c); H=80
700
+ e1,e2=self.e1,self.e2; n=len(e1)-WINDOW_SIZE+1; bw=W/n
701
+ for i in range(n):
702
+ mc=sum(1 for j in range(WINDOW_SIZE)
703
+ if (e1[i+j]>=0)==(e2[i+j]>=0))
704
+ ratio=mc/WINDOW_SIZE; h=ratio*(H-4)
705
+ col=GREEN if mc>=THRESHOLD else RED
706
+ x0=i*bw
707
+ c.create_rectangle(x0,H-h,x0+bw-0.3,H,fill=col,outline="")
708
+ c.create_line(0,H-(THRESHOLD/WINDOW_SIZE)*(H-4),
709
+ W,H-(THRESHOLD/WINDOW_SIZE)*(H-4),
710
+ fill=YELLOW,width=1,dash=(4,2))
711
+ c.create_text(4,4,anchor="nw",
712
+ text=f"Sign match ratio per window (thr={THRESHOLD}/{WINDOW_SIZE} = garis kuning)",
713
+ font=("Courier",7),fill=SUB)
714
+
715
+ def _draw_chain_pattern(self):
716
+ c=self.c_chain; c.delete("all")
717
+ W=self._get_w(c); H=70
718
+ e1,e2=self.e1,self.e2; n=len(e1)-WINDOW_SIZE+1; bw=W/n
719
+ in_chain=False; chain_start=0; chain_num=0
720
+ for i in range(n):
721
+ mc=sum(1 for j in range(WINDOW_SIZE)
722
+ if (e1[i+j]>=0)==(e2[i+j]>=0))
723
+ match=mc>=THRESHOLD; x0=i*bw
724
+ if match:
725
+ c.create_rectangle(x0,10,x0+bw-0.3,H-10,fill=TEAL,outline="")
726
+ if not in_chain:
727
+ chain_start=i; in_chain=True; chain_num+=1
728
+ c.create_line(x0,4,x0,H-4,fill=WHITE,width=1)
729
+ c.create_text(x0+1,6,anchor="nw",
730
+ text=str(chain_num),font=("Courier",6),fill=WHITE)
731
+ else:
732
+ in_chain=False
733
+ c.create_text(4,4,anchor="nw",
734
+ text=f"Chain pattern — teal=match, garis putih=awal chain baru, angka=nomor chain",
735
+ font=("Courier",7),fill=SUB)
736
+
737
+ # ── ABLATION: BACA ULANG SLIDING WINDOW ───────────────
738
+ def _switch_custom_photo(self, which):
739
+ self.custom_active_photo=which
740
+ self._update_custom_toggle_style()
741
+ self._draw_custom_canvas_bg()
742
+
743
+ def _update_custom_toggle_style(self):
744
+ active=self.custom_active_photo
745
+ p1_done=self.custom_mask.get(1) is not None
746
+ p2_done=self.custom_mask.get(2) is not None
747
+ self.btn_custom_p1.config(
748
+ bg=(BLUE if active==1 else CARD), fg=WHITE if active==1 else BLUE,
749
+ relief=("sunken" if active==1 else "flat"),
750
+ text=("✎ Gambar Foto 1 ✓" if p1_done else "✎ Gambar Foto 1"))
751
+ self.btn_custom_p2.config(
752
+ bg=(GREEN if active==2 else CARD), fg=WHITE if active==2 else GREEN,
753
+ relief=("sunken" if active==2 else "flat"),
754
+ text=("✎ Gambar Foto 2 ✓" if p2_done else "✎ Gambar Foto 2"))
755
+
756
+ def _draw_custom_canvas_bg(self):
757
+ """Gambar ulang background (foto yg lagi aktif di-edit) di kanvas gambar polygon."""
758
+ self.c_custom_draw.delete("all")
759
+ cs=IMG_SIZE*self.custom_scale
760
+ which=self.custom_active_photo
761
+ arr=self.arr1 if which==1 else self.arr2
762
+ if arr is not None:
763
+ img=Image.fromarray(arr.astype(np.uint8)).resize((cs,cs),Image.NEAREST)
764
+ tkimg=ImageTk.PhotoImage(img)
765
+ self.c_custom_draw.create_image(0,0,anchor="nw",image=tkimg)
766
+ self.c_custom_draw.image=tkimg
767
+ else:
768
+ self.c_custom_draw.create_text(cs//2,cs//2,text=f"Upload Foto {which} dulu",
769
+ font=("Courier",10),fill=SUB)
770
+ self._redraw_custom_points()
771
+
772
+ def _redraw_custom_points(self):
773
+ self.c_custom_draw.delete("pt")
774
+ sc=self.custom_scale
775
+ which=self.custom_active_photo
776
+ pts_orig=self.custom_points[which]
777
+ pts=[(x*sc,y*sc) for (x,y) in pts_orig]
778
+ col=BLUE if which==1 else GREEN
779
+ for i,(x,y) in enumerate(pts):
780
+ self.c_custom_draw.create_oval(x-4,y-4,x+4,y+4,fill=col,outline=WHITE,tags="pt")
781
+ self.c_custom_draw.create_text(x+8,y-8,text=str(i+1),fill=WHITE,
782
+ font=("Courier",8,"bold"),tags="pt")
783
+ for i in range(len(pts)-1):
784
+ x0,y0=pts[i]; x1,y1=pts[i+1]
785
+ self.c_custom_draw.create_line(x0,y0,x1,y1,fill=col,width=2,tags="pt")
786
+ if len(pts)==6:
787
+ x0,y0=pts[0]; x5,y5=pts[5]
788
+ self.c_custom_draw.create_line(x5,y5,x0,y0,fill=col,width=2,dash=(4,2),tags="pt")
789
+ self.custom_status_lbl.config(
790
+ text=f"Foto{which}: {len(pts_orig)}/6 titik")
791
+ self.btn_apply_custom.config(state=("normal" if len(pts_orig)==6 else "disabled"))
792
+
793
+ def _on_custom_click(self, event):
794
+ which=self.custom_active_photo
795
+ if len(self.custom_points[which])>=6: return
796
+ sc=self.custom_scale
797
+ ox=min(max(event.x/sc,0),IMG_SIZE-1)
798
+ oy=min(max(event.y/sc,0),IMG_SIZE-1)
799
+ self.custom_points[which].append((ox,oy))
800
+ self._redraw_custom_points()
801
+
802
+ def _reset_custom_points(self):
803
+ which=self.custom_active_photo
804
+ self.custom_points[which]=[]
805
+ self._redraw_custom_points()
806
+
807
+ def _compute_custom_for(self, which):
808
+ """Hitung ulang embedding+blocks utk region CUSTOM_KEY satu foto,
809
+ pakai self.custom_mask[which] milik foto itu sendiri (independen dari
810
+ foto satunya). Aman dipanggil dari thread background (cuma nulis ke
811
+ dict, tidak sentuh widget)."""
812
+ mask=self.custom_mask.get(which)
813
+ if mask is None: return
814
+ arr=self.arr1 if which==1 else self.arr2
815
+ emb=self.e1 if which==1 else self.e2
816
+ dc =self.dc1 if which==1 else self.dc2
817
+ if arr is None or emb is None: return
818
+ occ=occlude_mask(arr,mask)
819
+ eo=get_emb(occ)
820
+ dc[CUSTOM_KEY]={"emb":eo,"blocks":window_sign_match(emb,eo)}
821
+
822
+ def _apply_custom_occlusion(self):
823
+ which=self.custom_active_photo
824
+ pts=self.custom_points[which]
825
+ if len(pts)!=6: return
826
+ self.custom_mask[which]=polygon_to_mask(pts)
827
+ self._compute_custom_for(which)
828
+ self._update_custom_toggle_style()
829
+ self._draw_multi()
830
+ self._on_region()
831
+
832
+ def _on_region(self,*_):
833
+ if self.mode.get()!="ablation": return
834
+ name=self.sel.get()
835
+ self.region_active_lbl.config(text=f"Region aktif: {name}")
836
+ if name==CUSTOM_KEY:
837
+ self.custom_draw_frame.pack(fill="x",padx=8,pady=(2,4),before=self.abl_d1_title)
838
+ self._draw_custom_canvas_bg()
839
+ if self.arr1 is not None:
840
+ self._show(occlude_mask(self.arr1,self.custom_mask.get(1)),self.c1,120)
841
+ if self.arr2 is not None:
842
+ self._show(occlude_mask(self.arr2,self.custom_mask.get(2)),self.c2,120)
843
+ else:
844
+ self.custom_draw_frame.pack_forget()
845
+ if self.arr1 is not None: self._show(occlude(self.arr1,name),self.c1,120,region=name)
846
+ if self.arr2 is not None: self._show(occlude(self.arr2,name),self.c2,120,region=name)
847
+ self._draw_window_reread(name)
848
+ self._draw_consistency(name)
849
+
850
+ def _draw_window_reread(self,name):
851
+ """Untuk tiap foto: gambar embedding asli vs terdampak (garis), lalu di
852
+ bawahnya bar blok per sliding window (hijau=window tetap, merah=window
853
+ berubah). Breakdown ditampilkan (jumlah window berubah, jumlah segmen,
854
+ rata-rata panjang segmen) — bukan cuma satu angka skor akhir."""
855
+ show_asli = self.v_show_asli.get()
856
+ show_terdampak = self.v_show_terdampak.get()
857
+ show_blocks = self.v_show_blocks.get()
858
+ show_delta = self.v_show_delta.get()
859
+
860
+ pairs = [
861
+ (self.c_d1, self.c_blk1, self.dc1, self.e1, self.abl_d1_title, 1),
862
+ (self.c_d2, self.c_blk2, self.dc2, self.e2, self.abl_d2_title, 2),
863
+ ]
864
+
865
+ for line_canvas, blk_canvas, dc, orig_emb, title_lbl, which in pairs:
866
+ line_canvas.delete("all"); blk_canvas.delete("all")
867
+ Wl=self._get_w(line_canvas); Hl=90
868
+ Wb=self._get_w(blk_canvas)
869
+
870
+ if name not in dc or orig_emb is None:
871
+ msg=("Gambar 6 titik & klik Terapkan Oklusi dulu"
872
+ if name==CUSTOM_KEY else f"Menghitung Foto{which}...")
873
+ line_canvas.create_text(Wl//2,Hl//2,text=msg,
874
+ font=("Courier",9),fill=SUB)
875
+ continue
876
+
877
+ occ_emb = dc[name]["emb"]
878
+ blocks = dc[name]["blocks"] # True=tetap, False=berubah
879
+ n_dim = len(orig_emb)
880
+ n_win = len(blocks)
881
+
882
+ # ── Hitung delta per-dimensi & spike besar ──
883
+ delta = orig_emb.astype(np.float64) - occ_emb.astype(np.float64)
884
+ abs_delta = np.abs(delta)
885
+ mu, sd = float(abs_delta.mean()), float(abs_delta.std())
886
+ spike_thr = mu + 1.5*sd if sd > 1e-9 else abs_delta.max()+1
887
+ spike_idx = np.where(abs_delta >= spike_thr)[0]
888
+ d_max = float(abs_delta.max()) if abs_delta.size else 1e-8
889
+
890
+ # ── Baris atas: nilai embedding asli vs terdampak, full 1024D ──
891
+ mid=Hl/2
892
+ g_max=max(float(np.abs(orig_emb).max()),float(np.abs(occ_emb).max()))+1e-8
893
+ bw_d=Wl/n_dim
894
+
895
+ # spike bands digambar dulu (di belakang garis) — makin tinggi delta
896
+ # makin pekat oranye, tanda arah: naik keatas jika asli>terdampak,
897
+ # kebawah jika terdampak>asli
898
+ if show_delta:
899
+ for i in spike_idx:
900
+ x0=i*bw_d
901
+ t=min(1.0,(abs_delta[i]-spike_thr)/max(d_max-spike_thr,1e-8))
902
+ band_col = lerp_color(0.15+0.85*t, c1="#3a2a00", c2=YELLOW)
903
+ line_canvas.create_rectangle(x0,4,x0+bw_d+0.6,Hl-4,
904
+ fill=band_col,outline="")
905
+
906
+ line_canvas.create_line(0,mid,Wl,mid,fill=BORDER,width=1,dash=(3,2))
907
+ if show_asli:
908
+ pts=[(i*bw_d, mid-(orig_emb[i]/g_max)*(mid-4)) for i in range(n_dim)]
909
+ for i in range(len(pts)-1):
910
+ line_canvas.create_line(pts[i][0],pts[i][1],pts[i+1][0],pts[i+1][1],
911
+ fill=BLUE,width=1)
912
+ if show_terdampak:
913
+ pts=[(i*bw_d, mid-(occ_emb[i]/g_max)*(mid-4)) for i in range(n_dim)]
914
+ for i in range(len(pts)-1):
915
+ line_canvas.create_line(pts[i][0],pts[i][1],pts[i+1][0],pts[i+1][1],
916
+ fill=RED,width=1)
917
+
918
+ # marker segitiga di puncak tiap spike: ▲ oranye kalau asli>terdampak
919
+ # (delta positif, dimensi "turun" akibat oklusi), ▼ ungu kalau
920
+ # terdampak>asli (delta negatif, dimensi "naik" akibat oklusi)
921
+ if show_delta:
922
+ for i in spike_idx:
923
+ xc=i*bw_d+bw_d/2
924
+ yo=mid-(orig_emb[i]/g_max)*(mid-4)
925
+ yt=mid-(occ_emb[i]/g_max)*(mid-4)
926
+ y_ext = min(yo,yt)-6 # titik ekstrem paling atas
927
+ if delta[i] >= 0:
928
+ # asli lebih tinggi/lebih positif -> segitiga naik, oranye
929
+ line_canvas.create_polygon(
930
+ xc,y_ext-6, xc-4,y_ext, xc+4,y_ext,
931
+ fill=ORANGE, outline="")
932
+ else:
933
+ y_ext2 = max(yo,yt)+6
934
+ line_canvas.create_polygon(
935
+ xc,y_ext2+6, xc-4,y_ext2, xc+4,y_ext2,
936
+ fill=PURPLE, outline="")
937
+
938
+ n_spike=len(spike_idx)
939
+ line_canvas.create_text(4,2,anchor="nw",
940
+ text=f"Foto{which} [{name}] Biru=Asli Merah=Terdampak "
941
+ f"Kuning=zona delta besar ▲/▼=spike ({n_spike} dim, thr≈{spike_thr:.3f})",
942
+ font=("Courier",7),fill=SUB)
943
+
944
+ # ── Baris bawah: hasil BACA ULANG tiap sliding window, di-encode
945
+ # pakai AMPLITUDO (persis rumus amp_score) — tinggi & warna bar
946
+ # = amp_sim (hijau tinggi=amplitudo mirip, merah pendek=jomplang
947
+ # atau sign gagal). Titik oranye = window sign-nya MATCH tapi
948
+ # amplitudonya anomali (di atas ambang adaptif mean+1.5*std,
949
+ # dihitung hanya dari window yang match). ──
950
+ n_changed=int(np.sum(~blocks))
951
+ segs=changed_segments(blocks)
952
+ avg_len=(n_changed/len(segs)) if segs else 0.0
953
+
954
+ gate,amp_ratio,combined=soft_window_match(orig_emb,occ_emb) # dipakai utk bar visual (soft)
955
+ amp_sim=np.where(blocks,amp_ratio,0.0) # basis anomali (hard-gated), diturunkan dari amp_ratio
956
+ matched_idx=np.where(blocks)[0]
957
+ if matched_idx.size>0:
958
+ amp_dev=1-amp_sim[matched_idx]
959
+ mu_a,sd_a=float(amp_dev.mean()),float(amp_dev.std())
960
+ amp_thr=mu_a+1.5*sd_a if sd_a>1e-9 else float(amp_dev.max())+1.0
961
+ avg_amp=float(amp_sim[matched_idx].mean())
962
+ anomaly_idx=[i for i in matched_idx if (1-amp_sim[i])>=amp_thr]
963
+ else:
964
+ amp_thr=2.0; avg_amp=0.0; anomaly_idx=[]
965
+
966
+ if show_blocks:
967
+ Hb=50; bw_w=Wb/n_win
968
+ for i in range(n_win):
969
+ x0=i*bw_w; sim=combined[i]
970
+ h=max(2.0,sim*(Hb-4))
971
+ col=lerp_color(sim,c1=RED,c2=GREEN)
972
+ blk_canvas.create_rectangle(x0,Hb-h,x0+bw_w+0.5,Hb,fill=col,outline="")
973
+ for i in anomaly_idx:
974
+ xc=i*bw_w+bw_w/2; h=max(2.0,combined[i]*(Hb-4))
975
+ blk_canvas.create_oval(xc-3,Hb-h-9,xc+3,Hb-h-3,fill=ORANGE,outline="")
976
+ blk_canvas.create_text(Wb-4,2,anchor="ne",
977
+ text="tinggi/warna=soft gate×amp •=anomali amplitudo (hard)",
978
+ font=("Courier",6),fill=SUB)
979
+
980
+ blk_canvas.create_text(4,2,anchor="nw",
981
+ text=f"Berubah:{n_changed}/{n_win}({n_changed/max(n_win,1)*100:.0f}%) "
982
+ f"seg:{len(segs)} avglen:{avg_len:.1f} | "
983
+ f"AMP avg:{avg_amp:.2f} anomali:{len(anomaly_idx)}",
984
+ font=("Courier",7,"bold"),fill=WHITE)
985
+
986
+ title_lbl.config(
987
+ text=f"Foto{which} [{name}] — Baca Ulang Sliding Window "
988
+ f"(berubah: {n_changed}/{n_win} = {n_changed/max(n_win,1)*100:.1f}% "
989
+ f"spike delta: {n_spike} amp avg: {avg_amp:.2f} amp anomali: {len(anomaly_idx)})")
990
+
991
+ def _draw_multi(self):
992
+ """Bandingkan semua region: berapa persen window yang berubah kalau
993
+ region tsb ditutup. Biru=Foto1, Hijau=Foto2 — makin tinggi bar makin
994
+ sensitif region itu terhadap oklusi."""
995
+ c=self.c_multi; c.delete("all")
996
+ if not self.dc1 and not self.dc2: return
997
+ W=self._get_w(c); H=150
998
+ names=list(REGIONS.keys())
999
+ if CUSTOM_KEY in self.dc1 or CUSTOM_KEY in self.dc2:
1000
+ names=names+[CUSTOM_KEY]
1001
+ n=len(names)
1002
+ slot=W/n; bar_w=slot*0.32; base=H-28
1003
+
1004
+ c.create_line(0,base,W,base,fill=BORDER)
1005
+ for idx,name in enumerate(names):
1006
+ xc=idx*slot+slot/2
1007
+ col=REGION_COLS[idx%len(REGION_COLS)]
1008
+ if name in self.dc1:
1009
+ r1=np.sum(~self.dc1[name]["blocks"])/len(self.dc1[name]["blocks"])
1010
+ h1=r1*base
1011
+ c.create_rectangle(xc-bar_w,base-h1,xc,base,fill=BLUE,outline="")
1012
+ c.create_text(xc-bar_w/2,base-h1-2,text=f"{r1*100:.0f}",
1013
+ anchor="s",font=("Courier",6),fill=BLUE)
1014
+ if name in self.dc2:
1015
+ r2=np.sum(~self.dc2[name]["blocks"])/len(self.dc2[name]["blocks"])
1016
+ h2=r2*base
1017
+ c.create_rectangle(xc,base-h2,xc+bar_w,base,fill=GREEN,outline="")
1018
+ c.create_text(xc+bar_w/2,base-h2-2,text=f"{r2*100:.0f}",
1019
+ anchor="s",font=("Courier",6),fill=GREEN)
1020
+ c.create_text(xc,base+4,text=name.split()[0],anchor="n",
1021
+ font=("Courier",6),fill=col)
1022
+ c.create_text(W-4,4,anchor="ne",
1023
+ text="Biru=Foto1 Hijau=Foto2 (angka=% window berubah per region)",
1024
+ font=("Courier",7),fill=SUB)
1025
+
1026
+ def _draw_consistency(self,name):
1027
+ """Bandingkan blok yang berubah pada region terpilih antara Foto1 dan
1028
+ Foto2 (bukan kurva delta yang di-smooth). Konsistensi diukur dengan
1029
+ Jaccard overlap dari posisi window yang sama-sama berubah."""
1030
+ c=self.c_consist; c.delete("all")
1031
+ W=self._get_w(c); H=110
1032
+ if name not in self.dc1 or name not in self.dc2:
1033
+ c.create_text(W//2,H//2,text="Tunggu precompute...",
1034
+ font=("Courier",9),fill=SUB); return
1035
+ b1=self.dc1[name]["blocks"]; b2=self.dc2[name]["blocks"]
1036
+ n=min(len(b1),len(b2)); bw=W/n
1037
+
1038
+ row1_top,row1_bot = 8,44
1039
+ row2_top,row2_bot = 54,90
1040
+
1041
+ for i in range(n):
1042
+ x0=i*bw
1043
+ c.create_rectangle(x0,row1_top,x0+bw+0.5,row1_bot,
1044
+ fill=(RED if not b1[i] else "#0f2418"),outline="")
1045
+ c.create_rectangle(x0,row2_top,x0+bw+0.5,row2_bot,
1046
+ fill=(ORANGE if not b2[i] else "#0f2418"),outline="")
1047
+
1048
+ changed1=~b1[:n]; changed2=~b2[:n]
1049
+ inter=int(np.sum(changed1 & changed2))
1050
+ union=int(np.sum(changed1 | changed2))
1051
+ jacc=inter/union if union>0 else 0.0
1052
+ col_j=GREEN if jacc>0.5 else (YELLOW if jacc>0.2 else RED)
1053
+
1054
+ c.create_text(4,H-2,anchor="sw",
1055
+ text=f"Atas=Foto1(merah=berubah) Bawah=Foto2(oranye=berubah) overlap={inter}/{union}",
1056
+ font=("Courier",7),fill=SUB)
1057
+ c.create_text(W-4,4,anchor="ne",
1058
+ text=f"Konsistensi (Jaccard): {jacc:.3f}",
1059
+ font=("Courier",9,"bold"),fill=col_j)
1060
+
1061
+ sim1=img_sign_score(self.e1,self.dc1[name]["emb"]) if self.e1 is not None else 0
1062
+ sim2=img_sign_score(self.e2,self.dc2[name]["emb"]) if self.e2 is not None else 0
1063
+ n1=int(np.sum(changed1)); n2=int(np.sum(changed2))
1064
+ self.abl_lbl.config(
1065
+ text=f"[{name}]\n"
1066
+ f"Foto1: {n1}/{n} window berubah ({n1/max(n,1)*100:.1f}%) sign-sim akhir={sim1:.4f}\n"
1067
+ f"Foto2: {n2}/{n} window berubah ({n2/max(n,1)*100:.1f}%) sign-sim akhir={sim2:.4f}\n"
1068
+ f"Overlap posisi berubah (Jaccard): {jacc:.3f}\n"
1069
+ f"({'Konsisten ✓' if jacc>0.4 else 'Tidak konsisten ✗'})",
1070
+ fg=GREEN if jacc>0.4 else RED)
1071
+
1072
+
1073
+ if __name__=="__main__":
1074
+ app=App()
1075
+ app.after(200, app._redraw_all) # trigger setelah window render
1076
+ app.mainloop()