diff --git a/README.md b/README.md index 267bae6..aae9350 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@
-GRIDPRINT: the power grid is the witness - # GRIDPRINT **Checks that a video is real by reading the power grid in the flicker of the room lights.** diff --git a/docs/VIDEO_SCRIPT.md b/docs/VIDEO_SCRIPT.md index a47ff8a..be0233e 100644 --- a/docs/VIDEO_SCRIPT.md +++ b/docs/VIDEO_SCRIPT.md @@ -1,9 +1,9 @@ # GRIDPRINT submission video: script -Generated by `video/make_pitch.py`. Voice: edge-tts `en-US-AndrewNeural` (rate +0%). Duration: 195.4 s. +Generated by `video/make_pitch.py`. Voice: edge-tts `en-US-AndrewNeural` (rate +0%). Duration: 223.2 s. Style: ASD-STE100 Simplified Technical English (short sentences, active voice, one idea per sentence). -Sources on screen: live AWS app screenshots (`video/assets/live/`, captured with headless Chrome), `data/public/fluorescent_beat_effect.mp4` (Wikimedia Commons, CC BY-SA 3.0), engine outputs in `results/video/engine_runs/`, agent trace `results/agent_traces/clean__s1001.json`, benchmark `results/bench/2026-10-27/`. +Sources on screen: live AWS app screenshots (`video/assets/live/`, captured with headless Chrome), `data/public/fluorescent_beat_effect.mp4` (Wikimedia Commons, CC BY-SA 3.0), engine outputs in `results/video/engine_runs/`, agent trace `results/agent_traces/clean__s1001.json`, benchmark `results/bench/2026-10-27/`, real iPhone 13 clip `data/real/phone2.mov` with engine run `results/video/engine_runs/real-phone2/` and live job `cd65b6ea0c20`. ## 01 THE PROBLEM / hook (00:00:00 - 00:00:07) @@ -54,53 +54,65 @@ Sources on screen: live AWS app screenshots (`video/assets/live/`, captured with - Then it slides that track along the grid reference log. - One clear match gives a recording time. A cut or an insertion breaks the match. -## 04 THE PRODUCT / home (00:01:27 - 00:01:36) +## 04 REAL EVIDENCE / realclip (00:01:27 - 00:01:36) + +- We filmed a real room with a normal phone. +- This is an iPhone 13 video, recorded today under indoor mains light in India. + +## 04 REAL EVIDENCE / realrun (00:01:36 - 00:01:45) + +- The OpenCV 5 engine measured the full clip. +- It found no clean flicker in this clip. +- So GRIDPRINT said INCONCLUSIVE. It did not guess. + +## 04 REAL EVIDENCE / reallive (00:01:45 - 00:01:56) + +- We also ran the same clip live on AWS. +- Then the live agent asked the user one question: which camera made this video? +- A missing signal never counts against a video. + +## 05 THE PRODUCT / home (00:01:56 - 00:02:05) - GRIDPRINT runs live on AWS today. This is the web app. - You upload a video. You can add a claimed grid zone and a claimed recording time. -## 04 THE PRODUCT / cases (00:01:36 - 00:01:42) +## 05 THE PRODUCT / cases (00:02:05 - 00:02:12) - Ready-made example cases run through the same live pipeline. - One click starts a full analysis job. -## 04 THE PRODUCT / job (00:01:42 - 00:01:55) +## 05 THE PRODUCT / job (00:02:12 - 00:02:24) - Here is a finished job. The verdict is VERIFIED, and the report is final. - The page shows the estimated recording time and its uncertainty. - Each verdict comes with reason codes in plain words. -## 04 THE PRODUCT / trace (00:01:55 - 00:02:02) +## 05 THE PRODUCT / trace (00:02:24 - 00:02:32) - An agent plans each analysis. - It calls engine tools through an MCP server, and it logs every step. -## 04 THE PRODUCT / reviewer (00:02:02 - 00:02:09) +## 05 THE PRODUCT / reviewer (00:02:32 - 00:02:39) - A CONTRADICTED result is never final on its own. - A human reviewer must approve it first. -## 04 THE PRODUCT / limits (00:02:09 - 00:02:15) - -- The limitations page tells users what GRIDPRINT cannot do. -- Clear limits are part of the product. - -## 05 BUILT ON AWS / arch (00:02:15 - 00:02:48) +## 06 BUILT ON AWS / arch (00:02:39 - 00:03:20) -- A video arrives through API Gateway and a load balancer. -- An API service on Fargate stores it in S3 with KMS keys, and puts a job on an SQS queue. -- Workers on Graviton4 run the OpenCV 5 engine in fifteen stages. +- A video arrives through API Gateway and an Application Load Balancer. +- An API service on AWS Fargate stores it in S3 with KMS keys, and puts a job on an SQS queue. +- Workers on Amazon ECS with Graviton4 run the OpenCV 5 engine in fifteen stages. - An agent with twelve MCP tools uses Amazon Nova Lite on Bedrock to plan each run, inside strict policies. -- DynamoDB keeps jobs and the grid log. IoT Core brings in grid data. Cognito signs in reviewers. +- DynamoDB keeps jobs and the grid log. IoT Core and Lambda bring in grid data. +- Cognito signs in reviewers. CloudWatch watches the system, and the AWS CDK deploys it all. -## 05 BUILT ON AWS / bench (00:02:48 - 00:02:58) +## 06 BUILT ON AWS / bench (00:03:20 - 00:03:29) - We measured the same engine on Graviton and on x86. - Graviton processed more video per second, at a lower cost per video hour. -## 06 THE VERDICT / close (00:02:58 - 00:03:14) +## 07 THE VERDICT / close (00:03:29 - 00:03:42) - GRIDPRINT gives exactly three verdicts: VERIFIED, CONTRADICTED, or INCONCLUSIVE. - A missing signal never counts against a video. -- It needs mains lighting, and many phone videos will be INCONCLUSIVE. - GRIDPRINT. Evidence from the light in the room. diff --git a/docs/devpost/thumbnail.png b/docs/devpost/thumbnail.png deleted file mode 100644 index 0d80429..0000000 Binary files a/docs/devpost/thumbnail.png and /dev/null differ diff --git a/docs/media/01_title.png b/docs/media/01_title.png new file mode 100644 index 0000000..5a87bff Binary files /dev/null and b/docs/media/01_title.png differ diff --git a/docs/media/02_problem.png b/docs/media/02_problem.png new file mode 100644 index 0000000..dc35425 Binary files /dev/null and b/docs/media/02_problem.png differ diff --git a/docs/media/03_how_it_works.png b/docs/media/03_how_it_works.png new file mode 100644 index 0000000..4d680e4 Binary files /dev/null and b/docs/media/03_how_it_works.png differ diff --git a/docs/media/04_real_run.png b/docs/media/04_real_run.png new file mode 100644 index 0000000..6137d9f Binary files /dev/null and b/docs/media/04_real_run.png differ diff --git a/docs/media/05_live_app_verdict.png b/docs/media/05_live_app_verdict.png new file mode 100644 index 0000000..6c28641 Binary files /dev/null and b/docs/media/05_live_app_verdict.png differ diff --git a/docs/media/06_architecture.png b/docs/media/06_architecture.png new file mode 100644 index 0000000..5febb5a Binary files /dev/null and b/docs/media/06_architecture.png differ diff --git a/results/video/duration.json b/results/video/duration.json index ed60261..0102d9f 100644 --- a/results/video/duration.json +++ b/results/video/duration.json @@ -1,8 +1,8 @@ { "file": "gridprint_submission.mp4", - "sha256": "58a9b27aaf043511cd3a3b179575b8263b2956375897c00b4a52a095e98f0e8b", - "duration_s": 195.433333, + "sha256": "5260e9f06fb10c3d252ecb9349b344d76d92910ebf210db9fdea1f8c4989c188", + "duration_s": 223.166667, "limit_s": 300, "ok": true, - "checked": "2026-10-27T05:24:38.693595+00:00" + "checked": "2026-10-27T06:31:05.694934+00:00" } \ No newline at end of file diff --git a/results/video/gridprint_submission.srt b/results/video/gridprint_submission.srt index 3db3c85..00403a6 100644 --- a/results/video/gridprint_submission.srt +++ b/results/video/gridprint_submission.srt @@ -99,98 +99,122 @@ Then it slides that track along the grid reference log. One clear match gives a recording time. A cut or an insertion breaks the match. 26 -00:01:27,494 --> 00:01:31,284 -GRIDPRINT runs live on AWS today. This is the web app. +00:01:27,494 --> 00:01:29,609 +We filmed a real room with a normal phone. 27 -00:01:31,704 --> 00:01:35,764 -You upload a video. You can add a claimed grid zone and a claimed recording time. +00:01:30,029 --> 00:01:35,507 +This is an iPhone 13 video, recorded today under indoor mains light in India. 28 -00:01:36,764 --> 00:01:39,953 -Ready-made example cases run through the same live pipeline. +00:01:36,507 --> 00:01:39,272 +The OpenCV 5 engine measured the full clip. 29 -00:01:40,373 --> 00:01:42,321 -One click starts a full analysis job. +00:01:39,692 --> 00:01:41,513 +It found no clean flicker in this clip. 30 -00:01:43,321 --> 00:01:47,573 -Here is a finished job. The verdict is VERIFIED, and the report is final. +00:01:41,933 --> 00:01:45,254 +So GRIDPRINT said INCONCLUSIVE. It did not guess. 31 -00:01:47,993 --> 00:01:51,500 -The page shows the estimated recording time and its uncertainty. +00:01:46,254 --> 00:01:48,774 +We also ran the same clip live on AWS. 32 -00:01:51,920 --> 00:01:54,722 -Each verdict comes with reason codes in plain words. +00:01:49,194 --> 00:01:53,437 +Then the live agent asked the user one question: which camera made this video? 33 -00:01:55,722 --> 00:01:57,659 -An agent plans each analysis. +00:01:53,857 --> 00:01:56,118 +A missing signal never counts against a video. 34 -00:01:58,079 --> 00:02:02,353 -It calls engine tools through an MCP server, and it logs every step. +00:01:57,118 --> 00:02:00,908 +GRIDPRINT runs live on AWS today. This is the web app. 35 -00:02:03,353 --> 00:02:06,252 -A CONTRADICTED result is never final on its own. +00:02:01,328 --> 00:02:05,388 +You upload a video. You can add a claimed grid zone and a claimed recording time. 36 -00:02:06,672 --> 00:02:08,781 -A human reviewer must approve it first. +00:02:06,388 --> 00:02:09,577 +Ready-made example cases run through the same live pipeline. 37 -00:02:09,781 --> 00:02:13,295 -The limitations page tells users what GRIDPRINT cannot do. +00:02:09,997 --> 00:02:11,945 +One click starts a full analysis job. 38 -00:02:13,715 --> 00:02:15,254 -Clear limits are part of the product. +00:02:12,945 --> 00:02:17,197 +Here is a finished job. The verdict is VERIFIED, and the report is final. 39 -00:02:16,254 --> 00:02:19,867 -A video arrives through API Gateway and a load balancer. +00:02:17,617 --> 00:02:21,123 +The page shows the estimated recording time and its uncertainty. 40 -00:02:20,287 --> 00:02:27,030 -An API service on Fargate stores it in S3 with KMS keys, and puts a job on an SQS queue. +00:02:21,543 --> 00:02:24,345 +Each verdict comes with reason codes in plain words. 41 -00:02:27,450 --> 00:02:32,542 -Workers on Graviton4 run the OpenCV 5 engine in fifteen stages. +00:02:25,345 --> 00:02:27,283 +An agent plans each analysis. 42 -00:02:32,962 --> 00:02:40,429 -An agent with twelve MCP tools uses Amazon Nova Lite on Bedrock to plan each run, inside strict policies. +00:02:27,703 --> 00:02:31,977 +It calls engine tools through an MCP server, and it logs every step. 43 -00:02:40,849 --> 00:02:47,867 -DynamoDB keeps jobs and the grid log. IoT Core brings in grid data. Cognito signs in reviewers. +00:02:32,977 --> 00:02:35,876 +A CONTRADICTED result is never final on its own. 44 -00:02:48,867 --> 00:02:52,443 -We measured the same engine on Graviton and on x86. +00:02:36,296 --> 00:02:38,404 +A human reviewer must approve it first. 45 -00:02:52,863 --> 00:02:57,478 -Graviton processed more video per second, at a lower cost per video hour. +00:02:39,404 --> 00:02:43,734 +A video arrives through API Gateway and an Application Load Balancer. 46 -00:02:58,478 --> 00:03:04,149 -GRIDPRINT gives exactly three verdicts: VERIFIED, CONTRADICTED, or INCONCLUSIVE. +00:02:44,154 --> 00:02:51,621 +An API service on AWS Fargate stores it in S3 with KMS keys, and puts a job on an SQS queue. 47 -00:03:04,569 --> 00:03:06,830 -A missing signal never counts against a video. +00:02:52,041 --> 00:02:58,525 +Workers on Amazon ECS with Graviton4 run the OpenCV 5 engine in fifteen stages. 48 -00:03:07,250 --> 00:03:10,866 -It needs mains lighting, and many phone videos will be INCONCLUSIVE. +00:02:58,945 --> 00:03:06,412 +An agent with twelve MCP tools uses Amazon Nova Lite on Bedrock to plan each run, inside strict policies. 49 -00:03:11,286 --> 00:03:13,837 +00:03:06,832 --> 00:03:12,693 +DynamoDB keeps jobs and the grid log. IoT Core and Lambda bring in grid data. + +50 +00:03:13,113 --> 00:03:19,648 +Cognito signs in reviewers. CloudWatch watches the system, and the AWS CDK deploys it all. + +51 +00:03:20,648 --> 00:03:24,224 +We measured the same engine on Graviton and on x86. + +52 +00:03:24,644 --> 00:03:29,259 +Graviton processed more video per second, at a lower cost per video hour. + +53 +00:03:30,259 --> 00:03:35,931 +GRIDPRINT gives exactly three verdicts: VERIFIED, CONTRADICTED, or INCONCLUSIVE. + +54 +00:03:36,351 --> 00:03:38,612 +A missing signal never counts against a video. + +55 +00:03:39,032 --> 00:03:41,582 GRIDPRINT. Evidence from the light in the room. diff --git a/video/make_pitch.py b/video/make_pitch.py index 48d3ad7..f529ebb 100644 --- a/video/make_pitch.py +++ b/video/make_pitch.py @@ -97,7 +97,17 @@ def lerp(a, b, u): "Then it slides that track along the grid reference log.", "One clear match gives a recording time. A cut or an insertion breaks the match."]), ]), - ("04", "THE PRODUCT", [ + ("04", "REAL EVIDENCE", [ + ("realclip", ["We filmed a real room with a normal phone.", + "This is an iPhone 13 video, recorded today under indoor mains light in India."]), + ("realrun", ["The OpenCV 5 engine measured the full clip.", + "It found no clean flicker in this clip.", + "So GRIDPRINT said INCONCLUSIVE. It did not guess."]), + ("reallive", ["We also ran the same clip live on AWS.", + "LIVE_SENTENCE", + "A missing signal never counts against a video."]), + ]), + ("05", "THE PRODUCT", [ ("home", ["GRIDPRINT runs live on AWS today. This is the web app.", "You upload a video. You can add a claimed grid zone and a claimed recording time."]), ("cases", ["Ready-made example cases run through the same live pipeline.", @@ -109,22 +119,20 @@ def lerp(a, b, u): "It calls engine tools through an MCP server, and it logs every step."]), ("reviewer", ["A CONTRADICTED result is never final on its own.", "A human reviewer must approve it first."]), - ("limits", ["The limitations page tells users what GRIDPRINT cannot do.", - "Clear limits are part of the product."]), ]), - ("05", "BUILT ON AWS", [ - ("arch", ["A video arrives through API Gateway and a load balancer.", - "An API service on Fargate stores it in S3 with KMS keys, and puts a job on an SQS queue.", - "Workers on Graviton4 run the OpenCV 5 engine in fifteen stages.", + ("06", "BUILT ON AWS", [ + ("arch", ["A video arrives through API Gateway and an Application Load Balancer.", + "An API service on AWS Fargate stores it in S3 with KMS keys, and puts a job on an SQS queue.", + "Workers on Amazon ECS with Graviton4 run the OpenCV 5 engine in fifteen stages.", "An agent with twelve MCP tools uses Amazon Nova Lite on Bedrock to plan each run, inside strict policies.", - "DynamoDB keeps jobs and the grid log. IoT Core brings in grid data. Cognito signs in reviewers."]), + "DynamoDB keeps jobs and the grid log. IoT Core and Lambda bring in grid data.", + "Cognito signs in reviewers. CloudWatch watches the system, and the AWS CDK deploys it all."]), ("bench", ["We measured the same engine on Graviton and on x86.", "Graviton processed more video per second, at a lower cost per video hour."]), ]), - ("06", "THE VERDICT", [ + ("07", "THE VERDICT", [ ("close", ["GRIDPRINT gives exactly three verdicts: VERIFIED, CONTRADICTED, or INCONCLUSIVE.", "A missing signal never counts against a video.", - "It needs mains lighting, and many phone videos will be INCONCLUSIVE.", "GRIDPRINT. Evidence from the light in the room."]), ]), ] @@ -366,7 +374,7 @@ def v_bulb(img, d, t, D, c): def v_pain(img, d, t, D, c): fr = c.clip_blur[int(t * 30) % len(c.clip_blur)] - img.paste(Image.fromarray(fr), (0, 0)) + img.paste(Image.fromarray(fr).resize((W, H), Image.BILINEAR), (0, 0)) heading(img, d, "Real footage needs a defence.", t, 130, 64) items = [("Newsrooms", "verify footage before publishing", "\u2709"), ("Courts", "decide which video to trust", "\u2696"), @@ -571,26 +579,52 @@ def v_limits(img, d, t, D, c): browser(img, d, c.shots["limits"], "#/limitations", cam, cur, [], t) -# --- architecture -NODES = { # id: (x, y, w, h, title, subtitle, appear_sentence) - "cap": (70, 170, 260, 96, "Capture app / Upload", "Android + web", 0), - "apigw": (400, 170, 250, 96, "API Gateway", "HTTPS entry", 0), - "alb": (720, 170, 220, 96, "ALB", "load balancer", 0), - "api": (1010, 170, 300, 96, "Fargate API", "arm64", 1), - "s3": (1400, 120, 280, 80, "S3 + KMS", "videos, reports", 1), - "sqs": (1400, 240, 280, 80, "SQS", "job queue", 1), - "eng": (70, 380, 860, 300, "ECS workers \u00b7 Graviton4 c8g", "OpenCV 5 engine \u00b7 15 stages", 2), - "agent": (1010, 380, 330, 120, "Agent + MCP server", "12 tools \u00b7 policies", 3), - "bed": (1400, 380, 330, 120, "Amazon Bedrock", "Amazon Nova Lite planner", 3), - "ddb": (1010, 580, 330, 100, "DynamoDB", "jobs \u00b7 grid log", 4), - "iot": (1400, 580, 330, 100, "IoT Core", "grid node ingest", 4), - "cog": (1010, 740, 330, 90, "Cognito", "reviewer sign-in", 4), - "cw": (1400, 740, 330, 90, "CloudWatch", "logs \u00b7 metrics", 4), -} -EDGES = [("cap", "apigw"), ("apigw", "alb"), ("alb", "api"), ("api", "s3"), ("api", "sqs"), ("sqs", "eng"), - ("eng", "agent"), ("agent", "bed"), ("eng", "ddb"), ("iot", "ddb"), ("cog", "api"), ("eng", "s3")] +# --- architecture (boxes sized to their text) +ROWS = [ # (y, height, [(id, title, subtitle, appear_sentence)]) + (118, 92, [("cap", "Capture app / Upload", "Android app \u00b7 web upload", 0), + ("apigw", "Amazon API Gateway", "HTTPS entry", 0), + ("alb", "Application Load Balancer", "VPC link", 0), + ("api", "AWS Fargate", "API service \u00b7 arm64", 1)]), + (262, 300, [("bed", "Amazon Bedrock", "Amazon Nova Lite planner", 3), + ("agent", "Agent + MCP server", "12 tools \u00b7 policies", 3), + ("eng", "Amazon ECS \u00b7 Graviton4 c8g", "OpenCV 5 engine \u00b7 15 stages", 2), + ("sqs", "Amazon SQS", "job queue + DLQ", 1)]), + (612, 92, [("cog", "Amazon Cognito", "reviewer sign-in", 5), + ("ddb", "Amazon DynamoDB", "jobs \u00b7 grid log", 4), + ("s3", "Amazon S3 + AWS KMS", "videos \u00b7 reports \u00b7 encrypted", 1), + ("iot", "AWS IoT Core + AWS Lambda", "grid Node ingest", 4)]), + (756, 80, [("ecr", "Amazon ECR", "container images", 2), + ("cw", "Amazon CloudWatch", "logs \u00b7 metrics \u00b7 alarms", 5), + ("cdk", "AWS CDK", "infrastructure as code", 5)]), +] +EDGES = [("cap", "apigw"), ("apigw", "alb"), ("alb", "api"), ("api", "sqs"), ("sqs", "eng"), ("eng", "agent"), + ("agent", "bed"), ("eng", "s3"), ("eng", "ddb"), ("iot", "ddb"), ("ecr", "eng"), ("cog", "api"), ("api", "s3")] CHIPS = ["LUT linearisation", "goodFeaturesToTrack", "calcOpticalFlowPyrLK", "findHomography USAC_MAGSAC", "DISOpticalFlow", "dnn person masks", "FaceDetectorYN", "cv2.solve phase fit", "cv2.dft"] +TF, SF, CF = 24, 18, 16 +NODES: dict = {} + + +def layout_arch(): + if NODES: + return + probe = ImageDraw.Draw(Image.new("RGB", (8, 8))) + chip_w = max(probe.textlength(ch, font=F(CF, "m")) for ch in CHIPS) + 20 + for y, h_, items in ROWS: + ws = [] + for nid, ti, sub, s in items: + w_ = max(probe.textlength(ti, font=F(TF, "b")), probe.textlength(sub, font=F(SF, "r"))) + 40 + if nid == "eng": + w_ = max(w_, 3 * chip_w + 2 * 10 + 36) + ws.append(w_) + gap = (W - 2 * 70 - sum(ws)) / max(1, len(ws) - 1) + x = 70.0 + for (nid, ti, sub, s), w_ in zip(items, ws): + hh = h_ if nid == "eng" else min(h_, 92) + yy = y + (h_ - hh) / 2 + NODES[nid] = (x, yy, w_, hh, ti, sub, s) + x += w_ + gap + NODES["_chip_w"] = chip_w def anchor(n, other): @@ -598,23 +632,26 @@ def anchor(n, other): ox, oy, ow, oh = NODES[other][:4] cx, cy, ocx, ocy = x + w_ / 2, y + h_ / 2, ox + ow / 2, oy + oh / 2 dx, dy = ocx - cx, ocy - cy - if abs(dx) * h_ > abs(dy) * w_: + if abs(dy) < 1e-6 or abs(dx) * h_ > abs(dy) * w_: return (x + w_ if dx > 0 else x, cy) return (cx, y + h_ if dy > 0 else y) def v_arch(img, d, t, D, c): + layout_arch() st = c.sent_t - vis = {n for n, v in NODES.items() if t >= st[min(v[6], len(st) - 1)]} + nodes = {k: v for k, v in NODES.items() if not k.startswith("_")} + vis = {n for n, v in nodes.items() if t >= st[min(v[6], len(st) - 1)]} for a, b in EDGES: if a in vis and b in vis: p, q = anchor(a, b), anchor(b, a) - d.line((p, q), fill=(60, 110, 100), width=3) + d.line((p, q), fill=(60, 120, 108), width=3) for k in range(2): - u = (t * 0.6 + k * 0.5 + hash(a + b) % 7 * 0.1) % 1.0 + u = (t * 0.55 + k * 0.5 + (sum(map(ord, a + b)) % 7) * 0.1) % 1.0 px, py = lerp(p[0], q[0], u), lerp(p[1], q[1], u) d.ellipse((px - 7, py - 7, px + 7, py + 7), fill=ACC) - for n, (x, y, w_, h_, ti, sub, s) in NODES.items(): + chip_w = NODES["_chip_w"] + for n, (x, y, w_, h_, ti, sub, s) in nodes.items(): if n not in vis: continue a = ease((t - st[min(s, len(st) - 1)]) / 0.5) @@ -622,14 +659,14 @@ def v_arch(img, d, t, D, c): ld = ImageDraw.Draw(lay) yy = y + (1 - a) * 20 ld.rounded_rectangle((x, yy, x + w_, yy + h_), 14, fill=(18, 34, 40, int(245 * a)), outline=ACC + (int(255 * a),), width=2) - ld.text((x + 18, yy + 16), ti, font=F(26, "b"), fill=FG + (int(255 * a),)) - ld.text((x + 18, yy + 52), sub, font=F(20, "r"), fill=MUTED + (int(255 * a),)) + ld.text((x + 20, yy + 14), ti, font=F(TF, "b"), fill=FG + (int(255 * a),)) + ld.text((x + 20, yy + 50), sub, font=F(SF, "r"), fill=MUTED + (int(255 * a),)) if n == "eng": for i, ch in enumerate(CHIPS): ca = ease((t - st[2] - 0.4 - i * 0.25) / 0.3) * a - cx_, cy_ = x + 18 + (i % 3) * 280, yy + 100 + (i // 3) * 62 - ld.rounded_rectangle((cx_, cy_, cx_ + 268, cy_ + 48), 8, fill=(30, 60, 52, int(255 * ca))) - ld.text((cx_ + 134, cy_ + 24), ch, font=F(19, "m"), fill=(200, 255, 225, int(255 * ca)), anchor="mm") + cx_, cy_ = x + 18 + (i % 3) * (chip_w + 10), yy + 92 + (i // 3) * 64 + ld.rounded_rectangle((cx_, cy_, cx_ + chip_w, cy_ + 52), 8, fill=(30, 60, 52, int(255 * ca))) + ld.text((cx_ + chip_w / 2, cy_ + 26), ch, font=F(CF, "m"), fill=(200, 255, 225, int(255 * ca)), anchor="mm") img.alpha_composite(lay) @@ -675,20 +712,61 @@ def v_close(img, d, t, D, c): for i in (1, 2): if t > 1.5: d.text((110 + i * 580, 430), sub[i], font=F(22, "r"), fill=MUTED) - s2 = c.sent_t[2] if len(c.sent_t) > 2 else 3 - if t > s2: - heading(img, d, "Limits: needs mains lighting \u00b7 many phone videos give INCONCLUSIVE \u00b7 real-world evaluation pending.", - t, 520, 28, delay=s2, color=MUTED) - s3 = c.sent_t[3] if len(c.sent_t) > 3 else 6 + s3 = c.sent_t[2] if len(c.sent_t) > 2 else 6 if t > s3: heading(img, d, "GRIDPRINT", t, 610, 96, delay=s3, color=ACC) heading(img, d, REPO, t, 740, 44, delay=s3 + 0.4) heading(img, d, "OpenCV 5 \u00b7 AWS \u00b7 live at " + APP_URL, t, 810, 26, delay=s3 + 0.6, color=MUTED) +# --- real evidence: iPhone 13 recording, engine run, live AWS job +def v_realclip(img, d, t, D, c): + heading(img, d, "A real room. A normal phone.", t, 100, 60) + fr = c.phone[int(t * 30) % len(c.phone)] + paste_np(img, fr, (330, 200, 330 + 1260, 200 + 709)) + rrect(d, (350, 220, 870, 270), fill=(10, 16, 20), outline=ACC, r=10) + d.text((370, 245), "\u25cf REAL RECORDING \u00b7 iPhone 13 \u00b7 1080p30", font=F(22, "b"), fill=ACC, anchor="lm") + source_tag(d, "data/real/phone2.mov", 935) + + +def v_realrun(img, d, t, D, c): + heading(img, d, "The engine ran the full clip.", t, 100, 56) + i = int(t * 30) % len(c.povl) + paste_np(img, c.povl[i], (110, 220, 110 + 960, 220 + int(960 * c.povl[0].shape[0] / c.povl[0].shape[1]))) + d.text((110, 190), "OpenCV 5 engine overlay (engine output)", font=F(24, "b"), fill=ACC) + q = c.preal + rows = [("Verdict", q["verdict"]), ("Reasons", " \u00b7 ".join(q["reasons"])), + ("Band SNR", f"{q['quality']['band_snr_db']:.2f} dB"), ("DFT peak", f"{q['quality']['dft_peak_hz']:.4f} Hz")] + x0, y0 = 1130, 220 + rrect(d, (x0, y0, 1810, y0 + 470), fill=PANEL, outline=LINE) + for k, (a_, b_) in enumerate(rows): + al = ease((t - 0.6 - k * 0.5) / 0.4) + yy = y0 + 40 + k * 105 + alpha_text(img, (x0 + 30, yy), a_, F(24, "r"), MUTED, al) + col = VCOL.get(b_, FG) + alpha_text(img, (x0 + 30, yy + 34), b_, F(34 if k != 1 else 26, "b"), col, al) + s2 = c.sent_t[2] if len(c.sent_t) > 2 else D * 0.6 + if t > s2: + heading(img, d, "No guess. Honest answer.", t, 720, 44, x=x0, delay=s2, color=ACC) + source_tag(d, "results/video/engine_runs/real-phone2/video_result.json", 935) + + +def v_reallive(img, d, t, D, c): + if c.shots.get("realjob") is None: + heading(img, d, "Live on AWS", t, 300, 80) + return + sh = c.shots["realjob"] + s1 = c.sent_t[1] if len(c.sent_t) > 1 else D / 2 + cam = track([(0, (960, 560, 1500)), (1.0, (960, 560, 1500)), (2.4, (960, 380, 1150)), (s1, (960, 400, 1150)), + (s1 + 1.2, (960, 580, 1150)), (D, (960, 590, 1150))], t) + cur = track([(0, (1500, 900)), (2.2, (520, 400)), (s1 + 1.4, (600, 548)), (D - 0.5, (680, 626)), (D, (680, 627))], t) + browser(img, d, sh, "#/jobs/cd65b6ea0c20", cam, cur, [], t) + + + VIS = {"hook": v_hook, "dismiss": v_dismiss, "bulb": v_bulb, "pain": v_pain, "flicker": v_flicker, "rolling": v_rolling, "overlay": v_overlay, "track": v_track, "home": v_home, "cases": v_cases, "job": v_job, "trace": v_trace, - "reviewer": v_reviewer, "limits": v_limits, "arch": v_arch, "bench": v_bench, "close": v_close} + "reviewer": v_reviewer, "realclip": v_realclip, "realrun": v_realrun, "reallive": v_reallive, "arch": v_arch, "bench": v_bench, "close": v_close} # ----------------------------------------------------------------------------- build @@ -697,18 +775,68 @@ def srt_time(s: float) -> str: return f"{ms // 3600000:02d}:{ms // 60000 % 60:02d}:{ms // 1000 % 60:02d},{ms % 1000:03d}" +def render_range(c, timeline, srt, total, f0, f1, out): + cmd = ["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "18", "-pix_fmt", "yuv420p", str(out)] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) + seg = 0 + for fi in range(f0, f1): + tg = fi / FPS + while seg < len(timeline) - 1 and tg >= timeline[seg][0] + timeline[seg][1]: + seg += 1 + t0s, D, num, title, key, st, sents = timeline[seg] + t = tg - t0s + c.sent_t = st + img = background().convert("RGBA") + d = ImageDraw.Draw(img) + VIS[key](img, d, t, D, c) + d = ImageDraw.Draw(img) + tagline(d, num, title) + for (a, b, s) in srt: + if a - 0.05 <= tg < b + 0.25: + caption(img, s) + break + arr = np.asarray(img.convert("RGB")) + fade = min(1.0, t / 0.3, (D - t) / 0.3) + if tg > total - 1.0: + fade = 0.0 + if fade < 1: + arr = (arr.astype(np.float32) * max(0.0, fade)).astype(np.uint8) + proc.stdin.write(arr.tobytes()) + if (fi - f0) % 300 == 0: + print(f"part {out.name} frame {fi - f0}/{f1 - f0} ({key})", flush=True) + proc.stdin.close() + proc.wait() + + def main() -> int: BUILD.mkdir(parents=True, exist_ok=True) c = Ctx() clip = load_frames(PUBLIC_CLIP) c.clip = clip - c.clip_blur = [np.clip(cv2.GaussianBlur(cv2.resize(f, (W, H)), (0, 0), 18) * 0.35, 0, 255).astype(np.uint8) + c.clip_blur = [np.clip(cv2.GaussianBlur(cv2.resize(f, (960, 540)), (0, 0), 9) * 0.35, 0, 255).astype(np.uint8) for f in clip[::2]] c.clip_blur = [x for x in c.clip_blur for _ in (0, 1)] c.ovl = load_frames(RUNS / "public_fluorescent" / "public_fluorescent" / "overlay.mp4") c.enf = Image.open(RUNS / "capture_verified" / "capture_verified" / "enf_track.png").convert("RGB") c.ncc = Image.open(RUNS / "capture_verified" / "capture_verified" / "ncc_scan.png").convert("RGB") - c.shots = {k: Image.open(LIVE / f"{k}.png").convert("RGB") for k in ("home", "job", "reviewer", "limits")} + c.shots = {k: Image.open(LIVE / f"{k}.png").convert("RGB") for k in ("home", "job", "reviewer")} + rj = LIVE / "realjob.png" + c.shots["realjob"] = Image.open(rj).convert("RGB") if rj.exists() else None + c.phone = load_frames(ROOT / "data" / "real" / "phone2.mov", (960, 540), maxn=300) + c.povl = load_frames(RUNS / "real-phone2" / "real-phone2" / "overlay.mp4", None, maxn=360) + c.preal = json.loads((RUNS / "real-phone2" / "video_result.json").read_text()) + lj = LIVE / "job_cd65b6ea0c20.json" + live = json.loads(lj.read_text()) if lj.exists() else {} + if live.get("verdict"): + live_sentence = f"The live app gives the same kind of answer: {live['verdict']}." if live["verdict"] == c.preal["verdict"] else f"The live app returned {live['verdict']}." + else: + live_sentence = "Then the live agent asked the user one question: which camera made this video?" + for _, _, bl in SECTIONS: + for _, sents in bl: + for i_, s_ in enumerate(sents): + if s_ == "LIVE_SENTENCE": + sents[i_] = live_sentence tp = ROOT / "results" / "agent_traces" / "clean__s1001.json" c.trace, c.trace_name = json.loads(tp.read_text()), tp.name c.cost = json.loads((BENCH / "cost.json").read_text()) @@ -762,39 +890,31 @@ def main() -> int: img.convert("RGB").resize((960, 540)).save(BUILD / f"still_{num}_{key}_{int(frac * 100)}.png") return 0 out_mp4 = OUT / "gridprint_submission.mp4" - cmd = ["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-", - "-i", str(wav_p), "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p", - "-c:a", "aac", "-b:a", "160k", "-shortest", "-movflags", "+faststart", str(out_mp4)] - proc = subprocess.Popen(cmd, stdin=subprocess.PIPE) nfr = int(total * FPS) - seg = 0 - for fi in range(nfr): - tg = fi / FPS - while seg < len(timeline) - 1 and tg >= timeline[seg][0] + timeline[seg][1]: - seg += 1 - t0s, D, num, title, key, st, sents = timeline[seg] - t = tg - t0s - c.sent_t = st - img = background().convert("RGBA") - d = ImageDraw.Draw(img) - VIS[key](img, d, t, D, c) - d = ImageDraw.Draw(img) - tagline(d, num, title) - for (a, b, s) in srt: - if a - 0.05 <= tg < b + 0.25: - caption(img, s) - break - arr = np.asarray(img.convert("RGB")) - fade = min(1.0, t / 0.3, (D - t) / 0.3) if key != "hook" or t > 0.3 else t / 0.3 - if tg > total - 1.0: - fade = 0.0 - if fade < 1: - arr = (arr.astype(np.float32) * max(0.0, fade)).astype(np.uint8) - proc.stdin.write(arr.tobytes()) - if fi % 300 == 0: - print(f"frame {fi}/{nfr} ({key})", flush=True) - proc.stdin.close() - proc.wait() + NP = 6 + bounds = [nfr * k // NP for k in range(NP + 1)] + if "--part" in sys.argv: + k = int(sys.argv[sys.argv.index("--part") + 1]) + render_range(c, timeline, srt, total, bounds[k], bounds[k + 1], BUILD / f"part_{k}.mp4") + return 0 + procs = [subprocess.Popen([sys.executable, __file__, "--part", str(k)]) for k in range(NP)] + for pr in procs: + if pr.wait() != 0: + raise RuntimeError("part render failed") + lst = BUILD / "parts.txt" + lst.write_text("".join(f"file '{(BUILD / f'part_{k}.mp4').as_posix()}'\n" for k in range(NP))) + subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", str(lst), "-i", str(wav_p), + "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", "-movflags", "+faststart", str(out_mp4)], check=True) + # gallery stills + sd = OUT / "stills" + sd.mkdir(parents=True, exist_ok=True) + picks = {"01_title": ("flicker", 0.25), "02_problem": ("dismiss", 0.8), "03_how_it_works": ("rolling", 0.85), + "04_real_run": ("realrun", 0.9), "05_live_app_verdict": ("job", 0.4), "06_architecture": ("arch", 0.97)} + for name, (key, frac) in picks.items(): + for t0s, D, *_rest in timeline: + if _rest[2] == key: + subprocess.run(["ffmpeg", "-v", "error", "-y", "-ss", f"{t0s + D * frac:.2f}", "-i", str(out_mp4), + "-frames:v", "1", str(sd / f"{name}.png")], check=True) (OUT / "gridprint_submission.srt").write_text( "".join(f"{i + 1}\n{srt_time(a)} --> {srt_time(b)}\n{s}\n\n" for i, (a, b, s) in enumerate(srt)), encoding="utf-8") @@ -804,7 +924,7 @@ def main() -> int: "Sources on screen: live AWS app screenshots (`video/assets/live/`, captured with headless Chrome), " "`data/public/fluorescent_beat_effect.mp4` (Wikimedia Commons, CC BY-SA 3.0), engine outputs in " "`results/video/engine_runs/`, agent trace `results/agent_traces/clean__s1001.json`, benchmark " - "`results/bench/2026-10-27/`.", ""] + "`results/bench/2026-10-27/`, real iPhone 13 clip `data/real/phone2.mov` with engine run `results/video/engine_runs/real-phone2/` and live job `cd65b6ea0c20`.", ""] for t0s, D, num, title, key, st, sents in timeline: md += [f"## {num} {title} / {key} ({srt_time(t0s)[:-4]} - {srt_time(t0s + D)[:-4]})", ""] + [f"- {s}" for s in sents] + [""] (ROOT / "docs" / "VIDEO_SCRIPT.md").write_text("\n".join(md), encoding="utf-8")