From 5b8b443b98e4620d7b37f929711cc990812fa276 Mon Sep 17 00:00:00 2001 From: lanery Date: Wed, 6 May 2026 16:12:06 -0700 Subject: [PATCH 1/3] Add first-class support for the Epson V700 Scanner Register the new instrument enum, wire it into the Lambda handler, and add a TIFF-to-JPEG processing pipeline that resizes high-resolution scans to web-friendly previews and extracts TIFF metadata. Co-authored-by: Cursor --- .../epson_v700_scanner/__init__.py | 3 + .../epson_v700_scanner/image_processing.py | 124 ++++++++++ .../epson_v700_scanner/process_file.py | 90 +++++++ lambda/src/data_hub_lambda/handler.py | 7 + lambda/tests/epson_v700_scanner/__init__.py | 0 .../test_image_processing.py | 133 +++++++++++ .../epson_v700_scanner/test_process_file.py | 220 ++++++++++++++++++ .../shared/src/data_hub_shared/constants.py | 1 + packages/shared/src/data_hub_shared/enums.py | 1 + uv.lock | 4 +- 10 files changed, 581 insertions(+), 2 deletions(-) create mode 100644 lambda/src/data_hub_lambda/epson_v700_scanner/__init__.py create mode 100644 lambda/src/data_hub_lambda/epson_v700_scanner/image_processing.py create mode 100644 lambda/src/data_hub_lambda/epson_v700_scanner/process_file.py create mode 100644 lambda/tests/epson_v700_scanner/__init__.py create mode 100644 lambda/tests/epson_v700_scanner/test_image_processing.py create mode 100644 lambda/tests/epson_v700_scanner/test_process_file.py diff --git a/lambda/src/data_hub_lambda/epson_v700_scanner/__init__.py b/lambda/src/data_hub_lambda/epson_v700_scanner/__init__.py new file mode 100644 index 00000000..685e4e8b --- /dev/null +++ b/lambda/src/data_hub_lambda/epson_v700_scanner/__init__.py @@ -0,0 +1,3 @@ +from data_hub_lambda.epson_v700_scanner.process_file import ( + process_file, # noqa: F401 +) diff --git a/lambda/src/data_hub_lambda/epson_v700_scanner/image_processing.py b/lambda/src/data_hub_lambda/epson_v700_scanner/image_processing.py new file mode 100644 index 00000000..8fbad989 --- /dev/null +++ b/lambda/src/data_hub_lambda/epson_v700_scanner/image_processing.py @@ -0,0 +1,124 @@ +from __future__ import annotations +import logging +from pathlib import Path +from typing import Any + +import numpy as np +import skimage as ski +import tifffile +from numpy.typing import NDArray + +logger = logging.getLogger(__name__) + +JPEG_QUALITY = 85 + +MAX_DIMENSION = 1000 + +_TIFF_SUFFIXES = {".tif", ".tiff"} + +_METADATA_TAG_NAMES = { + "ImageWidth", + "ImageLength", + "BitsPerSample", + "Compression", + "PhotometricInterpretation", + "SamplesPerPixel", + "XResolution", + "YResolution", + "ResolutionUnit", + "Software", + "DateTime", + "Artist", + "Make", + "Model", + "ImageDescription", +} + + +class TIFFToJPEGConverter: + """Converts high-resolution TIFF scans to resized JPEG images.""" + + def __init__(self, path: Path) -> None: + self.path = path + self._intensities: NDArray[Any] | None = None + + def load(self) -> None: + if not self.path.exists(): + raise FileNotFoundError(f"TIFF file not found: {self.path}") + if self.path.suffix.lower() not in _TIFF_SUFFIXES: + raise ValueError(f"Expected TIFF file (.tif/.tiff), got: {self.path.suffix}") + + self._intensities = tifffile.imread(self.path) + + @property + def intensities(self) -> NDArray[Any]: + if self._intensities is None: + raise RuntimeError("Call load() first.") + return self._intensities + + def export_jpg(self) -> Path: + """Resize the loaded TIFF and write a JPEG next to the source file.""" + img = self._to_rgb_uint8(self.intensities) + img = self._resize(img) + + jpg_path = self.path.parent / f"{self.path.stem}.jpg" + ski.io.imsave(str(jpg_path), img, quality=JPEG_QUALITY) + return jpg_path + + def parse_metadata(self) -> dict[str, Any]: + """Extract TIFF tags as a flat string-keyed dict.""" + metadata: dict[str, Any] = {} + with tifffile.TiffFile(self.path) as tif: + page = tif.pages.first + for tag in page.tags.values(): + if tag.name in _METADATA_TAG_NAMES: + value = tag.value + if isinstance(value, tuple): + value = list(value) + metadata[tag.name] = value + + h, w = self.intensities.shape[:2] + metadata["OriginalHeight"] = int(h) + metadata["OriginalWidth"] = int(w) + return metadata + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _to_rgb_uint8(img: NDArray[Any]) -> NDArray[np.uint8]: + """Normalize to 8-bit RGB regardless of input dtype/channels.""" + if img.dtype != np.uint8: + img = ski.util.img_as_ubyte(ski.exposure.rescale_intensity(img)) + + if img.ndim == 2: + img = ski.color.gray2rgb(img) + elif img.ndim == 3 and img.shape[2] == 4: + img = ski.color.rgba2rgb(img) + img = ski.util.img_as_ubyte(img) + + return img # type: ignore[return-value] + + @staticmethod + def _resize(img: NDArray[np.uint8]) -> NDArray[np.uint8]: + """Downsample so the longest edge is at most MAX_DIMENSION pixels.""" + h, w = img.shape[:2] + if max(h, w) <= MAX_DIMENSION: + return img + + scale = MAX_DIMENSION / max(h, w) + new_h = int(h * scale) + new_w = int(w * scale) + + resized: NDArray[np.uint8] = np.asarray( + ski.transform.resize( + img, + (new_h, new_w), + anti_aliasing=True, + preserve_range=True, + ), + dtype=np.uint8, + ) + + return resized diff --git a/lambda/src/data_hub_lambda/epson_v700_scanner/process_file.py b/lambda/src/data_hub_lambda/epson_v700_scanner/process_file.py new file mode 100644 index 00000000..ae95ba33 --- /dev/null +++ b/lambda/src/data_hub_lambda/epson_v700_scanner/process_file.py @@ -0,0 +1,90 @@ +from __future__ import annotations +import logging + +from data_hub_lambda.api_client import get_client +from data_hub_lambda.constants import DATA_HUB_WEB_URL +from data_hub_lambda.epson_v700_scanner.image_processing import TIFFToJPEGConverter +from data_hub_shared import s3_utils +from data_hub_shared.config import config +from data_hub_shared.enums import Instrument + +logger = logging.getLogger(__name__) + +INSTRUMENT_ID = Instrument.EPSON_V700_SCANNER.value + + +def process_file(run_id: str, filename: str) -> str: + """Process a single Epson V700 Scanner file through the Data Hub API. + + Downloads the raw TIFF, resizes it to a web-friendly JPEG, uploads the + JPEG to the processed S3 bucket, extracts TIFF metadata, and registers + both files via the API. + + Args: + run_id: The run ID. + filename: The original filename (e.g. ``scan_001.tif``). + + Returns: + The web app URL for the instrument run. + """ + logger.info("Processing Epson V700 Scanner file: %s (run: %s)", filename, run_id) + + client = get_client() + s3_bucket = config.AWS_S3_RAW_DATA_BUCKET + s3_key = f"{INSTRUMENT_ID}/{run_id}/{filename}" + + client.ensure_run(INSTRUMENT_ID, run_id) + + file_record = client.create_file( + instrument_id=INSTRUMENT_ID, + run_id=run_id, + s3_bucket=s3_bucket or "", + s3_key=s3_key, + filename=filename, + ) + file_id = file_record.id + + try: + client.update_file(file_id, status="processing") + + raw_data_dir = config.LOCAL_RAW_DATA_DIRPATH / INSTRUMENT_ID / run_id + local_file_path = raw_data_dir / filename + s3_utils.download_file(f"s3://{s3_bucket}/{s3_key}", local_file_path) + logger.info("Downloaded %s to %s", filename, local_file_path) + + converter = TIFFToJPEGConverter(local_file_path) + converter.load() + jpg_file_path = converter.export_jpg() + + processed_bucket = config.AWS_S3_PROCESSED_DATA_BUCKET + jpg_s3_key = f"{INSTRUMENT_ID}/{run_id}/{jpg_file_path.name}" + s3_utils.upload_file(jpg_file_path, f"s3://{processed_bucket}/{jpg_s3_key}") + logger.info("Uploaded processed image to s3://%s/%s", processed_bucket, jpg_s3_key) + + processed_file = client.create_file( + instrument_id=INSTRUMENT_ID, + run_id=run_id, + s3_bucket=processed_bucket or "", + s3_key=jpg_s3_key, + filename=jpg_file_path.name, + category="processed", + ) + client.update_file( + processed_file.id, + size_bytes=jpg_file_path.stat().st_size, + content_type="image/jpeg", + ) + + metadata = converter.parse_metadata() + logger.info("Parsed metadata: %s", metadata) + + client.update_run(INSTRUMENT_ID, run_id, metadata=metadata) + client.update_file(file_id, status="completed") + logger.info("File %s marked as completed.", filename) + + except Exception as e: + logger.error("Error processing file: %s", e) + client.update_file(file_id, status="failed", error_message=str(e)) + raise + + return f"{DATA_HUB_WEB_URL}/instruments/{INSTRUMENT_ID}/runs/{run_id}" diff --git a/lambda/src/data_hub_lambda/handler.py b/lambda/src/data_hub_lambda/handler.py index 200b5140..e78c7e12 100644 --- a/lambda/src/data_hub_lambda/handler.py +++ b/lambda/src/data_hub_lambda/handler.py @@ -18,6 +18,7 @@ archive_builder, azure_600_gel_doc, azure_cielo_qpcr, + epson_v700_scanner, hina_microscope, spectramax_plate_reader, ) @@ -391,6 +392,12 @@ def lambda_handler(event: dict[str, Any], context: Context) -> dict[str, Any] | filename=event_info.filename, ) + elif instrument_id == Instrument.EPSON_V700_SCANNER.value: + result_url = epson_v700_scanner.process_file( + run_id=event_info.run_id, + filename=event_info.filename, + ) + elif instrument_id == Instrument.HINA_MICROSCOPE.value: result_url = hina_microscope.process_file( run_id=event_info.run_id, diff --git a/lambda/tests/epson_v700_scanner/__init__.py b/lambda/tests/epson_v700_scanner/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lambda/tests/epson_v700_scanner/test_image_processing.py b/lambda/tests/epson_v700_scanner/test_image_processing.py new file mode 100644 index 00000000..dbc558f5 --- /dev/null +++ b/lambda/tests/epson_v700_scanner/test_image_processing.py @@ -0,0 +1,133 @@ +"""Unit tests for `epson_v700_scanner.image_processing`.""" + +from __future__ import annotations +from pathlib import Path + +import numpy as np +import pytest +import skimage as ski +import tifffile + +from data_hub_lambda.epson_v700_scanner.image_processing import ( + MAX_DIMENSION, + TIFFToJPEGConverter, +) + + +def _write_tiff(path: Path, img: np.ndarray) -> Path: # type: ignore[type-arg] + tifffile.imwrite(str(path), img) + return path + + +class TestExportJpg: + def test_produces_valid_jpeg(self, tmp_path: Path) -> None: + img = np.random.randint(0, 255, (200, 300, 3), dtype=np.uint8) + tif_path = _write_tiff(tmp_path / "scan.tif", img) + + converter = TIFFToJPEGConverter(tif_path) + converter.load() + jpg_path = converter.export_jpg() + + assert jpg_path.exists() + assert jpg_path.suffix == ".jpg" + + loaded = ski.io.imread(str(jpg_path)) + assert loaded.ndim == 3 + assert loaded.shape[2] == 3 + + def test_resizes_large_image(self, tmp_path: Path) -> None: + img = np.random.randint(0, 255, (4000, 6000, 3), dtype=np.uint8) + tif_path = _write_tiff(tmp_path / "big.tif", img) + + converter = TIFFToJPEGConverter(tif_path) + converter.load() + jpg_path = converter.export_jpg() + + loaded = ski.io.imread(str(jpg_path)) + assert max(loaded.shape[:2]) <= MAX_DIMENSION + + def test_does_not_upscale_small_image(self, tmp_path: Path) -> None: + img = np.random.randint(0, 255, (100, 150, 3), dtype=np.uint8) + tif_path = _write_tiff(tmp_path / "small.tif", img) + + converter = TIFFToJPEGConverter(tif_path) + converter.load() + jpg_path = converter.export_jpg() + + loaded = ski.io.imread(str(jpg_path)) + assert loaded.shape[0] == 100 + assert loaded.shape[1] == 150 + + def test_converts_grayscale_to_rgb(self, tmp_path: Path) -> None: + img = np.random.randint(0, 255, (200, 300), dtype=np.uint8) + tif_path = _write_tiff(tmp_path / "gray.tif", img) + + converter = TIFFToJPEGConverter(tif_path) + converter.load() + jpg_path = converter.export_jpg() + + loaded = ski.io.imread(str(jpg_path)) + assert loaded.ndim == 3 + assert loaded.shape[2] == 3 + + def test_converts_rgba_to_rgb(self, tmp_path: Path) -> None: + img = np.random.randint(0, 255, (200, 300, 4), dtype=np.uint8) + tif_path = _write_tiff(tmp_path / "rgba.tif", img) + + converter = TIFFToJPEGConverter(tif_path) + converter.load() + jpg_path = converter.export_jpg() + + loaded = ski.io.imread(str(jpg_path)) + assert loaded.ndim == 3 + assert loaded.shape[2] == 3 + + def test_handles_16bit_input(self, tmp_path: Path) -> None: + img = np.random.randint(0, 65535, (200, 300, 3), dtype=np.uint16) + tif_path = _write_tiff(tmp_path / "16bit.tif", img) + + converter = TIFFToJPEGConverter(tif_path) + converter.load() + jpg_path = converter.export_jpg() + + loaded = ski.io.imread(str(jpg_path)) + assert loaded.dtype == np.uint8 + + +class TestParseMetadata: + def test_returns_original_dimensions(self, tmp_path: Path) -> None: + img = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8) + tif_path = _write_tiff(tmp_path / "scan.tif", img) + + converter = TIFFToJPEGConverter(tif_path) + converter.load() + metadata = converter.parse_metadata() + + assert metadata["OriginalHeight"] == 480 + assert metadata["OriginalWidth"] == 640 + + def test_returns_standard_tiff_tags(self, tmp_path: Path) -> None: + img = np.random.randint(0, 255, (100, 200, 3), dtype=np.uint8) + tif_path = _write_tiff(tmp_path / "scan.tif", img) + + converter = TIFFToJPEGConverter(tif_path) + converter.load() + metadata = converter.parse_metadata() + + assert "ImageWidth" in metadata + assert "ImageLength" in metadata + assert "BitsPerSample" in metadata + + +class TestValidation: + def test_rejects_missing_file(self, tmp_path: Path) -> None: + converter = TIFFToJPEGConverter(tmp_path / "nonexistent.tif") + with pytest.raises(FileNotFoundError): + converter.load() + + def test_rejects_non_tiff_extension(self, tmp_path: Path) -> None: + path = tmp_path / "scan.png" + path.write_bytes(b"fake") + converter = TIFFToJPEGConverter(path) + with pytest.raises(ValueError, match="Expected TIFF file"): + converter.load() diff --git a/lambda/tests/epson_v700_scanner/test_process_file.py b/lambda/tests/epson_v700_scanner/test_process_file.py new file mode 100644 index 00000000..8231e146 --- /dev/null +++ b/lambda/tests/epson_v700_scanner/test_process_file.py @@ -0,0 +1,220 @@ +"""Unit tests for `epson_v700_scanner.process_file`. + +These verify the orchestration logic by mocking S3 I/O, the API client, +and the TIFFToJPEGConverter. +""" + +from __future__ import annotations +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from data_hub_lambda.models import FileResponse, RunResponse + + +@pytest.fixture(autouse=True) +def _reset_api_client() -> Any: + """Ensure `get_client()` returns a fresh mock per test.""" + import data_hub_lambda.api_client as api_module + + original = api_module._client + api_module._client = None + try: + yield + finally: + api_module._client = original + + +def _run_response() -> RunResponse: + return RunResponse( + id="run-uuid", + instrument_id="epson-v700-scanner", + run_id="run-xyz", + source="lambda", + metadata={}, + ) + + +def _file_response(file_id: int = 123) -> FileResponse: + return FileResponse( + id=file_id, + instrument_run_id="run-uuid", + filename="scan.tif", + s3_bucket="raw", + s3_key="epson-v700-scanner/run-xyz/scan.tif", + category="raw", + status="uploaded", + ) + + +def _build_client_mock() -> MagicMock: + client = MagicMock() + client.ensure_run.return_value = _run_response() + client.create_file.side_effect = [ + _file_response(file_id=10), + _file_response(file_id=11), + ] + return client + + +@pytest.fixture +def patched_jpg_path(tmp_path: Path) -> Path: + """A fake JPG output path that actually exists on disk (for stat()).""" + jpg = tmp_path / "scan.jpg" + jpg.write_bytes(b"fake-jpg-bytes") + return jpg + + +@pytest.fixture +def patched_converter(patched_jpg_path: Path) -> MagicMock: + """A stand-in TIFFToJPEGConverter whose export_jpg returns a real tmp file.""" + converter = MagicMock() + converter.load.return_value = None + converter.export_jpg.return_value = patched_jpg_path + converter.parse_metadata.return_value = { + "ImageWidth": 6400, + "ImageLength": 4800, + "BitsPerSample": [8, 8, 8], + "OriginalHeight": 4800, + "OriginalWidth": 6400, + } + return converter + + +_PATCH_PREFIX = "data_hub_lambda.epson_v700_scanner.process_file" + + +class TestProcessFileHappyPath: + def test_creates_run_and_files( + self, + patched_converter: MagicMock, + ) -> None: + client = _build_client_mock() + + with ( + patch(f"{_PATCH_PREFIX}.get_client", return_value=client), + patch(f"{_PATCH_PREFIX}.s3_utils") as s3_mock, + patch( + f"{_PATCH_PREFIX}.TIFFToJPEGConverter", + return_value=patched_converter, + ), + ): + from data_hub_lambda.epson_v700_scanner.process_file import process_file + + result_url = process_file(run_id="run-xyz", filename="scan.tif") + + client.ensure_run.assert_called_once() + assert client.create_file.call_count == 2 + s3_mock.upload_file.assert_called_once() + client.update_run.assert_called_once() + assert "epson-v700-scanner" in result_url + assert "run-xyz" in result_url + + def test_uploads_jpg_and_registers_processed_file( + self, + patched_converter: MagicMock, + ) -> None: + client = _build_client_mock() + + with ( + patch(f"{_PATCH_PREFIX}.get_client", return_value=client), + patch(f"{_PATCH_PREFIX}.s3_utils"), + patch( + f"{_PATCH_PREFIX}.TIFFToJPEGConverter", + return_value=patched_converter, + ), + ): + from data_hub_lambda.epson_v700_scanner.process_file import process_file + + process_file(run_id="run-xyz", filename="scan.tif") + + patched_converter.export_jpg.assert_called_once() + + processed_create_call = client.create_file.call_args_list[1] + assert processed_create_call.kwargs["category"] == "processed" + assert processed_create_call.kwargs["filename"] == "scan.jpg" + + def test_stores_metadata_on_run( + self, + patched_converter: MagicMock, + ) -> None: + client = _build_client_mock() + + with ( + patch(f"{_PATCH_PREFIX}.get_client", return_value=client), + patch(f"{_PATCH_PREFIX}.s3_utils"), + patch( + f"{_PATCH_PREFIX}.TIFFToJPEGConverter", + return_value=patched_converter, + ), + ): + from data_hub_lambda.epson_v700_scanner.process_file import process_file + + process_file(run_id="run-xyz", filename="scan.tif") + + client.update_run.assert_called_once() + _, kwargs = client.update_run.call_args + assert kwargs["metadata"]["OriginalWidth"] == 6400 + + def test_marks_raw_file_completed( + self, + patched_converter: MagicMock, + ) -> None: + client = _build_client_mock() + + with ( + patch(f"{_PATCH_PREFIX}.get_client", return_value=client), + patch(f"{_PATCH_PREFIX}.s3_utils"), + patch( + f"{_PATCH_PREFIX}.TIFFToJPEGConverter", + return_value=patched_converter, + ), + ): + from data_hub_lambda.epson_v700_scanner.process_file import process_file + + process_file(run_id="run-xyz", filename="scan.tif") + + statuses = [ + call.kwargs.get("status") + for call in client.update_file.call_args_list + if "status" in call.kwargs + ] + assert "completed" in statuses + + +class TestProcessFileFailure: + def test_marks_raw_file_failed_on_exception(self) -> None: + client = _build_client_mock() + + failing_converter = MagicMock() + failing_converter.load.side_effect = RuntimeError("boom") + + with ( + patch(f"{_PATCH_PREFIX}.get_client", return_value=client), + patch(f"{_PATCH_PREFIX}.s3_utils"), + patch( + f"{_PATCH_PREFIX}.TIFFToJPEGConverter", + return_value=failing_converter, + ), + ): + from data_hub_lambda.epson_v700_scanner.process_file import process_file + + with pytest.raises(RuntimeError, match="boom"): + process_file(run_id="run-xyz", filename="broken.tif") + + statuses = [ + call.kwargs.get("status") + for call in client.update_file.call_args_list + if "status" in call.kwargs + ] + assert "processing" in statuses + assert "failed" in statuses + + failed_call = next( + call + for call in client.update_file.call_args_list + if call.kwargs.get("status") == "failed" + ) + assert failed_call.kwargs["error_message"] == "boom" diff --git a/packages/shared/src/data_hub_shared/constants.py b/packages/shared/src/data_hub_shared/constants.py index 9d103394..b0a185bb 100644 --- a/packages/shared/src/data_hub_shared/constants.py +++ b/packages/shared/src/data_hub_shared/constants.py @@ -7,6 +7,7 @@ Instrument.AKTA_FPLC.value: "Akta FPLC", Instrument.AZURE_600_GEL_DOC.value: "Azure 600 Gel Doc", Instrument.AZURE_CIELO_QPCR.value: "Azure Cielo qPCR", + Instrument.EPSON_V700_SCANNER.value: "Epson V700 Scanner", Instrument.HINA_MICROSCOPE.value: "Hina Microscope", Instrument.SPECTRAMAX_ID3_PLATE_READER.value: "SpectraMax iD3 Plate Reader", Instrument.SPECTRAMAX_ID5_PLATE_READER.value: "SpectraMax iD5 Plate Reader", diff --git a/packages/shared/src/data_hub_shared/enums.py b/packages/shared/src/data_hub_shared/enums.py index c4e3eea1..d524d862 100644 --- a/packages/shared/src/data_hub_shared/enums.py +++ b/packages/shared/src/data_hub_shared/enums.py @@ -12,6 +12,7 @@ class Instrument(Enum): AKTA_FPLC = "akta-fplc" AZURE_600_GEL_DOC = "azure-600-gel-doc" AZURE_CIELO_QPCR = "azure-cielo-qpcr" + EPSON_V700_SCANNER = "epson-v700-scanner" HINA_MICROSCOPE = "hina-microscope" SPECTRAMAX_ID3_PLATE_READER = "spectramax-id3-plate-reader" SPECTRAMAX_ID5_PLATE_READER = "spectramax-id5-plate-reader" diff --git a/uv.lock b/uv.lock index 3e321e9a..944ca02b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -415,7 +415,7 @@ requires-dist = [ [[package]] name = "data-hub-watcher" -version = "0.1.3" +version = "0.1.4" source = { editable = "watcher" } dependencies = [ { name = "click" }, From e4bf24fb4a5a714253fbf35c3cc61100f630f70f Mon Sep 17 00:00:00 2001 From: lanery Date: Wed, 6 May 2026 16:12:10 -0700 Subject: [PATCH 2/3] Add S3 trigger for .tiff files from the Epson V700 Scanner S3 event notifications only support a single suffix per rule, so add a second notification block to cover the .tiff extension alongside .tif. Co-authored-by: Cursor --- infra/template.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/infra/template.yaml b/infra/template.yaml index 7038db4c..ffc7b0f8 100644 --- a/infra/template.yaml +++ b/infra/template.yaml @@ -111,6 +111,24 @@ Resources: # with `+` — this matches keys ending in `_Cq Values.csv`. Value: _Cq+Values.csv Function: !GetAtt DataHubFunction.Arn + - Event: s3:ObjectCreated:* + Filter: + S3Key: + Rules: + - Name: prefix + Value: epson-v700-scanner/ + - Name: suffix + Value: .tif + Function: !GetAtt DataHubFunction.Arn + - Event: s3:ObjectCreated:* + Filter: + S3Key: + Rules: + - Name: prefix + Value: epson-v700-scanner/ + - Name: suffix + Value: .tiff + Function: !GetAtt DataHubFunction.Arn - Event: s3:ObjectCreated:* Filter: S3Key: From 3e36d3d53224fbb27a4557fb7a7289aabc5f6f8d Mon Sep 17 00:00:00 2001 From: lanery Date: Thu, 7 May 2026 10:44:30 -0700 Subject: [PATCH 3/3] Add `epson-scanner` command to the data-hub-process CLI Co-authored-by: Cursor --- docs/lambda.md | 2 ++ lambda/src/data_hub_lambda/cli.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/docs/lambda.md b/docs/lambda.md index f73fa02c..d5d1cb4f 100644 --- a/docs/lambda.md +++ b/docs/lambda.md @@ -44,6 +44,7 @@ See [Run archives](run-archives.md) for the full flow, S3 bucket layout, cache s | Akta FPLC | `akta_fplc` | `akta-fplc` | | Azure 600 Gel Doc | `azure_600_gel_doc` | `azure-600-gel-doc` | | Azure Cielo qPCR | `azure_cielo_qpcr` | `azure-cielo-qpcr` | +| Epson V700 Scanner | `epson_v700_scanner` | `epson-v700-scanner` | | Hina Microscope | `hina_microscope` | `hina-microscope` | | SpectraMax iD3 Plate Reader | `spectramax_plate_reader` | `spectramax-id3-plate-reader` | | SpectraMax iD5 Plate Reader | `spectramax_plate_reader` | `spectramax-id5-plate-reader` | @@ -78,6 +79,7 @@ Available commands: | Command | Description | | --- | --- | +| `epson-scanner` | Process an Epson V700 Scanner TIFF (resized JPEG preview + metadata) | | `gel-doc` | Process an Azure 600 Gel Doc TIFF (contrast-enhanced PNG + metadata) | | `hina` | Convert a Hina microscope ND2 file to a JPG overlay + metadata | | `qpcr` | Parse dye channels from an Azure Cielo qPCR Cq Values CSV | diff --git a/lambda/src/data_hub_lambda/cli.py b/lambda/src/data_hub_lambda/cli.py index 528d8e3b..2177a2ea 100644 --- a/lambda/src/data_hub_lambda/cli.py +++ b/lambda/src/data_hub_lambda/cli.py @@ -91,6 +91,43 @@ def hina(file: Path, output_dir: Path | None) -> None: click.echo(json.dumps(metadata, indent=2)) +# --------------------------------------------------------------------------- +# Epson V700 Scanner +# --------------------------------------------------------------------------- + + +@cli.command("epson-scanner") +@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "--output-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help="Directory for the exported JPG (default: same directory as FILE).", +) +def epson_scanner(file: Path, output_dir: Path | None) -> None: + """Process an Epson V700 Scanner TIFF file. + + Resizes the high-resolution scan to a web-friendly JPEG preview and + extracts TIFF metadata. + """ + from data_hub_lambda.epson_v700_scanner.image_processing import TIFFToJPEGConverter + + converter = TIFFToJPEGConverter(file) + converter.load() + jpg_path = converter.export_jpg() + + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + dest = output_dir / jpg_path.name + shutil.move(str(jpg_path), str(dest)) + jpg_path = dest + + click.echo(f"Exported JPG: {jpg_path}") + + metadata = converter.parse_metadata() + click.echo(json.dumps(metadata, indent=2)) + + # --------------------------------------------------------------------------- # Azure Cielo qPCR # ---------------------------------------------------------------------------