diff --git a/.github/workflows/partial-backend.yml b/.github/workflows/partial-backend.yml index 322af86dd43..685aa2878e8 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 cc3cf44705b..721b4c7e406 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 00000000000..0f5af03429c --- /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 00000000000..1779fca4ff9 --- /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 2f305b7ff6c..9cbb97709d7 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 d434b5b7403..3acb52f1a5b 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 3679832f918..9e275f1348e 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 620cb2d3105..445d3a690ee 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 00000000000..0a685b68422 --- /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 00000000000..9205be67eec --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue @@ -0,0 +1,53 @@ + + diff --git a/frontend/components/Domain/Recipe/RecipeOcrEditorPage/index.ts b/frontend/components/Domain/Recipe/RecipeOcrEditorPage/index.ts new file mode 100644 index 00000000000..ff8b655f3d1 --- /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 59ecdb1937b..be489ad193f 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 f6432552692..2afaa0ccb9b 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 00000000000..d00bda2ce4c --- /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 00000000000..c8ffde1e4c2 --- /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 aed12e7e044..d4b5755d1c2 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 0d9e4c134e7..1c7d22d8875 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 00000000000..af2082e80df --- /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 Paths = [D] extends [never] + ? never + : T extends object + ? { + [K in keyof T]-?: K extends string | number ? `${K}` | Join> : never; + }[keyof T] + : ""; + +export type Leaves = [D] extends [never] + ? never + : T extends object + ? { [K in keyof T]-?: Join> }[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 a3377620463..46f585267ab 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 41a45e02bb9..9e97a668ea8 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 76b442ef92a..8bff97107fd 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 849ec5b571f..e421c01ae5a 100644 --- a/mealie/routes/__init__.py +++ b/mealie/routes/__init__.py @@ -7,6 +7,7 @@ comments, explore, groups, + ocr, organizers, parser, recipe, @@ -31,3 +32,4 @@ 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 00000000000..e23bbc92ec8 --- /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 00000000000..d2ffdec6212 --- /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 2459e613d26..9e0f70ef5f1 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -33,7 +33,10 @@ 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 @@ def upload_recipe_asset( 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 00000000000..e69de29bb2d diff --git a/mealie/schema/ocr/ocr.py b/mealie/schema/ocr/ocr.py new file mode 100644 index 00000000000..fd535111066 --- /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 7b0238cc284..7d1f79e75cf 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 00000000000..e69de29bb2d diff --git a/mealie/services/ocr/pytesseract.py b/mealie/services/ocr/pytesseract.py new file mode 100644 index 00000000000..8bd63a36ce0 --- /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 b40d848fd5a..7234f742d6d 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -111,14 +111,18 @@ def create_one(self, create_data: Union[Recipe, CreateRecipe]) -> Recipe: 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 51a04fe7dfc..ac8198932ba 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 9120221ef80..84decf7fc7c 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 00000000000..1b699c9778d Binary files /dev/null and b/tests/data/images/test-ocr.png differ diff --git a/tests/data/text/test-ocr.tsv b/tests/data/text/test-ocr.tsv new file mode 100644 index 00000000000..4cb717e199d --- /dev/null +++ b/tests/data/text/test-ocr.tsv @@ -0,0 +1,73 @@ +level page_num block_num par_num line_num word_num left top width height conf text +1 1 0 0 0 0 0 0 640 480 -1 +2 1 1 0 0 0 36 92 582 269 -1 +3 1 1 1 0 0 36 92 582 92 -1 +4 1 1 1 1 0 36 92 544 30 -1 +5 1 1 1 1 1 36 92 60 24 87.137558 This +5 1 1 1 1 2 109 92 20 24 87.137558 is +5 1 1 1 1 3 141 98 15 18 87.823906 a +5 1 1 1 1 4 169 92 32 24 87.823906 lot +5 1 1 1 1 5 212 92 28 24 92.965874 of +5 1 1 1 1 6 251 92 31 24 93.247513 12 +5 1 1 1 1 7 296 92 68 30 92.734741 point +5 1 1 1 1 8 374 93 53 23 92.996040 text +5 1 1 1 1 9 437 93 26 23 93.160057 to +5 1 1 1 1 10 474 93 52 23 92.312637 test +5 1 1 1 1 11 536 92 44 24 92.312637 the +4 1 1 1 2 0 36 126 582 31 -1 +5 1 1 1 2 1 36 132 45 18 90.505524 ocr +5 1 1 1 2 2 91 126 69 24 90.505524 code +5 1 1 1 2 3 172 126 51 24 91.169167 and +5 1 1 1 2 4 236 132 50 18 89.765854 see +5 1 1 1 2 5 299 126 15 24 85.827324 if +5 1 1 1 2 6 325 126 14 24 93.116241 it +5 1 1 1 2 7 348 126 85 24 92.394562 works +5 1 1 1 2 8 445 132 33 18 30.119690 on +5 1 1 1 2 9 500 126 29 24 30.119690 all +5 1 1 1 2 10 541 127 77 30 92.090988 types +4 1 1 1 3 0 36 160 187 24 -1 +5 1 1 1 3 1 36 160 28 24 92.476135 of +5 1 1 1 3 2 72 160 41 24 90.919365 file +5 1 1 1 3 3 123 160 100 24 91.360367 format. +3 1 1 2 0 0 36 194 561 167 -1 +4 1 1 2 1 0 36 194 549 31 -1 +5 1 1 2 1 1 36 194 55 24 89.098892 The +5 1 1 2 1 2 102 194 75 30 89.098892 quick +5 1 1 2 1 3 189 194 85 24 91.415680 brown +5 1 1 2 1 4 287 194 52 31 91.943085 dog +5 1 1 2 1 5 348 194 108 31 92.167969 jumped +5 1 1 2 1 6 468 200 63 18 91.970985 over +5 1 1 2 1 7 540 194 45 24 92.843704 the +4 1 1 2 2 0 37 228 548 31 -1 +5 1 1 2 2 1 37 228 55 31 92.262550 lazy +5 1 1 2 2 2 103 228 50 24 92.693161 fox. +5 1 1 2 2 3 165 228 55 24 92.947639 The +5 1 1 2 2 4 232 228 75 30 90.589806 quick +5 1 1 2 2 5 319 228 85 24 91.051247 brown +5 1 1 2 2 6 417 228 51 31 91.925011 dog +5 1 1 2 2 7 478 228 107 31 91.471077 jumped +4 1 1 2 3 0 36 262 561 31 -1 +5 1 1 2 3 1 36 268 63 18 90.210129 over +5 1 1 2 3 2 109 262 44 24 90.210129 the +5 1 1 2 3 3 165 262 56 31 91.178192 lazy +5 1 1 2 3 4 231 262 50 24 92.794647 fox. +5 1 1 2 3 5 294 262 55 24 91.388016 The +5 1 1 2 3 6 360 262 75 30 92.525742 quick +5 1 1 2 3 7 447 262 85 24 90.425552 brown +5 1 1 2 3 8 545 262 52 31 90.425552 dog +4 1 1 2 4 0 43 296 518 31 -1 +5 1 1 2 4 1 43 296 107 31 91.759590 jumped +5 1 1 2 4 2 162 302 64 18 92.923576 over +5 1 1 2 4 3 235 296 44 24 92.017929 the +5 1 1 2 4 4 292 296 55 31 91.558884 lazy +5 1 1 2 4 5 357 296 50 24 92.687485 fox. +5 1 1 2 4 6 420 296 55 24 91.922661 The +5 1 1 2 4 7 486 296 75 30 91.870224 quick +4 1 1 2 5 0 37 330 524 31 -1 +5 1 1 2 5 1 37 330 85 24 92.923935 brown +5 1 1 2 5 2 135 330 52 31 91.468765 dog +5 1 1 2 5 3 196 330 108 31 91.425491 jumped +5 1 1 2 5 4 316 336 63 18 91.489830 over +5 1 1 2 5 5 388 330 45 24 91.740379 the +5 1 1 2 5 6 445 330 55 31 92.110054 lazy +5 1 1 2 5 7 511 330 50 24 93.180054 fox. diff --git a/tests/data/text/test-ocr.txt b/tests/data/text/test-ocr.txt new file mode 100644 index 00000000000..02d3a77cbb5 --- /dev/null +++ b/tests/data/text/test-ocr.txt @@ -0,0 +1,9 @@ +This is a lot of 12 point text to test the +ocr code and see if it works on all types +of file format. + +The quick brown dog jumped over the +lazy fox. The quick brown dog jumped +over the lazy fox. The quick brown dog +jumped over the lazy fox. The quick +brown dog jumped over the lazy fox. diff --git a/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py b/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py index 5fbbb6e5c94..62804a91427 100644 --- a/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py +++ b/tests/unit_tests/services_tests/backup_v2_tests/test_alchemy_exporter.py @@ -4,7 +4,7 @@ from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter ALEMBIC_VERSIONS = [ - {"version_num": "188374910655"}, + {"version_num": "089bfa50d0ed"}, ] diff --git a/tests/unit_tests/services_tests/test_ocr_service.py b/tests/unit_tests/services_tests/test_ocr_service.py new file mode 100644 index 00000000000..c0d2cb320f1 --- /dev/null +++ b/tests/unit_tests/services_tests/test_ocr_service.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import pytest + +from mealie.services.ocr.pytesseract import OcrService + +ocr_service = OcrService() + + +@pytest.mark.skip("Tesseract is not reliable between environments") +def test_image_to_string(): + with open(Path("tests/data/images/test-ocr.png"), "rb") as image: + result = ocr_service.image_to_string(image) + with open(Path("tests/data/text/test-ocr.txt"), "r", encoding="utf-8") as expected_result: + assert result == expected_result.read() + + +@pytest.mark.skip("Tesseract is not reliable between environments") +def test_image_to_tsv(): + with open(Path("tests/data/images/test-ocr.png"), "rb") as image: + result = ocr_service.image_to_tsv(image.read()) + with open(Path("tests/data/text/test-ocr.tsv"), "r", encoding="utf-8") as expected_result: + assert result == expected_result.read() + + +def test_format_tsv_output(): + tsv = " level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext \n1\t1\t0\t0\t0\t0\t0\t0\t640\t480\t-1\t\n5\t1\t1\t1\t1\t1\t36\t92\t60\t24\t87.137558\tThis" + expected_result = [ + { + "level": 1, + "page_num": 1, + "block_num": 0, + "par_num": 0, + "line_num": 0, + "word_num": 0, + "left": 0, + "top": 0, + "width": 640, + "height": 480, + "conf": -1.0, + "text": "", + }, + { + "level": 5, + "page_num": 1, + "block_num": 1, + "par_num": 1, + "line_num": 1, + "word_num": 1, + "left": 36, + "top": 92, + "width": 60, + "height": 24, + "conf": 87.137558, + "text": "This", + }, + ] + assert ocr_service.format_tsv_output(tsv) == expected_result