scan, split, OCR, prepare for LLM
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. from __future__ import annotations
  2. import tempfile
  3. from pathlib import Path
  4. import cv2
  5. import numpy as np
  6. import pytest
  7. from src.split.slicer import (
  8. SUPPORTED_EXTENSIONS,
  9. VALID_ROTATIONS,
  10. add_border,
  11. crop_to_content,
  12. detect_grid,
  13. find_content_bounds,
  14. generate_output_paths,
  15. load_image,
  16. parse_slice,
  17. process_image,
  18. rotate_image,
  19. save_page,
  20. slice_grid,
  21. )
  22. def _create_test_image(width: int = 400, height: int = 300) -> np.ndarray:
  23. image = np.zeros((height, width, 3), dtype=np.uint8)
  24. image[:, :] = (200, 200, 200)
  25. return image
  26. def _image_to_path(image: np.ndarray, suffix: str = ".png") -> Path:
  27. with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
  28. cv2.imwrite(tmp.name, image)
  29. return Path(tmp.name)
  30. class TestParseSlice:
  31. def test_simple_2x2(self) -> None:
  32. assert parse_slice("2:2") == (2, 2)
  33. def test_2x4(self) -> None:
  34. assert parse_slice("2:4") == (2, 4)
  35. def test_1x1(self) -> None:
  36. assert parse_slice("1:1") == (1, 1)
  37. def test_large(self) -> None:
  38. assert parse_slice("10:10") == (10, 10)
  39. def test_invalid_format_missing_colon(self) -> None:
  40. with pytest.raises(ValueError, match="Неверный формат --slice"):
  41. parse_slice("22")
  42. def test_invalid_format_extra_colon(self) -> None:
  43. with pytest.raises(ValueError, match="Неверный формат --slice"):
  44. parse_slice("2:3:4")
  45. def test_non_integer_value(self) -> None:
  46. with pytest.raises(ValueError, match="Неверный формат --slice"):
  47. parse_slice("a:b")
  48. def test_non_integer_col(self) -> None:
  49. with pytest.raises(ValueError, match="Неверный формат --slice"):
  50. parse_slice("x:2")
  51. def test_zero_cols(self) -> None:
  52. with pytest.raises(ValueError, match="Неверное значение --slice"):
  53. parse_slice("0:2")
  54. def test_zero_rows(self) -> None:
  55. with pytest.raises(ValueError, match="Неверное значение --slice"):
  56. parse_slice("2:0")
  57. def test_negative_cols(self) -> None:
  58. with pytest.raises(ValueError, match="Неверное значение --slice"):
  59. parse_slice("-1:2")
  60. def test_blank_string(self) -> None:
  61. with pytest.raises(ValueError):
  62. parse_slice("")
  63. class TestSliceGrid:
  64. def test_2x2_exact(self) -> None:
  65. image = _create_test_image(400, 300)
  66. pages = slice_grid(image, 2, 2)
  67. assert len(pages) == 4
  68. assert pages[0].shape == (150, 200, 3)
  69. assert pages[1].shape == (150, 200, 3)
  70. assert pages[2].shape == (150, 200, 3)
  71. assert pages[3].shape == (150, 200, 3)
  72. def test_1x1(self) -> None:
  73. image = _create_test_image(100, 100)
  74. pages = slice_grid(image, 1, 1)
  75. assert len(pages) == 1
  76. assert pages[0].shape == (100, 100, 3)
  77. def test_2x3(self) -> None:
  78. image = _create_test_image(300, 200)
  79. pages = slice_grid(image, 2, 3)
  80. assert len(pages) == 6
  81. for page in pages:
  82. assert page.shape == (100, 100, 3)
  83. def test_uneven_division_width(self) -> None:
  84. image = _create_test_image(width=403, height=300)
  85. pages = slice_grid(image, 1, 2)
  86. assert len(pages) == 2
  87. assert pages[0].shape == (300, 201, 3)
  88. assert pages[1].shape == (300, 202, 3)
  89. def test_uneven_division_height(self) -> None:
  90. image = _create_test_image(width=400, height=301)
  91. pages = slice_grid(image, 2, 1)
  92. assert len(pages) == 2
  93. assert pages[0].shape == (150, 400, 3)
  94. assert pages[1].shape == (151, 400, 3)
  95. def test_uneven_both(self) -> None:
  96. image = _create_test_image(width=401, height=301)
  97. pages = slice_grid(image, 2, 2)
  98. assert len(pages) == 4
  99. assert pages[0].shape == (150, 200, 3)
  100. assert pages[1].shape == (150, 201, 3)
  101. assert pages[2].shape == (151, 200, 3)
  102. assert pages[3].shape == (151, 201, 3)
  103. def test_pixel_content_preserved(self) -> None:
  104. image = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
  105. pages = slice_grid(image, 2, 2)
  106. assert np.array_equal(image[0:50, 0:50], pages[0])
  107. assert np.array_equal(image[0:50, 50:100], pages[1])
  108. assert np.array_equal(image[50:100, 0:50], pages[2])
  109. assert np.array_equal(image[50:100, 50:100], pages[3])
  110. def test_total_pixels_preserved(self) -> None:
  111. image = _create_test_image(width=401, height=301)
  112. pages = slice_grid(image, 3, 3)
  113. total = sum(page.shape[0] * page.shape[1] for page in pages)
  114. assert total == 401 * 301
  115. def test_order_2x2(self) -> None:
  116. image = _create_test_image(200, 200)
  117. image[0:100, 0:100] = (255, 0, 0)
  118. image[0:100, 100:200] = (0, 255, 0)
  119. image[100:200, 0:100] = (0, 0, 255)
  120. image[100:200, 100:200] = (255, 255, 0)
  121. pages = slice_grid(image, 2, 2)
  122. assert pages[0][0, 0].tolist() == [255, 0, 0]
  123. assert pages[1][0, 0].tolist() == [0, 255, 0]
  124. assert pages[2][0, 0].tolist() == [0, 0, 255]
  125. assert pages[3][0, 0].tolist() == [255, 255, 0]
  126. def test_order_2x4(self) -> None:
  127. image = _create_test_image(400, 200)
  128. pages = slice_grid(image, 2, 4)
  129. assert len(pages) == 8
  130. assert pages[0].shape == (100, 100, 3)
  131. assert pages[7].shape == (100, 100, 3)
  132. def test_4x4(self) -> None:
  133. image = _create_test_image(400, 400)
  134. pages = slice_grid(image, 4, 4)
  135. assert len(pages) == 16
  136. for page in pages:
  137. assert page.shape == (100, 100, 3)
  138. class TestAddBorder:
  139. def test_default_border(self) -> None:
  140. image = _create_test_image(100, 100)
  141. result = add_border(image, 20)
  142. assert result.shape == (140, 140, 3)
  143. def test_zero_border(self) -> None:
  144. image = _create_test_image(100, 100)
  145. result = add_border(image, 0)
  146. assert np.array_equal(result, image)
  147. def test_border_is_white(self) -> None:
  148. image = _create_test_image(100, 100)
  149. border = 10
  150. result = add_border(image, border)
  151. assert np.all(result[0:border, :] == 255)
  152. assert np.all(result[-border:, :] == 255)
  153. assert np.all(result[:, 0:border] == 255)
  154. assert np.all(result[:, -border:] == 255)
  155. def test_large_border(self) -> None:
  156. image = _create_test_image(10, 10)
  157. result = add_border(image, 50)
  158. assert result.shape == (110, 110, 3)
  159. class TestRotateImage:
  160. def test_rotate_90(self) -> None:
  161. image = _create_test_image(200, 100)
  162. result = rotate_image(image, 90)
  163. assert result.shape == (200, 100, 3)
  164. def test_rotate_180(self) -> None:
  165. image = _create_test_image(200, 100)
  166. result = rotate_image(image, 180)
  167. assert result.shape == (100, 200, 3)
  168. def test_rotate_270(self) -> None:
  169. image = _create_test_image(200, 100)
  170. result = rotate_image(image, 270)
  171. assert result.shape == (200, 100, 3)
  172. def test_invalid_angle(self) -> None:
  173. image = _create_test_image()
  174. with pytest.raises(ValueError, match="Недопустимый угол поворота"):
  175. rotate_image(image, 45)
  176. def test_invalid_angle_zero(self) -> None:
  177. image = _create_test_image()
  178. with pytest.raises(ValueError):
  179. rotate_image(image, 0)
  180. def test_valid_rotations_set(self) -> None:
  181. assert VALID_ROTATIONS == frozenset({90, 180, 270})
  182. class TestGenerateOutputPaths:
  183. def test_simple(self) -> None:
  184. paths = generate_output_paths(Path("scan001.jpg"), 2, Path("/tmp"))
  185. assert paths == [Path("/tmp/scan001_01.jpg"), Path("/tmp/scan001_02.jpg")]
  186. def test_tiff_extension(self) -> None:
  187. paths = generate_output_paths(Path("doc.tiff"), 3, Path("out"))
  188. assert paths == [
  189. Path("out/doc_01.tiff"),
  190. Path("out/doc_02.tiff"),
  191. Path("out/doc_03.tiff"),
  192. ]
  193. def test_png_extension(self) -> None:
  194. paths = generate_output_paths(Path("img.PNG"), 1, Path("out"))
  195. assert paths == [Path("out/img_01.png")]
  196. def test_zero_padding(self) -> None:
  197. paths = generate_output_paths(Path("scan.jpg"), 12, Path("out"))
  198. assert paths[0] == Path("out/scan_01.jpg")
  199. assert paths[9] == Path("out/scan_10.jpg")
  200. assert paths[11] == Path("out/scan_12.jpg")
  201. def test_many_pages(self) -> None:
  202. paths = generate_output_paths(Path("scan.jpg"), 100, Path("out"))
  203. assert paths[0] == Path("out/scan_001.jpg")
  204. assert paths[99] == Path("out/scan_100.jpg")
  205. def test_dot_in_filename(self) -> None:
  206. paths = generate_output_paths(Path("scan.001.jpg"), 2, Path("out"))
  207. assert paths == [Path("out/scan.001_01.jpg"), Path("out/scan.001_02.jpg")]
  208. class TestLoadImage:
  209. def test_valid_jpeg(self) -> None:
  210. image = _create_test_image()
  211. path = _image_to_path(image, ".jpg")
  212. loaded = load_image(path)
  213. assert loaded.shape == (300, 400, 3)
  214. def test_valid_png(self) -> None:
  215. image = _create_test_image()
  216. path = _image_to_path(image, ".png")
  217. loaded = load_image(path)
  218. assert loaded.shape == (300, 400, 3)
  219. def test_valid_tiff(self) -> None:
  220. image = _create_test_image()
  221. path = _image_to_path(image, ".tiff")
  222. loaded = load_image(path)
  223. assert loaded.shape == (300, 400, 3)
  224. def test_file_not_found(self) -> None:
  225. with pytest.raises(FileNotFoundError, match="Файл не найден"):
  226. load_image(Path("/tmp/nonexistent_scan2html_test.jpg"))
  227. def test_unsupported_extension(self) -> None:
  228. path = Path("/tmp/test.bmp")
  229. with pytest.raises(ValueError, match="Неподдерживаемый формат"):
  230. load_image(path)
  231. def test_unsupported_extension_no_dot(self) -> None:
  232. path = Path("/tmp/test")
  233. with pytest.raises(ValueError, match="Неподдерживаемый формат"):
  234. load_image(path)
  235. def test_supported_extensions_set(self) -> None:
  236. assert SUPPORTED_EXTENSIONS == frozenset({".jpg", ".jpeg", ".png", ".tiff", ".tif"})
  237. class TestSavePage:
  238. def test_saves_file(self) -> None:
  239. image = _create_test_image()
  240. with tempfile.TemporaryDirectory() as tmp:
  241. path = Path(tmp) / "test.png"
  242. save_page(image, path)
  243. assert path.exists()
  244. loaded = cv2.imread(str(path))
  245. assert loaded is not None
  246. assert loaded.shape == (300, 400, 3)
  247. def test_creates_parent_dir(self) -> None:
  248. image = _create_test_image()
  249. with tempfile.TemporaryDirectory() as tmp:
  250. path = Path(tmp) / "subdir" / "test.png"
  251. save_page(image, path)
  252. assert path.exists()
  253. class TestProcessImage:
  254. def test_end_to_end_2x2(self) -> None:
  255. image = _create_test_image(400, 300)
  256. path = _image_to_path(image, ".jpg")
  257. with tempfile.TemporaryDirectory() as tmp:
  258. result = process_image(Path(path), 2, 2, Path(tmp))
  259. assert len(result) == 4
  260. for p in result:
  261. assert p.exists()
  262. loaded = cv2.imread(str(p))
  263. assert loaded.shape == (250, 300, 3)
  264. def test_end_to_end_with_border(self) -> None:
  265. image = _create_test_image(200, 200)
  266. path = _image_to_path(image, ".png")
  267. with tempfile.TemporaryDirectory() as tmp:
  268. result = process_image(Path(path), 2, 2, Path(tmp), border_px=10)
  269. assert len(result) == 4
  270. for p in result:
  271. assert p.exists()
  272. loaded = cv2.imread(str(p))
  273. assert loaded.shape == (120, 120, 3)
  274. def test_end_to_end_tiff(self) -> None:
  275. image = _create_test_image(400, 400)
  276. path = _image_to_path(image, ".tiff")
  277. with tempfile.TemporaryDirectory() as tmp:
  278. result = process_image(Path(path), 2, 2, Path(tmp))
  279. assert len(result) == 4
  280. for p in result:
  281. assert p.suffix.lower() == ".tiff"
  282. assert p.exists()
  283. def test_end_to_end_with_rotation(self) -> None:
  284. image = _create_test_image(200, 400)
  285. path = _image_to_path(image, ".png")
  286. with tempfile.TemporaryDirectory() as tmp:
  287. result = process_image(Path(path), 2, 2, Path(tmp), pre_rotate=90)
  288. assert len(result) == 4
  289. for p in result:
  290. assert p.exists()
  291. loaded = cv2.imread(str(result[0]))
  292. assert loaded.shape[0] > 0
  293. class TestFindContentBounds:
  294. def test_uniform_image(self) -> None:
  295. image = _create_test_image(200, 150)
  296. image[:, :] = (255, 255, 255)
  297. x1, y1, x2, y2 = find_content_bounds(image)
  298. assert (x1, y1, x2, y2) == (0, 0, 200, 150)
  299. def test_single_dot_center(self) -> None:
  300. image = _create_test_image(200, 150)
  301. image[:, :] = (255, 255, 255)
  302. image[75, 100] = (0, 0, 0)
  303. x1, y1, x2, y2 = find_content_bounds(image)
  304. assert abs(x1 - 100) <= 1 and abs(x2 - 100) <= 1
  305. assert abs(y1 - 75) <= 1 and abs(y2 - 75) <= 1
  306. def test_rectangle_content(self) -> None:
  307. image = _create_test_image(200, 150)
  308. image[:, :] = (255, 255, 255)
  309. image[30:100, 50:140] = (0, 0, 0)
  310. x1, y1, x2, y2 = find_content_bounds(image)
  311. assert x1 == 50
  312. assert y1 == 30
  313. assert x2 == 139
  314. assert y2 == 99
  315. def test_content_at_edges(self) -> None:
  316. image = _create_test_image(200, 150)
  317. image[:, :] = (255, 255, 255)
  318. image[0:10, :] = (0, 0, 0)
  319. image[:, 0:10] = (0, 0, 0)
  320. x1, y1, _x2, _y2 = find_content_bounds(image)
  321. assert y1 == 0
  322. assert x1 == 0
  323. def test_grayscale_content(self) -> None:
  324. image = _create_test_image(100, 100)
  325. image[:, :] = (255, 255, 255)
  326. image[20:30, 20:30] = (128, 128, 128)
  327. x1, y1, x2, y2 = find_content_bounds(image)
  328. assert x1 <= 29 and x2 >= 20
  329. assert y1 <= 29 and y2 >= 20
  330. class TestCropToContent:
  331. def test_crop_centered_content(self) -> None:
  332. image = _create_test_image(200, 150)
  333. image[:, :] = (255, 255, 255)
  334. image[30:50, 40:60] = (0, 0, 0)
  335. result = crop_to_content(image, border_px=10)
  336. assert result.shape[0] >= 20
  337. assert result.shape[1] >= 20
  338. def test_crop_uniform_image_no_change(self) -> None:
  339. image = _create_test_image(100, 100)
  340. image[:, :] = (255, 255, 255)
  341. result = crop_to_content(image, border_px=10)
  342. assert result.shape == (100, 100, 3)
  343. def test_border_capped_by_image_edge(self) -> None:
  344. image = _create_test_image(100, 100)
  345. image[:, :] = (255, 255, 255)
  346. image[10:30, 10:30] = (0, 0, 0)
  347. result = crop_to_content(image, border_px=200)
  348. assert result.shape == (100, 100, 3)
  349. def test_border_equals_distance_to_edge(self) -> None:
  350. image = _create_test_image(100, 100)
  351. image[:, :] = (255, 255, 255)
  352. image[40:60, 30:70] = (0, 0, 0)
  353. result = crop_to_content(image, border_px=50)
  354. assert result.shape[1] <= 100
  355. assert result.shape[0] <= 100
  356. def test_dark_bg_no_crop_uniform(self) -> None:
  357. image = _create_test_image(100, 100)
  358. image[:, :] = (0, 0, 0)
  359. result = crop_to_content(image, border_px=0)
  360. assert result.shape == (100, 100, 3)
  361. def test_zero_border_exact_crop(self) -> None:
  362. image = _create_test_image(200, 150)
  363. image[:, :] = (255, 255, 255)
  364. image[20:100, 30:170] = (0, 0, 0)
  365. result = crop_to_content(image, border_px=0)
  366. assert result.shape == (80, 140, 3)
  367. class TestProcessImagePostCrop:
  368. def test_post_crop_reduces_size(self) -> None:
  369. image = _create_test_image(400, 400)
  370. image[:, :] = (255, 255, 255)
  371. image[100:200, 100:200] = (0, 0, 0)
  372. path = _image_to_path(image, ".png")
  373. with tempfile.TemporaryDirectory() as tmp:
  374. result = process_image(Path(path), 1, 1, Path(tmp), border_px=30, post_crop=True)
  375. assert len(result) == 1
  376. loaded = cv2.imread(str(result[0]))
  377. assert loaded.shape[0] < 400 or loaded.shape[1] < 400
  378. def test_post_crop_off_no_crop(self) -> None:
  379. image = _create_test_image(400, 300)
  380. path = _image_to_path(image, ".png")
  381. with tempfile.TemporaryDirectory() as tmp:
  382. result = process_image(Path(path), 1, 1, Path(tmp), post_crop=False)
  383. assert len(result) == 1
  384. loaded = cv2.imread(str(result[0]))
  385. assert loaded.shape == (400, 500, 3)
  386. def test_post_crop_e2e_2x2(self) -> None:
  387. image = _create_test_image(400, 400)
  388. image[:, :] = (255, 255, 255)
  389. image[50:150, 50:150] = (0, 0, 0)
  390. image[50:150, 250:350] = (0, 0, 0)
  391. image[250:350, 50:150] = (0, 0, 0)
  392. image[250:350, 250:350] = (0, 0, 0)
  393. path = _image_to_path(image, ".png")
  394. with tempfile.TemporaryDirectory() as tmp:
  395. result_crop = process_image(Path(path), 2, 2, Path(tmp) / "crop", border_px=30, post_crop=True)
  396. result_no = process_image(Path(path), 2, 2, Path(tmp) / "no", border_px=30, post_crop=False)
  397. assert len(result_crop) == 4
  398. for cp, np in zip(result_crop, result_no):
  399. c = cv2.imread(str(cp))
  400. n = cv2.imread(str(np))
  401. assert c.shape[0] < n.shape[0]
  402. assert c.shape[1] < n.shape[1]
  403. class TestDetectGrid:
  404. def test_2x2(self) -> None:
  405. image = _create_test_image(400, 300)
  406. image[:, :] = (255, 255, 255)
  407. dark = (0, 0, 0)
  408. gap = 20
  409. bw = (400 - 3 * gap) // 2
  410. bh = (300 - 3 * gap) // 2
  411. for r in range(2):
  412. for c in range(2):
  413. y = gap + r * (bh + gap)
  414. x = gap + c * (bw + gap)
  415. image[y:y + bh, x:x + bw] = dark
  416. cols, rows = detect_grid(image)
  417. assert cols == 2
  418. assert rows == 2
  419. def test_2x4(self) -> None:
  420. image = _create_test_image(800, 400)
  421. image[:, :] = (255, 255, 255)
  422. gap = 15
  423. bw = (800 - 5 * gap) // 4
  424. bh = (400 - 3 * gap) // 2
  425. dark = (0, 0, 0)
  426. for r in range(2):
  427. for c in range(4):
  428. y = gap + r * (bh + gap)
  429. x = gap + c * (bw + gap)
  430. image[y:y + bh, x:x + bw] = dark
  431. cols, rows = detect_grid(image)
  432. assert cols == 4
  433. assert rows == 2
  434. def test_1x1(self) -> None:
  435. image = _create_test_image(200, 200)
  436. image[:, :] = (255, 255, 255)
  437. image[20:180, 20:180] = (0, 0, 0)
  438. cols, rows = detect_grid(image)
  439. assert cols == 1
  440. assert rows == 1
  441. def test_3x3(self) -> None:
  442. image = _create_test_image(600, 450)
  443. image[:, :] = (255, 255, 255)
  444. dark = (0, 0, 0)
  445. gap = 10
  446. bw = (600 - 4 * gap) // 3
  447. bh = (450 - 4 * gap) // 3
  448. for r in range(3):
  449. for c in range(3):
  450. y = gap + r * (bh + gap)
  451. x = gap + c * (bw + gap)
  452. image[y:y + bh, x:x + bw] = dark
  453. cols, rows = detect_grid(image)
  454. assert cols == 3
  455. assert rows == 3
  456. def test_uniform_image(self) -> None:
  457. image = _create_test_image()
  458. cols, rows = detect_grid(image)
  459. assert cols == 1
  460. assert rows == 1