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 <alban.vachette@gmail.com>
This commit is contained in:
Hayden 2022-09-25 15:00:45 -08:00 committed by GitHub
parent a8f3922907
commit 39adea4ee3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1659 additions and 34 deletions

View file

@ -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'

View file

@ -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:

View file

@ -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")

View file

@ -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 });
}
}

View file

@ -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<CreateRecipe, Recipe, Recipe> {
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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -2,7 +2,7 @@
<div class="text-center">
<v-dialog v-model="dialog" width="800">
<template #activator="{ on, attrs }">
<BaseButton v-bind="attrs" v-on="on" @click="inputText = ''">
<BaseButton v-bind="attrs" v-on="on" @click="inputText = inputTextProp">
{{ $t("new-recipe.bulk-add") }}
</BaseButton>
</template>
@ -58,10 +58,17 @@
<script lang="ts">
import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api";
export default defineComponent({
setup(_, context) {
props: {
inputTextProp: {
type: String,
required: false,
default: "",
},
},
setup(props, context) {
const state = reactive({
dialog: false,
inputText: "",
inputText: props.inputTextProp,
});
function splitText() {

View file

@ -8,6 +8,7 @@
class="mx-1 mt-3 mb-4"
:placeholder="$t('recipe.section-title')"
style="max-width: 500px"
@click="$emit('clickIngredientField', 'title')"
>
</v-text-field>
<v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1">
@ -81,7 +82,15 @@
</v-col>
<v-col sm="12" md="" cols="12">
<div class="d-flex">
<v-text-field v-model="value.note" hide-details dense solo class="mx-1" :placeholder="$t('recipe.notes')">
<v-text-field
v-model="value.note"
hide-details
dense
solo
class="mx-1"
:placeholder="$t('recipe.notes')"
@click="$emit('clickIngredientField', 'note')"
>
<v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
{{ $globals.icons.arrowUpDown }}
</v-icon>
@ -93,12 +102,12 @@
:buttons="[
{
icon: $globals.icons.delete,
text: $t('general.delete'),
text: $tc('general.delete'),
event: 'delete',
},
{
icon: $globals.icons.dotsVertical,
text: $t('general.menu'),
text: $tc('general.menu'),
event: 'open',
children: contextMenuOptions,
},

View file

@ -176,6 +176,7 @@
blur: imageUploadMode,
}"
@drop.stop.prevent="handleImageDrop(index, $event)"
@click="$emit('clickInstructionField', `${index}.text`)"
>
<MarkdownEditor
v-model="value[index]['text']"

View file

@ -0,0 +1,381 @@
<template>
<v-container
v-if="recipe && recipe.slug && recipe.settings && recipe.recipeIngredient"
:class="{
'pa-0': $vuetify.breakpoint.smAndDown,
}"
>
<BannerExperimental />
<div v-if="loading">
<v-spacer />
<v-progress-circular indeterminate class="" color="primary"> </v-progress-circular>
{{ loadingText }}
<v-spacer />
</div>
<v-row v-if="!loading">
<v-col cols="12" sm="7" md="7" lg="7">
<RecipeOcrEditorPageCanvas
:image="canvasImage"
:tsv="tsv"
@setText="canvasSetText"
@update-recipe="updateRecipe"
@close-editor="closeEditor"
@text-selected="updateSelectedText"
>
</RecipeOcrEditorPageCanvas>
<RecipeOcrEditorPageHelp />
</v-col>
<v-col cols="12" sm="5" md="5" lg="5">
<v-tabs v-model="tab" fixed-tabs>
<v-tab key="header">
{{ $t("general.recipe") }}
</v-tab>
<v-tab key="ingredients">
{{ $t("recipe.ingredients") }}
</v-tab>
<v-tab key="instructions">
{{ $t("recipe.instructions") }}
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item key="header">
<v-text-field
v-model="recipe.name"
class="my-3"
:label="$t('recipe.recipe-name')"
:rules="[validators.required]"
@focus="selectedRecipeField = 'name'"
>
</v-text-field>
<div class="d-flex flex-wrap">
<v-text-field
v-model="recipe.totalTime"
class="mx-2"
:label="$t('recipe.total-time')"
@click="selectedRecipeField = 'totalTime'"
></v-text-field>
<v-text-field
v-model="recipe.prepTime"
class="mx-2"
:label="$t('recipe.prep-time')"
@click="selectedRecipeField = 'prepTime'"
></v-text-field>
<v-text-field
v-model="recipe.performTime"
class="mx-2"
:label="$t('recipe.perform-time')"
@click="selectedRecipeField = 'performTime'"
></v-text-field>
</div>
<v-textarea
v-model="recipe.description"
auto-grow
min-height="100"
:label="$t('recipe.description')"
@click="selectedRecipeField = 'description'"
>
</v-textarea>
<v-text-field
v-model="recipe.recipeYield"
dense
:label="$t('recipe.servings')"
@click="selectedRecipeField = 'recipeYield'"
>
</v-text-field>
</v-tab-item>
<v-tab-item key="ingredients">
<div class="d-flex justify-end mt-2">
<RecipeDialogBulkAdd class="ml-1 mr-1" :input-text-prop="canvasSelectedText" @bulk-data="addIngredient" />
<BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton>
</div>
<draggable
v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient"
handle=".handle"
v-bind="{
animation: 200,
group: 'description',
disabled: false,
ghostClass: 'ghost',
}"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
class="list-group-item"
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
@clickIngredientField="setSingleIngredient($event, index)"
/>
</TransitionGroup>
</draggable>
</v-tab-item>
<v-tab-item key="instructions">
<div class="d-flex justify-end mt-2">
<RecipeDialogBulkAdd class="ml-1 mr-1" :input-text-prop="canvasSelectedText" @bulk-data="addStep" />
<BaseButton @click="addStep()"> {{ $t("general.new") }}</BaseButton>
</div>
<RecipeInstructions
v-model="recipe.recipeInstructions"
:ingredients="recipe.recipeIngredient"
:disable-amount="recipe.settings.disableAmount"
:edit="true"
:recipe-id="recipe.id"
:recipe-slug="recipe.slug"
:assets.sync="recipe.assets"
@clickInstructionField="setSingleStep"
/>
</v-tab-item>
</v-tabs-items>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, reactive, toRefs, useRouter } from "@nuxtjs/composition-api";
import { until } from "@vueuse/core";
import { invoke } from "@vueuse/shared";
import draggable from "vuedraggable";
import { useUserApi, useStaticRoutes } from "~/composables/api";
import { OcrTsvResponse } from "~/types/api-types/ocr";
import { validators } from "~/composables/use-validators";
import { Recipe, RecipeIngredient, RecipeStep } from "~/types/api-types/recipe";
import { Paths, Leaves, SelectedRecipeLeaves } from "~/types/ocr-types";
import BannerExperimental from "~/components/global/BannerExperimental.vue";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import RecipeInstructions from "~/components/Domain/Recipe/RecipeInstructions.vue";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeOcrEditorPageCanvas from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageCanvas.vue";
import RecipeOcrEditorPageHelp from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPageParts/RecipeOcrEditorPageHelp.vue";
import { uuid4 } from "~/composables/use-utils";
import { NoUndefinedField } from "~/types/api";
export default defineComponent({
components: {
RecipeIngredientEditor,
draggable,
BannerExperimental,
RecipeDialogBulkAdd,
RecipeInstructions,
RecipeOcrEditorPageCanvas,
RecipeOcrEditorPageHelp,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const router = useRouter();
const api = useUserApi();
const tsv = ref<OcrTsvResponse[]>([]);
const drag = ref(false);
const { recipeAssetPath } = useStaticRoutes();
function assetURL(assetName: string) {
return recipeAssetPath(props.recipe.id, assetName);
}
const state = reactive({
loading: true,
loadingText: "Loading recipe...",
tab: null,
selectedRecipeField: "" as SelectedRecipeLeaves | "",
canvasSelectedText: "",
canvasImage: new Image(),
});
const setPropertyValueByPath = function <T extends Recipe>(object: T, path: Paths<T>, value: any) {
const a = path.split(".");
let nextProperty: any = object;
for (let i = 0, n = a.length - 1; i < n; ++i) {
const k = a[i];
if (k in nextProperty) {
nextProperty = nextProperty[k];
} else {
return;
}
}
nextProperty[a[a.length - 1]] = value;
};
/**
* This function will find the title of a recipe with the assumption that the title
* has the biggest ratio of surface area / number of words on the image.
* @return Returns the text parts of the block with the highest score.
*/
function findRecipeTitle() {
const filtered = tsv.value.filter((element) => element.level === 2 || element.level === 5);
const blocks = [[]] as OcrTsvResponse[][];
let blockNum = 1;
filtered.forEach((element, index, array) => {
if (index !== 0 && array[index - 1].blockNum !== element.blockNum) {
blocks.push([]);
blockNum = element.blockNum;
}
blocks[blockNum - 1].push(element);
});
let bestScore = 0;
let bestBlock = blocks[0];
blocks.forEach((element) => {
// element[0] is the block declaration line containing the blocks total dimensions
// element.length is the number of words (+ 2) contained in that block
const elementScore = (element[0].height * element[0].width) / element.length; // Prettier is adding useless parenthesis for a mysterious reason
const elementText = element.map((element) => element.text).join(""); // Identify empty blocks and don't count them
if (elementScore > bestScore && elementText !== "") {
bestBlock = element;
bestScore = elementScore;
}
});
return bestBlock
.filter((element) => element.level === 5 && element.conf >= 40)
.map((element) => {
return element.text.trim();
})
.join(" ");
}
onMounted(() => {
invoke(async () => {
await until(props.recipe).not.toBeNull();
state.loadingText = "Loading OCR data...";
const assetName = props.recipe.assets[0].fileName;
const imagesrc = assetURL(assetName);
state.canvasImage.src = imagesrc;
const res = await api.ocr.assetToTsv(props.recipe.slug, assetName);
tsv.value = res.data as OcrTsvResponse[];
state.loading = false;
if (props.recipe.name.match(/New\sOCR\sRecipe(\s\([0-9]+\))?/g)) {
props.recipe.name = findRecipeTitle();
}
});
});
function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
return {
referenceId: uuid4(),
title: "",
note: x,
unit: undefined,
food: undefined,
disableAmount: true,
quantity: 1,
originalText: "",
};
});
if (newIngredients) {
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
props.recipe.recipeIngredient.push(...newIngredients);
}
} else {
props.recipe.recipeIngredient.push({
referenceId: uuid4(),
title: "",
note: "",
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
food: undefined,
disableAmount: true,
quantity: 1,
});
}
}
function addStep(steps: Array<string> | null = null) {
if (!props.recipe.recipeInstructions) {
return;
}
if (steps) {
const cleanedSteps = steps.map((step) => {
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
});
props.recipe.recipeInstructions.push(...cleanedSteps);
} else {
props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
}
}
// EVENT HANDLERS
// Canvas component event handlers
async function updateRecipe() {
const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe);
if (data?.slug) {
router.push("/recipe/" + data.slug);
}
}
function closeEditor() {
router.push("/recipe/" + props.recipe.slug);
}
const canvasSetText = function () {
if (state.selectedRecipeField !== "") {
setPropertyValueByPath<Recipe>(props.recipe, state.selectedRecipeField, state.canvasSelectedText);
}
};
function updateSelectedText(value: string) {
state.canvasSelectedText = value;
}
// Recipe field selection event handlers
function setSingleIngredient(f: keyof RecipeIngredient, index: number) {
state.selectedRecipeField = `recipeIngredient.${index}.${f}` as SelectedRecipeLeaves;
}
// Leaves<RecipeStep[]> will return some function types making eslint very unhappy
type RecipeStepsLeaves = `${number}.${Leaves<RecipeStep>}`;
function setSingleStep(path: RecipeStepsLeaves) {
state.selectedRecipeField = `recipeInstructions.${path}` as SelectedRecipeLeaves;
}
return {
...toRefs(state),
addIngredient,
addStep,
drag,
assetURL,
updateRecipe,
closeEditor,
updateSelectedText,
tsv,
validators,
setSingleIngredient,
setSingleStep,
canvasSetText,
};
},
});
</script>
<style lang="css">
.ghost {
opacity: 0.5;
}
</style>

View file

@ -0,0 +1,484 @@
<template>
<v-card flat tile>
<v-toolbar v-for="(section, idx) in toolbarIcons" :key="section.sectionTitle" dense style="float: left">
<v-toolbar-title bottom>
{{ section.sectionTitle }}
</v-toolbar-title>
<v-tooltip v-for="icon in section.icons" :key="icon.name" bottom>
<template #activator="{ on, attrs }">
<v-btn icon @click="section.eventHandler(icon.name)">
<v-icon :color="section.highlight === icon.name ? 'primary' : 'default'" v-bind="attrs" v-on="on">
{{ icon.icon }}
</v-icon>
</v-btn>
</template>
<span>{{ icon.tooltip }}</span>
</v-tooltip>
<v-divider v-if="idx != toolbarIcons.length - 1" vertical class="mx-2" />
</v-toolbar>
<v-toolbar dense style="float: right">
<BaseButton class="ml-1 mr-1" save @click="updateRecipe()">
{{ $t("general.save") }}
</BaseButton>
<BaseButton cancel @click="closeEditor()">
{{ $t("general.close") }}
</BaseButton>
</v-toolbar>
<canvas
ref="canvas"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@mousemove="handleMouseMove"
@wheel="handleMouseScroll"
>
</canvas>
<span style="white-space: pre-wrap">
{{ selectedText }}
</span>
</v-card>
</template>
<script lang="ts">
import { defineComponent, reactive, useContext, ref, toRefs, watch } from "@nuxtjs/composition-api";
import { onMounted } from "vue-demi";
import { OcrTsvResponse } from "~/types/api-types/ocr";
import { CanvasModes, SelectedTextSplitModes, ImagePosition, Mouse, CanvasRect, ToolbarIcons } from "~/types/ocr-types";
export default defineComponent({
props: {
image: {
type: HTMLImageElement,
required: true,
},
tsv: {
type: Array as () => OcrTsvResponse[],
required: true,
},
},
setup(props, context) {
const state = reactive({
canvas: null as HTMLCanvasElement | null,
ctx: null as CanvasRenderingContext2D | null,
canvasRect: null as DOMRect | null,
rect: {
startX: 0,
startY: 0,
w: 0,
h: 0,
},
mouse: {
current: {
x: 0,
y: 0,
},
down: false,
},
selectedText: "",
canvasMode: "selection" as CanvasModes,
imagePosition: {
sx: 0,
sy: 0,
sWidth: 0,
sHeight: 0,
dx: 0,
dy: 0,
dWidth: 0,
dHeight: 0,
scale: 1,
panStartPoint: {
x: 0,
y: 0,
},
} as ImagePosition,
isImageSmallerThanCanvas: false,
selectedTextSplitMode: "lineNum" as SelectedTextSplitModes,
});
watch(
() => state.selectedText,
(value) => {
context.emit("text-selected", value);
}
);
onMounted(() => {
if (state.canvas === null) return; // never happens because the ref "canvas" is in the template
state.ctx = state.canvas.getContext("2d") as CanvasRenderingContext2D;
state.ctx.imageSmoothingEnabled = false;
state.canvasRect = state.canvas.getBoundingClientRect();
state.canvas.width = state.canvasRect.width;
if (props.image.width < state.canvas.width) {
state.isImageSmallerThanCanvas = true;
}
state.imagePosition.dWidth = state.canvas.width;
updateImageScale();
state.canvas.height = Math.min(props.image.height * state.imagePosition.scale, 700); // Max height of 700px
state.imagePosition.sWidth = props.image.width;
state.imagePosition.sHeight = props.image.height;
state.imagePosition.dWidth = state.canvas.width;
drawImage(state.ctx);
drawWordBoxesOnCanvas(props.tsv);
});
function handleMouseDown(event: MouseEvent) {
if (state.canvasRect === null || state.canvas === null || state.ctx === null) return;
state.mouse.down = true;
updateMousePos(event);
if (state.canvasMode === "selection") {
if (isMouseInRect(state.mouse, state.rect)) {
context.emit("setText", state.selectedText);
} else {
state.ctx.fillStyle = "rgb(255, 255, 255)";
state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height);
drawImage(state.ctx);
state.rect.startX = state.mouse.current.x;
state.rect.startY = state.mouse.current.y;
resetSelection();
}
return;
}
if (state.canvasMode === "panAndZoom") {
state.imagePosition.panStartPoint.x = state.mouse.current.x - state.imagePosition.dx;
state.imagePosition.panStartPoint.y = state.mouse.current.y - state.imagePosition.dy;
resetSelection();
}
}
function handleMouseUp(_event: MouseEvent) {
if (state.canvasRect === null) return;
state.mouse.down = false;
state.selectedText = getWordsInSelection(props.tsv, state.rect);
}
function handleMouseMove(event: MouseEvent) {
if (state.canvasRect === null || state.canvas === null || state.ctx === null) return;
updateMousePos(event);
if (state.mouse.down) {
if (state.canvasMode === "selection") {
state.rect.w = state.mouse.current.x - state.rect.startX;
state.rect.h = state.mouse.current.y - state.rect.startY;
draw();
return;
}
if (state.canvasMode === "panAndZoom") {
state.canvas.style.cursor = "move";
state.imagePosition.dx = state.mouse.current.x - state.imagePosition.panStartPoint.x;
state.imagePosition.dy = state.mouse.current.y - state.imagePosition.panStartPoint.y;
keepImageInCanvas();
state.ctx.fillStyle = "rgb(255, 255, 255)";
state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height);
drawImage(state.ctx);
return;
}
}
if (isMouseInRect(state.mouse, state.rect) && state.canvasMode === "selection") {
state.canvas.style.cursor = "pointer";
} else {
state.canvas.style.cursor = "default";
}
}
const scrollSensitivity = 0.05;
function handleMouseScroll(event: WheelEvent) {
if (state.isImageSmallerThanCanvas) return;
if (state.canvasRect === null || state.canvas === null || state.ctx === null) return;
if (state.canvasMode === "panAndZoom") {
event.preventDefault();
updateMousePos(event);
const m = Math.sign(event.deltaY);
const ndx = state.imagePosition.dx + m * state.imagePosition.dWidth * scrollSensitivity;
const ndy = state.imagePosition.dy + m * state.imagePosition.dHeight * scrollSensitivity;
const ndw = state.imagePosition.dWidth + -m * state.imagePosition.dWidth * scrollSensitivity * 2;
const ndh = state.imagePosition.dHeight + -m * state.imagePosition.dHeight * scrollSensitivity * 2;
if (ndw < props.image.width) {
state.imagePosition.dx = ndx;
state.imagePosition.dy = ndy;
state.imagePosition.dWidth = ndw;
state.imagePosition.dHeight = ndh;
}
keepImageInCanvas();
updateImageScale();
state.ctx.fillStyle = "rgb(255, 255, 255)";
state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height);
drawImage(state.ctx);
}
}
function draw() {
if (state.canvasRect === null || state.canvas === null || state.ctx === null) return;
if (state.mouse.down) {
state.ctx.imageSmoothingEnabled = false;
state.ctx.fillStyle = "rgb(255, 255, 255)";
state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height);
drawImage(state.ctx);
state.ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
state.ctx.setLineDash([6]);
state.ctx.fillRect(state.rect.startX, state.rect.startY, state.rect.w, state.rect.h);
state.ctx.strokeRect(state.rect.startX, state.rect.startY, state.rect.w, state.rect.h);
}
}
function drawImage(ctx: CanvasRenderingContext2D) {
ctx.drawImage(
props.image,
state.imagePosition.sx,
state.imagePosition.sy,
state.imagePosition.sWidth,
state.imagePosition.sHeight,
state.imagePosition.dx,
state.imagePosition.dy,
state.imagePosition.dWidth,
state.imagePosition.dHeight
);
}
function keepImageInCanvas() {
if (state.canvasRect === null || state.canvas === null) return;
// Prevent image from being smaller than the canvas width
if (state.imagePosition.dWidth - state.canvas.width < 0) {
state.imagePosition.dWidth = state.canvas.width;
}
// Prevent image from being smaller than the canvas height
if (state.imagePosition.dHeight - state.canvas.height < 0) {
state.imagePosition.dHeight = props.image.height * state.imagePosition.scale;
}
// Prevent to move the image too much to the left
if (state.canvas.width - state.imagePosition.dx - state.imagePosition.dWidth > 0) {
state.imagePosition.dx = state.canvas.width - state.imagePosition.dWidth;
}
// Prevent to move the image too much to the top
if (state.canvas.height - state.imagePosition.dy - state.imagePosition.dHeight > 0) {
state.imagePosition.dy = state.canvas.height - state.imagePosition.dHeight;
}
// Prevent to move the image too much to the right
if (state.imagePosition.dx > 0) {
state.imagePosition.dx = 0;
}
// Prevent to move the image too much to the bottom
if (state.imagePosition.dy > 0) {
state.imagePosition.dy = 0;
}
}
function updateImageScale() {
state.imagePosition.scale = state.imagePosition.dWidth / props.image.width;
// force the original ratio to be respected
state.imagePosition.dHeight = props.image.height * state.imagePosition.scale;
// Don't let images bigger than the canvas be zoomed in more than 1:1 scale
// Meaning only let images smaller than the canvas to have a scale > 1
if (!state.isImageSmallerThanCanvas && state.imagePosition.scale > 1) {
state.imagePosition.scale = 1;
}
}
function resetSelection() {
if (state.canvasRect === null) return;
state.rect.w = 0;
state.rect.h = 0;
state.selectedText = "";
}
function updateMousePos<T extends MouseEvent>(event: T) {
if (state.canvas === null) return;
state.canvasRect = state.canvas.getBoundingClientRect();
state.mouse.current = {
x: event.clientX - state.canvasRect.left,
y: event.clientY - state.canvasRect.top,
};
}
function isMouseInRect(mouse: Mouse, rect: CanvasRect) {
if (state.canvasRect === null) return;
const correctRect = correctRectCoordinates(rect);
return (
mouse.current.x > correctRect.startX &&
mouse.current.x < correctRect.startX + correctRect.w &&
mouse.current.y > correctRect.startY &&
mouse.current.y < correctRect.startY + correctRect.h
);
}
/**
* Returns rectangle coordinates with positive dimensions
* @param rect A rectangle
* @returns An equivalent rectangle with width and height > 0
*/
function correctRectCoordinates(rect: CanvasRect) {
if (rect.w < 0) {
rect.startX = rect.startX + rect.w;
rect.w = -rect.w;
}
if (rect.h < 0) {
rect.startY = rect.startY + rect.h;
rect.h = -rect.h;
}
return rect;
}
function drawWordBoxesOnCanvas(tsv: OcrTsvResponse[]) {
if (state.canvasRect === null || state.canvas === null || state.ctx === null) return;
state.ctx.fillStyle = "rgb(255, 255, 255, 0.3)";
tsv
.filter((element) => element.level === 5)
.forEach((element) => {
if (state.canvasRect === null || state.canvas === null || state.ctx === null) return;
state.ctx.fillRect(
element.left * state.imagePosition.scale,
element.top * state.imagePosition.scale,
element.width * state.imagePosition.scale,
element.height * state.imagePosition.scale
);
});
}
// Event emitters
const updateRecipe = function () {
context.emit("update-recipe");
};
const closeEditor = function () {
context.emit("close-editor");
};
// TOOLBAR STUFF
const { $globals, i18n } = useContext();
const toolbarIcons = ref<ToolbarIcons<CanvasModes | SelectedTextSplitModes>>([
{
sectionTitle: "Toolbar",
eventHandler: switchCanvasMode,
highlight: state.canvasMode,
icons: [
{
name: "selection",
icon: $globals.icons.selectMode,
tooltip: i18n.tc("ocr-editor.selection-mode"),
},
{
name: "panAndZoom",
icon: $globals.icons.panAndZoom,
tooltip: i18n.tc("ocr-editor.pan-and-zoom-picture"),
},
],
},
{
sectionTitle: i18n.tc("ocr-editor.split-text"),
eventHandler: switchSplitTextMode,
highlight: state.selectedTextSplitMode,
icons: [
{
name: "lineNum",
icon: $globals.icons.preserveLines,
tooltip: i18n.tc("ocr-editor.preserve-line-breaks"),
},
{
name: "blockNum",
icon: $globals.icons.preserveBlocks,
tooltip: i18n.tc("ocr-editor.split-by-block"),
},
{
name: "flatten",
icon: $globals.icons.flatten,
tooltip: i18n.tc("ocr-editor.flatten"),
},
],
},
]);
function switchCanvasMode(mode: CanvasModes) {
if (state.canvasRect === null || state.canvas === null) return;
state.canvasMode = mode;
toolbarIcons.value[0].highlight = mode;
if (mode === "panAndZoom") {
state.canvas.style.cursor = "pointer";
} else {
state.canvas.style.cursor = "default";
}
}
function switchSplitTextMode(mode: SelectedTextSplitModes) {
if (state.canvasRect === null) return;
state.selectedTextSplitMode = mode;
toolbarIcons.value[1].highlight = mode;
state.selectedText = getWordsInSelection(props.tsv, state.rect);
}
/**
* Using rectangle coordinates, filters the tsv to get text elements contained
* inside the rectangle
* Additionaly adds newlines depending on the current "text split" mode
* @param tsv An Object containing tesseracts tsv fields
* @param rect Coordinates of a rectangle
* @returns Text from tsv contained in the rectangle
*/
function getWordsInSelection(tsv: OcrTsvResponse[], rect: CanvasRect) {
const correctedRect = correctRectCoordinates(rect);
return tsv
.filter(
(element) =>
element.level === 5 &&
correctedRect.startY - state.imagePosition.dy < element.top * state.imagePosition.scale &&
correctedRect.startX - state.imagePosition.dx < element.left * state.imagePosition.scale &&
correctedRect.startX + correctedRect.w >
(element.left + element.width) * state.imagePosition.scale + state.imagePosition.dx &&
correctedRect.startY + correctedRect.h >
(element.top + element.height) * state.imagePosition.scale + state.imagePosition.dy
)
.map((element, index, array) => {
let separator = " ";
if (
state.selectedTextSplitMode !== "flatten" &&
index !== array.length - 1 &&
element[state.selectedTextSplitMode] !== array[index + 1][state.selectedTextSplitMode]
) {
separator = "\n";
}
return element.text + separator;
})
.join("");
}
return {
...toRefs(state),
handleMouseDown,
handleMouseUp,
handleMouseMove,
handleMouseScroll,
toolbarIcons,
updateRecipe,
closeEditor,
};
},
});
</script>

View file

@ -0,0 +1,54 @@
<template>
<v-card>
<v-app-bar dense dark color="primary" class="mb-2">
<v-icon large left>
{{ $globals.icons.help }}
</v-icon>
<v-toolbar-title class="headline"> Help </v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
<h1>Mouse modes</h1>
<v-divider class="mb-2 mt-1" />
<h2 class="my-2">
<v-icon> {{ $globals.icons.selectMode }} </v-icon>{{ $t("ocr-editor.help.selection-mode") }}
</h2>
<p class="my-1">{{ $t("ocr-editor.help.selection-mode") }}</p>
<ol>
<li>{{ $t("ocr-editor.help.selection-mode-steps.draw") }}</li>
<li>{{ $t("ocr-editor.help.selection-mode-steps.click") }}</li>
<li>{{ $t("ocr-editor.help.selection-mode-steps.result") }}</li>
</ol>
<h2 class="my-2">
<v-icon> {{ $globals.icons.panAndZoom }} </v-icon>{{ $t("ocr-editor.help.pan-and-zoom-mode") }}
</h2>
{{ $t("ocr-editor.help.pan-and-zoom-desc") }}
<h1 class="mt-5">{{ $t("ocr-editor.help.split-text-mode") }}</h1>
<v-divider class="mb-2 mt-1" />
<h2 class="my-2">
<v-icon> {{ $globals.icons.preserveLines }} </v-icon>
{{ $t("ocr-editor.help.split-modes.line-mode") }}
</h2>
<p>
{{ $t("ocr-editor.help.split-modes.line-mode-desc") }}
</p>
<h2 class="my-2">
<v-icon> {{ $globals.icons.preserveBlocks }} </v-icon>
{{ $t("ocr-editor.help.split-modes.block-mode") }}
</h2>
<p>
{{ $t("ocr-editor.help.split-modes.block-mode-desc") }}
</p>
<h2 class="my-2">
<v-icon> {{ $globals.icons.flatten }} </v-icon> {{ $t("ocr-editor.help.split-modes.flat-mode") }}
</h2>
<p>{{ $t("ocr-editor.help.split-modes.flat-mode-desc") }}</p>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({});
</script>

View file

@ -0,0 +1,3 @@
import RecipeOcrEditorPage from "./RecipeOcrEditorPage.vue";
export default RecipeOcrEditorPage;

View file

@ -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"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
import { defineComponent, useContext, computed, ref, watch, useRouter } from "@nuxtjs/composition-api";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
@ -82,6 +84,7 @@ export default defineComponent({
const { recipeImage } = useStaticRoutes();
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const router = useRouter();
function printRecipe() {
window.print();
@ -98,6 +101,10 @@ export default defineComponent({
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
function goToOcrEditor() {
router.push("/recipe/" + props.recipe.slug + "/ocr-editor");
}
watch(
() => recipeImageUrl.value,
() => {
@ -120,6 +127,7 @@ export default defineComponent({
hideImage,
isEditMode,
recipeImageUrl,
goToOcrEditor,
};
},
});

View file

@ -248,7 +248,8 @@
"trim-prefix-description": "Trim first character from each line",
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
"import-by-url": "Import a recipe by URL",
"create-manually": "Create a recipe manually"
"create-manually": "Create a recipe manually",
"make-recipe-image": "Make this the recipe image"
},
"page": {
"404-page-not-found": "404 Page not found",
@ -660,5 +661,34 @@
"info_message_with_version": "This is a Demo for version: {version}",
"demo_username": "Username: {username}",
"demo_password": "Password: {password}"
},
"ocr-editor": {
"ocr-editor": "Ocr editor",
"selection-mode": "Selection mode",
"pan-and-zoom-picture": "Pan and zoom picture",
"split-text": "Split text",
"preserve-line-breaks": "Preserve original line breaks",
"split-by-block": "Split by text block",
"flatten": "Flatten regardless of original formating",
"help": {
"selection-mode": "Selection Mode (default)",
"selection-mode-desc": "The selection mode is the main mode that can be used to enter data:",
"selection-mode-steps": {
"draw": "Draw a rectangle on the text you want to select.",
"click": "Click on any field on the right and then click back on the rectangle above the image.",
"result": "The selected text will appear inside the previously selected field."
},
"pan-and-zoom-mode": "Pan and Zoom Mode",
"pan-and-zoom-desc": "Select pan and zoom by clicking the icon. This mode allows to zoom inside the image and move around to make using big images easier.",
"split-text-mode": "Split Text modes",
"split-modes": {
"line-mode": "Line mode (default)",
"line-mode-desc": "In line mode, the text will be propagated by keeping the original line breaks. This mode is useful when using bulk add on a list of ingredients where one ingredient is one line.",
"block-mode": "Block mode",
"block-mode-desc": "In block mode, the text will be split in blocks. This mode is useful when bulk adding instructions that are usually written in paragraphs.",
"flat-mode": "Flat mode",
"flat-mode-desc": "In flat mode, the text will be added to the selected recipe field with no line breaks."
}
}
}
}

View file

@ -60,7 +60,7 @@
{{ isError(ing) ? $globals.icons.alert : $globals.icons.check }}
</v-icon>
<div class="my-auto" :color="isError(ing) ? 'error-text' : 'success-text'">
{{ asPercentage(ing.confidence.average) }}
{{ ing.confidence ? asPercentage(ing.confidence.average) : "" }}
</div>
</template>
</v-expansion-panel-header>
@ -197,7 +197,11 @@ export default defineComponent({
return !(ing.confidence.average >= 0.75);
}
function asPercentage(num: number) {
function asPercentage(num: number | undefined): string {
if (!num) {
return "0%";
}
return Math.round(num * 100).toFixed(2) + "%";
}
@ -230,7 +234,11 @@ export default defineComponent({
return false;
}
async function createFood(food: CreateIngredientFood, index: number) {
async function createFood(food: CreateIngredientFood | undefined, index: number) {
if (!food) {
return;
}
foodData.data.name = food.name;
await foodStore.actions.createOne(foodData.data);
errors.value[index].foodError = false;

View file

@ -0,0 +1,51 @@
<template>
<div>
<RecipeOcrEditorPage v-if="recipe" :recipe="recipe" />
</div>
</template>
<script lang="ts">
import { defineComponent, useRoute } from "@nuxtjs/composition-api";
import RecipeOcrEditorPage from "~/components/Domain/Recipe/RecipeOcrEditorPage/RecipeOcrEditorPage.vue";
import { useRecipe } from "~/composables/recipes";
export default defineComponent({
components: { RecipeOcrEditorPage },
setup() {
const route = useRoute();
const slug = route.value.params.slug;
const { recipe, loading } = useRecipe(slug);
return {
recipe,
loading,
};
},
});
</script>
<style lang="css">
.ghost {
opacity: 0.5;
}
body {
background: #eee;
}
canvas {
background: white;
box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.2);
width: 100%;
image-rendering: optimizeQuality;
}
.box {
position: absolute;
border: 2px #90ee90 solid;
background-color: #90ee90;
z-index: 3;
}
</style>

View file

@ -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",

View file

@ -0,0 +1,81 @@
<template>
<div>
<v-card-title class="headline"> Create Recipe from an Image </v-card-title>
<v-card-text>
Create a recipe by uploading a scan.
<v-form ref="domCreateByOcr"> </v-form>
</v-card-text>
<v-card-actions class="justify-center">
<v-file-input
v-model="imageUpload"
accept=".png"
label="recipe.png"
filled
clearable
class="rounded-lg mt-2"
rounded
truncate-length="100"
hint="Upload a png image from a recipe book"
persistent-hint
prepend-icon=""
:prepend-inner-icon="$globals.icons.fileImage"
/>
</v-card-actions>
<v-card-actions class="justify-center">
<v-checkbox v-model="makeFileRecipeImage" :label="$t('new-recipe.make-recipe-image')" />
</v-card-actions>
<v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton :disabled="imageUpload === null" large rounded block :loading="loading" @click="createByOcr" />
</div>
</v-card-actions>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, ref, useRouter } from "@nuxtjs/composition-api";
import { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { VForm } from "~/types/vuetify";
export default defineComponent({
setup() {
const state = reactive({
error: false,
loading: false,
makeFileRecipeImage: false,
});
const api = useUserApi();
const router = useRouter();
const imageUpload = ref<File | null>(null);
function handleResponse(response: AxiosResponse<string> | null) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
router.push(`/recipe/${response.data}/ocr-editor`);
}
const domCreateByOcr = ref<VForm | null>(null);
async function createByOcr() {
if (imageUpload.value === null) return; // Should never be true due to circumstances
state.loading = true;
const { response } = await api.recipes.createFromOcr(imageUpload.value, state.makeFileRecipeImage);
// @ts-ignore returns a string and not a full Recipe
handleResponse(response);
}
return {
domCreateByOcr,
createByOcr,
...toRefs(state),
validators,
imageUpload,
};
},
});
</script>

View file

@ -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;
}

View file

@ -214,6 +214,7 @@ export interface Recipe {
[k: string]: unknown;
};
comments?: RecipeCommentOut[];
isOcrRecipe?: boolean;
}
export interface RecipeTool {
id: string;

View file

@ -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 {

View file

@ -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, P> = K extends string | number
? P extends string | number
? `${K}${"" extends P ? "" : "."}${P}`
: never
: never;
export type Leaves<T, D extends number = 10> = [D] extends [never]
? never
: T extends object
? { [K in keyof T]-?: Join<K, Leaves<T[K], Prev[D]>> }[keyof T]
: "";
export type Paths<T, D extends number = 10> = [D] extends [never]
? never
: T extends object
? {
[K in keyof T]-?: K extends string | number ? `${K}` | Join<K, Paths<T[K], Prev[D]>> : never;
}[keyof T]
: "";
export type SelectedRecipeLeaves = Leaves<Recipe>;
export type CanvasModes = "selection" | "panAndZoom";
export type SelectedTextSplitModes = keyof OcrTsvResponse | "flatten";
export type ToolbarIcons<T extends string> = {
sectionTitle: string;
eventHandler(mode: T): void;
highlight: T;
icons: {
name: T;
icon: string;
tooltip: string;
}[];
}[];

View file

@ -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;
}

View file

@ -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,
};

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
from . import pytesseract
router = APIRouter(prefix="/ocr")
router.include_router(pytesseract.router)

View file

@ -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)

View file

@ -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

View file

21
mealie/schema/ocr/ocr.py Normal file
View file

@ -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

View file

@ -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]] = []

View file

View file

@ -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

View file

@ -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)

18
poetry.lock generated
View file

@ -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"},

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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.
1 level page_num block_num par_num line_num word_num left top width height conf text
2 1 1 0 0 0 0 0 0 640 480 -1
3 2 1 1 0 0 0 36 92 582 269 -1
4 3 1 1 1 0 0 36 92 582 92 -1
5 4 1 1 1 1 0 36 92 544 30 -1
6 5 1 1 1 1 1 36 92 60 24 87.137558 This
7 5 1 1 1 1 2 109 92 20 24 87.137558 is
8 5 1 1 1 1 3 141 98 15 18 87.823906 a
9 5 1 1 1 1 4 169 92 32 24 87.823906 lot
10 5 1 1 1 1 5 212 92 28 24 92.965874 of
11 5 1 1 1 1 6 251 92 31 24 93.247513 12
12 5 1 1 1 1 7 296 92 68 30 92.734741 point
13 5 1 1 1 1 8 374 93 53 23 92.996040 text
14 5 1 1 1 1 9 437 93 26 23 93.160057 to
15 5 1 1 1 1 10 474 93 52 23 92.312637 test
16 5 1 1 1 1 11 536 92 44 24 92.312637 the
17 4 1 1 1 2 0 36 126 582 31 -1
18 5 1 1 1 2 1 36 132 45 18 90.505524 ocr
19 5 1 1 1 2 2 91 126 69 24 90.505524 code
20 5 1 1 1 2 3 172 126 51 24 91.169167 and
21 5 1 1 1 2 4 236 132 50 18 89.765854 see
22 5 1 1 1 2 5 299 126 15 24 85.827324 if
23 5 1 1 1 2 6 325 126 14 24 93.116241 it
24 5 1 1 1 2 7 348 126 85 24 92.394562 works
25 5 1 1 1 2 8 445 132 33 18 30.119690 on
26 5 1 1 1 2 9 500 126 29 24 30.119690 all
27 5 1 1 1 2 10 541 127 77 30 92.090988 types
28 4 1 1 1 3 0 36 160 187 24 -1
29 5 1 1 1 3 1 36 160 28 24 92.476135 of
30 5 1 1 1 3 2 72 160 41 24 90.919365 file
31 5 1 1 1 3 3 123 160 100 24 91.360367 format.
32 3 1 1 2 0 0 36 194 561 167 -1
33 4 1 1 2 1 0 36 194 549 31 -1
34 5 1 1 2 1 1 36 194 55 24 89.098892 The
35 5 1 1 2 1 2 102 194 75 30 89.098892 quick
36 5 1 1 2 1 3 189 194 85 24 91.415680 brown
37 5 1 1 2 1 4 287 194 52 31 91.943085 dog
38 5 1 1 2 1 5 348 194 108 31 92.167969 jumped
39 5 1 1 2 1 6 468 200 63 18 91.970985 over
40 5 1 1 2 1 7 540 194 45 24 92.843704 the
41 4 1 1 2 2 0 37 228 548 31 -1
42 5 1 1 2 2 1 37 228 55 31 92.262550 lazy
43 5 1 1 2 2 2 103 228 50 24 92.693161 fox.
44 5 1 1 2 2 3 165 228 55 24 92.947639 The
45 5 1 1 2 2 4 232 228 75 30 90.589806 quick
46 5 1 1 2 2 5 319 228 85 24 91.051247 brown
47 5 1 1 2 2 6 417 228 51 31 91.925011 dog
48 5 1 1 2 2 7 478 228 107 31 91.471077 jumped
49 4 1 1 2 3 0 36 262 561 31 -1
50 5 1 1 2 3 1 36 268 63 18 90.210129 over
51 5 1 1 2 3 2 109 262 44 24 90.210129 the
52 5 1 1 2 3 3 165 262 56 31 91.178192 lazy
53 5 1 1 2 3 4 231 262 50 24 92.794647 fox.
54 5 1 1 2 3 5 294 262 55 24 91.388016 The
55 5 1 1 2 3 6 360 262 75 30 92.525742 quick
56 5 1 1 2 3 7 447 262 85 24 90.425552 brown
57 5 1 1 2 3 8 545 262 52 31 90.425552 dog
58 4 1 1 2 4 0 43 296 518 31 -1
59 5 1 1 2 4 1 43 296 107 31 91.759590 jumped
60 5 1 1 2 4 2 162 302 64 18 92.923576 over
61 5 1 1 2 4 3 235 296 44 24 92.017929 the
62 5 1 1 2 4 4 292 296 55 31 91.558884 lazy
63 5 1 1 2 4 5 357 296 50 24 92.687485 fox.
64 5 1 1 2 4 6 420 296 55 24 91.922661 The
65 5 1 1 2 4 7 486 296 75 30 91.870224 quick
66 4 1 1 2 5 0 37 330 524 31 -1
67 5 1 1 2 5 1 37 330 85 24 92.923935 brown
68 5 1 1 2 5 2 135 330 52 31 91.468765 dog
69 5 1 1 2 5 3 196 330 108 31 91.425491 jumped
70 5 1 1 2 5 4 316 336 63 18 91.489830 over
71 5 1 1 2 5 5 388 330 45 24 91.740379 the
72 5 1 1 2 5 6 445 330 55 31 92.110054 lazy
73 5 1 1 2 5 7 511 330 50 24 93.180054 fox.

View file

@ -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.

View file

@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
ALEMBIC_VERSIONS = [
{"version_num": "188374910655"},
{"version_num": "089bfa50d0ed"},
]

View file

@ -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