Skip to content
Merged
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
0e8ca1c
feat: add OCR functionality and related configurations
nyakang Jun 1, 2025
ee3e81c
chore: update labeler configuration for machine learning files
nyakang Jun 2, 2025
701950c
feat(i18n): enhance OCR model descriptions and add orientation classi…
nyakang Jun 2, 2025
3949bf2
chore: update Dockerfile to include ccache for improved build perform…
nyakang Jun 2, 2025
0e7ad8b
feat(ocr): enhance OCR model configuration with orientation classific…
nyakang Jun 2, 2025
3183a1c
refactor(ocr): remove OCR_CLEANUP job from enum and type definitions
nyakang Jun 2, 2025
46ef023
refactor(ocr): remove obsolete OCR entity and migration files, and up…
nyakang Jun 2, 2025
df36a09
refactor(ocr): update OCR schema and response structure to use indivi…
nyakang Jun 2, 2025
4d8e51e
feat: enhance OCR configuration and functionality
nyakang Jun 3, 2025
08e54ec
sql changes
mertalev Jun 9, 2025
c59f932
use rapidocr
mertalev Jun 10, 2025
4124689
change dto
mertalev Jun 13, 2025
786ae3f
update web
mertalev Jun 16, 2025
1c53e65
update lock
mertalev Jun 16, 2025
dd7c06f
update api
mertalev Jun 16, 2025
5e7fab8
store positions as normalized floats
mertalev Jun 16, 2025
fea41c6
match column order in db
mertalev Jun 16, 2025
22690fa
update admin ui settings descriptions
mertalev Jun 16, 2025
585d093
apply config correctly, adjust defaults
mertalev Jun 16, 2025
49ff045
unnecessary model type
mertalev Sep 8, 2025
7be708e
unnecessary sources
mertalev Sep 8, 2025
7211086
fix(ocr): switch RapidOCR lang type from LangDet to LangRec
nyakang Sep 9, 2025
b5a2bf5
fix(ocr): expose lang_type (LangRec.CH) and font_path on OcrOptions f…
nyakang Sep 9, 2025
31f191f
fix(ocr): make OCR text search case- and accent-insensitive using ILI…
nyakang Sep 9, 2025
140124f
fix(ocr): add OCR search fields
nyakang Sep 9, 2025
ee83d06
Merge branch 'main' into ocr
nyakang Sep 9, 2025
8840a4e
Merge branch 'main' into ocr
nyakang Sep 11, 2025
d61de59
Merge branch 'main' into ocr
nyakang Sep 13, 2025
4543ac3
Merge branch 'main' into ocr
nyakang Sep 17, 2025
de8abe0
Merge branch 'main' into ocr
nyakang Sep 21, 2025
d82096a
Merge branch 'main' into ocr
nyakang Sep 24, 2025
7d80dc4
fix: Add OCR database migration and update ML prediction logic.
nyakang Sep 24, 2025
1118832
Merge branch 'main' into ocr
nyakang Oct 9, 2025
88f6546
Merge remote-tracking branch 'origin/ocr' into ocr
nyakang Oct 9, 2025
0fd4d4c
trigrams are already case insensitive
mertalev Oct 14, 2025
88c9935
add tests
mertalev Oct 15, 2025
664e1bd
format
mertalev Oct 15, 2025
99fc6b8
update migrations
mertalev Oct 15, 2025
5c044fb
wrong uuid function
mertalev Oct 15, 2025
44f90d4
linting
mertalev Oct 15, 2025
80853df
maybe fix medium tests
mertalev Oct 15, 2025
36d9f40
formatting
mertalev Oct 15, 2025
abef381
fix weblate check
mertalev Oct 15, 2025
321f0d1
openapi
mertalev Oct 15, 2025
c285caf
sql
mertalev Oct 15, 2025
ec56633
minor fixes
mertalev Oct 15, 2025
c3bb212
maybe fix medium tests part 2
mertalev Oct 15, 2025
5809992
passing medium tests
mertalev Oct 15, 2025
d7cc9ac
format web
mertalev Oct 15, 2025
b656d2b
readd sql
mertalev Oct 15, 2025
65b98e2
format dart
mertalev Oct 15, 2025
a6c59ab
disabled in e2e
mertalev Oct 15, 2025
8c704cc
Merge branch 'main' into ocr
nyakang Oct 20, 2025
8edf246
merge main
alextran1502 Oct 24, 2025
39c794d
chore: translation ordering
alextran1502 Oct 24, 2025
9769bbc
Merge branch 'main' into ocr
alextran1502 Oct 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ documentation:
🧠machine-learning:
- changed-files:
- any-glob-to-any-file:
- machine-learning/app/**
- machine-learning/**

changelog:translation:
- head-branch: ['^chore/translations$']
2 changes: 1 addition & 1 deletion docker/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ services:
ports:
- 3003:3003
volumes:
- ../machine-learning:/usr/src/app
- ../machine-learning/immich_ml:/usr/src/immich_ml
- model-cache:/cache
env_file:
- .env
Expand Down
1 change: 1 addition & 0 deletions e2e/src/api/specs/server.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ describe('/server', () => {
importFaces: false,
oauth: false,
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
search: true,
sidecar: true,
Expand Down
17 changes: 17 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@
"machine_learning_min_detection_score_description": "Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives.",
"machine_learning_min_recognized_faces": "Minimum recognized faces",
"machine_learning_min_recognized_faces_description": "The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person.",
"machine_learning_ocr": "OCR",
"machine_learning_ocr_description": "Use machine learning to recognize text in images",
"machine_learning_ocr_enabled": "Enable OCR",
"machine_learning_ocr_enabled_description": "If disabled, images will not undergo text recognition.",
"machine_learning_ocr_max_resolution": "Maximum resolution",
"machine_learning_ocr_max_resolution_description": "Previews above this resolution will be resized while preserving aspect ratio. Higher values are more accurate, but take longer to process and use more memory.",
"machine_learning_ocr_min_detection_score": "Minimum detection score",
"machine_learning_ocr_min_detection_score_description": "Minimum confidence score for text to be detected from 0-1. Lower values will detect more text but may result in false positives.",
"machine_learning_ocr_min_recognition_score": "Minimum recognition score",
"machine_learning_ocr_min_score_recognition_description": "Minimum confidence score for detected text to be recognized from 0-1. Lower values will recognize more text but may result in false positives.",
"machine_learning_ocr_model": "OCR model",
"machine_learning_ocr_model_description": "Server models are more accurate than mobile models, but take longer to process and use more memory.",
"machine_learning_settings": "Machine Learning Settings",
"machine_learning_settings_description": "Manage machine learning features and settings",
"machine_learning_smart_search": "Smart Search",
Expand Down Expand Up @@ -245,6 +257,7 @@
"oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided.",
"oauth_timeout": "Request Timeout",
"oauth_timeout_description": "Timeout for requests in milliseconds",
"ocr_job_description": "Use machine learning to recognize text in images",
"password_enable_description": "Login with email and password",
"password_settings": "Password Login",
"password_settings_description": "Manage password login settings",
Expand Down Expand Up @@ -1436,6 +1449,7 @@
"oauth": "OAuth",
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"ocr": "OCR",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",
Expand Down Expand Up @@ -1710,6 +1724,8 @@
"search_by_description_example": "Hiking day in Sapa",
"search_by_filename": "Search by file name or extension",
"search_by_filename_example": "i.e. IMG_1234.JPG or PNG",
"search_by_ocr": "Search by OCR",
"search_by_ocr_example": "Latte",
"search_camera_make": "Search camera make...",
"search_camera_model": "Search camera model...",
"search_city": "Search city...",
Expand All @@ -1726,6 +1742,7 @@
"search_filter_location_title": "Select location",
"search_filter_media_type": "Media Type",
"search_filter_media_type_title": "Select media type",
"search_filter_ocr": "Search by OCR",
"search_filter_people_title": "Select people",
"search_for": "Search for",
"search_for_existing_person": "Search for existing person",
Expand Down
2 changes: 1 addition & 1 deletion machine-learning/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ FROM prod-${DEVICE} AS prod
ARG DEVICE

RUN apt-get update && \
apt-get install -y --no-install-recommends tini $(if ! [ "$DEVICE" = "openvino" ] && ! [ "$DEVICE" = "rocm" ]; then echo "libmimalloc2.0"; fi) && \
apt-get install -y --no-install-recommends tini ccache libgl1 libglib2.0-0 libgomp1 $(if ! [ "$DEVICE" = "openvino" ] && ! [ "$DEVICE" = "rocm" ]; then echo "libmimalloc2.0"; fi) && \
apt-get autoremove -yqq && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
Expand Down
1 change: 1 addition & 0 deletions machine-learning/immich_ml/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class PreloadModelData(BaseModel):

class MaxBatchSize(BaseModel):
facial_recognition: int | None = None
text_recognition: int | None = None


class Settings(BaseSettings):
Expand Down
4 changes: 3 additions & 1 deletion machine-learning/immich_ml/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ async def run_inference(payload: Image | str, entries: InferenceEntries) -> Infe
response: InferenceResponse = {}

async def _run_inference(entry: InferenceEntry) -> None:
model = await model_cache.get(entry["name"], entry["type"], entry["task"], ttl=settings.model_ttl)
model = await model_cache.get(
entry["name"], entry["type"], entry["task"], ttl=settings.model_ttl, **entry["options"]
)
inputs = [payload]
for dep in model.depends:
try:
Expand Down
8 changes: 8 additions & 0 deletions machine-learning/immich_ml/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from immich_ml.models.base import InferenceModel
from immich_ml.models.clip.textual import MClipTextualEncoder, OpenClipTextualEncoder
from immich_ml.models.clip.visual import OpenClipVisualEncoder
from immich_ml.models.ocr.detection import TextDetector
from immich_ml.models.ocr.recognition import TextRecognizer
from immich_ml.schemas import ModelSource, ModelTask, ModelType

from .constants import get_model_source
Expand All @@ -28,6 +30,12 @@ def get_model_class(model_name: str, model_type: ModelType, model_task: ModelTas
case ModelSource.INSIGHTFACE, ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION:
return FaceRecognizer

case ModelSource.PADDLE, ModelType.DETECTION, ModelTask.OCR:
return TextDetector

case ModelSource.PADDLE, ModelType.RECOGNITION, ModelTask.OCR:
return TextRecognizer

case _:
raise ValueError(f"Unknown model combination: {source}, {model_type}, {model_task}")

Expand Down
7 changes: 3 additions & 4 deletions machine-learning/immich_ml/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ def __init__(

def download(self) -> None:
if not self.cached:
log.info(
f"Downloading {self.model_type.replace('-', ' ')} model '{self.model_name}'. This may take a while."
)
model_type = self.model_type.replace("-", " ")
log.info(f"Downloading {model_type} model '{self.model_name}' to {self.model_path}. This may take a while.")
self._download()

def load(self) -> None:
Expand All @@ -58,7 +57,7 @@ def predict(self, *inputs: Any, **model_kwargs: Any) -> Any:
self.load()
if model_kwargs:
self.configure(**model_kwargs)
return self._predict(*inputs, **model_kwargs)
return self._predict(*inputs)

@abstractmethod
def _predict(self, *inputs: Any, **model_kwargs: Any) -> Any: ...
Expand Down
2 changes: 1 addition & 1 deletion machine-learning/immich_ml/models/clip/textual.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class BaseCLIPTextualEncoder(InferenceModel):
depends = []
identity = (ModelType.TEXTUAL, ModelTask.SEARCH)

def _predict(self, inputs: str, language: str | None = None, **kwargs: Any) -> str:
def _predict(self, inputs: str, language: str | None = None) -> str:
tokens = self.tokenize(inputs, language=language)
res: NDArray[np.float32] = self.session.run(None, tokens)[0][0]
return serialize_np_array(res)
Expand Down
2 changes: 1 addition & 1 deletion machine-learning/immich_ml/models/clip/visual.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class BaseCLIPVisualEncoder(InferenceModel):
depends = []
identity = (ModelType.VISUAL, ModelTask.SEARCH)

def _predict(self, inputs: Image.Image | bytes, **kwargs: Any) -> str:
def _predict(self, inputs: Image.Image | bytes) -> str:
image = decode_pil(inputs)
res: NDArray[np.float32] = self.session.run(None, self.transform(image))[0][0]
return serialize_np_array(res)
Expand Down
8 changes: 8 additions & 0 deletions machine-learning/immich_ml/models/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@
}


_PADDLE_MODELS = {
"PP-OCRv5_server",
"PP-OCRv5_mobile",
}

SUPPORTED_PROVIDERS = [
"CUDAExecutionProvider",
"ROCMExecutionProvider",
Expand Down Expand Up @@ -159,4 +164,7 @@ def get_model_source(model_name: str) -> ModelSource | None:
if cleaned_name in _OPENCLIP_MODELS:
return ModelSource.OPENCLIP

if cleaned_name in _PADDLE_MODELS:
return ModelSource.PADDLE

return None
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def _load(self) -> ModelSession:

return session

def _predict(self, inputs: NDArray[np.uint8] | bytes, **kwargs: Any) -> FaceDetectionOutput:
def _predict(self, inputs: NDArray[np.uint8] | bytes) -> FaceDetectionOutput:
inputs = decode_cv2(inputs)

bboxes, landmarks = self._detect(inputs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def _load(self) -> ModelSession:
return session

def _predict(
self, inputs: NDArray[np.uint8] | bytes | Image.Image, faces: FaceDetectionOutput, **kwargs: Any
self, inputs: NDArray[np.uint8] | bytes | Image.Image, faces: FaceDetectionOutput
) -> FacialRecognitionOutput:
if faces["boxes"].shape[0] == 0:
return []
Expand Down
86 changes: 86 additions & 0 deletions machine-learning/immich_ml/models/ocr/detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from typing import Any

import numpy as np
from PIL import Image
from rapidocr.ch_ppocr_det import TextDetector as RapidTextDetector
from rapidocr.inference_engine.base import FileInfo, InferSession
from rapidocr.utils import DownloadFile, DownloadFileInput
from rapidocr.utils.typings import EngineType, LangDet, OCRVersion, TaskType
from rapidocr.utils.typings import ModelType as RapidModelType

from immich_ml.config import log
from immich_ml.models.base import InferenceModel
from immich_ml.models.transforms import decode_cv2
from immich_ml.schemas import ModelFormat, ModelSession, ModelTask, ModelType
from immich_ml.sessions.ort import OrtSession

from .schemas import OcrOptions, TextDetectionOutput


class TextDetector(InferenceModel):
depends = []
identity = (ModelType.DETECTION, ModelTask.OCR)

def __init__(self, model_name: str, **model_kwargs: Any) -> None:
super().__init__(model_name, **model_kwargs, model_format=ModelFormat.ONNX)
self.max_resolution = 736
self.min_score = 0.5
self.score_mode = "fast"
self._empty: TextDetectionOutput = {
"image": np.empty(0, dtype=np.float32),
"boxes": np.empty(0, dtype=np.float32),
"scores": np.empty(0, dtype=np.float32),
}

def _download(self) -> None:
model_info = InferSession.get_model_url(
FileInfo(
engine_type=EngineType.ONNXRUNTIME,
ocr_version=OCRVersion.PPOCRV5,
task_type=TaskType.DET,
lang_type=LangDet.CH,
model_type=RapidModelType.MOBILE if "mobile" in self.model_name else RapidModelType.SERVER,
)
)
download_params = DownloadFileInput(
file_url=model_info["model_dir"],
sha256=model_info["SHA256"],
save_path=self.model_path,
logger=log,
)
DownloadFile.run(download_params)

def _load(self) -> ModelSession:
# TODO: support other runtime sessions
session = OrtSession(self.model_path)
self.model = RapidTextDetector(
OcrOptions(
session=session.session,
limit_side_len=self.max_resolution,
limit_type="min",
box_thresh=self.min_score,
score_mode=self.score_mode,
)
)
return session

def _predict(self, inputs: bytes | Image.Image) -> TextDetectionOutput:
results = self.model(decode_cv2(inputs))
if results.boxes is None or results.scores is None or results.img is None:
return self._empty
return {
"image": results.img,
"boxes": np.array(results.boxes, dtype=np.float32),
"scores": np.array(results.scores, dtype=np.float32),
}

def configure(self, **kwargs: Any) -> None:
if (max_resolution := kwargs.get("maxResolution")) is not None:
self.max_resolution = max_resolution
self.model.limit_side_len = max_resolution
if (min_score := kwargs.get("minScore")) is not None:
self.min_score = min_score
self.model.postprocess_op.box_thresh = min_score
if (score_mode := kwargs.get("scoreMode")) is not None:
self.score_mode = score_mode
self.model.postprocess_op.score_mode = score_mode
Loading
Loading