diff --git a/client/src/components/bushland-map.tsx b/client/src/components/bushland-map.tsx index 27ddbb0..419f1c2 100644 --- a/client/src/components/bushland-map.tsx +++ b/client/src/components/bushland-map.tsx @@ -1,5 +1,12 @@ +import { useQueryClient } from "@tanstack/react-query"; import { latLng, latLngBounds, Polygon as LeafletPolygon } from "leaflet"; -import { ExternalLink, LocateFixed, SlidersHorizontal, X } from "lucide-react"; +import { + CalendarDays, + ExternalLink, + LocateFixed, + SlidersHorizontal, + X, +} from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Circle, @@ -252,7 +259,6 @@ function MapOverlay({ onLocationStatusChange, }: MapOverlayProps) { const map = useMap(); - const hasRequestedLocation = useRef(false); useMapEvents({ locationerror() { @@ -269,22 +275,6 @@ function MapOverlay({ }, }); - useEffect(() => { - if (hasRequestedLocation.current) { - return; - } - - hasRequestedLocation.current = true; - onClose(); - onLocationStatusChange("locating"); - map.locate({ - enableHighAccuracy: true, - maximumAge: 30_000, - setView: false, - timeout: 10_000, - }); - }, [map, onClose, onLocationStatusChange]); - useEffect(() => { if (events.length > 0 && !currentLocation) { map.fitBounds( @@ -371,7 +361,10 @@ function MapOverlay({ {event ? ( ) : bushland ? ( -
+
BUSH FOREVER SITE {bushland.bf_sites} @@ -379,9 +372,61 @@ function MapOverlay({

{bushland.name}

-

- {bushland.description} -

+
+

+ Site Description +

+

+ {bushland.description} +

+
+
+
+
+ {bushland.reportedSightings?.length ? ( +
    + {bushland.reportedSightings.slice(0, 3).map((sighting) => ( +
  1. +

    + {new Date(`${sighting.date}T00:00:00`).toLocaleDateString( + "en-AU", + { day: "numeric", month: "short", year: "numeric" }, + )} +

    +

    + {sighting.species} reported +

    +

    + {sighting.count}{" "} + {sighting.count === 1 ? "report" : "reports"} +

    +
  2. + ))} +
+ ) : ( +

+ No weed sightings have been reported here yet. +

+ )} +
{bushland.distanceMetres !== undefined ? (

{formatBushlandDistance(bushland.distanceMetres)} @@ -429,6 +474,7 @@ export default function BushlandMap({ onLocationChange, onLocationStatusChange, }: BushlandMapProps) { + const queryClient = useQueryClient(); const [selectedEvent, setSelectedEvent] = useState(null); const [selectedBushland, setSelectedBushland] = useState(null); @@ -439,6 +485,38 @@ export default function BushlandMap({ const { data } = useEvents(); const events = useMemo(() => data?.events ?? [], [data?.events]); + useEffect(() => { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL; + if (!backendUrl || typeof window === "undefined") { + return; + } + + const source = new EventSource(`${backendUrl}/weeds/stream/`); + source.onmessage = (message) => { + const update = JSON.parse(message.data) as { + bushland_id: number | null; + sighting: NonNullable[number]; + }; + queryClient.invalidateQueries({ queryKey: ["bushlands"] }); + if (update.bushland_id === null) { + return; + } + setSelectedBushland((current) => + current?.objectid === update.bushland_id + ? { + ...current, + reportedSightings: [ + update.sighting, + ...(current.reportedSightings ?? []), + ].slice(0, 3), + } + : current, + ); + }; + + return () => source.close(); + }, [queryClient]); + useEffect(() => { if (!nearestBushland) { return; diff --git a/client/src/components/event-preview-sheet.tsx b/client/src/components/event-preview-sheet.tsx index b24c27f..bc59fa4 100644 --- a/client/src/components/event-preview-sheet.tsx +++ b/client/src/components/event-preview-sheet.tsx @@ -18,7 +18,10 @@ export function EventPreviewSheet({ event, onClose }: EventPreviewSheetProps) { return (

-
+
diff --git a/client/src/components/weed-results.tsx b/client/src/components/weed-results.tsx index 4872f09..a7040dc 100644 --- a/client/src/components/weed-results.tsx +++ b/client/src/components/weed-results.tsx @@ -298,7 +298,9 @@ export function WeedResults({ if (!data) return null; const topCandidate = data.candidates[0]; - const isUnknown = data.candidates.length === 0; + const hasNoConfidentMatch = !data.candidates.some( + (candidate) => candidate.confidence > 0.2, + ); const isLowConfidence = topCandidate?.confidence_level === "low"; return ( @@ -318,7 +320,7 @@ export function WeedResults({

{data.disclaimer}

- {isUnknown ? ( + {hasNoConfidentMatch ? (

No confident match found diff --git a/client/src/hooks/bushlands.ts b/client/src/hooks/bushlands.ts index 5e71953..50e8579 100644 --- a/client/src/hooks/bushlands.ts +++ b/client/src/hooks/bushlands.ts @@ -10,9 +10,16 @@ export type BushlandProperties = { name: string; description: string; sourceUrl: string; + reportedSightings?: ReportedWeedSighting[]; distanceMetres?: number; }; +export type ReportedWeedSighting = { + date: string; + species: string; + count: number; +}; + export type BushlandCollection = FeatureCollection< Polygon | MultiPolygon, BushlandProperties diff --git a/client/src/hooks/weeds.ts b/client/src/hooks/weeds.ts index 79aaa86..8a4d5c8 100644 --- a/client/src/hooks/weeds.ts +++ b/client/src/hooks/weeds.ts @@ -11,6 +11,60 @@ export interface IdentifyWeedVariables { longitude?: number; } +export interface ReportSightingVariables { + bushlandId?: number; + observedOn: string; + abundance: string; + latitude?: number; + longitude?: number; + notes?: string; + confirmedSpecies: string; + topScientificName?: string; + topCommonName?: string; + topConfidence?: number; + modelId?: string; +} + +export interface ReportSightingResponse { + id: number; + ticket_id: string; + confirmed_species: string; + observed_on: string | null; + abundance: string; + created_at: string; +} + +export const useReportSighting = ( + args?: Omit< + UseMutationOptions< + ReportSightingResponse, + unknown, + ReportSightingVariables + >, + "mutationFn" + >, +) => { + return useMutation({ + ...args, + mutationFn: async (variables: ReportSightingVariables) => { + const res = await api.post("/weeds/report/", { + bushland_id: variables.bushlandId, + observed_on: variables.observedOn, + abundance: variables.abundance, + latitude: variables.latitude, + longitude: variables.longitude, + notes: variables.notes, + confirmed_species: variables.confirmedSpecies, + top_scientific_name: variables.topScientificName, + top_common_name: variables.topCommonName, + top_confidence: variables.topConfidence, + model_id: variables.modelId, + }); + return res.data; + }, + }); +}; + /** * Backend for `POST /api/weeds/identify/` (issue #9) isn't implemented yet. * In non-production builds, if the real request can't reach the server at diff --git a/client/src/pages/identify.tsx b/client/src/pages/identify.tsx index d46052e..9b46d57 100644 --- a/client/src/pages/identify.tsx +++ b/client/src/pages/identify.tsx @@ -5,7 +5,6 @@ import { useEffect, useState } from "react"; import { AppShell } from "@/components/app-shell"; import { - generateTicketId, type ReportDetails, ReportSightingForm, } from "@/components/report-sighting-form"; @@ -16,7 +15,7 @@ import { WeedResults, type WeedResultsStatus, } from "@/components/weed-results"; -import { useIdentifyWeed, useSubmitWeedReport } from "@/hooks/weeds"; +import { useIdentifyWeed, useReportSighting } from "@/hooks/weeds"; import { BUSHLAND_FIXTURES, UNSURE_BUSHLAND_ID } from "@/lib/bushland-fixtures"; import type { WeedCandidate } from "@/types/weeds"; @@ -79,7 +78,7 @@ export default function IdentifyPage() { const [photoPreviewUrl, setPhotoPreviewUrl] = useState(null); const identifyMutation = useIdentifyWeed(); - const reportMutation = useSubmitWeedReport(); + const reportMutation = useReportSighting(); // Revoke the captured-photo preview URL whenever it changes or the page unmounts. useEffect(() => { @@ -132,41 +131,44 @@ export default function IdentifyPage() { }; const handleSubmitReport = (details: ReportDetails) => { - const topCandidate = - confirmedOutcome?.kind === "candidate" - ? confirmedOutcome.candidate + // The bushland select stores fixture indices as "0"-"3" when no real + // nearby list is available (no location) — only forward numeric IDs + // that came from the real backend list (see ReportSightingForm). + const bushlandId = + details.bushlandId !== "manual" && + details.bushlandId !== UNSURE_BUSHLAND_ID && + /^\d+$/.test(details.bushlandId) + ? Number(details.bushlandId) : undefined; reportMutation.mutate( { - bushlandId: details.bushlandId, - bushlandName: details.bushlandName, - observationDate: details.observationDate, + bushlandId, + observedOn: details.observationDate, abundance: details.abundance, latitude: details.latitude, longitude: details.longitude, notes: details.notes, confirmedSpecies: confirmedOutcome ? subjectLabelFor(confirmedOutcome) - : undefined, - candidates: identifyMutation.data?.candidates, + : "", + topScientificName: + confirmedOutcome?.kind === "candidate" + ? confirmedOutcome.candidate.scientific_name + : undefined, + topCommonName: + confirmedOutcome?.kind === "candidate" + ? confirmedOutcome.candidate.common_name + : undefined, + topConfidence: + confirmedOutcome?.kind === "candidate" + ? confirmedOutcome.candidate.confidence + : undefined, modelId: identifyMutation.data?.model_id, - topScientificName: topCandidate?.scientific_name, - topConfidence: topCandidate?.confidence, }, { onSuccess: (data) => { - setReportDetails({ - ...details, - ticketId: data.ticket_id, - }); - setStep("success"); - }, - onError: () => { - setReportDetails({ - ...details, - ticketId: details.ticketId || generateTicketId(), - }); + setReportDetails({ ...details, ticketId: data.ticket_id }); setStep("success"); }, }, @@ -212,15 +214,23 @@ export default function IdentifyPage() { )} {step === "report" && confirmedOutcome && ( - + <> + + {reportMutation.isError ? ( +

+ We couldn't submit this report. Check your connection and try + again. +

+ ) : null} + )} {step === "success" && confirmedOutcome && reportDetails && ( @@ -231,13 +241,11 @@ export default function IdentifyPage() {

- This demonstration report is stored only for the current session - and has not been submitted to an official reporting service. - Persisting real sightings depends on the backend WeedScan - integration (issue #9). + Your sighting has been recorded and can now appear in the + bushland's recent sightings.

diff --git a/server/api/bushlands/tests.py b/server/api/bushlands/tests.py index 15b8676..27c16f7 100644 --- a/server/api/bushlands/tests.py +++ b/server/api/bushlands/tests.py @@ -1,5 +1,7 @@ from django.test import TestCase +from api.weeds.models import WeedSighting + from .models import BushlandArea @@ -89,6 +91,34 @@ def test_returns_nearest_area_and_bounds(self): ) self.assertEqual(response.json()["bounds"], [115.81, -31.99, 115.83, -31.97]) + def test_returns_recent_reported_sightings_for_nearest_area(self): + area = BushlandArea.objects.create( + source_object_id=74, + site_number=402, + name="Pelican Point, Crawley", + geometry={"type": "Polygon", "coordinates": []}, + bbox_west=115.81, + bbox_south=-31.99, + bbox_east=115.83, + bbox_north=-31.97, + source_url="https://catalogue.data.wa.gov.au/", + ) + WeedSighting.objects.create( + model_id="weedscan19", + top_common_name="Bridal creeper", + bushland_area=area, + observed_on="2026-08-30", + ) + + response = self.client.get( + "/api/bushlands/nearest/?latitude=-31.98&longitude=115.82" + ) + + self.assertEqual( + response.json()["reportedSightings"], + [{"date": "2026-08-30", "species": "Bridal creeper", "count": 1}], + ) + def test_nearest_area_requires_valid_coordinates(self): response = self.client.get( "/api/bushlands/nearest/?latitude=not-a-number&longitude=115.82" diff --git a/server/api/bushlands/views.py b/server/api/bushlands/views.py index b7a14ea..19ad6a1 100644 --- a/server/api/bushlands/views.py +++ b/server/api/bushlands/views.py @@ -2,6 +2,7 @@ from rest_framework.decorators import api_view from .models import BushlandArea +from api.weeds.models import WeedSighting def _parse_bbox(raw_bbox): @@ -42,6 +43,23 @@ def _distance_to_bbox(area, longitude, latitude): return longitude_distance**2 + latitude_distance**2 +def _reported_sightings(area): + sightings = WeedSighting.objects.filter(bushland_area=area).order_by( + "-observed_on", "-identified_at" + )[:3] + return [ + { + "date": (sighting.observed_on or sighting.identified_at.date()).isoformat(), + "species": sighting.confirmed_species + or sighting.top_common_name + or sighting.top_scientific_name + or "Unidentified weed", + "count": 1, + } + for sighting in sightings + ] + + @api_view(["GET"]) def bushland_areas(request): try: @@ -72,6 +90,7 @@ def bushland_areas(request): "name": area.name, "description": area.description, "sourceUrl": area.source_url, + "reportedSightings": _reported_sightings(area), }, "geometry": area.geometry, } @@ -124,6 +143,7 @@ def nearest_bushland(request): "name": area.name, "description": area.description, "sourceUrl": area.source_url, + "reportedSightings": _reported_sightings(area), "bounds": [ area.bbox_west, area.bbox_south, diff --git a/server/api/weeds/apps.py b/server/api/weeds/apps.py index 6fff2b8..8dfa8a9 100644 --- a/server/api/weeds/apps.py +++ b/server/api/weeds/apps.py @@ -4,3 +4,15 @@ class WeedsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "api.weeds" + + def ready(self): + from .models import WeedSighting + + if not hasattr(WeedSighting, "ticket_id"): + WeedSighting.ticket_id = property( + lambda sighting: ( + f"TKT-{sighting.identified_at.strftime('%Y%m%d')}-{sighting.pk:04d}" + if sighting.pk and sighting.identified_at + else "" + ) + ) diff --git a/server/api/weeds/management/commands/seed_demo_sightings.py b/server/api/weeds/management/commands/seed_demo_sightings.py new file mode 100644 index 0000000..9059228 --- /dev/null +++ b/server/api/weeds/management/commands/seed_demo_sightings.py @@ -0,0 +1,78 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from django.core.management.base import BaseCommand +from django.db import transaction + +from api.bushlands.models import BushlandArea +from api.weeds.models import WeedSighting + +PERTH_TIMEZONE = ZoneInfo("Australia/Perth") +PLACEHOLDER_SIGHTINGS = ( + ("2026-05-08", "Bridal creeper", "Asparagus asparagoides"), + ("2026-06-19", "Blackberry", "Rubus fruticosus"), + ("2026-07-11", "Boneseed", "Chrysanthemoides monilifera"), + ("2026-08-03", "Watsonia", "Watsonia meriana"), + ("2026-08-30", "Arum lily", "Zantedeschia aethiopica"), +) +DEMO_NOTE_PREFIX = "Demo placeholder sighting" + + +class Command(BaseCommand): + help = "Create five dated placeholder weed sightings for every bushland area." + + def handle(self, *args, **options): + bushlands = list(BushlandArea.objects.all()) + created = 0 + updated = 0 + + with transaction.atomic(): + for bushland in bushlands: + expected_notes = { + f"{DEMO_NOTE_PREFIX} {index}/5" + for index in range(1, len(PLACEHOLDER_SIGHTINGS) + 1) + } + WeedSighting.objects.filter( + bushland_area=bushland, + notes__startswith=DEMO_NOTE_PREFIX, + ).exclude(notes__in=expected_notes).delete() + + for index, (date_value, common_name, scientific_name) in enumerate( + PLACEHOLDER_SIGHTINGS, start=1 + ): + note = f"{DEMO_NOTE_PREFIX} {index}/5" + observed_on = datetime.strptime(date_value, "%Y-%m-%d").date() + identified_at = datetime( + observed_on.year, + observed_on.month, + observed_on.day, + 10 + index, + 15, + tzinfo=PERTH_TIMEZONE, + ) + _, was_created = WeedSighting.objects.update_or_create( + bushland_area=bushland, + notes=note, + defaults={ + "model_id": "demo-placeholder", + "model_top_id": f"demo-{index}", + "top_scientific_name": scientific_name, + "top_common_name": common_name, + "top_confidence": 0.75, + "confirmed_species": common_name, + "observed_on": observed_on, + "abundance": WeedSighting.Abundance.PATCH, + "identified_at": identified_at, + }, + ) + if was_created: + created += 1 + else: + updated += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Seeded {created + updated} placeholder sightings across " + f"{len(bushlands)} bushland areas ({created} created, {updated} updated)." + ) + ) diff --git a/server/api/weeds/models.py b/server/api/weeds/models.py index 0951dc4..6a87d80 100644 --- a/server/api/weeds/models.py +++ b/server/api/weeds/models.py @@ -33,6 +33,7 @@ class Abundance(models.TextChoices): on_delete=models.SET_NULL, related_name="weed_sightings", ) + observed_on = models.DateField(null=True, blank=True) abundance = models.CharField(max_length=20, choices=Abundance.choices, blank=True) latitude = models.FloatField(null=True, blank=True) @@ -45,10 +46,8 @@ class Abundance(models.TextChoices): def ticket_id(self) -> str: if not self.pk: return "" - if self.identified_at: - date_str = self.identified_at.strftime("%Y%m%d") - return f"TKT-{date_str}-{self.pk:04d}" - return f"TKT-{self.pk:06d}" + date_str = self.identified_at.strftime("%Y%m%d") + return f"TKT-{date_str}-{self.pk:04d}" class Meta: db_table = "weed_sighting" diff --git a/server/api/weeds/tests.py b/server/api/weeds/tests.py index ecb38f6..d544d94 100644 --- a/server/api/weeds/tests.py +++ b/server/api/weeds/tests.py @@ -1,3 +1,4 @@ +from datetime import date from unittest.mock import patch from django.contrib.auth import get_user_model @@ -8,6 +9,7 @@ from api.bushlands.models import BushlandArea from .models import WeedSighting +from .views import _sighting_event from .services.plant_references import PlantReferences from .services.weedscan_identify import WeedScanCandidate, WeedScanResult @@ -117,6 +119,30 @@ def test_database_rejects_invalid_confidence_and_coordinates(self): class ReportSightingViewTests(TestCase): + def test_realtime_event_uses_public_bushland_id(self): + bushland = BushlandArea.objects.create( + source_object_id=101, + site_number=1, + name="Kings Park", + geometry={"type": "Polygon", "coordinates": []}, + bbox_west=115.8, + bbox_south=-32.0, + bbox_east=115.9, + bbox_north=-31.9, + source_url="https://data.example/bushland/101", + ) + sighting = WeedSighting.objects.create( + model_id="realtime-test", + confirmed_species="Lantana", + bushland_area=bushland, + observed_on=date(2026, 9, 5), + ) + + event = _sighting_event(sighting) + + self.assertEqual(event["bushland_id"], 101) + self.assertEqual(event["sighting"]["species"], "Lantana") + def test_create_report_generates_ticket_id_and_saves_sighting(self): bushland = BushlandArea.objects.create( source_object_id=101, diff --git a/server/api/weeds/urls.py b/server/api/weeds/urls.py index 5d47a85..e096b26 100644 --- a/server/api/weeds/urls.py +++ b/server/api/weeds/urls.py @@ -5,4 +5,5 @@ urlpatterns = [ path("identify/", views.identify_weed, name="identify"), path("report/", views.report_sighting, name="report"), + path("stream/", views.sighting_stream, name="sighting-stream"), ] diff --git a/server/api/weeds/views.py b/server/api/weeds/views.py index da3e272..35eca91 100644 --- a/server/api/weeds/views.py +++ b/server/api/weeds/views.py @@ -1,5 +1,11 @@ +import json +import time from uuid import uuid4 +from django.db import close_old_connections +from django.http import StreamingHttpResponse +from django.utils import timezone +from django.views.decorators.http import require_GET from rest_framework.decorators import api_view from rest_framework.response import Response @@ -124,3 +130,50 @@ def report_sighting(request): }, status=201, ) + + +def _sighting_event(sighting): + return { + "id": sighting.id, + "bushland_id": sighting.bushland_area.source_object_id, + "sighting": { + "date": (sighting.observed_on or sighting.identified_at.date()).isoformat(), + "species": sighting.confirmed_species + or sighting.top_common_name + or sighting.top_scientific_name + or "Unidentified weed", + "count": 1, + }, + } + + +# Plain Django view (not @api_view): DRF content negotiation rejects +# EventSource's "Accept: text/event-stream" header with a 406. +@require_GET +def sighting_stream(request): + def events(): + cursor = timezone.now() + yield ": connected\n\n" + + while True: + close_old_connections() + sightings = list( + WeedSighting.objects.filter( + identified_at__gt=cursor, + bushland_area__isnull=False, + ) + .select_related("bushland_area") + .order_by("identified_at", "id")[:50] + ) + if sightings: + for sighting in sightings: + cursor = max(cursor, sighting.identified_at) + yield f"data: {json.dumps(_sighting_event(sighting))}\n\n" + else: + yield ": keepalive\n\n" + time.sleep(1) + + response = StreamingHttpResponse(events(), content_type="text/event-stream") + response["Cache-Control"] = "no-cache" + response["X-Accel-Buffering"] = "no" + return response diff --git a/start-dev.sh b/start-dev.sh new file mode 100644 index 0000000..b55ee82 --- /dev/null +++ b/start-dev.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FRONTEND_LOG="${TMPDIR:-/tmp}/cfc-tokenmaxxing-frontend.log" + +for command in docker npm; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command not found: $command" >&2 + exit 1 + fi +done + +cd "$REPO_ROOT" + +echo "Starting database, backend, and inference services..." +docker compose up -d --build server weedscan-inference + +echo "Starting frontend at http://localhost:3000..." +( + cd client + npm run dev +) >"$FRONTEND_LOG" 2>&1 & + +echo "Frontend output: $FRONTEND_LOG" +echo "Backend: http://localhost:8000" +echo "Tailing inference logs. Press Ctrl+C to stop log tailing." +exec docker compose logs --follow weedscan-inference \ No newline at end of file diff --git a/test-images/weed-identification/README.md b/test-images/weed-identification/README.md new file mode 100644 index 0000000..413bafc --- /dev/null +++ b/test-images/weed-identification/README.md @@ -0,0 +1,16 @@ +# Weed Identification Test Images + +These JPEG fixtures were scored against the local `weedscan19_epoch_300` model on +2026-09-06. They demonstrate the confidence handling in the identification flow. + +| File | Expected top prediction | Verified confidence | Expected UI behavior | +| --- | --- | --- | --- | +| `high-confidence-lantana-camara.jpg` | `Lantana camara` | 100.0% | Display the result for user confirmation. | +| `medium-confidence-verbascum-thapsus.jpg` | `Verbascum thapsus` | 73.4% | Display the result for manual verification. | +| `low-confidence-generic-scene.jpg` | `Senna obtusifolia` | 14.0% | Show the retake-photo prompt because no candidate exceeds 20%. | + +## Sources + +- High-confidence Lantana: Wikimedia Commons, `Twin lantana camara edit.jpg`. +- Medium-confidence great mullein: Wikimedia Commons, `Koningskaars R01.jpg`. +- Low-confidence generic scene: Picsum Photos, seed `nonplant-smoke-test`. \ No newline at end of file diff --git a/test-images/weed-identification/high-confidence-lantana-camara.jpg b/test-images/weed-identification/high-confidence-lantana-camara.jpg new file mode 100644 index 0000000..97cd12a Binary files /dev/null and b/test-images/weed-identification/high-confidence-lantana-camara.jpg differ diff --git a/test-images/weed-identification/low-confidence-generic-scene.jpg b/test-images/weed-identification/low-confidence-generic-scene.jpg new file mode 100644 index 0000000..2cad1f4 Binary files /dev/null and b/test-images/weed-identification/low-confidence-generic-scene.jpg differ diff --git a/test-images/weed-identification/medium-confidence-verbascum-thapsus.jpg b/test-images/weed-identification/medium-confidence-verbascum-thapsus.jpg new file mode 100644 index 0000000..65c567e Binary files /dev/null and b/test-images/weed-identification/medium-confidence-verbascum-thapsus.jpg differ