feat: implement plant health scanning functionality with backend integration and UI support

This commit is contained in:
2026-04-08 19:34:43 +02:00
parent d0a13fa4f0
commit de8130686a
15 changed files with 232 additions and 128 deletions

49
SplitImage.ps1 Normal file
View File

@@ -0,0 +1,49 @@
param (
[string]$ImagePath,
[string]$OutputDir,
[string]$Prefix
)
Add-Type -AssemblyName System.Drawing
$img = [System.Drawing.Image]::FromFile($ImagePath)
$w = $img.Width
$h = $img.Height
$targetWidth = $w
$targetHeight = [math]::Floor($w / 3.0)
$left = 0
$top = 0
if ($targetHeight -gt $h) {
$targetHeight = $h
$targetWidth = $h * 3
$left = [math]::Floor(($w - $targetWidth) / 2.0)
} else {
$top = [math]::Floor(($h - $targetHeight) / 2.0)
}
[int]$sqSize = [math]::Floor($targetWidth / 3.0)
[int]$leftInt = $left
[int]$topInt = $top
for ($i = 0; $i -lt 3; $i++) {
$bmp = New-Object System.Drawing.Bitmap -ArgumentList $sqSize, $sqSize
$g = [System.Drawing.Graphics]::FromImage($bmp)
[int]$rx = $leftInt + ($i * $sqSize)
$srcRect = New-Object System.Drawing.Rectangle -ArgumentList $rx, $topInt, $sqSize, $sqSize
$destRect = New-Object System.Drawing.Rectangle -ArgumentList 0, 0, $sqSize, $sqSize
$g.DrawImage($img, $destRect, $srcRect, [System.Drawing.GraphicsUnit]::Pixel)
$g.Dispose()
$outFile = Join-Path $OutputDir ("{0}_part{1}.png" -f $Prefix, ($i + 1))
$bmp.Save($outFile, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
}
$img.Dispose()
Write-Output "Successfully split the image into 3 pieces."

View File

@@ -14,7 +14,7 @@ import { useColors } from '../constants/Colors';
import { PlantRecognitionService } from '../services/plantRecognitionService';
import { IdentificationResult } from '../types';
import { ResultCard } from '../components/ResultCard';
import { backendApiClient, isInsufficientCreditsError, isNetworkError } from '../services/backend/backendApiClient';
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
import { isBackendApiError } from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
@@ -33,6 +33,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analyse fehlgeschlagen.',
noConnectionTitle: 'Keine Verbindung',
noConnectionMessage: 'Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung und versuche es erneut.',
timeoutTitle: 'Scan zu langsam',
timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.',
retryLabel: 'Erneut versuchen',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
@@ -55,6 +57,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analisis fallido.',
noConnectionTitle: 'Sin conexión',
noConnectionMessage: 'Sin conexión al servidor. Comprueba tu internet e inténtalo de nuevo.',
timeoutTitle: 'Escaneo lento',
timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.',
retryLabel: 'Reintentar',
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
@@ -76,6 +80,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
genericErrorMessage: 'Analysis failed.',
noConnectionTitle: 'No connection',
noConnectionMessage: 'Could not reach the server. Check your internet connection and try again.',
timeoutTitle: 'Scan Too Slow',
timeoutMessage: 'Analysis took too long. Please try again.',
retryLabel: 'Try again',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
@@ -295,6 +301,15 @@ export default function ScannerScreen() {
},
],
);
} else if (isTimeoutError(error)) {
Alert.alert(
billingCopy.timeoutTitle,
billingCopy.timeoutMessage,
[
{ text: billingCopy.dismiss, style: 'cancel' },
{ text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) },
],
);
} else if (isNetworkError(error)) {
Alert.alert(
billingCopy.noConnectionTitle,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View File

@@ -19,7 +19,7 @@ import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types';
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
const REQUEST_TIMEOUT_MS = 15000;
const REQUEST_TIMEOUT_MS = 60000;
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
if (status === 400) return 'BAD_REQUEST';
@@ -304,8 +304,9 @@ export const isInsufficientCreditsError = (error: unknown): boolean => {
};
export const isNetworkError = (error: unknown): boolean => {
return (
error instanceof BackendApiError &&
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
);
return error instanceof BackendApiError && error.code === 'NETWORK_ERROR';
};
export const isTimeoutError = (error: unknown): boolean => {
return error instanceof BackendApiError && error.code === 'TIMEOUT';
};

41
split_image.py Normal file
View File

@@ -0,0 +1,41 @@
import sys
from PIL import Image
def split_image(image_path, output_prefix):
try:
img = Image.open(image_path)
width, height = img.size
target_height = width // 3
if target_height > height:
target_height = height
target_width = height * 3
left = (width - target_width) // 2
top = 0
right = left + target_width
bottom = height
else:
target_width = width
left = 0
top = (height - target_height) // 2
right = width
bottom = top + target_height
img_cropped = img.crop((left, top, right, bottom))
sq_size = target_width // 3
for i in range(3):
box = (i * sq_size, 0, (i + 1) * sq_size, sq_size)
part = img_cropped.crop(box)
part.save(f"{output_prefix}_{i+1}.png")
print("Success")
except Exception as e:
print(f"Error: {str(e)}")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: split_image.py <input_img> <output_prefix>")
else:
split_image(sys.argv[1], sys.argv[2])

View File

@@ -1,5 +1,3 @@
const DEFAULT_API_BASE_URL = 'http://localhost:3000/api';
const normalizeHttpUrl = (value?: string | null): string | null => {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
@@ -23,7 +21,7 @@ export const getConfiguredApiBaseUrl = (): string => {
return backendBaseUrl.endsWith('/api') ? backendBaseUrl : `${backendBaseUrl}/api`;
}
return DEFAULT_API_BASE_URL;
return '';
};
export const getConfiguredBackendRootUrl = (): string => {
@@ -50,6 +48,6 @@ export const getConfiguredAssetBaseUrl = (): string => {
: pathname;
return `${parsed.origin}${assetPath}`.replace(/\/+$/, '');
} catch {
return 'http://localhost:3000';
return '';
}
};