From 39adea4ee35b3abd1b3fdb65e54cd06883b6e995 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 25 Sep 2022 15:00:45 -0800 Subject: [PATCH] feat (WIP): bring png OCR scanning support (#1670) * Add pytesseract * Add simple ocr endpoint replace extension argument * feat/ocr-editor gui * fix frontend linting issues * Add service unit tests * Add split text modes & single ingredient/instruction editing * make split mode really reactive * Remove default step and ingredient * make the linter haappy * Accept only image uploads * Add automatic recipe title suggestion * Correct regex * fix incorrect array.map method usage * make the linter happy again * Swap route to use asset name * Rearange buttons * fix test data * feat: Allow making image the recipe image * Add translation * Make the linter happy * Restrict function setPropertyValueByPath generic * Restrict template literal type * Add a more friendly icon to creation page * update poetry lock file * Correct sloppy ocr classes * Make MyPy happy * Rewrite safer tests * Add tesseract to backend test CI container dependencies * Make canvas element a component global * Remove unwanted spaces in selected text * Add way to know if recipe was created with ocr * Access to ocr-editor for ocr recipes * Update Alembic revision * Make the frontend build * Fix scrolling offset bug * Allow creation of recipes with custom settings * Fix rebasing mistakes * Add format_tsv_output test * Exclude the tests data directory only * Enforce camelCase for frontend functions * Remove import of unused component * Fix type and class initialization * Add multi-language support * Highlight words in mount * Fix image ratio bug * Better ocr creation page * Revert awkward feature to scroll in Selection mode * Rebasing alembic migrations sux * Remove obsolete getShared function * Add function docstring * Move down ocr creation option * Make toolbar icons more generic * Show help at the bottom of the page * move ocr types to own file * Use template ref for the canvas * Use i18n.tc to get strings directly * Correct naming mistake * Move Ocr editor to own directory * Create Ocr Editor parts * Safeguard recipe properties access * Add loading frontend animation due to longer request time * minor cleanup chores Co-authored-by: Miroito --- .github/workflows/partial-backend.yml | 2 +- .pre-commit-config.yaml | 1 + ...0ed_add_is_ocr_recipe_column_to_recipes.py | 27 + frontend/api/class-interfaces/ocr.ts | 18 + .../api/class-interfaces/recipes/recipe.ts | 10 + frontend/api/index.ts | 5 + .../Domain/Recipe/RecipeActionMenu.vue | 23 +- .../Domain/Recipe/RecipeDialogBulkAdd.vue | 13 +- .../Domain/Recipe/RecipeIngredientEditor.vue | 15 +- .../Domain/Recipe/RecipeInstructions.vue | 1 + .../RecipeOcrEditorPage.vue | 381 ++++++++++++++ .../RecipeOcrEditorPageCanvas.vue | 484 ++++++++++++++++++ .../RecipeOcrEditorPageHelp.vue | 54 ++ .../Recipe/RecipeOcrEditorPage/index.ts | 3 + .../RecipePageParts/RecipePageHeader.vue | 10 +- frontend/lang/messages/en-US.json | 32 +- .../pages/recipe/_slug/ingredient-parser.vue | 14 +- frontend/pages/recipe/_slug/ocr-editor.vue | 51 ++ frontend/pages/recipe/create.vue | 5 + frontend/pages/recipe/create/ocr.vue | 81 +++ frontend/types/api-types/ocr.ts | 21 + frontend/types/api-types/recipe.ts | 1 + frontend/types/components.d.ts | 4 - frontend/types/ocr-types.ts | 73 +++ frontend/utils/icons/icon-type.ts | 7 + frontend/utils/icons/icons.ts | 12 + mealie/db/models/recipe/recipe.py | 1 + mealie/routes/__init__.py | 2 + mealie/routes/ocr/__init__.py | 7 + mealie/routes/ocr/pytesseract.py | 37 ++ mealie/routes/recipe/recipe_crud_routes.py | 37 ++ mealie/schema/ocr/__init__.py | 0 mealie/schema/ocr/ocr.py | 21 + mealie/schema/recipe/recipe.py | 3 +- mealie/services/ocr/__init__.py | 0 mealie/services/ocr/pytesseract.py | 56 ++ mealie/services/recipe/recipe_service.py | 20 +- poetry.lock | 18 +- pyproject.toml | 1 + tests/data/images/test-ocr.png | Bin 0 -> 11513 bytes tests/data/text/test-ocr.tsv | 73 +++ tests/data/text/test-ocr.txt | 9 + .../backup_v2_tests/test_alchemy_exporter.py | 2 +- .../services_tests/test_ocr_service.py | 58 +++ 44 files changed, 1659 insertions(+), 34 deletions(-) create mode 100644 alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py create mode 100644 frontend/api/class-interfaces/ocr.ts create mode 100644 frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPage.vue create mode 100644 frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageCanvas.vue create mode 100644 frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue create mode 100644 frontend/components/Domain/Recipe/RecipeOcrEditorPage/index.ts create mode 100644 frontend/pages/recipe/_slug/ocr-editor.vue create mode 100644 frontend/pages/recipe/create/ocr.vue create mode 100644 frontend/types/api-types/ocr.ts create mode 100644 frontend/types/ocr-types.ts create mode 100644 mealie/routes/ocr/__init__.py create mode 100644 mealie/routes/ocr/pytesseract.py create mode 100644 mealie/schema/ocr/__init__.py create mode 100644 mealie/schema/ocr/ocr.py create mode 100644 mealie/services/ocr/__init__.py create mode 100644 mealie/services/ocr/pytesseract.py create mode 100644 tests/data/images/test-ocr.png create mode 100644 tests/data/text/test-ocr.tsv create mode 100644 tests/data/text/test-ocr.txt create mode 100644 tests/unit_tests/services_tests/test_ocr_service.py diff --git a/.github/workflows/partial-backend.yml b/.github/workflows/partial-backend.yml index 322af86d..685aa287 100644 --- a/.github/workflows/partial-backend.yml +++ b/.github/workflows/partial-backend.yml @@ -62,7 +62,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install libsasl2-dev libldap2-dev libssl-dev + sudo apt-get install libsasl2-dev libldap2-dev libssl-dev tesseract-ocr-all poetry install poetry add "psycopg2-binary==2.8.6" if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc3cf447..721b4c7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,7 @@ repos: - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace + exclude: ^tests/data/ - repo: https://github.com/sondrelg/pep585-upgrade rev: "v1.0.1" # Use the sha / tag you want to point at hooks: diff --git a/alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py b/alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py new file mode 100644 index 00000000..0f5af034 --- /dev/null +++ b/alembic/versions/2022-08-05-17.07.07_089bfa50d0ed_add_is_ocr_recipe_column_to_recipes.py @@ -0,0 +1,27 @@ +"""Add is_ocr_recipe column to recipes + +Revision ID: 089bfa50d0ed +Revises: f30cf048c228 +Create Date: 2022-08-05 17:07:07.389271 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "089bfa50d0ed" +down_revision = "188374910655" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("recipes", sa.Column("is_ocr_recipe", sa.Boolean(), default=False, nullable=True)) + op.execute("UPDATE recipes SET is_ocr_recipe = FALSE") + # SQLITE does not support ALTER COLUMN, so the column will stay nullable to prevent making this migration a mess + # The Recipe pydantic model and the SQL server use False as default value anyway for this column so Null should be a very rare sight + + +def downgrade(): + op.drop_column("recipes", "is_ocr_recipe") diff --git a/frontend/api/class-interfaces/ocr.ts b/frontend/api/class-interfaces/ocr.ts new file mode 100644 index 00000000..1779fca4 --- /dev/null +++ b/frontend/api/class-interfaces/ocr.ts @@ -0,0 +1,18 @@ +import { BaseAPI } from "~/api/_base"; + +const prefix = "/api"; + +export class OcrAPI extends BaseAPI { + + // Currently unused in favor for the endpoint using asset names + async fileToTsv(file: File) { + const formData = new FormData(); + formData.append("file", file); + return await this.requests.post(`${prefix}/ocr/file-to-tsv`, formData); + } + + async assetToTsv(recipeSlug: string, assetName: string) { + return await this.requests.post(`${prefix}/ocr/asset-to-tsv`, { recipeSlug, assetName }); + } + +} diff --git a/frontend/api/class-interfaces/recipes/recipe.ts b/frontend/api/class-interfaces/recipes/recipe.ts index 2f305b7f..9cbb9770 100644 --- a/frontend/api/class-interfaces/recipes/recipe.ts +++ b/frontend/api/class-interfaces/recipes/recipe.ts @@ -34,6 +34,7 @@ const routes = { recipesCategory: `${prefix}/recipes/category`, recipesParseIngredient: `${prefix}/parser/ingredient`, recipesParseIngredients: `${prefix}/parser/ingredients`, + recipesCreateFromOcr: `${prefix}/recipes/create-ocr`, recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`, recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`, @@ -116,4 +117,13 @@ export class RecipeAPI extends BaseCRUDAPI { getZipRedirectUrl(recipeSlug: string, token: string) { return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`; } + + async createFromOcr(file: File, makeFileRecipeImage: boolean) { + const formData = new FormData(); + formData.append("file", file); + formData.append("extension", file.name.split(".").pop() ?? ""); + formData.append("makefilerecipeimage", String(makeFileRecipeImage)); + + return await this.requests.post(routes.recipesCreateFromOcr, formData); + } } diff --git a/frontend/api/index.ts b/frontend/api/index.ts index d434b5b7..3acb52f1 100644 --- a/frontend/api/index.ts +++ b/frontend/api/index.ts @@ -24,6 +24,7 @@ import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose import { GroupEventNotifierApi } from "./class-interfaces/group-event-notifier"; import { MealPlanRulesApi } from "./class-interfaces/group-mealplan-rules"; import { GroupDataSeederApi } from "./class-interfaces/group-seeder"; +import {OcrAPI} from "./class-interfaces/ocr"; import { ApiRequestInstance } from "~/types/api"; class Api { @@ -52,6 +53,7 @@ class Api { public groupEventNotifier: GroupEventNotifierApi; public upload: UploadFile; public seeders: GroupDataSeederApi; + public ocr: OcrAPI; constructor(requests: ApiRequestInstance) { // Recipes @@ -90,6 +92,9 @@ class Api { this.bulk = new BulkActionsAPI(requests); this.groupEventNotifier = new GroupEventNotifierApi(requests); + // ocr + this.ocr = new OcrAPI(requests); + Object.freeze(this); } } diff --git a/frontend/components/Domain/Recipe/RecipeActionMenu.vue b/frontend/components/Domain/Recipe/RecipeActionMenu.vue index 3679832f..9e275f13 100644 --- a/frontend/components/Domain/Recipe/RecipeActionMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeActionMenu.vue @@ -90,6 +90,7 @@ const SAVE_EVENT = "save"; const DELETE_EVENT = "delete"; const CLOSE_EVENT = "close"; const JSON_EVENT = "json"; +const OCR_EVENT = "ocr"; export default defineComponent({ components: { RecipeContextMenu, RecipeFavoriteBadge }, @@ -122,8 +123,12 @@ export default defineComponent({ type: Boolean, default: false, }, + showOcrButton: { + type: Boolean, + default: false, + }, }, - setup(_, context) { + setup(props, context) { const deleteDialog = ref(false); const { i18n, $globals } = useContext(); @@ -154,22 +159,26 @@ export default defineComponent({ }, ]; + if (props.showOcrButton) { + editorButtons.splice(2, 0, { + text: i18n.t("ocr-editor.ocr-editor"), + icon: $globals.icons.eye, + event: OCR_EVENT, + color: "accent", + }); + } + function emitHandler(event: string) { switch (event) { case CLOSE_EVENT: context.emit(CLOSE_EVENT); context.emit("input", false); break; - case SAVE_EVENT: - context.emit(SAVE_EVENT); - break; - case JSON_EVENT: - context.emit(JSON_EVENT); - break; case DELETE_EVENT: deleteDialog.value = true; break; default: + context.emit(event); break; } } diff --git a/frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue b/frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue index 620cb2d3..445d3a69 100644 --- a/frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue +++ b/frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue @@ -2,7 +2,7 @@
@@ -58,10 +58,17 @@ + + diff --git a/frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageCanvas.vue b/frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageCanvas.vue new file mode 100644 index 00000000..0a685b68 --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageCanvas.vue @@ -0,0 +1,484 @@ + + + diff --git a/frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue b/frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue new file mode 100644 index 00000000..a14ba02e --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/components/Domain/Recipe/RecipeOcrEditorPage/index.ts b/frontend/components/Domain/Recipe/RecipeOcrEditorPage/index.ts new file mode 100644 index 00000000..ff8b655f --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeOcrEditorPage/index.ts @@ -0,0 +1,3 @@ +import RecipeOcrEditorPage from "./RecipeOcrEditorPage.vue"; + +export default RecipeOcrEditorPage; diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue index 59ecdb19..be489ad1 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue @@ -42,6 +42,7 @@ :logged-in="$auth.loggedIn" :open="isEditMode" :recipe-id="recipe.id" + :show-ocr-button="recipe.isOcrRecipe" class="ml-auto mt-n8 pb-4" @close="setMode(PageMode.VIEW)" @json="toggleEditMode()" @@ -49,12 +50,13 @@ @save="$emit('save')" @delete="$emit('delete')" @print="printRecipe" + @ocr="goToOcrEditor" />
+ + diff --git a/frontend/pages/recipe/create.vue b/frontend/pages/recipe/create.vue index f6432552..2afaa0cc 100644 --- a/frontend/pages/recipe/create.vue +++ b/frontend/pages/recipe/create.vue @@ -52,6 +52,11 @@ export default defineComponent({ text: "Import with .zip", value: "zip", }, + { + icon: $globals.icons.fileImage, + text: "Create recipe from an image", + value: "ocr", + }, { icon: $globals.icons.link, text: "Bulk URL Import", diff --git a/frontend/pages/recipe/create/ocr.vue b/frontend/pages/recipe/create/ocr.vue new file mode 100644 index 00000000..d00bda2c --- /dev/null +++ b/frontend/pages/recipe/create/ocr.vue @@ -0,0 +1,81 @@ + + diff --git a/frontend/types/api-types/ocr.ts b/frontend/types/api-types/ocr.ts new file mode 100644 index 00000000..c8ffde1e --- /dev/null +++ b/frontend/types/api-types/ocr.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +/** +/* This file was automatically generated from pydantic models by running pydantic2ts. +/* Do not modify it by hand - just update the pydantic models and then re-run the script +*/ + +export interface OcrTsvResponse { + level: number; + pageNum: number; + blockNum: number; + parNum: number; + lineNum: number; + wordNum: number; + left: number; + top: number; + width: number; + height: number; + conf: number; + text: string; +} diff --git a/frontend/types/api-types/recipe.ts b/frontend/types/api-types/recipe.ts index aed12e7e..d4b5755d 100644 --- a/frontend/types/api-types/recipe.ts +++ b/frontend/types/api-types/recipe.ts @@ -214,6 +214,7 @@ export interface Recipe { [k: string]: unknown; }; comments?: RecipeCommentOut[]; + isOcrRecipe?: boolean; } export interface RecipeTool { id: string; diff --git a/frontend/types/components.d.ts b/frontend/types/components.d.ts index 0d9e4c13..1c7d22d8 100644 --- a/frontend/types/components.d.ts +++ b/frontend/types/components.d.ts @@ -30,10 +30,6 @@ import AdvancedOnly from "@/components/global/AdvancedOnly.vue"; import BasePageTitle from "@/components/global/BasePageTitle.vue"; import ButtonLink from "@/components/global/ButtonLink.vue"; -import TheSnackbar from "@/components/layout/TheSnackbar.vue"; -import AppHeader from "@/components/layout/AppHeader.vue"; -import AppSidebar from "@/components/layout/AppSidebar.vue"; -import AppFooter from "@/components/layout/AppFooter.vue"; declare module "vue" { export interface GlobalComponents { diff --git a/frontend/types/ocr-types.ts b/frontend/types/ocr-types.ts new file mode 100644 index 00000000..85e95149 --- /dev/null +++ b/frontend/types/ocr-types.ts @@ -0,0 +1,73 @@ +import { OcrTsvResponse } from "~/types/api-types/ocr"; +import { Recipe } from "~/types/api-types/recipe"; + +export type CanvasRect = { + startX: number; + startY: number; + w: number; + h: number; +}; + +export type ImagePosition = { + sx: number; + sy: number; + sWidth: number; + sHeight: number; + dx: number; + dy: number; + dWidth: number; + dHeight: number; + scale: number; + panStartPoint: { + x: number; + y: number; + }; +}; + +export type Mouse = { + current: { + x: number; + y: number; + }; + down: boolean; +}; + +// https://stackoverflow.com/questions/58434389/export typescript-deep-keyof-of-a-nested-object/58436959#58436959 +type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]; + +type Join = K extends string | number + ? P extends string | number + ? `${K}${"" extends P ? "" : "."}${P}` + : never + : never; + +export type Leaves = [D] extends [never] + ? never + : T extends object + ? { [K in keyof T]-?: Join> }[keyof T] + : ""; + +export type Paths = [D] extends [never] + ? never + : T extends object + ? { + [K in keyof T]-?: K extends string | number ? `${K}` | Join> : never; + }[keyof T] + : ""; + +export type SelectedRecipeLeaves = Leaves; + +export type CanvasModes = "selection" | "panAndZoom"; + +export type SelectedTextSplitModes = keyof OcrTsvResponse | "flatten"; + +export type ToolbarIcons = { + sectionTitle: string; + eventHandler(mode: T): void; + highlight: T; + icons: { + name: T; + icon: string; + tooltip: string; + }[]; +}[]; diff --git a/frontend/utils/icons/icon-type.ts b/frontend/utils/icons/icon-type.ts index a3377620..46f58526 100644 --- a/frontend/utils/icons/icon-type.ts +++ b/frontend/utils/icons/icon-type.ts @@ -125,4 +125,11 @@ export interface Icon { back: string; slotMachine: string; chevronDown: string; + + // Ocr toolbar + selectMode: string; + panAndZoom: string; + preserveLines: string; + preserveBlocks: string; + flatten: string; } diff --git a/frontend/utils/icons/icons.ts b/frontend/utils/icons/icons.ts index 41a45e02..9e97a668 100644 --- a/frontend/utils/icons/icons.ts +++ b/frontend/utils/icons/icons.ts @@ -118,6 +118,10 @@ import { mdiHelpCircleOutline, mdiDocker, mdiUndo, + mdiSelectionDrag, + mdiCursorMove, + mdiText, + mdiTextBoxOutline, } from "@mdi/js"; export const icons = { @@ -253,4 +257,12 @@ export const icons = { slotMachine: mdiSlotMachine, chevronDown: mdiChevronDown, chevronRight: mdiChevronRight, + + // Ocr toolbar + selectMode: mdiSelectionDrag, + panAndZoom: mdiCursorMove, + preserveLines: mdiText, + preserveBlocks: mdiTextBoxOutline, + flatten: mdiMinus, + }; diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 76b442ef..8bff9710 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -104,6 +104,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): rating = sa.Column(sa.Integer) org_url = sa.Column(sa.String) extras: list[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete-orphan") + is_ocr_recipe = sa.Column(sa.Boolean, default=False) # Time Stamp Properties date_added = sa.Column(sa.Date, default=datetime.date.today) diff --git a/mealie/routes/__init__.py b/mealie/routes/__init__.py index 849ec5b5..e421c01a 100644 --- a/mealie/routes/__init__.py +++ b/mealie/routes/__init__.py @@ -7,6 +7,7 @@ from . import ( comments, explore, groups, + ocr, organizers, parser, recipe, @@ -31,3 +32,4 @@ router.include_router(unit_and_foods.router) router.include_router(admin.router) router.include_router(validators.router) router.include_router(explore.router) +router.include_router(ocr.router) diff --git a/mealie/routes/ocr/__init__.py b/mealie/routes/ocr/__init__.py new file mode 100644 index 00000000..e23bbc92 --- /dev/null +++ b/mealie/routes/ocr/__init__.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from . import pytesseract + +router = APIRouter(prefix="/ocr") + +router.include_router(pytesseract.router) diff --git a/mealie/routes/ocr/pytesseract.py b/mealie/routes/ocr/pytesseract.py new file mode 100644 index 00000000..d2ffdec6 --- /dev/null +++ b/mealie/routes/ocr/pytesseract.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, File + +from mealie.routes._base import BaseUserController, controller +from mealie.schema.ocr.ocr import OcrAssetReq, OcrTsvResponse +from mealie.services.ocr.pytesseract import OcrService +from mealie.services.recipe.recipe_data_service import RecipeDataService +from mealie.services.recipe.recipe_service import RecipeService + +router = APIRouter() + + +@controller(router) +class OCRController(BaseUserController): + def __init__(self): + self.ocr_service = OcrService() + + @router.post("/", response_model=str) + def image_to_string(self, file: bytes = File(...)): + return self.ocr_service.image_to_string(file) + + @router.post("/file-to-tsv", response_model=list[OcrTsvResponse]) + def file_to_tsv(self, file: bytes = File(...)): + tsv = self.ocr_service.image_to_tsv(file) + return self.ocr_service.format_tsv_output(tsv) + + @router.post("/asset-to-tsv", response_model=list[OcrTsvResponse]) + def asset_to_tsv(self, req: OcrAssetReq): + recipe_service = RecipeService(self.repos, self.user, self.group) + recipe = recipe_service._get_recipe(req.recipe_slug) + if recipe.id is None: + return [] + data_service = RecipeDataService(recipe.id, recipe.group_id) + asset_path = data_service.dir_assets.joinpath(req.asset_name) + file = open(asset_path, "rb") + tsv = self.ocr_service.image_to_tsv(file.read()) + + return self.ocr_service.format_tsv_output(tsv) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 2459e613..9e0f70ef 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -33,7 +33,10 @@ from mealie.schema.recipe.recipe import ( RecipeSummary, ) from mealie.schema.recipe.recipe_asset import RecipeAsset +from mealie.schema.recipe.recipe_ingredient import RecipeIngredient from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest +from mealie.schema.recipe.recipe_settings import RecipeSettings +from mealie.schema.recipe.recipe_step import RecipeStep from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse from mealie.schema.response.responses import ErrorResponse from mealie.services import urls @@ -435,3 +438,37 @@ class RecipeController(BaseRecipeController): self.mixins.update_one(recipe, slug) return asset_in + + # ================================================================================================================== + # OCR + @router.post("/create-ocr", status_code=201, response_model=str) + def create_recipe_ocr( + self, extension: str = Form(...), file: UploadFile = File(...), makefilerecipeimage: bool = Form(...) + ): + """Takes an image and creates a recipe based on the image""" + slug = self.service.create_one( + Recipe( + name="New OCR Recipe", + recipe_ingredient=[RecipeIngredient(note="", title=None, unit=None, food=None, original_text=None)], + recipe_instructions=[RecipeStep(text="")], + is_ocr_recipe=True, + settings=RecipeSettings(show_assets=True), + id=None, + image=None, + recipe_yield=None, + rating=None, + orgURL=None, + date_added=None, + date_updated=None, + created_at=None, + update_at=None, + nutrition=None, + ) + ).slug + RecipeController.upload_recipe_asset(self, slug, "Original recipe image", "", extension, file) + if makefilerecipeimage: + # Get the pointer to the beginning of the file to read it once more + file.file.seek(0) + self.update_recipe_image(slug, file.file.read(), extension) + + return slug diff --git a/mealie/schema/ocr/__init__.py b/mealie/schema/ocr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mealie/schema/ocr/ocr.py b/mealie/schema/ocr/ocr.py new file mode 100644 index 00000000..fd535111 --- /dev/null +++ b/mealie/schema/ocr/ocr.py @@ -0,0 +1,21 @@ +from mealie.schema._mealie import MealieModel + + +class OcrTsvResponse(MealieModel): + level: int = 0 + page_num: int = 0 + block_num: int = 0 + par_num: int = 0 + line_num: int = 0 + word_num: int = 0 + left: int = 0 + top: int = 0 + width: int = 0 + height: int = 0 + conf: float = 0.0 + text: str = "" + + +class OcrAssetReq(MealieModel): + recipe_slug: str + asset_name: str diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index 7b0238cc..7d1f79e7 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -141,10 +141,11 @@ class Recipe(RecipeSummary): nutrition: Optional[Nutrition] # Mealie Specific - settings: Optional[RecipeSettings] = RecipeSettings() + settings: Optional[RecipeSettings] = None assets: Optional[list[RecipeAsset]] = [] notes: Optional[list[RecipeNote]] = [] extras: Optional[dict] = {} + is_ocr_recipe: Optional[bool] = False comments: Optional[list[RecipeCommentOut]] = [] diff --git a/mealie/services/ocr/__init__.py b/mealie/services/ocr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mealie/services/ocr/pytesseract.py b/mealie/services/ocr/pytesseract.py new file mode 100644 index 00000000..8bd63a36 --- /dev/null +++ b/mealie/services/ocr/pytesseract.py @@ -0,0 +1,56 @@ +from io import BytesIO + +import pytesseract +from PIL import Image + +from mealie.schema.ocr.ocr import OcrTsvResponse +from mealie.services._base_service import BaseService + + +class OcrService(BaseService): + """ + Class for ocr engines. + """ + + def image_to_string(self, image_data): + """ + Returns a plain text translation of an image + """ + return pytesseract.image_to_string(Image.open(image_data)) + + def image_to_tsv(self, image_data, lang=None): + """ + Returns the pytesseract default tsv output + """ + if lang is not None: + return pytesseract.image_to_data(Image.open(BytesIO(image_data)), lang=lang) + + return pytesseract.image_to_data(Image.open(BytesIO(image_data))) + + def format_tsv_output(self, tsv: str) -> list[OcrTsvResponse]: + """ + Returns a OcrTsvResponse from a default pytesseract tsv output + """ + lines = tsv.split("\n") + titles = [t.strip() for t in lines[0].split("\t")] + response: list[OcrTsvResponse] = [] + + for i in range(1, len(lines)): + if lines[i] == "": + continue + + line = OcrTsvResponse() + for key, value in zip(titles, lines[i].split("\t")): + if key == "text": + setattr(line, key, value.strip()) + elif key == "conf": + setattr(line, key, float(value.strip())) + elif key in OcrTsvResponse.__fields__: + setattr(line, key, int(value.strip())) + else: + continue + + if isinstance(line, OcrTsvResponse): + response.append(line) + + return response diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index e35be681..22408207 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -111,14 +111,18 @@ class RecipeService(BaseService): additional_attrs=create_data.dict(), ) - data.settings = RecipeSettings( - public=self.group.preferences.recipe_public, - show_nutrition=self.group.preferences.recipe_show_nutrition, - show_assets=self.group.preferences.recipe_show_assets, - landscape_view=self.group.preferences.recipe_landscape_view, - disable_comments=self.group.preferences.recipe_disable_comments, - disable_amount=self.group.preferences.recipe_disable_amount, - ) + if isinstance(create_data, CreateRecipe) or create_data.settings is None: + if self.group.preferences is not None: + data.settings = RecipeSettings( + public=self.group.preferences.recipe_public, + show_nutrition=self.group.preferences.recipe_show_nutrition, + show_assets=self.group.preferences.recipe_show_assets, + landscape_view=self.group.preferences.recipe_landscape_view, + disable_comments=self.group.preferences.recipe_disable_comments, + disable_amount=self.group.preferences.recipe_disable_amount, + ) + else: + data.settings = RecipeSettings() return self.repos.recipes.create(data) diff --git a/poetry.lock b/poetry.lock index 51a04fe7..ac819893 100644 --- a/poetry.lock +++ b/poetry.lock @@ -821,7 +821,7 @@ requests = ["requests"] name = "packaging" version = "21.3" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -1083,6 +1083,18 @@ category = "dev" optional = false python-versions = ">=3.7" +[[package]] +name = "pytesseract" +version = "0.3.9" +description = "Python-tesseract is a python wrapper for Google's Tesseract-OCR" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +packaging = ">=21.3" +Pillow = ">=8.0.0" + [[package]] name = "pytest" version = "6.2.5" @@ -2309,6 +2321,10 @@ pyrsistent = [ {file = "pyrsistent-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:e24a828f57e0c337c8d8bb9f6b12f09dfdf0273da25fda9e314f0b684b415a07"}, {file = "pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96"}, ] +pytesseract = [ + {file = "pytesseract-0.3.9-py2.py3-none-any.whl", hash = "sha256:fecda37d1e4eaf744c657cd03a5daab4eb97c61506ac5550274322c8ae32eca2"}, + {file = "pytesseract-0.3.9.tar.gz", hash = "sha256:7e2bafc7f48d1bb71443ce4633a56f5e21925a98f220a36c336297edcd1956d0"}, +] pytest = [ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, diff --git a/pyproject.toml b/pyproject.toml index 9120221e..84decf7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ python-ldap = "^3.3.1" pydantic = "^1.9.1" tzdata = "^2021.5" pyhumps = "^3.5.3" +pytesseract = "^0.3.9" [tool.poetry.dev-dependencies] pylint = "^2.6.0" diff --git a/tests/data/images/test-ocr.png b/tests/data/images/test-ocr.png new file mode 100644 index 0000000000000000000000000000000000000000..1b699c9778d0b7b691845573347eac2e213664cb GIT binary patch literal 11513 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