はじめに
2023年に農業関連の事業で一時的に静岡に住んで、圃場(畑とか田んぼとか農作物を栽培するための場所)の測定業務をしていたときに、作業完了するとエビデンスとして写真を撮るんだが、できるだけ自動で写真と圃場を紐づけられたらなって思ったんだ。静岡にいたころは忙しくてさすがに作れなかったけど、もしそれができれば写真バシャバシャ撮っておりゃーってアップロードすれば日誌に紐づけたりとかできるわけだ。まぁ定点カメラで自動的に撮影して~ってやれるともっといいけどね、こういう実際のフィールドと紐づくようなプログラミングはおもしろさあるよな


GPSの制約について
📸 写真のGPS位置情報について
スマートフォンで撮影した写真のGPS情報は以下の理由により実際の位置から 最大100m程度ずれる
ことがあります
これほんとに100mぐらいズレるのよ。新しいタブレット買って試したんだけど、となりにも圃場があって~みたいな状況だとマジで役に立たない。せめて同じズレ方するなら補正ができるけど単純な補正が困難。
ということで「写真の位置に最も近い圃場を紐づける」機能を提供することに注力し、写真アップロード機能ではなく、あらかじめ用意した撮影位置から選択する方式を採用することにした。これにより、位置情報のずれによる誤った紐づけを防ぐ。まぁポートフォリオだからね。十分十分。
KMLについて
なぜKMLを使うのか?
作業当時、ザルビオという圃場管理ソフトを使用しており、このソフトがクライアント(農家)にとって入力しやすい仕様になっていた。そのため、農家が自身の圃場の座標を手軽に登録できる環境が整っていた。

「ザルビオ®フィールドマネージャーは、JA 全農が推奨するAI を活用した栽培管理システムです。各ほ場の土壌や作物の品種特性、気象情報、人工衛星からの画像等をAI が解析し、作物の生育や病害・雑草の発生を予測、最適な防除時期や収穫時期等を提案します。いつ、どのような作業が必要かをほ場ごとに把握できるため、効率的な栽培管理計画を作ることが可能になります。」
本来はこういう建付けなんだが、単純に図形を定義して名前をつけるという用途で使っていた
さらに、ザルビオでは圃場の座標データをKML形式でエクスポートできる機能があった。このKMLデータを活用すれば、圃場の形状や位置情報をGISソフトやカスタムアプリで簡単に取り扱えるため、効率的な管理が可能となる。
以上の理由から、KMLを利用することにした。
KML(Keyhole Markup Language)は、地理空間データを表現するためのXMLベースのフォーマットであり、Google EarthやGoogle Mapsなどで広く利用されている。KMLを使うことで、圃場の位置や形状を視覚的に確認できるだけでなく、他のシステムとのデータ連携も容易になる。
また、KMLはシンプルな構造のため、圃場データのプログラム処理も比較的簡単に行える。例えば、KMLファイルを解析し、撮影した写真のGPS情報と最も近い圃場を紐づけるといった処理をスムーズに実装できる。
このように、ザルビオとの親和性、エクスポートの容易さ、視覚的な確認のしやすさ、データ処理のしやすさなどの点から、KMLを採用することにした。
source code
まぁ今回は既存のアプリに追加機能だからviews.pyとか結構いろいろ省略してある。まぁ距離測定の仕組みとかは参考になるんじゃないかな
repository
company.py
from soil_analysis.models import Company
class CompanyRepository:
@staticmethod
def get_company_by_id(company_id: int) -> Company:
try:
return Company.objects.get(pk=company_id)
except Company.DoesNotExist:
return None
land.py
from soil_analysis.models import Land, LandLedger
class LandRepository:
@staticmethod
def find_land_by_id(land_id: int) -> Land:
return Land.objects.get(pk=land_id)
@staticmethod
def get_land_to_ledgers_map(land_list: list[Land]):
"""
Landオブジェクトをキー、その圃場の台帳のQuerySetを値とする辞書を返す
"""
land_ledger_map = {}
land_ledgers = LandLedger.objects.filter(land__in=land_list)
for land in land_list:
land_ledger_map[land] = land_ledgers.filter(land=land)
return land_ledger_map
service
kml.py
from fastkml import kml
from soil_analysis.domain.valueobject.land import LandLocation
class KmlService:
KML_DOCUMENT = 0
KML_POLYGON = 0
KML_LNG = 0
KML_LAT = 1
def parse_kml(self, kml_str):
"""
KML形式の文字列を解析して 圃場位置情報のリスト を作成します。
Args:
kml_str (str): KML形式の文字列データ。
Notes: xarvioなら以下でOK
upload_file: InMemoryUploadedFile = self.request.FILES['file']
kml_raw = upload_file.read()
kml_service = KmlService()
Returns:
list[LandLocation]: 解析された圃場位置情報のリスト。
Raises:
ValueError: 不正なKML形式の文字列が指定された場合に発生します。
"""
land_location_list: list[LandLocation] = []
try:
kml_doc = kml.KML()
kml_doc.from_string(kml_str)
kml_document = list(kml_doc.features())[self.KML_DOCUMENT]
for place_mark in kml_document.features():
place_mark_object = place_mark.geometry
name = place_mark.name
coord_str = self.to_str(
place_mark_object.geoms[self.KML_POLYGON].exterior.coords
)
land_location_list.append(LandLocation(coord_str, name))
return land_location_list
except ValueError as e:
raise ValueError("Invalid KML format") from e
def to_str(self, coord_list: list):
"""
座標のリストを文字列表現に変換します。
Args:
coord_list (list): 座標のリスト。各座標はタプルとして表されます。
Returns:
str: 座標の文字列表現。
"""
return " ".join(
[f"{coord[self.KML_LNG]},{coord[self.KML_LAT]}" for coord in coord_list]
)
photo_processing_service.py
from haversine import haversine, Unit
from lib.geo.valueobject.coord import XarvioCoord
from soil_analysis.domain.valueobject.capturelocation import CaptureLocation
from soil_analysis.domain.valueobject.photo import AndroidPhoto
from soil_analysis.domain.valueobject.photo_land_association import PhotoLandAssociation
from soil_analysis.models import Land
class PhotoProcessingService:
def process_photos(
self, photo_path_list: list[str], land_list: list[Land]
) -> list[PhotoLandAssociation]:
"""写真パスのリストから写真を処理し、最寄りの圃場と紐づけます。
Args:
photo_path_list: 処理する写真ファイルのパスリスト
land_list: 検索対象の圃場リスト
Returns:
list[PhotoLandAssociation]: 写真と圃場の紐づけ情報のリスト
"""
associations = []
# 複数の写真ファイルを処理
for photo_path in photo_path_list:
# IMG20230630190442.jpg のようなファイル名になっている
android_photo = AndroidPhoto(photo_path)
photo_spot = android_photo.location
# 画像(=撮影位置)から最も近い圃場を特定
nearest_land = self.find_nearest_land(photo_spot, land_list)
# 距離を計算
distance = self.calculate_distance(
photo_spot.adjusted_position, nearest_land
)
# 写真と圃場の紐づけ情報を作成
association = PhotoLandAssociation(photo_path, nearest_land, distance)
associations.append(association)
# TODO: ここで写真のリネーム処理や output_folder への保存などの操作を行う
return associations
def find_nearest_land(
self, photo_spot: CaptureLocation, land_list: list[Land]
) -> Land:
"""撮影位置から最も近い圃場を特定します。
写真のGPSメタデータから抽出した撮影位置を使用して、候補となる圃場の中から
最も距離が近い圃場を特定します。カメラの方向情報が含まれている場合は、
その方向に調整された位置を使用して、より正確に撮影対象の圃場を特定します。
例えば、複数の隣接する圃場(A1、A2、A3など)を撮影した場合、各写真がどの圃場を
対象としているかを自動的に判別することができます。
Args:
photo_spot: 撮影位置情報(方位角による調整を含む)
land_list: 検索対象の圃場リスト
Returns:
Land: 最も近いと判断された圃場
"""
min_distance = float("inf")
nearest_land = None
for land in land_list:
distance = self.calculate_distance(photo_spot.adjusted_position, land)
if distance < min_distance:
min_distance = distance
nearest_land = land
return nearest_land
@staticmethod
def calculate_distance(
photo_spot: XarvioCoord, land: Land, unit: str = Unit.METERS
) -> float:
"""2つの座標間の距離を計算します。
xarvioは経度緯度(lng,lat)をエクスポートする一方、
haversineライブラリは緯度経度(lat,lng)の2セットを受け取って距離を計算します。
そのため、haversineライブラリを使用する際に座標のタプルを逆にしています。
Args:
photo_spot: 開始座標
land: 圃場(中心点を終了座標として使用)
unit: 距離の単位(デフォルトはメートル)
Returns:
float: 指定単位での2点間の距離
"""
return haversine(
photo_spot.to_google().to_tuple(),
land.to_google().to_tuple(),
unit=unit,
)
valueobject
photo_land_association.py
from dataclasses import dataclass
from typing import Optional
from soil_analysis.models import Land
@dataclass(frozen=True)
class PhotoLandAssociation:
"""写真と圃場の紐づけを表現する値オブジェクト。
写真パスと、その写真に対応する最寄りの圃場情報を保持します。
"""
photo_path: str
nearest_land: Land
distance: Optional[float] = None
def __str__(self) -> str:
"""人間が読みやすい形式で紐づけ情報を返します。"""
distance_info = (
f", 距離: {self.distance:.1f}m" if self.distance is not None else ""
)
return (
f"写真: {self.photo_path} → 圃場: {self.nearest_land.name}{distance_info}"
)
picture_land_associate
form.html
{% extends "soil_analysis/base.html" %}
{% load static %}
{% block content %}
<div class="container">
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'soil:home' %}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Picture Land Associate</li>
</ol>
</nav>
<h1>写真と圃場の自動紐づけ</h1>
<div class="alert alert-info mb-4">
<p><strong>📸 写真のGPS位置情報について</strong></p>
<p>
スマートフォンで撮影した写真のGPS情報は以下の理由により実際の位置から最大100m程度ずれることがあります:</p>
<ul>
<li>GPS技術自体の精度限界(一般的に5〜10m、条件によってはそれ以上)</li>
<li>周囲の建物、樹木、地形による信号の遮断や反射</li>
<li>天候や大気条件による信号品質の低下</li>
<li>利用可能な衛星の数や配置</li>
<li>スマートフォンのGPSチップやアンテナの性能差</li>
</ul>
<p>さらに、このずれは一定方向や距離ではなくランダム要素を含むため、単純な補正が困難です。</p>
<p>
このページでは「写真の位置に最も近い圃場を紐づける」機能を提供することに注力し、写真アップロード機能ではなく、あらかじめ設定した撮影位置から選択する方式を採用しています。これにより、位置情報のずれによる誤った紐づけを防ぎ、正確な圃場管理を実現します。</p>
</div>
<form method="post">
{% csrf_token %}
<div class="row mb-4">
<div class="col-md-6">
<h3>写真(撮影位置)一覧</h3>
{% for photo_spot in photo_spots %}
<div class="form-check">
<input class="form-check-input" type="radio" name="photo_spot"
id="photo_spot_{{ forloop.counter }}"
value="{{ forloop.counter0 }}">
<label class="form-check-label" for="photo_spot_{{ forloop.counter }}">
座標 {{ forloop.counter }}: {{ photo_spot.to_google.to_str }}
</label>
</div>
{% endfor %}
</div>
<div class="col-md-6">
<h3>圃場一覧</h3>
<div class="list-group">
{% for land in land_list %}
<div class="list-group-item">
{{ land.name }} {{ land.to_google.to_str }}
</div>
{% endfor %}
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">最も近い圃場を特定</button>
</form>
</div>
{% endblock %}
result.html
{% extends "soil_analysis/base.html" %}
{% load static %}
{% block content %}
<div class="container">
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'soil:home' %}">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">Picture Land Associate</li>
</ol>
</nav>
<h1>写真と圃場の紐づけ結果</h1>
<div class="alert alert-info mb-4">
<p><strong>📸 写真のGPS位置情報について</strong></p>
<p>
スマートフォンで撮影した写真のGPS情報は以下の理由により実際の位置から最大100m程度ずれることがあります:</p>
<ul>
<li>GPS技術自体の精度限界(一般的に5〜10m、条件によってはそれ以上)</li>
<li>周囲の建物、樹木、地形による信号の遮断や反射</li>
<li>天候や大気条件による信号品質の低下</li>
<li>利用可能な衛星の数や配置</li>
<li>スマートフォンのGPSチップやアンテナの性能差</li>
</ul>
<p>さらに、このずれは一定方向や距離ではなくランダム要素を含むため、単純な補正が困難です。</p>
<p>
このページでは「写真の位置に最も近い圃場を紐づける」機能を提供することに注力し、写真アップロード機能ではなく、あらかじめ設定した撮影位置から選択する方式を採用しています。これにより、位置情報のずれによる誤った紐づけを防ぎ、正確な圃場管理を実現します。</p>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">選択された撮影地点</div>
<div class="card-body">
<p>座標: {{ photo_spot_coord }}</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">最も近い圃場</div>
<div class="card-body">
<h5>{{ nearest_land.name }}</h5>
<p>位置: {{ nearest_land.location }}</p>
<p>面積: {{ nearest_land.area|default:"-" }} ㎡</p>
<p>所有者: {{ nearest_land.owner }}</p>
</div>
</div>
</div>
</div>
{% if route_url %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">撮影地点から圃場へのルート</div>
<div class="card-body">
<p>以下のリンクから、撮影地点から圃場までの徒歩ルートを確認できます:</p>
<a href="{{ route_url }}" class="btn btn-primary" target="_blank">
<i class="bi bi-map"></i> Googleマップでルートを確認する
</a>
</div>
</div>
</div>
</div>
{% endif %}
<div class="mt-4">
<a href="{% url 'soil:associate_picture_and_land' %}" class="btn btn-secondary">戻る</a>
</div>
</div>
{% endblock %}
test
service
test_kml.py
from unittest import TestCase
from soil_analysis.domain.service.kml import KmlService
class TestKmlService(TestCase):
def test_parse_kml(self):
kml_str = """
<?xml version="1.0" encoding="UTF-8"?>
<kml>
<Document id="featureCollection">
<Placemark id="06ad0c86-6cba-46a6-a7f8-11117fb75634">
<name>株式会社ABC_ABCグループ - ススムA1</name>
<MultiGeometry>
<Polygon>
<outerBoundaryIs>
<LinearRing>
<coordinates>137.6489657,34.7443565 137.6491266,34.744123 137.648613,34.7438929
137.6484413,34.7441175 137.6489657,34.7443565</coordinates>
</LinearRing>
</outerBoundaryIs>
</Polygon>
</MultiGeometry>
</Placemark>
<Placemark id="0d681402-ae55-4406-9582-6c9360bc5e5b">
<name>株式会社ABC_ABCグループ - ススムA3</name>
<MultiGeometry>
<Polygon>
<outerBoundaryIs>
<LinearRing>
<coordinates>137.6492809,34.743865 137.6494646,34.7436029 137.6489644,34.7433683
137.6487806,34.7436403 137.6492809,34.743865</coordinates>
</LinearRing>
</outerBoundaryIs>
</Polygon>
</MultiGeometry>
</Placemark>
</Document>
</kml>
"""
kml_service = KmlService()
land_location_list = kml_service.parse_kml(kml_str)
# パース結果の確認
self.assertIsInstance(land_location_list, list)
self.assertEqual(2, len(land_location_list))
# 個別の領域の確認
self.assertEqual(
"株式会社ABC_ABCグループ - ススムA1", land_location_list[0].name
)
self.assertAlmostEqual(
137.6487867, land_location_list[0].center.longitude, delta=0.000001
)
self.assertAlmostEqual(
34.7441225, land_location_list[0].center.latitude, delta=0.000001
)
self.assertEqual(
"株式会社ABC_ABCグループ - ススムA3", land_location_list[1].name
)
self.assertAlmostEqual(
137.6491226, land_location_list[1].center.longitude, delta=0.000001
)
self.assertAlmostEqual(
34.7436191, land_location_list[1].center.latitude, delta=0.000001
)
test_photo_processing_service.py
from unittest.mock import MagicMock, patch, Mock
from django.test import TestCase
from lib.geo.valueobject.coord import XarvioCoord
from soil_analysis.domain.service.photo_processing_service import PhotoProcessingService
from soil_analysis.domain.valueobject.capturelocation import CaptureLocation
from soil_analysis.domain.valueobject.land import LandLocation
class TestPhotoProcessingService(TestCase):
"""写真と圃場を自動的に紐づける機能を検証するテストスイートです。
複数の圃場(ススムA1〜A4)が隣接する環境で、各写真の撮影位置から
どの圃場を撮影したものかを正確に特定できるかをテストします。
主な検証内容:
- 2点間の距離計算の正確性
- 撮影位置から最も近い圃場の特定アルゴリズム
- カメラの方向を考慮した位置調整の効果
- 複数写真の一括処理と圃場の関連付け
このテストスイートは、農業向け画像管理システムの重要な機能である
「撮影した圃場の自動判別」機能の正確性を担保します。
"""
def setUp(self):
# モックオブジェクトを作成
self.land1 = Mock()
self.land1.name = "農業法人2の圃場1(静岡ススムA1)"
self.land1.center = "34.7441225,137.6487867"
self.land1.id = 4
self.land1.to_google.return_value.to_tuple.return_value = (
34.7441225,
137.6487867,
)
self.land2 = Mock()
self.land2.name = "農業法人2の圃場2(静岡ススムA2)"
self.land2.center = "34.7438825,137.648955"
self.land2.id = 5
self.land2.to_google.return_value.to_tuple.return_value = (
34.7438825,
137.648955,
)
self.land3 = Mock()
self.land3.name = "農業法人2の圃場3(静岡ススムA3)"
self.land3.center = "34.7436191,137.6491226"
self.land3.id = 6
self.land3.to_google.return_value.to_tuple.return_value = (
34.7436191,
137.6491226,
)
self.land4 = Mock()
self.land4.name = "農業法人2の圃場3(静岡ススムA4)"
self.land4.center = "34.7432844,137.6493423"
self.land4.id = 7
self.land4.to_google.return_value.to_tuple.return_value = (
34.7432844,
137.6493423,
)
# 土地候補のリストを作成
self.land_list = [self.land1, self.land2, self.land3, self.land4]
def test_calculate_distance(self):
"""座標間の距離計算機能をテストします。
ススムA3の中心座標から約100メートル離れた場所までの距離を計算し、
計算結果が期待値(約100メートル)に近いことを検証します。
検証内容:
- XarvioCoordとLandLocation間の距離計算の正確性
- haversineライブラリを使った地球上の距離計算の精度
"""
photo_spot = XarvioCoord(longitude=137.6492809, latitude=34.743865)
reference_point = LandLocation(
"137.6487935,34.744671",
"ススムA3撮影座標から100mの場所",
)
expected_distance = 100.0
distance = PhotoProcessingService.calculate_distance(
photo_spot, reference_point.center
)
self.assertAlmostEqual(expected_distance, distance, delta=0.1)
def test_find_nearest_land_a1(self):
"""ススムA1圃場の正面からの撮影で、正しく最寄りの圃場が特定できることをテストします。
撮影位置をススムA1の正面に設定し、find_nearest_land関数が
ススムA1を最も近い圃場として正しく特定できることを検証します。
"""
photo_spot = CaptureLocation(
XarvioCoord(longitude=137.64905, latitude=34.74424)
)
service = PhotoProcessingService()
nearest_land = service.find_nearest_land(photo_spot, self.land_list)
self.assertEqual(self.land1, nearest_land)
def test_find_nearest_land_a2(self):
"""ススムA2圃場の正面からの撮影で、正しく最寄りの圃場が特定できることをテストします。
撮影位置をススムA2の正面に設定し、find_nearest_land関数が
ススムA2を最も近い圃場として正しく特定できることを検証します。
"""
photo_spot = CaptureLocation(XarvioCoord(longitude=137.64921, latitude=34.744))
service = PhotoProcessingService()
nearest_land = service.find_nearest_land(photo_spot, self.land_list)
self.assertEqual(self.land2, nearest_land)
def test_find_nearest_land_a3(self):
"""ススムA3圃場の正面からの撮影で、正しく最寄りの圃場が特定できることをテストします。
撮影位置をススムA3の正面に設定し、find_nearest_land関数が
ススムA3を最も近い圃場として正しく特定できることを検証します。
"""
photo_spot = CaptureLocation(
XarvioCoord(longitude=137.64938, latitude=34.74374)
)
service = PhotoProcessingService()
nearest_land = service.find_nearest_land(photo_spot, self.land_list)
self.assertEqual(self.land3, nearest_land)
def test_find_nearest_land_a4(self):
"""ススムA4圃場の正面からの撮影で、正しく最寄りの圃場が特定できることをテストします。
撮影位置をススムA4の正面に設定し、find_nearest_land関数が
ススムA4を最も近い圃場として正しく特定できることを検証します。
"""
photo_spot = CaptureLocation(XarvioCoord(longitude=137.6496, latitude=34.7434))
service = PhotoProcessingService()
nearest_land = service.find_nearest_land(photo_spot, self.land_list)
self.assertEqual(self.land4, nearest_land)
def test_process_photos(self):
"""
写真処理サービスのprocess_photos機能を検証するテストです。
このテストでは以下の内容を確認します:
1. 複数の写真を一括処理する機能が正しく動作すること
2. 各写真に対して適切な圃場(最も近い圃場)が関連付けられること
3. 写真と圃場の距離が正確に計算されること
テスト手法:
- 4枚の写真をモックし、それぞれ異なる位置情報を持たせる
- それぞれの写真に対して、最も近い圃場が正しく特定されるか検証
- 写真処理サービスのprocess_photosメソッドの戻り値を検証
検証項目:
- 結果のリストが入力写真数と同じ長さであること
- 各写真に対して、期待される最寄り圃場が正しく関連付けられていること
"""
# AndroidPhotoのモックを作成
with patch(
"soil_analysis.domain.service.photo_processing_service.AndroidPhoto"
) as mock_android_photo:
# 各写真に対応するモックオブジェクトのリスト
mock_photos = []
# 各写真の座標を設定
photo_spots = [
XarvioCoord(longitude=137.64905, latitude=34.74424), # A1用
XarvioCoord(longitude=137.64921, latitude=34.744), # A2用
XarvioCoord(longitude=137.64938, latitude=34.74374), # A3用
XarvioCoord(longitude=137.6496, latitude=34.7434), # A4用
]
# 各写真のモックを準備
for photo_spot in photo_spots:
mock_photo = MagicMock()
mock_location = MagicMock()
mock_location.adjusted_position = photo_spot
mock_photo.location = mock_location
mock_photos.append(mock_photo)
# AndroidPhotoが順番に異なるモックを返すように設定
mock_android_photo.side_effect = mock_photos
# テスト実行
service = PhotoProcessingService()
result = service.process_photos(
[
"path/to/photo1.jpg",
"path/to/photo2.jpg",
"path/to/photo3.jpg",
"path/to/photo4.jpg",
],
self.land_list,
)
# 検証
self.assertEqual(self.land1, result[0].nearest_land)
self.assertEqual(self.land2, result[1].nearest_land)
self.assertEqual(self.land3, result[2].nearest_land)
self.assertEqual(self.land4, result[3].nearest_land)
# デバッグ出力(Googleマップで確認できる形式)
for i, r in enumerate(result):
photo_path = r.photo_path
nearest_land = r.nearest_land
print(f"結果 {i + 1}: ファイル={photo_path}, 圃場={nearest_land.name}")
# 写真の座標をGoogleマップ形式で出力
photo_spot = photo_spots[i]
print(f" 写真の座標: {photo_spot.to_google().to_str()}")
# 最寄り圃場の座標をGoogleマップ形式で出力
print(f" 圃場の座標: {nearest_land.to_google().to_str()}")
# 距離も表示 - 更新された引数名でメソッドを呼び出す
print(
f" 距離: {service.calculate_distance(photo_spot=photo_spot, land=nearest_land, unit='m'):.2f}m"
)