import time import cv2 import numpy as np from django.conf import settings from django.db.models import Max from django.utils import timezone from .algorithms import ProcessingError, average_images, decode_image, histogram, histogram_payload, normalize_to_uint8, process_image, verify_registration from .models import ImageSession, ImageState, ProcessingJob from .registry import apply_registered_operation from .storage import delete_relative_file, load_image_array, payload_for_image, save_image_array from .tasks import run_batch_job def image_session_create(*, uploaded_file=None, image_base64=None): """Create a new image session and its initial S0 upload state. The session groups all later processing states so a user can present the full workflow. """ if uploaded_file and uploaded_file.size > settings.MAX_UPLOAD_MB * 1024 * 1024: raise ProcessingError(f"Upload exceeds {settings.MAX_UPLOAD_MB} MB.") image = compact_workspace_image(decode_image(uploaded_file=uploaded_file, base64_image=image_base64)) relative_path = save_image_array(image, "original") hist = histogram(image) session = ImageSession.objects.create( original_image=relative_path, width=image.shape[1], height=image.shape[0], channels=image.shape[2] if image.ndim == 3 else 1, color_mode="RGB" if image.ndim == 3 else "L", original_histogram=hist, expires_at=timezone.now() + timezone.timedelta(hours=settings.IMAGE_SESSION_TTL_HOURS), ) state = image_state_create( session=session, parent=None, image=image, operation="upload", params={}, label="S0 Upload", prefix="state-upload", ) payload = { "session_id": str(session.id), "active_state_id": str(state.id), "width": session.width, "height": session.height, "channels": session.channels, "color_mode": session.color_mode, "original_histogram": hist, "histogram": state.histogram, "expires_at": session.expires_at.isoformat(), "states": [image_state_payload(state=state, include_image=True)], } payload.update(payload_for_image(image, relative_path)) return payload def compact_workspace_image(image): """Resize large uploads to the configured maximum dimension for faster processing. This keeps classroom-sized experiments responsive even when the uploaded file is very large. """ max_dimension = int(getattr(settings, "IMAGE_WORKSPACE_MAX_DIMENSION", 1400)) if max_dimension <= 0: return image height, width = image.shape[:2] longest = max(width, height) if longest <= max_dimension: return image scale = max_dimension / float(longest) next_size = (max(1, int(round(width * scale))), max(1, int(round(height * scale)))) return cv2.resize(image, next_size, interpolation=cv2.INTER_AREA).astype(np.uint8) def image_state_create(*, session, parent, image, operation, params, label=None, prefix="state"): """Persist one processed image state with its metadata, parent, and histogram. Saving every result makes it possible to compare steps and combine previous states later. """ relative_path = save_image_array(image, prefix) max_sequence = session.states.aggregate(value=Max("sequence"))["value"] sequence = 0 if max_sequence is None else max_sequence + 1 state = ImageState.objects.create( session=session, parent=parent, sequence=sequence, label=label or f"S{sequence} {operation}", operation=operation, params=params or {}, image=relative_path, width=image.shape[1], height=image.shape[0], channels=image.shape[2] if image.ndim == 3 else 1, color_mode="RGB" if image.ndim == 3 else "L", histogram=histogram_payload(image), ) return state def image_state_payload(*, state, include_image=True): """Serialize an image state for the frontend workspace.""" payload = { "state_id": str(state.id), "session_id": str(state.session_id), "parent_state_id": str(state.parent_id) if state.parent_id else None, "sequence": state.sequence, "label": state.label, "operation": state.operation, "params": state.params, "width": state.width, "height": state.height, "channels": state.channels, "color_mode": state.color_mode, "histogram": state.histogram, "created_at": state.created_at.isoformat(), } if include_image: image = load_image_array(state.image) payload.update(payload_for_image(image, state.image)) else: payload["image_path"] = state.image payload["image_url"] = f"{settings.MEDIA_URL}{state.image}" return payload def image_states_payload(*, states): """Serialize a list of image states.""" return [image_state_payload(state=state, include_image=True) for state in states] def image_state_delete(*, state): """Delete a non-S0 state while keeping child states available. Use it to remove unhelpful experiments without losing later useful results. """ if state.sequence == 0 or state.operation == "upload": raise ProcessingError("The original S0 upload state cannot be deleted.") image_path = state.image state.children.update(parent=None) state.delete() delete_relative_file(image_path) def image_state_apply_operation(*, state, operation, params): """Apply one registered algorithm to a selected state and save the result as a new state. This is the main workspace action: every filter or transform becomes a reproducible step. """ if state.session.expired: raise ProcessingError("Image session has expired.") source = load_image_array(state.image) result = apply_registered_operation(source, operation, params or {}) new_state = image_state_create( session=state.session, parent=state, image=result, operation=operation, params=params or {}, label=None, prefix=f"state-{operation}", ) return image_state_payload(state=new_state, include_image=True) def combine_states(*, states, operation, params=None): """Combine registered states using add, subtract, dot product, average, and/or. Use it for MATLAB-like image arithmetic, change detection, masking, and K-image denoising. """ params = params or {} if len(states) < 2: raise ProcessingError("At least two states are required.") session = states[0].session if any(state.session_id != session.id for state in states): raise ProcessingError("All states must belong to the same session.") images = [load_image_array(state.image) for state in states] verify_registration(images) if operation == "average": result = average_images(images) elif operation == "add": result = np_clip_sum(images) elif operation == "subtract": result = normalize_to_uint8(images[0].astype("float32") - images[1].astype("float32")) elif operation == "dot_product": result = normalize_to_uint8(np.prod([image.astype("float32") / 255.0 for image in images], axis=0)) elif operation == "and": result = images[0].copy() for image in images[1:]: result = result & image elif operation == "or": result = images[0].copy() for image in images[1:]: result = result | image else: raise ProcessingError(f"Unsupported combine operation '{operation}'.") new_state = image_state_create( session=session, parent=states[0], image=result, operation=f"combine_{operation}", params={**params, "state_ids": [str(state.id) for state in states]}, label=None, prefix=f"state-combine-{operation}", ) return image_state_payload(state=new_state, include_image=True) def np_clip_sum(images): """Add several registered images and clip the result to [0, 255]. Use it to combine brightness/detail contributions while keeping the output displayable. """ total = np.zeros_like(images[0], dtype="float32") for image in images: total += image.astype("float32") return np.clip(total, 0, 255).astype("uint8") def image_session_process(*, session, operation, params): """Run a legacy single-image operation against the original session image. This keeps the older API working while the state workspace handles the main presentation flow. """ if session.expired: raise ProcessingError("Image session has expired.") started_at = time.perf_counter() source = load_image_array(session.original_image) result = process_image(source, operation, params) relative_path = save_image_array(result, f"processed-{operation}") hist = histogram(result) session.processed_image = relative_path session.processed_histogram = hist session.save(update_fields=["processed_image", "processed_histogram"]) payload = { "session_id": str(session.id), "operation": operation, "params": params, "processed_histogram": hist, "elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2), } payload.update(payload_for_image(result, relative_path)) return payload def batch_job_create(*, operation, session_ids, params=None): """Create a Celery-backed processing job for heavier batch operations. Use it when an operation may take longer than an interactive request should block. """ job = ProcessingJob.objects.create(operation=operation, params=params or {}) run_batch_job.delay(str(job.id), operation, [str(session_id) for session_id in session_ids]) return job def processing_job_payload(*, job): """Serialize a processing job, including result image data when available.""" payload = { "job_id": str(job.id), "operation": job.operation, "status": job.status, "progress": job.progress, "error": job.error, "result_histogram": job.result_histogram, } if job.result_image: image = load_image_array(job.result_image) payload.update(payload_for_image(image, job.result_image)) return payload