BcantCode commited on
Commit
89bf6c3
Β·
verified Β·
1 Parent(s): 7b3b093

Upload src/generate_pdf.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/generate_pdf.py +1230 -0
src/generate_pdf.py ADDED
@@ -0,0 +1,1230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate a comprehensive walkthrough PDF for GazeInception-Lite.
4
+ Covers every design decision, reasoning, citations, architecture diagrams, and results.
5
+ """
6
+
7
+ from reportlab.lib.pagesizes import A4
8
+ from reportlab.lib.units import mm, cm, inch
9
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
10
+ from reportlab.lib.colors import HexColor, black, white, Color
11
+ from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY, TA_RIGHT
12
+ from reportlab.platypus import (
13
+ SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
14
+ PageBreak, Image, KeepTogether, ListFlowable, ListItem,
15
+ Flowable, HRFlowable
16
+ )
17
+ from reportlab.graphics.shapes import Drawing, Rect, String, Line, Circle, Group, Polygon
18
+ from reportlab.graphics.charts.barcharts import VerticalBarChart
19
+ from reportlab.graphics import renderPDF
20
+ from reportlab.pdfgen import canvas
21
+ import json
22
+ import os
23
+
24
+ # ──────────────────────────────────────────────────────────────
25
+ # Colors
26
+ # ──────────────────────────────────────────────────────────────
27
+ PRIMARY = HexColor('#1a73e8')
28
+ SECONDARY = HexColor('#34a853')
29
+ ACCENT = HexColor('#ea4335')
30
+ DARK = HexColor('#202124')
31
+ LIGHT_BG = HexColor('#f8f9fa')
32
+ BORDER = HexColor('#dadce0')
33
+ LINK_BLUE = HexColor('#1967d2')
34
+ PURPLE = HexColor('#7c3aed')
35
+ ORANGE = HexColor('#f59e0b')
36
+
37
+ # ──────────────────────────────────────────────────────────────
38
+ # Styles
39
+ # ──────────────────────────────────────────────────────────────
40
+ styles = getSampleStyleSheet()
41
+
42
+ styles.add(ParagraphStyle(
43
+ 'DocTitle', parent=styles['Title'],
44
+ fontSize=28, leading=34, textColor=DARK,
45
+ spaceAfter=6, fontName='Helvetica-Bold',
46
+ alignment=TA_CENTER
47
+ ))
48
+
49
+ styles.add(ParagraphStyle(
50
+ 'Subtitle', parent=styles['Normal'],
51
+ fontSize=14, leading=18, textColor=HexColor('#5f6368'),
52
+ spaceAfter=20, fontName='Helvetica',
53
+ alignment=TA_CENTER
54
+ ))
55
+
56
+ styles.add(ParagraphStyle(
57
+ 'H1', parent=styles['Heading1'],
58
+ fontSize=22, leading=28, textColor=PRIMARY,
59
+ spaceBefore=24, spaceAfter=10, fontName='Helvetica-Bold'
60
+ ))
61
+
62
+ styles.add(ParagraphStyle(
63
+ 'H2', parent=styles['Heading2'],
64
+ fontSize=16, leading=22, textColor=DARK,
65
+ spaceBefore=16, spaceAfter=8, fontName='Helvetica-Bold'
66
+ ))
67
+
68
+ styles.add(ParagraphStyle(
69
+ 'H3', parent=styles['Heading3'],
70
+ fontSize=13, leading=18, textColor=HexColor('#3c4043'),
71
+ spaceBefore=12, spaceAfter=6, fontName='Helvetica-Bold'
72
+ ))
73
+
74
+ styles.add(ParagraphStyle(
75
+ 'Body', parent=styles['Normal'],
76
+ fontSize=10.5, leading=16, textColor=DARK,
77
+ spaceAfter=8, fontName='Helvetica',
78
+ alignment=TA_JUSTIFY
79
+ ))
80
+
81
+ styles.add(ParagraphStyle(
82
+ 'BodyBold', parent=styles['Normal'],
83
+ fontSize=10.5, leading=16, textColor=DARK,
84
+ spaceAfter=8, fontName='Helvetica-Bold',
85
+ alignment=TA_JUSTIFY
86
+ ))
87
+
88
+ styles.add(ParagraphStyle(
89
+ 'Caption', parent=styles['Normal'],
90
+ fontSize=9, leading=13, textColor=HexColor('#5f6368'),
91
+ spaceAfter=12, fontName='Helvetica-Oblique',
92
+ alignment=TA_CENTER
93
+ ))
94
+
95
+ styles.add(ParagraphStyle(
96
+ 'CodeBlock', parent=styles['Normal'],
97
+ fontSize=9, leading=13, textColor=DARK,
98
+ fontName='Courier', backColor=LIGHT_BG,
99
+ borderPadding=6, spaceAfter=8
100
+ ))
101
+
102
+ styles.add(ParagraphStyle(
103
+ 'Citation', parent=styles['Normal'],
104
+ fontSize=9, leading=13, textColor=HexColor('#5f6368'),
105
+ fontName='Helvetica-Oblique', leftIndent=20,
106
+ spaceAfter=6, alignment=TA_JUSTIFY
107
+ ))
108
+
109
+ styles.add(ParagraphStyle(
110
+ 'KeyInsight', parent=styles['Normal'],
111
+ fontSize=10.5, leading=16, textColor=DARK,
112
+ fontName='Helvetica', backColor=HexColor('#e8f0fe'),
113
+ borderPadding=10, spaceAfter=12, spaceBefore=6,
114
+ borderWidth=1, borderColor=PRIMARY, borderRadius=4,
115
+ alignment=TA_JUSTIFY
116
+ ))
117
+
118
+ styles.add(ParagraphStyle(
119
+ 'WhyBox', parent=styles['Normal'],
120
+ fontSize=10.5, leading=16, textColor=HexColor('#1e3a5f'),
121
+ fontName='Helvetica', backColor=HexColor('#fef3c7'),
122
+ borderPadding=10, spaceAfter=12, spaceBefore=6,
123
+ borderWidth=1, borderColor=ORANGE, borderRadius=4,
124
+ alignment=TA_JUSTIFY
125
+ ))
126
+
127
+ styles.add(ParagraphStyle(
128
+ 'Footer', parent=styles['Normal'],
129
+ fontSize=8, leading=10, textColor=HexColor('#9aa0a6'),
130
+ fontName='Helvetica', alignment=TA_CENTER
131
+ ))
132
+
133
+
134
+ # ──────────────────────────────────────────────────────────────
135
+ # Helper: colored box for "WHY" callouts
136
+ # ───────────���──────────────────────────────────────────────────
137
+ def why_box(text):
138
+ return Paragraph(f"<b>πŸ’‘ WHY:</b> {text}", styles['WhyBox'])
139
+
140
+ def key_insight(text):
141
+ return Paragraph(f"<b>πŸ”‘ Key Insight:</b> {text}", styles['KeyInsight'])
142
+
143
+ def citation(text):
144
+ return Paragraph(f"πŸ“„ {text}", styles['Citation'])
145
+
146
+ def body(text):
147
+ return Paragraph(text, styles['Body'])
148
+
149
+ def bold_body(text):
150
+ return Paragraph(text, styles['BodyBold'])
151
+
152
+ def heading1(text):
153
+ return Paragraph(text, styles['H1'])
154
+
155
+ def heading2(text):
156
+ return Paragraph(text, styles['H2'])
157
+
158
+ def heading3(text):
159
+ return Paragraph(text, styles['H3'])
160
+
161
+ def spacer(h=6):
162
+ return Spacer(1, h)
163
+
164
+
165
+ def make_table(data, col_widths=None, header=True):
166
+ """Make a styled table."""
167
+ t = Table(data, colWidths=col_widths, repeatRows=1 if header else 0)
168
+ style_cmds = [
169
+ ('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
170
+ ('FONTSIZE', (0, 0), (-1, -1), 9),
171
+ ('LEADING', (0, 0), (-1, -1), 14),
172
+ ('TEXTCOLOR', (0, 0), (-1, -1), DARK),
173
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
174
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
175
+ ('GRID', (0, 0), (-1, -1), 0.5, BORDER),
176
+ ('TOPPADDING', (0, 0), (-1, -1), 6),
177
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
178
+ ('LEFTPADDING', (0, 0), (-1, -1), 8),
179
+ ('RIGHTPADDING', (0, 0), (-1, -1), 8),
180
+ ]
181
+ if header:
182
+ style_cmds += [
183
+ ('BACKGROUND', (0, 0), (-1, 0), PRIMARY),
184
+ ('TEXTCOLOR', (0, 0), (-1, 0), white),
185
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
186
+ ]
187
+ # Alternate row colors
188
+ for i in range(1, len(data)):
189
+ if i % 2 == 0:
190
+ style_cmds.append(('BACKGROUND', (0, i), (-1, i), LIGHT_BG))
191
+ t.setStyle(TableStyle(style_cmds))
192
+ return t
193
+
194
+
195
+ def draw_gated_inception_diagram():
196
+ """Draw the Gated Inception Block architecture."""
197
+ d = Drawing(460, 280)
198
+
199
+ # Background
200
+ d.add(Rect(0, 0, 460, 280, fillColor=HexColor('#fafafa'), strokeColor=BORDER, strokeWidth=0.5, rx=6))
201
+
202
+ # Title
203
+ d.add(String(230, 262, 'Gated Inception Block', fontSize=12, fontName='Helvetica-Bold',
204
+ fillColor=DARK, textAnchor='middle'))
205
+
206
+ # Input box
207
+ d.add(Rect(185, 230, 90, 22, fillColor=PRIMARY, strokeColor=None, rx=4))
208
+ d.add(String(230, 237, 'Input Features', fontSize=9, fontName='Helvetica-Bold',
209
+ fillColor=white, textAnchor='middle'))
210
+
211
+ # Four branches
212
+ branch_colors = [HexColor('#4285f4'), HexColor('#34a853'), HexColor('#fbbc04'), HexColor('#ea4335')]
213
+ branch_labels = ['1×1 Conv\n(Point)', '1×1→3×3\nDWConv\n(Local)', '1×1→5×5\nDWConv\n(Wide)', 'MaxPool\n→1×1\n(Pool)']
214
+ branch_short = ['Branch 1', 'Branch 2', 'Branch 3', 'Branch 4']
215
+
216
+ bx_start = 30
217
+ bw = 90
218
+ bh = 55
219
+ gap = 15
220
+ by = 148
221
+
222
+ for i in range(4):
223
+ x = bx_start + i * (bw + gap)
224
+ # Branch box
225
+ d.add(Rect(x, by, bw, bh, fillColor=branch_colors[i], strokeColor=None, rx=4))
226
+ lines = branch_labels[i].split('\n')
227
+ for j, line in enumerate(lines):
228
+ d.add(String(x + bw/2, by + bh - 14 - j*12, line, fontSize=8,
229
+ fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
230
+
231
+ # Arrow from input
232
+ d.add(Line(230, 230, x + bw/2, by + bh, strokeColor=HexColor('#9aa0a6'), strokeWidth=1))
233
+
234
+ # Gate network box
235
+ d.add(Rect(155, 88, 150, 30, fillColor=PURPLE, strokeColor=None, rx=4))
236
+ d.add(String(230, 99, 'Gate: GAP β†’ Dense β†’ Οƒ', fontSize=9, fontName='Helvetica-Bold',
237
+ fillColor=white, textAnchor='middle'))
238
+
239
+ # Gate arrows to branches
240
+ for i in range(4):
241
+ x = bx_start + i * (bw + gap) + bw/2
242
+ # Multiplication symbol
243
+ d.add(String(x, 130, 'Γ— g[' + str(i) + ']', fontSize=8, fontName='Helvetica-Bold',
244
+ fillColor=PURPLE, textAnchor='middle'))
245
+
246
+ # Gate input arrow
247
+ d.add(Line(230, 148, 230, 118, strokeColor=PURPLE, strokeWidth=1.5, strokeDashArray=[3,2]))
248
+
249
+ # Concat + Output
250
+ d.add(Rect(145, 35, 170, 28, fillColor=SECONDARY, strokeColor=None, rx=4))
251
+ d.add(String(230, 44, 'Concat(gated branches)', fontSize=9, fontName='Helvetica-Bold',
252
+ fillColor=white, textAnchor='middle'))
253
+
254
+ # Arrows from branches to concat
255
+ for i in range(4):
256
+ x = bx_start + i * (bw + gap) + bw/2
257
+ d.add(Line(x, 148, x, 85, strokeColor=branch_colors[i], strokeWidth=1.5))
258
+ d.add(Line(x, 85, 230, 63, strokeColor=HexColor('#9aa0a6'), strokeWidth=1))
259
+
260
+ # Output
261
+ d.add(Rect(185, 5, 90, 22, fillColor=DARK, strokeColor=None, rx=4))
262
+ d.add(String(230, 12, 'Output', fontSize=9, fontName='Helvetica-Bold',
263
+ fillColor=white, textAnchor='middle'))
264
+ d.add(Line(230, 35, 230, 27, strokeColor=DARK, strokeWidth=1.5))
265
+
266
+ return d
267
+
268
+
269
+ def draw_dual_eye_pipeline():
270
+ """Draw the dual-eye pipeline diagram."""
271
+ d = Drawing(460, 200)
272
+ d.add(Rect(0, 0, 460, 200, fillColor=HexColor('#fafafa'), strokeColor=BORDER, strokeWidth=0.5, rx=6))
273
+
274
+ d.add(String(230, 182, 'Dual-Eye GazeInception-Lite Pipeline', fontSize=12,
275
+ fontName='Helvetica-Bold', fillColor=DARK, textAnchor='middle'))
276
+
277
+ # Left eye input
278
+ d.add(Rect(10, 130, 80, 30, fillColor=PRIMARY, strokeColor=None, rx=4))
279
+ d.add(String(50, 140, 'Left Eye', fontSize=9, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
280
+ d.add(String(50, 123, '64Γ—64Γ—3', fontSize=7, fontName='Helvetica', fillColor=HexColor('#5f6368'), textAnchor='middle'))
281
+
282
+ # Right eye input
283
+ d.add(Rect(10, 82, 80, 30, fillColor=PRIMARY, strokeColor=None, rx=4))
284
+ d.add(String(50, 92, 'Right Eye', fontSize=9, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
285
+ d.add(String(50, 75, '64Γ—64Γ—3', fontSize=7, fontName='Helvetica', fillColor=HexColor('#5f6368'), textAnchor='middle'))
286
+
287
+ # Face input
288
+ d.add(Rect(10, 28, 80, 30, fillColor=ORANGE, strokeColor=None, rx=4))
289
+ d.add(String(50, 38, 'Face', fontSize=9, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
290
+ d.add(String(50, 21, '64Γ—64Γ—3', fontSize=7, fontName='Helvetica', fillColor=HexColor('#5f6368'), textAnchor='middle'))
291
+
292
+ # Shared backbone
293
+ d.add(Rect(120, 90, 120, 60, fillColor=SECONDARY, strokeColor=None, rx=4))
294
+ d.add(String(180, 128, 'Shared Eye Backbone', fontSize=9, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
295
+ d.add(String(180, 115, 'GatedInception Γ—3', fontSize=8, fontName='Helvetica', fillColor=white, textAnchor='middle'))
296
+ d.add(String(180, 103, '+ CoordAttention', fontSize=8, fontName='Helvetica', fillColor=white, textAnchor='middle'))
297
+
298
+ # Face CNN
299
+ d.add(Rect(120, 28, 120, 30, fillColor=HexColor('#f97316'), strokeColor=None, rx=4))
300
+ d.add(String(180, 40, 'Lightweight CNN', fontSize=9, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
301
+
302
+ # Arrows
303
+ d.add(Line(90, 145, 120, 130, strokeColor=PRIMARY, strokeWidth=1.5))
304
+ d.add(Line(90, 97, 120, 110, strokeColor=PRIMARY, strokeWidth=1.5))
305
+ d.add(Line(90, 43, 120, 43, strokeColor=ORANGE, strokeWidth=1.5))
306
+
307
+ # Shared weight indicator
308
+ d.add(String(180, 82, '(shared weights)', fontSize=7, fontName='Helvetica-Oblique', fillColor=HexColor('#5f6368'), textAnchor='middle'))
309
+
310
+ # Concat
311
+ d.add(Rect(270, 55, 70, 70, fillColor=PURPLE, strokeColor=None, rx=4))
312
+ d.add(String(305, 95, 'Concat', fontSize=9, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
313
+ d.add(String(305, 75, '176+176', fontSize=8, fontName='Helvetica', fillColor=white, textAnchor='middle'))
314
+ d.add(String(305, 63, '+64', fontSize=8, fontName='Helvetica', fillColor=white, textAnchor='middle'))
315
+
316
+ d.add(Line(240, 120, 270, 100, strokeColor=SECONDARY, strokeWidth=1.5))
317
+ d.add(Line(240, 43, 270, 70, strokeColor=ORANGE, strokeWidth=1.5))
318
+
319
+ # Dense head
320
+ d.add(Rect(360, 65, 80, 50, fillColor=DARK, strokeColor=None, rx=4))
321
+ d.add(String(400, 96, 'Dense Head', fontSize=9, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
322
+ d.add(String(400, 80, '128β†’64β†’2', fontSize=8, fontName='Helvetica', fillColor=white, textAnchor='middle'))
323
+ d.add(String(400, 68, '+ Dropout', fontSize=8, fontName='Helvetica', fillColor=white, textAnchor='middle'))
324
+
325
+ d.add(Line(340, 90, 360, 90, strokeColor=DARK, strokeWidth=1.5))
326
+
327
+ # Output
328
+ d.add(String(400, 48, 'β†’ (x, y)', fontSize=10, fontName='Helvetica-Bold', fillColor=ACCENT, textAnchor='middle'))
329
+ d.add(String(400, 36, 'Screen coordinates', fontSize=7, fontName='Helvetica', fillColor=HexColor('#5f6368'), textAnchor='middle'))
330
+ d.add(String(400, 26, '[0,1] Γ— [0,1]', fontSize=7, fontName='Helvetica', fillColor=HexColor('#5f6368'), textAnchor='middle'))
331
+
332
+ return d
333
+
334
+
335
+ def draw_coord_attention_diagram():
336
+ """Draw Coordinate Attention mechanism."""
337
+ d = Drawing(460, 170)
338
+ d.add(Rect(0, 0, 460, 170, fillColor=HexColor('#fafafa'), strokeColor=BORDER, strokeWidth=0.5, rx=6))
339
+
340
+ d.add(String(230, 152, 'Coordinate Attention Module', fontSize=12,
341
+ fontName='Helvetica-Bold', fillColor=DARK, textAnchor='middle'))
342
+
343
+ # Input
344
+ d.add(Rect(10, 65, 60, 50, fillColor=PRIMARY, strokeColor=None, rx=4))
345
+ d.add(String(40, 95, 'Input X', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
346
+ d.add(String(40, 80, 'HΓ—WΓ—C', fontSize=7, fontName='Helvetica', fillColor=white, textAnchor='middle'))
347
+
348
+ # Pool H
349
+ d.add(Rect(100, 100, 70, 25, fillColor=HexColor('#4285f4'), strokeColor=None, rx=3))
350
+ d.add(String(135, 109, 'Pool(H,1)', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
351
+ d.add(String(135, 90, 'β†’ HΓ—1Γ—C', fontSize=7, fillColor=HexColor('#5f6368'), textAnchor='middle'))
352
+
353
+ # Pool W
354
+ d.add(Rect(100, 48, 70, 25, fillColor=HexColor('#34a853'), strokeColor=None, rx=3))
355
+ d.add(String(135, 57, 'Pool(1,W)', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
356
+ d.add(String(135, 38, 'β†’ 1Γ—WΓ—C', fontSize=7, fillColor=HexColor('#5f6368'), textAnchor='middle'))
357
+
358
+ d.add(Line(70, 97, 100, 112, strokeColor=PRIMARY, strokeWidth=1))
359
+ d.add(Line(70, 83, 100, 60, strokeColor=PRIMARY, strokeWidth=1))
360
+
361
+ # Concat + Conv
362
+ d.add(Rect(195, 65, 80, 45, fillColor=PURPLE, strokeColor=None, rx=4))
363
+ d.add(String(235, 95, 'Concat β†’', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
364
+ d.add(String(235, 82, '1Γ—1 Conv β†’', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
365
+ d.add(String(235, 69, 'BN + ReLU', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
366
+
367
+ d.add(Line(170, 112, 195, 95, strokeColor=HexColor('#4285f4'), strokeWidth=1))
368
+ d.add(Line(170, 60, 195, 78, strokeColor=HexColor('#34a853'), strokeWidth=1))
369
+
370
+ # Split + Conv
371
+ d.add(Rect(300, 100, 55, 25, fillColor=HexColor('#4285f4'), strokeColor=None, rx=3))
372
+ d.add(String(327, 109, 'Conv_h Οƒ', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
373
+
374
+ d.add(Rect(300, 48, 55, 25, fillColor=HexColor('#34a853'), strokeColor=None, rx=3))
375
+ d.add(String(327, 57, 'Conv_w Οƒ', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
376
+
377
+ d.add(Line(275, 95, 300, 112, strokeColor=PURPLE, strokeWidth=1))
378
+ d.add(Line(275, 80, 300, 60, strokeColor=PURPLE, strokeWidth=1))
379
+
380
+ # Multiply
381
+ d.add(Rect(380, 65, 60, 50, fillColor=ACCENT, strokeColor=None, rx=4))
382
+ d.add(String(410, 95, 'X Γ— g_h', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
383
+ d.add(String(410, 80, 'Γ— g_w', fontSize=8, fontName='Helvetica-Bold', fillColor=white, textAnchor='middle'))
384
+
385
+ d.add(Line(355, 112, 380, 97, strokeColor=HexColor('#4285f4'), strokeWidth=1))
386
+ d.add(Line(355, 60, 380, 80, strokeColor=HexColor('#34a853'), strokeWidth=1))
387
+
388
+ # Output label
389
+ d.add(String(410, 50, 'Output Y', fontSize=8, fontName='Helvetica-Bold', fillColor=DARK, textAnchor='middle'))
390
+
391
+ return d
392
+
393
+
394
+ # ══════════════════════════════════════════════════════════════
395
+ # Build the PDF
396
+ # ══════════════════════════════════════════════════════════════
397
+ def build_pdf(output_path='/app/output/GazeInceptionLite_Walkthrough.pdf'):
398
+ doc = SimpleDocTemplate(
399
+ output_path,
400
+ pagesize=A4,
401
+ leftMargin=2*cm, rightMargin=2*cm,
402
+ topMargin=2.5*cm, bottomMargin=2*cm,
403
+ title='GazeInception-Lite: Technical Walkthrough',
404
+ author='BcantCode'
405
+ )
406
+
407
+ story = []
408
+ W = doc.width
409
+
410
+ # ──────────────────────────────────────────────────────────
411
+ # COVER PAGE
412
+ # ──────────────────────────────────────────────────────────
413
+ story.append(Spacer(1, 3*cm))
414
+ story.append(Paragraph('πŸ‘οΈ GazeInception-Lite', styles['DocTitle']))
415
+ story.append(Spacer(1, 0.5*cm))
416
+ story.append(Paragraph(
417
+ 'A Lightweight Gated Inception Model for Mobile Eye Gaze Estimation',
418
+ styles['Subtitle']
419
+ ))
420
+ story.append(Spacer(1, 0.3*cm))
421
+ story.append(Paragraph(
422
+ 'Complete Technical Walkthrough: Architecture, Reasoning, and Results',
423
+ ParagraphStyle('sub2', parent=styles['Subtitle'], fontSize=11, textColor=HexColor('#80868b'))
424
+ ))
425
+ story.append(Spacer(1, 1.5*cm))
426
+
427
+ # Feature summary table
428
+ cover_data = [
429
+ ['Feature', 'Details'],
430
+ ['πŸ”¦ Dark Mode', 'Works in low-light (15% brightness)'],
431
+ ['πŸ‘“ Glasses', 'Synthetic glasses overlay (10 styles)'],
432
+ ['πŸ‘οΈ Lazy Eye', 'Dual-eye independent processing'],
433
+ ['⚑ Gated Inception', 'Learned gates skip useless branches'],
434
+ ['πŸ“± Model Size', '161 KB (single) / 267 KB (dual) TFLite'],
435
+ ['🎯 Accuracy', '4.2 mm screen error (single-eye)'],
436
+ ['⏱️ Speed', '0.59 ms / 1684 FPS (CPU)'],
437
+ ]
438
+ story.append(make_table(cover_data, col_widths=[W*0.3, W*0.7]))
439
+
440
+ story.append(Spacer(1, 2*cm))
441
+ story.append(Paragraph(
442
+ 'Model: <link href="https://huggingface.co/BcantCode/GazeInceptionLite" color="#1967d2">'
443
+ 'huggingface.co/BcantCode/GazeInceptionLite</link>',
444
+ ParagraphStyle('link', parent=styles['Body'], alignment=TA_CENTER, fontSize=11)
445
+ ))
446
+
447
+ story.append(PageBreak())
448
+
449
+ # ──────────────────────────────────────────────────────────
450
+ # TABLE OF CONTENTS
451
+ # ──────────────────────────────────────────────────────────
452
+ story.append(heading1('Table of Contents'))
453
+ story.append(spacer(6))
454
+ toc_items = [
455
+ ('1', 'Problem Statement & Motivation'),
456
+ ('2', 'Literature Review & Design Decisions'),
457
+ ('3', 'Architecture Deep-Dive: Gated Inception'),
458
+ ('4', 'Coordinate Attention: Why Spatial Position Matters'),
459
+ ('5', 'Dual-Eye Architecture: Handling Lazy Eye'),
460
+ ('6', 'Training Data: Synthetic Generation & Augmentation'),
461
+ ('7', 'Training Pipeline & Hyperparameters'),
462
+ ('8', 'TFLite Conversion & Mobile Optimization'),
463
+ ('9', 'Evaluation Results & Robustness Analysis'),
464
+ ('10', 'Comparison with Prior Work'),
465
+ ('11', 'Limitations & Future Work'),
466
+ ('12', 'References'),
467
+ ]
468
+ for num, title in toc_items:
469
+ story.append(Paragraph(
470
+ f'<b>{num}.</b> {title}',
471
+ ParagraphStyle('toc', parent=styles['Body'], fontSize=11, leading=20, leftIndent=10)
472
+ ))
473
+
474
+ story.append(PageBreak())
475
+
476
+ # ══════════════════════════════════════════════════════════
477
+ # SECTION 1: PROBLEM STATEMENT
478
+ # ══════════════════════════════════════════════════════════
479
+ story.append(heading1('1. Problem Statement & Motivation'))
480
+
481
+ story.append(body(
482
+ '<b>Goal:</b> Build a model that takes a mobile phone front-camera image and predicts the '
483
+ '(x, y) screen coordinate where the user is looking. The model must:'
484
+ ))
485
+
486
+ reqs = [
487
+ '<b>Run on-device</b> β€” sub-millisecond inference on mobile CPUs/NPUs, no cloud dependency',
488
+ '<b>Be tiny</b> β€” under 300 KB TFLite model, fits in L2 cache',
489
+ '<b>Work in the dark</b> β€” low-light conditions where IR illumination is absent',
490
+ '<b>Handle glasses</b> β€” lens reflections and frame occlusions',
491
+ '<b>Handle lazy eye (strabismus)</b> β€” eyes pointing in different directions',
492
+ '<b>Reduce useless compute</b> β€” not all branches needed for every input',
493
+ ]
494
+ for r in reqs:
495
+ story.append(Paragraph(f'β€’ {r}', ParagraphStyle('bullet', parent=styles['Body'], leftIndent=20, bulletIndent=10)))
496
+
497
+ story.append(spacer(8))
498
+ story.append(why_box(
499
+ 'Traditional eye trackers use infrared LEDs and specialized cameras (e.g., Tobii). These add '
500
+ 'hardware cost and power draw. Modern phones have only a front-facing RGB camera. We need a '
501
+ 'purely appearance-based approach that works with this single camera, in all conditions. '
502
+ 'The iTracker paper (Krafka et al., CVPR 2016) showed this is feasible with CNNs, achieving '
503
+ '~2.3 cm error. Our goal is to match or improve this accuracy in a model 100Γ— smaller.'
504
+ ))
505
+
506
+ story.append(heading2('1.1 Why These Specific Challenges?'))
507
+ story.append(body(
508
+ '<b>Dark conditions:</b> Users commonly use phones in bed, in theaters, in cars at night. '
509
+ 'The AGE framework (arxiv:2603.26945) found that performance degrades 15-30% under side-lighting '
510
+ 'and low-light unless explicitly trained for it. ETH-XGaze is the only dataset with 16 controlled '
511
+ 'illumination conditions β€” the rest lack this diversity.'
512
+ ))
513
+ story.append(body(
514
+ '<b>Glasses:</b> ~64% of Americans wear corrective lenses. The AGE framework Table 3 shows glasses '
515
+ 'cause 24.4 mm X-error vs 16.0 mm ideal for their MobileNet model β€” a 52% degradation. Lens reflections '
516
+ 'occlude the iris. We need explicit glasses synthesis during training.'
517
+ ))
518
+ story.append(body(
519
+ '<b>Lazy eye (strabismus):</b> Affects 2-4% of the population. With a single-eye model, if the tracked '
520
+ 'eye has strabismus, the gaze prediction will be completely wrong. Processing both eyes independently '
521
+ 'and learning to combine them is the only robust approach. No public gaze dataset annotates strabismus.'
522
+ ))
523
+ story.append(body(
524
+ '<b>Reducing useless compute:</b> Not every input needs the same computation. A centered gaze under '
525
+ 'good lighting is "easy" β€” a single 1Γ—1 convolution branch might suffice. Extreme gaze angles under '
526
+ 'dark conditions with glasses is "hard" β€” all inception branches are needed. Gated computation lets '
527
+ 'the model adapt per-sample.'
528
+ ))
529
+
530
+ story.append(PageBreak())
531
+
532
+ # ══════════════════════════════════════════════════════════
533
+ # SECTION 2: LITERATURE REVIEW
534
+ # ══════════════════════════════════════════════════════════
535
+ story.append(heading1('2. Literature Review & Design Decisions'))
536
+
537
+ story.append(body(
538
+ 'Every design decision in GazeInception-Lite is grounded in published research. Below, we trace '
539
+ 'the reasoning chain from problem β†’ literature β†’ our specific architectural choices.'
540
+ ))
541
+
542
+ story.append(heading2('2.1 iTracker: The Foundation (Krafka et al., CVPR 2016)'))
543
+ citation('arxiv:1606.05814 β€” "Eye Tracking for Everyone" β€” 2,445,504 frames, 1,474 subjects')
544
+
545
+ story.append(body(
546
+ 'iTracker established the key insight for appearance-based mobile gaze: <b>use both eyes AND the face '
547
+ 'as separate inputs.</b> The face provides head pose context (where the head is pointing), while the '
548
+ 'eye crops provide fine-grained iris position (where the eyes are looking relative to the head). '
549
+ 'By combining these, the model disentangles head pose from eye gaze.'
550
+ ))
551
+ story.append(body(
552
+ 'iTracker uses an AlexNet-style backbone (later ResNet-50) with separate streams for left eye, '
553
+ 'right eye, and face, plus a "face grid" binary mask encoding the face location within the frame. '
554
+ 'It achieved 2.58 cm error on phones and 1.86 cm on tablets, running at 10-15 FPS on iPhone 6s.'
555
+ ))
556
+ story.append(key_insight(
557
+ '<b>What we adopted:</b> Dual-eye + face architecture with separate input streams. '
558
+ '<b>What we changed:</b> (1) Replaced AlexNet with Gated Inception for efficiency, '
559
+ '(2) Dropped the face grid (adds complexity, marginal gain), '
560
+ '(3) Used shared weights between eye streams (halves parameters, forces symmetric feature learning), '
561
+ '(4) Process eyes independently (handles strabismus).'
562
+ ))
563
+
564
+ story.append(heading2('2.2 AGE Framework: Robustness Recipe (2025)'))
565
+ citation('arxiv:2603.26945 β€” "Real-time Appearance-based Gaze Estimation for Open Domains"')
566
+
567
+ story.append(body(
568
+ 'The AGE framework is the most comprehensive modern work on making gaze estimation robust to '
569
+ 'real-world conditions. They identified three critical failure modes: (1) illumination variation, '
570
+ '(2) eyeglasses occlusion, (3) inter-dataset label deviation. Their solution:'
571
+ ))
572
+
573
+ age_data = [
574
+ ['Problem', 'AGE Solution', 'Our Adoption'],
575
+ ['Dark / side-light', 'Illumination perturbation:\nrandom gradient overlays', 'Yes β€” random directional\ngradient + warm/cool tint'],
576
+ ['Glasses', 'GlassesGAN: 300 pose-\nconsistent templates', 'Simplified: frame overlay\n+ lens reflection synthesis'],
577
+ ['Label bias', 'Stratified resampling +\ndiscretized classification', 'Uniform gaze sampling\nfrom continuous distribution'],
578
+ ['Mean collapse', 'Multi-task: regression +\nclassification + SupCon', 'MSE regression\n(synthetic data has no bias)'],
579
+ ['Architecture', 'MobileNetV2 + Coord.\nAttention (3.8M params)', 'Gated Inception + Coord.\nAttention (89K params)'],
580
+ ]
581
+ story.append(make_table(age_data, col_widths=[W*0.2, W*0.4, W*0.4]))
582
+ story.append(spacer(6))
583
+
584
+ story.append(body(
585
+ 'AGE achieved 46.3 mm overall error on their RealGaze benchmark with a 3.8M parameter MobileNetV2, '
586
+ 'competitive with UniGaze-H (632M params, 51.5 mm). The key result: <b>with their augmentation '
587
+ 'pipeline, glasses performance (46.6 mm) matched normal performance (36.6 mm ideal)</b>. This proved '
588
+ 'that augmentation-based robustness works as well as having actual data.'
589
+ ))
590
+
591
+ story.append(why_box(
592
+ 'We adopted AGE\'s augmentation philosophy: simulate failure modes during training rather than '
593
+ 'collecting hard-to-get real data. Since no public dataset has strabismus annotations, lazy eye '
594
+ 'simulation via iris displacement augmentation is our only viable approach. We also adopted their '
595
+ 'Coordinate Attention choice β€” it gives spatial awareness with minimal overhead.'
596
+ ))
597
+
598
+ story.append(heading2('2.3 Gated Compression Layers (2023)'))
599
+ citation('arxiv:2303.08970 β€” "Gated Compression Layers for Efficient Always-On Models"')
600
+
601
+ story.append(body(
602
+ 'This paper introduced the concept of <b>learned gating</b> for on-device models. The core idea: '
603
+ 'insert a trainable gate inside the network that learns to (1) early-stop "easy" samples and '
604
+ '(2) compress activations to reduce data transmission between compute stages.'
605
+ ))
606
+ story.append(body(
607
+ 'The GC layer combines a binary gate G (stops data flow) with a compression layer C (reduces '
608
+ 'activated dimensions). Key results: on ImageNet with ResNeXt-101, they achieve 82-96% early '
609
+ 'stopping of negative samples while <b>improving</b> accuracy by 1-6 percentage points over the '
610
+ 'baseline. The gate at 40% network depth stops 70-90% of unnecessary computation.'
611
+ ))
612
+ story.append(body(
613
+ 'Crucially, the Ξ± and Ξ² hyperparameters in their loss function (Eq. 4) control the trade-off between '
614
+ 'accuracy (Ξ±) and early stopping/compression (Ξ²). This gives fine-grained control: "best accuracy" mode '
615
+ 'maintains full accuracy with moderate gating, while "best tradeoff" mode aggressively gates with minimal '
616
+ 'accuracy loss.'
617
+ ))
618
+ story.append(key_insight(
619
+ '<b>Our adaptation:</b> Instead of a binary gate for early stopping (their use case is always-on '
620
+ 'keyword detection), we apply <b>soft sigmoid gates per inception branch</b>. Each branch gets a '
621
+ 'learned weight [0,1] that modulates its contribution. The gate network sees the global average of '
622
+ 'the input features and decides which branches to activate. This is trained end-to-end with the '
623
+ 'main task β€” no separate gate loss needed. Result: the model learns to use fewer branches for '
624
+ 'easy inputs, automatically reducing computation.'
625
+ ))
626
+
627
+ story.append(heading2('2.4 Inception Architecture (Szegedy et al., 2015)'))
628
+ citation('arxiv:1512.00567 β€” "Rethinking the Inception Architecture" (GoogLeNet / Inception v2-v3)')
629
+
630
+ story.append(body(
631
+ 'The Inception module processes input through parallel branches of different kernel sizes (1Γ—1, 3Γ—3, 5Γ—5) '
632
+ 'and pools them. This captures features at multiple spatial scales simultaneously. The 1Γ—1 convolutions '
633
+ 'serve as dimensionality reduction bottlenecks, keeping compute manageable.'
634
+ ))
635
+ story.append(why_box(
636
+ '<b>Why Inception for gaze estimation specifically?</b> The iris is a small structure (~14% of the 64Γ—64 '
637
+ 'eye crop). To detect iris position accurately, you need: (1) fine-grained local features from 3Γ—3 convs '
638
+ '(iris edge detection), (2) wider context from 5Γ—5 convs (iris position relative to sclera boundaries), '
639
+ 'and (3) global features from 1Γ—1 convs (overall eye appearance, lighting). Inception naturally provides '
640
+ 'all three. A standard sequential CNN would need many layers to achieve the same multi-scale receptive field, '
641
+ 'at higher parameter cost.'
642
+ ))
643
+
644
+ story.append(heading2('2.5 Coordinate Attention (Hou et al., CVPR 2021)'))
645
+ citation('arxiv:2103.02907 β€” "Coordinate Attention for Efficient Mobile Network Design"')
646
+
647
+ story.append(body(
648
+ 'Standard channel attention (SE-Net) uses Global Average Pooling to produce a single vector per channel, '
649
+ 'then learns channel weights. This <b>discards all spatial information</b>. Coordinate Attention instead '
650
+ 'uses two 1D pooling operations β€” along height and along width β€” preserving position information.'
651
+ ))
652
+ story.append(body(
653
+ 'The result is two attention maps: g_h (which rows matter) and g_w (which columns matter). Applied '
654
+ 'multiplicatively: Y = X Γ— g_h Γ— g_w. This tells the model both "what" (which channels) and "where" '
655
+ '(which spatial positions) to attend to, with nearly zero overhead (<0.1% extra FLOPs).'
656
+ ))
657
+ story.append(why_box(
658
+ '<b>Why this matters for gaze:</b> Gaze direction is encoded by the spatial position of the iris within '
659
+ 'the eye. SE-Net would collapse "iris at left" and "iris at right" into the same channel descriptor β€” '
660
+ 'losing the critical positional information. Coordinate Attention preserves it: "row 15 has high iris '
661
+ 'energy" (horizontal gaze) and "column 20 has high iris energy" (vertical gaze). This directly encodes '
662
+ 'gaze direction into the attention mechanism.'
663
+ ))
664
+
665
+ story.append(PageBreak())
666
+
667
+ # ══════════════════════════════════════════════════════════
668
+ # SECTION 3: ARCHITECTURE DEEP-DIVE
669
+ # ══════════════════════════════════════════════════════════
670
+ story.append(heading1('3. Architecture Deep-Dive: Gated Inception'))
671
+
672
+ story.append(body(
673
+ 'The Gated Inception Block is the core building block of GazeInception-Lite. It combines the '
674
+ 'multi-scale feature extraction of Inception with the conditional computation of learned gating.'
675
+ ))
676
+
677
+ story.append(spacer(6))
678
+ story.append(draw_gated_inception_diagram())
679
+ story.append(Paragraph('Figure 1: Gated Inception Block architecture. Each branch computes features at a '
680
+ 'different spatial scale. The gate network (purple) produces per-branch sigmoid '
681
+ 'weights that modulate branch contributions.', styles['Caption']))
682
+
683
+ story.append(heading2('3.1 Branch Design'))
684
+
685
+ branch_data = [
686
+ ['Branch', 'Structure', 'Receptive Field', 'Purpose'],
687
+ ['1: Point', '1Γ—1 Conv', '1Γ—1', 'Channel mixing,\nglobal appearance'],
688
+ ['2: Local', '1Γ—1 β†’ 3Γ—3 DWConv β†’ 1Γ—1', '3Γ—3', 'Local edges,\niris boundary'],
689
+ ['3: Wide', '1Γ—1 β†’ 5Γ—5 DWConv β†’ 1Γ—1', '5Γ—5', 'Iris-sclera relation,\nwider context'],
690
+ ['4: Pool', '3Γ—3 MaxPool β†’ 1Γ—1', '3Γ—3', 'Robust features,\ntranslation invariance'],
691
+ ]
692
+ story.append(make_table(branch_data, col_widths=[W*0.15, W*0.3, W*0.18, W*0.37]))
693
+ story.append(spacer(6))
694
+
695
+ story.append(body(
696
+ '<b>Depthwise Separable Convolutions</b> in branches 2 and 3 replace standard convolutions. '
697
+ 'A standard 5×5 conv with C_in→C_out channels costs C_in × C_out × 25 multiplications per pixel. '
698
+ 'Depthwise separable factorizes this into: (1) a depthwise 5Γ—5 conv (C_in Γ— 25) + (2) a pointwise '
699
+ '1Γ—1 conv (C_in Γ— C_out). For C=64, this reduces computation by ~8Γ— while maintaining expressiveness. '
700
+ 'This is the key insight from MobileNetV2 (arxiv:1801.04381).'
701
+ ))
702
+
703
+ story.append(heading2('3.2 The Gating Mechanism'))
704
+ story.append(body(
705
+ 'The gate network consists of: <b>Global Average Pooling β†’ Dense(4Γ—num_branches) β†’ ReLU β†’ Dense(num_branches) β†’ Sigmoid</b>.'
706
+ ))
707
+ story.append(body(
708
+ 'For each input sample, the gate produces 4 sigmoid values [0, 1] β€” one per branch. Each branch\'s '
709
+ 'output is multiplied by its gate value before concatenation. Gate values near 0 effectively "skip" '
710
+ 'that branch; values near 1 fully activate it.'
711
+ ))
712
+ story.append(why_box(
713
+ '<b>Why soft gates instead of hard gates?</b> Hard (binary) gates are non-differentiable and require '
714
+ 'special training (Straight-Through Estimator, Gumbel-Softmax). Soft sigmoid gates are fully '
715
+ 'differentiable and train end-to-end with standard backpropagation. The TFLite runtime cannot '
716
+ 'conditionally skip operations anyway (no dynamic branching), but the near-zero multiplications '
717
+ 'from low gate values still reduce the <i>effective</i> capacity used per sample, acting as a form '
718
+ 'of regularization that prevents overfitting on easy samples.'
719
+ ))
720
+
721
+ story.append(heading2('3.3 Network Configuration'))
722
+
723
+ config_data = [
724
+ ['Block', 'Input Size', '1Γ—1', '3Γ—3 (r/o)', '5Γ—5 (r/o)', 'Pool', 'Output Ch', 'Gate Params'],
725
+ ['Stem', '64Γ—64Γ—3', '-', '-', '-', '-', '32', '-'],
726
+ ['GI-1', '32Γ—32Γ—32', '16', '16/24', '8/12', '12', '64', '16+4=20'],
727
+ ['GI-2', '16Γ—16Γ—64', '32', '24/48', '12/24', '24', '128', '64+4=68'],
728
+ ['CoordAtt', '8Γ—8Γ—128', '-', '-', '-', '-', '128', '~12.7K'],
729
+ ['GI-3', '8Γ—8Γ—128', '48', '32/64', '16/32', '32', '176', '128+4=132'],
730
+ ['Head', '4Γ—4Γ—176', '-', '-', '-', '-', '2', '~31K'],
731
+ ]
732
+ story.append(make_table(config_data))
733
+ story.append(spacer(4))
734
+ story.append(body(
735
+ 'Total single-eye parameters: <b>89,754</b> (350 KB). After TFLite float16: <b>161 KB</b>. '
736
+ 'After INT8 quantization: <b>164 KB</b>. For comparison, iTracker\'s AlexNet backbone alone is '
737
+ '~60M parameters, and UniGaze-H is 632M.'
738
+ ))
739
+
740
+ story.append(PageBreak())
741
+
742
+ # ══════════════════════════════════════════════════════════
743
+ # SECTION 4: COORDINATE ATTENTION
744
+ # ══════════════════════════════════════════════════════════
745
+ story.append(heading1('4. Coordinate Attention: Why Spatial Position Matters'))
746
+
747
+ story.append(spacer(6))
748
+ story.append(draw_coord_attention_diagram())
749
+ story.append(Paragraph('Figure 2: Coordinate Attention encodes both horizontal and vertical spatial positions '
750
+ 'into channel attention maps, preserving "where" information that SE-Net loses.',
751
+ styles['Caption']))
752
+
753
+ story.append(heading2('4.1 The Problem with Standard Channel Attention'))
754
+ story.append(body(
755
+ 'Squeeze-and-Excitation (SE-Net, Hu et al. 2018) applies Global Average Pooling to produce a '
756
+ 'C-dimensional vector, then learns channel weights via Dense→ReLU→Dense→Sigmoid. The problem: '
757
+ 'GAP collapses the entire HΓ—W spatial map into a single number per channel. <b>Two images with '
758
+ 'iris at opposite sides of the eye produce the same channel descriptor</b> if the average intensity is the same.'
759
+ ))
760
+ story.append(body(
761
+ 'Coordinate Attention solves this by factorizing the pooling: pool along width to get HΓ—1Γ—C '
762
+ '(preserves vertical position), pool along height to get 1Γ—WΓ—C (preserves horizontal position). '
763
+ 'The paper shows +0.8% ImageNet accuracy over SE-Net with MobileNetV2, and +1.5 AP on COCO detection.'
764
+ ))
765
+
766
+ story.append(heading2('4.2 Placement in Our Architecture'))
767
+ story.append(body(
768
+ 'We place Coordinate Attention <b>between the 2nd and 3rd Gated Inception blocks</b>, at 8Γ—8 spatial '
769
+ 'resolution. At this resolution, each spatial position corresponds to an 8Γ—8 pixel region of the '
770
+ 'original 64Γ—64 eye image β€” roughly the size of the iris. The attention mechanism can then precisely '
771
+ 'weight the spatial position of the iris, directly encoding gaze direction into the feature map '
772
+ 'before the final inception block refines it.'
773
+ ))
774
+ story.append(why_box(
775
+ '<b>Why not place it earlier or later?</b> Earlier (at 32Γ—32): too much spatial detail, the attention '
776
+ 'would focus on texture rather than position. Later (at 4Γ—4): too little spatial resolution β€” only 16 '
777
+ 'positions to attend to. At 8Γ—8 (64 positions), each position is semantically meaningful (iris, sclera, '
778
+ 'eyelid, corner) and the attention can make precise spatial decisions.'
779
+ ))
780
+
781
+ story.append(PageBreak())
782
+
783
+ # ══════════════════════════════════════════════════════════
784
+ # SECTION 5: DUAL-EYE ARCHITECTURE
785
+ # ══════════════════════════════════════════════════════════
786
+ story.append(heading1('5. Dual-Eye Architecture: Handling Lazy Eye'))
787
+
788
+ story.append(spacer(6))
789
+ story.append(draw_dual_eye_pipeline())
790
+ story.append(Paragraph('Figure 3: Full dual-eye pipeline. Both eyes pass through the same backbone (shared '
791
+ 'weights) independently, then concatenate with face features for final prediction.',
792
+ styles['Caption']))
793
+
794
+ story.append(heading2('5.1 Why Process Eyes Independently?'))
795
+ story.append(body(
796
+ 'In strabismus (lazy eye), one eye may deviate significantly from the gaze target while the other '
797
+ 'tracks correctly. If we average the two eye images (as some methods do), the deviating eye corrupts '
798
+ 'the signal from the tracking eye.'
799
+ ))
800
+ story.append(body(
801
+ 'Our architecture processes each eye through the <b>same backbone with shared weights</b>, producing '
802
+ 'two independent 176-dimensional feature vectors. These are concatenated (not averaged) with a 64-dimensional '
803
+ 'face context vector, giving the fusion head a 416-dimensional input. The fusion head (128β†’64β†’2 dense layers) '
804
+ 'learns to: (1) weight the reliable eye more than the deviating one, (2) use face context for head pose compensation.'
805
+ ))
806
+ story.append(why_box(
807
+ '<b>Why shared weights?</b> Left and right eyes have the same anatomy β€” iris, pupil, sclera, eyelids. '
808
+ 'Sharing weights means the backbone learns general eye features that work for either eye, and the '
809
+ 'parameter count stays at 89K instead of doubling to 178K. The fusion head learns the <b>combination</b> '
810
+ 'asymmetry (which eye to trust more), not the feature extraction asymmetry.'
811
+ ))
812
+
813
+ story.append(heading2('5.2 Face Context Branch'))
814
+ story.append(body(
815
+ 'The face branch is intentionally lightweight: 3 Conv2D layers (16β†’32β†’32 channels) with stride 2, '
816
+ 'followed by GAP and Dense(64). It provides a <b>head pose proxy</b> β€” where the head is pointing, '
817
+ 'how the face is tilted. This is crucial because the same iris position in the eye means different '
818
+ 'screen coordinates depending on head pose.'
819
+ ))
820
+ story.append(body(
821
+ 'iTracker used a "face grid" (a 25Γ—25 binary mask of face location) for similar purpose. '
822
+ 'We replaced this with a learned face feature extractor, which captures richer information '
823
+ '(face orientation, distance from camera) without manual engineering.'
824
+ ))
825
+
826
+ story.append(heading2('5.3 Strabismus Simulation'))
827
+ story.append(body(
828
+ 'During training, 15% of samples receive strabismus augmentation. For a randomly chosen eye '
829
+ '(left or right), the iris is displaced by up to Β±40% horizontally and Β±15% vertically from '
830
+ 'the correct gaze position. This simulates esotropia (inward deviation), exotropia (outward), '
831
+ 'and vertical strabismus. The label (gaze target) remains the same β€” the model must learn to '
832
+ 'ignore the deviating eye and rely on the other.'
833
+ ))
834
+
835
+ story.append(PageBreak())
836
+
837
+ # ══════════════════════════════════════════════════════════
838
+ # SECTION 6: TRAINING DATA
839
+ # ═════════════════════════��════════════════════════════════
840
+ story.append(heading1('6. Training Data: Synthetic Generation & Augmentation'))
841
+
842
+ story.append(heading2('6.1 Why Synthetic Data?'))
843
+ story.append(body(
844
+ 'The ideal datasets for this task require special access:'
845
+ ))
846
+
847
+ dataset_data = [
848
+ ['Dataset', 'Size', 'Mobile?', 'Dark?', 'Glasses?', 'Lazy Eye?', 'Access'],
849
+ ['GazeCapture', '2.4M frames', 'βœ…', '~', '~', '❌', 'Academic license'],
850
+ ['ETH-XGaze', '1.1M frames', '❌', 'βœ… (16 lights)', 'βœ… (17 subj)', '❌', 'Academic license'],
851
+ ['MPIIFaceGaze', '45K frames', '❌', '~', '~', '❌', 'Academic license'],
852
+ ['MobilePoG', '86 GB', 'βœ…', '❌', '❌', '❌', 'βœ… HF Hub'],
853
+ ['Ours (synthetic)', '20K frames', 'βœ…', 'βœ…', 'βœ…', 'βœ…', 'Generated'],
854
+ ]
855
+ story.append(make_table(dataset_data))
856
+ story.append(spacer(6))
857
+
858
+ story.append(body(
859
+ 'No single public dataset covers all our target conditions (dark + glasses + lazy eye + mobile screen '
860
+ 'coordinates). The AGE framework (arxiv:2603.26945) demonstrated that <b>synthetic augmentation can match '
861
+ 'or exceed real data diversity</b> β€” their glasses augmentation closed the accuracy gap between glasses and '
862
+ 'non-glasses conditions from 52% to near-zero degradation.'
863
+ ))
864
+
865
+ story.append(heading2('6.2 Augmentation Pipeline'))
866
+ story.append(body(
867
+ 'Each training sample is generated with stochastic augmentations applied at the following rates:'
868
+ ))
869
+
870
+ aug_data = [
871
+ ['Augmentation', 'Probability', 'Implementation', 'Inspired By'],
872
+ ['Dark / low-light', '30%', 'Brightness Γ— [0.15, 0.5]\n+ Poisson noise + color temp shift', 'AGE: illumination\nperturbation'],
873
+ ['Glasses overlay', '25%', '10 frame styles, 5 colors\n+ lens tint + reflection', 'AGE: GlassesGAN\n(simplified)'],
874
+ ['Lazy eye', '15%', 'One eye iris displaced\nΒ±40% H, Β±15% V', 'Novel (no prior\nwork found)'],
875
+ ['Sensor noise', '50%', 'Gaussian read noise +\nshot noise + fixed pattern', 'AGE: CMOS\nnoise model'],
876
+ ['Illumination gradient', '50%', 'Random directional gradient\noverlay with random color', 'AGE: directional\nlight synthesis'],
877
+ ['Skin tone diversity', '100%', '12 skin tones (Fitzpatrick I-VI)', 'Standard demographic\nrepresentation'],
878
+ ['Eye color diversity', '100%', '7 iris colors (brown, blue,\ngreen, grey, hazel, dark)', 'Natural distribution'],
879
+ ]
880
+ story.append(make_table(aug_data, col_widths=[W*0.18, W*0.12, W*0.38, W*0.32]))
881
+
882
+ story.append(spacer(6))
883
+ story.append(heading2('6.3 Data Distribution'))
884
+ story.append(body(
885
+ 'Gaze targets are sampled uniformly from [0.05, 0.95] Γ— [0.05, 0.95] (avoiding extreme screen edges '
886
+ 'where people rarely look). The AGE framework found that non-uniform label distribution causes '
887
+ '"mean collapse" β€” predictions gravitate toward the dataset mean. Our uniform sampling avoids this '
888
+ 'without needing the stratified resampling AGE employs for real data.'
889
+ ))
890
+ story.append(body(
891
+ '<b>Dataset size:</b> 20,000 training, 2,000 validation, 2,000 test samples, plus 500 samples each '
892
+ 'for dark-only, glasses-only, and lazy-eye-only evaluation sets. Each sample produces 3 images (left eye, '
893
+ 'right eye, face) at 64Γ—64Γ—3. Total memory: ~20K Γ— 3 Γ— 64 Γ— 64 Γ— 3 Γ— 4 bytes β‰ˆ 2.9 GB.'
894
+ ))
895
+
896
+ story.append(PageBreak())
897
+
898
+ # ══════════════════════════════════════════════════════════
899
+ # SECTION 7: TRAINING PIPELINE
900
+ # ══════════════════════════════════════════════════════════
901
+ story.append(heading1('7. Training Pipeline & Hyperparameters'))
902
+
903
+ story.append(heading2('7.1 Two-Model Training Strategy'))
904
+ story.append(body(
905
+ 'We train two models independently: (1) a single-eye model for maximum speed, and (2) a dual-eye model '
906
+ 'for maximum accuracy and lazy eye robustness. Both use the same backbone architecture.'
907
+ ))
908
+
909
+ story.append(heading3('Single-Eye Model (89,754 parameters)'))
910
+ story.append(body(
911
+ 'Takes one eye crop (64Γ—64Γ—3) and predicts (x,y) screen coordinates. During training, both left and right '
912
+ 'eyes are used as separate samples (doubling effective dataset to 40K). This is valid because each eye '
913
+ 'looks at the same gaze target. At inference, you can use either eye.'
914
+ ))
915
+
916
+ story.append(heading3('Dual-Eye Model (136,922 parameters)'))
917
+ story.append(body(
918
+ 'Takes left eye + right eye + face as three separate inputs. The eyes share weights through the '
919
+ 'backbone, and the face has its own lightweight CNN. Higher accuracy at the cost of 3Γ— input processing.'
920
+ ))
921
+
922
+ story.append(heading2('7.2 Hyperparameters'))
923
+
924
+ hp_data = [
925
+ ['Hyperparameter', 'Single-Eye', 'Dual-Eye', 'Reasoning'],
926
+ ['Optimizer', 'Adam', 'Adam', 'Standard for regression tasks;\nfaster convergence than SGD'],
927
+ ['Initial LR', '2Γ—10⁻³', '2Γ—10⁻³', 'Aggressive start for fast convergence;\ncosine decay prevents overshooting'],
928
+ ['LR Schedule', 'Cosine Decay\nβ†’ 10⁻⁢', 'Cosine Decay\nβ†’ 10⁻⁢', 'Smooth decay; avoids step artifacts;\nbetter final convergence than step decay'],
929
+ ['Batch Size', '128', '64', 'Single: smaller model, can handle larger\nbatch. Dual: 3 inputs Γ— memory'],
930
+ ['Loss', 'MSE', 'MSE', 'Directly optimizes coordinate error;\nstandard for regression'],
931
+ ['Epochs', '60 (ES @ 52)', '60 (ES @ 25)', 'Early stopping patience=20;\nmodel converged well before limit'],
932
+ ['Dropout', '0.3 + 0.2', '0.3 + 0.2', 'Prevents overfitting on synthetic data;\ngraduated rates for regularization'],
933
+ ]
934
+ story.append(make_table(hp_data, col_widths=[W*0.18, W*0.16, W*0.16, W*0.5]))
935
+
936
+ story.append(spacer(6))
937
+ story.append(heading2('7.3 Training Dynamics'))
938
+ story.append(body(
939
+ '<b>Single-eye model convergence:</b>'
940
+ ))
941
+
942
+ convergence_data = [
943
+ ['Epoch', 'Train Loss', 'Val Eucl. Error', 'Event'],
944
+ ['1', '0.0189', '0.2252', 'Initial random β†’ first learning'],
945
+ ['3', '0.0032', '0.0435', '80% error reduction in 3 epochs'],
946
+ ['7', '0.0024', '0.0380', 'First major plateau'],
947
+ ['12', '0.0021', '0.0373', 'Slight improvement'],
948
+ ['32', '0.0017', '0.0362', 'Best model (early stop reference)'],
949
+ ['52', '0.0015', '0.0387', 'Early stopping triggered; restored epoch 32'],
950
+ ]
951
+ story.append(make_table(convergence_data))
952
+
953
+ story.append(spacer(6))
954
+ story.append(why_box(
955
+ '<b>Why cosine decay over step decay?</b> Step LR decay (e.g., Γ·10 at epochs 30, 50) creates abrupt '
956
+ 'changes that destabilize training. Cosine decay provides a smooth, mathematically natural reduction: '
957
+ 'LR(t) = Ξ±_min + 0.5(Ξ±_max - Ξ±_min)(1 + cos(Ο€t/T)). The warm start at 2Γ—10⁻³ enables rapid initial '
958
+ 'learning (epoch 1β†’3: 80% error reduction), while the smooth tail allows fine-grained refinement.'
959
+ ))
960
+
961
+ story.append(PageBreak())
962
+
963
+ # ══════════════════════════════════════════════════════════
964
+ # SECTION 8: TFLITE CONVERSION
965
+ # ══════════════════════════════════════════════════════════
966
+ story.append(heading1('8. TFLite Conversion & Mobile Optimization'))
967
+
968
+ story.append(heading2('8.1 Why TFLite?'))
969
+ story.append(body(
970
+ 'TensorFlow Lite is the de facto standard for on-device ML inference on Android/iOS. It supports: '
971
+ '(1) hardware acceleration via GPU, NPU, and DSP delegates, (2) INT8 quantization for 2-4Γ— speedup, '
972
+ '(3) model sizes under 1 MB that fit in L2 cache. Alternatives like ONNX Runtime Mobile exist but '
973
+ 'have smaller mobile ecosystem support.'
974
+ ))
975
+
976
+ story.append(heading2('8.2 Quantization Strategy'))
977
+ story.append(body(
978
+ 'We produce four model variants to cover different deployment scenarios:'
979
+ ))
980
+
981
+ quant_data = [
982
+ ['Variant', 'Input Type', 'Weights', 'Activations', 'Size', 'Speed', 'Use Case'],
983
+ ['Single F16', 'float32', 'float16', 'float16', '161 KB', '0.59ms', 'Dev/debugging;\nfloat GPU delegate'],
984
+ ['Single INT8', 'uint8', 'int8', 'int8', '164 KB', '0.62ms', 'Production;\nNPU/DSP delegate'],
985
+ ['Dual F16', 'float32', 'float16', 'float16', '242 KB', '1.50ms', 'Accuracy-first;\nfloat GPU delegate'],
986
+ ['Dual INT8', 'uint8', 'int8', 'int8', '267 KB', '0.93ms', 'Best accuracy+speed;\nNPU/DSP delegate'],
987
+ ]
988
+ story.append(make_table(quant_data))
989
+
990
+ story.append(spacer(6))
991
+ story.append(heading2('8.3 INT8 Calibration'))
992
+ story.append(body(
993
+ 'Full integer quantization requires a <b>representative calibration dataset</b> to determine the '
994
+ 'dynamic range of each activation tensor. We use 200 test samples spanning all conditions (normal, '
995
+ 'dark, glasses, lazy eye) as calibration data. The TFLite converter then maps float32 ranges to '
996
+ '[0, 255] (uint8 input) and [-128, 127] (int8 weights/activations).'
997
+ ))
998
+ story.append(body(
999
+ 'The accuracy loss from quantization is minimal: single-eye error goes from 4.24 mm (F16) to 4.27 mm '
1000
+ '(INT8) β€” only 0.7% degradation. This is because our model has relatively few parameters and the '
1001
+ 'activations have well-behaved distributions (sigmoid outputs in [0,1], ReLU outputs β‰₯ 0).'
1002
+ ))
1003
+ story.append(why_box(
1004
+ '<b>Why INT8 is faster even on CPU:</b> Modern ARM CPUs have NEON SIMD units that process four int8 '
1005
+ 'operations in the same cycle as one float32 operation. On mobile NPUs (Qualcomm Hexagon, Apple ANE, '
1006
+ 'MediaTek APU), INT8 is the native precision β€” enabling 10-50Γ— speedup over CPU float32. Our model\'s '
1007
+ '164 KB INT8 size fits entirely in the L2 cache of most mobile SoCs, avoiding slow DRAM accesses.'
1008
+ ))
1009
+
1010
+ story.append(PageBreak())
1011
+
1012
+ # ══════════════════════════════════════════════════════════
1013
+ # SECTION 9: EVALUATION RESULTS
1014
+ # ══════════════════════════════════════════════════════════
1015
+ story.append(heading1('9. Evaluation Results & Robustness Analysis'))
1016
+
1017
+ story.append(heading2('9.1 Overall Performance'))
1018
+
1019
+ results_data = [
1020
+ ['Model', 'Eucl. Error', 'Screen Error', 'Screen Error', 'Inference', 'FPS'],
1021
+ ['', '(normalized)', '(mm)', '(cm)', '(ms)', '(CPU)'],
1022
+ ['Single Eye F16', '0.0376', '4.2 mm', '0.42 cm', '0.59', '1,684'],
1023
+ ['Single Eye INT8', '0.0378', '4.3 mm', '0.43 cm', '0.62', '1,619'],
1024
+ ['Dual Eye F16', '0.1299', '14.2 mm', '1.42 cm', '1.50', '666'],
1025
+ ['Dual Eye INT8', '0.1307', '14.3 mm', '1.43 cm', '0.93', '1,070'],
1026
+ ]
1027
+ story.append(make_table(results_data))
1028
+
1029
+ story.append(spacer(6))
1030
+ story.append(body(
1031
+ 'The single-eye model achieves <b>4.2 mm screen error</b> β€” meaning the predicted gaze point is on '
1032
+ 'average 4.2 mm away from the true gaze target on a typical phone screen (65mm Γ— 140mm). For context, '
1033
+ 'a typical phone icon is about 10-15 mm wide, so this accuracy is sufficient for icon-level targeting.'
1034
+ ))
1035
+ story.append(body(
1036
+ '<b>Note on dual-eye performance:</b> The dual-eye model shows higher error (14.2 mm) than single-eye '
1037
+ '(4.2 mm). This is because the dual model has a harder task β€” combining three inputs through fusion β€” '
1038
+ 'and the synthetic face data provides limited head pose variation. With real face data (e.g., GazeCapture), '
1039
+ 'the dual model would outperform single-eye. The dual model\'s strength is robustness to lazy eye, not absolute accuracy on synthetic data.'
1040
+ ))
1041
+
1042
+ story.append(heading2('9.2 Robustness Analysis (Dual-Eye Model)'))
1043
+
1044
+ robust_data = [
1045
+ ['Condition', 'Screen Error', 'vs Normal', 'Interpretation'],
1046
+ ['Normal (mixed)', '14.2 mm', 'baseline', 'Mixed conditions reference'],
1047
+ ['Dark / Low-light', '13.8 mm', '-2.8% βœ…', 'Illumination augmentation works;\nmodel is lighting-invariant'],
1048
+ ['With Glasses', '13.9 mm', '-2.1% βœ…', 'Glasses overlay training works;\nmodel sees through reflections'],
1049
+ ['Lazy Eye', '13.5 mm', '-5.0% βœ…', 'Strabismus augmentation works;\nmodel learns to rely on good eye'],
1050
+ ]
1051
+ story.append(make_table(robust_data, col_widths=[W*0.2, W*0.17, W*0.15, W*0.48]))
1052
+
1053
+ story.append(spacer(6))
1054
+ story.append(key_insight(
1055
+ 'All challenging conditions perform <b>equal to or better than</b> the mixed baseline. This validates '
1056
+ 'our augmentation-driven robustness approach. The slight improvement under challenging conditions suggests '
1057
+ 'that the augmentations also act as regularization β€” reducing overfitting to "easy" patterns in normal data. '
1058
+ 'This matches findings from the AGE framework where augmented models showed minimal degradation '
1059
+ 'under side-lighting and glasses conditions.'
1060
+ ))
1061
+
1062
+ story.append(heading2('9.3 Speed Analysis'))
1063
+ story.append(body(
1064
+ 'All timings measured on CPU (server-grade, not mobile). Mobile timings would be different:'
1065
+ ))
1066
+
1067
+ speed_data = [
1068
+ ['Platform', 'Est. Single INT8', 'Est. Dual INT8', 'Notes'],
1069
+ ['CPU (measured)', '0.62 ms', '0.93 ms', 'Server CPU, XNNPACK delegate'],
1070
+ ['Mobile CPU (est.)', '2-5 ms', '5-12 ms', 'ARM Cortex-A78, NEON SIMD'],
1071
+ ['Mobile GPU (est.)', '1-2 ms', '3-5 ms', 'Adreno/Mali GPU delegate'],
1072
+ ['Mobile NPU (est.)', '0.5-1 ms', '1-3 ms', 'Hexagon/ANE, native INT8'],
1073
+ ]
1074
+ story.append(make_table(speed_data, col_widths=[W*0.22, W*0.22, W*0.22, W*0.34]))
1075
+
1076
+ story.append(spacer(6))
1077
+ story.append(body(
1078
+ 'Even on mobile CPU (worst case), the single-eye INT8 model should achieve 200-500 FPS β€” vastly '
1079
+ 'exceeding the 30-60 FPS needed for real-time gaze tracking. The bottleneck in a real application '
1080
+ 'would be the face/eye detection step (MediaPipe Face Mesh: ~5-10 ms), not our gaze regression.'
1081
+ ))
1082
+
1083
+ story.append(PageBreak())
1084
+
1085
+ # ══════════════════════════════════════════════════════════
1086
+ # SECTION 10: COMPARISON WITH PRIOR WORK
1087
+ # ══════════════════════════════════════════════════════════
1088
+ story.append(heading1('10. Comparison with Prior Work'))
1089
+
1090
+ comp_data = [
1091
+ ['Model', 'Params', 'Size', 'Error*', 'Speed', 'Dark', 'Glasses', 'Lazy Eye'],
1092
+ ['iTracker (2016)', '60M', '~240 MB', '23 mm', '10-15 FPS', '❌', '~', '❌'],
1093
+ ['UniGaze-B (2025)', '86.6M', '~350 MB', '52.8 mm†', 'Offline', '~', '63.8 mm†', '❌'],
1094
+ ['UniGaze-H (2025)', '632M', '~2.5 GB', '51.5 mm†', 'Offline', '~', '59.0 mm†', '❌'],
1095
+ ['AGE MobileNet (2025)', '3.8M', '~15 MB', '46.3 mm†', 'Real-time', '37.0 mm†', '46.6 mm†', '❌'],
1096
+ ['Ours Single Eye', '90K', '161 KB', '4.2 mm‑', '1,684 FPS', 'βœ…', 'βœ…', '❌'],
1097
+ ['Ours Dual Eye', '137K', '267 KB', '14.2 mm‑', '1,070 FPS', 'βœ…', 'βœ…', 'βœ…'],
1098
+ ]
1099
+ story.append(make_table(comp_data))
1100
+
1101
+ story.append(spacer(4))
1102
+ story.append(Paragraph(
1103
+ '* Errors measured on different benchmarks and are not directly comparable. '
1104
+ '† RealGaze benchmark (mm at tablet distance). ‑ Synthetic test set (mm at phone distance). '
1105
+ 'Our synthetic data results are optimistic; real-world error would be higher.',
1106
+ styles['Caption']
1107
+ ))
1108
+
1109
+ story.append(spacer(6))
1110
+ story.append(body(
1111
+ '<b>Key advantages of GazeInception-Lite:</b>'
1112
+ ))
1113
+ advantages = [
1114
+ '<b>1,600Γ— smaller</b> than iTracker (161 KB vs 240 MB) while targeting similar mobile use case',
1115
+ '<b>Only model with explicit lazy eye support</b> β€” dual-eye independent processing + strabismus training',
1116
+ '<b>Only model with dark condition training</b> β€” AGE uses illumination augmentation but for gaze angle, not screen coordinates',
1117
+ '<b>Fastest inference</b> β€” sub-millisecond on CPU, 1000+ FPS, enabling always-on tracking',
1118
+ '<b>TFLite native</b> β€” ready for Android/iOS deployment with no conversion needed',
1119
+ ]
1120
+ for a in advantages:
1121
+ story.append(Paragraph(f'β€’ {a}', ParagraphStyle('bullet', parent=styles['Body'], leftIndent=20, bulletIndent=10)))
1122
+
1123
+ story.append(spacer(6))
1124
+ story.append(body(
1125
+ '<b>Limitations of comparison:</b> Our model is evaluated on synthetic data. Real-world accuracy would '
1126
+ 'likely be worse due to domain gap between synthetic and real eye images. Fine-tuning on GazeCapture '
1127
+ '(2.4M real frames, 1,474 subjects) would close this gap and enable fair comparison.'
1128
+ ))
1129
+
1130
+ story.append(PageBreak())
1131
+
1132
+ # ══════════════════════════════════════════════════════════
1133
+ # SECTION 11: LIMITATIONS & FUTURE WORK
1134
+ # ══════════════════════════════════════════════════════════
1135
+ story.append(heading1('11. Limitations & Future Work'))
1136
+
1137
+ story.append(heading2('11.1 Current Limitations'))
1138
+
1139
+ limitations = [
1140
+ ('<b>Synthetic data gap:</b> The model is trained purely on synthetic data. Real eye images have '
1141
+ 'vastly more variability in texture, lighting, and geometry. Fine-tuning on real data (GazeCapture, '
1142
+ 'ETH-XGaze) is essential before production deployment.'),
1143
+ ('<b>No calibration:</b> The current model is calibration-free (one model for all users). '
1144
+ 'Adding a per-user calibration step (even just 5-9 points) typically reduces error by 30-50% '
1145
+ '(MobilePoG, arxiv:2508.10268).'),
1146
+ ('<b>No face/eye detection:</b> The model assumes pre-cropped eye and face inputs. In a real '
1147
+ 'application, you need MediaPipe Face Mesh or a similar detector to extract these crops.'),
1148
+ ('<b>No temporal modeling:</b> Each frame is processed independently. Real eye tracking systems '
1149
+ 'use Kalman filtering or temporal smoothing to reduce jitter between frames.'),
1150
+ ('<b>No depth/distance modeling:</b> The model does not account for the distance between the '
1151
+ 'phone and the face, which affects the mapping from eye angle to screen position.'),
1152
+ ]
1153
+ for l in limitations:
1154
+ story.append(Paragraph(f'β€’ {l}', ParagraphStyle('bullet', parent=styles['Body'], leftIndent=20, bulletIndent=10)))
1155
+
1156
+ story.append(heading2('11.2 Future Work'))
1157
+
1158
+ future = [
1159
+ ('<b>Fine-tune on GazeCapture:</b> Transfer learning from our backbone to the 2.4M-frame '
1160
+ 'GazeCapture dataset. Expected to reduce error to 1.5-2.5 cm range.'),
1161
+ ('<b>Add person-specific calibration:</b> Use 5-9 calibration points to fit a linear mapping '
1162
+ 'from model predictions to screen coordinates per user.'),
1163
+ ('<b>Temporal smoothing:</b> Add a lightweight LSTM or Kalman filter on top of frame-level '
1164
+ 'predictions for smoother, more stable gaze trajectories.'),
1165
+ ('<b>Dynamic gating analysis:</b> Visualize which inception branches activate for which '
1166
+ 'input conditions β€” do easy inputs really use fewer branches?'),
1167
+ ('<b>Real strabismus validation:</b> Evaluate on actual strabismus patients to validate '
1168
+ 'that the lazy eye simulation transfers to clinical reality.'),
1169
+ ('<b>Knowledge distillation:</b> Train our model as a student of a larger teacher (e.g., '
1170
+ 'UniGaze-H, 632M params) to inherit knowledge from real data without increasing model size.'),
1171
+ ]
1172
+ for f in future:
1173
+ story.append(Paragraph(f'β€’ {f}', ParagraphStyle('bullet', parent=styles['Body'], leftIndent=20, bulletIndent=10)))
1174
+
1175
+ story.append(PageBreak())
1176
+
1177
+ # ══════════════════════════════════════════════════════════
1178
+ # SECTION 12: REFERENCES
1179
+ # ══════════════════════════════════════════════════════════
1180
+ story.append(heading1('12. References'))
1181
+
1182
+ refs = [
1183
+ ('[1] Krafka, K., et al. "Eye Tracking for Everyone." CVPR 2016. arxiv:1606.05814. '
1184
+ 'β€” Foundation: dual-eye + face architecture, GazeCapture dataset (2.4M frames, 1,474 subjects).'),
1185
+ ('[2] Real-time AGE Framework. arxiv:2603.26945, March 2025. '
1186
+ 'β€” Augmentation pipeline (GlassesGAN, illumination perturbation, CMOS noise), '
1187
+ 'MobileNetV2 + Coordinate Attention (3.8M params, 46.3mm on RealGaze).'),
1188
+ ('[3] Gated Compression Layers. arxiv:2303.08970, 2023. '
1189
+ 'β€” Learned gating mechanism for always-on models. GC layers stop 82-96% of unnecessary '
1190
+ 'computation while improving accuracy by 1-6 percentage points.'),
1191
+ ('[4] Hou, Q., et al. "Coordinate Attention for Efficient Mobile Network Design." CVPR 2021. '
1192
+ 'arxiv:2103.02907. β€” Spatial-aware channel attention using 1D pooling factorization.'),
1193
+ ('[5] Sandler, M., et al. "MobileNetV2: Inverted Residuals and Linear Bottlenecks." CVPR 2018. '
1194
+ 'arxiv:1801.04381. β€” Depthwise separable convolutions, inverted residual blocks.'),
1195
+ ('[6] Szegedy, C., et al. "Rethinking the Inception Architecture." CVPR 2016. '
1196
+ 'arxiv:1512.00567. β€” Multi-scale parallel convolution branches (Inception module).'),
1197
+ ('[7] Zhang, X., et al. "ETH-XGaze: A Large Scale Dataset for Gaze Estimation." ECCV 2020. '
1198
+ 'arxiv:2007.15837. β€” 1.1M images, 110 subjects, 16 illumination conditions, glasses metadata.'),
1199
+ ('[8] Cheng, Y., et al. "UniGaze: Towards Universal Gaze Estimation." arxiv:2502.02307, 2025. '
1200
+ 'β€” SOTA cross-domain gaze estimation using ViT-H (632M params).'),
1201
+ ('[9] Zhao, Y., et al. "MobilePoG: Mobile Point-of-Gaze." BMVC 2025. arxiv:2508.10268. '
1202
+ 'β€” Mobile-specific PoG benchmark showing calibration importance for mobile gaze.'),
1203
+ ('[10] Hu, J., et al. "Squeeze-and-Excitation Networks." CVPR 2018. '
1204
+ 'β€” Channel attention via global average pooling (predecessor to Coordinate Attention).'),
1205
+ ('[11] Google. "TensorFlow Lite: Deploy ML on Mobile and Edge Devices." tensorflow.org/lite. '
1206
+ 'β€” Model quantization framework (float16, INT8, dynamic range).'),
1207
+ ]
1208
+ for r in refs:
1209
+ story.append(Paragraph(r, ParagraphStyle('ref', parent=styles['Body'], fontSize=9, leading=14, leftIndent=30, firstLineIndent=-30, spaceAfter=8)))
1210
+
1211
+ story.append(Spacer(1, 2*cm))
1212
+ story.append(HRFlowable(width='100%', thickness=1, color=BORDER))
1213
+ story.append(spacer(8))
1214
+ story.append(Paragraph(
1215
+ 'Generated for <b>BcantCode/GazeInceptionLite</b> β€” '
1216
+ '<link href="https://huggingface.co/BcantCode/GazeInceptionLite" color="#1967d2">'
1217
+ 'https://huggingface.co/BcantCode/GazeInceptionLite</link>',
1218
+ ParagraphStyle('end', parent=styles['Body'], alignment=TA_CENTER, fontSize=10)
1219
+ ))
1220
+
1221
+ # ──────────────────────────────────────────────────────────
1222
+ # Build
1223
+ # ──────────────────────────────────────────────────────────
1224
+ doc.build(story)
1225
+ print(f"βœ… PDF generated: {output_path}")
1226
+ print(f" Size: {os.path.getsize(output_path) / 1024:.1f} KB")
1227
+
1228
+
1229
+ if __name__ == '__main__':
1230
+ build_pdf()