crownemmanuel commited on
Commit
be1cb53
·
1 Parent(s): ed8146d

Refactor to Python Gradio app

Browse files
.gitignore CHANGED
@@ -7,3 +7,6 @@ node_modules/
7
  *.png
8
  *.webp
9
  !app/icon.svg
 
 
 
 
7
  *.png
8
  *.webp
9
  !app/icon.svg
10
+ __pycache__/
11
+ *.pyc
12
+ .gradio/
README.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Grid2Frame
2
+
3
+ Extract individual images from a grid or contact-sheet style image.
4
+
5
+ Grid2Frame is now a Python-only Gradio app, which makes it easy to run on free Python-friendly hosts such as Hugging Face Spaces. It uses OpenCV to detect full grid separator lines, previews the detected frame boxes, shows the extracted frames, and creates a ZIP download.
6
+
7
+ ## Run Locally
8
+
9
+ ```bash
10
+ python3 -m venv .venv
11
+ .venv/bin/python3 -m pip install -r requirements.txt
12
+ .venv/bin/python3 app.py
13
+ ```
14
+
15
+ Then open the local URL printed by Gradio.
16
+
17
+ ## Deploy Free
18
+
19
+ Recommended target: Hugging Face Spaces.
20
+
21
+ 1. Create a new Space.
22
+ 2. Select `Gradio` as the SDK.
23
+ 3. Push this repo.
24
+
25
+ Hugging Face Spaces will install `requirements.txt` and run `app.py`.
26
+
27
+ ## Next.js Version
28
+
29
+ The previous Next.js implementation is preserved on the `nextjs` branch.
app.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import tempfile
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import cv2
9
+ import gradio as gr
10
+
11
+ from grid2frame.extractor import annotate_regions, detect_grid, extract_frames, read_image
12
+
13
+ WORK_ROOT = Path(tempfile.gettempdir()) / "grid2frame"
14
+ WORK_ROOT.mkdir(parents=True, exist_ok=True)
15
+
16
+
17
+ def process_image(
18
+ image_path: str | None,
19
+ sensitivity: int,
20
+ min_frame_size: int,
21
+ separator_mode: str,
22
+ output_format: str,
23
+ ):
24
+ if not image_path:
25
+ return None, "<span>No image loaded</span>", [], None
26
+
27
+ run_dir = Path(tempfile.mkdtemp(prefix="run-", dir=WORK_ROOT))
28
+ image = read_image(image_path)
29
+ result = detect_grid(
30
+ image,
31
+ min_frame_size=min_frame_size,
32
+ sensitivity=sensitivity,
33
+ separator_mode=separator_mode.lower(),
34
+ )
35
+
36
+ if not result.regions:
37
+ return None, "<span>No frames detected</span>", [], None
38
+
39
+ annotated = annotate_regions(image, result.regions)
40
+ annotated_path = run_dir / "detected-grid.jpg"
41
+ cv2.imwrite(str(annotated_path), annotated, [cv2.IMWRITE_JPEG_QUALITY, 92])
42
+
43
+ extension = extension_for_format(output_format)
44
+ image_params = encoding_params(extension)
45
+ frame_paths: list[str] = []
46
+ zip_path = run_dir / "grid2frame-frames.zip"
47
+
48
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
49
+ for index, (region, frame) in enumerate(
50
+ zip(result.regions, extract_frames(image, result.regions)),
51
+ start=1,
52
+ ):
53
+ frame_name = (
54
+ f"frame-{index:03d}-r{region.row + 1:02d}-c{region.col + 1:02d}"
55
+ f".{extension}"
56
+ )
57
+ frame_path = run_dir / frame_name
58
+ cv2.imwrite(str(frame_path), frame, image_params)
59
+ frame_paths.append(str(frame_path))
60
+ archive.write(frame_path, arcname=frame_name)
61
+
62
+ status = (
63
+ f"<span>{len(result.regions)} frames extracted from "
64
+ f"{result.columns} columns x {result.rows} rows</span>"
65
+ )
66
+ return str(annotated_path), status, frame_paths, str(zip_path)
67
+
68
+
69
+ def clear_outputs():
70
+ return None, "<span>No image loaded</span>", [], None
71
+
72
+
73
+ def extension_for_format(output_format: str) -> str:
74
+ if output_format == "PNG":
75
+ return "png"
76
+ if output_format == "WebP":
77
+ return "webp"
78
+ return "jpg"
79
+
80
+
81
+ def encoding_params(extension: str) -> list[int]:
82
+ if extension == "jpg":
83
+ return [cv2.IMWRITE_JPEG_QUALITY, 94]
84
+ if extension == "webp":
85
+ return [cv2.IMWRITE_WEBP_QUALITY, 94]
86
+ return []
87
+
88
+
89
+ def build_app() -> gr.Blocks:
90
+ with gr.Blocks(
91
+ title="Grid2Frame",
92
+ ) as demo:
93
+ gr.HTML(
94
+ """
95
+ <section class="hero-band">
96
+ <div>
97
+ <p class="eyebrow">Grid2Frame</p>
98
+ <h1>Extract every frame from a grid image.</h1>
99
+ </div>
100
+ </section>
101
+ """
102
+ )
103
+
104
+ with gr.Row(elem_classes="workspace"):
105
+ with gr.Column(scale=8, elem_classes="preview-panel"):
106
+ input_image = gr.Image(
107
+ label="Upload grid",
108
+ sources=["upload"],
109
+ type="filepath",
110
+ height=420,
111
+ elem_classes="upload-box",
112
+ )
113
+ annotated_image = gr.Image(
114
+ label="Detected frames",
115
+ type="filepath",
116
+ height=420,
117
+ elem_classes="detected-box",
118
+ )
119
+
120
+ with gr.Column(scale=3, elem_classes="control-panel"):
121
+ status = gr.HTML("<span>No image loaded</span>", elem_classes="status-line")
122
+ sensitivity = gr.Slider(
123
+ minimum=0,
124
+ maximum=100,
125
+ value=58,
126
+ step=1,
127
+ label="Sensitivity",
128
+ )
129
+ min_frame_size = gr.Slider(
130
+ minimum=16,
131
+ maximum=600,
132
+ value=80,
133
+ step=4,
134
+ label="Minimum frame",
135
+ )
136
+ separator_mode = gr.Radio(
137
+ ["Auto", "Dark", "Light"],
138
+ value="Auto",
139
+ label="Separator",
140
+ )
141
+ output_format = gr.Radio(
142
+ ["JPG", "PNG", "WebP"],
143
+ value="JPG",
144
+ label="Output",
145
+ )
146
+ extract_button = gr.Button("Extract", variant="primary")
147
+ zip_file = gr.File(label="Download ZIP", elem_classes="zip-download")
148
+
149
+ with gr.Column(elem_classes="frames-section"):
150
+ gr.HTML("<div class='section-head'><h2>Extracted frames</h2></div>")
151
+ gallery = gr.Gallery(
152
+ label="",
153
+ columns=6,
154
+ rows=2,
155
+ height=520,
156
+ object_fit="cover",
157
+ elem_classes="frame-gallery",
158
+ )
159
+
160
+ extract_inputs = [
161
+ input_image,
162
+ sensitivity,
163
+ min_frame_size,
164
+ separator_mode,
165
+ output_format,
166
+ ]
167
+ extract_outputs = [annotated_image, status, gallery, zip_file]
168
+
169
+ input_image.upload(process_image, extract_inputs, extract_outputs)
170
+ extract_button.click(process_image, extract_inputs, extract_outputs)
171
+ input_image.clear(clear_outputs, None, extract_outputs)
172
+
173
+ return demo
174
+
175
+
176
+ def launch_app() -> None:
177
+ build_app().launch(
178
+ css=APP_CSS,
179
+ theme=gr.themes.Base(
180
+ primary_hue="teal",
181
+ neutral_hue="slate",
182
+ font=["Arial", "Helvetica", "sans-serif"],
183
+ ),
184
+ )
185
+
186
+
187
+ APP_CSS = """
188
+ :root {
189
+ --background: #f6f3ec;
190
+ --surface: #ffffff;
191
+ --surface-muted: #e9edf0;
192
+ --ink: #171c1f;
193
+ --muted: #66727a;
194
+ --line: #c8d0d4;
195
+ --accent: #0f766e;
196
+ --accent-strong: #0b4f49;
197
+ }
198
+
199
+ body,
200
+ .gradio-container {
201
+ background: linear-gradient(180deg, #f6f3ec 0, #eef3f1 42%, #f6f3ec 100%) !important;
202
+ color: var(--ink) !important;
203
+ }
204
+
205
+ .gradio-container {
206
+ max-width: none !important;
207
+ padding: 28px !important;
208
+ }
209
+
210
+ .hero-band {
211
+ align-items: end;
212
+ display: flex;
213
+ justify-content: space-between;
214
+ margin: 0 auto 24px;
215
+ max-width: 1440px;
216
+ }
217
+
218
+ .eyebrow {
219
+ color: var(--accent-strong);
220
+ font-size: 0.76rem;
221
+ font-weight: 800;
222
+ letter-spacing: 0.12em;
223
+ margin: 0 0 8px;
224
+ text-transform: uppercase;
225
+ }
226
+
227
+ h1 {
228
+ color: var(--ink);
229
+ font-size: clamp(2rem, 4vw, 4.6rem);
230
+ line-height: 0.98;
231
+ margin: 0;
232
+ max-width: 820px;
233
+ }
234
+
235
+ .workspace {
236
+ gap: 18px !important;
237
+ margin: 0 auto !important;
238
+ max-width: 1440px !important;
239
+ }
240
+
241
+ .preview-panel {
242
+ background: #111619;
243
+ border: 1px solid rgba(23, 28, 31, 0.18);
244
+ border-radius: 8px;
245
+ box-shadow: 0 22px 50px rgba(23, 28, 31, 0.12);
246
+ min-height: 58vh;
247
+ overflow: hidden;
248
+ padding: 18px;
249
+ }
250
+
251
+ .preview-panel .block,
252
+ .control-panel .block {
253
+ border-radius: 8px !important;
254
+ }
255
+
256
+ .upload-box,
257
+ .detected-box {
258
+ background: #111619 !important;
259
+ border-color: rgba(255, 255, 255, 0.08) !important;
260
+ }
261
+
262
+ .control-panel {
263
+ background: var(--surface);
264
+ border: 1px solid var(--line);
265
+ border-radius: 8px;
266
+ box-shadow: 0 22px 50px rgba(23, 28, 31, 0.12);
267
+ gap: 16px;
268
+ padding: 18px;
269
+ }
270
+
271
+ .status-line {
272
+ color: var(--muted);
273
+ font-weight: 700;
274
+ }
275
+
276
+ button.primary,
277
+ .primary {
278
+ background: var(--accent) !important;
279
+ border-color: var(--accent) !important;
280
+ color: #ffffff !important;
281
+ }
282
+
283
+ .zip-download {
284
+ background: #d7efea !important;
285
+ }
286
+
287
+ .frames-section {
288
+ margin: 28px auto 0 !important;
289
+ max-width: 1440px !important;
290
+ }
291
+
292
+ .section-head h2 {
293
+ color: var(--ink);
294
+ font-size: 1.35rem;
295
+ margin: 0 0 14px;
296
+ }
297
+
298
+ .frame-gallery {
299
+ background: transparent !important;
300
+ border: 0 !important;
301
+ }
302
+
303
+ @media (max-width: 980px) {
304
+ .gradio-container {
305
+ padding: 18px !important;
306
+ }
307
+ }
308
+ """
309
+
310
+
311
+ if __name__ == "__main__":
312
+ launch_app()
app/api/extract/route.ts DELETED
@@ -1,123 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { mkdir, rm, writeFile } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import path from "node:path";
6
-
7
- export const runtime = "nodejs";
8
-
9
- type PythonResult = {
10
- regions: Array<{
11
- id: string;
12
- row: number;
13
- col: number;
14
- x: number;
15
- y: number;
16
- width: number;
17
- height: number;
18
- }>;
19
- rows: number;
20
- columns: number;
21
- width: number;
22
- height: number;
23
- };
24
-
25
- export async function POST(request: Request) {
26
- const formData = await request.formData();
27
- const file = formData.get("image");
28
-
29
- if (!(file instanceof File)) {
30
- return Response.json({ error: "Missing image file." }, { status: 400 });
31
- }
32
-
33
- const tempDirectory = path.join(
34
- tmpdir(),
35
- `grid2frame-${Date.now()}-${Math.random().toString(16).slice(2)}`,
36
- );
37
- const tempFile = path.join(tempDirectory, sanitizeFileName(file.name));
38
-
39
- try {
40
- await mkdir(tempDirectory, { recursive: true });
41
- await writeFile(tempFile, Buffer.from(await file.arrayBuffer()));
42
-
43
- const result = await runPythonDetector(tempFile, {
44
- minFrameSize: String(formData.get("minFrameSize") ?? "80"),
45
- separatorMode: String(formData.get("separatorMode") ?? "auto"),
46
- sensitivity: String(formData.get("sensitivity") ?? "58"),
47
- });
48
-
49
- return Response.json({ ...result, engine: "python-opencv" });
50
- } catch (error) {
51
- return Response.json(
52
- { error: error instanceof Error ? error.message : "Extraction failed." },
53
- { status: 500 },
54
- );
55
- } finally {
56
- await rm(tempDirectory, { recursive: true, force: true });
57
- }
58
- }
59
-
60
- function runPythonDetector(
61
- imagePath: string,
62
- options: {
63
- minFrameSize: string;
64
- separatorMode: string;
65
- sensitivity: string;
66
- },
67
- ) {
68
- return new Promise<PythonResult>((resolve, reject) => {
69
- const projectRoot = process.cwd();
70
- const virtualEnvPython = [
71
- projectRoot,
72
- ".venv",
73
- "bin",
74
- "python3",
75
- ].join(path.sep);
76
- const pythonPath =
77
- process.env.PYTHON_BIN ??
78
- (existsSync(virtualEnvPython) ? virtualEnvPython : "python3");
79
- const scriptPath = path.join(projectRoot, "scripts", "extract_grid.py");
80
- const child = spawn(
81
- pythonPath,
82
- [
83
- scriptPath,
84
- imagePath,
85
- "--min-frame-size",
86
- options.minFrameSize,
87
- "--separator-mode",
88
- options.separatorMode,
89
- "--sensitivity",
90
- options.sensitivity,
91
- ],
92
- { cwd: projectRoot },
93
- );
94
-
95
- let stdout = "";
96
- let stderr = "";
97
-
98
- child.stdout.on("data", (chunk) => {
99
- stdout += chunk.toString();
100
- });
101
- child.stderr.on("data", (chunk) => {
102
- stderr += chunk.toString();
103
- });
104
- child.on("error", (error) => reject(error));
105
- child.on("close", (code) => {
106
- if (code !== 0) {
107
- reject(new Error(stderr.trim() || `Python exited with code ${code}.`));
108
- return;
109
- }
110
-
111
- try {
112
- resolve(JSON.parse(stdout) as PythonResult);
113
- } catch {
114
- reject(new Error("Python returned invalid JSON."));
115
- }
116
- });
117
- });
118
- }
119
-
120
- function sanitizeFileName(fileName: string) {
121
- const normalized = fileName.replace(/[^\w.-]+/g, "-");
122
- return normalized || "upload";
123
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/globals.css DELETED
@@ -1,500 +0,0 @@
1
- :root {
2
- --background: #f6f3ec;
3
- --surface: #ffffff;
4
- --surface-muted: #e9edf0;
5
- --ink: #171c1f;
6
- --muted: #66727a;
7
- --line: #c8d0d4;
8
- --accent: #0f766e;
9
- --accent-strong: #0b4f49;
10
- --accent-soft: #d7efea;
11
- --warning: #9b2c2c;
12
- --shadow: 0 22px 50px rgba(23, 28, 31, 0.12);
13
- }
14
-
15
- * {
16
- box-sizing: border-box;
17
- }
18
-
19
- html {
20
- background: var(--background);
21
- }
22
-
23
- body {
24
- margin: 0;
25
- color: var(--ink);
26
- background:
27
- linear-gradient(180deg, #f6f3ec 0, #eef3f1 42%, #f6f3ec 100%);
28
- font-family:
29
- Arial,
30
- Helvetica,
31
- sans-serif;
32
- letter-spacing: 0;
33
- }
34
-
35
- button,
36
- input {
37
- font: inherit;
38
- }
39
-
40
- button {
41
- cursor: pointer;
42
- }
43
-
44
- button:disabled {
45
- cursor: not-allowed;
46
- opacity: 0.55;
47
- }
48
-
49
- svg {
50
- display: block;
51
- height: 1.1rem;
52
- width: 1.1rem;
53
- fill: currentColor;
54
- }
55
-
56
- .app-shell {
57
- min-height: 100vh;
58
- padding: 28px;
59
- }
60
-
61
- .hero-band {
62
- align-items: end;
63
- display: flex;
64
- gap: 24px;
65
- justify-content: space-between;
66
- margin: 0 auto 24px;
67
- max-width: 1440px;
68
- }
69
-
70
- .eyebrow {
71
- color: var(--accent-strong);
72
- font-size: 0.76rem;
73
- font-weight: 800;
74
- letter-spacing: 0.12em;
75
- margin: 0 0 8px;
76
- text-transform: uppercase;
77
- }
78
-
79
- h1,
80
- h2,
81
- p {
82
- margin: 0;
83
- }
84
-
85
- h1 {
86
- font-size: clamp(2rem, 4vw, 4.6rem);
87
- line-height: 0.98;
88
- max-width: 820px;
89
- }
90
-
91
- h2 {
92
- font-size: 1.35rem;
93
- }
94
-
95
- .actions {
96
- align-items: center;
97
- display: flex;
98
- flex-wrap: wrap;
99
- gap: 10px;
100
- justify-content: flex-end;
101
- }
102
-
103
- .button,
104
- .icon-button,
105
- .empty-state,
106
- .segmented button {
107
- border: 1px solid var(--line);
108
- border-radius: 8px;
109
- color: var(--ink);
110
- transition:
111
- background 160ms ease,
112
- border-color 160ms ease,
113
- color 160ms ease,
114
- transform 160ms ease;
115
- }
116
-
117
- .button:hover:not(:disabled),
118
- .icon-button:hover:not(:disabled),
119
- .empty-state:hover,
120
- .segmented button:hover {
121
- transform: translateY(-1px);
122
- }
123
-
124
- .button {
125
- align-items: center;
126
- background: var(--surface);
127
- display: inline-flex;
128
- font-weight: 800;
129
- gap: 9px;
130
- min-height: 44px;
131
- padding: 0 16px;
132
- }
133
-
134
- .button-primary {
135
- background: var(--accent);
136
- border-color: var(--accent);
137
- color: #ffffff;
138
- }
139
-
140
- .button-wide {
141
- justify-content: center;
142
- margin-top: 8px;
143
- width: 100%;
144
- }
145
-
146
- .workspace {
147
- align-items: stretch;
148
- display: grid;
149
- gap: 18px;
150
- grid-template-columns: minmax(0, 1fr) 340px;
151
- margin: 0 auto;
152
- max-width: 1440px;
153
- }
154
-
155
- .drop-zone {
156
- align-items: center;
157
- background: #111619;
158
- border: 1px solid rgba(23, 28, 31, 0.18);
159
- border-radius: 8px;
160
- box-shadow: var(--shadow);
161
- display: flex;
162
- justify-content: center;
163
- min-height: 58vh;
164
- overflow: hidden;
165
- position: relative;
166
- }
167
-
168
- .empty-state {
169
- align-items: center;
170
- background: #182024;
171
- color: #f5f7f4;
172
- display: grid;
173
- gap: 10px;
174
- justify-items: center;
175
- min-height: 210px;
176
- min-width: min(360px, calc(100vw - 72px));
177
- padding: 34px;
178
- }
179
-
180
- .empty-state svg {
181
- height: 42px;
182
- width: 42px;
183
- }
184
-
185
- .empty-state span {
186
- font-size: 1.2rem;
187
- font-weight: 800;
188
- }
189
-
190
- .empty-state small {
191
- color: #bac5c7;
192
- }
193
-
194
- .preview-wrap {
195
- align-items: center;
196
- display: flex;
197
- height: 100%;
198
- justify-content: center;
199
- min-height: 58vh;
200
- position: relative;
201
- width: 100%;
202
- }
203
-
204
- .image-stage {
205
- aspect-ratio: var(--ratio);
206
- max-height: 76vh;
207
- position: relative;
208
- width: min(100%, calc(76vh * var(--ratio)));
209
- }
210
-
211
- .source-preview {
212
- display: block;
213
- height: 100%;
214
- object-fit: contain;
215
- width: 100%;
216
- }
217
-
218
- .region-layer {
219
- inset: 0;
220
- margin: auto;
221
- pointer-events: none;
222
- position: absolute;
223
- }
224
-
225
- .region-box {
226
- border: 1px solid rgba(38, 255, 201, 0.78);
227
- box-shadow: inset 0 0 0 1px rgba(10, 32, 31, 0.58);
228
- position: absolute;
229
- }
230
-
231
- .control-panel {
232
- align-self: stretch;
233
- background: var(--surface);
234
- border: 1px solid var(--line);
235
- border-radius: 8px;
236
- box-shadow: var(--shadow);
237
- display: flex;
238
- flex-direction: column;
239
- gap: 18px;
240
- padding: 18px;
241
- }
242
-
243
- .status-line {
244
- align-items: center;
245
- color: var(--muted);
246
- display: flex;
247
- font-size: 0.92rem;
248
- gap: 9px;
249
- min-height: 26px;
250
- }
251
-
252
- .dot,
253
- .pulse-dot {
254
- background: var(--accent);
255
- border-radius: 50%;
256
- display: inline-block;
257
- flex: 0 0 auto;
258
- height: 9px;
259
- width: 9px;
260
- }
261
-
262
- .pulse-dot {
263
- animation: pulse 1.1s infinite;
264
- }
265
-
266
- .meta-grid {
267
- display: grid;
268
- gap: 10px;
269
- grid-template-columns: 1fr 1fr;
270
- margin: 0;
271
- }
272
-
273
- .meta-grid div {
274
- background: var(--surface-muted);
275
- border-radius: 8px;
276
- padding: 12px;
277
- }
278
-
279
- .meta-grid dt {
280
- color: var(--muted);
281
- font-size: 0.75rem;
282
- font-weight: 800;
283
- margin-bottom: 4px;
284
- text-transform: uppercase;
285
- }
286
-
287
- .meta-grid dd {
288
- font-weight: 800;
289
- margin: 0;
290
- }
291
-
292
- .error-text {
293
- background: #fff1f1;
294
- border: 1px solid #e0aaa8;
295
- border-radius: 8px;
296
- color: var(--warning);
297
- font-weight: 700;
298
- padding: 10px 12px;
299
- }
300
-
301
- .field {
302
- display: grid;
303
- gap: 9px;
304
- }
305
-
306
- .field > span {
307
- color: var(--muted);
308
- font-size: 0.82rem;
309
- font-weight: 800;
310
- text-transform: uppercase;
311
- }
312
-
313
- .field output {
314
- color: var(--ink);
315
- font-size: 0.9rem;
316
- font-weight: 800;
317
- }
318
-
319
- input[type="range"] {
320
- accent-color: var(--accent);
321
- width: 100%;
322
- }
323
-
324
- .segmented {
325
- background: var(--surface-muted);
326
- border-radius: 8px;
327
- display: grid;
328
- gap: 4px;
329
- grid-auto-columns: 1fr;
330
- grid-auto-flow: column;
331
- padding: 4px;
332
- }
333
-
334
- .segmented button {
335
- background: transparent;
336
- border-color: transparent;
337
- min-height: 36px;
338
- padding: 0 10px;
339
- text-transform: capitalize;
340
- }
341
-
342
- .segmented button[aria-pressed="true"] {
343
- background: var(--surface);
344
- border-color: var(--line);
345
- color: var(--accent-strong);
346
- font-weight: 800;
347
- }
348
-
349
- .frames-section {
350
- margin: 28px auto 0;
351
- max-width: 1440px;
352
- }
353
-
354
- .section-head {
355
- align-items: center;
356
- display: flex;
357
- justify-content: space-between;
358
- margin-bottom: 14px;
359
- }
360
-
361
- .section-head span {
362
- color: var(--muted);
363
- font-weight: 800;
364
- }
365
-
366
- .frame-grid {
367
- display: grid;
368
- gap: 14px;
369
- grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
370
- }
371
-
372
- .frame-card {
373
- background: var(--surface);
374
- border: 1px solid var(--line);
375
- border-radius: 8px;
376
- overflow: hidden;
377
- }
378
-
379
- .frame-card img {
380
- aspect-ratio: 16 / 10;
381
- background: #101517;
382
- display: block;
383
- object-fit: cover;
384
- width: 100%;
385
- }
386
-
387
- .frame-info {
388
- align-items: center;
389
- display: flex;
390
- gap: 12px;
391
- justify-content: space-between;
392
- padding: 10px;
393
- }
394
-
395
- .frame-info div {
396
- display: grid;
397
- gap: 2px;
398
- min-width: 0;
399
- }
400
-
401
- .frame-info strong {
402
- font-size: 0.9rem;
403
- }
404
-
405
- .frame-info span {
406
- color: var(--muted);
407
- font-size: 0.78rem;
408
- font-weight: 700;
409
- }
410
-
411
- .icon-button {
412
- align-items: center;
413
- background: var(--accent-soft);
414
- color: var(--accent-strong);
415
- display: inline-flex;
416
- flex: 0 0 auto;
417
- height: 36px;
418
- justify-content: center;
419
- padding: 0;
420
- width: 36px;
421
- }
422
-
423
- .sr-only {
424
- height: 1px;
425
- margin: -1px;
426
- overflow: hidden;
427
- position: absolute;
428
- width: 1px;
429
- }
430
-
431
- @keyframes pulse {
432
- 0%,
433
- 100% {
434
- opacity: 0.36;
435
- transform: scale(0.82);
436
- }
437
-
438
- 50% {
439
- opacity: 1;
440
- transform: scale(1);
441
- }
442
- }
443
-
444
- @media (max-width: 980px) {
445
- .app-shell {
446
- padding: 18px;
447
- }
448
-
449
- .hero-band {
450
- align-items: start;
451
- flex-direction: column;
452
- }
453
-
454
- .actions {
455
- justify-content: flex-start;
456
- }
457
-
458
- .workspace {
459
- grid-template-columns: 1fr;
460
- }
461
-
462
- .drop-zone {
463
- min-height: 46vh;
464
- }
465
-
466
- .control-panel {
467
- min-height: auto;
468
- }
469
- }
470
-
471
- @media (max-width: 560px) {
472
- .app-shell {
473
- padding: 12px;
474
- }
475
-
476
- h1 {
477
- font-size: 2rem;
478
- }
479
-
480
- .button {
481
- justify-content: center;
482
- width: 100%;
483
- }
484
-
485
- .actions {
486
- width: 100%;
487
- }
488
-
489
- .meta-grid {
490
- grid-template-columns: 1fr;
491
- }
492
-
493
- .segmented {
494
- grid-auto-flow: row;
495
- }
496
-
497
- .frame-grid {
498
- grid-template-columns: repeat(auto-fill, minmax(142px, 1fr));
499
- }
500
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/icon.svg DELETED
app/layout.tsx DELETED
@@ -1,20 +0,0 @@
1
- import type { Metadata } from "next";
2
- import type { ReactNode } from "react";
3
- import "./globals.css";
4
-
5
- export const metadata: Metadata = {
6
- title: "Grid2Frame",
7
- description: "Extract individual frames from image grids.",
8
- };
9
-
10
- export default function RootLayout({
11
- children,
12
- }: Readonly<{
13
- children: ReactNode;
14
- }>) {
15
- return (
16
- <html lang="en">
17
- <body>{children}</body>
18
- </html>
19
- );
20
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/page.tsx DELETED
@@ -1,5 +0,0 @@
1
- import { GridExtractor } from "@/components/grid-extractor";
2
-
3
- export default function Home() {
4
- return <GridExtractor />;
5
- }
 
 
 
 
 
 
components/grid-extractor.tsx DELETED
@@ -1,620 +0,0 @@
1
- "use client";
2
-
3
- /* eslint-disable @next/next/no-img-element */
4
-
5
- import JSZip from "jszip";
6
- import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
7
- import {
8
- detectGridRegions,
9
- type DetectionSettings,
10
- type FrameRegion,
11
- type SeparatorMode,
12
- } from "@/lib/grid-detection";
13
-
14
- type OutputFormat = "image/jpeg" | "image/png" | "image/webp";
15
-
16
- type ExtractedFrame = FrameRegion & {
17
- blob: Blob;
18
- url: string;
19
- fileName: string;
20
- };
21
-
22
- type SourceImage = {
23
- file: File;
24
- url: string;
25
- width: number;
26
- height: number;
27
- };
28
-
29
- const defaultSettings: DetectionSettings = {
30
- minFrameSize: 80,
31
- separatorMode: "auto",
32
- sensitivity: 58,
33
- };
34
-
35
- const maxAnalysisSide = 1800;
36
-
37
- export function GridExtractor() {
38
- const inputRef = useRef<HTMLInputElement>(null);
39
- const sourceUrlRef = useRef<string | null>(null);
40
- const framesRef = useRef<ExtractedFrame[]>([]);
41
- const [source, setSource] = useState<SourceImage | null>(null);
42
- const [settings, setSettings] = useState<DetectionSettings>(defaultSettings);
43
- const [format, setFormat] = useState<OutputFormat>("image/jpeg");
44
- const [regions, setRegions] = useState<FrameRegion[]>([]);
45
- const [frames, setFramesState] = useState<ExtractedFrame[]>([]);
46
- const [engine, setEngine] = useState("Idle");
47
- const [status, setStatus] = useState("No image loaded");
48
- const [error, setError] = useState<string | null>(null);
49
- const [isExtracting, setIsExtracting] = useState(false);
50
- const [isZipping, setIsZipping] = useState(false);
51
- const [zipProgress, setZipProgress] = useState(0);
52
-
53
- useEffect(() => {
54
- return () => {
55
- if (sourceUrlRef.current) URL.revokeObjectURL(sourceUrlRef.current);
56
- revokeFrames(framesRef.current);
57
- };
58
- }, []);
59
-
60
- const baseName = useMemo(() => {
61
- if (!source?.file.name) return "grid";
62
- return source.file.name.replace(/\.[^/.]+$/, "").replace(/[^\w-]+/g, "-");
63
- }, [source]);
64
-
65
- async function handleFile(file: File) {
66
- if (!file.type.startsWith("image/")) {
67
- setError("Select an image file.");
68
- return;
69
- }
70
-
71
- setError(null);
72
- setStatus("Loading image");
73
- setEngine("Loading");
74
- setRegions([]);
75
- replaceFrames([]);
76
-
77
- if (sourceUrlRef.current) URL.revokeObjectURL(sourceUrlRef.current);
78
- const url = URL.createObjectURL(file);
79
- sourceUrlRef.current = url;
80
-
81
- try {
82
- const bitmap = await createImageBitmap(file, {
83
- imageOrientation: "from-image",
84
- });
85
- setSource({ file, url, width: bitmap.width, height: bitmap.height });
86
- bitmap.close();
87
- await extractFrames(file, url);
88
- } catch (loadError) {
89
- URL.revokeObjectURL(url);
90
- sourceUrlRef.current = null;
91
- setSource(null);
92
- setError(toErrorMessage(loadError));
93
- setEngine("Idle");
94
- setStatus("Image could not be loaded");
95
- }
96
- }
97
-
98
- async function extractFrames(file = source?.file, fallbackUrl = source?.url) {
99
- if (!file) return;
100
-
101
- setError(null);
102
- setIsExtracting(true);
103
- setZipProgress(0);
104
- setEngine("Python/OpenCV");
105
- setStatus("Detecting grid with Python");
106
- replaceFrames([]);
107
-
108
- let bitmap: ImageBitmap | null = null;
109
-
110
- try {
111
- bitmap = await createImageBitmap(file, {
112
- imageOrientation: "from-image",
113
- });
114
- const result = await detectRegions(file, bitmap, settings);
115
- setEngine(result.engine === "python-opencv" ? "Python/OpenCV" : "Browser fallback");
116
-
117
- setRegions(result.regions);
118
-
119
- if (result.regions.length === 0) {
120
- setStatus("No frames detected");
121
- setError("No frames were detected. Raise sensitivity or lower minimum size.");
122
- return;
123
- }
124
-
125
- setStatus(`Cropping ${result.regions.length} frames`);
126
- const nextFrames = await cropRegions(
127
- bitmap,
128
- result.regions,
129
- fileBaseName(file),
130
- format,
131
- );
132
- replaceFrames(nextFrames);
133
- setStatus(
134
- `${nextFrames.length} frames extracted from ${result.columns} columns x ${result.rows} rows`,
135
- );
136
- } catch (extractError) {
137
- setError(toErrorMessage(extractError));
138
- setEngine("Failed");
139
- setStatus("Extraction failed");
140
- if (fallbackUrl) setSource((current) => current && { ...current, url: fallbackUrl });
141
- } finally {
142
- bitmap?.close();
143
- setIsExtracting(false);
144
- }
145
- }
146
-
147
- async function downloadAll() {
148
- if (frames.length === 0) return;
149
-
150
- setIsZipping(true);
151
- setZipProgress(0);
152
- setStatus("Building ZIP");
153
-
154
- try {
155
- const zip = new JSZip();
156
- for (const frame of frames) {
157
- zip.file(frame.fileName, frame.blob);
158
- }
159
-
160
- const blob = await zip.generateAsync({ type: "blob" }, (metadata) => {
161
- setZipProgress(Math.round(metadata.percent));
162
- });
163
-
164
- downloadBlob(blob, `${baseName || "grid"}-frames.zip`);
165
- setStatus(`ZIP ready with ${frames.length} frames`);
166
- } catch (zipError) {
167
- setError(toErrorMessage(zipError));
168
- setStatus("ZIP failed");
169
- } finally {
170
- setIsZipping(false);
171
- }
172
- }
173
-
174
- function replaceFrames(nextFrames: ExtractedFrame[]) {
175
- revokeFrames(framesRef.current);
176
- framesRef.current = nextFrames;
177
- setFramesState(nextFrames);
178
- }
179
-
180
- return (
181
- <main className="app-shell">
182
- <section className="hero-band">
183
- <div>
184
- <p className="eyebrow">Grid2Frame</p>
185
- <h1>Extract every frame from a grid image.</h1>
186
- </div>
187
- <div className="actions">
188
- <button
189
- className="button button-primary"
190
- onClick={() => inputRef.current?.click()}
191
- type="button"
192
- >
193
- <UploadIcon />
194
- Upload grid
195
- </button>
196
- <button
197
- className="button"
198
- disabled={!source || isExtracting}
199
- onClick={() => void extractFrames()}
200
- type="button"
201
- >
202
- <ScanIcon />
203
- Extract
204
- </button>
205
- </div>
206
- </section>
207
-
208
- <section className="workspace">
209
- <div
210
- className="drop-zone"
211
- onDragOver={(event) => event.preventDefault()}
212
- onDrop={(event) => {
213
- event.preventDefault();
214
- const file = event.dataTransfer.files[0];
215
- if (file) void handleFile(file);
216
- }}
217
- >
218
- <input
219
- ref={inputRef}
220
- accept="image/*"
221
- className="sr-only"
222
- onChange={(event) => {
223
- const file = event.target.files?.[0];
224
- if (file) void handleFile(file);
225
- event.currentTarget.value = "";
226
- }}
227
- type="file"
228
- />
229
-
230
- {source ? (
231
- <div className="preview-wrap">
232
- <div
233
- className="image-stage"
234
- style={{ "--ratio": source.width / source.height } as CSSProperties}
235
- >
236
- <img alt="Uploaded grid preview" className="source-preview" src={source.url} />
237
- <div aria-hidden="true" className="region-layer">
238
- {regions.map((region) => (
239
- <span
240
- className="region-box"
241
- key={region.id}
242
- style={{
243
- height: `${(region.height / source.height) * 100}%`,
244
- left: `${(region.x / source.width) * 100}%`,
245
- top: `${(region.y / source.height) * 100}%`,
246
- width: `${(region.width / source.width) * 100}%`,
247
- }}
248
- />
249
- ))}
250
- </div>
251
- </div>
252
- </div>
253
- ) : (
254
- <button
255
- className="empty-state"
256
- onClick={() => inputRef.current?.click()}
257
- type="button"
258
- >
259
- <ImageIcon />
260
- <span>Drop image or browse</span>
261
- <small>JPG, PNG, WebP</small>
262
- </button>
263
- )}
264
- </div>
265
-
266
- <aside className="control-panel">
267
- <div className="status-line">
268
- <span className={isExtracting || isZipping ? "pulse-dot" : "dot"} />
269
- <span>{isZipping ? `${status} ${zipProgress}%` : status}</span>
270
- </div>
271
-
272
- {source && (
273
- <dl className="meta-grid">
274
- <div>
275
- <dt>Source</dt>
276
- <dd>{source.width.toLocaleString()} x {source.height.toLocaleString()}</dd>
277
- </div>
278
- <div>
279
- <dt>Frames</dt>
280
- <dd>{frames.length}</dd>
281
- </div>
282
- <div>
283
- <dt>Engine</dt>
284
- <dd>{engine}</dd>
285
- </div>
286
- </dl>
287
- )}
288
-
289
- {error && <p className="error-text">{error}</p>}
290
-
291
- <label className="field">
292
- <span>Sensitivity</span>
293
- <input
294
- max="100"
295
- min="0"
296
- onChange={(event) =>
297
- setSettings((current) => ({
298
- ...current,
299
- sensitivity: Number(event.target.value),
300
- }))
301
- }
302
- type="range"
303
- value={settings.sensitivity}
304
- />
305
- <output>{settings.sensitivity}</output>
306
- </label>
307
-
308
- <label className="field">
309
- <span>Minimum frame</span>
310
- <input
311
- max="600"
312
- min="16"
313
- onChange={(event) =>
314
- setSettings((current) => ({
315
- ...current,
316
- minFrameSize: Number(event.target.value),
317
- }))
318
- }
319
- step="4"
320
- type="range"
321
- value={settings.minFrameSize}
322
- />
323
- <output>{settings.minFrameSize}px</output>
324
- </label>
325
-
326
- <div className="field">
327
- <span>Separator</span>
328
- <div className="segmented" role="group">
329
- {(["auto", "dark", "light"] as SeparatorMode[]).map((mode) => (
330
- <button
331
- aria-pressed={settings.separatorMode === mode}
332
- key={mode}
333
- onClick={() =>
334
- setSettings((current) => ({
335
- ...current,
336
- separatorMode: mode,
337
- }))
338
- }
339
- type="button"
340
- >
341
- {mode}
342
- </button>
343
- ))}
344
- </div>
345
- </div>
346
-
347
- <div className="field">
348
- <span>Output</span>
349
- <div className="segmented" role="group">
350
- {(["image/jpeg", "image/png", "image/webp"] as OutputFormat[]).map(
351
- (mimeType) => (
352
- <button
353
- aria-pressed={format === mimeType}
354
- key={mimeType}
355
- onClick={() => setFormat(mimeType)}
356
- type="button"
357
- >
358
- {formatLabel(mimeType)}
359
- </button>
360
- ),
361
- )}
362
- </div>
363
- </div>
364
-
365
- <button
366
- className="button button-primary button-wide"
367
- disabled={frames.length === 0 || isZipping}
368
- onClick={() => void downloadAll()}
369
- type="button"
370
- >
371
- <ArchiveIcon />
372
- Download ZIP
373
- </button>
374
- </aside>
375
- </section>
376
-
377
- {frames.length > 0 && (
378
- <section className="frames-section">
379
- <div className="section-head">
380
- <h2>Extracted frames</h2>
381
- <span>{frames.length} files</span>
382
- </div>
383
- <div className="frame-grid">
384
- {frames.map((frame, index) => (
385
- <article className="frame-card" key={frame.url}>
386
- <img alt={`Extracted frame ${index + 1}`} src={frame.url} />
387
- <div className="frame-info">
388
- <div>
389
- <strong>{String(index + 1).padStart(2, "0")}</strong>
390
- <span>{frame.width} x {frame.height}</span>
391
- </div>
392
- <button
393
- aria-label={`Download ${frame.fileName}`}
394
- className="icon-button"
395
- onClick={() => downloadBlob(frame.blob, frame.fileName)}
396
- title="Download frame"
397
- type="button"
398
- >
399
- <DownloadIcon />
400
- </button>
401
- </div>
402
- </article>
403
- ))}
404
- </div>
405
- </section>
406
- )}
407
- </main>
408
- );
409
- }
410
-
411
- async function detectRegions(
412
- file: File,
413
- bitmap: ImageBitmap,
414
- settings: DetectionSettings,
415
- ) {
416
- try {
417
- const formData = new FormData();
418
- formData.append("image", file);
419
- formData.append("minFrameSize", String(settings.minFrameSize));
420
- formData.append("separatorMode", settings.separatorMode);
421
- formData.append("sensitivity", String(settings.sensitivity));
422
-
423
- const response = await fetch("/api/extract", {
424
- method: "POST",
425
- body: formData,
426
- });
427
-
428
- const payload = (await response.json()) as
429
- | {
430
- regions: FrameRegion[];
431
- rows: number;
432
- columns: number;
433
- engine: string;
434
- }
435
- | { error: string };
436
-
437
- if (!response.ok || "error" in payload) {
438
- throw new Error("error" in payload ? payload.error : "Python extraction failed.");
439
- }
440
-
441
- return payload;
442
- } catch {
443
- const analysis = buildAnalysisImageData(bitmap);
444
- return {
445
- ...detectGridRegions({
446
- imageData: analysis.imageData,
447
- originalWidth: bitmap.width,
448
- originalHeight: bitmap.height,
449
- settings,
450
- }),
451
- engine: "browser-fallback",
452
- };
453
- }
454
- }
455
-
456
- function buildAnalysisImageData(bitmap: ImageBitmap) {
457
- const scale = Math.min(1, maxAnalysisSide / Math.max(bitmap.width, bitmap.height));
458
- const width = Math.max(1, Math.round(bitmap.width * scale));
459
- const height = Math.max(1, Math.round(bitmap.height * scale));
460
- const canvas = document.createElement("canvas");
461
- canvas.width = width;
462
- canvas.height = height;
463
- const context = getCanvasContext(canvas);
464
- context.drawImage(bitmap, 0, 0, width, height);
465
-
466
- return {
467
- imageData: context.getImageData(0, 0, width, height),
468
- scale,
469
- };
470
- }
471
-
472
- async function cropRegions(
473
- bitmap: ImageBitmap,
474
- regions: FrameRegion[],
475
- baseName: string,
476
- format: OutputFormat,
477
- ) {
478
- const canvas = document.createElement("canvas");
479
- const context = getCanvasContext(canvas);
480
- const extension = extensionForFormat(format);
481
- const frames: ExtractedFrame[] = [];
482
-
483
- for (const region of regions) {
484
- canvas.width = region.width;
485
- canvas.height = region.height;
486
- context.clearRect(0, 0, region.width, region.height);
487
- context.drawImage(
488
- bitmap,
489
- region.x,
490
- region.y,
491
- region.width,
492
- region.height,
493
- 0,
494
- 0,
495
- region.width,
496
- region.height,
497
- );
498
-
499
- const blob = await canvasToBlob(canvas, format, format === "image/jpeg" ? 0.94 : 0.98);
500
- const fileName = `${baseName || "grid"}-r${String(region.row + 1).padStart(2, "0")}-c${String(region.col + 1).padStart(2, "0")}.${extension}`;
501
- frames.push({
502
- ...region,
503
- blob,
504
- fileName,
505
- url: URL.createObjectURL(blob),
506
- });
507
- }
508
-
509
- return frames;
510
- }
511
-
512
- function getCanvasContext(canvas: HTMLCanvasElement) {
513
- const context = canvas.getContext("2d", {
514
- alpha: false,
515
- willReadFrequently: true,
516
- });
517
-
518
- if (!context) {
519
- throw new Error("Canvas is unavailable in this browser.");
520
- }
521
-
522
- return context;
523
- }
524
-
525
- function canvasToBlob(
526
- canvas: HTMLCanvasElement,
527
- type: OutputFormat,
528
- quality: number,
529
- ) {
530
- return new Promise<Blob>((resolve, reject) => {
531
- canvas.toBlob(
532
- (blob) => {
533
- if (blob) resolve(blob);
534
- else reject(new Error("Could not render extracted frame."));
535
- },
536
- type,
537
- quality,
538
- );
539
- });
540
- }
541
-
542
- function revokeFrames(frames: ExtractedFrame[]) {
543
- for (const frame of frames) {
544
- URL.revokeObjectURL(frame.url);
545
- }
546
- }
547
-
548
- function downloadBlob(blob: Blob, fileName: string) {
549
- const url = URL.createObjectURL(blob);
550
- const anchor = document.createElement("a");
551
- anchor.href = url;
552
- anchor.download = fileName;
553
- document.body.appendChild(anchor);
554
- anchor.click();
555
- anchor.remove();
556
- window.setTimeout(() => URL.revokeObjectURL(url), 500);
557
- }
558
-
559
- function toErrorMessage(error: unknown) {
560
- if (error instanceof Error) return error.message;
561
- return "Something went wrong.";
562
- }
563
-
564
- function fileBaseName(file: File) {
565
- return file.name.replace(/\.[^/.]+$/, "").replace(/[^\w-]+/g, "-");
566
- }
567
-
568
- function formatLabel(type: OutputFormat) {
569
- if (type === "image/png") return "PNG";
570
- if (type === "image/webp") return "WebP";
571
- return "JPG";
572
- }
573
-
574
- function extensionForFormat(type: OutputFormat) {
575
- if (type === "image/png") return "png";
576
- if (type === "image/webp") return "webp";
577
- return "jpg";
578
- }
579
-
580
- function UploadIcon() {
581
- return (
582
- <svg aria-hidden="true" viewBox="0 0 24 24">
583
- <path d="M12 3 7.5 7.5l1.4 1.4 2.1-2.08V16h2V6.82l2.1 2.08 1.4-1.4L12 3Z" />
584
- <path d="M5 15h2v3h10v-3h2v5H5v-5Z" />
585
- </svg>
586
- );
587
- }
588
-
589
- function ScanIcon() {
590
- return (
591
- <svg aria-hidden="true" viewBox="0 0 24 24">
592
- <path d="M5 4h5v2H7v3H5V4Zm9 0h5v5h-2V6h-3V4ZM5 15h2v3h3v2H5v-5Zm12 3v-3h2v5h-5v-2h3ZM8 9h8v6H8V9Zm2 2v2h4v-2h-4Z" />
593
- </svg>
594
- );
595
- }
596
-
597
- function ImageIcon() {
598
- return (
599
- <svg aria-hidden="true" viewBox="0 0 24 24">
600
- <path d="M4 5h16v14H4V5Zm2 2v8.6l3.5-3.5 2.5 2.5 4-4 2 2V7H6Zm0 10h12.6L16 14.4l-4 4-2.5-2.5L6 19.4V17Z" />
601
- </svg>
602
- );
603
- }
604
-
605
- function ArchiveIcon() {
606
- return (
607
- <svg aria-hidden="true" viewBox="0 0 24 24">
608
- <path d="M4 4h16v5H4V4Zm2 2v1h12V6H6Zm0 5h12v9H6v-9Zm5 2v2h2v-2h-2Z" />
609
- </svg>
610
- );
611
- }
612
-
613
- function DownloadIcon() {
614
- return (
615
- <svg aria-hidden="true" viewBox="0 0 24 24">
616
- <path d="M11 4h2v8.18l2.1-2.08 1.4 1.4L12 16l-4.5-4.5 1.4-1.4 2.1 2.08V4Z" />
617
- <path d="M5 18h14v2H5v-2Z" />
618
- </svg>
619
- );
620
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eslint.config.mjs DELETED
@@ -1,12 +0,0 @@
1
- import nextVitals from "eslint-config-next/core-web-vitals";
2
- import nextTypescript from "eslint-config-next/typescript";
3
-
4
- const eslintConfig = [
5
- ...nextVitals,
6
- ...nextTypescript,
7
- {
8
- ignores: [".next/**", "node_modules/**", "out/**"],
9
- },
10
- ];
11
-
12
- export default eslintConfig;
 
 
 
 
 
 
 
 
 
 
 
 
 
grid2frame/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .extractor import DetectionResult, FrameRegion, detect_grid, extract_frames
2
+
3
+ __all__ = ["DetectionResult", "FrameRegion", "detect_grid", "extract_frames"]
grid2frame/extractor.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import cv2
7
+ import numpy as np
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class FrameRegion:
12
+ id: str
13
+ row: int
14
+ col: int
15
+ x: int
16
+ y: int
17
+ width: int
18
+ height: int
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DetectionResult:
23
+ regions: list[FrameRegion]
24
+ rows: int
25
+ columns: int
26
+ width: int
27
+ height: int
28
+
29
+
30
+ @dataclass
31
+ class Band:
32
+ start: int
33
+ end: int
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Segment:
38
+ start: int
39
+ end: int
40
+
41
+
42
+ def read_image(path: str | Path) -> np.ndarray:
43
+ image = cv2.imdecode(np.fromfile(Path(path), dtype=np.uint8), cv2.IMREAD_COLOR)
44
+ if image is None:
45
+ raise ValueError(f"Could not read image: {path}")
46
+ return image
47
+
48
+
49
+ def detect_grid(
50
+ image: np.ndarray,
51
+ min_frame_size: int = 80,
52
+ sensitivity: int = 58,
53
+ separator_mode: str = "auto",
54
+ ) -> DetectionResult:
55
+ height, width = image.shape[:2]
56
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
57
+
58
+ vertical_bands = find_separator_bands(
59
+ gray=gray,
60
+ axis="vertical",
61
+ min_frame_size=min_frame_size,
62
+ sensitivity=sensitivity,
63
+ separator_mode=separator_mode,
64
+ )
65
+ horizontal_bands = find_separator_bands(
66
+ gray=gray,
67
+ axis="horizontal",
68
+ min_frame_size=min_frame_size,
69
+ sensitivity=sensitivity,
70
+ separator_mode=separator_mode,
71
+ )
72
+
73
+ x_segments = bands_to_segments(vertical_bands, width, min_frame_size)
74
+ y_segments = bands_to_segments(horizontal_bands, height, min_frame_size)
75
+ regions: list[FrameRegion] = []
76
+
77
+ for row, y_segment in enumerate(y_segments):
78
+ for col, x_segment in enumerate(x_segments):
79
+ regions.append(
80
+ FrameRegion(
81
+ id=f"{row + 1}-{col + 1}",
82
+ row=row,
83
+ col=col,
84
+ x=x_segment.start,
85
+ y=y_segment.start,
86
+ width=x_segment.end - x_segment.start,
87
+ height=y_segment.end - y_segment.start,
88
+ )
89
+ )
90
+
91
+ return DetectionResult(
92
+ regions=regions,
93
+ rows=len(y_segments),
94
+ columns=len(x_segments),
95
+ width=width,
96
+ height=height,
97
+ )
98
+
99
+
100
+ def extract_frames(image: np.ndarray, regions: list[FrameRegion]) -> list[np.ndarray]:
101
+ frames = []
102
+ for region in regions:
103
+ frames.append(
104
+ image[
105
+ region.y : region.y + region.height,
106
+ region.x : region.x + region.width,
107
+ ].copy()
108
+ )
109
+ return frames
110
+
111
+
112
+ def annotate_regions(image: np.ndarray, regions: list[FrameRegion]) -> np.ndarray:
113
+ annotated = image.copy()
114
+ color = (190, 255, 55)
115
+
116
+ for region in regions:
117
+ cv2.rectangle(
118
+ annotated,
119
+ (region.x, region.y),
120
+ (region.x + region.width, region.y + region.height),
121
+ color,
122
+ max(1, round(max(image.shape[:2]) / 700)),
123
+ )
124
+
125
+ return annotated
126
+
127
+
128
+ def find_separator_bands(
129
+ gray: np.ndarray,
130
+ axis: str,
131
+ min_frame_size: int,
132
+ sensitivity: int,
133
+ separator_mode: str,
134
+ ) -> list[Band]:
135
+ sensitivity = max(0, min(100, sensitivity))
136
+ dark_threshold = 10 + round(sensitivity * 0.24)
137
+ light_threshold = 246 - round(sensitivity * 0.16)
138
+ line_ratio_threshold = 0.972 - sensitivity * 0.00035
139
+
140
+ if separator_mode == "dark":
141
+ separator_mask = gray <= dark_threshold
142
+ elif separator_mode == "light":
143
+ separator_mask = gray >= light_threshold
144
+ else:
145
+ separator_mask = (gray <= dark_threshold) | (gray >= light_threshold)
146
+
147
+ if axis == "vertical":
148
+ profile = separator_mask.mean(axis=0)
149
+ axis_size = gray.shape[1]
150
+ else:
151
+ profile = separator_mask.mean(axis=1)
152
+ axis_size = gray.shape[0]
153
+
154
+ candidate_indexes = np.flatnonzero(profile >= line_ratio_threshold)
155
+ bands = group_indexes(candidate_indexes)
156
+ max_thickness = max(2, min(round(axis_size * 0.018), round(min_frame_size * 0.35)))
157
+ bands = [band for band in bands if band.end - band.start + 1 <= max_thickness]
158
+
159
+ return merge_close_bands(bands, min_gap=max(2, round(min_frame_size * 0.08)))
160
+
161
+
162
+ def group_indexes(indexes: np.ndarray) -> list[Band]:
163
+ if indexes.size == 0:
164
+ return []
165
+
166
+ bands: list[Band] = []
167
+ start = int(indexes[0])
168
+ previous = int(indexes[0])
169
+
170
+ for raw_index in indexes[1:]:
171
+ index = int(raw_index)
172
+ if index <= previous + 2:
173
+ previous = index
174
+ continue
175
+
176
+ bands.append(Band(start=start, end=previous))
177
+ start = index
178
+ previous = index
179
+
180
+ bands.append(Band(start=start, end=previous))
181
+ return bands
182
+
183
+
184
+ def merge_close_bands(bands: list[Band], min_gap: int) -> list[Band]:
185
+ if not bands:
186
+ return []
187
+
188
+ merged = [bands[0]]
189
+ for band in bands[1:]:
190
+ current = merged[-1]
191
+ if band.start - current.end <= min_gap:
192
+ current.end = band.end
193
+ else:
194
+ merged.append(band)
195
+
196
+ return merged
197
+
198
+
199
+ def bands_to_segments(bands: list[Band], axis_size: int, min_frame_size: int) -> list[Segment]:
200
+ if not bands:
201
+ return [Segment(start=0, end=axis_size)]
202
+
203
+ edge_tolerance = max(3, round(axis_size * 0.008))
204
+ first_pixel = 0
205
+ last_pixel = axis_size
206
+ internal_bands: list[Band] = []
207
+
208
+ for band in bands:
209
+ if band.start <= edge_tolerance:
210
+ first_pixel = max(first_pixel, band.end + 1)
211
+ elif band.end >= axis_size - edge_tolerance - 1:
212
+ last_pixel = min(last_pixel, band.start)
213
+ else:
214
+ internal_bands.append(band)
215
+
216
+ segments: list[Segment] = []
217
+ segment_start = first_pixel
218
+
219
+ for band in internal_bands:
220
+ push_segment(segments, segment_start, band.start, min_frame_size)
221
+ segment_start = band.end + 1
222
+
223
+ push_segment(segments, segment_start, last_pixel, min_frame_size)
224
+ return segments
225
+
226
+
227
+ def push_segment(segments: list[Segment], start: int, end: int, min_frame_size: int) -> None:
228
+ start = max(0, int(round(start)))
229
+ end = max(start, int(round(end)))
230
+
231
+ if end - start >= min_frame_size:
232
+ segments.append(Segment(start=start, end=end))
lib/grid-detection.ts DELETED
@@ -1,290 +0,0 @@
1
- export type SeparatorMode = "auto" | "dark" | "light";
2
-
3
- export type DetectionSettings = {
4
- minFrameSize: number;
5
- separatorMode: SeparatorMode;
6
- sensitivity: number;
7
- };
8
-
9
- export type Segment = {
10
- start: number;
11
- end: number;
12
- };
13
-
14
- export type FrameRegion = {
15
- id: string;
16
- row: number;
17
- col: number;
18
- x: number;
19
- y: number;
20
- width: number;
21
- height: number;
22
- };
23
-
24
- type AxisSample = {
25
- candidate: boolean;
26
- score: number;
27
- };
28
-
29
- type Band = {
30
- start: number;
31
- end: number;
32
- };
33
-
34
- type DetectGridInput = {
35
- imageData: ImageData;
36
- originalWidth: number;
37
- originalHeight: number;
38
- settings: DetectionSettings;
39
- };
40
-
41
- export function detectGridRegions({
42
- imageData,
43
- originalWidth,
44
- originalHeight,
45
- settings,
46
- }: DetectGridInput) {
47
- const analysisWidth = imageData.width;
48
- const analysisHeight = imageData.height;
49
- const scaleX = analysisWidth / originalWidth;
50
- const scaleY = analysisHeight / originalHeight;
51
-
52
- const verticalBands = selectSeparatorBands(
53
- buildAxisProfile(imageData, "vertical", settings),
54
- analysisWidth,
55
- Math.max(3, Math.round(settings.minFrameSize * scaleX)),
56
- );
57
- const horizontalBands = selectSeparatorBands(
58
- buildAxisProfile(imageData, "horizontal", settings),
59
- analysisHeight,
60
- Math.max(3, Math.round(settings.minFrameSize * scaleY)),
61
- );
62
-
63
- const xSegments = bandsToSegments({
64
- bands: verticalBands,
65
- analysisSize: analysisWidth,
66
- originalSize: originalWidth,
67
- scale: scaleX,
68
- minFrameSize: settings.minFrameSize,
69
- });
70
- const ySegments = bandsToSegments({
71
- bands: horizontalBands,
72
- analysisSize: analysisHeight,
73
- originalSize: originalHeight,
74
- scale: scaleY,
75
- minFrameSize: settings.minFrameSize,
76
- });
77
-
78
- const regions = ySegments.flatMap((ySegment, row) =>
79
- xSegments.map((xSegment, col) => ({
80
- id: `${row + 1}-${col + 1}`,
81
- row,
82
- col,
83
- x: xSegment.start,
84
- y: ySegment.start,
85
- width: xSegment.end - xSegment.start,
86
- height: ySegment.end - ySegment.start,
87
- })),
88
- );
89
-
90
- return {
91
- regions,
92
- rows: ySegments.length,
93
- columns: xSegments.length,
94
- xSegments,
95
- ySegments,
96
- };
97
- }
98
-
99
- function buildAxisProfile(
100
- imageData: ImageData,
101
- axis: "vertical" | "horizontal",
102
- settings: DetectionSettings,
103
- ): AxisSample[] {
104
- const { data, width, height } = imageData;
105
- const axisSize = axis === "vertical" ? width : height;
106
- const crossSize = axis === "vertical" ? height : width;
107
- const crossStep = Math.max(1, Math.floor(crossSize / 900));
108
- const threshold = sensitivityToThreshold(settings.sensitivity);
109
- const edgeThreshold = Math.max(0.28, threshold * 0.74);
110
-
111
- return Array.from({ length: axisSize }, (_, axisIndex) => {
112
- let count = 0;
113
- let darkCount = 0;
114
- let lightCount = 0;
115
- let luminanceSum = 0;
116
- let luminanceSquareSum = 0;
117
- let edgeDiffSum = 0;
118
-
119
- for (let crossIndex = 0; crossIndex < crossSize; crossIndex += crossStep) {
120
- const x = axis === "vertical" ? axisIndex : crossIndex;
121
- const y = axis === "vertical" ? crossIndex : axisIndex;
122
- const offset = (y * width + x) * 4;
123
- const luminance =
124
- data[offset] * 0.2126 +
125
- data[offset + 1] * 0.7152 +
126
- data[offset + 2] * 0.0722;
127
-
128
- luminanceSum += luminance;
129
- luminanceSquareSum += luminance * luminance;
130
- if (luminance <= 42) darkCount += 1;
131
- if (luminance >= 218) lightCount += 1;
132
-
133
- if (axisIndex < axisSize - 1) {
134
- const nextX = axis === "vertical" ? axisIndex + 1 : crossIndex;
135
- const nextY = axis === "vertical" ? crossIndex : axisIndex + 1;
136
- const nextOffset = (nextY * width + nextX) * 4;
137
- const nextLuminance =
138
- data[nextOffset] * 0.2126 +
139
- data[nextOffset + 1] * 0.7152 +
140
- data[nextOffset + 2] * 0.0722;
141
- edgeDiffSum += Math.abs(luminance - nextLuminance);
142
- }
143
-
144
- count += 1;
145
- }
146
-
147
- const mean = luminanceSum / count;
148
- const variance = Math.max(0, luminanceSquareSum / count - mean * mean);
149
- const standardDeviation = Math.sqrt(variance);
150
- const uniformity = 1 - Math.min(standardDeviation / 64, 1);
151
- const darkRatio = darkCount / count;
152
- const lightRatio = lightCount / count;
153
- const extremeRatio =
154
- settings.separatorMode === "dark"
155
- ? darkRatio
156
- : settings.separatorMode === "light"
157
- ? lightRatio
158
- : Math.max(darkRatio, lightRatio);
159
- const separatorScore = uniformity * extremeRatio;
160
- const edgeScore =
161
- Math.min(edgeDiffSum / count / 34, 1) *
162
- Math.min(extremeRatio / 0.72, 1);
163
- const score = Math.max(
164
- edgeScore * 1.2,
165
- separatorScore * 0.68 + edgeScore * 0.38,
166
- );
167
-
168
- return {
169
- candidate:
170
- (separatorScore >= threshold &&
171
- uniformity >= 0.46 &&
172
- extremeRatio >= 0.5) ||
173
- (edgeScore >= edgeThreshold && extremeRatio >= 0.48),
174
- score,
175
- };
176
- });
177
- }
178
-
179
- function sensitivityToThreshold(sensitivity: number) {
180
- const normalized = clamp(sensitivity, 0, 100) / 100;
181
- return 0.68 - normalized * 0.34;
182
- }
183
-
184
- function selectSeparatorBands(
185
- profile: AxisSample[],
186
- axisSize: number,
187
- minFrameAnalysisSize: number,
188
- ) {
189
- const edgeZone = Math.max(2, Math.round(axisSize * 0.006));
190
- const candidates = profile
191
- .map((sample, index) => ({ index, ...sample }))
192
- .filter(({ candidate, index, score }) => {
193
- if (!candidate) return false;
194
- if (index <= edgeZone || index >= axisSize - edgeZone - 1) return true;
195
-
196
- const previous = profile[index - 1]?.score ?? 0;
197
- const next = profile[index + 1]?.score ?? 0;
198
- return score >= previous && score >= next;
199
- })
200
- .sort((a, b) => b.score - a.score);
201
-
202
- const selected: Array<{ index: number; score: number }> = [];
203
- const minGap = Math.max(4, Math.round(minFrameAnalysisSize * 0.84));
204
-
205
- for (const candidate of candidates) {
206
- const isEdge =
207
- candidate.index <= edgeZone || candidate.index >= axisSize - edgeZone - 1;
208
- const hasNearby = selected.some(
209
- (current) => Math.abs(current.index - candidate.index) < minGap,
210
- );
211
-
212
- if (isEdge || !hasNearby) {
213
- selected.push({ index: candidate.index, score: candidate.score });
214
- }
215
- }
216
-
217
- return selected
218
- .sort((a, b) => a.index - b.index)
219
- .map(({ index }) => ({
220
- start: Math.max(0, index - 1),
221
- end: Math.min(axisSize - 1, index + 1),
222
- }));
223
- }
224
-
225
- function bandsToSegments({
226
- bands,
227
- analysisSize,
228
- originalSize,
229
- scale,
230
- minFrameSize,
231
- }: {
232
- bands: Band[];
233
- analysisSize: number;
234
- originalSize: number;
235
- scale: number;
236
- minFrameSize: number;
237
- }) {
238
- const edgeTolerance = Math.max(2, Math.round(analysisSize * 0.006));
239
- let firstPixel = 0;
240
- let lastPixel = originalSize;
241
- const internalBands: Band[] = [];
242
-
243
- for (const band of bands) {
244
- if (band.start <= edgeTolerance) {
245
- firstPixel = Math.max(firstPixel, Math.ceil((band.end + 1) / scale));
246
- continue;
247
- }
248
-
249
- if (band.end >= analysisSize - edgeTolerance - 1) {
250
- lastPixel = Math.min(lastPixel, Math.floor(band.start / scale));
251
- continue;
252
- }
253
-
254
- internalBands.push(band);
255
- }
256
-
257
- const segments: Segment[] = [];
258
- let segmentStart = firstPixel;
259
-
260
- for (const band of internalBands) {
261
- const segmentEnd = Math.floor(band.start / scale);
262
- pushSegment(segments, segmentStart, segmentEnd, minFrameSize);
263
- segmentStart = Math.ceil((band.end + 1) / scale);
264
- }
265
-
266
- pushSegment(segments, segmentStart, lastPixel, minFrameSize);
267
-
268
- return segments;
269
- }
270
-
271
- function pushSegment(
272
- segments: Segment[],
273
- start: number,
274
- end: number,
275
- minFrameSize: number,
276
- ) {
277
- const normalizedStart = Math.max(0, Math.round(start));
278
- const normalizedEnd = Math.max(normalizedStart, Math.round(end));
279
-
280
- if (normalizedEnd - normalizedStart >= minFrameSize) {
281
- segments.push({
282
- start: normalizedStart,
283
- end: normalizedEnd,
284
- });
285
- }
286
- }
287
-
288
- function clamp(value: number, min: number, max: number) {
289
- return Math.min(max, Math.max(min, value));
290
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
next-env.d.ts DELETED
@@ -1,6 +0,0 @@
1
- /// <reference types="next" />
2
- /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
4
-
5
- // NOTE: This file should not be edited
6
- // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
 
 
 
 
 
 
 
next.config.ts DELETED
@@ -1,5 +0,0 @@
1
- import type { NextConfig } from "next";
2
-
3
- const nextConfig: NextConfig = {};
4
-
5
- export default nextConfig;
 
 
 
 
 
 
package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
package.json DELETED
@@ -1,27 +0,0 @@
1
- {
2
- "name": "gride2frame",
3
- "version": "0.1.0",
4
- "private": true,
5
- "scripts": {
6
- "dev": "next dev",
7
- "build": "next build",
8
- "start": "next start",
9
- "lint": "eslint .",
10
- "setup:python": "python3 -m venv .venv && .venv/bin/python3 -m pip install -r requirements.txt"
11
- },
12
- "dependencies": {
13
- "jszip": "^3.10.1",
14
- "next": "latest",
15
- "react": "latest",
16
- "react-dom": "latest"
17
- },
18
- "devDependencies": {
19
- "@playwright/test": "^1.59.1",
20
- "@types/node": "latest",
21
- "@types/react": "latest",
22
- "@types/react-dom": "latest",
23
- "eslint": "latest",
24
- "eslint-config-next": "latest",
25
- "typescript": "latest"
26
- }
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1 +1,2 @@
1
  opencv-python-headless==4.13.0.92
 
 
1
  opencv-python-headless==4.13.0.92
2
+ gradio==6.1.0
scripts/extract_grid.py CHANGED
@@ -1,24 +1,13 @@
1
  #!/usr/bin/env python3
2
  import argparse
 
3
  import json
4
  import sys
5
- from dataclasses import dataclass
6
  from pathlib import Path
7
 
8
- import cv2
9
- import numpy as np
10
 
11
-
12
- @dataclass
13
- class Band:
14
- start: int
15
- end: int
16
-
17
-
18
- @dataclass
19
- class Segment:
20
- start: int
21
- end: int
22
 
23
 
24
  def parse_args() -> argparse.Namespace:
@@ -32,172 +21,17 @@ def parse_args() -> argparse.Namespace:
32
 
33
  def main() -> int:
34
  args = parse_args()
35
- image = cv2.imdecode(np.fromfile(args.image, dtype=np.uint8), cv2.IMREAD_COLOR)
36
-
37
- if image is None:
38
- raise ValueError(f"Could not read image: {args.image}")
39
-
40
- height, width = image.shape[:2]
41
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
42
-
43
- vertical_bands = find_separator_bands(
44
- gray=gray,
45
- axis="vertical",
46
  min_frame_size=args.min_frame_size,
47
- sensitivity=args.sensitivity,
48
  separator_mode=args.separator_mode,
49
- )
50
- horizontal_bands = find_separator_bands(
51
- gray=gray,
52
- axis="horizontal",
53
- min_frame_size=args.min_frame_size,
54
  sensitivity=args.sensitivity,
55
- separator_mode=args.separator_mode,
56
- )
57
-
58
- x_segments = bands_to_segments(vertical_bands, width, args.min_frame_size)
59
- y_segments = bands_to_segments(horizontal_bands, height, args.min_frame_size)
60
-
61
- regions = []
62
- for row, y_segment in enumerate(y_segments):
63
- for col, x_segment in enumerate(x_segments):
64
- regions.append(
65
- {
66
- "id": f"{row + 1}-{col + 1}",
67
- "row": row,
68
- "col": col,
69
- "x": x_segment.start,
70
- "y": y_segment.start,
71
- "width": x_segment.end - x_segment.start,
72
- "height": y_segment.end - y_segment.start,
73
- }
74
- )
75
-
76
- print(
77
- json.dumps(
78
- {
79
- "regions": regions,
80
- "rows": len(y_segments),
81
- "columns": len(x_segments),
82
- "width": width,
83
- "height": height,
84
- }
85
- )
86
  )
 
87
  return 0
88
 
89
 
90
- def find_separator_bands(
91
- gray: np.ndarray,
92
- axis: str,
93
- min_frame_size: int,
94
- sensitivity: int,
95
- separator_mode: str,
96
- ) -> list[Band]:
97
- sensitivity = max(0, min(100, sensitivity))
98
- dark_threshold = 10 + round(sensitivity * 0.24)
99
- light_threshold = 246 - round(sensitivity * 0.16)
100
- line_ratio_threshold = 0.972 - sensitivity * 0.00035
101
-
102
- if separator_mode == "dark":
103
- separator_mask = gray <= dark_threshold
104
- elif separator_mode == "light":
105
- separator_mask = gray >= light_threshold
106
- else:
107
- separator_mask = (gray <= dark_threshold) | (gray >= light_threshold)
108
-
109
- if axis == "vertical":
110
- profile = separator_mask.mean(axis=0)
111
- axis_size = gray.shape[1]
112
- else:
113
- profile = separator_mask.mean(axis=1)
114
- axis_size = gray.shape[0]
115
-
116
- candidate_indexes = np.flatnonzero(profile >= line_ratio_threshold)
117
- bands = group_indexes(candidate_indexes)
118
-
119
- max_thickness = max(
120
- 2,
121
- min(round(axis_size * 0.018), round(min_frame_size * 0.35)),
122
- )
123
- bands = [band for band in bands if band.end - band.start + 1 <= max_thickness]
124
-
125
- return merge_close_bands(bands, min_gap=max(2, round(min_frame_size * 0.08)))
126
-
127
-
128
- def group_indexes(indexes: np.ndarray) -> list[Band]:
129
- if indexes.size == 0:
130
- return []
131
-
132
- bands: list[Band] = []
133
- start = int(indexes[0])
134
- previous = int(indexes[0])
135
-
136
- for raw_index in indexes[1:]:
137
- index = int(raw_index)
138
- if index <= previous + 2:
139
- previous = index
140
- continue
141
-
142
- bands.append(Band(start=start, end=previous))
143
- start = index
144
- previous = index
145
-
146
- bands.append(Band(start=start, end=previous))
147
- return bands
148
-
149
-
150
- def merge_close_bands(bands: list[Band], min_gap: int) -> list[Band]:
151
- if not bands:
152
- return []
153
-
154
- merged = [bands[0]]
155
- for band in bands[1:]:
156
- current = merged[-1]
157
- if band.start - current.end <= min_gap:
158
- current.end = band.end
159
- else:
160
- merged.append(band)
161
-
162
- return merged
163
-
164
-
165
- def bands_to_segments(bands: list[Band], axis_size: int, min_frame_size: int) -> list[Segment]:
166
- if not bands:
167
- return [Segment(start=0, end=axis_size)]
168
-
169
- edge_tolerance = max(3, round(axis_size * 0.008))
170
- first_pixel = 0
171
- last_pixel = axis_size
172
- internal_bands: list[Band] = []
173
-
174
- for band in bands:
175
- if band.start <= edge_tolerance:
176
- first_pixel = max(first_pixel, band.end + 1)
177
- elif band.end >= axis_size - edge_tolerance - 1:
178
- last_pixel = min(last_pixel, band.start)
179
- else:
180
- internal_bands.append(band)
181
-
182
- segments: list[Segment] = []
183
- segment_start = first_pixel
184
-
185
- for band in internal_bands:
186
- push_segment(segments, segment_start, band.start, min_frame_size)
187
- segment_start = band.end + 1
188
-
189
- push_segment(segments, segment_start, last_pixel, min_frame_size)
190
- return segments
191
-
192
-
193
- def push_segment(segments: list[Segment], start: int, end: int, min_frame_size: int) -> None:
194
- start = max(0, int(round(start)))
195
- end = max(start, int(round(end)))
196
-
197
- if end - start >= min_frame_size:
198
- segments.append(Segment(start=start, end=end))
199
-
200
-
201
  if __name__ == "__main__":
202
  try:
203
  raise SystemExit(main())
 
1
  #!/usr/bin/env python3
2
  import argparse
3
+ from dataclasses import asdict
4
  import json
5
  import sys
 
6
  from pathlib import Path
7
 
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
 
9
 
10
+ from grid2frame.extractor import detect_grid, read_image
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
  def parse_args() -> argparse.Namespace:
 
21
 
22
  def main() -> int:
23
  args = parse_args()
24
+ image = read_image(args.image)
25
+ result = detect_grid(
26
+ image,
 
 
 
 
 
 
 
 
27
  min_frame_size=args.min_frame_size,
 
28
  separator_mode=args.separator_mode,
 
 
 
 
 
29
  sensitivity=args.sensitivity,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  )
31
+ print(json.dumps(asdict(result)))
32
  return 0
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  if __name__ == "__main__":
36
  try:
37
  raise SystemExit(main())
tsconfig.json DELETED
@@ -1,41 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2017",
4
- "lib": [
5
- "dom",
6
- "dom.iterable",
7
- "esnext"
8
- ],
9
- "allowJs": false,
10
- "skipLibCheck": true,
11
- "strict": true,
12
- "noEmit": true,
13
- "esModuleInterop": true,
14
- "module": "esnext",
15
- "moduleResolution": "bundler",
16
- "resolveJsonModule": true,
17
- "isolatedModules": true,
18
- "jsx": "react-jsx",
19
- "incremental": true,
20
- "plugins": [
21
- {
22
- "name": "next"
23
- }
24
- ],
25
- "paths": {
26
- "@/*": [
27
- "./*"
28
- ]
29
- }
30
- },
31
- "include": [
32
- "next-env.d.ts",
33
- "**/*.ts",
34
- "**/*.tsx",
35
- ".next/types/**/*.ts",
36
- ".next/dev/types/**/*.ts"
37
- ],
38
- "exclude": [
39
- "node_modules"
40
- ]
41
- }