Spaces:
Sleeping
Sleeping
Commit ·
be1cb53
1
Parent(s): ed8146d
Refactor to Python Gradio app
Browse files- .gitignore +3 -0
- README.md +29 -0
- app.py +312 -0
- app/api/extract/route.ts +0 -123
- app/globals.css +0 -500
- app/icon.svg +0 -4
- app/layout.tsx +0 -20
- app/page.tsx +0 -5
- components/grid-extractor.tsx +0 -620
- eslint.config.mjs +0 -12
- grid2frame/__init__.py +3 -0
- grid2frame/extractor.py +232 -0
- lib/grid-detection.ts +0 -290
- next-env.d.ts +0 -6
- next.config.ts +0 -5
- package-lock.json +0 -0
- package.json +0 -27
- requirements.txt +1 -0
- scripts/extract_grid.py +7 -173
- tsconfig.json +0 -41
.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 |
-
|
| 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 =
|
| 36 |
-
|
| 37 |
-
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|