diff --git a/backend/scripts/crop_question_row.py b/backend/scripts/crop_question_row.py new file mode 100644 index 0000000..d49843f --- /dev/null +++ b/backend/scripts/crop_question_row.py @@ -0,0 +1,38 @@ +""" +Crop a question row from a rendered PDF page PNG. + +Usage: + python crop_question_row.py + + Coordinates are NORMALIZED (0.0-1.0) relative to page dimensions. + Add --padding 0.01 for extra border (default 0.005). + +Example: + python crop_question_row.py page_005.png 0.0 0.12 1.0 0.22 2019_v1_a7.png +""" +import sys +import argparse +from PIL import Image + +def crop(page_png, x0, y0, x1, y1, out_png, padding=0.005): + img = Image.open(page_png) + w, h = img.size + px0 = max(0, int((x0 - padding) * w)) + py0 = max(0, int((y0 - padding) * h)) + px1 = min(w, int((x1 + padding) * w)) + py1 = min(h, int((y1 + padding) * h)) + cropped = img.crop((px0, py0, px1, py1)) + cropped.save(out_png) + print(f'Saved {out_png} ({cropped.width}x{cropped.height})') + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('page_png') + parser.add_argument('x0', type=float) + parser.add_argument('y0', type=float) + parser.add_argument('x1', type=float) + parser.add_argument('y1', type=float) + parser.add_argument('out_png') + parser.add_argument('--padding', type=float, default=0.005) + args = parser.parse_args() + crop(args.page_png, args.x0, args.y0, args.x1, args.y1, args.out_png, args.padding) diff --git a/backend/scripts/detect_table_rows.py b/backend/scripts/detect_table_rows.py new file mode 100644 index 0000000..1c1cc14 --- /dev/null +++ b/backend/scripts/detect_table_rows.py @@ -0,0 +1,77 @@ +""" +Detect horizontal table borders in a scanned PDF page PNG +and extract row bounding boxes. + +Usage: + python detect_table_rows.py [--min-width 0.7] [--debug] + +Prints detected row y-ranges as normalized (0-1) coordinates. +""" +import sys +import argparse +import numpy as np +from PIL import Image + + +def detect_rows(page_png, min_width_frac=0.7, debug=False): + img = Image.open(page_png).convert('L') # grayscale + arr = np.array(img) + h, w = arr.shape + + # Binarize: dark pixels (potential lines) = True + dark = arr < 128 + # Count dark pixels per row + row_dark_count = dark.sum(axis=1) + min_dark = int(min_width_frac * w) + + # Find rows that are mostly dark (horizontal lines) + is_line = row_dark_count > min_dark + + # Group consecutive line pixels into bands + line_bands = [] + in_band = False + band_start = 0 + for y in range(h): + if is_line[y] and not in_band: + in_band = True + band_start = y + elif not is_line[y] and in_band: + in_band = False + band_end = y + line_bands.append((band_start, band_end)) + if in_band: + line_bands.append((band_start, h)) + + if not line_bands: + print("No table lines detected. Try reducing --min-width.", file=sys.stderr) + return [] + + # Extract row y-ranges between consecutive line bands + rows = [] + for i in range(len(line_bands) - 1): + y_top = line_bands[i][1] # bottom of upper border + y_bot = line_bands[i + 1][0] # top of lower border + if y_bot - y_top > 5: # skip tiny gaps + rows.append((y_top / h, y_bot / h)) + + if debug: + print(f"Detected {len(line_bands)} line bands:") + for b in line_bands: + print(f" pixels {b[0]}-{b[1]} (y={b[0]/h:.3f}-{b[1]/h:.3f})") + print(f"\nDetected {len(rows)} content rows:") + for i, (y0, y1) in enumerate(rows): + print(f" row {i}: y={y0:.3f}-{y1:.3f} (pixels {int(y0*h)}-{int(y1*h)}, height={int((y1-y0)*h)}px)") + + return rows + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('page_png') + parser.add_argument('--min-width', type=float, default=0.7) + parser.add_argument('--debug', action='store_true') + args = parser.parse_args() + rows = detect_rows(args.page_png, min_width_frac=args.min_width, debug=args.debug) + if not args.debug: + for i, (y0, y1) in enumerate(rows): + print(f"row {i}: {y0:.4f} - {y1:.4f}") diff --git a/backend/scripts/render_pdf_page.py b/backend/scripts/render_pdf_page.py new file mode 100644 index 0000000..9ef1267 --- /dev/null +++ b/backend/scripts/render_pdf_page.py @@ -0,0 +1,43 @@ +""" +Render one or more pages of a PDF to PNG files. + +Usage: + python render_pdf_page.py [--dpi 300] [--out-dir ./pages] + python render_pdf_page.py "F:/ЦТ/2018.pdf" "5,6,7,8" --dpi 300 --out-dir ./tmp_pages + +page_numbers: comma-separated 1-based page numbers +""" +import sys +import os +import argparse +import fitz # PyMuPDF + +def render_pages(pdf_path, pages, dpi=300, out_dir='.'): + os.makedirs(out_dir, exist_ok=True) + doc = fitz.open(pdf_path) + scale = dpi / 72.0 + mat = fitz.Matrix(scale, scale) + results = [] + for page_num in pages: + idx = page_num - 1 + if idx < 0 or idx >= len(doc): + print(f' Page {page_num} out of range (doc has {len(doc)} pages)', file=sys.stderr) + continue + page = doc[idx] + pix = page.get_pixmap(matrix=mat) + out_path = os.path.join(out_dir, f'page_{page_num:03d}.png') + pix.save(out_path) + results.append(out_path) + print(f' Rendered page {page_num} -> {out_path} ({pix.width}x{pix.height})') + doc.close() + return results + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('pdf_path') + parser.add_argument('pages', help='comma-separated 1-based page numbers, e.g. "5,6,7,8"') + parser.add_argument('--dpi', type=int, default=300) + parser.add_argument('--out-dir', default='./tmp_pages') + args = parser.parse_args() + pages = [int(p.strip()) for p in args.pages.split(',')] + render_pages(args.pdf_path, pages, dpi=args.dpi, out_dir=args.out_dir) diff --git a/backend/scripts/seed_math_ct2020.js b/backend/scripts/seed_math_ct2020.js new file mode 100644 index 0000000..6e46468 --- /dev/null +++ b/backend/scripts/seed_math_ct2020.js @@ -0,0 +1,184 @@ +'use strict'; +/** + * ЦТ 2020 Математика — Вариант 1 (32 задания: A1-A20 + B1-B12) + * Ответы: страница 44-45 сборника (ЦТ 2020.pdf) + */ +const db = require('../src/db/db'); +const MATH_ID = 3; +const T = {arithmetic:16,word:17,numbers:18,trig:19,quadratic:20,progression:21,inequalities:22,geometry:23,functions:24,log:25,expineq:26,equations:27,stats:28}; +function getTopic(n){const e=db.prepare('SELECT id FROM topics WHERE subject_id=? AND LOWER(name)=LOWER(?)').get(MATH_ID,n);if(e)return e.id;return Number(db.prepare('INSERT INTO topics (subject_id,name) VALUES (?,?)').run(MATH_ID,n).lastInsertRowid);} +const Tx={stereo:getTopic('Стереометрия'),circle:getTopic('Окружность и круг'),sets:getTopic('Числовые промежутки'),}; +const ex=new Set(db.prepare('SELECT text FROM questions WHERE subject_id=3').all().map(q=>q.text.slice(0,80).trim())); +let added=0,skipped=0; +const insQ=db.prepare(`INSERT INTO questions (subject_id,topic_id,text,type,difficulty,year,explanation,correct_text,image,source_type) VALUES (?,?,?,?,?,?,?,?,?,?)`); +const insO=db.prepare(`INSERT INTO options (question_id,text,is_correct,order_index) VALUES (?,?,?,?)`); + +function q(tid,text,opts,diff,year,img){ + const key=text.slice(0,80).trim();if(ex.has(key)){skipped++;return;}ex.add(key); + const r=insQ.run(MATH_ID,tid,text,'single',diff,year||null,null,null,img||null,'ЦТ'); + const id=r.lastInsertRowid;opts.forEach((o,i)=>insO.run(id,o.t,o.c?1:0,i));added++; +} +function fb(tid,text,ans,diff,year){ + const a=String(ans); + const key=text.slice(0,80).trim();if(ex.has(key)){skipped++;return;}ex.add(key); + insQ.run(MATH_ID,tid,text,'fill-blank',diff,year||null,null,a,null,'ЦТ'); + added++; +} + +const run=db.transaction(()=>{ + +// ══ ЧАСТЬ A ══════════════════════════════════════════════════ + +// A1 — точка на графике y=5^x (ответ: 4 — точка (2;25)) +q(T.functions,`Укажите номер точки, которая принадлежит графику функции \\(y=5^x\\):\n1) \\((25;\\,2)\\); 2) \\((2;\\,10)\\); 3) \\((5;\\,25)\\); 4) \\((2;\\,25)\\); 5) \\((1;\\,0)\\).`, +[{t:'4',c:true},{t:'1',c:false},{t:'2',c:false},{t:'3',c:false},{t:'5',c:false}], +1,2020); + +// A2 — вписанный угол KML=38°, найти KNL [РИСУНОК; ответ: 4 — 52°] +q(Tx.circle,`A2. Если вписанный угол \\(KML\\), изображённый на рисунке, равен 38°, то вписанный угол \\(KNL\\) равен:\n1) 46°; 2) 38°; 3) 19°; 4) 52°; 5) 76°.`, +[{t:'4',c:true},{t:'1',c:false},{t:'2',c:false},{t:'3',c:false},{t:'5',c:false}], +1,2020,'/img/ct/math/2020_v1_a2.png'); + +// A3 — выражение для числа с c десятками и 3 единицами (ответ: 4 — 10c+3) +q(T.numbers,`Укажите номер выражения для определения натурального числа, содержащего \\(c\\) десятков и 3 единицы:\n1) \\(c+3\\); 2) \\(3c\\); 3) \\(3c+10\\); 4) \\(10c+3\\); 5) \\(30+c\\).`, +[{t:'4',c:true},{t:'1',c:false},{t:'2',c:false},{t:'3',c:false},{t:'5',c:false}], +1,2020); + +// A4 — наименьшее слагаемое меньше суммы на сколько, x+20=80 (ответ: 2 — 60) +q(T.numbers,`Определите, на сколько наименьшее слагаемое меньше суммы, если известно, что \\(x+20=80\\):\n1) 80; 2) 60; 3) 20; 4) 40; 5) 100.`, +[{t:'2',c:true},{t:'1',c:false},{t:'3',c:false},{t:'4',c:false},{t:'5',c:false}], +1,2020); + +// A5 — точка, симметричная A(5) относительно B(19) (ответ: 1 — C(33)) +q(T.numbers,`Среди точек \\(C(33)\\), \\(D(24)\\), \\(E(28)\\), \\(F(43)\\), \\(K(12)\\) координатной прямой укажите точку, симметричную точке \\(A(5)\\) относительно точки \\(B(19)\\):\n1) \\(C(33)\\); 2) \\(D(24)\\); 3) \\(E(28)\\); 4) \\(F(43)\\); 5) \\(K(12)\\).`, +[{t:'1',c:true},{t:'2',c:false},{t:'3',c:false},{t:'4',c:false},{t:'5',c:false}], +1,2020); + +// A6 — значение (3 1/7 - 2)·(1+3/4):9 (ответ: 5 — 2/9) +q(T.numbers,`Найдите значение выражения \\(\\left(3\\dfrac{1}{7}-2\\right)\\cdot\\left(1+\\dfrac{3}{4}\\right)\\div9\\):\n1) \\(1\\dfrac{41}{63}\\); 2) \\(\\dfrac{3}{28}\\); 3) \\(1\\dfrac{19}{252}\\); 4) \\(-\\dfrac{11}{36}\\); 5) \\(\\dfrac{2}{9}\\).`, +[{t:'5',c:true},{t:'1',c:false},{t:'2',c:false},{t:'3',c:false},{t:'4',c:false}], +1,2020); + +// A7 — угол ANM четырёхугольника ABMN [РИСУНОК; ответ: 4 — 133°] +q(T.geometry,`A7. На рисунке изображён треугольник \\(ABC\\), в котором \\(\\angle ABC=104°\\), \\(\\angle ACB=29°\\). Используя данные рисунка, найдите градусную меру угла \\(ANM\\) четырёхугольника \\(ABMN\\):\n1) 151°; 2) 128°; 3) 119°; 4) 133°; 5) 104°.`, +[{t:'4',c:true},{t:'1',c:false},{t:'2',c:false},{t:'3',c:false},{t:'5',c:false}], +2,2020,'/img/ct/math/2020_v1_a7.png'); + +// A8 — число марок в альбоме, кратное 3 (ответ: 5 — 39) +q(T.word,`У Юры есть некоторое количество марок, а у Яна марок в 2 раза больше, чем у Юры. Мальчики поместили все свои марки в один альбом. Среди чисел 26; 38; 20; 37; 39 выберите то, которое может выражать количество марок, оказавшихся в альбоме:\n1) 26; 2) 38; 3) 20; 4) 37; 5) 39.`, +[{t:'5',c:true},{t:'1',c:false},{t:'2',c:false},{t:'3',c:false},{t:'4',c:false}], +1,2020); + +// A9 — координаты точки, симметричной A относительно l [РИСУНОК; ответ: 3 — (-2;1)] +q(T.geometry,`A9. На координатной плоскости даны точка \\(A\\), расположенная в узле сетки, и прямая \\(l\\) (см. рис.). Определите координаты точки, симметричной точке \\(A\\) относительно прямой \\(l\\):\n1) \\((1;\\,1)\\); 2) \\((-1;\\,0)\\); 3) \\((-2;\\,1)\\); 4) \\((0;\\,2)\\); 5) \\((-2;\\,4)\\).`, +[{t:'3',c:true},{t:'1',c:false},{t:'2',c:false},{t:'4',c:false},{t:'5',c:false}], +1,2020,'/img/ct/math/2020_v1_a9.png'); + +// A10 — найти a если 1,8x-0,6y=a проходит через A(-2;9) (ответ: 1 — -9) +q(T.functions,`График уравнения \\(1{,}8x-0{,}6y=a\\) проходит через точку \\(A(-2;\\,9)\\). Найдите число \\(a\\):\n1) \\(-9\\); 2) 9; 3) 7; 4) \\(-18\\); 5) \\(-2{,}4\\).`, +[{t:'1',c:true},{t:'2',c:false},{t:'3',c:false},{t:'4',c:false},{t:'5',c:false}], +1,2020); + +// A11 — через сколько минут плот прибудет в пункт отправления катера [РИСУНОК; ответ: 2 — 960 мин] +q(T.word,`A11. Из двух пунктов одновременно навстречу друг другу с постоянными скоростями отправляются по течению реки плот (I) и против течения реки катер (II). На рисунке приведены графики их движения. Определите, через сколько минут от начала движения плот прибудет в пункт, из которого отправился катер:\n1) 1020 мин; 2) 960 мин; 3) 510 мин; 4) 900 мин; 5) 480 мин.`, +[{t:'2',c:true},{t:'1',c:false},{t:'3',c:false},{t:'4',c:false},{t:'5',c:false}], +2,2020,'/img/ct/math/2020_v1_a11.png'); + +// A12 — внести -x под знак ∛ в -x·∛(2x²) (ответ: 3 — -∛(2x^5)) +q(T.numbers,`Внесите множитель под знак корня в выражении \\(-x\\cdot\\sqrt[3]{2x^2}\\):\n1) \\(\\sqrt[3]{2x^2}\\); 2) \\(\\sqrt[3]{2x^3}\\); 3) \\(-\\sqrt[3]{2x^5}\\); 4) \\(-\\sqrt[3]{2x}\\); 5) \\(-\\sqrt[3]{2x^{10}}\\).`, +[{t:'3',c:true},{t:'1',c:false},{t:'2',c:false},{t:'4',c:false},{t:'5',c:false}], +1,2020); + +// A13 — расстояние от M до центра окружности (r=13, AM=10, MB=12) (ответ: 2 — 7) +q(Tx.circle,`В окружности радиуса 13 проведена хорда \\(AB\\). Точка \\(M\\) делит хорду \\(AB\\) на отрезки длиной 10 и 12. Найдите расстояние от точки \\(M\\) до центра окружности:\n1) 11; 2) 7; 3) 3; 4) 4; 5) 8.`, +[{t:'2',c:true},{t:'1',c:false},{t:'3',c:false},{t:'4',c:false},{t:'5',c:false}], +2,2020); + +// A14 — верное утверждение для (8-x)(x+3)≥0 (ответ: 3 — 4 есть решение) +q(T.inequalities,`Для неравенства \\((8-x)(x+3)\\geq0\\) укажите номер верного утверждения:\n1) равносильно \\(|x|\\geq8\\);\n2) количество целых решений равно 12;\n3) 4 есть решение;\n4) ложно при \\(x\\in(-\\infty;8)\\);\n5) решением является промежуток \\([-8;\\,3]\\).`, +[{t:'3',c:true},{t:'1',c:false},{t:'2',c:false},{t:'4',c:false},{t:'5',c:false}], +2,2020); + +// A15 — площадь ромба (диагонали — корни 0,1x²-2,2x+7,4=0) (ответ: 5 — 37) +q(T.geometry,`Длины диагоналей ромба являются корнями уравнения \\(0{,}1x^2-2{,}2x+7{,}4=0\\). Составьте площадь ромба:\n1) 22; 2) 48; 3) 74; 4) 11; 5) 37.`, +[{t:'5',c:true},{t:'1',c:false},{t:'2',c:false},{t:'3',c:false},{t:'4',c:false}], +1,2020); + +// A16 — радиус r окружности через A(OA=1,7), B(OB=a), касающейся другой стороны прямого угла (ответ: 1) +q(Tx.circle,`На одной стороне прямого угла \\(O\\) отмечены точки \\(A\\) и \\(B\\), \\(OA=1{,}7\\), \\(OB=a\\), \\(OA0\\); 3)\\(\\mathrm{ctg}\\,\\alpha<0\\); 4)\\(\\alpha\\) — угол 1-й четверти; 5)\\(\\sin^2\\!\\alpha+\\cos^2\\!23°=1\\); 6)\\(\\alpha=-23°\\).`, +'135',2,2020); + +// B3 — яблоки: 3 корзины по x, +19 < 2x, +19+23 > 3x → x=20 +fb(T.word,`В каждую из трёх корзин положили одинаковое количество яблок. Если в одну из корзин добавить 19 яблок, то в ней их окажется меньше, чем в двух других корзинах вместе. Если в эту корзину добавить ещё 23 яблока, то в ней их станет больше, чем было первоначально в трёх корзинах вместе. Сколько яблок было в каждой корзине первоначально?`, +20,2,2020); + +// B4 — периметр равнобедренной трапеции (S=115, вписанная окружность r=5) → 46 +fb(T.geometry,`В равнобедренной трапеции, площадь которой равна 115, вписана окружность радиуса 5. Найдите периметр трапеции.`, +46,2,2020); + +// B5 — наим. (в градусах) × кол-во корней sin5x=cos65° на [-90°;90°] → -335 +fb(T.trig,`Найдите произведение наименьшего числа (в градусах) на количество различных корней уравнения \\(\\sin5x=\\cos65°\\) на промежутке \\([-90°;\\,90°]\\).`, +-335,2,2020); + +// B6 — площадь ABCD (AN:NB=AM:MD=1:2, S(CMN)=45) → 162 +fb(T.geometry,`Точки \\(N\\) и \\(M\\) лежат на сторонах \\(AB\\) и \\(AD\\) параллелограмма \\(ABCD\\) так, что \\(AN:NB=1:2\\), \\(AM:MD=1:2\\). Площадь треугольника \\(CMN\\) равна 45. Найдите площадь параллелограмма \\(ABCD\\).`, +162,2,2020); + +// B7 — наим.отриц.цел. × наим.цел.полож. для 3·16^((x²−28)/(x−1))−10·16^(−x/(6x))>8 → -32 +fb(T.inequalities,`Найдите произведение наименьшего отрицательного целого и наименьшего целого положительного решений неравенства \\(3\\cdot16^{\\frac{x^2-28}{x-1}}-10\\cdot16^{\\frac{-x}{6x}}>8\\).`, +-32,3,2020); + +// B8 — сумма корней ∛(x²+3x−40)·∛(x²−3x−40)=0 → -320 +fb(T.equations,`Найдите сумму корней (корень, если он единственный) уравнения \\(\\sqrt[3]{x^2+3x-40}\\cdot\\sqrt[3]{x^2-3x-40}=0\\).`, +-320,3,2020); + +// B9 — -36/cos²φ (призма ABCA₁B₁C₁, AB=AA₁=5, P,Q — середины AB и A₁C₁) → 160 +fb(Tx.stereo,`\\(ABCA_1B_1C_1\\) — правильная треугольная призма, \\(AB=5\\), \\(AA_1=5\\). Точки \\(P\\) и \\(Q\\) — середины рёбер \\(AB\\) и \\(A_1C_1\\) соответственно. Найдите значение \\(-\\dfrac{36}{\\cos^2\\!\\varphi}\\), где \\(\\varphi\\) — угол между \\(PQ\\) и \\(AB_1\\).`, +160,3,2020); + +// B10 — сумма квадратов корней log₁₀(17−x)²=2−2·log₁₀x → 577 +fb(T.log,`Найдите сумму квадратов корней (корень, если он единственный) уравнения \\(\\log_{10}(17-x)^2=2-2\\cdot\\log_{10}x\\).`, +577,3,2020); + +// B11 — k−m₀ для пар (m,n): m²+2n=n²−6n+13 → -16 +fb(T.equations,`Найдите все пары \\((m,n)\\) целых чисел, связанные соотношением \\(m^2+2n=n^2-6n+13\\). Пусть \\(k\\) — количество таких пар, \\(m_0\\) — наименьшее значение \\(m\\). Найдите \\(k-m_0\\).`, +-16,3,2020); + +// B12 — S/π для сферы через B, D₁, серед. BB₁ и CC₁ куба с ребром 4√6 → 336 +fb(Tx.stereo,`\\(ABCDA_1B_1C_1D_1\\) — куб, длина ребра \\(4\\sqrt{6}\\). Сфера проходит через вершины \\(B\\) и \\(D_1\\) и середины рёбер \\(BB_1\\) и \\(CC_1\\). Найдите площадь сферы \\(S\\) и запишите значение \\(\\dfrac{S}{\\pi}\\).`, +336,3,2020); + +}); +run(); +console.log(`ЦТ 2020 V1: добавлено ${added}, пропущено (дубликаты) ${skipped}`); diff --git a/frontend/img/ct/math/2020_v1_a11.png b/frontend/img/ct/math/2020_v1_a11.png new file mode 100644 index 0000000..d2f94c4 Binary files /dev/null and b/frontend/img/ct/math/2020_v1_a11.png differ diff --git a/frontend/img/ct/math/2020_v1_a2.png b/frontend/img/ct/math/2020_v1_a2.png new file mode 100644 index 0000000..b549347 Binary files /dev/null and b/frontend/img/ct/math/2020_v1_a2.png differ diff --git a/frontend/img/ct/math/2020_v1_a20.png b/frontend/img/ct/math/2020_v1_a20.png new file mode 100644 index 0000000..9f65ac2 Binary files /dev/null and b/frontend/img/ct/math/2020_v1_a20.png differ diff --git a/frontend/img/ct/math/2020_v1_a7.png b/frontend/img/ct/math/2020_v1_a7.png new file mode 100644 index 0000000..19c9a77 Binary files /dev/null and b/frontend/img/ct/math/2020_v1_a7.png differ diff --git a/frontend/img/ct/math/2020_v1_a9.png b/frontend/img/ct/math/2020_v1_a9.png new file mode 100644 index 0000000..06d82b6 Binary files /dev/null and b/frontend/img/ct/math/2020_v1_a9.png differ